Merge branch 'main' into reconnections-2

Antonio Scandurra created

Change summary

.gitignore                                                      |   1 
Cargo.lock                                                      | 330 
Cargo.toml                                                      |   4 
crates/activity_indicator/src/activity_indicator.rs             |   2 
crates/auto_update/Cargo.toml                                   |   1 
crates/auto_update/src/auto_update.rs                           |  34 
crates/auto_update/src/update_notification.rs                   |   9 
crates/breadcrumbs/src/breadcrumbs.rs                           |   5 
crates/client/src/client.rs                                     |   7 
crates/client/src/telemetry.rs                                  |  26 
crates/collab/src/integration_tests.rs                          | 167 
crates/collab_ui/src/collab_ui.rs                               |   8 
crates/collab_ui/src/contact_notification.rs                    |   2 
crates/command_palette/src/command_palette.rs                   |   5 
crates/db/Cargo.toml                                            |  12 
crates/db/README.md                                             |   5 
crates/db/src/db.rs                                             | 430 +
crates/db/src/items.rs                                          | 311 
crates/db/src/kvp.rs                                            | 110 
crates/db/src/migrations.rs                                     |  15 
crates/db/src/query.rs                                          | 314 
crates/diagnostics/src/diagnostics.rs                           |  40 
crates/diagnostics/src/items.rs                                 |   4 
crates/editor/Cargo.toml                                        |   2 
crates/editor/src/editor.rs                                     |   6 
crates/editor/src/editor_tests.rs                               |   7 
crates/editor/src/items.rs                                      | 106 
crates/editor/src/persistence.rs                                |  36 
crates/editor/src/test/editor_lsp_test_context.rs               |  11 
crates/file_finder/src/file_finder.rs                           |  30 
crates/gpui/Cargo.toml                                          |   1 
crates/gpui/grammars/context-predicate/bindings/node/binding.cc |  12 
crates/gpui/src/presenter.rs                                    |  33 
crates/gpui_macros/Cargo.toml                                   |   1 
crates/journal/src/journal.rs                                   |   6 
crates/project/src/project.rs                                   |   3 
crates/project_panel/src/project_panel.rs                       |  22 
crates/search/src/buffer_search.rs                              |   3 
crates/search/src/project_search.rs                             |  22 
crates/settings/Cargo.toml                                      |   1 
crates/settings/src/settings.rs                                 |  51 
crates/sqlez/.gitignore                                         |   2 
crates/sqlez/Cargo.lock                                         | 150 
crates/sqlez/Cargo.toml                                         |  16 
crates/sqlez/src/bindable.rs                                    | 352 +
crates/sqlez/src/connection.rs                                  | 334 +
crates/sqlez/src/domain.rs                                      |  56 
crates/sqlez/src/lib.rs                                         |  11 
crates/sqlez/src/migrations.rs                                  | 260 
crates/sqlez/src/savepoint.rs                                   | 148 
crates/sqlez/src/statement.rs                                   | 491 +
crates/sqlez/src/thread_safe_connection.rs                      | 359 +
crates/sqlez/src/typed_statements.rs                            |  60 
crates/sqlez/src/util.rs                                        |  32 
crates/sqlez_macros/Cargo.toml                                  |  17 
crates/sqlez_macros/src/sqlez_macros.rs                         |  93 
crates/terminal/Cargo.toml                                      |   1 
crates/terminal/src/persistence.rs                              |  52 
crates/terminal/src/terminal.rs                                 |  47 
crates/terminal/src/terminal_container_view.rs                  |  73 
crates/terminal/src/tests/terminal_test_context.rs              |  12 
crates/theme/src/theme.rs                                       |   8 
crates/theme_testbench/src/theme_testbench.rs                   |  26 
crates/util/Cargo.toml                                          |   1 
crates/util/src/channel.rs                                      |  40 
crates/util/src/lib.rs                                          |  40 
crates/util/src/paths.rs                                        |   0 
crates/vim/src/test/vim_test_context.rs                         |  11 
crates/workspace/Cargo.toml                                     |   7 
crates/workspace/src/dock.rs                                    |  35 
crates/workspace/src/item.rs                                    | 905 ++
crates/workspace/src/notifications.rs                           | 280 
crates/workspace/src/pane.rs                                    |  22 
crates/workspace/src/pane_group.rs                              |  14 
crates/workspace/src/persistence.rs                             | 836 ++
crates/workspace/src/persistence/model.rs                       | 315 
crates/workspace/src/searchable.rs                              |   2 
crates/workspace/src/shared_screen.rs                           |  32 
crates/workspace/src/workspace.rs                               | 985 --
crates/workspace/test.db                                        |   0 
crates/zed/Cargo.toml                                           |   1 
crates/zed/src/feedback.rs                                      |   4 
crates/zed/src/main.rs                                          |  53 
crates/zed/src/zed.rs                                           | 205 
styles/src/styleTree/app.ts                                     |   2 
styles/src/styleTree/simpleMessageNotification.ts               |  31 
86 files changed, 6,920 insertions(+), 1,696 deletions(-)

Detailed changes

.gitignore 🔗

@@ -18,3 +18,4 @@ DerivedData/
 .swiftpm/config/registries.json
 .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
 .netrc
+**/*.db

Cargo.lock 🔗

@@ -14,7 +14,7 @@ version = "0.1.0"
 dependencies = [
  "auto_update",
  "editor",
- "futures 0.3.24",
+ "futures 0.3.25",
  "gpui",
  "language",
  "project",
@@ -51,16 +51,16 @@ version = "0.7.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47"
 dependencies = [
- "getrandom 0.2.7",
+ "getrandom 0.2.8",
  "once_cell",
  "version_check",
 ]
 
 [[package]]
 name = "aho-corasick"
-version = "0.7.19"
+version = "0.7.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4f55bd91a0978cbfd91c457a164bab8b4001c833b7f323132c0a4e1922dd44e"
+checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac"
 dependencies = [
  "memchr",
 ]
@@ -145,9 +145,12 @@ dependencies = [
 
 [[package]]
 name = "anyhow"
-version = "1.0.65"
+version = "1.0.66"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "98161a4e3e2184da77bb14f02184cdd111e83bbbcc9979dfee3c44b9a85f5602"
+checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6"
+dependencies = [
+ "backtrace",
+]
 
 [[package]]
 name = "arrayref"
@@ -195,9 +198,9 @@ dependencies = [
 
 [[package]]
 name = "async-channel"
-version = "1.7.1"
+version = "1.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e14485364214912d3b19cc3435dde4df66065127f05fa0d75c712f36f12c2f28"
+checksum = "cf46fee83e5ccffc220104713af3292ff9bc7c64c7de289f66dae8e38d826833"
 dependencies = [
  "concurrent-queue",
  "event-listener",
@@ -232,15 +235,15 @@ dependencies = [
 
 [[package]]
 name = "async-executor"
-version = "1.4.1"
+version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "871f9bb5e0a22eeb7e8cf16641feb87c9dc67032ccf8ff49e772eb9941d3a965"
+checksum = "17adb73da160dfb475c183343c8cccd80721ea5a605d3eb57125f0a7b7a92d0b"
 dependencies = [
+ "async-lock",
  "async-task",
  "concurrent-queue",
  "fastrand",
  "futures-lite",
- "once_cell",
  "slab",
 ]
 
@@ -258,22 +261,22 @@ dependencies = [
 
 [[package]]
 name = "async-io"
-version = "1.9.0"
+version = "1.12.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83e21f3a490c72b3b0cf44962180e60045de2925d8dff97918f7ee43c8f637c7"
+checksum = "8c374dda1ed3e7d8f0d9ba58715f924862c63eae6849c92d3a18e7fbde9e2794"
 dependencies = [
+ "async-lock",
  "autocfg 1.1.0",
  "concurrent-queue",
  "futures-lite",
  "libc",
  "log",
- "once_cell",
  "parking",
  "polling",
  "slab",
  "socket2",
  "waker-fn",
- "winapi 0.3.9",
+ "windows-sys 0.42.0",
 ]
 
 [[package]]
@@ -303,26 +306,26 @@ name = "async-pipe"
 version = "0.1.3"
 source = "git+https://github.com/zed-industries/async-pipe-rs?rev=82d00a04211cf4e1236029aa03e6b6ce2a74c553#82d00a04211cf4e1236029aa03e6b6ce2a74c553"
 dependencies = [
- "futures 0.3.24",
+ "futures 0.3.25",
  "log",
 ]
 
 [[package]]
 name = "async-process"
-version = "1.5.0"
+version = "1.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02111fd8655a613c25069ea89fc8d9bb89331fa77486eb3bc059ee757cfa481c"
+checksum = "6381ead98388605d0d9ff86371043b5aa922a3905824244de40dc263a14fcba4"
 dependencies = [
  "async-io",
+ "async-lock",
  "autocfg 1.1.0",
  "blocking",
  "cfg-if 1.0.0",
  "event-listener",
  "futures-lite",
  "libc",
- "once_cell",
  "signal-hook",
- "winapi 0.3.9",
+ "windows-sys 0.42.0",
 ]
 
 [[package]]
@@ -336,6 +339,17 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "async-recursion"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cda8f4bcc10624c4e85bc66b3f452cca98cfa5ca002dc83a16aad2367641bea"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "async-stream"
 version = "0.3.3"
@@ -377,9 +391,9 @@ dependencies = [
 
 [[package]]
 name = "async-trait"
-version = "0.1.57"
+version = "0.1.59"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76464446b8bc32758d7e88ee1a804d9914cd9b1cb264c029899680b0be29826f"
+checksum = "31e6e93155431f3931513b243d371981bb2770112b370c82745a1d19d2f99364"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -441,6 +455,7 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "client",
+ "db",
  "gpui",
  "isahc",
  "lazy_static",
@@ -474,15 +489,15 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
 
 [[package]]
 name = "axum"
-version = "0.5.16"
+version = "0.5.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c9e3356844c4d6a6d6467b8da2cffb4a2820be256f50a3a386c9d152bab31043"
+checksum = "acee9fd5073ab6b045a275b3e709c163dd36c90685219cb21804a147b58dba43"
 dependencies = [
  "async-trait",
  "axum-core",
  "base64",
  "bitflags",
- "bytes 1.2.1",
+ "bytes 1.3.0",
  "futures-util",
  "headers",
  "http",
@@ -497,7 +512,7 @@ dependencies = [
  "serde",
  "serde_json",
  "serde_urlencoded",
- "sha-1 0.10.0",
+ "sha-1 0.10.1",
  "sync_wrapper",
  "tokio",
  "tokio-tungstenite",
@@ -509,12 +524,12 @@ dependencies = [
 
 [[package]]
 name = "axum-core"
-version = "0.2.8"
+version = "0.2.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9f0c0a60006f2a293d82d571f635042a72edf927539b7685bd62d361963839b"
+checksum = "37e5939e02c56fecd5c017c37df4238c0a839fa76b7f97acdd7efb804fd181cc"
 dependencies = [
  "async-trait",
- "bytes 1.2.1",
+ "bytes 1.3.0",
  "futures-util",
  "http",
  "http-body",
@@ -530,7 +545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "69034b3b0fd97923eee2ce8a47540edb21e07f48f87f67d44bb4271cec622bdb"
 dependencies = [
  "axum",
- "bytes 1.2.1",
+ "bytes 1.3.0",
  "futures-util",
  "http",
  "mime",
@@ -574,15 +589,15 @@ dependencies = [
 
 [[package]]
 name = "base64"
-version = "0.13.0"
+version = "0.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
 
 [[package]]
 name = "base64ct"
-version = "1.5.2"
+version = "1.5.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea2b2456fd614d856680dcd9fcc660a51a820fa09daef2e49772b56a193c8474"
+checksum = "b645a089122eccb6111b4f81cbc1a49f5900ac4666bb93ac027feaecf15607bf"
 
 [[package]]
 name = "bincode"
@@ -648,16 +663,16 @@ dependencies = [
 
 [[package]]
 name = "blocking"
-version = "1.2.0"
+version = "1.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6ccb65d468978a086b69884437ded69a90faab3bbe6e67f242173ea728acccc"
+checksum = "3c67b173a56acffd6d2326fb7ab938ba0b00a71480e14902b2591c87bc5741e8"
 dependencies = [
  "async-channel",
+ "async-lock",
  "async-task",
  "atomic-waker",
  "fastrand",
  "futures-lite",
- "once_cell",
 ]
 
 [[package]]
@@ -744,9 +759,9 @@ dependencies = [
 
 [[package]]
 name = "bumpalo"
-version = "3.11.0"
+version = "3.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1ad822118d20d2c234f427000d5acc36eabe1e29a348c89b63dd60b13f28e5d"
+checksum = "572f695136211188308f16ad2ca5c851a712c464060ae6974944458eb83880ba"
 
 [[package]]
 name = "bytecheck"
@@ -771,9 +786,9 @@ dependencies = [
 
 [[package]]
 name = "bytemuck"
-version = "1.12.1"
+version = "1.12.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f5715e491b5a1598fc2bef5a606847b5dc1d48ea625bd3c02c00de8285591da"
+checksum = "aaa3a8d9a1ca92e282c96a32d6511b695d7d994d1d102ba85d279f9b2756947f"
 
 [[package]]
 name = "byteorder"
@@ -793,15 +808,9 @@ dependencies = [
 
 [[package]]
 name = "bytes"
-version = "1.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec8a7b6a70fde80372154c65702f00a0f56f3e1c36abbc6c440484be248856db"
-
-[[package]]
-name = "cache-padded"
-version = "1.2.0"
+version = "1.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1db59621ec70f09c5e9b597b220c7a2b43611f4710dc03ceb8748637775692c"
+checksum = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c"
 
 [[package]]
 name = "call"
@@ -811,7 +820,7 @@ dependencies = [
  "async-broadcast",
  "client",
  "collections",
- "futures 0.3.24",
+ "futures 0.3.25",
  "gpui",
  "live_kit_client",
  "media",
@@ -894,9 +903,9 @@ checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6"
 
 [[package]]
 name = "cc"
-version = "1.0.73"
+version = "1.0.77"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11"
+checksum = "e9f73505338f7d905b19d18738976aae232eb46b8efc15554ffc56deb5d9ebe4"
 dependencies = [
  "jobserver",
 ]
@@ -924,16 +933,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
 [[package]]
 name = "chrono"
-version = "0.4.22"
+version = "0.4.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1"
+checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f"
 dependencies = [
  "iana-time-zone",
  "js-sys",
  "num-integer",
  "num-traits",
  "serde",
- "time 0.1.44",
+ "time 0.1.45",
  "wasm-bindgen",
  "winapi 0.3.9",
 ]
@@ -981,9 +990,9 @@ dependencies = [
 
 [[package]]
 name = "clap"
-version = "3.2.22"
+version = "3.2.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "86447ad904c7fb335a790c9d7fe3d0d971dc523b8ccd1561a520de9a85302750"
+checksum = "71655c45cb9845d3270c9d6df84ebe72b4dad3c2ba3f7023ad47c144e4e473a5"
 dependencies = [
  "atty",
  "bitflags",
@@ -993,7 +1002,7 @@ dependencies = [
  "once_cell",
  "strsim 0.10.0",
  "termcolor",
- "textwrap 0.15.1",
+ "textwrap 0.16.0",
 ]
 
 [[package]]
@@ -1023,7 +1032,7 @@ name = "cli"
 version = "0.1.0"
 dependencies = [
  "anyhow",
- "clap 3.2.22",
+ "clap 3.2.23",
  "core-foundation",
  "core-services",
  "dirs 3.0.2",
@@ -1037,11 +1046,11 @@ name = "client"
 version = "0.1.0"
 dependencies = [
  "anyhow",
- "async-recursion",
+ "async-recursion 0.3.2",
  "async-tungstenite",
  "collections",
  "db",
- "futures 0.3.24",
+ "futures 0.3.25",
  "gpui",
  "image",
  "isahc",
@@ -1057,11 +1066,11 @@ dependencies = [
  "sum_tree",
  "tempfile",
  "thiserror",
- "time 0.3.15",
+ "time 0.3.17",
  "tiny_http",
  "url",
  "util",
- "uuid 1.2.1",
+ "uuid 1.2.2",
 ]
 
 [[package]]
@@ -1073,9 +1082,9 @@ dependencies = [
 
 [[package]]
 name = "cmake"
-version = "0.1.48"
+version = "0.1.49"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e8ad8cef104ac57b68b89df3208164d228503abbdce70f6880ffa3d970e7443a"
+checksum = "db34956e100b30725f2eb215f90d4871051239535632f84fea3bc92722c66b7c"
 dependencies = [
  "cc",
 ]
@@ -1129,7 +1138,7 @@ dependencies = [
  "axum-extra",
  "base64",
  "call",
- "clap 3.2.22",
+ "clap 3.2.23",
  "client",
  "collections",
  "ctor",
@@ -1138,7 +1147,7 @@ dependencies = [
  "env_logger",
  "envy",
  "fs",
- "futures 0.3.24",
+ "futures 0.3.25",
  "git",
  "gpui",
  "hyper",
@@ -1165,7 +1174,7 @@ dependencies = [
  "sha-1 0.9.8",
  "sqlx",
  "theme",
- "time 0.3.15",
+ "time 0.3.17",
  "tokio",
  "tokio-tungstenite",
  "toml",
@@ -1189,7 +1198,7 @@ dependencies = [
  "clock",
  "collections",
  "editor",
- "futures 0.3.24",
+ "futures 0.3.25",
  "fuzzy",
  "gpui",
  "log",
@@ -1238,11 +1247,11 @@ dependencies = [
 
 [[package]]
 name = "concurrent-queue"
-version = "1.2.4"
+version = "2.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af4780a44ab5696ea9e28294517f1fffb421a83a25af521333c838635509db9c"
+checksum = "bd7bef69dc86e3c610e4e7aed41035e2a7ed12e72dd7530f61327a6579a4390b"
 dependencies = [
- "cache-padded",
+ "crossbeam-utils 0.8.14",
 ]
 
 [[package]]
@@ -1469,7 +1478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521"
 dependencies = [
  "cfg-if 1.0.0",
- "crossbeam-utils 0.8.12",
+ "crossbeam-utils 0.8.14",
 ]
 
 [[package]]
@@ -1480,30 +1489,30 @@ checksum = "715e8152b692bba2d374b53d4875445368fdf21a94751410af607a5ac677d1fc"
 dependencies = [
  "cfg-if 1.0.0",
  "crossbeam-epoch",
- "crossbeam-utils 0.8.12",
+ "crossbeam-utils 0.8.14",
 ]
 
 [[package]]
 name = "crossbeam-epoch"
-version = "0.9.11"
+version = "0.9.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f916dfc5d356b0ed9dae65f1db9fc9770aa2851d2662b988ccf4fe3516e86348"
+checksum = "01a9af1f4c2ef74bb8aa1f7e19706bc72d03598c8a570bb5de72243c7a9d9d5a"
 dependencies = [
  "autocfg 1.1.0",
  "cfg-if 1.0.0",
- "crossbeam-utils 0.8.12",
- "memoffset",
+ "crossbeam-utils 0.8.14",
+ "memoffset 0.7.1",
  "scopeguard",
 ]
 
 [[package]]
 name = "crossbeam-queue"
-version = "0.3.6"
+version = "0.3.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1cd42583b04998a5363558e5f9291ee5a5ff6b49944332103f251e7479a82aa7"
+checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add"
 dependencies = [
  "cfg-if 1.0.0",
- "crossbeam-utils 0.8.12",
+ "crossbeam-utils 0.8.14",
 ]
 
 [[package]]
@@ -1519,9 +1528,9 @@ dependencies = [
 
 [[package]]
 name = "crossbeam-utils"
-version = "0.8.12"
+version = "0.8.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edbafec5fa1f196ca66527c1b12c2ec4745ca14b50f1ad8f9f6f720b55d11fac"
+checksum = "4fb766fa798726286dbbb842f174001dab8abc7b627a1dd86e0b7222a95d929f"
 dependencies = [
  "cfg-if 1.0.0",
 ]
@@ -1548,9 +1557,9 @@ dependencies = [
 
 [[package]]
 name = "ctor"
-version = "0.1.23"
+version = "0.1.26"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cdffe87e1d521a10f9696f833fe502293ea446d7f256c06128293a4119bdf4cb"
+checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096"
 dependencies = [
  "quote",
  "syn",
@@ -1573,9 +1582,9 @@ dependencies = [
 
 [[package]]
 name = "curl-sys"
-version = "0.4.56+curl-7.83.1"
+version = "0.4.59+curl-7.86.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6093e169dd4de29e468fa649fbae11cdcd5551c81fe5bf1b0677adad7ef3d26f"
+checksum = "6cfce34829f448b08f55b7db6d0009e23e2e86a34e8c2b366269bf5799b4a407"
 dependencies = [
  "cc",
  "libc",
@@ -1589,9 +1598,9 @@ dependencies = [
 
 [[package]]
 name = "cxx"
-version = "1.0.79"
+version = "1.0.83"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f83d0ebf42c6eafb8d7c52f7e5f2d3003b89c7aa4fd2b79229209459a849af8"
+checksum = "bdf07d07d6531bfcdbe9b8b739b104610c6508dcc4d63b410585faf338241daf"
 dependencies = [
  "cc",
  "cxxbridge-flags",
@@ -1601,9 +1610,9 @@ dependencies = [
 
 [[package]]
 name = "cxx-build"
-version = "1.0.79"
+version = "1.0.83"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07d050484b55975889284352b0ffc2ecbda25c0c55978017c132b29ba0818a86"
+checksum = "d2eb5b96ecdc99f72657332953d4d9c50135af1bac34277801cc3937906ebd39"
 dependencies = [
  "cc",
  "codespan-reporting",
@@ -1616,15 +1625,15 @@ dependencies = [
 
 [[package]]
 name = "cxxbridge-flags"
-version = "1.0.79"
+version = "1.0.83"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "99d2199b00553eda8012dfec8d3b1c75fce747cf27c169a270b3b99e3448ab78"
+checksum = "ac040a39517fd1674e0f32177648334b0f4074625b5588a64519804ba0553b12"
 
 [[package]]
 name = "cxxbridge-macro"
-version = "1.0.79"
+version = "1.0.83"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dcb67a6de1f602736dd7eaead0080cf3435df806c61b24b13328db128c58868f"
+checksum = "1362b0ddcfc4eb0a1f57b68bd77dd99f0e826958a96abd0ae9bd092e114ffed6"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1641,7 +1650,7 @@ dependencies = [
  "hashbrown 0.12.3",
  "lock_api",
  "once_cell",
- "parking_lot_core 0.9.4",
+ "parking_lot_core 0.9.5",
 ]
 
 [[package]]
@@ -1660,15 +1669,18 @@ dependencies = [
  "anyhow",
  "async-trait",
  "collections",
+ "env_logger",
  "gpui",
+ "indoc",
  "lazy_static",
  "log",
  "parking_lot 0.11.2",
- "rusqlite",
- "rusqlite_migration",
  "serde",
- "serde_rusqlite",
+ "smol",
+ "sqlez",
+ "sqlez_macros",
  "tempdir",
+ "util",
 ]
 
 [[package]]
@@ -1683,12 +1695,13 @@ dependencies = [
 
 [[package]]
 name = "dhat"
-version = "0.3.1"
+version = "0.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0684eaa19a59be283a6f99369917b679bd4d1d06604b2eb2e2f87b4bbd67668d"
+checksum = "4f2aaf837aaf456f6706cb46386ba8dffd4013a757e36f4ea05c20dd46b209a3"
 dependencies = [
  "backtrace",
  "lazy_static",
+ "mintex",
  "parking_lot 0.12.1",
  "rustc-hash",
  "serde",
@@ -1728,9 +1741,9 @@ dependencies = [
 
 [[package]]
 name = "digest"
-version = "0.10.5"
+version = "0.10.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "adfbc57365a37acbd2ebf2b64d7e69bb766e2fea813521ed536f5d0520dcf86c"
+checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f"
 dependencies = [
  "block-buffer 0.10.3",
  "crypto-common",
@@ -1845,9 +1858,10 @@ dependencies = [
  "collections",
  "context_menu",
  "ctor",
+ "db",
  "drag_and_drop",
  "env_logger",
- "futures 0.3.24",
+ "futures 0.3.25",
  "fuzzy",
  "git",
  "gpui",
@@ -1868,6 +1882,7 @@ dependencies = [
  "smallvec",
  "smol",
  "snippet",
+ "sqlez",
  "sum_tree",
  "text",
  "theme",
@@ -1897,9 +1912,9 @@ dependencies = [
 
 [[package]]
 name = "env_logger"
-version = "0.9.1"
+version = "0.9.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c90bf5f19754d10198ccb95b70664fc925bd1fc090a0fd9a6ebc54acc8cd6272"
+checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7"
 dependencies = [
  "atty",
  "humantime",
@@ -1988,12 +2003,6 @@ version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
 
-[[package]]
-name = "fallible-streaming-iterator"
-version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
-
 [[package]]
 name = "fastrand"
 version = "1.8.0"
@@ -2041,12 +2050,12 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
 
 [[package]]
 name = "flate2"
-version = "1.0.24"
+version = "1.0.25"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6"
+checksum = "a8a2db397cb1c8772f31494cb8917e48cd1e64f0fa7efac59fbd741a0a8ce841"
 dependencies = [
  "crc32fast",
- "miniz_oxide 0.5.4",
+ "miniz_oxide 0.6.2",
 ]
 
 [[package]]
@@ -2167,7 +2176,7 @@ dependencies = [
  "async-trait",
  "collections",
  "fsevent",
- "futures 0.3.24",
+ "futures 0.3.25",
  "git2",
  "gpui",
  "lazy_static",
@@ -2244,9 +2253,9 @@ checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678"
 
 [[package]]
 name = "futures"
-version = "0.3.24"
+version = "0.3.25"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f21eda599937fba36daeb58a22e8f5cee2d14c4a17b5b7739c7c8e5e3b8230c"
+checksum = "38390104763dc37a5145a53c29c63c1290b5d316d6086ec32c293f6736051bb0"
 dependencies = [
  "futures-channel",
  "futures-core",
@@ -2259,9 +2268,9 @@ dependencies = [
 
 [[package]]
 name = "futures-channel"
-version = "0.3.24"
+version = "0.3.25"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30bdd20c28fadd505d0fd6712cdfcb0d4b5648baf45faef7f852afb2399bb050"
+checksum = "52ba265a92256105f45b719605a571ffe2d1f0fea3807304b522c1d778f79eed"
 dependencies = [
  "futures-core",
  "futures-sink",
@@ -2269,15 +2278,15 @@ dependencies = [
 
 [[package]]
 name = "futures-core"
-version = "0.3.24"
+version = "0.3.25"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e5aa3de05362c3fb88de6531e6296e85cde7739cccad4b9dfeeb7f6ebce56bf"
+checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac"
 
 [[package]]
 name = "futures-executor"
-version = "0.3.24"
+version = "0.3.25"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ff63c23854bee61b6e9cd331d523909f238fc7636290b96826e9cfa5faa00ab"
+checksum = "7acc85df6714c176ab5edf386123fafe217be88c0840ec11f199441134a074e2"
 dependencies = [
  "futures-core",
  "futures-task",
@@ -2286,9 +2295,9 @@ dependencies = [
 
 [[package]]
 name = "futures-intrusive"
-version = "0.4.0"
+version = "0.4.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62007592ac46aa7c2b6416f7deb9a8a8f63a01e0f1d6e1787d5630170db2b63e"
+checksum = "a604f7a68fbf8103337523b1fadc8ade7361ee3f112f7c680ad179651616aed5"
 dependencies = [
  "futures-core",
  "lock_api",
@@ -2297,9 +2306,9 @@ dependencies = [
 
 [[package]]
 name = "futures-io"
-version = "0.3.24"
+version = "0.3.25"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbf4d2a7a308fd4578637c0b17c7e1c7ba127b8f6ba00b29f717e9655d85eb68"
+checksum = "00f5fb52a06bdcadeb54e8d3671f8888a39697dcb0b81b23b55174030427f4eb"
 
 [[package]]
 name = "futures-lite"
@@ -2318,9 +2327,9 @@ dependencies = [
 
 [[package]]
 name = "futures-macro"
-version = "0.3.24"
+version = "0.3.25"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42cd15d1c7456c04dbdf7e88bcd69760d74f3a798d6444e16974b505b0e62f17"
+checksum = "bdfb8ce053d86b91919aad980c220b1fb8401a9394410e1c289ed7e66b61835d"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -2329,21 +2338,21 @@ dependencies = [
 
 [[package]]
 name = "futures-sink"
-version = "0.3.24"
+version = "0.3.25"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21b20ba5a92e727ba30e72834706623d94ac93a725410b6a6b6fbc1b07f7ba56"
+checksum = "39c15cf1a4aa79df40f1bb462fb39676d0ad9e366c2a33b590d7c66f4f81fcf9"
 
 [[package]]
 name = "futures-task"
-version = "0.3.24"
+version = "0.3.25"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6508c467c73851293f390476d4491cf4d227dbabcd4170f3bb6044959b294f1"
+checksum = "2ffb393ac5d9a6eaa9d3fdf37ae2776656b706e200c8e16b1bdb227f5198e6ea"
 
 [[package]]
 name = "futures-util"
-version = "0.3.24"
+version = "0.3.25"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44fb6cb1be61cc1d2e43b262516aafcf63b241cffdb1d3fa115f91d9c7b09c90"
+checksum = "197676987abd2f9cadff84926f410af1c183608d36641465df73ae8211dc65d6"
 dependencies = [
  "futures 0.1.31",
  "futures-channel",
@@ -2399,9 +2408,9 @@ dependencies = [
 
 [[package]]
 name = "getrandom"
-version = "0.2.7"
+version = "0.2.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6"
+checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31"
 dependencies = [
  "cfg-if 1.0.0",
  "libc",
@@ -2437,7 +2446,7 @@ dependencies = [
  "async-trait",
  "clock",
  "collections",
- "futures 0.3.24",
+ "futures 0.3.25",
  "git2",
  "lazy_static",
  "log",
@@ -2515,7 +2524,7 @@ dependencies = [
  "etagere",
  "font-kit",
  "foreign-types",
- "futures 0.3.24",
+ "futures 0.3.25",
  "gpui_macros",
  "image",
  "itertools",
@@ -2540,8 +2549,9 @@ dependencies = [
  "simplelog",
  "smallvec",
  "smol",
+ "sqlez",
  "sum_tree",
- "time 0.3.15",
+ "time 0.3.17",
  "tiny-skia",
  "tree-sitter",
  "usvg",
@@ -2560,11 +2570,11 @@ dependencies = [
 
 [[package]]
 name = "h2"
-version = "0.3.14"
+version = "0.3.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ca32592cf21ac7ccab1825cd87f6c9b3d9022c44d086172ed0966bec8af30be"
+checksum = "5f9f29bc9dda355256b2916cf526ab02ce0aeaaaf2bad60d65ef3f12f11dd0f4"
 dependencies = [
- "bytes 1.2.1",
+ "bytes 1.3.0",
  "fnv",
  "futures-core",
  "futures-sink",
@@ -2612,7 +2622,7 @@ checksum = "f3e372db8e5c0d213e0cd0b9be18be2aca3d44cf2fe30a9d46a65581cd454584"
 dependencies = [
  "base64",
  "bitflags",
- "bytes 1.2.1",
+ "bytes 1.3.0",
  "headers-core",
  "http",
  "httpdate",
@@ -2696,7 +2706,7 @@ version = "0.12.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
 dependencies = [
- "digest 0.10.5",
+ "digest 0.10.6",
 ]
 
 [[package]]
@@ -2705,7 +2715,7 @@ version = "0.2.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399"
 dependencies = [
- "bytes 1.2.1",
+ "bytes 1.3.0",
  "fnv",
  "itoa",
 ]
@@ -2716,7 +2726,7 @@ version = "0.4.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1"
 dependencies = [
- "bytes 1.2.1",
+ "bytes 1.3.0",
  "http",
  "pin-project-lite 0.2.9",
 ]
@@ -2747,11 +2757,11 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
 
 [[package]]
 name = "hyper"
-version = "0.14.20"
+version = "0.14.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02c929dc5c39e335a03c405292728118860721b10190d98c2a0f0efd5baafbac"
+checksum = "034711faac9d2166cb1baf1a2fb0b60b1f277f8492fd72176c17f3515e1abd3c"
 dependencies = [
- "bytes 1.2.1",
+ "bytes 1.3.0",
  "futures-channel",
  "futures-core",
  "futures-util",
@@ -2787,7 +2797,7 @@ version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
 dependencies = [
- "bytes 1.2.1",
+ "bytes 1.3.0",
  "hyper",
  "native-tls",
  "tokio",
@@ -2796,9 +2806,9 @@ dependencies = [
 
 [[package]]
 name = "iana-time-zone"
-version = "0.1.51"
+version = "0.1.53"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f5a6ef98976b22b3b7f2f3a806f858cb862044cfa66805aa3ad84cb3d3b785ed"
+checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765"
 dependencies = [
  "android_system_properties",
  "core-foundation-sys",
@@ -2834,7 +2844,7 @@ version = "0.4.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "713f1b139373f96a2e0ce3ac931cd01ee973c3c5dd7c40c0c2efe96ad2b6751d"
 dependencies = [
- "crossbeam-utils 0.8.12",
+ "crossbeam-utils 0.8.14",
  "globset",
  "lazy_static",
  "log",
@@ -2867,9 +2877,9 @@ dependencies = [
 
 [[package]]
 name = "indexmap"
-version = "1.9.1"
+version = "1.9.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e"
+checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399"
 dependencies = [
  "autocfg 1.1.0",
  "hashbrown 0.12.3",
@@ -2941,9 +2951,9 @@ dependencies = [
 
 [[package]]
 name = "ipnet"
-version = "2.5.0"
+version = "2.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b"
+checksum = "f88c5561171189e69df9d98bcf18fd5f9558300f7ea7b801eb8a0fd748bd8745"
 
 [[package]]
 name = "is-terminal"
@@ -2965,7 +2975,7 @@ checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9"
 dependencies = [
  "async-channel",
  "castaway",
- "crossbeam-utils 0.8.12",
+ "crossbeam-utils 0.8.14",
  "curl",
  "curl-sys",
  "encoding_rs",

Cargo.toml 🔗

@@ -45,6 +45,8 @@ members = [
     "crates/search",
     "crates/settings",
     "crates/snippet",
+    "crates/sqlez",
+    "crates/sqlez_macros",
     "crates/sum_tree",
     "crates/terminal",
     "crates/text",
@@ -67,7 +69,6 @@ rand = { version = "0.8" }
 [patch.crates-io]
 tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "36b5b6c89e55ad1a502f8b3234bb3e12ec83a5da" }
 async-task = { git = "https://github.com/zed-industries/async-task", rev = "341b57d6de98cdfd7b418567b8de2022ca993a6e" }
-sqlx = { git = "https://github.com/launchbadge/sqlx", rev = "4b7053807c705df312bcb9b6281e184bf7534eb3" }
 
 # TODO - Remove when a version is released with this PR: https://github.com/servo/core-foundation-rs/pull/457
 cocoa = { git = "https://github.com/servo/core-foundation-rs", rev = "079665882507dd5e2ff77db3de5070c1f6c0fb85" }
@@ -82,3 +83,4 @@ split-debuginfo = "unpacked"
 [profile.release]
 debug = true
 
+

crates/activity_indicator/src/activity_indicator.rs 🔗

@@ -11,7 +11,7 @@ use settings::Settings;
 use smallvec::SmallVec;
 use std::{cmp::Reverse, fmt::Write, sync::Arc};
 use util::ResultExt;
-use workspace::{ItemHandle, StatusItemView, Workspace};
+use workspace::{item::ItemHandle, StatusItemView, Workspace};
 
 actions!(lsp_status, [ShowErrorMessage]);
 

crates/auto_update/Cargo.toml 🔗

@@ -8,6 +8,7 @@ path = "src/auto_update.rs"
 doctest = false
 
 [dependencies]
+db = { path = "../db" }
 client = { path = "../client" }
 gpui = { path = "../gpui" }
 menu = { path = "../menu" }

crates/auto_update/src/auto_update.rs 🔗

@@ -1,17 +1,18 @@
 mod update_notification;
 
 use anyhow::{anyhow, Context, Result};
-use client::{http::HttpClient, ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL};
+use client::{http::HttpClient, ZED_SECRET_CLIENT_TOKEN};
+use db::kvp::KEY_VALUE_STORE;
 use gpui::{
     actions, platform::AppVersion, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
     MutableAppContext, Task, WeakViewHandle,
 };
 use lazy_static::lazy_static;
 use serde::Deserialize;
-use settings::ReleaseChannel;
 use smol::{fs::File, io::AsyncReadExt, process::Command};
 use std::{env, ffi::OsString, path::PathBuf, sync::Arc, time::Duration};
 use update_notification::UpdateNotification;
+use util::channel::ReleaseChannel;
 use workspace::Workspace;
 
 const SHOULD_SHOW_UPDATE_NOTIFICATION_KEY: &str = "auto-updater-should-show-updated-notification";
@@ -41,7 +42,6 @@ pub struct AutoUpdater {
     current_version: AppVersion,
     http_client: Arc<dyn HttpClient>,
     pending_poll: Option<Task<()>>,
-    db: project::Db,
     server_url: String,
 }
 
@@ -55,11 +55,11 @@ impl Entity for AutoUpdater {
     type Event = ();
 }
 
-pub fn init(db: project::Db, http_client: Arc<dyn HttpClient>, cx: &mut MutableAppContext) {
+pub fn init(http_client: Arc<dyn HttpClient>, server_url: String, cx: &mut MutableAppContext) {
     if let Some(version) = (*ZED_APP_VERSION).or_else(|| cx.platform().app_version().ok()) {
-        let server_url = ZED_SERVER_URL.to_string();
+        let server_url = server_url;
         let auto_updater = cx.add_model(|cx| {
-            let updater = AutoUpdater::new(version, db.clone(), http_client, server_url.clone());
+            let updater = AutoUpdater::new(version, http_client, server_url.clone());
             updater.start_polling(cx).detach();
             updater
         });
@@ -120,14 +120,12 @@ impl AutoUpdater {
 
     fn new(
         current_version: AppVersion,
-        db: project::Db,
         http_client: Arc<dyn HttpClient>,
         server_url: String,
     ) -> Self {
         Self {
             status: AutoUpdateStatus::Idle,
             current_version,
-            db,
             http_client,
             server_url,
             pending_poll: None,
@@ -297,20 +295,28 @@ impl AutoUpdater {
         should_show: bool,
         cx: &AppContext,
     ) -> Task<Result<()>> {
-        let db = self.db.clone();
         cx.background().spawn(async move {
             if should_show {
-                db.write_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY, "")?;
+                KEY_VALUE_STORE
+                    .write_kvp(
+                        SHOULD_SHOW_UPDATE_NOTIFICATION_KEY.to_string(),
+                        "".to_string(),
+                    )
+                    .await?;
             } else {
-                db.delete_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY)?;
+                KEY_VALUE_STORE
+                    .delete_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY.to_string())
+                    .await?;
             }
             Ok(())
         })
     }
 
     fn should_show_update_notification(&self, cx: &AppContext) -> Task<Result<bool>> {
-        let db = self.db.clone();
-        cx.background()
-            .spawn(async move { Ok(db.read_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY)?.is_some()) })
+        cx.background().spawn(async move {
+            Ok(KEY_VALUE_STORE
+                .read_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY)?
+                .is_some())
+        })
     }
 }

crates/auto_update/src/update_notification.rs 🔗

@@ -5,8 +5,9 @@ use gpui::{
     Element, Entity, MouseButton, View, ViewContext,
 };
 use menu::Cancel;
-use settings::{ReleaseChannel, Settings};
-use workspace::Notification;
+use settings::Settings;
+use util::channel::ReleaseChannel;
+use workspace::notifications::Notification;
 
 pub struct UpdateNotification {
     version: AppVersion,
@@ -27,9 +28,9 @@ impl View for UpdateNotification {
 
     fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
         let theme = cx.global::<Settings>().theme.clone();
-        let theme = &theme.update_notification;
+        let theme = &theme.simple_message_notification;
 
-        let app_name = cx.global::<ReleaseChannel>().name();
+        let app_name = cx.global::<ReleaseChannel>().display_name();
 
         MouseEventHandler::<ViewReleaseNotes>::new(0, cx, |state, cx| {
             Flex::column()

crates/breadcrumbs/src/breadcrumbs.rs 🔗

@@ -4,7 +4,10 @@ use gpui::{
 use itertools::Itertools;
 use search::ProjectSearchView;
 use settings::Settings;
-use workspace::{ItemEvent, ItemHandle, ToolbarItemLocation, ToolbarItemView};
+use workspace::{
+    item::{ItemEvent, ItemHandle},
+    ToolbarItemLocation, ToolbarItemView,
+};
 
 pub enum Event {
     UpdateLocation,

crates/client/src/client.rs 🔗

@@ -11,7 +11,6 @@ use async_tungstenite::tungstenite::{
     error::Error as WebsocketError,
     http::{Request, StatusCode},
 };
-use db::Db;
 use futures::{future::LocalBoxFuture, AsyncReadExt, FutureExt, SinkExt, StreamExt, TryStreamExt};
 use gpui::{
     actions,
@@ -26,7 +25,6 @@ use postage::watch;
 use rand::prelude::*;
 use rpc::proto::{AnyTypedEnvelope, EntityMessage, EnvelopedMessage, RequestMessage};
 use serde::Deserialize;
-use settings::ReleaseChannel;
 use std::{
     any::TypeId,
     collections::HashMap,
@@ -41,6 +39,7 @@ use std::{
 use telemetry::Telemetry;
 use thiserror::Error;
 use url::Url;
+use util::channel::ReleaseChannel;
 use util::{ResultExt, TryFutureExt};
 
 pub use rpc::*;
@@ -1278,8 +1277,8 @@ impl Client {
         }
     }
 
-    pub fn start_telemetry(&self, db: Db) {
-        self.telemetry.start(db.clone());
+    pub fn start_telemetry(&self) {
+        self.telemetry.start();
     }
 
     pub fn report_event(&self, kind: &str, properties: Value) {

crates/client/src/telemetry.rs 🔗

@@ -1,5 +1,5 @@
 use crate::http::HttpClient;
-use db::Db;
+use db::kvp::KEY_VALUE_STORE;
 use gpui::{
     executor::Background,
     serde_json::{self, value::Map, Value},
@@ -10,7 +10,6 @@ use lazy_static::lazy_static;
 use parking_lot::Mutex;
 use serde::Serialize;
 use serde_json::json;
-use settings::ReleaseChannel;
 use std::{
     io::Write,
     mem,
@@ -19,7 +18,7 @@ use std::{
     time::{Duration, SystemTime, UNIX_EPOCH},
 };
 use tempfile::NamedTempFile;
-use util::{post_inc, ResultExt, TryFutureExt};
+use util::{channel::ReleaseChannel, post_inc, ResultExt, TryFutureExt};
 use uuid::Uuid;
 
 pub struct Telemetry {
@@ -107,7 +106,7 @@ impl Telemetry {
     pub fn new(client: Arc<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
         let platform = cx.platform();
         let release_channel = if cx.has_global::<ReleaseChannel>() {
-            Some(cx.global::<ReleaseChannel>().name())
+            Some(cx.global::<ReleaseChannel>().display_name())
         } else {
             None
         };
@@ -148,18 +147,21 @@ impl Telemetry {
         Some(self.state.lock().log_file.as_ref()?.path().to_path_buf())
     }
 
-    pub fn start(self: &Arc<Self>, db: Db) {
+    pub fn start(self: &Arc<Self>) {
         let this = self.clone();
         self.executor
             .spawn(
                 async move {
-                    let device_id = if let Ok(Some(device_id)) = db.read_kvp("device_id") {
-                        device_id
-                    } else {
-                        let device_id = Uuid::new_v4().to_string();
-                        db.write_kvp("device_id", &device_id)?;
-                        device_id
-                    };
+                    let device_id =
+                        if let Ok(Some(device_id)) = KEY_VALUE_STORE.read_kvp("device_id") {
+                            device_id
+                        } else {
+                            let device_id = Uuid::new_v4().to_string();
+                            KEY_VALUE_STORE
+                                .write_kvp("device_id".to_string(), device_id.clone())
+                                .await?;
+                            device_id
+                        };
 
                     let device_id: Arc<str> = device_id.into();
                     let mut state = this.state.lock();

crates/collab/src/integration_tests.rs 🔗

@@ -3,6 +3,7 @@ use crate::{
     rpc::{Executor, Server},
     AppState,
 };
+
 use ::rpc::Peer;
 use anyhow::anyhow;
 use call::{room, ActiveCall, ParticipantLocation, Room};
@@ -49,7 +50,7 @@ use std::{
 use theme::ThemeRegistry;
 use unindent::Unindent as _;
 use util::post_inc;
-use workspace::{shared_screen::SharedScreen, Item, SplitDirection, ToggleFollow, Workspace};
+use workspace::{item::Item, shared_screen::SharedScreen, SplitDirection, ToggleFollow, Workspace};
 
 #[ctor::ctor]
 fn init_logger() {
@@ -899,8 +900,15 @@ async fn test_host_disconnect(
     let project_b = client_b.build_remote_project(project_id, cx_b).await;
     assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
 
-    let (_, workspace_b) =
-        cx_b.add_window(|cx| Workspace::new(project_b.clone(), |_, _| unimplemented!(), cx));
+    let (_, workspace_b) = cx_b.add_window(|cx| {
+        Workspace::new(
+            Default::default(),
+            0,
+            project_b.clone(),
+            |_, _| unimplemented!(),
+            cx,
+        )
+    });
     let editor_b = workspace_b
         .update(cx_b, |workspace, cx| {
             workspace.open_path((worktree_id, "b.txt"), None, true, cx)
@@ -3691,8 +3699,15 @@ async fn test_collaborating_with_code_actions(
 
     // Join the project as client B.
     let project_b = client_b.build_remote_project(project_id, cx_b).await;
-    let (_window_b, workspace_b) =
-        cx_b.add_window(|cx| Workspace::new(project_b.clone(), |_, _| unimplemented!(), cx));
+    let (_window_b, workspace_b) = cx_b.add_window(|cx| {
+        Workspace::new(
+            Default::default(),
+            0,
+            project_b.clone(),
+            |_, _| unimplemented!(),
+            cx,
+        )
+    });
     let editor_b = workspace_b
         .update(cx_b, |workspace, cx| {
             workspace.open_path((worktree_id, "main.rs"), None, true, cx)
@@ -3912,8 +3927,15 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T
         .unwrap();
     let project_b = client_b.build_remote_project(project_id, cx_b).await;
 
-    let (_window_b, workspace_b) =
-        cx_b.add_window(|cx| Workspace::new(project_b.clone(), |_, _| unimplemented!(), cx));
+    let (_window_b, workspace_b) = cx_b.add_window(|cx| {
+        Workspace::new(
+            Default::default(),
+            0,
+            project_b.clone(),
+            |_, _| unimplemented!(),
+            cx,
+        )
+    });
     let editor_b = workspace_b
         .update(cx_b, |workspace, cx| {
             workspace.open_path((worktree_id, "one.rs"), None, true, cx)
@@ -4943,6 +4965,129 @@ async fn test_following(
     );
 }
 
+#[gpui::test]
+async fn test_following_tab_order(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    cx_a.update(editor::init);
+    cx_b.update(editor::init);
+
+    let mut server = TestServer::start(cx_a.background()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+
+    client_a
+        .fs
+        .insert_tree(
+            "/a",
+            json!({
+                "1.txt": "one",
+                "2.txt": "two",
+                "3.txt": "three",
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
+    active_call_a
+        .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
+        .await
+        .unwrap();
+
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+    let project_b = client_b.build_remote_project(project_id, cx_b).await;
+    active_call_b
+        .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
+        .await
+        .unwrap();
+
+    let workspace_a = client_a.build_workspace(&project_a, cx_a);
+    let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone());
+
+    let workspace_b = client_b.build_workspace(&project_b, cx_b);
+    let pane_b = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone());
+
+    let client_b_id = project_a.read_with(cx_a, |project, _| {
+        project.collaborators().values().next().unwrap().peer_id
+    });
+
+    //Open 1, 3 in that order on client A
+    workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.open_path((worktree_id, "1.txt"), None, true, cx)
+        })
+        .await
+        .unwrap();
+    workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.open_path((worktree_id, "3.txt"), None, true, cx)
+        })
+        .await
+        .unwrap();
+
+    let pane_paths = |pane: &ViewHandle<workspace::Pane>, cx: &mut TestAppContext| {
+        pane.update(cx, |pane, cx| {
+            pane.items()
+                .map(|item| {
+                    item.project_path(cx)
+                        .unwrap()
+                        .path
+                        .to_str()
+                        .unwrap()
+                        .to_owned()
+                })
+                .collect::<Vec<_>>()
+        })
+    };
+
+    //Verify that the tabs opened in the order we expect
+    assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt"]);
+
+    //Follow client B as client A
+    workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace
+                .toggle_follow(&ToggleFollow(client_b_id), cx)
+                .unwrap()
+        })
+        .await
+        .unwrap();
+
+    //Open just 2 on client B
+    workspace_b
+        .update(cx_b, |workspace, cx| {
+            workspace.open_path((worktree_id, "2.txt"), None, true, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+
+    // Verify that newly opened followed file is at the end
+    assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt", "2.txt"]);
+
+    //Open just 1 on client B
+    workspace_b
+        .update(cx_b, |workspace, cx| {
+            workspace.open_path((worktree_id, "1.txt"), None, true, cx)
+        })
+        .await
+        .unwrap();
+    assert_eq!(&pane_paths(&pane_b, cx_b), &["2.txt", "1.txt"]);
+    deterministic.run_until_parked();
+
+    // Verify that following into 1 did not reorder
+    assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt", "2.txt"]);
+}
+
 #[gpui::test(iterations = 10)]
 async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
     cx_a.foreground().forbid_parking();
@@ -6041,7 +6186,13 @@ impl TestClient {
     ) -> ViewHandle<Workspace> {
         let (_, root_view) = cx.add_window(|_| EmptyView);
         cx.add_view(&root_view, |cx| {
-            Workspace::new(project.clone(), |_, _| unimplemented!(), cx)
+            Workspace::new(
+                Default::default(),
+                0,
+                project.clone(),
+                |_, _| unimplemented!(),
+                cx,
+            )
         })
     }
 

crates/collab_ui/src/collab_ui.rs 🔗

@@ -50,7 +50,13 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
                 .await?;
 
                 let (_, workspace) = cx.add_window((app_state.build_window_options)(), |cx| {
-                    let mut workspace = Workspace::new(project, app_state.default_item_factory, cx);
+                    let mut workspace = Workspace::new(
+                        Default::default(),
+                        0,
+                        project,
+                        app_state.default_item_factory,
+                        cx,
+                    );
                     (app_state.initialize_workspace)(&mut workspace, &app_state, cx);
                     workspace
                 });

crates/collab_ui/src/contact_notification.rs 🔗

@@ -6,7 +6,7 @@ use gpui::{
     elements::*, impl_internal_actions, Entity, ModelHandle, MutableAppContext, RenderContext,
     View, ViewContext,
 };
-use workspace::Notification;
+use workspace::notifications::Notification;
 
 impl_internal_actions!(contact_notifications, [Dismiss, RespondToContactRequest]);
 

crates/command_palette/src/command_palette.rs 🔗

@@ -350,8 +350,9 @@ mod tests {
         });
 
         let project = Project::test(app_state.fs.clone(), [], cx).await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
         let editor = cx.add_view(&workspace, |cx| {
             let mut editor = Editor::single_line(None, cx);
             editor.set_text("abc", cx);

crates/db/Cargo.toml 🔗

@@ -12,16 +12,20 @@ test-support = []
 
 [dependencies]
 collections = { path = "../collections" }
+gpui = { path = "../gpui" }
+sqlez = { path = "../sqlez" }
+sqlez_macros = { path = "../sqlez_macros" }
+util = { path = "../util" }
 anyhow = "1.0.57"
+indoc = "1.0.4"
 async-trait = "0.1"
 lazy_static = "1.4.0"
 log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 parking_lot = "0.11.1"
-rusqlite = { version = "0.28.0", features = ["bundled", "serde_json"] }
-rusqlite_migration = { git = "https://github.com/cljoly/rusqlite_migration", rev = "c433555d7c1b41b103426e35756eb3144d0ebbc6" }
-serde = { workspace = true }
-serde_rusqlite = "0.31.0"
+serde = { version = "1.0", features = ["derive"] }
+smol = "1.2"
 
 [dev-dependencies]
 gpui = { path = "../gpui", features = ["test-support"] }
+env_logger = "0.9.1"
 tempdir = { version = "0.3.7" }

crates/db/README.md 🔗

@@ -0,0 +1,5 @@
+# Building Queries
+
+First, craft your test data. The examples folder shows a template for building a test-db, and can be ran with `cargo run --example [your-example]`.
+
+To actually use and test your queries, import the generated DB file into https://sqliteonline.com/

crates/db/src/db.rs 🔗

@@ -1,119 +1,365 @@
-mod kvp;
-mod migrations;
+pub mod kvp;
+pub mod query;
 
-use std::fs;
+// Re-export
+pub use anyhow;
+use anyhow::Context;
+pub use indoc::indoc;
+pub use lazy_static;
+use parking_lot::{Mutex, RwLock};
+pub use smol;
+pub use sqlez;
+pub use sqlez_macros;
+pub use util::channel::{RELEASE_CHANNEL, RELEASE_CHANNEL_NAME};
+pub use util::paths::DB_DIR;
+
+use sqlez::domain::Migrator;
+use sqlez::thread_safe_connection::ThreadSafeConnection;
+use sqlez_macros::sql;
+use std::fs::create_dir_all;
 use std::path::{Path, PathBuf};
-use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::time::{SystemTime, UNIX_EPOCH};
+use util::{async_iife, ResultExt};
+use util::channel::ReleaseChannel;
 
-use anyhow::Result;
-use log::error;
-use parking_lot::Mutex;
-use rusqlite::Connection;
+const CONNECTION_INITIALIZE_QUERY: &'static str = sql!(
+    PRAGMA foreign_keys=TRUE;
+);
 
-use migrations::MIGRATIONS;
+const DB_INITIALIZE_QUERY: &'static str = sql!(
+    PRAGMA journal_mode=WAL;
+    PRAGMA busy_timeout=1;
+    PRAGMA case_sensitive_like=TRUE;
+    PRAGMA synchronous=NORMAL;
+);
 
-#[derive(Clone)]
-pub enum Db {
-    Real(Arc<RealDb>),
-    Null,
-}
+const FALLBACK_DB_NAME: &'static str = "FALLBACK_MEMORY_DB";
 
-pub struct RealDb {
-    connection: Mutex<Connection>,
-    path: Option<PathBuf>,
+const DB_FILE_NAME: &'static str = "db.sqlite";
+
+lazy_static::lazy_static! {
+    static ref DB_FILE_OPERATIONS: Mutex<()> = Mutex::new(());
+    pub static ref BACKUP_DB_PATH: RwLock<Option<PathBuf>> = RwLock::new(None);
+    pub static ref ALL_FILE_DB_FAILED: AtomicBool = AtomicBool::new(false);    
 }
 
-impl Db {
-    /// Open or create a database at the given directory path.
-    pub fn open(db_dir: &Path, channel: &'static str) -> Self {
-        // Use 0 for now. Will implement incrementing and clearing of old db files soon TM
-        let current_db_dir = db_dir.join(Path::new(&format!("0-{}", channel)));
-        fs::create_dir_all(&current_db_dir)
-            .expect("Should be able to create the database directory");
-        let db_path = current_db_dir.join(Path::new("db.sqlite"));
-
-        Connection::open(db_path)
-            .map_err(Into::into)
-            .and_then(|connection| Self::initialize(connection))
-            .map(|connection| {
-                Db::Real(Arc::new(RealDb {
-                    connection,
-                    path: Some(db_dir.to_path_buf()),
-                }))
-            })
-            .unwrap_or_else(|e| {
-                error!(
-                    "Connecting to file backed db failed. Reverting to null db. {}",
-                    e
-                );
-                Self::Null
-            })
-    }
+/// Open or create a database at the given directory path.
+/// This will retry a couple times if there are failures. If opening fails once, the db directory
+/// is moved to a backup folder and a new one is created. If that fails, a shared in memory db is created.
+/// In either case, static variables are set so that the user can be notified.
+pub async fn open_db<M: Migrator + 'static>(db_dir: &Path, release_channel: &ReleaseChannel) -> ThreadSafeConnection<M> {
+    let release_channel_name = release_channel.dev_name();
+    let main_db_dir = db_dir.join(Path::new(&format!("0-{}", release_channel_name)));
 
-    /// Open a in memory database for testing and as a fallback.
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn open_in_memory() -> Self {
-        Connection::open_in_memory()
-            .map_err(Into::into)
-            .and_then(|connection| Self::initialize(connection))
-            .map(|connection| {
-                Db::Real(Arc::new(RealDb {
-                    connection,
-                    path: None,
-                }))
-            })
-            .unwrap_or_else(|e| {
-                error!(
-                    "Connecting to in memory db failed. Reverting to null db. {}",
-                    e
-                );
-                Self::Null
-            })
-    }
+    let connection = async_iife!({
+        // Note: This still has a race condition where 1 set of migrations succeeds
+        // (e.g. (Workspace, Editor)) and another fails (e.g. (Workspace, Terminal))
+        // This will cause the first connection to have the database taken out 
+        // from under it. This *should* be fine though. The second dabatase failure will
+        // cause errors in the log and so should be observed by developers while writing
+        // soon-to-be good migrations. If user databases are corrupted, we toss them out
+        // and try again from a blank. As long as running all migrations from start to end 
+        // on a blank database is ok, this race condition will never be triggered.
+        //
+        // Basically: Don't ever push invalid migrations to stable or everyone will have
+        // a bad time.
+        
+        // If no db folder, create one at 0-{channel}
+        create_dir_all(&main_db_dir).context("Could not create db directory")?;
+        let db_path = main_db_dir.join(Path::new(DB_FILE_NAME));
+        
+        // Optimistically open databases in parallel
+        if !DB_FILE_OPERATIONS.is_locked() {
+            // Try building a connection
+            if let Some(connection) = open_main_db(&db_path).await {
+                return Ok(connection)
+            };
+        }
+        
+        // Take a lock in the failure case so that we move the db once per process instead 
+        // of potentially multiple times from different threads. This shouldn't happen in the
+        // normal path
+        let _lock = DB_FILE_OPERATIONS.lock();
+        if let Some(connection) = open_main_db(&db_path).await {
+            return Ok(connection)
+        };
+        
+        let backup_timestamp = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .expect("System clock is set before the unix timestamp, Zed does not support this region of spacetime")
+            .as_millis();
+        
+        // If failed, move 0-{channel} to {current unix timestamp}-{channel}
+        let backup_db_dir = db_dir.join(Path::new(&format!(
+            "{}-{}",
+            backup_timestamp,
+            release_channel_name,
+        )));
+
+        std::fs::rename(&main_db_dir, &backup_db_dir)
+            .context("Failed clean up corrupted database, panicking.")?;
 
-    fn initialize(mut conn: Connection) -> Result<Mutex<Connection>> {
-        MIGRATIONS.to_latest(&mut conn)?;
+        // Set a static ref with the failed timestamp and error so we can notify the user
+        {
+            let mut guard = BACKUP_DB_PATH.write();
+            *guard = Some(backup_db_dir);
+        }
+        
+        // Create a new 0-{channel}
+        create_dir_all(&main_db_dir).context("Should be able to create the database directory")?;
+        let db_path = main_db_dir.join(Path::new(DB_FILE_NAME));
 
-        conn.pragma_update(None, "journal_mode", "WAL")?;
-        conn.pragma_update(None, "synchronous", "NORMAL")?;
-        conn.pragma_update(None, "foreign_keys", true)?;
-        conn.pragma_update(None, "case_sensitive_like", true)?;
+        // Try again
+        open_main_db(&db_path).await.context("Could not newly created db")
+    }).await.log_err();
 
-        Ok(Mutex::new(conn))
+    if let Some(connection) = connection {
+        return connection;
     }
+   
+    // Set another static ref so that we can escalate the notification
+    ALL_FILE_DB_FAILED.store(true, Ordering::Release);
+    
+    // If still failed, create an in memory db with a known name
+    open_fallback_db().await
+}
 
-    pub fn persisting(&self) -> bool {
-        self.real().and_then(|db| db.path.as_ref()).is_some()
-    }
+async fn open_main_db<M: Migrator>(db_path: &PathBuf) -> Option<ThreadSafeConnection<M>> {
+    log::info!("Opening main db");
+    ThreadSafeConnection::<M>::builder(db_path.to_string_lossy().as_ref(), true)
+        .with_db_initialization_query(DB_INITIALIZE_QUERY)
+        .with_connection_initialize_query(CONNECTION_INITIALIZE_QUERY)
+        .build()
+        .await
+        .log_err()
+}
 
-    pub fn real(&self) -> Option<&RealDb> {
-        match self {
-            Db::Real(db) => Some(&db),
-            _ => None,
-        }
-    }
+async fn open_fallback_db<M: Migrator>() -> ThreadSafeConnection<M> {
+    log::info!("Opening fallback db");
+    ThreadSafeConnection::<M>::builder(FALLBACK_DB_NAME, false)
+        .with_db_initialization_query(DB_INITIALIZE_QUERY)
+        .with_connection_initialize_query(CONNECTION_INITIALIZE_QUERY)
+        .build()
+        .await
+        .expect(
+            "Fallback in memory database failed. Likely initialization queries or migrations have fundamental errors",
+        )
+}
+
+#[cfg(any(test, feature = "test-support"))]
+pub async fn open_test_db<M: Migrator>(db_name: &str) -> ThreadSafeConnection<M> {
+    use sqlez::thread_safe_connection::locking_queue;
+
+    ThreadSafeConnection::<M>::builder(db_name, false)
+        .with_db_initialization_query(DB_INITIALIZE_QUERY)
+        .with_connection_initialize_query(CONNECTION_INITIALIZE_QUERY)
+        // Serialize queued writes via a mutex and run them synchronously
+        .with_write_queue_constructor(locking_queue())
+        .build()
+        .await
+        .unwrap()
 }
 
-impl Drop for Db {
-    fn drop(&mut self) {
-        match self {
-            Db::Real(real_db) => {
-                let lock = real_db.connection.lock();
+/// Implements a basic DB wrapper for a given domain
+#[macro_export]
+macro_rules! define_connection {
+    (pub static ref $id:ident: $t:ident<()> = $migrations:expr;) => {
+        pub struct $t($crate::sqlez::thread_safe_connection::ThreadSafeConnection<$t>);
 
-                let _ = lock.pragma_update(None, "analysis_limit", "500");
-                let _ = lock.pragma_update(None, "optimize", "");
+        impl ::std::ops::Deref for $t {
+            type Target = $crate::sqlez::thread_safe_connection::ThreadSafeConnection<$t>;
+
+            fn deref(&self) -> &Self::Target {
+                &self.0
             }
-            Db::Null => {}
         }
-    }
+        
+        impl $crate::sqlez::domain::Domain for $t {
+            fn name() -> &'static str {
+                stringify!($t)
+            }
+            
+            fn migrations() -> &'static [&'static str] {
+                $migrations
+            } 
+        }
+
+        #[cfg(any(test, feature = "test-support"))]
+        $crate::lazy_static::lazy_static! {
+            pub static ref $id: $t = $t($crate::smol::block_on($crate::open_test_db(stringify!($id))));
+        }
+
+        #[cfg(not(any(test, feature = "test-support")))]
+        $crate::lazy_static::lazy_static! {
+            pub static ref $id: $t = $t($crate::smol::block_on($crate::open_db(&$crate::DB_DIR, &$crate::RELEASE_CHANNEL)));
+        }
+    };
+    (pub static ref $id:ident: $t:ident<$($d:ty),+> = $migrations:expr;) => {
+        pub struct $t($crate::sqlez::thread_safe_connection::ThreadSafeConnection<( $($d),+, $t )>);
+
+        impl ::std::ops::Deref for $t {
+            type Target = $crate::sqlez::thread_safe_connection::ThreadSafeConnection<($($d),+, $t)>;
+
+            fn deref(&self) -> &Self::Target {
+                &self.0
+            }
+        }
+        
+        impl $crate::sqlez::domain::Domain for $t {
+            fn name() -> &'static str {
+                stringify!($t)
+            }
+            
+            fn migrations() -> &'static [&'static str] {
+                $migrations
+            } 
+        }
+
+        #[cfg(any(test, feature = "test-support"))]
+        $crate::lazy_static::lazy_static! {
+            pub static ref $id: $t = $t($crate::smol::block_on($crate::open_test_db(stringify!($id))));
+        }
+
+        #[cfg(not(any(test, feature = "test-support")))]
+        $crate::lazy_static::lazy_static! {
+            pub static ref $id: $t = $t($crate::smol::block_on($crate::open_db(&$crate::DB_DIR, &$crate::RELEASE_CHANNEL)));
+        }
+    };
 }
 
 #[cfg(test)]
 mod tests {
-    use crate::migrations::MIGRATIONS;
+    use std::{fs, thread};
+
+    use sqlez::{domain::Domain, connection::Connection};
+    use sqlez_macros::sql;
+    use tempdir::TempDir;
 
-    #[test]
-    fn test_migrations() {
-        assert!(MIGRATIONS.validate().is_ok());
+    use crate::{open_db, DB_FILE_NAME};
+        
+    // 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());
+        
+        let mut corrupted_backup_dir = fs::read_dir(
+            tempdir.path()
+        ).unwrap().find(|entry| {
+            !entry.as_ref().unwrap().file_name().to_str().unwrap().starts_with("0")
+        }
+        ).unwrap().unwrap().path();
+        corrupted_backup_dir.push(DB_FILE_NAME);
+        
+        dbg!(&corrupted_backup_dir);
+        
+        let backup = Connection::open_file(&corrupted_backup_dir.to_string_lossy());
+        assert!(backup.select_row::<usize>("SELECT * FROM test").unwrap()().unwrap().is_none());
+    }
+    
+    /// Test that DB exists but corrupted (causing recreate)
+    #[gpui::test]
+    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());
+       }
     }
 }

crates/db/src/items.rs 🔗

@@ -1,311 +0,0 @@
-use std::{ffi::OsStr, fmt::Display, hash::Hash, os::unix::prelude::OsStrExt, path::PathBuf};
-
-use anyhow::Result;
-use collections::HashSet;
-use rusqlite::{named_params, params};
-
-use super::Db;
-
-pub(crate) const ITEMS_M_1: &str = "
-CREATE TABLE items(
-    id INTEGER PRIMARY KEY,
-    kind TEXT
-) STRICT;
-CREATE TABLE item_path(
-    item_id INTEGER PRIMARY KEY,
-    path BLOB
-) STRICT;
-CREATE TABLE item_query(
-    item_id INTEGER PRIMARY KEY,
-    query TEXT
-) STRICT;
-";
-
-#[derive(PartialEq, Eq, Hash, Debug)]
-pub enum SerializedItemKind {
-    Editor,
-    Terminal,
-    ProjectSearch,
-    Diagnostics,
-}
-
-impl Display for SerializedItemKind {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        f.write_str(&format!("{:?}", self))
-    }
-}
-
-#[derive(Clone, Debug, PartialEq, Eq, Hash)]
-pub enum SerializedItem {
-    Editor(usize, PathBuf),
-    Terminal(usize),
-    ProjectSearch(usize, String),
-    Diagnostics(usize),
-}
-
-impl SerializedItem {
-    fn kind(&self) -> SerializedItemKind {
-        match self {
-            SerializedItem::Editor(_, _) => SerializedItemKind::Editor,
-            SerializedItem::Terminal(_) => SerializedItemKind::Terminal,
-            SerializedItem::ProjectSearch(_, _) => SerializedItemKind::ProjectSearch,
-            SerializedItem::Diagnostics(_) => SerializedItemKind::Diagnostics,
-        }
-    }
-
-    fn id(&self) -> usize {
-        match self {
-            SerializedItem::Editor(id, _)
-            | SerializedItem::Terminal(id)
-            | SerializedItem::ProjectSearch(id, _)
-            | SerializedItem::Diagnostics(id) => *id,
-        }
-    }
-}
-
-impl Db {
-    fn write_item(&self, serialized_item: SerializedItem) -> Result<()> {
-        self.real()
-            .map(|db| {
-                let mut lock = db.connection.lock();
-                let tx = lock.transaction()?;
-
-                // Serialize the item
-                let id = serialized_item.id();
-                {
-                    let mut stmt = tx.prepare_cached(
-                        "INSERT OR REPLACE INTO items(id, kind) VALUES ((?), (?))",
-                    )?;
-
-                    dbg!("inserting item");
-                    stmt.execute(params![id, serialized_item.kind().to_string()])?;
-                }
-
-                // Serialize item data
-                match &serialized_item {
-                    SerializedItem::Editor(_, path) => {
-                        dbg!("inserting path");
-                        let mut stmt = tx.prepare_cached(
-                            "INSERT OR REPLACE INTO item_path(item_id, path) VALUES ((?), (?))",
-                        )?;
-
-                        let path_bytes = path.as_os_str().as_bytes();
-                        stmt.execute(params![id, path_bytes])?;
-                    }
-                    SerializedItem::ProjectSearch(_, query) => {
-                        dbg!("inserting query");
-                        let mut stmt = tx.prepare_cached(
-                            "INSERT OR REPLACE INTO item_query(item_id, query) VALUES ((?), (?))",
-                        )?;
-
-                        stmt.execute(params![id, query])?;
-                    }
-                    _ => {}
-                }
-
-                tx.commit()?;
-
-                let mut stmt = lock.prepare_cached("SELECT id, kind FROM items")?;
-                let _ = stmt
-                    .query_map([], |row| {
-                        let zero: usize = row.get(0)?;
-                        let one: String = row.get(1)?;
-
-                        dbg!(zero, one);
-                        Ok(())
-                    })?
-                    .collect::<Vec<Result<(), _>>>();
-
-                Ok(())
-            })
-            .unwrap_or(Ok(()))
-    }
-
-    fn delete_item(&self, item_id: usize) -> Result<()> {
-        self.real()
-            .map(|db| {
-                let lock = db.connection.lock();
-
-                let mut stmt = lock.prepare_cached(
-                    r#"
-                    DELETE FROM items WHERE id = (:id);
-                    DELETE FROM item_path WHERE id = (:id);
-                    DELETE FROM item_query WHERE id = (:id);
-                    "#,
-                )?;
-
-                stmt.execute(named_params! {":id": item_id})?;
-
-                Ok(())
-            })
-            .unwrap_or(Ok(()))
-    }
-
-    fn take_items(&self) -> Result<HashSet<SerializedItem>> {
-        self.real()
-            .map(|db| {
-                let mut lock = db.connection.lock();
-
-                let tx = lock.transaction()?;
-
-                // When working with transactions in rusqlite, need to make this kind of scope
-                // To make the borrow stuff work correctly. Don't know why, rust is wild.
-                let result = {
-                    let mut editors_stmt = tx.prepare_cached(
-                        r#"
-                        SELECT items.id, item_path.path
-                        FROM items
-                        LEFT JOIN item_path
-                            ON items.id = item_path.item_id
-                        WHERE items.kind = ?;
-                        "#,
-                    )?;
-
-                    let editors_iter = editors_stmt.query_map(
-                        [SerializedItemKind::Editor.to_string()],
-                        |row| {
-                            let id: usize = row.get(0)?;
-
-                            let buf: Vec<u8> = row.get(1)?;
-                            let path: PathBuf = OsStr::from_bytes(&buf).into();
-
-                            Ok(SerializedItem::Editor(id, path))
-                        },
-                    )?;
-
-                    let mut terminals_stmt = tx.prepare_cached(
-                        r#"
-                        SELECT items.id
-                        FROM items
-                        WHERE items.kind = ?;
-                        "#,
-                    )?;
-                    let terminals_iter = terminals_stmt.query_map(
-                        [SerializedItemKind::Terminal.to_string()],
-                        |row| {
-                            let id: usize = row.get(0)?;
-
-                            Ok(SerializedItem::Terminal(id))
-                        },
-                    )?;
-
-                    let mut search_stmt = tx.prepare_cached(
-                        r#"
-                        SELECT items.id, item_query.query
-                        FROM items
-                        LEFT JOIN item_query
-                            ON items.id = item_query.item_id
-                        WHERE items.kind = ?;
-                        "#,
-                    )?;
-                    let searches_iter = search_stmt.query_map(
-                        [SerializedItemKind::ProjectSearch.to_string()],
-                        |row| {
-                            let id: usize = row.get(0)?;
-                            let query = row.get(1)?;
-
-                            Ok(SerializedItem::ProjectSearch(id, query))
-                        },
-                    )?;
-
-                    #[cfg(debug_assertions)]
-                    let tmp =
-                        searches_iter.collect::<Vec<Result<SerializedItem, rusqlite::Error>>>();
-                    #[cfg(debug_assertions)]
-                    debug_assert!(tmp.len() == 0 || tmp.len() == 1);
-                    #[cfg(debug_assertions)]
-                    let searches_iter = tmp.into_iter();
-
-                    let mut diagnostic_stmt = tx.prepare_cached(
-                        r#"
-                        SELECT items.id
-                        FROM items
-                        WHERE items.kind = ?;
-                        "#,
-                    )?;
-
-                    let diagnostics_iter = diagnostic_stmt.query_map(
-                        [SerializedItemKind::Diagnostics.to_string()],
-                        |row| {
-                            let id: usize = row.get(0)?;
-
-                            Ok(SerializedItem::Diagnostics(id))
-                        },
-                    )?;
-
-                    #[cfg(debug_assertions)]
-                    let tmp =
-                        diagnostics_iter.collect::<Vec<Result<SerializedItem, rusqlite::Error>>>();
-                    #[cfg(debug_assertions)]
-                    debug_assert!(tmp.len() == 0 || tmp.len() == 1);
-                    #[cfg(debug_assertions)]
-                    let diagnostics_iter = tmp.into_iter();
-
-                    let res = editors_iter
-                        .chain(terminals_iter)
-                        .chain(diagnostics_iter)
-                        .chain(searches_iter)
-                        .collect::<Result<HashSet<SerializedItem>, rusqlite::Error>>()?;
-
-                    let mut delete_stmt = tx.prepare_cached(
-                        r#"
-                        DELETE FROM items;
-                        DELETE FROM item_path;
-                        DELETE FROM item_query;
-                        "#,
-                    )?;
-
-                    delete_stmt.execute([])?;
-
-                    res
-                };
-
-                tx.commit()?;
-
-                Ok(result)
-            })
-            .unwrap_or(Ok(HashSet::default()))
-    }
-}
-
-#[cfg(test)]
-mod test {
-    use anyhow::Result;
-
-    use super::*;
-
-    #[test]
-    fn test_items_round_trip() -> Result<()> {
-        let db = Db::open_in_memory();
-
-        let mut items = vec![
-            SerializedItem::Editor(0, PathBuf::from("/tmp/test.txt")),
-            SerializedItem::Terminal(1),
-            SerializedItem::ProjectSearch(2, "Test query!".to_string()),
-            SerializedItem::Diagnostics(3),
-        ]
-        .into_iter()
-        .collect::<HashSet<_>>();
-
-        for item in items.iter() {
-            dbg!("Inserting... ");
-            db.write_item(item.clone())?;
-        }
-
-        assert_eq!(items, db.take_items()?);
-
-        // Check that it's empty, as expected
-        assert_eq!(HashSet::default(), db.take_items()?);
-
-        for item in items.iter() {
-            db.write_item(item.clone())?;
-        }
-
-        items.remove(&SerializedItem::ProjectSearch(2, "Test query!".to_string()));
-        db.delete_item(2)?;
-
-        assert_eq!(items, db.take_items()?);
-
-        Ok(())
-    }
-}

crates/db/src/kvp.rs 🔗

@@ -1,82 +1,62 @@
-use anyhow::Result;
-use rusqlite::OptionalExtension;
-
-use super::Db;
-
-pub(crate) const KVP_M_1_UP: &str = "
-CREATE TABLE kv_store(
-    key TEXT PRIMARY KEY,
-    value TEXT NOT NULL
-) STRICT;
-";
-
-impl Db {
-    pub fn read_kvp(&self, key: &str) -> Result<Option<String>> {
-        self.real()
-            .map(|db| {
-                let lock = db.connection.lock();
-                let mut stmt = lock.prepare_cached("SELECT value FROM kv_store WHERE key = (?)")?;
-
-                Ok(stmt.query_row([key], |row| row.get(0)).optional()?)
-            })
-            .unwrap_or(Ok(None))
+use sqlez_macros::sql;
+
+use crate::{define_connection, query};
+
+define_connection!(pub static ref KEY_VALUE_STORE: KeyValueStore<()> =
+    &[sql!(
+        CREATE TABLE IF NOT EXISTS kv_store(
+            key TEXT PRIMARY KEY,
+            value TEXT NOT NULL
+        ) STRICT;
+    )];
+);
+
+impl KeyValueStore {
+    query! {
+        pub fn read_kvp(key: &str) -> Result<Option<String>> {
+            SELECT value FROM kv_store WHERE key = (?)
+        }
     }
 
-    pub fn write_kvp(&self, key: &str, value: &str) -> Result<()> {
-        self.real()
-            .map(|db| {
-                let lock = db.connection.lock();
-
-                let mut stmt = lock.prepare_cached(
-                    "INSERT OR REPLACE INTO kv_store(key, value) VALUES ((?), (?))",
-                )?;
-
-                stmt.execute([key, value])?;
-
-                Ok(())
-            })
-            .unwrap_or(Ok(()))
+    query! {
+        pub async fn write_kvp(key: String, value: String) -> Result<()> {
+            INSERT OR REPLACE INTO kv_store(key, value) VALUES ((?), (?))
+        }
     }
 
-    pub fn delete_kvp(&self, key: &str) -> Result<()> {
-        self.real()
-            .map(|db| {
-                let lock = db.connection.lock();
-
-                let mut stmt = lock.prepare_cached("DELETE FROM kv_store WHERE key = (?)")?;
-
-                stmt.execute([key])?;
-
-                Ok(())
-            })
-            .unwrap_or(Ok(()))
+    query! {
+        pub async fn delete_kvp(key: String) -> Result<()> {
+            DELETE FROM kv_store WHERE key = (?)
+        }
     }
 }
 
 #[cfg(test)]
 mod tests {
-    use anyhow::Result;
-
-    use super::*;
-
-    #[test]
-    fn test_kvp() -> Result<()> {
-        let db = Db::open_in_memory();
+    use crate::kvp::KeyValueStore;
 
-        assert_eq!(db.read_kvp("key-1")?, None);
+    #[gpui::test]
+    async fn test_kvp() {
+        let db = KeyValueStore(crate::open_test_db("test_kvp").await);
 
-        db.write_kvp("key-1", "one")?;
-        assert_eq!(db.read_kvp("key-1")?, Some("one".to_string()));
+        assert_eq!(db.read_kvp("key-1").unwrap(), None);
 
-        db.write_kvp("key-1", "one-2")?;
-        assert_eq!(db.read_kvp("key-1")?, Some("one-2".to_string()));
+        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-2", "two")?;
-        assert_eq!(db.read_kvp("key-2")?, Some("two".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.delete_kvp("key-1")?;
-        assert_eq!(db.read_kvp("key-1")?, None);
+        db.write_kvp("key-2".to_string(), "two".to_string())
+            .await
+            .unwrap();
+        assert_eq!(db.read_kvp("key-2").unwrap(), Some("two".to_string()));
 
-        Ok(())
+        db.delete_kvp("key-1".to_string()).await.unwrap();
+        assert_eq!(db.read_kvp("key-1").unwrap(), None);
     }
 }

crates/db/src/migrations.rs 🔗

@@ -1,15 +0,0 @@
-use rusqlite_migration::{Migrations, M};
-
-// use crate::items::ITEMS_M_1;
-use crate::kvp::KVP_M_1_UP;
-
-// This must be ordered by development time! Only ever add new migrations to the end!!
-// Bad things will probably happen if you don't monotonically edit this vec!!!!
-// And no re-ordering ever!!!!!!!!!! The results of these migrations are on the user's
-// file system and so everything we do here is locked in _f_o_r_e_v_e_r_.
-lazy_static::lazy_static! {
-    pub static ref MIGRATIONS: Migrations<'static> = Migrations::new(vec![
-        M::up(KVP_M_1_UP),
-        // M::up(ITEMS_M_1),
-    ]);
-}

crates/db/src/query.rs 🔗

@@ -0,0 +1,314 @@
+#[macro_export]
+macro_rules! query {
+    ($vis:vis fn $id:ident() -> Result<()> { $($sql:tt)+ }) => {
+        $vis fn $id(&self) -> $crate::anyhow::Result<()> {
+            use $crate::anyhow::Context;
+
+            let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+            self.exec(sql_stmt)?().context(::std::format!(
+                "Error in {}, exec failed to execute or parse for: {}",
+                ::std::stringify!($id),
+                sql_stmt,
+            ))
+        }
+    };
+    ($vis:vis async fn $id:ident() -> Result<()> { $($sql:tt)+ }) => {
+        $vis async fn $id(&self) -> $crate::anyhow::Result<()> {
+            use $crate::anyhow::Context;
+
+            self.write(|connection| {
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.exec(sql_stmt)?().context(::std::format!(
+                    "Error in {}, exec failed to execute or parse for: {}",
+                    ::std::stringify!($id),
+                    sql_stmt
+                ))
+            }).await
+        }
+    };
+    ($vis:vis fn $id:ident($($arg:ident: $arg_type:ty),+) -> Result<()> { $($sql:tt)+ }) => {
+        $vis fn $id(&self, $($arg: $arg_type),+) -> $crate::anyhow::Result<()> {
+            use $crate::anyhow::Context;
+
+            let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+            self.exec_bound::<($($arg_type),+)>(sql_stmt)?(($($arg),+))
+                .context(::std::format!(
+                    "Error in {}, exec_bound failed to execute or parse for: {}",
+                    ::std::stringify!($id),
+                    sql_stmt
+                ))
+        }
+    };
+    ($vis:vis async fn $id:ident($arg:ident: $arg_type:ty) -> Result<()> { $($sql:tt)+ }) => {
+        $vis async fn $id(&self, $arg: $arg_type) -> $crate::anyhow::Result<()> {
+            use $crate::anyhow::Context;
+
+            self.write(move |connection| {
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.exec_bound::<$arg_type>(sql_stmt)?($arg)
+                    .context(::std::format!(
+                        "Error in {}, exec_bound failed to execute or parse for: {}",
+                        ::std::stringify!($id),
+                        sql_stmt
+                    ))
+            }).await
+        }
+    };
+    ($vis:vis async fn $id:ident($($arg:ident: $arg_type:ty),+) -> Result<()> { $($sql:tt)+ }) => {
+        $vis async fn $id(&self, $($arg: $arg_type),+) -> $crate::anyhow::Result<()> {
+            use $crate::anyhow::Context;
+
+            self.write(move |connection| {
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.exec_bound::<($($arg_type),+)>(sql_stmt)?(($($arg),+))
+                    .context(::std::format!(
+                        "Error in {}, exec_bound failed to execute or parse for: {}",
+                        ::std::stringify!($id),
+                        sql_stmt
+                    ))
+            }).await
+        }
+    };
+    ($vis:vis fn $id:ident() ->  Result<Vec<$return_type:ty>> { $($sql:tt)+ }) => {
+        $vis fn $id(&self) -> $crate::anyhow::Result<Vec<$return_type>> {
+            use $crate::anyhow::Context;
+
+            let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+            self.select::<$return_type>(sql_stmt)?(())
+                .context(::std::format!(
+                    "Error in {}, select_row failed to execute or parse for: {}",
+                    ::std::stringify!($id),
+                    sql_stmt
+                ))
+        }
+    };
+    ($vis:vis async fn $id:ident() -> Result<Vec<$return_type:ty>> { $($sql:tt)+ }) => {
+        pub async fn $id(&self) -> $crate::anyhow::Result<Vec<$return_type>> {
+            use $crate::anyhow::Context;
+
+            self.write(|connection| {
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.select::<$return_type>(sql_stmt)?(())
+                    .context(::std::format!(
+                        "Error in {}, select_row failed to execute or parse for: {}",
+                        ::std::stringify!($id),
+                        sql_stmt
+                    ))
+            }).await
+        }
+    };
+    ($vis:vis fn $id:ident($($arg:ident: $arg_type:ty),+) -> Result<Vec<$return_type:ty>> { $($sql:tt)+ }) => {
+        $vis fn $id(&self, $($arg: $arg_type),+) -> $crate::anyhow::Result<Vec<$return_type>> {
+            use $crate::anyhow::Context;
+
+            let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+            self.select_bound::<($($arg_type),+), $return_type>(sql_stmt)?(($($arg),+))
+                .context(::std::format!(
+                    "Error in {}, exec_bound failed to execute or parse for: {}",
+                    ::std::stringify!($id),
+                    sql_stmt
+                ))
+        }
+    };
+    ($vis:vis async fn $id:ident($($arg:ident: $arg_type:ty),+) -> Result<Vec<$return_type:ty>> { $($sql:tt)+ }) => {
+        $vis async fn $id(&self, $($arg: $arg_type),+) -> $crate::anyhow::Result<Vec<$return_type>> {
+            use $crate::anyhow::Context;
+
+            self.write(|connection| {
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.select_bound::<($($arg_type),+), $return_type>(sql_stmt)?(($($arg),+))
+                    .context(::std::format!(
+                        "Error in {}, exec_bound failed to execute or parse for: {}",
+                        ::std::stringify!($id),
+                        sql_stmt
+                    ))
+            }).await
+        }
+    };
+    ($vis:vis fn $id:ident() ->  Result<Option<$return_type:ty>> { $($sql:tt)+ }) => {
+        $vis fn $id(&self) -> $crate::anyhow::Result<Option<$return_type>> {
+            use $crate::anyhow::Context;
+
+            let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+            self.select_row::<$return_type>(sql_stmt)?()
+                .context(::std::format!(
+                    "Error in {}, select_row failed to execute or parse for: {}",
+                    ::std::stringify!($id),
+                    sql_stmt
+                ))
+        }
+    };
+    ($vis:vis async fn $id:ident() ->  Result<Option<$return_type:ty>> { $($sql:tt)+ }) => {
+        $vis async fn $id(&self) -> $crate::anyhow::Result<Option<$return_type>> {
+            use $crate::anyhow::Context;
+
+            self.write(|connection| {
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.select_row::<$return_type>(sql_stmt)?()
+                    .context(::std::format!(
+                        "Error in {}, select_row failed to execute or parse for: {}",
+                        ::std::stringify!($id),
+                        sql_stmt
+                    ))
+            }).await
+        }
+    };
+    ($vis:vis fn $id:ident($arg:ident: $arg_type:ty) ->  Result<Option<$return_type:ty>> { $($sql:tt)+ }) => {
+        $vis fn $id(&self, $arg: $arg_type) -> $crate::anyhow::Result<Option<$return_type>>  {
+            use $crate::anyhow::Context;
+
+            let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+            self.select_row_bound::<$arg_type, $return_type>(sql_stmt)?($arg)
+                .context(::std::format!(
+                    "Error in {}, select_row_bound failed to execute or parse for: {}",
+                    ::std::stringify!($id),
+                    sql_stmt
+                ))
+
+        }
+    };
+    ($vis:vis fn $id:ident($($arg:ident: $arg_type:ty),+) ->  Result<Option<$return_type:ty>> { $($sql:tt)+ }) => {
+        $vis fn $id(&self, $($arg: $arg_type),+) -> $crate::anyhow::Result<Option<$return_type>>  {
+            use $crate::anyhow::Context;
+
+            let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+            self.select_row_bound::<($($arg_type),+), $return_type>(sql_stmt)?(($($arg),+))
+                .context(::std::format!(
+                    "Error in {}, select_row_bound failed to execute or parse for: {}",
+                    ::std::stringify!($id),
+                    sql_stmt
+                ))
+
+        }
+    };
+    ($vis:vis async fn $id:ident($($arg:ident: $arg_type:ty),+) ->  Result<Option<$return_type:ty>> { $($sql:tt)+ }) => {
+        $vis async fn $id(&self, $($arg: $arg_type),+) -> $crate::anyhow::Result<Option<$return_type>>  {
+            use $crate::anyhow::Context;
+
+
+            self.write(|connection| {
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.select_row_bound::<($($arg_type),+), $return_type>(indoc! { $sql })?(($($arg),+))
+                    .context(::std::format!(
+                        "Error in {}, select_row_bound failed to execute or parse for: {}",
+                        ::std::stringify!($id),
+                        sql_stmt
+                    ))
+            }).await
+        }
+    };
+    ($vis:vis fn $id:ident() ->  Result<$return_type:ty> { $($sql:tt)+ }) => {
+        $vis fn $id(&self) ->  $crate::anyhow::Result<$return_type>  {
+            use $crate::anyhow::Context;
+
+            let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+            self.select_row::<$return_type>(indoc! { $sql })?()
+                .context(::std::format!(
+                    "Error in {}, select_row_bound failed to execute or parse for: {}",
+                    ::std::stringify!($id),
+                    sql_stmt
+                ))?
+                .context(::std::format!(
+                    "Error in {}, select_row_bound expected single row result but found none for: {}",
+                    ::std::stringify!($id),
+                    sql_stmt
+                ))
+        }
+    };
+    ($vis:vis async fn $id:ident() ->  Result<$return_type:ty> { $($sql:tt)+ }) => {
+        $vis async fn $id(&self) ->  $crate::anyhow::Result<$return_type>  {
+            use $crate::anyhow::Context;
+
+            self.write(|connection| {
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.select_row::<$return_type>(sql_stmt)?()
+                    .context(::std::format!(
+                        "Error in {}, select_row_bound failed to execute or parse for: {}",
+                        ::std::stringify!($id),
+                        sql_stmt
+                    ))?
+                    .context(::std::format!(
+                        "Error in {}, select_row_bound expected single row result but found none for: {}",
+                        ::std::stringify!($id),
+                        sql_stmt
+                    ))
+            }).await
+        }
+    };
+    ($vis:vis fn $id:ident($arg:ident: $arg_type:ty) ->  Result<$return_type:ty> { $($sql:tt)+ }) => {
+        pub fn $id(&self, $arg: $arg_type) ->  $crate::anyhow::Result<$return_type>  {
+            use $crate::anyhow::Context;
+
+            let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+            self.select_row_bound::<$arg_type, $return_type>(sql_stmt)?($arg)
+                .context(::std::format!(
+                    "Error in {}, select_row_bound failed to execute or parse for: {}",
+                    ::std::stringify!($id),
+                    sql_stmt
+                ))?
+                .context(::std::format!(
+                    "Error in {}, select_row_bound expected single row result but found none for: {}",
+                    ::std::stringify!($id),
+                    sql_stmt
+                ))
+        }
+    };
+    ($vis:vis fn $id:ident($($arg:ident: $arg_type:ty),+) ->  Result<$return_type:ty> { $($sql:tt)+ }) => {
+        $vis fn $id(&self, $($arg: $arg_type),+) ->  $crate::anyhow::Result<$return_type>  {
+            use $crate::anyhow::Context;
+
+            let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+            self.select_row_bound::<($($arg_type),+), $return_type>(sql_stmt)?(($($arg),+))
+                .context(::std::format!(
+                    "Error in {}, select_row_bound failed to execute or parse for: {}",
+                    ::std::stringify!($id),
+                    sql_stmt
+                ))?
+                .context(::std::format!(
+                    "Error in {}, select_row_bound expected single row result but found none for: {}",
+                    ::std::stringify!($id),
+                    sql_stmt
+                ))
+        }
+    };
+    ($vis:vis fn async $id:ident($($arg:ident: $arg_type:ty),+) ->  Result<$return_type:ty> { $($sql:tt)+ }) => {
+        $vis async fn $id(&self, $($arg: $arg_type),+) ->  $crate::anyhow::Result<$return_type>  {
+            use $crate::anyhow::Context;
+
+
+            self.write(|connection| {
+                let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
+
+                connection.select_row_bound::<($($arg_type),+), $return_type>(sql_stmt)?(($($arg),+))
+                    .context(::std::format!(
+                        "Error in {}, select_row_bound failed to execute or parse for: {}",
+                        ::std::stringify!($id),
+                        sql_stmt
+                    ))?
+                    .context(::std::format!(
+                        "Error in {}, select_row_bound expected single row result but found none for: {}",
+                        ::std::stringify!($id),
+                        sql_stmt
+                    ))
+            }).await
+        }
+    };
+}

crates/diagnostics/src/diagnostics.rs 🔗

@@ -29,7 +29,10 @@ use std::{
     sync::Arc,
 };
 use util::TryFutureExt;
-use workspace::{ItemHandle as _, ItemNavHistory, Workspace};
+use workspace::{
+    item::{Item, ItemEvent, ItemHandle},
+    ItemNavHistory, Pane, Workspace,
+};
 
 actions!(diagnostics, [Deploy]);
 
@@ -503,7 +506,7 @@ impl ProjectDiagnosticsEditor {
     }
 }
 
-impl workspace::Item for ProjectDiagnosticsEditor {
+impl Item for ProjectDiagnosticsEditor {
     fn tab_content(
         &self,
         _detail: Option<usize>,
@@ -571,7 +574,7 @@ impl workspace::Item for ProjectDiagnosticsEditor {
         unreachable!()
     }
 
-    fn to_item_events(event: &Self::Event) -> Vec<workspace::ItemEvent> {
+    fn to_item_events(event: &Self::Event) -> Vec<ItemEvent> {
         Editor::to_item_events(event)
     }
 
@@ -581,7 +584,11 @@ impl workspace::Item for ProjectDiagnosticsEditor {
         });
     }
 
-    fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self>
+    fn clone_on_split(
+        &self,
+        _workspace_id: workspace::WorkspaceId,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<Self>
     where
         Self: Sized,
     {
@@ -610,6 +617,20 @@ impl workspace::Item for ProjectDiagnosticsEditor {
     fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
         self.editor.update(cx, |editor, cx| editor.deactivated(cx));
     }
+
+    fn serialized_item_kind() -> Option<&'static str> {
+        Some("diagnostics")
+    }
+
+    fn deserialize(
+        project: ModelHandle<Project>,
+        workspace: WeakViewHandle<Workspace>,
+        _workspace_id: workspace::WorkspaceId,
+        _item_id: workspace::ItemId,
+        cx: &mut ViewContext<Pane>,
+    ) -> Task<Result<ViewHandle<Self>>> {
+        Task::ready(Ok(cx.add_view(|cx| Self::new(project, workspace, cx))))
+    }
 }
 
 fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
@@ -781,8 +802,15 @@ mod tests {
             .await;
 
         let project = Project::test(app_state.fs.clone(), ["/test".as_ref()], cx).await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(
+                Default::default(),
+                0,
+                project.clone(),
+                |_, _| unimplemented!(),
+                cx,
+            )
+        });
 
         // Create some diagnostics
         project.update(cx, |project, cx| {

crates/diagnostics/src/items.rs 🔗

@@ -7,7 +7,7 @@ use gpui::{
 use language::Diagnostic;
 use project::Project;
 use settings::Settings;
-use workspace::StatusItemView;
+use workspace::{item::ItemHandle, StatusItemView};
 
 pub struct DiagnosticIndicator {
     summary: project::DiagnosticSummary,
@@ -219,7 +219,7 @@ impl View for DiagnosticIndicator {
 impl StatusItemView for DiagnosticIndicator {
     fn set_active_pane_item(
         &mut self,
-        active_pane_item: Option<&dyn workspace::ItemHandle>,
+        active_pane_item: Option<&dyn ItemHandle>,
         cx: &mut ViewContext<Self>,
     ) {
         if let Some(editor) = active_pane_item.and_then(|item| item.downcast::<Editor>()) {

crates/editor/Cargo.toml 🔗

@@ -23,6 +23,7 @@ test-support = [
 drag_and_drop = { path = "../drag_and_drop" }
 text = { path = "../text" }
 clock = { path = "../clock" }
+db = { path = "../db" }
 collections = { path = "../collections" }
 context_menu = { path = "../context_menu" }
 fuzzy = { path = "../fuzzy" }
@@ -37,6 +38,7 @@ snippet = { path = "../snippet" }
 sum_tree = { path = "../sum_tree" }
 theme = { path = "../theme" }
 util = { path = "../util" }
+sqlez = { path = "../sqlez" }
 workspace = { path = "../workspace" }
 aho-corasick = "0.7"
 anyhow = "1.0"

crates/editor/src/editor.rs 🔗

@@ -9,6 +9,7 @@ mod link_go_to_definition;
 mod mouse_context_menu;
 pub mod movement;
 mod multi_buffer;
+mod persistence;
 pub mod selections_collection;
 
 #[cfg(test)]
@@ -80,7 +81,7 @@ use std::{
 pub use sum_tree::Bias;
 use theme::{DiagnosticStyle, Theme};
 use util::{post_inc, ResultExt, TryFutureExt};
-use workspace::{ItemNavHistory, Workspace};
+use workspace::{ItemNavHistory, Workspace, WorkspaceId};
 
 use crate::git::diff_hunk_to_display;
 
@@ -372,6 +373,7 @@ pub fn init(cx: &mut MutableAppContext) {
 
     workspace::register_project_item::<Editor>(cx);
     workspace::register_followable_item::<Editor>(cx);
+    workspace::register_deserializable_item::<Editor>(cx);
 }
 
 trait InvalidationRegion {
@@ -582,6 +584,7 @@ pub struct Editor {
     pending_rename: Option<RenameState>,
     searchable: bool,
     cursor_shape: CursorShape,
+    workspace_id: Option<WorkspaceId>,
     keymap_context_layers: BTreeMap<TypeId, gpui::keymap::Context>,
     input_enabled: bool,
     leader_replica_id: Option<u16>,
@@ -1235,6 +1238,7 @@ impl Editor {
             searchable: true,
             override_text_style: None,
             cursor_shape: Default::default(),
+            workspace_id: None,
             keymap_context_layers: Default::default(),
             input_enabled: true,
             leader_replica_id: None,

crates/editor/src/editor_tests.rs 🔗

@@ -22,7 +22,10 @@ use util::{
     assert_set_eq,
     test::{marked_text_ranges, marked_text_ranges_by, sample_text, TextRangeMarker},
 };
-use workspace::{FollowableItem, ItemHandle, NavigationEntry, Pane};
+use workspace::{
+    item::{FollowableItem, ItemHandle},
+    NavigationEntry, Pane,
+};
 
 #[gpui::test]
 fn test_edit_events(cx: &mut MutableAppContext) {
@@ -475,7 +478,7 @@ fn test_clone(cx: &mut gpui::MutableAppContext) {
 fn test_navigation_history(cx: &mut gpui::MutableAppContext) {
     cx.set_global(Settings::test(cx));
     cx.set_global(DragAndDrop::<Workspace>::default());
-    use workspace::Item;
+    use workspace::item::Item;
     let (_, pane) = cx.add_window(Default::default(), |cx| Pane::new(None, cx));
     let buffer = MultiBuffer::build_simple(&sample_text(300, 5, 'a'), cx);
 

crates/editor/src/items.rs 🔗

@@ -1,13 +1,8 @@
-use crate::{
-    display_map::ToDisplayPoint, link_go_to_definition::hide_link_definition,
-    movement::surrounding_word, Anchor, Autoscroll, Editor, Event, ExcerptId, MultiBuffer,
-    MultiBufferSnapshot, NavigationData, ToPoint as _, FORMAT_TIMEOUT,
-};
-use anyhow::{anyhow, Result};
+use anyhow::{anyhow, Context, Result};
 use futures::FutureExt;
 use gpui::{
     elements::*, geometry::vector::vec2f, AppContext, Entity, ModelHandle, MutableAppContext,
-    RenderContext, Subscription, Task, View, ViewContext, ViewHandle,
+    RenderContext, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
 };
 use language::{Bias, Buffer, File as _, OffsetRangeExt, Point, SelectionGoal};
 use project::{File, FormatTrigger, Project, ProjectEntryId, ProjectPath};
@@ -22,11 +17,17 @@ use std::{
     path::{Path, PathBuf},
 };
 use text::Selection;
-use util::TryFutureExt;
+use util::{ResultExt, TryFutureExt};
 use workspace::{
+    item::{FollowableItem, Item, ItemEvent, ItemHandle, ProjectItem},
     searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
-    FollowableItem, Item, ItemEvent, ItemHandle, ItemNavHistory, ProjectItem, StatusItemView,
-    ToolbarItemLocation,
+    ItemId, ItemNavHistory, Pane, StatusItemView, ToolbarItemLocation, Workspace, WorkspaceId,
+};
+
+use crate::{
+    display_map::ToDisplayPoint, link_go_to_definition::hide_link_definition,
+    movement::surrounding_word, persistence::DB, Anchor, Autoscroll, Editor, Event, ExcerptId,
+    MultiBuffer, MultiBufferSnapshot, NavigationData, ToPoint as _, FORMAT_TIMEOUT,
 };
 
 pub const MAX_TAB_TITLE_LEN: usize = 24;
@@ -367,7 +368,7 @@ impl Item for Editor {
         self.buffer.read(cx).is_singleton()
     }
 
-    fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self>
+    fn clone_on_split(&self, _workspace_id: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<Self>
     where
         Self: Sized,
     {
@@ -490,7 +491,7 @@ impl Item for Editor {
         Task::ready(Ok(()))
     }
 
-    fn to_item_events(event: &Self::Event) -> Vec<workspace::ItemEvent> {
+    fn to_item_events(event: &Self::Event) -> Vec<ItemEvent> {
         let mut result = Vec::new();
         match event {
             Event::Closed => result.push(ItemEvent::CloseItem),
@@ -552,6 +553,87 @@ impl Item for Editor {
         }));
         Some(breadcrumbs)
     }
+
+    fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
+        let workspace_id = workspace.database_id();
+        let item_id = cx.view_id();
+
+        fn serialize(
+            buffer: ModelHandle<Buffer>,
+            workspace_id: WorkspaceId,
+            item_id: ItemId,
+            cx: &mut MutableAppContext,
+        ) {
+            if let Some(file) = buffer.read(cx).file().and_then(|file| file.as_local()) {
+                let path = file.abs_path(cx);
+
+                cx.background()
+                    .spawn(async move {
+                        DB.save_path(item_id, workspace_id, path.clone())
+                            .await
+                            .log_err()
+                    })
+                    .detach();
+            }
+        }
+
+        if let Some(buffer) = self.buffer().read(cx).as_singleton() {
+            serialize(buffer.clone(), workspace_id, item_id, cx);
+
+            cx.subscribe(&buffer, |this, buffer, event, cx| {
+                if let Some(workspace_id) = this.workspace_id {
+                    if let language::Event::FileHandleChanged = event {
+                        serialize(buffer, workspace_id, cx.view_id(), cx);
+                    }
+                }
+            })
+            .detach();
+        }
+    }
+
+    fn serialized_item_kind() -> Option<&'static str> {
+        Some("Editor")
+    }
+
+    fn deserialize(
+        project: ModelHandle<Project>,
+        _workspace: WeakViewHandle<Workspace>,
+        workspace_id: workspace::WorkspaceId,
+        item_id: ItemId,
+        cx: &mut ViewContext<Pane>,
+    ) -> Task<Result<ViewHandle<Self>>> {
+        let project_item: Result<_> = project.update(cx, |project, cx| {
+            // Look up the path with this key associated, create a self with that path
+            let path = DB
+                .get_path(item_id, workspace_id)?
+                .context("No path stored for this editor")?;
+
+            let (worktree, path) = project
+                .find_local_worktree(&path, cx)
+                .with_context(|| format!("No worktree for path: {path:?}"))?;
+            let project_path = ProjectPath {
+                worktree_id: worktree.read(cx).id(),
+                path: path.into(),
+            };
+
+            Ok(project.open_path(project_path, cx))
+        });
+
+        project_item
+            .map(|project_item| {
+                cx.spawn(|pane, mut cx| async move {
+                    let (_, project_item) = project_item.await?;
+                    let buffer = project_item
+                        .downcast::<Buffer>()
+                        .context("Project item at stored path was not a buffer")?;
+
+                    Ok(cx.update(|cx| {
+                        cx.add_view(pane, |cx| Editor::for_buffer(buffer, Some(project), cx))
+                    }))
+                })
+            })
+            .unwrap_or_else(|error| Task::ready(Err(error)))
+    }
 }
 
 impl ProjectItem for Editor {

crates/editor/src/persistence.rs 🔗

@@ -0,0 +1,36 @@
+use std::path::PathBuf;
+
+use db::sqlez_macros::sql;
+use db::{define_connection, query};
+use workspace::{ItemId, WorkspaceDb, WorkspaceId};
+
+define_connection!(
+    pub static ref DB: EditorDb<WorkspaceDb> =
+        &[sql! (
+            CREATE TABLE editors(
+                item_id INTEGER NOT NULL,
+                workspace_id INTEGER NOT NULL,
+                path BLOB NOT NULL,
+                PRIMARY KEY(item_id, workspace_id),
+                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+                ON DELETE CASCADE
+                ON UPDATE CASCADE
+        ) STRICT;
+    )];
+);
+
+impl EditorDb {
+    query! {
+        pub fn get_path(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<PathBuf>> {
+            SELECT path FROM editors
+            WHERE item_id = ? AND workspace_id = ?
+        }
+    }
+
+    query! {
+        pub async fn save_path(item_id: ItemId, workspace_id: WorkspaceId, path: PathBuf) -> Result<()> {
+            INSERT OR REPLACE INTO editors(item_id, workspace_id, path)
+            VALUES (?, ?, ?)
+        }
+    }
+}

crates/editor/src/test/editor_lsp_test_context.rs 🔗

@@ -63,8 +63,15 @@ impl<'a> EditorLspTestContext<'a> {
             .insert_tree("/root", json!({ "dir": { file_name: "" }}))
             .await;
 
-        let (window_id, workspace) =
-            cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
+        let (window_id, workspace) = cx.add_window(|cx| {
+            Workspace::new(
+                Default::default(),
+                0,
+                project.clone(),
+                |_, _| unimplemented!(),
+                cx,
+            )
+        });
         project
             .update(cx, |project, cx| {
                 project.find_or_create_local_worktree("/root", true, cx)

crates/file_finder/src/file_finder.rs 🔗

@@ -316,8 +316,9 @@ mod tests {
             .await;
 
         let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
-        let (window_id, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (window_id, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
         cx.dispatch_action(window_id, Toggle);
 
         let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
@@ -371,8 +372,9 @@ mod tests {
             .await;
 
         let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
         let (_, finder) =
             cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
 
@@ -446,8 +448,9 @@ mod tests {
             cx,
         )
         .await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
         let (_, finder) =
             cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
         finder
@@ -471,8 +474,9 @@ mod tests {
             cx,
         )
         .await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
         let (_, finder) =
             cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
 
@@ -524,8 +528,9 @@ mod tests {
             cx,
         )
         .await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
         let (_, finder) =
             cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
 
@@ -563,8 +568,9 @@ mod tests {
             .await;
 
         let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
         let (_, finder) =
             cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
         finder

crates/gpui/Cargo.toml 🔗

@@ -17,6 +17,7 @@ collections = { path = "../collections" }
 gpui_macros = { path = "../gpui_macros" }
 util = { path = "../util" }
 sum_tree = { path = "../sum_tree" }
+sqlez = { path = "../sqlez" }
 async-task = "4.0.3"
 backtrace = { version = "0.3", optional = true }
 ctor = "0.1"

crates/gpui/grammars/context-predicate/bindings/node/binding.cc 🔗

@@ -1,10 +1,10 @@
+#include "nan.h"
 #include "tree_sitter/parser.h"
 #include <node.h>
-#include "nan.h"
 
 using namespace v8;
 
-extern "C" TSLanguage * tree_sitter_context_predicate();
+extern "C" TSLanguage *tree_sitter_context_predicate();
 
 namespace {
 
@@ -16,13 +16,15 @@ void Init(Local<Object> exports, Local<Object> module) {
   tpl->InstanceTemplate()->SetInternalFieldCount(1);
 
   Local<Function> constructor = Nan::GetFunction(tpl).ToLocalChecked();
-  Local<Object> instance = constructor->NewInstance(Nan::GetCurrentContext()).ToLocalChecked();
+  Local<Object> instance =
+      constructor->NewInstance(Nan::GetCurrentContext()).ToLocalChecked();
   Nan::SetInternalFieldPointer(instance, 0, tree_sitter_context_predicate());
 
-  Nan::Set(instance, Nan::New("name").ToLocalChecked(), Nan::New("context_predicate").ToLocalChecked());
+  Nan::Set(instance, Nan::New("name").ToLocalChecked(),
+           Nan::New("context_predicate").ToLocalChecked());
   Nan::Set(module, Nan::New("exports").ToLocalChecked(), instance);
 }
 
 NODE_MODULE(tree_sitter_context_predicate_binding, Init)
 
-}  // namespace
+} // namespace

crates/gpui/src/presenter.rs 🔗

@@ -17,10 +17,15 @@ use crate::{
     SceneBuilder, UpgradeModelHandle, UpgradeViewHandle, View, ViewHandle, WeakModelHandle,
     WeakViewHandle,
 };
+use anyhow::bail;
 use collections::{HashMap, HashSet};
 use pathfinder_geometry::vector::{vec2f, Vector2F};
 use serde_json::json;
 use smallvec::SmallVec;
+use sqlez::{
+    bindable::{Bind, Column},
+    statement::Statement,
+};
 use std::{
     marker::PhantomData,
     ops::{Deref, DerefMut, Range},
@@ -863,8 +868,9 @@ pub struct DebugContext<'a> {
     pub app: &'a AppContext,
 }
 
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
 pub enum Axis {
+    #[default]
     Horizontal,
     Vertical,
 }
@@ -894,6 +900,31 @@ impl ToJson for Axis {
     }
 }
 
+impl Bind for Axis {
+    fn bind(&self, statement: &Statement, start_index: i32) -> anyhow::Result<i32> {
+        match self {
+            Axis::Horizontal => "Horizontal",
+            Axis::Vertical => "Vertical",
+        }
+        .bind(statement, start_index)
+    }
+}
+
+impl Column for Axis {
+    fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
+        String::column(statement, start_index).and_then(|(axis_text, next_index)| {
+            Ok((
+                match axis_text.as_str() {
+                    "Horizontal" => Axis::Horizontal,
+                    "Vertical" => Axis::Vertical,
+                    _ => bail!("Stored serialized item kind is incorrect"),
+                },
+                next_index,
+            ))
+        })
+    }
+}
+
 pub trait Vector2FExt {
     fn along(self, axis: Axis) -> f32;
 }

crates/journal/src/journal.rs 🔗

@@ -115,7 +115,7 @@ mod tests {
 
         #[test]
         fn test_heading_entry_defaults_to_hour_12() {
-            let naive_time = NaiveTime::from_hms_milli(15, 0, 0, 0);
+            let naive_time = NaiveTime::from_hms_milli_opt(15, 0, 0, 0).unwrap();
             let actual_heading_entry = heading_entry(naive_time, &None);
             let expected_heading_entry = "# 3:00 PM";
 
@@ -124,7 +124,7 @@ mod tests {
 
         #[test]
         fn test_heading_entry_is_hour_12() {
-            let naive_time = NaiveTime::from_hms_milli(15, 0, 0, 0);
+            let naive_time = NaiveTime::from_hms_milli_opt(15, 0, 0, 0).unwrap();
             let actual_heading_entry = heading_entry(naive_time, &Some(HourFormat::Hour12));
             let expected_heading_entry = "# 3:00 PM";
 
@@ -133,7 +133,7 @@ mod tests {
 
         #[test]
         fn test_heading_entry_is_hour_24() {
-            let naive_time = NaiveTime::from_hms_milli(15, 0, 0, 0);
+            let naive_time = NaiveTime::from_hms_milli_opt(15, 0, 0, 0).unwrap();
             let actual_heading_entry = heading_entry(naive_time, &Some(HourFormat::Hour24));
             let expected_heading_entry = "# 15:00";
 

crates/project/src/project.rs 🔗

@@ -65,7 +65,6 @@ use std::{
 use thiserror::Error;
 use util::{defer, post_inc, ResultExt, TryFutureExt as _};
 
-pub use db::Db;
 pub use fs::*;
 pub use worktree::*;
 
@@ -758,6 +757,7 @@ impl Project {
         &self.collaborators
     }
 
+    /// Collect all worktrees, including ones that don't appear in the project panel
     pub fn worktrees<'a>(
         &'a self,
         cx: &'a AppContext,
@@ -767,6 +767,7 @@ impl Project {
             .filter_map(move |worktree| worktree.upgrade(cx))
     }
 
+    /// Collect all user-visible worktrees, the ones that appear in the project panel
     pub fn visible_worktrees<'a>(
         &'a self,
         cx: &'a AppContext,

crates/project_panel/src/project_panel.rs 🔗

@@ -1393,8 +1393,15 @@ mod tests {
         .await;
 
         let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(
+                Default::default(),
+                0,
+                project.clone(),
+                |_, _| unimplemented!(),
+                cx,
+            )
+        });
         let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
         assert_eq!(
             visible_entries_as_strings(&panel, 0..50, cx),
@@ -1486,8 +1493,15 @@ mod tests {
         .await;
 
         let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(
+                Default::default(),
+                0,
+                project.clone(),
+                |_, _| unimplemented!(),
+                cx,
+            )
+        });
         let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
 
         select_path(&panel, "root1", cx);

crates/search/src/buffer_search.rs 🔗

@@ -14,8 +14,9 @@ use serde::Deserialize;
 use settings::Settings;
 use std::{any::Any, sync::Arc};
 use workspace::{
+    item::ItemHandle,
     searchable::{Direction, SearchEvent, SearchableItemHandle, WeakSearchableItemHandle},
-    ItemHandle, Pane, ToolbarItemLocation, ToolbarItemView,
+    Pane, ToolbarItemLocation, ToolbarItemView,
 };
 
 #[derive(Clone, Deserialize, PartialEq)]

crates/search/src/project_search.rs 🔗

@@ -24,9 +24,9 @@ use std::{
 };
 use util::ResultExt as _;
 use workspace::{
+    item::{Item, ItemEvent, ItemHandle},
     searchable::{Direction, SearchableItem, SearchableItemHandle},
-    Item, ItemEvent, ItemHandle, ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView,
-    Workspace,
+    ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId,
 };
 
 actions!(project_search, [SearchInNew, ToggleFocus]);
@@ -315,7 +315,7 @@ impl Item for ProjectSearchView {
             .update(cx, |editor, cx| editor.reload(project, cx))
     }
 
-    fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self>
+    fn clone_on_split(&self, _workspace_id: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<Self>
     where
         Self: Sized,
     {
@@ -353,6 +353,20 @@ impl Item for ProjectSearchView {
     fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<ElementBox>> {
         self.results_editor.breadcrumbs(theme, cx)
     }
+
+    fn serialized_item_kind() -> Option<&'static str> {
+        None
+    }
+
+    fn deserialize(
+        _project: ModelHandle<Project>,
+        _workspace: WeakViewHandle<Workspace>,
+        _workspace_id: workspace::WorkspaceId,
+        _item_id: workspace::ItemId,
+        _cx: &mut ViewContext<Pane>,
+    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
+        unimplemented!()
+    }
 }
 
 impl ProjectSearchView {
@@ -893,7 +907,7 @@ impl View for ProjectSearchBar {
 impl ToolbarItemView for ProjectSearchBar {
     fn set_active_pane_item(
         &mut self,
-        active_pane_item: Option<&dyn workspace::ItemHandle>,
+        active_pane_item: Option<&dyn ItemHandle>,
         cx: &mut ViewContext<Self>,
     ) -> ToolbarItemLocation {
         cx.notify();

crates/settings/Cargo.toml 🔗

@@ -14,6 +14,7 @@ test-support = []
 assets = { path = "../assets" }
 collections = { path = "../collections" }
 gpui = { path = "../gpui" }
+sqlez = { path = "../sqlez" }
 fs = { path = "../fs" }
 anyhow = "1.0.38"
 futures = "0.3"

crates/settings/src/settings.rs 🔗

@@ -2,7 +2,7 @@ mod keymap_file;
 pub mod settings_file;
 pub mod watched_json;
 
-use anyhow::Result;
+use anyhow::{bail, Result};
 use gpui::{
     font_cache::{FamilyId, FontCache},
     AssetSource,
@@ -14,6 +14,10 @@ use schemars::{
 };
 use serde::{de::DeserializeOwned, Deserialize, Serialize};
 use serde_json::Value;
+use sqlez::{
+    bindable::{Bind, Column},
+    statement::Statement,
+};
 use std::{collections::HashMap, fmt::Write as _, num::NonZeroU32, str, sync::Arc};
 use theme::{Theme, ThemeRegistry};
 use tree_sitter::Query;
@@ -55,24 +59,6 @@ pub struct FeatureFlags {
     pub experimental_themes: bool,
 }
 
-#[derive(Copy, Clone, PartialEq, Eq, Default)]
-pub enum ReleaseChannel {
-    #[default]
-    Dev,
-    Preview,
-    Stable,
-}
-
-impl ReleaseChannel {
-    pub fn name(&self) -> &'static str {
-        match self {
-            ReleaseChannel::Dev => "Zed Dev",
-            ReleaseChannel::Preview => "Zed Preview",
-            ReleaseChannel::Stable => "Zed",
-        }
-    }
-}
-
 impl FeatureFlags {
     pub fn keymap_files(&self) -> Vec<&'static str> {
         vec![]
@@ -244,6 +230,33 @@ pub enum DockAnchor {
     Expanded,
 }
 
+impl Bind for DockAnchor {
+    fn bind(&self, statement: &Statement, start_index: i32) -> anyhow::Result<i32> {
+        match self {
+            DockAnchor::Bottom => "Bottom",
+            DockAnchor::Right => "Right",
+            DockAnchor::Expanded => "Expanded",
+        }
+        .bind(statement, start_index)
+    }
+}
+
+impl Column for DockAnchor {
+    fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
+        String::column(statement, start_index).and_then(|(anchor_text, next_index)| {
+            Ok((
+                match anchor_text.as_ref() {
+                    "Bottom" => DockAnchor::Bottom,
+                    "Right" => DockAnchor::Right,
+                    "Expanded" => DockAnchor::Expanded,
+                    _ => bail!("Stored dock anchor is incorrect"),
+                },
+                next_index,
+            ))
+        })
+    }
+}
+
 #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
 pub struct SettingsFileContent {
     pub experiments: Option<FeatureFlags>,

crates/sqlez/Cargo.lock 🔗

@@ -0,0 +1,150 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "addr2line"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ecd88a8c8378ca913a680cd98f0f13ac67383d35993f86c90a70e3f137816b"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
+
+[[package]]
+name = "anyhow"
+version = "1.0.66"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6"
+dependencies = [
+ "backtrace",
+]
+
+[[package]]
+name = "backtrace"
+version = "0.3.66"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab84319d616cfb654d03394f38ab7e6f0919e181b1b57e1fd15e7fb4077d9a7"
+dependencies = [
+ "addr2line",
+ "cc",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+]
+
+[[package]]
+name = "cc"
+version = "1.0.73"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "gimli"
+version = "0.26.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22030e2c5a68ec659fde1e949a745124b48e6fa8b045b7ed5bd1fe4ccc5c4e5d"
+
+[[package]]
+name = "indoc"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adab1eaa3408fb7f0c777a73e7465fd5656136fc93b670eb6df3c88c2c1344e3"
+
+[[package]]
+name = "libc"
+version = "0.2.137"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89"
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29f835d03d717946d28b1d1ed632eb6f0e24a299388ee623d0c23118d3e8a7fa"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "memchr"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96590ba8f175222643a85693f33d26e9c8a015f599c216509b1a6894af675d34"
+dependencies = [
+ "adler",
+]
+
+[[package]]
+name = "object"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21158b2c33aa6d4561f1c0a6ea283ca92bc54802a93b263e910746d679a7eb53"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e82dad04139b71a90c080c8463fe0dc7902db5192d939bd0950f074d014339e1"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160"
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342"
+
+[[package]]
+name = "sqlez"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "indoc",
+ "libsqlite3-sys",
+ "thread_local",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"

crates/sqlez/Cargo.toml 🔗

@@ -0,0 +1,16 @@
+[package]
+name = "sqlez"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+anyhow = { version = "1.0.38", features = ["backtrace"] }
+indoc = "1.0.7"
+libsqlite3-sys = { version = "0.24", features = ["bundled"] }
+smol = "1.2"
+thread_local = "1.1.4"
+lazy_static = "1.4"
+parking_lot = "0.11.1"
+futures = "0.3"

crates/sqlez/src/bindable.rs 🔗

@@ -0,0 +1,352 @@
+use std::{
+    ffi::OsStr,
+    os::unix::prelude::OsStrExt,
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+
+use anyhow::{Context, Result};
+
+use crate::statement::{SqlType, Statement};
+
+pub trait Bind {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32>;
+}
+
+pub trait Column: Sized {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)>;
+}
+
+impl Bind for bool {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        statement
+            .bind(self.then_some(1).unwrap_or(0), start_index)
+            .with_context(|| format!("Failed to bind bool at index {start_index}"))
+    }
+}
+
+impl Column for bool {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        i32::column(statement, start_index)
+            .map(|(i, next_index)| (i != 0, next_index))
+            .with_context(|| format!("Failed to read bool at index {start_index}"))
+    }
+}
+
+impl Bind for &[u8] {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        statement
+            .bind_blob(start_index, self)
+            .with_context(|| format!("Failed to bind &[u8] at index {start_index}"))?;
+        Ok(start_index + 1)
+    }
+}
+
+impl<const C: usize> Bind for &[u8; C] {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        statement
+            .bind_blob(start_index, self.as_slice())
+            .with_context(|| format!("Failed to bind &[u8; C] at index {start_index}"))?;
+        Ok(start_index + 1)
+    }
+}
+
+impl Bind for Vec<u8> {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        statement
+            .bind_blob(start_index, self)
+            .with_context(|| format!("Failed to bind Vec<u8> at index {start_index}"))?;
+        Ok(start_index + 1)
+    }
+}
+
+impl Column for Vec<u8> {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let result = statement
+            .column_blob(start_index)
+            .with_context(|| format!("Failed to read Vec<u8> at index {start_index}"))?;
+
+        Ok((Vec::from(result), start_index + 1))
+    }
+}
+
+impl Bind for f64 {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        statement
+            .bind_double(start_index, *self)
+            .with_context(|| format!("Failed to bind f64 at index {start_index}"))?;
+        Ok(start_index + 1)
+    }
+}
+
+impl Column for f64 {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let result = statement
+            .column_double(start_index)
+            .with_context(|| format!("Failed to parse f64 at index {start_index}"))?;
+
+        Ok((result, start_index + 1))
+    }
+}
+
+impl Bind for i32 {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        statement
+            .bind_int(start_index, *self)
+            .with_context(|| format!("Failed to bind i32 at index {start_index}"))?;
+
+        Ok(start_index + 1)
+    }
+}
+
+impl Column for i32 {
+    fn column<'a>(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let result = statement.column_int(start_index)?;
+        Ok((result, start_index + 1))
+    }
+}
+
+impl Bind for i64 {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        statement
+            .bind_int64(start_index, *self)
+            .with_context(|| format!("Failed to bind i64 at index {start_index}"))?;
+        Ok(start_index + 1)
+    }
+}
+
+impl Column for i64 {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let result = statement.column_int64(start_index)?;
+        Ok((result, start_index + 1))
+    }
+}
+
+impl Bind for usize {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        (*self as i64)
+            .bind(statement, start_index)
+            .with_context(|| format!("Failed to bind usize at index {start_index}"))
+    }
+}
+
+impl Column for usize {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let result = statement.column_int64(start_index)?;
+        Ok((result as usize, start_index + 1))
+    }
+}
+
+impl Bind for &str {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        statement.bind_text(start_index, self)?;
+        Ok(start_index + 1)
+    }
+}
+
+impl Bind for Arc<str> {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        statement.bind_text(start_index, self.as_ref())?;
+        Ok(start_index + 1)
+    }
+}
+
+impl Bind for String {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        statement.bind_text(start_index, self)?;
+        Ok(start_index + 1)
+    }
+}
+
+impl Column for Arc<str> {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let result = statement.column_text(start_index)?;
+        Ok((Arc::from(result), start_index + 1))
+    }
+}
+
+impl Column for String {
+    fn column<'a>(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let result = statement.column_text(start_index)?;
+        Ok((result.to_owned(), start_index + 1))
+    }
+}
+
+impl<T: Bind> Bind for Option<T> {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        if let Some(this) = self {
+            this.bind(statement, start_index)
+        } else {
+            statement.bind_null(start_index)?;
+            Ok(start_index + 1)
+        }
+    }
+}
+
+impl<T: Column> Column for Option<T> {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        if let SqlType::Null = statement.column_type(start_index)? {
+            Ok((None, start_index + 1))
+        } else {
+            T::column(statement, start_index).map(|(result, next_index)| (Some(result), next_index))
+        }
+    }
+}
+
+impl<T: Bind, const COUNT: usize> Bind for [T; COUNT] {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        let mut current_index = start_index;
+        for binding in self {
+            current_index = binding.bind(statement, current_index)?
+        }
+
+        Ok(current_index)
+    }
+}
+
+impl<T: Column + Default + Copy, const COUNT: usize> Column for [T; COUNT] {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let mut array = [Default::default(); COUNT];
+        let mut current_index = start_index;
+        for i in 0..COUNT {
+            (array[i], current_index) = T::column(statement, current_index)?;
+        }
+        Ok((array, current_index))
+    }
+}
+
+impl<T: Bind> Bind for Vec<T> {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        let mut current_index = start_index;
+        for binding in self.iter() {
+            current_index = binding.bind(statement, current_index)?
+        }
+
+        Ok(current_index)
+    }
+}
+
+impl<T: Bind> Bind for &[T] {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        let mut current_index = start_index;
+        for binding in *self {
+            current_index = binding.bind(statement, current_index)?
+        }
+
+        Ok(current_index)
+    }
+}
+
+impl Bind for &Path {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        self.as_os_str().as_bytes().bind(statement, start_index)
+    }
+}
+
+impl Bind for Arc<Path> {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        self.as_ref().bind(statement, start_index)
+    }
+}
+
+impl Bind for PathBuf {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        (self.as_ref() as &Path).bind(statement, start_index)
+    }
+}
+
+impl Column for PathBuf {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let blob = statement.column_blob(start_index)?;
+
+        Ok((
+            PathBuf::from(OsStr::from_bytes(blob).to_owned()),
+            start_index + 1,
+        ))
+    }
+}
+
+/// Unit impls do nothing. This simplifies query macros
+impl Bind for () {
+    fn bind(&self, _statement: &Statement, start_index: i32) -> Result<i32> {
+        Ok(start_index)
+    }
+}
+
+impl Column for () {
+    fn column(_statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        Ok(((), start_index))
+    }
+}
+
+impl<T1: Bind, T2: Bind> Bind for (T1, T2) {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        let next_index = self.0.bind(statement, start_index)?;
+        self.1.bind(statement, next_index)
+    }
+}
+
+impl<T1: Column, T2: Column> Column for (T1, T2) {
+    fn column<'a>(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let (first, next_index) = T1::column(statement, start_index)?;
+        let (second, next_index) = T2::column(statement, next_index)?;
+        Ok(((first, second), next_index))
+    }
+}
+
+impl<T1: Bind, T2: Bind, T3: Bind> Bind for (T1, T2, T3) {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        let next_index = self.0.bind(statement, start_index)?;
+        let next_index = self.1.bind(statement, next_index)?;
+        self.2.bind(statement, next_index)
+    }
+}
+
+impl<T1: Column, T2: Column, T3: Column> Column for (T1, T2, T3) {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let (first, next_index) = T1::column(statement, start_index)?;
+        let (second, next_index) = T2::column(statement, next_index)?;
+        let (third, next_index) = T3::column(statement, next_index)?;
+        Ok(((first, second, third), next_index))
+    }
+}
+
+impl<T1: Bind, T2: Bind, T3: Bind, T4: Bind> Bind for (T1, T2, T3, T4) {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        let next_index = self.0.bind(statement, start_index)?;
+        let next_index = self.1.bind(statement, next_index)?;
+        let next_index = self.2.bind(statement, next_index)?;
+        self.3.bind(statement, next_index)
+    }
+}
+
+impl<T1: Column, T2: Column, T3: Column, T4: Column> Column for (T1, T2, T3, T4) {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let (first, next_index) = T1::column(statement, start_index)?;
+        let (second, next_index) = T2::column(statement, next_index)?;
+        let (third, next_index) = T3::column(statement, next_index)?;
+        let (fourth, next_index) = T4::column(statement, next_index)?;
+        Ok(((first, second, third, fourth), next_index))
+    }
+}
+
+impl<T1: Bind, T2: Bind, T3: Bind, T4: Bind, T5: Bind> Bind for (T1, T2, T3, T4, T5) {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        let next_index = self.0.bind(statement, start_index)?;
+        let next_index = self.1.bind(statement, next_index)?;
+        let next_index = self.2.bind(statement, next_index)?;
+        let next_index = self.3.bind(statement, next_index)?;
+        self.4.bind(statement, next_index)
+    }
+}
+
+impl<T1: Column, T2: Column, T3: Column, T4: Column, T5: Column> Column for (T1, T2, T3, T4, T5) {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let (first, next_index) = T1::column(statement, start_index)?;
+        let (second, next_index) = T2::column(statement, next_index)?;
+        let (third, next_index) = T3::column(statement, next_index)?;
+        let (fourth, next_index) = T4::column(statement, next_index)?;
+        let (fifth, next_index) = T5::column(statement, next_index)?;
+        Ok(((first, second, third, fourth, fifth), next_index))
+    }
+}

crates/sqlez/src/connection.rs 🔗

@@ -0,0 +1,334 @@
+use std::{
+    cell::RefCell,
+    ffi::{CStr, CString},
+    marker::PhantomData,
+    path::Path,
+    ptr,
+};
+
+use anyhow::{anyhow, Result};
+use libsqlite3_sys::*;
+
+pub struct Connection {
+    pub(crate) sqlite3: *mut sqlite3,
+    persistent: bool,
+    pub(crate) write: RefCell<bool>,
+    _sqlite: PhantomData<sqlite3>,
+}
+unsafe impl Send for Connection {}
+
+impl Connection {
+    pub(crate) fn open(uri: &str, persistent: bool) -> Result<Self> {
+        let mut connection = Self {
+            sqlite3: 0 as *mut _,
+            persistent,
+            write: RefCell::new(true),
+            _sqlite: PhantomData,
+        };
+
+        let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE;
+        unsafe {
+            sqlite3_open_v2(
+                CString::new(uri)?.as_ptr(),
+                &mut connection.sqlite3,
+                flags,
+                0 as *const _,
+            );
+
+            // Turn on extended error codes
+            sqlite3_extended_result_codes(connection.sqlite3, 1);
+
+            connection.last_error()?;
+        }
+
+        Ok(connection)
+    }
+
+    /// Attempts to open the database at uri. If it fails, a shared memory db will be opened
+    /// instead.
+    pub fn open_file(uri: &str) -> Self {
+        Self::open(uri, true).unwrap_or_else(|_| Self::open_memory(Some(uri)))
+    }
+
+    pub fn open_memory(uri: Option<&str>) -> Self {
+        let in_memory_path = if let Some(uri) = uri {
+            format!("file:{}?mode=memory&cache=shared", uri)
+        } else {
+            ":memory:".to_string()
+        };
+
+        Self::open(&in_memory_path, false).expect("Could not create fallback in memory db")
+    }
+
+    pub fn persistent(&self) -> bool {
+        self.persistent
+    }
+
+    pub fn can_write(&self) -> bool {
+        *self.write.borrow()
+    }
+
+    pub fn backup_main(&self, destination: &Connection) -> Result<()> {
+        unsafe {
+            let backup = sqlite3_backup_init(
+                destination.sqlite3,
+                CString::new("main")?.as_ptr(),
+                self.sqlite3,
+                CString::new("main")?.as_ptr(),
+            );
+            sqlite3_backup_step(backup, -1);
+            sqlite3_backup_finish(backup);
+            destination.last_error()
+        }
+    }
+
+    pub fn backup_main_to(&self, destination: impl AsRef<Path>) -> Result<()> {
+        let destination = Self::open_file(destination.as_ref().to_string_lossy().as_ref());
+        self.backup_main(&destination)
+    }
+
+    pub fn sql_has_syntax_error(&self, sql: &str) -> Option<(String, usize)> {
+        let sql = CString::new(sql).unwrap();
+        let mut remaining_sql = sql.as_c_str();
+        let sql_start = remaining_sql.as_ptr();
+
+        unsafe {
+            while {
+                let remaining_sql_str = remaining_sql.to_str().unwrap().trim();
+                remaining_sql_str != ";" && !remaining_sql_str.is_empty()
+            } {
+                let mut raw_statement = 0 as *mut sqlite3_stmt;
+                let mut remaining_sql_ptr = ptr::null();
+                sqlite3_prepare_v2(
+                    self.sqlite3,
+                    remaining_sql.as_ptr(),
+                    -1,
+                    &mut raw_statement,
+                    &mut remaining_sql_ptr,
+                );
+
+                let res = sqlite3_errcode(self.sqlite3);
+                let offset = sqlite3_error_offset(self.sqlite3);
+                let message = sqlite3_errmsg(self.sqlite3);
+
+                sqlite3_finalize(raw_statement);
+
+                if res == 1 && offset >= 0 {
+                    let err_msg =
+                        String::from_utf8_lossy(CStr::from_ptr(message as *const _).to_bytes())
+                            .into_owned();
+                    let sub_statement_correction =
+                        remaining_sql.as_ptr() as usize - sql_start as usize;
+
+                    return Some((err_msg, offset as usize + sub_statement_correction));
+                }
+                remaining_sql = CStr::from_ptr(remaining_sql_ptr);
+            }
+        }
+        None
+    }
+
+    pub(crate) fn last_error(&self) -> Result<()> {
+        unsafe {
+            let code = sqlite3_errcode(self.sqlite3);
+            const NON_ERROR_CODES: &[i32] = &[SQLITE_OK, SQLITE_ROW];
+            if NON_ERROR_CODES.contains(&code) {
+                return Ok(());
+            }
+
+            let message = sqlite3_errmsg(self.sqlite3);
+            let message = if message.is_null() {
+                None
+            } else {
+                Some(
+                    String::from_utf8_lossy(CStr::from_ptr(message as *const _).to_bytes())
+                        .into_owned(),
+                )
+            };
+
+            Err(anyhow!(
+                "Sqlite call failed with code {} and message: {:?}",
+                code as isize,
+                message
+            ))
+        }
+    }
+
+    pub(crate) fn with_write<T>(&self, callback: impl FnOnce(&Connection) -> T) -> T {
+        *self.write.borrow_mut() = true;
+        let result = callback(self);
+        *self.write.borrow_mut() = false;
+        result
+    }
+}
+
+impl Drop for Connection {
+    fn drop(&mut self) {
+        unsafe { sqlite3_close(self.sqlite3) };
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use anyhow::Result;
+    use indoc::indoc;
+
+    use crate::connection::Connection;
+
+    #[test]
+    fn string_round_trips() -> Result<()> {
+        let connection = Connection::open_memory(Some("string_round_trips"));
+        connection
+            .exec(indoc! {"
+            CREATE TABLE text (
+                text TEXT
+            );"})
+            .unwrap()()
+        .unwrap();
+
+        let text = "Some test text";
+
+        connection
+            .exec_bound("INSERT INTO text (text) VALUES (?);")
+            .unwrap()(text)
+        .unwrap();
+
+        assert_eq!(
+            connection.select_row("SELECT text FROM text;").unwrap()().unwrap(),
+            Some(text.to_string())
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn tuple_round_trips() {
+        let connection = Connection::open_memory(Some("tuple_round_trips"));
+        connection
+            .exec(indoc! {"
+                CREATE TABLE test (
+                    text TEXT,
+                    integer INTEGER,
+                    blob BLOB
+                );"})
+            .unwrap()()
+        .unwrap();
+
+        let tuple1 = ("test".to_string(), 64, vec![0, 1, 2, 4, 8, 16, 32, 64]);
+        let tuple2 = ("test2".to_string(), 32, vec![64, 32, 16, 8, 4, 2, 1, 0]);
+
+        let mut insert = connection
+            .exec_bound::<(String, usize, Vec<u8>)>(
+                "INSERT INTO test (text, integer, blob) VALUES (?, ?, ?)",
+            )
+            .unwrap();
+
+        insert(tuple1.clone()).unwrap();
+        insert(tuple2.clone()).unwrap();
+
+        assert_eq!(
+            connection
+                .select::<(String, usize, Vec<u8>)>("SELECT * FROM test")
+                .unwrap()()
+            .unwrap(),
+            vec![tuple1, tuple2]
+        );
+    }
+
+    #[test]
+    fn bool_round_trips() {
+        let connection = Connection::open_memory(Some("bool_round_trips"));
+        connection
+            .exec(indoc! {"
+                CREATE TABLE bools (
+                    t INTEGER,
+                    f INTEGER
+                );"})
+            .unwrap()()
+        .unwrap();
+
+        connection
+            .exec_bound("INSERT INTO bools(t, f) VALUES (?, ?)")
+            .unwrap()((true, false))
+        .unwrap();
+
+        assert_eq!(
+            connection
+                .select_row::<(bool, bool)>("SELECT * FROM bools;")
+                .unwrap()()
+            .unwrap(),
+            Some((true, false))
+        );
+    }
+
+    #[test]
+    fn backup_works() {
+        let connection1 = Connection::open_memory(Some("backup_works"));
+        connection1
+            .exec(indoc! {"
+                CREATE TABLE blobs (
+                    data BLOB
+                );"})
+            .unwrap()()
+        .unwrap();
+        let blob = vec![0, 1, 2, 4, 8, 16, 32, 64];
+        connection1
+            .exec_bound::<Vec<u8>>("INSERT INTO blobs (data) VALUES (?);")
+            .unwrap()(blob.clone())
+        .unwrap();
+
+        // Backup connection1 to connection2
+        let connection2 = Connection::open_memory(Some("backup_works_other"));
+        connection1.backup_main(&connection2).unwrap();
+
+        // Delete the added blob and verify its deleted on the other side
+        let read_blobs = connection1
+            .select::<Vec<u8>>("SELECT * FROM blobs;")
+            .unwrap()()
+        .unwrap();
+        assert_eq!(read_blobs, vec![blob]);
+    }
+
+    #[test]
+    fn multi_step_statement_works() {
+        let connection = Connection::open_memory(Some("multi_step_statement_works"));
+
+        connection
+            .exec(indoc! {"
+                CREATE TABLE test (
+                    col INTEGER
+                )"})
+            .unwrap()()
+        .unwrap();
+
+        connection
+            .exec(indoc! {"
+            INSERT INTO test(col) VALUES (2)"})
+            .unwrap()()
+        .unwrap();
+
+        assert_eq!(
+            connection
+                .select_row::<usize>("SELECT * FROM test")
+                .unwrap()()
+            .unwrap(),
+            Some(2)
+        );
+    }
+
+    #[test]
+    fn test_sql_has_syntax_errors() {
+        let connection = Connection::open_memory(Some("test_sql_has_syntax_errors"));
+        let first_stmt =
+            "CREATE TABLE kv_store(key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT ;";
+        let second_stmt = "SELECT FROM";
+
+        let second_offset = connection.sql_has_syntax_error(second_stmt).unwrap().1;
+
+        let res = connection
+            .sql_has_syntax_error(&format!("{}\n{}", first_stmt, second_stmt))
+            .map(|(_, offset)| offset);
+
+        assert_eq!(res, Some(first_stmt.len() + second_offset + 1));
+    }
+}

crates/sqlez/src/domain.rs 🔗

@@ -0,0 +1,56 @@
+use crate::connection::Connection;
+
+pub trait Domain: 'static {
+    fn name() -> &'static str;
+    fn migrations() -> &'static [&'static str];
+}
+
+pub trait Migrator: 'static {
+    fn migrate(connection: &Connection) -> anyhow::Result<()>;
+}
+
+impl Migrator for () {
+    fn migrate(_connection: &Connection) -> anyhow::Result<()> {
+        Ok(()) // Do nothing
+    }
+}
+
+impl<D: Domain> Migrator for D {
+    fn migrate(connection: &Connection) -> anyhow::Result<()> {
+        connection.migrate(Self::name(), Self::migrations())
+    }
+}
+
+impl<D1: Domain, D2: Domain> Migrator for (D1, D2) {
+    fn migrate(connection: &Connection) -> anyhow::Result<()> {
+        D1::migrate(connection)?;
+        D2::migrate(connection)
+    }
+}
+
+impl<D1: Domain, D2: Domain, D3: Domain> Migrator for (D1, D2, D3) {
+    fn migrate(connection: &Connection) -> anyhow::Result<()> {
+        D1::migrate(connection)?;
+        D2::migrate(connection)?;
+        D3::migrate(connection)
+    }
+}
+
+impl<D1: Domain, D2: Domain, D3: Domain, D4: Domain> Migrator for (D1, D2, D3, D4) {
+    fn migrate(connection: &Connection) -> anyhow::Result<()> {
+        D1::migrate(connection)?;
+        D2::migrate(connection)?;
+        D3::migrate(connection)?;
+        D4::migrate(connection)
+    }
+}
+
+impl<D1: Domain, D2: Domain, D3: Domain, D4: Domain, D5: Domain> Migrator for (D1, D2, D3, D4, D5) {
+    fn migrate(connection: &Connection) -> anyhow::Result<()> {
+        D1::migrate(connection)?;
+        D2::migrate(connection)?;
+        D3::migrate(connection)?;
+        D4::migrate(connection)?;
+        D5::migrate(connection)
+    }
+}

crates/sqlez/src/lib.rs 🔗

@@ -0,0 +1,11 @@
+pub mod bindable;
+pub mod connection;
+pub mod domain;
+pub mod migrations;
+pub mod savepoint;
+pub mod statement;
+pub mod thread_safe_connection;
+pub mod typed_statements;
+mod util;
+
+pub use anyhow;

crates/sqlez/src/migrations.rs 🔗

@@ -0,0 +1,260 @@
+// Migrations are constructed by domain, and stored in a table in the connection db with domain name,
+// effected tables, actual query text, and order.
+// If a migration is run and any of the query texts don't match, the app panics on startup (maybe fallback
+// to creating a new db?)
+// Otherwise any missing migrations are run on the connection
+
+use anyhow::{anyhow, Result};
+use indoc::{formatdoc, indoc};
+
+use crate::connection::Connection;
+
+impl Connection {
+    pub fn migrate(&self, domain: &'static str, migrations: &[&'static str]) -> Result<()> {
+        self.with_savepoint("migrating", || {
+            // Setup the migrations table unconditionally
+            self.exec(indoc! {"
+                CREATE TABLE IF NOT EXISTS migrations (
+                    domain TEXT,
+                    step INTEGER,
+                    migration TEXT
+                )"})?()?;
+
+            let completed_migrations =
+                self.select_bound::<&str, (String, usize, String)>(indoc! {"
+                    SELECT domain, step, migration FROM migrations
+                    WHERE domain = ?
+                    ORDER BY step
+                    "})?(domain)?;
+
+            let mut store_completed_migration = self
+                .exec_bound("INSERT INTO migrations (domain, step, migration) VALUES (?, ?, ?)")?;
+
+            for (index, migration) in migrations.iter().enumerate() {
+                if let Some((_, _, completed_migration)) = completed_migrations.get(index) {
+                    if completed_migration != migration {
+                        return Err(anyhow!(formatdoc! {"
+                            Migration changed for {} at step {}
+                            
+                            Stored migration:
+                            {}
+                            
+                            Proposed migration:
+                            {}", domain, index, completed_migration, migration}));
+                    } else {
+                        // Migration already run. Continue
+                        continue;
+                    }
+                }
+
+                self.exec(migration)?()?;
+                store_completed_migration((domain, index, *migration))?;
+            }
+
+            Ok(())
+        })
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use indoc::indoc;
+
+    use crate::connection::Connection;
+
+    #[test]
+    fn test_migrations_are_added_to_table() {
+        let connection = Connection::open_memory(Some("migrations_are_added_to_table"));
+
+        // Create first migration with a single step and run it
+        connection
+            .migrate(
+                "test",
+                &[indoc! {"
+                CREATE TABLE test1 (
+                    a TEXT,
+                    b TEXT
+                )"}],
+            )
+            .unwrap();
+
+        // Verify it got added to the migrations table
+        assert_eq!(
+            &connection
+                .select::<String>("SELECT (migration) FROM migrations")
+                .unwrap()()
+            .unwrap()[..],
+            &[indoc! {"
+                CREATE TABLE test1 (
+                    a TEXT,
+                    b TEXT
+                )"}],
+        );
+
+        // Add another step to the migration and run it again
+        connection
+            .migrate(
+                "test",
+                &[
+                    indoc! {"
+                    CREATE TABLE test1 (
+                        a TEXT,
+                        b TEXT
+                    )"},
+                    indoc! {"
+                    CREATE TABLE test2 (
+                        c TEXT,
+                        d TEXT
+                    )"},
+                ],
+            )
+            .unwrap();
+
+        // Verify it is also added to the migrations table
+        assert_eq!(
+            &connection
+                .select::<String>("SELECT (migration) FROM migrations")
+                .unwrap()()
+            .unwrap()[..],
+            &[
+                indoc! {"
+                    CREATE TABLE test1 (
+                        a TEXT,
+                        b TEXT
+                    )"},
+                indoc! {"
+                    CREATE TABLE test2 (
+                        c TEXT,
+                        d TEXT
+                    )"},
+            ],
+        );
+    }
+
+    #[test]
+    fn test_migration_setup_works() {
+        let connection = Connection::open_memory(Some("migration_setup_works"));
+
+        connection
+            .exec(indoc! {"
+                CREATE TABLE IF NOT EXISTS migrations (
+                    domain TEXT,
+                    step INTEGER,
+                    migration TEXT
+                );"})
+            .unwrap()()
+        .unwrap();
+
+        let mut store_completed_migration = connection
+            .exec_bound::<(&str, usize, String)>(indoc! {"
+                INSERT INTO migrations (domain, step, migration)
+                VALUES (?, ?, ?)"})
+            .unwrap();
+
+        let domain = "test_domain";
+        for i in 0..5 {
+            // Create a table forcing a schema change
+            connection
+                .exec(&format!("CREATE TABLE table{} ( test TEXT );", i))
+                .unwrap()()
+            .unwrap();
+
+            store_completed_migration((domain, i, i.to_string())).unwrap();
+        }
+    }
+
+    #[test]
+    fn migrations_dont_rerun() {
+        let connection = Connection::open_memory(Some("migrations_dont_rerun"));
+
+        // Create migration which clears a tabl
+
+        // Manually create the table for that migration with a row
+        connection
+            .exec(indoc! {"
+                CREATE TABLE test_table (
+                    test_column INTEGER
+                );"})
+            .unwrap()()
+        .unwrap();
+        connection
+            .exec(indoc! {"
+            INSERT INTO test_table (test_column) VALUES (1);"})
+            .unwrap()()
+        .unwrap();
+
+        assert_eq!(
+            connection
+                .select_row::<usize>("SELECT * FROM test_table")
+                .unwrap()()
+            .unwrap(),
+            Some(1)
+        );
+
+        // Run the migration verifying that the row got dropped
+        connection
+            .migrate("test", &["DELETE FROM test_table"])
+            .unwrap();
+        assert_eq!(
+            connection
+                .select_row::<usize>("SELECT * FROM test_table")
+                .unwrap()()
+            .unwrap(),
+            None
+        );
+
+        // Recreate the dropped row
+        connection
+            .exec("INSERT INTO test_table (test_column) VALUES (2)")
+            .unwrap()()
+        .unwrap();
+
+        // Run the same migration again and verify that the table was left unchanged
+        connection
+            .migrate("test", &["DELETE FROM test_table"])
+            .unwrap();
+        assert_eq!(
+            connection
+                .select_row::<usize>("SELECT * FROM test_table")
+                .unwrap()()
+            .unwrap(),
+            Some(2)
+        );
+    }
+
+    #[test]
+    fn changed_migration_fails() {
+        let connection = Connection::open_memory(Some("changed_migration_fails"));
+
+        // Create a migration with two steps and run it
+        connection
+            .migrate(
+                "test migration",
+                &[
+                    indoc! {"
+                CREATE TABLE test (
+                    col INTEGER
+                )"},
+                    indoc! {"
+                    INSERT INTO test (col) VALUES (1)"},
+                ],
+            )
+            .unwrap();
+
+        // Create another migration with the same domain but different steps
+        let second_migration_result = connection.migrate(
+            "test migration",
+            &[
+                indoc! {"
+                CREATE TABLE test (
+                    color INTEGER
+                )"},
+                indoc! {"
+                INSERT INTO test (color) VALUES (1)"},
+            ],
+        );
+
+        // Verify new migration returns error when run
+        assert!(second_migration_result.is_err())
+    }
+}

crates/sqlez/src/savepoint.rs 🔗

@@ -0,0 +1,148 @@
+use anyhow::Result;
+use indoc::formatdoc;
+
+use crate::connection::Connection;
+
+impl Connection {
+    // Run a set of commands within the context of a `SAVEPOINT name`. If the callback
+    // returns Err(_), the savepoint will be rolled back. Otherwise, the save
+    // point is released.
+    pub fn with_savepoint<R, F>(&self, name: impl AsRef<str>, f: F) -> Result<R>
+    where
+        F: FnOnce() -> Result<R>,
+    {
+        let name = name.as_ref();
+        self.exec(&format!("SAVEPOINT {name}"))?()?;
+        let result = f();
+        match result {
+            Ok(_) => {
+                self.exec(&format!("RELEASE {name}"))?()?;
+            }
+            Err(_) => {
+                self.exec(&formatdoc! {"
+                    ROLLBACK TO {name};
+                    RELEASE {name}"})?()?;
+            }
+        }
+        result
+    }
+
+    // Run a set of commands within the context of a `SAVEPOINT name`. If the callback
+    // returns Ok(None) or Err(_), the savepoint will be rolled back. Otherwise, the save
+    // point is released.
+    pub fn with_savepoint_rollback<R, F>(&self, name: impl AsRef<str>, f: F) -> Result<Option<R>>
+    where
+        F: FnOnce() -> Result<Option<R>>,
+    {
+        let name = name.as_ref();
+        self.exec(&format!("SAVEPOINT {name}"))?()?;
+        let result = f();
+        match result {
+            Ok(Some(_)) => {
+                self.exec(&format!("RELEASE {name}"))?()?;
+            }
+            Ok(None) | Err(_) => {
+                self.exec(&formatdoc! {"
+                    ROLLBACK TO {name};
+                    RELEASE {name}"})?()?;
+            }
+        }
+        result
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::connection::Connection;
+    use anyhow::Result;
+    use indoc::indoc;
+
+    #[test]
+    fn test_nested_savepoints() -> Result<()> {
+        let connection = Connection::open_memory(Some("nested_savepoints"));
+
+        connection
+            .exec(indoc! {"
+            CREATE TABLE text (
+                text TEXT,
+                idx INTEGER
+            );"})
+            .unwrap()()
+        .unwrap();
+
+        let save1_text = "test save1";
+        let save2_text = "test save2";
+
+        connection.with_savepoint("first", || {
+            connection.exec_bound("INSERT INTO text(text, idx) VALUES (?, ?)")?((save1_text, 1))?;
+
+            assert!(connection
+                .with_savepoint("second", || -> Result<Option<()>, anyhow::Error> {
+                    connection.exec_bound("INSERT INTO text(text, idx) VALUES (?, ?)")?((
+                        save2_text, 2,
+                    ))?;
+
+                    assert_eq!(
+                        connection
+                            .select::<String>("SELECT text FROM text ORDER BY text.idx ASC")?(
+                        )?,
+                        vec![save1_text, save2_text],
+                    );
+
+                    anyhow::bail!("Failed second save point :(")
+                })
+                .err()
+                .is_some());
+
+            assert_eq!(
+                connection.select::<String>("SELECT text FROM text ORDER BY text.idx ASC")?()?,
+                vec![save1_text],
+            );
+
+            connection.with_savepoint_rollback::<(), _>("second", || {
+                connection.exec_bound("INSERT INTO text(text, idx) VALUES (?, ?)")?((
+                    save2_text, 2,
+                ))?;
+
+                assert_eq!(
+                    connection.select::<String>("SELECT text FROM text ORDER BY text.idx ASC")?()?,
+                    vec![save1_text, save2_text],
+                );
+
+                Ok(None)
+            })?;
+
+            assert_eq!(
+                connection.select::<String>("SELECT text FROM text ORDER BY text.idx ASC")?()?,
+                vec![save1_text],
+            );
+
+            connection.with_savepoint_rollback("second", || {
+                connection.exec_bound("INSERT INTO text(text, idx) VALUES (?, ?)")?((
+                    save2_text, 2,
+                ))?;
+
+                assert_eq!(
+                    connection.select::<String>("SELECT text FROM text ORDER BY text.idx ASC")?()?,
+                    vec![save1_text, save2_text],
+                );
+
+                Ok(Some(()))
+            })?;
+
+            assert_eq!(
+                connection.select::<String>("SELECT text FROM text ORDER BY text.idx ASC")?()?,
+                vec![save1_text, save2_text],
+            );
+
+            Ok(())
+        })?;
+
+        assert_eq!(
+            connection.select::<String>("SELECT text FROM text ORDER BY text.idx ASC")?()?,
+            vec![save1_text, save2_text],
+        );
+
+        Ok(())
+    }
+}

crates/sqlez/src/statement.rs 🔗

@@ -0,0 +1,491 @@
+use std::ffi::{c_int, CStr, CString};
+use std::marker::PhantomData;
+use std::{ptr, slice, str};
+
+use anyhow::{anyhow, bail, Context, Result};
+use libsqlite3_sys::*;
+
+use crate::bindable::{Bind, Column};
+use crate::connection::Connection;
+
+pub struct Statement<'a> {
+    raw_statements: Vec<*mut sqlite3_stmt>,
+    current_statement: usize,
+    connection: &'a Connection,
+    phantom: PhantomData<sqlite3_stmt>,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum StepResult {
+    Row,
+    Done,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum SqlType {
+    Text,
+    Integer,
+    Blob,
+    Float,
+    Null,
+}
+
+impl<'a> Statement<'a> {
+    pub fn prepare<T: AsRef<str>>(connection: &'a Connection, query: T) -> Result<Self> {
+        let mut statement = Self {
+            raw_statements: Default::default(),
+            current_statement: 0,
+            connection,
+            phantom: PhantomData,
+        };
+        unsafe {
+            let sql = CString::new(query.as_ref()).context("Error creating cstr")?;
+            let mut remaining_sql = sql.as_c_str();
+            while {
+                let remaining_sql_str = remaining_sql
+                    .to_str()
+                    .context("Parsing remaining sql")?
+                    .trim();
+                remaining_sql_str != ";" && !remaining_sql_str.is_empty()
+            } {
+                let mut raw_statement = 0 as *mut sqlite3_stmt;
+                let mut remaining_sql_ptr = ptr::null();
+                sqlite3_prepare_v2(
+                    connection.sqlite3,
+                    remaining_sql.as_ptr(),
+                    -1,
+                    &mut raw_statement,
+                    &mut remaining_sql_ptr,
+                );
+
+                remaining_sql = CStr::from_ptr(remaining_sql_ptr);
+                statement.raw_statements.push(raw_statement);
+
+                connection.last_error().with_context(|| {
+                    format!("Prepare call failed for query:\n{}", query.as_ref())
+                })?;
+
+                if !connection.can_write() && sqlite3_stmt_readonly(raw_statement) == 0 {
+                    let sql = CStr::from_ptr(sqlite3_sql(raw_statement));
+
+                    bail!(
+                        "Write statement prepared with connection that is not write capable. SQL:\n{} ",
+                        sql.to_str()?)
+                }
+            }
+        }
+
+        Ok(statement)
+    }
+
+    fn current_statement(&self) -> *mut sqlite3_stmt {
+        *self.raw_statements.get(self.current_statement).unwrap()
+    }
+
+    pub fn reset(&mut self) {
+        unsafe {
+            for raw_statement in self.raw_statements.iter() {
+                sqlite3_reset(*raw_statement);
+            }
+        }
+        self.current_statement = 0;
+    }
+
+    pub fn parameter_count(&self) -> i32 {
+        unsafe {
+            self.raw_statements
+                .iter()
+                .map(|raw_statement| sqlite3_bind_parameter_count(*raw_statement))
+                .max()
+                .unwrap_or(0)
+        }
+    }
+
+    fn bind_index_with(&self, index: i32, bind: impl Fn(&*mut sqlite3_stmt) -> ()) -> Result<()> {
+        let mut any_succeed = false;
+        unsafe {
+            for raw_statement in self.raw_statements.iter() {
+                if index <= sqlite3_bind_parameter_count(*raw_statement) {
+                    bind(raw_statement);
+                    self.connection
+                        .last_error()
+                        .with_context(|| format!("Failed to bind value at index {index}"))?;
+                    any_succeed = true;
+                } else {
+                    continue;
+                }
+            }
+        }
+        if any_succeed {
+            Ok(())
+        } else {
+            Err(anyhow!("Failed to bind parameters"))
+        }
+    }
+
+    pub fn bind_blob(&self, index: i32, blob: &[u8]) -> Result<()> {
+        let index = index as c_int;
+        let blob_pointer = blob.as_ptr() as *const _;
+        let len = blob.len() as c_int;
+
+        self.bind_index_with(index, |raw_statement| unsafe {
+            sqlite3_bind_blob(*raw_statement, index, blob_pointer, len, SQLITE_TRANSIENT());
+        })
+    }
+
+    pub fn column_blob<'b>(&'b mut self, index: i32) -> Result<&'b [u8]> {
+        let index = index as c_int;
+        let pointer = unsafe { sqlite3_column_blob(self.current_statement(), index) };
+
+        self.connection
+            .last_error()
+            .with_context(|| format!("Failed to read blob at index {index}"))?;
+        if pointer.is_null() {
+            return Ok(&[]);
+        }
+        let len = unsafe { sqlite3_column_bytes(self.current_statement(), index) as usize };
+        self.connection
+            .last_error()
+            .with_context(|| format!("Failed to read length of blob at index {index}"))?;
+
+        unsafe { Ok(slice::from_raw_parts(pointer as *const u8, len)) }
+    }
+
+    pub fn bind_double(&self, index: i32, double: f64) -> Result<()> {
+        let index = index as c_int;
+
+        self.bind_index_with(index, |raw_statement| unsafe {
+            sqlite3_bind_double(*raw_statement, index, double);
+        })
+    }
+
+    pub fn column_double(&self, index: i32) -> Result<f64> {
+        let index = index as c_int;
+        let result = unsafe { sqlite3_column_double(self.current_statement(), index) };
+        self.connection
+            .last_error()
+            .with_context(|| format!("Failed to read double at index {index}"))?;
+        Ok(result)
+    }
+
+    pub fn bind_int(&self, index: i32, int: i32) -> Result<()> {
+        let index = index as c_int;
+        self.bind_index_with(index, |raw_statement| unsafe {
+            sqlite3_bind_int(*raw_statement, index, int);
+        })
+    }
+
+    pub fn column_int(&self, index: i32) -> Result<i32> {
+        let index = index as c_int;
+        let result = unsafe { sqlite3_column_int(self.current_statement(), index) };
+        self.connection
+            .last_error()
+            .with_context(|| format!("Failed to read int at index {index}"))?;
+        Ok(result)
+    }
+
+    pub fn bind_int64(&self, index: i32, int: i64) -> Result<()> {
+        let index = index as c_int;
+        self.bind_index_with(index, |raw_statement| unsafe {
+            sqlite3_bind_int64(*raw_statement, index, int);
+        })
+    }
+
+    pub fn column_int64(&self, index: i32) -> Result<i64> {
+        let index = index as c_int;
+        let result = unsafe { sqlite3_column_int64(self.current_statement(), index) };
+        self.connection
+            .last_error()
+            .with_context(|| format!("Failed to read i64 at index {index}"))?;
+        Ok(result)
+    }
+
+    pub fn bind_null(&self, index: i32) -> Result<()> {
+        let index = index as c_int;
+        self.bind_index_with(index, |raw_statement| unsafe {
+            sqlite3_bind_null(*raw_statement, index);
+        })
+    }
+
+    pub fn bind_text(&self, index: i32, text: &str) -> Result<()> {
+        let index = index as c_int;
+        let text_pointer = text.as_ptr() as *const _;
+        let len = text.len() as c_int;
+
+        self.bind_index_with(index, |raw_statement| unsafe {
+            sqlite3_bind_text(*raw_statement, index, text_pointer, len, SQLITE_TRANSIENT());
+        })
+    }
+
+    pub fn column_text<'b>(&'b mut self, index: i32) -> Result<&'b str> {
+        let index = index as c_int;
+        let pointer = unsafe { sqlite3_column_text(self.current_statement(), index) };
+
+        self.connection
+            .last_error()
+            .with_context(|| format!("Failed to read text from column {index}"))?;
+        if pointer.is_null() {
+            return Ok("");
+        }
+        let len = unsafe { sqlite3_column_bytes(self.current_statement(), index) as usize };
+        self.connection
+            .last_error()
+            .with_context(|| format!("Failed to read text length at {index}"))?;
+
+        let slice = unsafe { slice::from_raw_parts(pointer as *const u8, len) };
+        Ok(str::from_utf8(slice)?)
+    }
+
+    pub fn bind<T: Bind>(&self, value: T, index: i32) -> Result<i32> {
+        debug_assert!(index > 0);
+        value.bind(self, index)
+    }
+
+    pub fn column<T: Column>(&mut self) -> Result<T> {
+        let (result, _) = T::column(self, 0)?;
+        Ok(result)
+    }
+
+    pub fn column_type(&mut self, index: i32) -> Result<SqlType> {
+        let result = unsafe { sqlite3_column_type(self.current_statement(), index) };
+        self.connection.last_error()?;
+        match result {
+            SQLITE_INTEGER => Ok(SqlType::Integer),
+            SQLITE_FLOAT => Ok(SqlType::Float),
+            SQLITE_TEXT => Ok(SqlType::Text),
+            SQLITE_BLOB => Ok(SqlType::Blob),
+            SQLITE_NULL => Ok(SqlType::Null),
+            _ => Err(anyhow!("Column type returned was incorrect ")),
+        }
+    }
+
+    pub fn with_bindings(&mut self, bindings: impl Bind) -> Result<&mut Self> {
+        self.bind(bindings, 1)?;
+        Ok(self)
+    }
+
+    fn step(&mut self) -> Result<StepResult> {
+        unsafe {
+            match sqlite3_step(self.current_statement()) {
+                SQLITE_ROW => Ok(StepResult::Row),
+                SQLITE_DONE => {
+                    if self.current_statement >= self.raw_statements.len() - 1 {
+                        Ok(StepResult::Done)
+                    } else {
+                        self.current_statement += 1;
+                        self.step()
+                    }
+                }
+                SQLITE_MISUSE => Err(anyhow!("Statement step returned SQLITE_MISUSE")),
+                _other_error => {
+                    self.connection.last_error()?;
+                    unreachable!("Step returned error code and last error failed to catch it");
+                }
+            }
+        }
+    }
+
+    pub fn exec(&mut self) -> Result<()> {
+        fn logic(this: &mut Statement) -> Result<()> {
+            while this.step()? == StepResult::Row {}
+            Ok(())
+        }
+        let result = logic(self);
+        self.reset();
+        result
+    }
+
+    pub fn map<R>(&mut self, callback: impl FnMut(&mut Statement) -> Result<R>) -> Result<Vec<R>> {
+        fn logic<R>(
+            this: &mut Statement,
+            mut callback: impl FnMut(&mut Statement) -> Result<R>,
+        ) -> Result<Vec<R>> {
+            let mut mapped_rows = Vec::new();
+            while this.step()? == StepResult::Row {
+                mapped_rows.push(callback(this)?);
+            }
+            Ok(mapped_rows)
+        }
+
+        let result = logic(self, callback);
+        self.reset();
+        result
+    }
+
+    pub fn rows<R: Column>(&mut self) -> Result<Vec<R>> {
+        self.map(|s| s.column::<R>())
+    }
+
+    pub fn single<R>(&mut self, callback: impl FnOnce(&mut Statement) -> Result<R>) -> Result<R> {
+        fn logic<R>(
+            this: &mut Statement,
+            callback: impl FnOnce(&mut Statement) -> Result<R>,
+        ) -> Result<R> {
+            if this.step()? != StepResult::Row {
+                return Err(anyhow!("single called with query that returns no rows."));
+            }
+            let result = callback(this)?;
+
+            if this.step()? != StepResult::Done {
+                return Err(anyhow!(
+                    "single called with a query that returns more than one row."
+                ));
+            }
+
+            Ok(result)
+        }
+        let result = logic(self, callback);
+        self.reset();
+        result
+    }
+
+    pub fn row<R: Column>(&mut self) -> Result<R> {
+        self.single(|this| this.column::<R>())
+    }
+
+    pub fn maybe<R>(
+        &mut self,
+        callback: impl FnOnce(&mut Statement) -> Result<R>,
+    ) -> Result<Option<R>> {
+        fn logic<R>(
+            this: &mut Statement,
+            callback: impl FnOnce(&mut Statement) -> Result<R>,
+        ) -> Result<Option<R>> {
+            if this.step().context("Failed on step call")? != StepResult::Row {
+                return Ok(None);
+            }
+
+            let result = callback(this)
+                .map(|r| Some(r))
+                .context("Failed to parse row result")?;
+
+            if this.step().context("Second step call")? != StepResult::Done {
+                return Err(anyhow!(
+                    "maybe called with a query that returns more than one row."
+                ));
+            }
+
+            Ok(result)
+        }
+        let result = logic(self, callback);
+        self.reset();
+        result
+    }
+
+    pub fn maybe_row<R: Column>(&mut self) -> Result<Option<R>> {
+        self.maybe(|this| this.column::<R>())
+    }
+}
+
+impl<'a> Drop for Statement<'a> {
+    fn drop(&mut self) {
+        unsafe {
+            for raw_statement in self.raw_statements.iter() {
+                sqlite3_finalize(*raw_statement);
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use indoc::indoc;
+
+    use crate::{
+        connection::Connection,
+        statement::{Statement, StepResult},
+    };
+
+    #[test]
+    fn binding_multiple_statements_with_parameter_gaps() {
+        let connection =
+            Connection::open_memory(Some("binding_multiple_statements_with_parameter_gaps"));
+
+        connection
+            .exec(indoc! {"
+            CREATE TABLE test (
+                col INTEGER
+            )"})
+            .unwrap()()
+        .unwrap();
+
+        let statement = Statement::prepare(
+            &connection,
+            indoc! {"
+                INSERT INTO test(col) VALUES (?3);
+                SELECT * FROM test WHERE col = ?1"},
+        )
+        .unwrap();
+
+        statement
+            .bind_int(1, 1)
+            .expect("Could not bind parameter to first index");
+        statement
+            .bind_int(2, 2)
+            .expect("Could not bind parameter to second index");
+        statement
+            .bind_int(3, 3)
+            .expect("Could not bind parameter to third index");
+    }
+
+    #[test]
+    fn blob_round_trips() {
+        let connection1 = Connection::open_memory(Some("blob_round_trips"));
+        connection1
+            .exec(indoc! {"
+                CREATE TABLE blobs (
+                    data BLOB
+                )"})
+            .unwrap()()
+        .unwrap();
+
+        let blob = &[0, 1, 2, 4, 8, 16, 32, 64];
+
+        let mut write =
+            Statement::prepare(&connection1, "INSERT INTO blobs (data) VALUES (?)").unwrap();
+        write.bind_blob(1, blob).unwrap();
+        assert_eq!(write.step().unwrap(), StepResult::Done);
+
+        // Read the blob from the
+        let connection2 = Connection::open_memory(Some("blob_round_trips"));
+        let mut read = Statement::prepare(&connection2, "SELECT * FROM blobs").unwrap();
+        assert_eq!(read.step().unwrap(), StepResult::Row);
+        assert_eq!(read.column_blob(0).unwrap(), blob);
+        assert_eq!(read.step().unwrap(), StepResult::Done);
+
+        // Delete the added blob and verify its deleted on the other side
+        connection2.exec("DELETE FROM blobs").unwrap()().unwrap();
+        let mut read = Statement::prepare(&connection1, "SELECT * FROM blobs").unwrap();
+        assert_eq!(read.step().unwrap(), StepResult::Done);
+    }
+
+    #[test]
+    pub fn maybe_returns_options() {
+        let connection = Connection::open_memory(Some("maybe_returns_options"));
+        connection
+            .exec(indoc! {"
+                CREATE TABLE texts (
+                    text TEXT 
+                )"})
+            .unwrap()()
+        .unwrap();
+
+        assert!(connection
+            .select_row::<String>("SELECT text FROM texts")
+            .unwrap()()
+        .unwrap()
+        .is_none());
+
+        let text_to_insert = "This is a test";
+
+        connection
+            .exec_bound("INSERT INTO texts VALUES (?)")
+            .unwrap()(text_to_insert)
+        .unwrap();
+
+        assert_eq!(
+            connection.select_row("SELECT text FROM texts").unwrap()().unwrap(),
+            Some(text_to_insert.to_string())
+        );
+    }
+}

crates/sqlez/src/thread_safe_connection.rs 🔗

@@ -0,0 +1,359 @@
+use anyhow::Context;
+use futures::{channel::oneshot, Future, FutureExt};
+use lazy_static::lazy_static;
+use parking_lot::{Mutex, RwLock};
+use std::{collections::HashMap, marker::PhantomData, ops::Deref, sync::Arc, thread};
+use thread_local::ThreadLocal;
+
+use crate::{connection::Connection, domain::Migrator, util::UnboundedSyncSender};
+
+const MIGRATION_RETRIES: usize = 10;
+
+type QueuedWrite = Box<dyn 'static + Send + FnOnce()>;
+type WriteQueueConstructor =
+    Box<dyn 'static + Send + FnMut() -> Box<dyn 'static + Send + Sync + Fn(QueuedWrite)>>;
+lazy_static! {
+    /// List of queues of tasks by database uri. This lets us serialize writes to the database
+    /// and have a single worker thread per db file. This means many thread safe connections
+    /// (possibly with different migrations) could all be communicating with the same background
+    /// thread.
+    static ref QUEUES: RwLock<HashMap<Arc<str>, Box<dyn 'static + Send + Sync + Fn(QueuedWrite)>>> =
+        Default::default();
+}
+
+/// Thread safe connection to a given database file or in memory db. This can be cloned, shared, static,
+/// whatever. It derefs to a synchronous connection by thread that is read only. A write capable connection
+/// may be accessed by passing a callback to the `write` function which will queue the callback
+pub struct ThreadSafeConnection<M: Migrator + 'static = ()> {
+    uri: Arc<str>,
+    persistent: bool,
+    connection_initialize_query: Option<&'static str>,
+    connections: Arc<ThreadLocal<Connection>>,
+    _migrator: PhantomData<*mut M>,
+}
+
+unsafe impl<M: Migrator> Send for ThreadSafeConnection<M> {}
+unsafe impl<M: Migrator> Sync for ThreadSafeConnection<M> {}
+
+pub struct ThreadSafeConnectionBuilder<M: Migrator + 'static = ()> {
+    db_initialize_query: Option<&'static str>,
+    write_queue_constructor: Option<WriteQueueConstructor>,
+    connection: ThreadSafeConnection<M>,
+}
+
+impl<M: Migrator> ThreadSafeConnectionBuilder<M> {
+    /// Sets the query to run every time a connection is opened. This must
+    /// be infallible (EG only use pragma statements) and not cause writes.
+    /// to the db or it will panic.
+    pub fn with_connection_initialize_query(mut self, initialize_query: &'static str) -> Self {
+        self.connection.connection_initialize_query = Some(initialize_query);
+        self
+    }
+
+    /// Queues an initialization query for the database file. This must be infallible
+    /// but may cause changes to the database file such as with `PRAGMA journal_mode`
+    pub fn with_db_initialization_query(mut self, initialize_query: &'static str) -> Self {
+        self.db_initialize_query = Some(initialize_query);
+        self
+    }
+
+    /// Specifies how the thread safe connection should serialize writes. If provided
+    /// the connection will call the write_queue_constructor for each database file in
+    /// this process. The constructor is responsible for setting up a background thread or
+    /// async task which handles queued writes with the provided connection.
+    pub fn with_write_queue_constructor(
+        mut self,
+        write_queue_constructor: WriteQueueConstructor,
+    ) -> Self {
+        self.write_queue_constructor = Some(write_queue_constructor);
+        self
+    }
+
+    pub async fn build(self) -> anyhow::Result<ThreadSafeConnection<M>> {
+        self.connection
+            .initialize_queues(self.write_queue_constructor);
+
+        let db_initialize_query = self.db_initialize_query;
+
+        self.connection
+            .write(move |connection| {
+                if let Some(db_initialize_query) = db_initialize_query {
+                    connection.exec(db_initialize_query).with_context(|| {
+                        format!(
+                            "Db initialize query failed to execute: {}",
+                            db_initialize_query
+                        )
+                    })?()?;
+                }
+
+                // Retry failed migrations in case they were run in parallel from different
+                // processes. This gives a best attempt at migrating before bailing
+                let mut migration_result =
+                    anyhow::Result::<()>::Err(anyhow::anyhow!("Migration never run"));
+
+                for _ in 0..MIGRATION_RETRIES {
+                    migration_result = connection
+                        .with_savepoint("thread_safe_multi_migration", || M::migrate(connection));
+
+                    if migration_result.is_ok() {
+                        break;
+                    }
+                }
+
+                migration_result
+            })
+            .await?;
+
+        Ok(self.connection)
+    }
+}
+
+impl<M: Migrator> ThreadSafeConnection<M> {
+    fn initialize_queues(&self, write_queue_constructor: Option<WriteQueueConstructor>) -> bool {
+        if !QUEUES.read().contains_key(&self.uri) {
+            let mut queues = QUEUES.write();
+            if !queues.contains_key(&self.uri) {
+                let mut write_queue_constructor =
+                    write_queue_constructor.unwrap_or(background_thread_queue());
+                queues.insert(self.uri.clone(), write_queue_constructor());
+                return true;
+            }
+        }
+        return false;
+    }
+
+    pub fn builder(uri: &str, persistent: bool) -> ThreadSafeConnectionBuilder<M> {
+        ThreadSafeConnectionBuilder::<M> {
+            db_initialize_query: None,
+            write_queue_constructor: None,
+            connection: Self {
+                uri: Arc::from(uri),
+                persistent,
+                connection_initialize_query: None,
+                connections: Default::default(),
+                _migrator: PhantomData,
+            },
+        }
+    }
+
+    /// Opens a new db connection with the initialized file path. This is internal and only
+    /// called from the deref function.
+    fn open_file(uri: &str) -> Connection {
+        Connection::open_file(uri)
+    }
+
+    /// Opens a shared memory connection using the file path as the identifier. This is internal
+    /// and only called from the deref function.
+    fn open_shared_memory(uri: &str) -> Connection {
+        Connection::open_memory(Some(uri))
+    }
+
+    pub fn write<T: 'static + Send + Sync>(
+        &self,
+        callback: impl 'static + Send + FnOnce(&Connection) -> T,
+    ) -> impl Future<Output = T> {
+        // Check and invalidate queue and maybe recreate queue
+        let queues = QUEUES.read();
+        let write_channel = queues
+            .get(&self.uri)
+            .expect("Queues are inserted when build is called. This should always succeed");
+
+        // Create a one shot channel for the result of the queued write
+        // so we can await on the result
+        let (sender, reciever) = oneshot::channel();
+
+        let thread_safe_connection = (*self).clone();
+        write_channel(Box::new(move || {
+            let connection = thread_safe_connection.deref();
+            let result = connection.with_write(|connection| callback(connection));
+            sender.send(result).ok();
+        }));
+        reciever.map(|response| response.expect("Write queue unexpectedly closed"))
+    }
+
+    pub(crate) fn create_connection(
+        persistent: bool,
+        uri: &str,
+        connection_initialize_query: Option<&'static str>,
+    ) -> Connection {
+        let mut connection = if persistent {
+            Self::open_file(uri)
+        } else {
+            Self::open_shared_memory(uri)
+        };
+
+        // Disallow writes on the connection. The only writes allowed for thread safe connections
+        // are from the background thread that can serialize them.
+        *connection.write.get_mut() = false;
+
+        if let Some(initialize_query) = connection_initialize_query {
+            connection.exec(initialize_query).expect(&format!(
+                "Initialize query failed to execute: {}",
+                initialize_query
+            ))()
+            .unwrap()
+        }
+
+        connection
+    }
+}
+
+impl ThreadSafeConnection<()> {
+    /// Special constructor for ThreadSafeConnection which disallows db initialization and migrations.
+    /// This allows construction to be infallible and not write to the db.
+    pub fn new(
+        uri: &str,
+        persistent: bool,
+        connection_initialize_query: Option<&'static str>,
+        write_queue_constructor: Option<WriteQueueConstructor>,
+    ) -> Self {
+        let connection = Self {
+            uri: Arc::from(uri),
+            persistent,
+            connection_initialize_query,
+            connections: Default::default(),
+            _migrator: PhantomData,
+        };
+
+        connection.initialize_queues(write_queue_constructor);
+        connection
+    }
+}
+
+impl<M: Migrator> Clone for ThreadSafeConnection<M> {
+    fn clone(&self) -> Self {
+        Self {
+            uri: self.uri.clone(),
+            persistent: self.persistent,
+            connection_initialize_query: self.connection_initialize_query.clone(),
+            connections: self.connections.clone(),
+            _migrator: PhantomData,
+        }
+    }
+}
+
+impl<M: Migrator> Deref for ThreadSafeConnection<M> {
+    type Target = Connection;
+
+    fn deref(&self) -> &Self::Target {
+        self.connections.get_or(|| {
+            Self::create_connection(self.persistent, &self.uri, self.connection_initialize_query)
+        })
+    }
+}
+
+pub fn background_thread_queue() -> WriteQueueConstructor {
+    use std::sync::mpsc::channel;
+
+    Box::new(|| {
+        let (sender, reciever) = channel::<QueuedWrite>();
+
+        thread::spawn(move || {
+            while let Ok(write) = reciever.recv() {
+                write()
+            }
+        });
+
+        let sender = UnboundedSyncSender::new(sender);
+        Box::new(move |queued_write| {
+            sender
+                .send(queued_write)
+                .expect("Could not send write action to background thread");
+        })
+    })
+}
+
+pub fn locking_queue() -> WriteQueueConstructor {
+    Box::new(|| {
+        let write_mutex = Mutex::new(());
+        Box::new(move |queued_write| {
+            let _lock = write_mutex.lock();
+            queued_write();
+        })
+    })
+}
+
+#[cfg(test)]
+mod test {
+    use indoc::indoc;
+    use lazy_static::__Deref;
+
+    use std::thread;
+
+    use crate::{domain::Domain, thread_safe_connection::ThreadSafeConnection};
+
+    #[test]
+    fn many_initialize_and_migrate_queries_at_once() {
+        let mut handles = vec![];
+
+        enum TestDomain {}
+        impl Domain for TestDomain {
+            fn name() -> &'static str {
+                "test"
+            }
+            fn migrations() -> &'static [&'static str] {
+                &["CREATE TABLE test(col1 TEXT, col2 TEXT) STRICT;"]
+            }
+        }
+
+        for _ in 0..100 {
+            handles.push(thread::spawn(|| {
+                let builder =
+                    ThreadSafeConnection::<TestDomain>::builder("annoying-test.db", false)
+                        .with_db_initialization_query("PRAGMA journal_mode=WAL")
+                        .with_connection_initialize_query(indoc! {"
+                                PRAGMA synchronous=NORMAL;
+                                PRAGMA busy_timeout=1;
+                                PRAGMA foreign_keys=TRUE;
+                                PRAGMA case_sensitive_like=TRUE;
+                            "});
+
+                let _ = smol::block_on(builder.build()).unwrap().deref();
+            }));
+        }
+
+        for handle in handles {
+            let _ = handle.join();
+        }
+    }
+
+    #[test]
+    #[should_panic]
+    fn wild_zed_lost_failure() {
+        enum TestWorkspace {}
+        impl Domain for TestWorkspace {
+            fn name() -> &'static str {
+                "workspace"
+            }
+
+            fn migrations() -> &'static [&'static str] {
+                &["
+                    CREATE TABLE workspaces(
+                        workspace_id INTEGER PRIMARY KEY,
+                        dock_visible INTEGER, -- Boolean
+                        dock_anchor TEXT, -- Enum: 'Bottom' / 'Right' / 'Expanded'
+                        dock_pane INTEGER, -- NULL indicates that we don't have a dock pane yet
+                        timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
+                        FOREIGN KEY(dock_pane) REFERENCES panes(pane_id),
+                        FOREIGN KEY(active_pane) REFERENCES panes(pane_id)
+                    ) STRICT;
+                    
+                    CREATE TABLE panes(
+                        pane_id INTEGER PRIMARY KEY,
+                        workspace_id INTEGER NOT NULL,
+                        active INTEGER NOT NULL, -- Boolean
+                        FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) 
+                            ON DELETE CASCADE 
+                            ON UPDATE CASCADE
+                    ) STRICT;
+                "]
+            }
+        }
+
+        let builder =
+            ThreadSafeConnection::<TestWorkspace>::builder("wild_zed_lost_failure", false)
+                .with_connection_initialize_query("PRAGMA FOREIGN_KEYS=true");
+
+        smol::block_on(builder.build()).unwrap();
+    }
+}

crates/sqlez/src/typed_statements.rs 🔗

@@ -0,0 +1,60 @@
+use anyhow::{Context, Result};
+
+use crate::{
+    bindable::{Bind, Column},
+    connection::Connection,
+    statement::Statement,
+};
+
+impl Connection {
+    pub fn exec<'a>(&'a self, query: &str) -> Result<impl 'a + FnMut() -> Result<()>> {
+        let mut statement = Statement::prepare(&self, query)?;
+        Ok(move || statement.exec())
+    }
+
+    pub fn exec_bound<'a, B: Bind>(
+        &'a self,
+        query: &str,
+    ) -> Result<impl 'a + FnMut(B) -> Result<()>> {
+        let mut statement = Statement::prepare(&self, query)?;
+        Ok(move |bindings| statement.with_bindings(bindings)?.exec())
+    }
+
+    pub fn select<'a, C: Column>(
+        &'a self,
+        query: &str,
+    ) -> Result<impl 'a + FnMut() -> Result<Vec<C>>> {
+        let mut statement = Statement::prepare(&self, query)?;
+        Ok(move || statement.rows::<C>())
+    }
+
+    pub fn select_bound<'a, B: Bind, C: Column>(
+        &'a self,
+        query: &str,
+    ) -> Result<impl 'a + FnMut(B) -> Result<Vec<C>>> {
+        let mut statement = Statement::prepare(&self, query)?;
+        Ok(move |bindings| statement.with_bindings(bindings)?.rows::<C>())
+    }
+
+    pub fn select_row<'a, C: Column>(
+        &'a self,
+        query: &str,
+    ) -> Result<impl 'a + FnMut() -> Result<Option<C>>> {
+        let mut statement = Statement::prepare(&self, query)?;
+        Ok(move || statement.maybe_row::<C>())
+    }
+
+    pub fn select_row_bound<'a, B: Bind, C: Column>(
+        &'a self,
+        query: &str,
+    ) -> Result<impl 'a + FnMut(B) -> Result<Option<C>>> {
+        let mut statement = Statement::prepare(&self, query)?;
+        Ok(move |bindings| {
+            statement
+                .with_bindings(bindings)
+                .context("Bindings failed")?
+                .maybe_row::<C>()
+                .context("Maybe row failed")
+        })
+    }
+}

crates/sqlez/src/util.rs 🔗

@@ -0,0 +1,32 @@
+use std::ops::Deref;
+use std::sync::mpsc::Sender;
+
+use parking_lot::Mutex;
+use thread_local::ThreadLocal;
+
+/// Unbounded standard library sender which is stored per thread to get around
+/// the lack of sync on the standard library version while still being unbounded
+/// Note: this locks on the cloneable sender, but its done once per thread, so it
+/// shouldn't result in too much contention
+pub struct UnboundedSyncSender<T: Send> {
+    clonable_sender: Mutex<Sender<T>>,
+    local_senders: ThreadLocal<Sender<T>>,
+}
+
+impl<T: Send> UnboundedSyncSender<T> {
+    pub fn new(sender: Sender<T>) -> Self {
+        Self {
+            clonable_sender: Mutex::new(sender),
+            local_senders: ThreadLocal::new(),
+        }
+    }
+}
+
+impl<T: Send> Deref for UnboundedSyncSender<T> {
+    type Target = Sender<T>;
+
+    fn deref(&self) -> &Self::Target {
+        self.local_senders
+            .get_or(|| self.clonable_sender.lock().clone())
+    }
+}

crates/sqlez_macros/Cargo.toml 🔗

@@ -0,0 +1,17 @@
+[package]
+name = "sqlez_macros"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/sqlez_macros.rs"
+proc-macro = true
+doctest = false
+
+[dependencies]
+syn = "1.0"
+quote = "1.0"
+proc-macro2 = "1.0"
+lazy_static = "1.4"
+sqlez = { path = "../sqlez" }
+sqlformat = "0.2"

crates/sqlez_macros/src/sqlez_macros.rs 🔗

@@ -0,0 +1,93 @@
+use proc_macro::{Delimiter, Span, TokenStream, TokenTree};
+use sqlez::thread_safe_connection::{locking_queue, ThreadSafeConnection};
+use syn::Error;
+
+lazy_static::lazy_static! {
+    static ref SQLITE: ThreadSafeConnection =  {
+        ThreadSafeConnection::new(":memory:", false, None, Some(locking_queue()))
+    };
+}
+
+#[proc_macro]
+pub fn sql(tokens: TokenStream) -> TokenStream {
+    let (spans, sql) = make_sql(tokens);
+
+    let error = SQLITE.sql_has_syntax_error(sql.trim());
+    let formatted_sql = sqlformat::format(&sql, &sqlformat::QueryParams::None, Default::default());
+
+    if let Some((error, error_offset)) = error {
+        create_error(spans, error_offset, error, &formatted_sql)
+    } else {
+        format!("r#\"{}\"#", &formatted_sql).parse().unwrap()
+    }
+}
+
+fn create_error(
+    spans: Vec<(usize, Span)>,
+    error_offset: usize,
+    error: String,
+    formatted_sql: &String,
+) -> TokenStream {
+    let error_span = spans
+        .into_iter()
+        .skip_while(|(offset, _)| offset <= &error_offset)
+        .map(|(_, span)| span)
+        .next()
+        .unwrap_or(Span::call_site());
+    let error_text = format!("Sql Error: {}\nFor Query: {}", error, formatted_sql);
+    TokenStream::from(Error::new(error_span.into(), error_text).into_compile_error())
+}
+
+fn make_sql(tokens: TokenStream) -> (Vec<(usize, Span)>, String) {
+    let mut sql_tokens = vec![];
+    flatten_stream(tokens.clone(), &mut sql_tokens);
+    // Lookup of spans by offset at the end of the token
+    let mut spans: Vec<(usize, Span)> = Vec::new();
+    let mut sql = String::new();
+    for (token_text, span) in sql_tokens {
+        sql.push_str(&token_text);
+        spans.push((sql.len(), span));
+    }
+    (spans, sql)
+}
+
+/// This method exists to normalize the representation of groups
+/// to always include spaces between tokens. This is why we don't use the usual .to_string().
+/// This allows our token search in token_at_offset to resolve
+/// ambiguity of '(tokens)' vs. '( token )', due to sqlite requiring byte offsets
+fn flatten_stream(tokens: TokenStream, result: &mut Vec<(String, Span)>) {
+    for token_tree in tokens.into_iter() {
+        match token_tree {
+            TokenTree::Group(group) => {
+                // push open delimiter
+                result.push((open_delimiter(group.delimiter()), group.span()));
+                // recurse
+                flatten_stream(group.stream(), result);
+                // push close delimiter
+                result.push((close_delimiter(group.delimiter()), group.span()));
+            }
+            TokenTree::Ident(ident) => {
+                result.push((format!("{} ", ident.to_string()), ident.span()));
+            }
+            leaf_tree => result.push((leaf_tree.to_string(), leaf_tree.span())),
+        }
+    }
+}
+
+fn open_delimiter(delimiter: Delimiter) -> String {
+    match delimiter {
+        Delimiter::Parenthesis => "( ".to_string(),
+        Delimiter::Brace => "[ ".to_string(),
+        Delimiter::Bracket => "{ ".to_string(),
+        Delimiter::None => "".to_string(),
+    }
+}
+
+fn close_delimiter(delimiter: Delimiter) -> String {
+    match delimiter {
+        Delimiter::Parenthesis => " ) ".to_string(),
+        Delimiter::Brace => " ] ".to_string(),
+        Delimiter::Bracket => " } ".to_string(),
+        Delimiter::None => "".to_string(),
+    }
+}

crates/terminal/Cargo.toml 🔗

@@ -17,6 +17,7 @@ settings = { path = "../settings" }
 theme = { path = "../theme" }
 util = { path = "../util" }
 workspace = { path = "../workspace" }
+db = { path = "../db" }
 alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "a51dbe25d67e84d6ed4261e640d3954fbdd9be45" }
 procinfo = { git = "https://github.com/zed-industries/wezterm", rev = "5cd757e5f2eb039ed0c6bb6512223e69d5efc64d", default-features = false }
 smallvec = { version = "1.6", features = ["union"] }

crates/terminal/src/persistence.rs 🔗

@@ -0,0 +1,52 @@
+use std::path::PathBuf;
+
+use db::{define_connection, query, sqlez_macros::sql};
+
+use workspace::{ItemId, WorkspaceDb, WorkspaceId};
+
+define_connection! {
+    pub static ref TERMINAL_CONNECTION: TerminalDb<WorkspaceDb> =
+        &[sql!(
+            CREATE TABLE terminals (
+                workspace_id INTEGER,
+                item_id INTEGER UNIQUE,
+                working_directory BLOB,
+                PRIMARY KEY(workspace_id, item_id),
+                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+                    ON DELETE CASCADE
+            ) STRICT;
+        )];
+}
+
+impl TerminalDb {
+    query! {
+       pub async fn update_workspace_id(
+            new_id: WorkspaceId,
+            old_id: WorkspaceId,
+            item_id: ItemId
+        ) -> Result<()> {
+            UPDATE terminals
+            SET workspace_id = ?
+            WHERE workspace_id = ? AND item_id = ?
+        }
+    }
+
+    query! {
+        pub async fn save_working_directory(
+            item_id: ItemId,
+            workspace_id: WorkspaceId,
+            working_directory: PathBuf
+        ) -> Result<()> {
+            INSERT OR REPLACE INTO terminals(item_id, workspace_id, working_directory)
+            VALUES (?, ?, ?)
+        }
+    }
+
+    query! {
+        pub fn get_working_directory(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<PathBuf>> {
+            SELECT working_directory
+            FROM terminals
+            WHERE item_id = ? AND workspace_id = ?
+        }
+    }
+}

crates/terminal/src/terminal.rs 🔗

@@ -1,4 +1,5 @@
 pub mod mappings;
+mod persistence;
 pub mod terminal_container_view;
 pub mod terminal_element;
 pub mod terminal_view;
@@ -32,9 +33,11 @@ use mappings::mouse::{
     alt_scroll, grid_point, mouse_button_report, mouse_moved_report, mouse_side, scroll_report,
 };
 
+use persistence::TERMINAL_CONNECTION;
 use procinfo::LocalProcessInfo;
 use settings::{AlternateScroll, Settings, Shell, TerminalBlink};
 use util::ResultExt;
+use workspace::{ItemId, WorkspaceId};
 
 use std::{
     cmp::min,
@@ -54,7 +57,8 @@ use gpui::{
     geometry::vector::{vec2f, Vector2F},
     keymap::Keystroke,
     scene::{MouseDown, MouseDrag, MouseScrollWheel, MouseUp},
-    ClipboardItem, Entity, ModelContext, MouseButton, MouseMovedEvent, MutableAppContext, Task,
+    AppContext, ClipboardItem, Entity, ModelContext, MouseButton, MouseMovedEvent,
+    MutableAppContext, Task,
 };
 
 use crate::mappings::{
@@ -281,6 +285,8 @@ impl TerminalBuilder {
         blink_settings: Option<TerminalBlink>,
         alternate_scroll: &AlternateScroll,
         window_id: usize,
+        item_id: ItemId,
+        workspace_id: WorkspaceId,
     ) -> Result<TerminalBuilder> {
         let pty_config = {
             let alac_shell = shell.clone().and_then(|shell| match shell {
@@ -385,6 +391,8 @@ impl TerminalBuilder {
             last_mouse_position: None,
             next_link_id: 0,
             selection_phase: SelectionPhase::Ended,
+            workspace_id,
+            item_id,
         };
 
         Ok(TerminalBuilder {
@@ -528,6 +536,8 @@ pub struct Terminal {
     scroll_px: f32,
     next_link_id: usize,
     selection_phase: SelectionPhase,
+    workspace_id: WorkspaceId,
+    item_id: ItemId,
 }
 
 impl Terminal {
@@ -567,7 +577,21 @@ impl Terminal {
                 cx.emit(Event::Wakeup);
 
                 if self.update_process_info() {
-                    cx.emit(Event::TitleChanged)
+                    cx.emit(Event::TitleChanged);
+
+                    if let Some(foreground_info) = &self.foreground_process_info {
+                        let cwd = foreground_info.cwd.clone();
+                        let item_id = self.item_id;
+                        let workspace_id = self.workspace_id;
+                        cx.background()
+                            .spawn(async move {
+                                TERMINAL_CONNECTION
+                                    .save_working_directory(item_id, workspace_id, cwd)
+                                    .await
+                                    .log_err();
+                            })
+                            .detach();
+                    }
                 }
             }
             AlacTermEvent::ColorRequest(idx, fun_ptr) => {
@@ -875,10 +899,6 @@ impl Terminal {
             return;
         };
 
-        if self.update_process_info() {
-            cx.emit(Event::TitleChanged);
-        }
-
         //Note that the ordering of events matters for event processing
         while let Some(e) = self.events.pop_front() {
             self.process_terminal_event(&e, &mut terminal, cx)
@@ -1174,6 +1194,21 @@ impl Terminal {
         }
     }
 
+    pub fn set_workspace_id(&mut self, id: WorkspaceId, cx: &AppContext) {
+        let old_workspace_id = self.workspace_id;
+        let item_id = self.item_id;
+        cx.background()
+            .spawn(async move {
+                TERMINAL_CONNECTION
+                    .update_workspace_id(id, old_workspace_id, item_id)
+                    .await
+                    .log_err()
+            })
+            .detach();
+
+        self.workspace_id = id;
+    }
+
     pub fn find_matches(
         &mut self,
         query: project::search::SearchQuery,

crates/terminal/src/terminal_container_view.rs 🔗

@@ -1,15 +1,20 @@
+use crate::persistence::TERMINAL_CONNECTION;
 use crate::terminal_view::TerminalView;
-use crate::{Event, Terminal, TerminalBuilder, TerminalError};
+use crate::{Event, TerminalBuilder, TerminalError};
 
 use alacritty_terminal::index::Point;
 use dirs::home_dir;
 use gpui::{
     actions, elements::*, AnyViewHandle, AppContext, Entity, ModelHandle, MutableAppContext, Task,
-    View, ViewContext, ViewHandle,
+    View, ViewContext, ViewHandle, WeakViewHandle,
 };
-use util::truncate_and_trailoff;
+use util::{truncate_and_trailoff, ResultExt};
 use workspace::searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle};
-use workspace::{Item, ItemEvent, ToolbarItemLocation, Workspace};
+use workspace::{
+    item::{Item, ItemEvent},
+    ToolbarItemLocation, Workspace,
+};
+use workspace::{register_deserializable_item, Pane, WorkspaceId};
 
 use project::{LocalWorktree, Project, ProjectPath};
 use settings::{AlternateScroll, Settings, WorkingDirectory};
@@ -23,6 +28,8 @@ actions!(terminal, [DeployModal]);
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(TerminalContainer::deploy);
+
+    register_deserializable_item::<TerminalContainer>(cx);
 }
 
 //Make terminal view an enum, that can give you views for the error and non-error states
@@ -75,7 +82,9 @@ impl TerminalContainer {
             .unwrap_or(WorkingDirectory::CurrentProjectDirectory);
 
         let working_directory = get_working_directory(workspace, cx, strategy);
-        let view = cx.add_view(|cx| TerminalContainer::new(working_directory, false, cx));
+        let view = cx.add_view(|cx| {
+            TerminalContainer::new(working_directory, false, workspace.database_id(), cx)
+        });
         workspace.add_item(Box::new(view), cx);
     }
 
@@ -83,6 +92,7 @@ impl TerminalContainer {
     pub fn new(
         working_directory: Option<PathBuf>,
         modal: bool,
+        workspace_id: WorkspaceId,
         cx: &mut ViewContext<Self>,
     ) -> Self {
         let settings = cx.global::<Settings>();
@@ -109,10 +119,13 @@ impl TerminalContainer {
             settings.terminal_overrides.blinking.clone(),
             scroll,
             cx.window_id(),
+            cx.view_id(),
+            workspace_id,
         ) {
             Ok(terminal) => {
                 let terminal = cx.add_model(|cx| terminal.subscribe(cx));
                 let view = cx.add_view(|cx| TerminalView::from_terminal(terminal, modal, cx));
+
                 cx.subscribe(&view, |_this, _content, event, cx| cx.emit(*event))
                     .detach();
                 TerminalContainerContent::Connected(view)
@@ -124,7 +137,6 @@ impl TerminalContainer {
                 TerminalContainerContent::Error(view)
             }
         };
-        cx.focus(content.handle());
 
         TerminalContainer {
             content,
@@ -132,18 +144,6 @@ impl TerminalContainer {
         }
     }
 
-    pub fn from_terminal(
-        terminal: ModelHandle<Terminal>,
-        modal: bool,
-        cx: &mut ViewContext<Self>,
-    ) -> Self {
-        let connected_view = cx.add_view(|cx| TerminalView::from_terminal(terminal, modal, cx));
-        TerminalContainer {
-            content: TerminalContainerContent::Connected(connected_view),
-            associated_directory: None,
-        }
-    }
-
     fn connected(&self) -> Option<ViewHandle<TerminalView>> {
         match &self.content {
             TerminalContainerContent::Connected(vh) => Some(vh.clone()),
@@ -271,13 +271,18 @@ impl Item for TerminalContainer {
             .boxed()
     }
 
-    fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> {
+    fn clone_on_split(
+        &self,
+        workspace_id: WorkspaceId,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<Self> {
         //From what I can tell, there's no  way to tell the current working
         //Directory of the terminal from outside the shell. There might be
         //solutions to this, but they are non-trivial and require more IPC
         Some(TerminalContainer::new(
             self.associated_directory.clone(),
             false,
+            workspace_id,
             cx,
         ))
     }
@@ -372,6 +377,36 @@ impl Item for TerminalContainer {
         )
         .boxed()])
     }
+
+    fn serialized_item_kind() -> Option<&'static str> {
+        Some("Terminal")
+    }
+
+    fn deserialize(
+        _project: ModelHandle<Project>,
+        _workspace: WeakViewHandle<Workspace>,
+        workspace_id: workspace::WorkspaceId,
+        item_id: workspace::ItemId,
+        cx: &mut ViewContext<Pane>,
+    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
+        let working_directory = TERMINAL_CONNECTION.get_working_directory(item_id, workspace_id);
+        Task::ready(Ok(cx.add_view(|cx| {
+            TerminalContainer::new(
+                working_directory.log_err().flatten(),
+                false,
+                workspace_id,
+                cx,
+            )
+        })))
+    }
+
+    fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
+        if let Some(connected) = self.connected() {
+            let id = workspace.database_id();
+            let terminal_handle = connected.read(cx).terminal().clone();
+            terminal_handle.update(cx, |terminal, cx| terminal.set_workspace_id(id, cx))
+        }
+    }
 }
 
 impl SearchableItem for TerminalContainer {

crates/terminal/src/tests/terminal_test_context.rs 🔗

@@ -28,9 +28,15 @@ impl<'a> TerminalTestContext<'a> {
         let params = self.cx.update(AppState::test);
 
         let project = Project::test(params.fs.clone(), [], self.cx).await;
-        let (_, workspace) = self
-            .cx
-            .add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
+        let (_, workspace) = self.cx.add_window(|cx| {
+            Workspace::new(
+                Default::default(),
+                0,
+                project.clone(),
+                |_, _| unimplemented!(),
+                cx,
+            )
+        });
 
         (project, workspace)
     }

crates/theme/src/theme.rs 🔗

@@ -31,6 +31,7 @@ pub struct Theme {
     pub shared_screen: ContainerStyle,
     pub contact_notification: ContactNotification,
     pub update_notification: UpdateNotification,
+    pub simple_message_notification: MessageNotification,
     pub project_shared_notification: ProjectSharedNotification,
     pub incoming_call_notification: IncomingCallNotification,
     pub tooltip: TooltipStyle,
@@ -478,6 +479,13 @@ pub struct UpdateNotification {
     pub dismiss_button: Interactive<IconButton>,
 }
 
+#[derive(Deserialize, Default)]
+pub struct MessageNotification {
+    pub message: ContainedText,
+    pub action_message: Interactive<ContainedText>,
+    pub dismiss_button: Interactive<IconButton>,
+}
+
 #[derive(Deserialize, Default)]
 pub struct ProjectSharedNotification {
     pub window_height: f32,

crates/theme_testbench/src/theme_testbench.rs 🔗

@@ -6,18 +6,24 @@ use gpui::{
         Padding, ParentElement,
     },
     fonts::TextStyle,
-    Border, Element, Entity, MutableAppContext, Quad, RenderContext, View, ViewContext,
+    Border, Element, Entity, ModelHandle, MutableAppContext, Quad, RenderContext, Task, View,
+    ViewContext, ViewHandle, WeakViewHandle,
 };
 use project::{Project, ProjectEntryId, ProjectPath};
 use settings::Settings;
 use smallvec::SmallVec;
 use theme::{ColorScheme, Layer, Style, StyleSet};
-use workspace::{Item, Workspace};
+use workspace::{
+    item::{Item, ItemEvent},
+    register_deserializable_item, Pane, Workspace,
+};
 
 actions!(theme, [DeployThemeTestbench]);
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(ThemeTestbench::deploy);
+
+    register_deserializable_item::<ThemeTestbench>(cx)
 }
 
 pub struct ThemeTestbench {}
@@ -351,7 +357,21 @@ impl Item for ThemeTestbench {
         gpui::Task::ready(Ok(()))
     }
 
-    fn to_item_events(_: &Self::Event) -> Vec<workspace::ItemEvent> {
+    fn to_item_events(_: &Self::Event) -> Vec<ItemEvent> {
         Vec::new()
     }
+
+    fn serialized_item_kind() -> Option<&'static str> {
+        Some("ThemeTestBench")
+    }
+
+    fn deserialize(
+        _project: ModelHandle<Project>,
+        _workspace: WeakViewHandle<Workspace>,
+        _workspace_id: workspace::WorkspaceId,
+        _item_id: workspace::ItemId,
+        cx: &mut ViewContext<Pane>,
+    ) -> Task<gpui::anyhow::Result<ViewHandle<Self>>> {
+        Task::ready(Ok(cx.add_view(|_| Self {})))
+    }
 }

crates/util/Cargo.toml 🔗

@@ -19,6 +19,7 @@ rand = { workspace = true }
 tempdir = { version = "0.3.7", optional = true }
 serde_json = { version = "1.0", features = ["preserve_order"], optional = true }
 git2 = { version = "0.15", default-features = false, optional = true }
+dirs = "3.0"
 
 
 [dev-dependencies]

crates/util/src/channel.rs 🔗

@@ -0,0 +1,40 @@
+use std::env;
+
+use lazy_static::lazy_static;
+
+lazy_static! {
+    pub static ref RELEASE_CHANNEL_NAME: String = env::var("ZED_RELEASE_CHANNEL")
+        .unwrap_or(include_str!("../../zed/RELEASE_CHANNEL").to_string());
+    pub static ref RELEASE_CHANNEL: ReleaseChannel = match RELEASE_CHANNEL_NAME.as_str() {
+        "dev" => ReleaseChannel::Dev,
+        "preview" => ReleaseChannel::Preview,
+        "stable" => ReleaseChannel::Stable,
+        _ => panic!("invalid release channel {}", *RELEASE_CHANNEL_NAME),
+    };
+}
+
+#[derive(Copy, Clone, PartialEq, Eq, Default)]
+pub enum ReleaseChannel {
+    #[default]
+    Dev,
+    Preview,
+    Stable,
+}
+
+impl ReleaseChannel {
+    pub fn display_name(&self) -> &'static str {
+        match self {
+            ReleaseChannel::Dev => "Zed Dev",
+            ReleaseChannel::Preview => "Zed Preview",
+            ReleaseChannel::Stable => "Zed",
+        }
+    }
+
+    pub fn dev_name(&self) -> &'static str {
+        match self {
+            ReleaseChannel::Dev => "dev",
+            ReleaseChannel::Preview => "preview",
+            ReleaseChannel::Stable => "stable",
+        }
+    }
+}

crates/util/src/lib.rs 🔗

@@ -1,3 +1,5 @@
+pub mod channel;
+pub mod paths;
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
 
@@ -204,6 +206,30 @@ impl<T: Rng> Iterator for RandomCharIter<T> {
     }
 }
 
+// copy unstable standard feature option unzip
+// https://github.com/rust-lang/rust/issues/87800
+// Remove when this ship in Rust 1.66 or 1.67
+pub fn unzip_option<T, U>(option: Option<(T, U)>) -> (Option<T>, Option<U>) {
+    match option {
+        Some((a, b)) => (Some(a), Some(b)),
+        None => (None, None),
+    }
+}
+
+#[macro_export]
+macro_rules! iife {
+    ($block:block) => {
+        (|| $block)()
+    };
+}
+
+#[macro_export]
+macro_rules! async_iife {
+    ($block:block) => {
+        (|| async move { $block })()
+    };
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -221,4 +247,18 @@ mod tests {
         extend_sorted(&mut vec, vec![1000, 19, 17, 9, 5], 8, |a, b| b.cmp(a));
         assert_eq!(vec, &[1000, 101, 21, 19, 17, 13, 9, 8]);
     }
+
+    #[test]
+    fn test_iife() {
+        fn option_returning_function() -> Option<()> {
+            None
+        }
+
+        let foo = iife!({
+            option_returning_function()?;
+            Some(())
+        });
+
+        assert_eq!(foo, None);
+    }
 }

crates/vim/src/test/vim_test_context.rs 🔗

@@ -41,8 +41,15 @@ impl<'a> VimTestContext<'a> {
             .insert_tree("/root", json!({ "dir": { "test.txt": "" } }))
             .await;
 
-        let (window_id, workspace) =
-            cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
+        let (window_id, workspace) = cx.add_window(|cx| {
+            Workspace::new(
+                Default::default(),
+                0,
+                project.clone(),
+                |_, _| unimplemented!(),
+                cx,
+            )
+        });
 
         // Setup search toolbars
         workspace.update(cx, |workspace, cx| {

crates/workspace/Cargo.toml 🔗

@@ -18,6 +18,7 @@ test-support = [
 ]
 
 [dependencies]
+db = { path = "../db" }
 call = { path = "../call" }
 client = { path = "../client" }
 collections = { path = "../collections" }
@@ -31,14 +32,19 @@ project = { path = "../project" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }
 util = { path = "../util" }
+async-recursion = "1.0.0"
+bincode = "1.2.1"
 anyhow = "1.0.38"
 futures = "0.3"
+lazy_static = "1.4"
+env_logger = "0.9.1"
 log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 parking_lot = "0.11.1"
 postage = { version = "0.4.1", features = ["futures-traits"] }
 serde = { version = "1.0", features = ["derive", "rc"] }
 serde_json = { version = "1.0", features = ["preserve_order"] }
 smallvec = { version = "1.6", features = ["union"] }
+indoc = "1.0.4"
 
 [dev-dependencies]
 call = { path = "../call", features = ["test-support"] }
@@ -47,3 +53,4 @@ gpui = { path = "../gpui", features = ["test-support"] }
 project = { path = "../project", features = ["test-support"] }
 settings = { path = "../settings", features = ["test-support"] }
 fs = { path = "../fs", features = ["test-support"] }
+db = { path = "../db", features = ["test-support"] }

crates/workspace/src/dock.rs 🔗

@@ -98,14 +98,14 @@ pub fn icon_for_dock_anchor(anchor: DockAnchor) -> &'static str {
 }
 
 impl DockPosition {
-    fn is_visible(&self) -> bool {
+    pub fn is_visible(&self) -> bool {
         match self {
             DockPosition::Shown(_) => true,
             DockPosition::Hidden(_) => false,
         }
     }
 
-    fn anchor(&self) -> DockAnchor {
+    pub fn anchor(&self) -> DockAnchor {
         match self {
             DockPosition::Shown(anchor) | DockPosition::Hidden(anchor) => *anchor,
         }
@@ -137,9 +137,10 @@ pub struct Dock {
 }
 
 impl Dock {
-    pub fn new(cx: &mut ViewContext<Workspace>, default_item_factory: DefaultItemFactory) -> Self {
-        let anchor = cx.global::<Settings>().default_dock_anchor;
-        let pane = cx.add_view(|cx| Pane::new(Some(anchor), cx));
+    pub fn new(default_item_factory: DefaultItemFactory, cx: &mut ViewContext<Workspace>) -> Self {
+        let position = DockPosition::Hidden(cx.global::<Settings>().default_dock_anchor);
+
+        let pane = cx.add_view(|cx| Pane::new(Some(position.anchor()), cx));
         pane.update(cx, |pane, cx| {
             pane.set_active(false, cx);
         });
@@ -152,7 +153,7 @@ impl Dock {
         Self {
             pane,
             panel_sizes: Default::default(),
-            position: DockPosition::Hidden(anchor),
+            position,
             default_item_factory,
         }
     }
@@ -169,21 +170,26 @@ impl Dock {
         self.position.is_visible() && self.position.anchor() == anchor
     }
 
-    fn set_dock_position(
+    pub(crate) fn set_dock_position(
         workspace: &mut Workspace,
         new_position: DockPosition,
         cx: &mut ViewContext<Workspace>,
     ) {
+        dbg!("starting", &new_position);
         workspace.dock.position = new_position;
         // Tell the pane about the new anchor position
         workspace.dock.pane.update(cx, |pane, cx| {
+            dbg!("setting docked");
             pane.set_docked(Some(new_position.anchor()), cx)
         });
 
         if workspace.dock.position.is_visible() {
+            dbg!("dock is visible");
             // Close the right sidebar if the dock is on the right side and the right sidebar is open
             if workspace.dock.position.anchor() == DockAnchor::Right {
+                dbg!("dock anchor is right");
                 if workspace.right_sidebar().read(cx).is_open() {
+                    dbg!("Toggling right sidebar");
                     workspace.toggle_sidebar(SidebarSide::Right, cx);
                 }
             }
@@ -193,8 +199,10 @@ impl Dock {
             if pane.read(cx).items().next().is_none() {
                 let item_to_add = (workspace.dock.default_item_factory)(workspace, cx);
                 // Adding the item focuses the pane by default
+                dbg!("Adding item to dock");
                 Pane::add_item(workspace, &pane, item_to_add, true, true, None, cx);
             } else {
+                dbg!("just focusing dock");
                 cx.focus(pane);
             }
         } else if let Some(last_active_center_pane) = workspace
@@ -205,6 +213,8 @@ impl Dock {
             cx.focus(last_active_center_pane);
         }
         cx.emit(crate::Event::DockAnchorChanged);
+        workspace.serialize_workspace(cx);
+        dbg!("Serializing workspace after dock position changed");
         cx.notify();
     }
 
@@ -341,6 +351,10 @@ impl Dock {
                 }
             })
     }
+
+    pub fn position(&self) -> DockPosition {
+        self.position
+    }
 }
 
 pub struct ToggleDockButton {
@@ -454,7 +468,7 @@ mod tests {
     use settings::Settings;
 
     use super::*;
-    use crate::{sidebar::Sidebar, tests::TestItem, ItemHandle, Workspace};
+    use crate::{item::test::TestItem, sidebar::Sidebar, ItemHandle, Workspace};
 
     pub fn default_item_factory(
         _workspace: &mut Workspace,
@@ -568,8 +582,9 @@ mod tests {
 
             cx.update(|cx| init(cx));
             let project = Project::test(fs, [], cx).await;
-            let (window_id, workspace) =
-                cx.add_window(|cx| Workspace::new(project, default_item_factory, cx));
+            let (window_id, workspace) = cx.add_window(|cx| {
+                Workspace::new(Default::default(), 0, project, default_item_factory, cx)
+            });
 
             workspace.update(cx, |workspace, cx| {
                 let left_panel = cx.add_view(|_| TestItem::new());

crates/workspace/src/item.rs 🔗

@@ -0,0 +1,905 @@
+use std::{
+    any::{Any, TypeId},
+    borrow::Cow,
+    cell::RefCell,
+    fmt,
+    path::PathBuf,
+    rc::Rc,
+    sync::atomic::{AtomicBool, Ordering},
+    time::Duration,
+};
+
+use anyhow::Result;
+use client::proto;
+use gpui::{
+    AnyViewHandle, AppContext, ElementBox, ModelHandle, MutableAppContext, Task, View, ViewContext,
+    ViewHandle, WeakViewHandle,
+};
+use project::{Project, ProjectEntryId, ProjectPath};
+use settings::{Autosave, Settings};
+use smallvec::SmallVec;
+use theme::Theme;
+use util::ResultExt;
+
+use crate::{
+    pane, persistence::model::ItemId, searchable::SearchableItemHandle, DelayedDebouncedEditAction,
+    FollowableItemBuilders, ItemNavHistory, Pane, ToolbarItemLocation, Workspace, WorkspaceId,
+};
+
+#[derive(Eq, PartialEq, Hash)]
+pub enum ItemEvent {
+    CloseItem,
+    UpdateTab,
+    UpdateBreadcrumbs,
+    Edit,
+}
+
+pub trait Item: View {
+    fn deactivated(&mut self, _: &mut ViewContext<Self>) {}
+    fn workspace_deactivated(&mut self, _: &mut ViewContext<Self>) {}
+    fn navigate(&mut self, _: Box<dyn Any>, _: &mut ViewContext<Self>) -> bool {
+        false
+    }
+    fn tab_description<'a>(&'a self, _: usize, _: &'a AppContext) -> Option<Cow<'a, str>> {
+        None
+    }
+    fn tab_content(&self, detail: Option<usize>, style: &theme::Tab, cx: &AppContext)
+        -> ElementBox;
+    fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
+    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
+    fn is_singleton(&self, cx: &AppContext) -> bool;
+    fn set_nav_history(&mut self, _: ItemNavHistory, _: &mut ViewContext<Self>);
+    fn clone_on_split(&self, _workspace_id: WorkspaceId, _: &mut ViewContext<Self>) -> Option<Self>
+    where
+        Self: Sized,
+    {
+        None
+    }
+    fn is_dirty(&self, _: &AppContext) -> bool {
+        false
+    }
+    fn has_conflict(&self, _: &AppContext) -> bool {
+        false
+    }
+    fn can_save(&self, cx: &AppContext) -> bool;
+    fn save(
+        &mut self,
+        project: ModelHandle<Project>,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Result<()>>;
+    fn save_as(
+        &mut self,
+        project: ModelHandle<Project>,
+        abs_path: PathBuf,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Result<()>>;
+    fn reload(
+        &mut self,
+        project: ModelHandle<Project>,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Result<()>>;
+    fn git_diff_recalc(
+        &mut self,
+        _project: ModelHandle<Project>,
+        _cx: &mut ViewContext<Self>,
+    ) -> Task<Result<()>> {
+        Task::ready(Ok(()))
+    }
+    fn to_item_events(event: &Self::Event) -> Vec<ItemEvent>;
+    fn should_close_item_on_event(_: &Self::Event) -> bool {
+        false
+    }
+    fn should_update_tab_on_event(_: &Self::Event) -> bool {
+        false
+    }
+    fn is_edit_event(_: &Self::Event) -> bool {
+        false
+    }
+    fn act_as_type(
+        &self,
+        type_id: TypeId,
+        self_handle: &ViewHandle<Self>,
+        _: &AppContext,
+    ) -> Option<AnyViewHandle> {
+        if TypeId::of::<Self>() == type_id {
+            Some(self_handle.into())
+        } else {
+            None
+        }
+    }
+    fn as_searchable(&self, _: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+        None
+    }
+
+    fn breadcrumb_location(&self) -> ToolbarItemLocation {
+        ToolbarItemLocation::Hidden
+    }
+
+    fn breadcrumbs(&self, _theme: &Theme, _cx: &AppContext) -> Option<Vec<ElementBox>> {
+        None
+    }
+
+    fn added_to_workspace(&mut self, _workspace: &mut Workspace, _cx: &mut ViewContext<Self>) {}
+
+    fn serialized_item_kind() -> Option<&'static str>;
+
+    fn deserialize(
+        project: ModelHandle<Project>,
+        workspace: WeakViewHandle<Workspace>,
+        workspace_id: WorkspaceId,
+        item_id: ItemId,
+        cx: &mut ViewContext<Pane>,
+    ) -> Task<Result<ViewHandle<Self>>>;
+}
+
+pub trait ItemHandle: 'static + fmt::Debug {
+    fn subscribe_to_item_events(
+        &self,
+        cx: &mut MutableAppContext,
+        handler: Box<dyn Fn(ItemEvent, &mut MutableAppContext)>,
+    ) -> gpui::Subscription;
+    fn tab_description<'a>(&self, detail: usize, cx: &'a AppContext) -> Option<Cow<'a, str>>;
+    fn tab_content(&self, detail: Option<usize>, style: &theme::Tab, cx: &AppContext)
+        -> ElementBox;
+    fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
+    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
+    fn is_singleton(&self, cx: &AppContext) -> bool;
+    fn boxed_clone(&self) -> Box<dyn ItemHandle>;
+    fn clone_on_split(
+        &self,
+        workspace_id: WorkspaceId,
+        cx: &mut MutableAppContext,
+    ) -> Option<Box<dyn ItemHandle>>;
+    fn added_to_pane(
+        &self,
+        workspace: &mut Workspace,
+        pane: ViewHandle<Pane>,
+        cx: &mut ViewContext<Workspace>,
+    );
+    fn deactivated(&self, cx: &mut MutableAppContext);
+    fn workspace_deactivated(&self, cx: &mut MutableAppContext);
+    fn navigate(&self, data: Box<dyn Any>, cx: &mut MutableAppContext) -> bool;
+    fn id(&self) -> usize;
+    fn window_id(&self) -> usize;
+    fn to_any(&self) -> AnyViewHandle;
+    fn is_dirty(&self, cx: &AppContext) -> bool;
+    fn has_conflict(&self, cx: &AppContext) -> bool;
+    fn can_save(&self, cx: &AppContext) -> bool;
+    fn save(&self, project: ModelHandle<Project>, cx: &mut MutableAppContext) -> Task<Result<()>>;
+    fn save_as(
+        &self,
+        project: ModelHandle<Project>,
+        abs_path: PathBuf,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<()>>;
+    fn reload(&self, project: ModelHandle<Project>, cx: &mut MutableAppContext)
+        -> Task<Result<()>>;
+    fn git_diff_recalc(
+        &self,
+        project: ModelHandle<Project>,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<()>>;
+    fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option<AnyViewHandle>;
+    fn to_followable_item_handle(&self, cx: &AppContext) -> Option<Box<dyn FollowableItemHandle>>;
+    fn on_release(
+        &self,
+        cx: &mut MutableAppContext,
+        callback: Box<dyn FnOnce(&mut MutableAppContext)>,
+    ) -> gpui::Subscription;
+    fn to_searchable_item_handle(&self, cx: &AppContext) -> Option<Box<dyn SearchableItemHandle>>;
+    fn breadcrumb_location(&self, cx: &AppContext) -> ToolbarItemLocation;
+    fn breadcrumbs(&self, theme: &Theme, cx: &AppContext) -> Option<Vec<ElementBox>>;
+    fn serialized_item_kind(&self) -> Option<&'static str>;
+}
+
+pub trait WeakItemHandle {
+    fn id(&self) -> usize;
+    fn window_id(&self) -> usize;
+    fn upgrade(&self, cx: &AppContext) -> Option<Box<dyn ItemHandle>>;
+}
+
+impl dyn ItemHandle {
+    pub fn downcast<T: View>(&self) -> Option<ViewHandle<T>> {
+        self.to_any().downcast()
+    }
+
+    pub fn act_as<T: View>(&self, cx: &AppContext) -> Option<ViewHandle<T>> {
+        self.act_as_type(TypeId::of::<T>(), cx)
+            .and_then(|t| t.downcast())
+    }
+}
+
+impl<T: Item> ItemHandle for ViewHandle<T> {
+    fn subscribe_to_item_events(
+        &self,
+        cx: &mut MutableAppContext,
+        handler: Box<dyn Fn(ItemEvent, &mut MutableAppContext)>,
+    ) -> gpui::Subscription {
+        cx.subscribe(self, move |_, event, cx| {
+            for item_event in T::to_item_events(event) {
+                handler(item_event, cx)
+            }
+        })
+    }
+
+    fn tab_description<'a>(&self, detail: usize, cx: &'a AppContext) -> Option<Cow<'a, str>> {
+        self.read(cx).tab_description(detail, cx)
+    }
+
+    fn tab_content(
+        &self,
+        detail: Option<usize>,
+        style: &theme::Tab,
+        cx: &AppContext,
+    ) -> ElementBox {
+        self.read(cx).tab_content(detail, style, cx)
+    }
+
+    fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
+        self.read(cx).project_path(cx)
+    }
+
+    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]> {
+        self.read(cx).project_entry_ids(cx)
+    }
+
+    fn is_singleton(&self, cx: &AppContext) -> bool {
+        self.read(cx).is_singleton(cx)
+    }
+
+    fn boxed_clone(&self) -> Box<dyn ItemHandle> {
+        Box::new(self.clone())
+    }
+
+    fn clone_on_split(
+        &self,
+        workspace_id: WorkspaceId,
+        cx: &mut MutableAppContext,
+    ) -> Option<Box<dyn ItemHandle>> {
+        self.update(cx, |item, cx| {
+            cx.add_option_view(|cx| item.clone_on_split(workspace_id, cx))
+        })
+        .map(|handle| Box::new(handle) as Box<dyn ItemHandle>)
+    }
+
+    fn added_to_pane(
+        &self,
+        workspace: &mut Workspace,
+        pane: ViewHandle<Pane>,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        let history = pane.read(cx).nav_history_for_item(self);
+        self.update(cx, |this, cx| {
+            this.set_nav_history(history, cx);
+            this.added_to_workspace(workspace, cx);
+        });
+
+        if let Some(followed_item) = self.to_followable_item_handle(cx) {
+            if let Some(message) = followed_item.to_state_proto(cx) {
+                workspace.update_followers(
+                    proto::update_followers::Variant::CreateView(proto::View {
+                        id: followed_item.id() as u64,
+                        variant: Some(message),
+                        leader_id: workspace.leader_for_pane(&pane).map(|id| id.0),
+                    }),
+                    cx,
+                );
+            }
+        }
+
+        if workspace
+            .panes_by_item
+            .insert(self.id(), pane.downgrade())
+            .is_none()
+        {
+            let mut pending_autosave = DelayedDebouncedEditAction::new();
+            let mut pending_git_update = DelayedDebouncedEditAction::new();
+            let pending_update = Rc::new(RefCell::new(None));
+            let pending_update_scheduled = Rc::new(AtomicBool::new(false));
+
+            let mut event_subscription =
+                Some(cx.subscribe(self, move |workspace, item, event, cx| {
+                    let pane = if let Some(pane) = workspace
+                        .panes_by_item
+                        .get(&item.id())
+                        .and_then(|pane| pane.upgrade(cx))
+                    {
+                        pane
+                    } else {
+                        log::error!("unexpected item event after pane was dropped");
+                        return;
+                    };
+
+                    if let Some(item) = item.to_followable_item_handle(cx) {
+                        let leader_id = workspace.leader_for_pane(&pane);
+
+                        if leader_id.is_some() && item.should_unfollow_on_event(event, cx) {
+                            workspace.unfollow(&pane, cx);
+                        }
+
+                        if item.add_event_to_update_proto(
+                            event,
+                            &mut *pending_update.borrow_mut(),
+                            cx,
+                        ) && !pending_update_scheduled.load(Ordering::SeqCst)
+                        {
+                            pending_update_scheduled.store(true, Ordering::SeqCst);
+                            cx.after_window_update({
+                                let pending_update = pending_update.clone();
+                                let pending_update_scheduled = pending_update_scheduled.clone();
+                                move |this, cx| {
+                                    pending_update_scheduled.store(false, Ordering::SeqCst);
+                                    this.update_followers(
+                                        proto::update_followers::Variant::UpdateView(
+                                            proto::UpdateView {
+                                                id: item.id() as u64,
+                                                variant: pending_update.borrow_mut().take(),
+                                                leader_id: leader_id.map(|id| id.0),
+                                            },
+                                        ),
+                                        cx,
+                                    );
+                                }
+                            });
+                        }
+                    }
+
+                    for item_event in T::to_item_events(event).into_iter() {
+                        match item_event {
+                            ItemEvent::CloseItem => {
+                                Pane::close_item(workspace, pane, item.id(), cx)
+                                    .detach_and_log_err(cx);
+                                return;
+                            }
+
+                            ItemEvent::UpdateTab => {
+                                pane.update(cx, |_, cx| {
+                                    cx.emit(pane::Event::ChangeItemTitle);
+                                    cx.notify();
+                                });
+                            }
+
+                            ItemEvent::Edit => {
+                                if let Autosave::AfterDelay { milliseconds } =
+                                    cx.global::<Settings>().autosave
+                                {
+                                    let delay = Duration::from_millis(milliseconds);
+                                    let item = item.clone();
+                                    pending_autosave.fire_new(
+                                        delay,
+                                        workspace,
+                                        cx,
+                                        |project, mut cx| async move {
+                                            cx.update(|cx| Pane::autosave_item(&item, project, cx))
+                                                .await
+                                                .log_err();
+                                        },
+                                    );
+                                }
+
+                                let settings = cx.global::<Settings>();
+                                let debounce_delay = settings.git_overrides.gutter_debounce;
+
+                                let item = item.clone();
+
+                                if let Some(delay) = debounce_delay {
+                                    const MIN_GIT_DELAY: u64 = 50;
+
+                                    let delay = delay.max(MIN_GIT_DELAY);
+                                    let duration = Duration::from_millis(delay);
+
+                                    pending_git_update.fire_new(
+                                        duration,
+                                        workspace,
+                                        cx,
+                                        |project, mut cx| async move {
+                                            cx.update(|cx| item.git_diff_recalc(project, cx))
+                                                .await
+                                                .log_err();
+                                        },
+                                    );
+                                } else {
+                                    let project = workspace.project().downgrade();
+                                    cx.spawn_weak(|_, mut cx| async move {
+                                        if let Some(project) = project.upgrade(&cx) {
+                                            cx.update(|cx| item.git_diff_recalc(project, cx))
+                                                .await
+                                                .log_err();
+                                        }
+                                    })
+                                    .detach();
+                                }
+                            }
+
+                            _ => {}
+                        }
+                    }
+                }));
+
+            cx.observe_focus(self, move |workspace, item, focused, cx| {
+                if !focused && cx.global::<Settings>().autosave == Autosave::OnFocusChange {
+                    Pane::autosave_item(&item, workspace.project.clone(), cx)
+                        .detach_and_log_err(cx);
+                }
+            })
+            .detach();
+
+            let item_id = self.id();
+            cx.observe_release(self, move |workspace, _, _| {
+                workspace.panes_by_item.remove(&item_id);
+                event_subscription.take();
+            })
+            .detach();
+        }
+
+        cx.defer(|workspace, cx| {
+            workspace.serialize_workspace(cx);
+        });
+    }
+
+    fn deactivated(&self, cx: &mut MutableAppContext) {
+        self.update(cx, |this, cx| this.deactivated(cx));
+    }
+
+    fn workspace_deactivated(&self, cx: &mut MutableAppContext) {
+        self.update(cx, |this, cx| this.workspace_deactivated(cx));
+    }
+
+    fn navigate(&self, data: Box<dyn Any>, cx: &mut MutableAppContext) -> bool {
+        self.update(cx, |this, cx| this.navigate(data, cx))
+    }
+
+    fn id(&self) -> usize {
+        self.id()
+    }
+
+    fn window_id(&self) -> usize {
+        self.window_id()
+    }
+
+    fn to_any(&self) -> AnyViewHandle {
+        self.into()
+    }
+
+    fn is_dirty(&self, cx: &AppContext) -> bool {
+        self.read(cx).is_dirty(cx)
+    }
+
+    fn has_conflict(&self, cx: &AppContext) -> bool {
+        self.read(cx).has_conflict(cx)
+    }
+
+    fn can_save(&self, cx: &AppContext) -> bool {
+        self.read(cx).can_save(cx)
+    }
+
+    fn save(&self, project: ModelHandle<Project>, cx: &mut MutableAppContext) -> Task<Result<()>> {
+        self.update(cx, |item, cx| item.save(project, cx))
+    }
+
+    fn save_as(
+        &self,
+        project: ModelHandle<Project>,
+        abs_path: PathBuf,
+        cx: &mut MutableAppContext,
+    ) -> Task<anyhow::Result<()>> {
+        self.update(cx, |item, cx| item.save_as(project, abs_path, cx))
+    }
+
+    fn reload(
+        &self,
+        project: ModelHandle<Project>,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<()>> {
+        self.update(cx, |item, cx| item.reload(project, cx))
+    }
+
+    fn git_diff_recalc(
+        &self,
+        project: ModelHandle<Project>,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<()>> {
+        self.update(cx, |item, cx| item.git_diff_recalc(project, cx))
+    }
+
+    fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option<AnyViewHandle> {
+        self.read(cx).act_as_type(type_id, self, cx)
+    }
+
+    fn to_followable_item_handle(&self, cx: &AppContext) -> Option<Box<dyn FollowableItemHandle>> {
+        if cx.has_global::<FollowableItemBuilders>() {
+            let builders = cx.global::<FollowableItemBuilders>();
+            let item = self.to_any();
+            Some(builders.get(&item.view_type())?.1(item))
+        } else {
+            None
+        }
+    }
+
+    fn on_release(
+        &self,
+        cx: &mut MutableAppContext,
+        callback: Box<dyn FnOnce(&mut MutableAppContext)>,
+    ) -> gpui::Subscription {
+        cx.observe_release(self, move |_, cx| callback(cx))
+    }
+
+    fn to_searchable_item_handle(&self, cx: &AppContext) -> Option<Box<dyn SearchableItemHandle>> {
+        self.read(cx).as_searchable(self)
+    }
+
+    fn breadcrumb_location(&self, cx: &AppContext) -> ToolbarItemLocation {
+        self.read(cx).breadcrumb_location()
+    }
+
+    fn breadcrumbs(&self, theme: &Theme, cx: &AppContext) -> Option<Vec<ElementBox>> {
+        self.read(cx).breadcrumbs(theme, cx)
+    }
+
+    fn serialized_item_kind(&self) -> Option<&'static str> {
+        T::serialized_item_kind()
+    }
+}
+
+impl From<Box<dyn ItemHandle>> for AnyViewHandle {
+    fn from(val: Box<dyn ItemHandle>) -> Self {
+        val.to_any()
+    }
+}
+
+impl From<&Box<dyn ItemHandle>> for AnyViewHandle {
+    fn from(val: &Box<dyn ItemHandle>) -> Self {
+        val.to_any()
+    }
+}
+
+impl Clone for Box<dyn ItemHandle> {
+    fn clone(&self) -> Box<dyn ItemHandle> {
+        self.boxed_clone()
+    }
+}
+
+impl<T: Item> WeakItemHandle for WeakViewHandle<T> {
+    fn id(&self) -> usize {
+        self.id()
+    }
+
+    fn window_id(&self) -> usize {
+        self.window_id()
+    }
+
+    fn upgrade(&self, cx: &AppContext) -> Option<Box<dyn ItemHandle>> {
+        self.upgrade(cx).map(|v| Box::new(v) as Box<dyn ItemHandle>)
+    }
+}
+
+pub trait ProjectItem: Item {
+    type Item: project::Item;
+
+    fn for_project_item(
+        project: ModelHandle<Project>,
+        item: ModelHandle<Self::Item>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self;
+}
+
+pub trait FollowableItem: Item {
+    fn to_state_proto(&self, cx: &AppContext) -> Option<proto::view::Variant>;
+    fn from_state_proto(
+        pane: ViewHandle<Pane>,
+        project: ModelHandle<Project>,
+        state: &mut Option<proto::view::Variant>,
+        cx: &mut MutableAppContext,
+    ) -> Option<Task<Result<ViewHandle<Self>>>>;
+    fn add_event_to_update_proto(
+        &self,
+        event: &Self::Event,
+        update: &mut Option<proto::update_view::Variant>,
+        cx: &AppContext,
+    ) -> bool;
+    fn apply_update_proto(
+        &mut self,
+        message: proto::update_view::Variant,
+        cx: &mut ViewContext<Self>,
+    ) -> Result<()>;
+
+    fn set_leader_replica_id(&mut self, leader_replica_id: Option<u16>, cx: &mut ViewContext<Self>);
+    fn should_unfollow_on_event(event: &Self::Event, cx: &AppContext) -> bool;
+}
+
+pub trait FollowableItemHandle: ItemHandle {
+    fn set_leader_replica_id(&self, leader_replica_id: Option<u16>, cx: &mut MutableAppContext);
+    fn to_state_proto(&self, cx: &AppContext) -> Option<proto::view::Variant>;
+    fn add_event_to_update_proto(
+        &self,
+        event: &dyn Any,
+        update: &mut Option<proto::update_view::Variant>,
+        cx: &AppContext,
+    ) -> bool;
+    fn apply_update_proto(
+        &self,
+        message: proto::update_view::Variant,
+        cx: &mut MutableAppContext,
+    ) -> Result<()>;
+    fn should_unfollow_on_event(&self, event: &dyn Any, cx: &AppContext) -> bool;
+}
+
+impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
+    fn set_leader_replica_id(&self, leader_replica_id: Option<u16>, cx: &mut MutableAppContext) {
+        self.update(cx, |this, cx| {
+            this.set_leader_replica_id(leader_replica_id, cx)
+        })
+    }
+
+    fn to_state_proto(&self, cx: &AppContext) -> Option<proto::view::Variant> {
+        self.read(cx).to_state_proto(cx)
+    }
+
+    fn add_event_to_update_proto(
+        &self,
+        event: &dyn Any,
+        update: &mut Option<proto::update_view::Variant>,
+        cx: &AppContext,
+    ) -> bool {
+        if let Some(event) = event.downcast_ref() {
+            self.read(cx).add_event_to_update_proto(event, update, cx)
+        } else {
+            false
+        }
+    }
+
+    fn apply_update_proto(
+        &self,
+        message: proto::update_view::Variant,
+        cx: &mut MutableAppContext,
+    ) -> Result<()> {
+        self.update(cx, |this, cx| this.apply_update_proto(message, cx))
+    }
+
+    fn should_unfollow_on_event(&self, event: &dyn Any, cx: &AppContext) -> bool {
+        if let Some(event) = event.downcast_ref() {
+            T::should_unfollow_on_event(event, cx)
+        } else {
+            false
+        }
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test {
+    use std::{any::Any, borrow::Cow, cell::Cell};
+
+    use gpui::{
+        elements::Empty, AppContext, Element, ElementBox, Entity, ModelHandle, RenderContext, Task,
+        View, ViewContext, ViewHandle, WeakViewHandle,
+    };
+    use project::{Project, ProjectEntryId, ProjectPath};
+    use smallvec::SmallVec;
+
+    use crate::{sidebar::SidebarItem, ItemId, ItemNavHistory, Pane, Workspace, WorkspaceId};
+
+    use super::{Item, ItemEvent};
+
+    pub struct TestItem {
+        pub state: String,
+        pub label: String,
+        pub save_count: usize,
+        pub save_as_count: usize,
+        pub reload_count: usize,
+        pub is_dirty: bool,
+        pub is_singleton: bool,
+        pub has_conflict: bool,
+        pub project_entry_ids: Vec<ProjectEntryId>,
+        pub project_path: Option<ProjectPath>,
+        pub nav_history: Option<ItemNavHistory>,
+        pub tab_descriptions: Option<Vec<&'static str>>,
+        pub tab_detail: Cell<Option<usize>>,
+    }
+
+    pub enum TestItemEvent {
+        Edit,
+    }
+
+    impl Clone for TestItem {
+        fn clone(&self) -> Self {
+            Self {
+                state: self.state.clone(),
+                label: self.label.clone(),
+                save_count: self.save_count,
+                save_as_count: self.save_as_count,
+                reload_count: self.reload_count,
+                is_dirty: self.is_dirty,
+                is_singleton: self.is_singleton,
+                has_conflict: self.has_conflict,
+                project_entry_ids: self.project_entry_ids.clone(),
+                project_path: self.project_path.clone(),
+                nav_history: None,
+                tab_descriptions: None,
+                tab_detail: Default::default(),
+            }
+        }
+    }
+
+    impl TestItem {
+        pub fn new() -> Self {
+            Self {
+                state: String::new(),
+                label: String::new(),
+                save_count: 0,
+                save_as_count: 0,
+                reload_count: 0,
+                is_dirty: false,
+                has_conflict: false,
+                project_entry_ids: Vec::new(),
+                project_path: None,
+                is_singleton: true,
+                nav_history: None,
+                tab_descriptions: None,
+                tab_detail: Default::default(),
+            }
+        }
+
+        pub fn with_label(mut self, state: &str) -> Self {
+            self.label = state.to_string();
+            self
+        }
+
+        pub fn with_singleton(mut self, singleton: bool) -> Self {
+            self.is_singleton = singleton;
+            self
+        }
+
+        pub fn with_project_entry_ids(mut self, project_entry_ids: &[u64]) -> Self {
+            self.project_entry_ids.extend(
+                project_entry_ids
+                    .iter()
+                    .copied()
+                    .map(ProjectEntryId::from_proto),
+            );
+            self
+        }
+
+        pub fn set_state(&mut self, state: String, cx: &mut ViewContext<Self>) {
+            self.push_to_nav_history(cx);
+            self.state = state;
+        }
+
+        fn push_to_nav_history(&mut self, cx: &mut ViewContext<Self>) {
+            if let Some(history) = &mut self.nav_history {
+                history.push(Some(Box::new(self.state.clone())), cx);
+            }
+        }
+    }
+
+    impl Entity for TestItem {
+        type Event = TestItemEvent;
+    }
+
+    impl View for TestItem {
+        fn ui_name() -> &'static str {
+            "TestItem"
+        }
+
+        fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
+            Empty::new().boxed()
+        }
+    }
+
+    impl Item for TestItem {
+        fn tab_description<'a>(&'a self, detail: usize, _: &'a AppContext) -> Option<Cow<'a, str>> {
+            self.tab_descriptions.as_ref().and_then(|descriptions| {
+                let description = *descriptions.get(detail).or_else(|| descriptions.last())?;
+                Some(description.into())
+            })
+        }
+
+        fn tab_content(&self, detail: Option<usize>, _: &theme::Tab, _: &AppContext) -> ElementBox {
+            self.tab_detail.set(detail);
+            Empty::new().boxed()
+        }
+
+        fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
+            self.project_path.clone()
+        }
+
+        fn project_entry_ids(&self, _: &AppContext) -> SmallVec<[ProjectEntryId; 3]> {
+            self.project_entry_ids.iter().copied().collect()
+        }
+
+        fn is_singleton(&self, _: &AppContext) -> bool {
+            self.is_singleton
+        }
+
+        fn set_nav_history(&mut self, history: ItemNavHistory, _: &mut ViewContext<Self>) {
+            self.nav_history = Some(history);
+        }
+
+        fn navigate(&mut self, state: Box<dyn Any>, _: &mut ViewContext<Self>) -> bool {
+            let state = *state.downcast::<String>().unwrap_or_default();
+            if state != self.state {
+                self.state = state;
+                true
+            } else {
+                false
+            }
+        }
+
+        fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
+            self.push_to_nav_history(cx);
+        }
+
+        fn clone_on_split(
+            &self,
+            _workspace_id: WorkspaceId,
+            _: &mut ViewContext<Self>,
+        ) -> Option<Self>
+        where
+            Self: Sized,
+        {
+            Some(self.clone())
+        }
+
+        fn is_dirty(&self, _: &AppContext) -> bool {
+            self.is_dirty
+        }
+
+        fn has_conflict(&self, _: &AppContext) -> bool {
+            self.has_conflict
+        }
+
+        fn can_save(&self, _: &AppContext) -> bool {
+            !self.project_entry_ids.is_empty()
+        }
+
+        fn save(
+            &mut self,
+            _: ModelHandle<Project>,
+            _: &mut ViewContext<Self>,
+        ) -> Task<anyhow::Result<()>> {
+            self.save_count += 1;
+            self.is_dirty = false;
+            Task::ready(Ok(()))
+        }
+
+        fn save_as(
+            &mut self,
+            _: ModelHandle<Project>,
+            _: std::path::PathBuf,
+            _: &mut ViewContext<Self>,
+        ) -> Task<anyhow::Result<()>> {
+            self.save_as_count += 1;
+            self.is_dirty = false;
+            Task::ready(Ok(()))
+        }
+
+        fn reload(
+            &mut self,
+            _: ModelHandle<Project>,
+            _: &mut ViewContext<Self>,
+        ) -> Task<anyhow::Result<()>> {
+            self.reload_count += 1;
+            self.is_dirty = false;
+            Task::ready(Ok(()))
+        }
+
+        fn to_item_events(_: &Self::Event) -> Vec<ItemEvent> {
+            vec![ItemEvent::UpdateTab, ItemEvent::Edit]
+        }
+
+        fn serialized_item_kind() -> Option<&'static str> {
+            None
+        }
+
+        fn deserialize(
+            _project: ModelHandle<Project>,
+            _workspace: WeakViewHandle<Workspace>,
+            _workspace_id: WorkspaceId,
+            _item_id: ItemId,
+            _cx: &mut ViewContext<Pane>,
+        ) -> Task<anyhow::Result<ViewHandle<Self>>> {
+            unreachable!("Cannot deserialize test item")
+        }
+    }
+
+    impl SidebarItem for TestItem {}
+}

crates/workspace/src/notifications.rs 🔗

@@ -0,0 +1,280 @@
+use std::{any::TypeId, ops::DerefMut};
+
+use collections::HashSet;
+use gpui::{AnyViewHandle, Entity, MutableAppContext, View, ViewContext, ViewHandle};
+
+use crate::Workspace;
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.set_global(NotificationTracker::new());
+    simple_message_notification::init(cx);
+}
+
+pub trait Notification: View {
+    fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool;
+}
+
+pub trait NotificationHandle {
+    fn id(&self) -> usize;
+    fn to_any(&self) -> AnyViewHandle;
+}
+
+impl<T: Notification> NotificationHandle for ViewHandle<T> {
+    fn id(&self) -> usize {
+        self.id()
+    }
+
+    fn to_any(&self) -> AnyViewHandle {
+        self.into()
+    }
+}
+
+impl From<&dyn NotificationHandle> for AnyViewHandle {
+    fn from(val: &dyn NotificationHandle) -> Self {
+        val.to_any()
+    }
+}
+
+struct NotificationTracker {
+    notifications_sent: HashSet<TypeId>,
+}
+
+impl std::ops::Deref for NotificationTracker {
+    type Target = HashSet<TypeId>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.notifications_sent
+    }
+}
+
+impl DerefMut for NotificationTracker {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.notifications_sent
+    }
+}
+
+impl NotificationTracker {
+    fn new() -> Self {
+        Self {
+            notifications_sent: HashSet::default(),
+        }
+    }
+}
+
+impl Workspace {
+    pub fn show_notification_once<V: Notification>(
+        &mut self,
+        id: usize,
+        cx: &mut ViewContext<Self>,
+        build_notification: impl FnOnce(&mut ViewContext<Self>) -> ViewHandle<V>,
+    ) {
+        if !cx
+            .global::<NotificationTracker>()
+            .contains(&TypeId::of::<V>())
+        {
+            cx.update_global::<NotificationTracker, _, _>(|tracker, _| {
+                tracker.insert(TypeId::of::<V>())
+            });
+
+            self.show_notification::<V>(id, cx, build_notification)
+        }
+    }
+
+    pub fn show_notification<V: Notification>(
+        &mut self,
+        id: usize,
+        cx: &mut ViewContext<Self>,
+        build_notification: impl FnOnce(&mut ViewContext<Self>) -> ViewHandle<V>,
+    ) {
+        let type_id = TypeId::of::<V>();
+        if self
+            .notifications
+            .iter()
+            .all(|(existing_type_id, existing_id, _)| {
+                (*existing_type_id, *existing_id) != (type_id, id)
+            })
+        {
+            let notification = build_notification(cx);
+            cx.subscribe(&notification, move |this, handle, event, cx| {
+                if handle.read(cx).should_dismiss_notification_on_event(event) {
+                    this.dismiss_notification(type_id, id, cx);
+                }
+            })
+            .detach();
+            self.notifications
+                .push((type_id, id, Box::new(notification)));
+            cx.notify();
+        }
+    }
+
+    fn dismiss_notification(&mut self, type_id: TypeId, id: usize, cx: &mut ViewContext<Self>) {
+        self.notifications
+            .retain(|(existing_type_id, existing_id, _)| {
+                if (*existing_type_id, *existing_id) == (type_id, id) {
+                    cx.notify();
+                    false
+                } else {
+                    true
+                }
+            });
+    }
+}
+
+pub mod simple_message_notification {
+    use std::process::Command;
+
+    use gpui::{
+        actions,
+        elements::{Flex, MouseEventHandler, Padding, ParentElement, Svg, Text},
+        impl_actions, Action, CursorStyle, Element, Entity, MouseButton, MutableAppContext, View,
+        ViewContext,
+    };
+    use menu::Cancel;
+    use serde::Deserialize;
+    use settings::Settings;
+
+    use crate::Workspace;
+
+    use super::Notification;
+
+    actions!(message_notifications, [CancelMessageNotification]);
+
+    #[derive(Clone, Default, Deserialize, PartialEq)]
+    pub struct OsOpen(pub String);
+
+    impl_actions!(message_notifications, [OsOpen]);
+
+    pub fn init(cx: &mut MutableAppContext) {
+        cx.add_action(MessageNotification::dismiss);
+        cx.add_action(
+            |_workspace: &mut Workspace, open_action: &OsOpen, _cx: &mut ViewContext<Workspace>| {
+                #[cfg(target_os = "macos")]
+                {
+                    let mut command = Command::new("open");
+                    command.arg(open_action.0.clone());
+
+                    command.spawn().ok();
+                }
+            },
+        )
+    }
+
+    pub struct MessageNotification {
+        message: String,
+        click_action: Box<dyn Action>,
+        click_message: String,
+    }
+
+    pub enum MessageNotificationEvent {
+        Dismiss,
+    }
+
+    impl Entity for MessageNotification {
+        type Event = MessageNotificationEvent;
+    }
+
+    impl MessageNotification {
+        pub fn new<S1: AsRef<str>, A: Action, S2: AsRef<str>>(
+            message: S1,
+            click_action: A,
+            click_message: S2,
+        ) -> Self {
+            Self {
+                message: message.as_ref().to_string(),
+                click_action: Box::new(click_action) as Box<dyn Action>,
+                click_message: click_message.as_ref().to_string(),
+            }
+        }
+
+        pub fn dismiss(&mut self, _: &CancelMessageNotification, cx: &mut ViewContext<Self>) {
+            cx.emit(MessageNotificationEvent::Dismiss);
+        }
+    }
+
+    impl View for MessageNotification {
+        fn ui_name() -> &'static str {
+            "MessageNotification"
+        }
+
+        fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
+            let theme = cx.global::<Settings>().theme.clone();
+            let theme = &theme.update_notification;
+
+            enum MessageNotificationTag {}
+
+            let click_action = self.click_action.boxed_clone();
+            let click_message = self.click_message.clone();
+            let message = self.message.clone();
+
+            MouseEventHandler::<MessageNotificationTag>::new(0, cx, |state, cx| {
+                Flex::column()
+                    .with_child(
+                        Flex::row()
+                            .with_child(
+                                Text::new(message, theme.message.text.clone())
+                                    .contained()
+                                    .with_style(theme.message.container)
+                                    .aligned()
+                                    .top()
+                                    .left()
+                                    .flex(1., true)
+                                    .boxed(),
+                            )
+                            .with_child(
+                                MouseEventHandler::<Cancel>::new(0, cx, |state, _| {
+                                    let style = theme.dismiss_button.style_for(state, false);
+                                    Svg::new("icons/x_mark_8.svg")
+                                        .with_color(style.color)
+                                        .constrained()
+                                        .with_width(style.icon_width)
+                                        .aligned()
+                                        .contained()
+                                        .with_style(style.container)
+                                        .constrained()
+                                        .with_width(style.button_width)
+                                        .with_height(style.button_width)
+                                        .boxed()
+                                })
+                                .with_padding(Padding::uniform(5.))
+                                .on_click(MouseButton::Left, move |_, cx| {
+                                    cx.dispatch_action(CancelMessageNotification)
+                                })
+                                .aligned()
+                                .constrained()
+                                .with_height(
+                                    cx.font_cache().line_height(theme.message.text.font_size),
+                                )
+                                .aligned()
+                                .top()
+                                .flex_float()
+                                .boxed(),
+                            )
+                            .boxed(),
+                    )
+                    .with_child({
+                        let style = theme.action_message.style_for(state, false);
+
+                        Text::new(click_message, style.text.clone())
+                            .contained()
+                            .with_style(style.container)
+                            .boxed()
+                    })
+                    .contained()
+                    .boxed()
+            })
+            .with_cursor_style(CursorStyle::PointingHand)
+            .on_click(MouseButton::Left, move |_, cx| {
+                cx.dispatch_any_action(click_action.boxed_clone())
+            })
+            .boxed()
+        }
+    }
+
+    impl Notification for MessageNotification {
+        fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
+            match event {
+                MessageNotificationEvent::Dismiss => true,
+            }
+        }
+    }
+}

crates/workspace/src/pane.rs 🔗

@@ -3,8 +3,9 @@ mod dragged_item_receiver;
 use super::{ItemHandle, SplitDirection};
 use crate::{
     dock::{icon_for_dock_anchor, AnchorDockBottom, AnchorDockRight, ExpandDock, HideDock},
+    item::WeakItemHandle,
     toolbar::Toolbar,
-    Item, NewFile, NewSearch, NewTerminal, WeakItemHandle, Workspace,
+    Item, NewFile, NewSearch, NewTerminal, Workspace,
 };
 use anyhow::Result;
 use collections::{HashMap, HashSet, VecDeque};
@@ -1634,7 +1635,7 @@ mod tests {
     use std::sync::Arc;
 
     use super::*;
-    use crate::tests::TestItem;
+    use crate::item::test::TestItem;
     use gpui::{executor::Deterministic, TestAppContext};
     use project::FakeFs;
 
@@ -1645,8 +1646,9 @@ mod tests {
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, None, cx).await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
         let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
 
         // 1. Add with a destination index
@@ -1734,8 +1736,9 @@ mod tests {
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, None, cx).await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
         let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
 
         // 1. Add with a destination index
@@ -1811,8 +1814,9 @@ mod tests {
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, None, cx).await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
         let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
 
         // singleton view
@@ -1922,7 +1926,7 @@ mod tests {
 
         let project = Project::test(fs, None, cx).await;
         let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+            cx.add_window(|cx| Workspace::new(None, 0, project, |_, _| unimplemented!(), cx));
         let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
 
         add_labled_item(&workspace, &pane, "A", cx);

crates/workspace/src/pane_group.rs 🔗

@@ -13,10 +13,14 @@ use theme::Theme;
 
 #[derive(Clone, Debug, Eq, PartialEq)]
 pub struct PaneGroup {
-    root: Member,
+    pub(crate) root: Member,
 }
 
 impl PaneGroup {
+    pub(crate) fn with_root(root: Member) -> Self {
+        Self { root }
+    }
+
     pub fn new(pane: ViewHandle<Pane>) -> Self {
         Self {
             root: Member::Pane(pane),
@@ -85,7 +89,7 @@ impl PaneGroup {
 }
 
 #[derive(Clone, Debug, Eq, PartialEq)]
-enum Member {
+pub(crate) enum Member {
     Axis(PaneAxis),
     Pane(ViewHandle<Pane>),
 }
@@ -276,9 +280,9 @@ impl Member {
 }
 
 #[derive(Clone, Debug, Eq, PartialEq)]
-struct PaneAxis {
-    axis: Axis,
-    members: Vec<Member>,
+pub(crate) struct PaneAxis {
+    pub axis: Axis,
+    pub members: Vec<Member>,
 }
 
 impl PaneAxis {

crates/workspace/src/persistence.rs 🔗

@@ -0,0 +1,836 @@
+#![allow(dead_code)]
+
+pub mod model;
+
+use std::path::Path;
+
+use anyhow::{anyhow, bail, Context, Result};
+use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql};
+use gpui::Axis;
+
+use util::{iife, unzip_option, ResultExt};
+
+use crate::dock::DockPosition;
+use crate::WorkspaceId;
+
+use model::{
+    GroupId, PaneId, SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace,
+    WorkspaceLocation,
+};
+
+define_connection! {
+    pub static ref DB: WorkspaceDb<()> =
+        &[sql!(
+            CREATE TABLE workspaces(
+                workspace_id INTEGER PRIMARY KEY,
+                workspace_location BLOB UNIQUE,
+                dock_visible INTEGER, // Boolean
+                dock_anchor TEXT, // Enum: 'Bottom' / 'Right' / 'Expanded'
+                dock_pane INTEGER, // NULL indicates that we don't have a dock pane yet
+                left_sidebar_open INTEGER, //Boolean
+                timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
+                FOREIGN KEY(dock_pane) REFERENCES panes(pane_id)
+            ) STRICT;
+            
+            CREATE TABLE pane_groups(
+                group_id INTEGER PRIMARY KEY,
+                workspace_id INTEGER NOT NULL,
+                parent_group_id INTEGER, // NULL indicates that this is a root node
+                position INTEGER, // NULL indicates that this is a root node
+                axis TEXT NOT NULL, // Enum: 'Vertical' / 'Horizontal'
+                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+                ON DELETE CASCADE
+                ON UPDATE CASCADE,
+                FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
+            ) STRICT;
+            
+            CREATE TABLE panes(
+                pane_id INTEGER PRIMARY KEY,
+                workspace_id INTEGER NOT NULL,
+                active INTEGER NOT NULL, // Boolean
+                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+                ON DELETE CASCADE
+                ON UPDATE CASCADE
+            ) STRICT;
+            
+            CREATE TABLE center_panes(
+                pane_id INTEGER PRIMARY KEY,
+                parent_group_id INTEGER, // NULL means that this is a root pane
+                position INTEGER, // NULL means that this is a root pane
+                FOREIGN KEY(pane_id) REFERENCES panes(pane_id)
+                ON DELETE CASCADE,
+                FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
+            ) STRICT;
+            
+            CREATE TABLE items(
+                item_id INTEGER NOT NULL, // This is the item's view id, so this is not unique
+                workspace_id INTEGER NOT NULL,
+                pane_id INTEGER NOT NULL,
+                kind TEXT NOT NULL,
+                position INTEGER NOT NULL,
+                active INTEGER NOT NULL,
+                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+                ON DELETE CASCADE
+                ON UPDATE CASCADE,
+                FOREIGN KEY(pane_id) REFERENCES panes(pane_id)
+                ON DELETE CASCADE,
+                PRIMARY KEY(item_id, workspace_id)
+            ) STRICT;
+        )];
+}
+
+impl WorkspaceDb {
+    /// Returns a serialized workspace for the given worktree_roots. If the passed array
+    /// is empty, the most recent workspace is returned instead. If no workspace for the
+    /// passed roots is stored, returns none.
+    pub fn workspace_for_roots<P: AsRef<Path>>(
+        &self,
+        worktree_roots: &[P],
+    ) -> Option<SerializedWorkspace> {
+        let workspace_location: WorkspaceLocation = worktree_roots.into();
+
+        // Note that we re-assign the workspace_id here in case it's empty
+        // and we've grabbed the most recent workspace
+        let (workspace_id, workspace_location, left_sidebar_open, dock_position): (
+            WorkspaceId,
+            WorkspaceLocation,
+            bool,
+            DockPosition,
+        ) = iife!({
+            if worktree_roots.len() == 0 {
+                self.select_row(sql!(
+                    SELECT workspace_id, workspace_location, left_sidebar_open, dock_visible, dock_anchor
+                    FROM workspaces
+                    ORDER BY timestamp DESC LIMIT 1))?()?
+            } else {
+                self.select_row_bound(sql!(
+                    SELECT workspace_id, workspace_location, left_sidebar_open, dock_visible, dock_anchor
+                    FROM workspaces 
+                    WHERE workspace_location = ?))?(&workspace_location)?
+            }
+            .context("No workspaces found")
+        })
+        .warn_on_err()
+        .flatten()?;
+
+        Some(SerializedWorkspace {
+            id: workspace_id,
+            location: workspace_location.clone(),
+            dock_pane: self
+                .get_dock_pane(workspace_id)
+                .context("Getting dock pane")
+                .log_err()?,
+            center_group: self
+                .get_center_pane_group(workspace_id)
+                .context("Getting center group")
+                .log_err()?,
+            dock_position,
+            left_sidebar_open
+        })
+    }
+
+    /// Saves a workspace using the worktree roots. Will garbage collect any workspaces
+    /// that used this workspace previously
+    pub async fn save_workspace(&self, workspace: SerializedWorkspace) {
+        self.write(move |conn| {
+            conn.with_savepoint("update_worktrees", || {
+                // Clear out panes and pane_groups
+                conn.exec_bound(sql!(
+                    UPDATE workspaces SET dock_pane = NULL WHERE workspace_id = ?1;
+                    DELETE FROM pane_groups WHERE workspace_id = ?1;
+                    DELETE FROM panes WHERE workspace_id = ?1;))?(workspace.id)
+                .expect("Clearing old panes");
+
+                conn.exec_bound(sql!(
+                    DELETE FROM workspaces WHERE workspace_location = ? AND workspace_id != ?
+                ))?((&workspace.location, workspace.id.clone()))
+                .context("clearing out old locations")?;
+
+                // Upsert
+                conn.exec_bound(sql!(
+                        INSERT INTO workspaces(
+                            workspace_id,
+                            workspace_location,
+                            left_sidebar_open,
+                            dock_visible,
+                            dock_anchor,
+                            timestamp
+                        )
+                        VALUES (?1, ?2, ?3, ?4, ?5, CURRENT_TIMESTAMP)
+                        ON CONFLICT DO
+                            UPDATE SET
+                            workspace_location = ?2,
+                            left_sidebar_open = ?3,
+                            dock_visible = ?4,
+                            dock_anchor = ?5,
+                            timestamp = CURRENT_TIMESTAMP
+                ))?((workspace.id, &workspace.location, workspace.left_sidebar_open, workspace.dock_position))
+                .context("Updating workspace")?;
+
+                // Save center pane group and dock pane
+                Self::save_pane_group(conn, workspace.id, &workspace.center_group, None)
+                    .context("save pane group in save workspace")?;
+
+                let dock_id = Self::save_pane(conn, workspace.id, &workspace.dock_pane, None, true)
+                    .context("save pane in save workspace")?;
+
+                // Complete workspace initialization
+                conn.exec_bound(sql!(
+                    UPDATE workspaces
+                    SET dock_pane = ?
+                    WHERE workspace_id = ?
+                ))?((dock_id, workspace.id))
+                .context("Finishing initialization with dock pane")?;
+
+                Ok(())
+            })
+            .log_err();
+        })
+        .await;
+    }
+
+    query! {
+        pub async fn next_id() -> Result<WorkspaceId> {
+            INSERT INTO workspaces DEFAULT VALUES RETURNING workspace_id
+        }
+    }
+
+    query! {
+        pub fn recent_workspaces(limit: usize) -> Result<Vec<(WorkspaceId, WorkspaceLocation)>> {
+            SELECT workspace_id, workspace_location 
+            FROM workspaces
+            WHERE workspace_location IS NOT NULL
+            ORDER BY timestamp DESC 
+            LIMIT ?
+        }
+    }
+
+    fn get_center_pane_group(&self, workspace_id: WorkspaceId) -> Result<SerializedPaneGroup> {
+        self.get_pane_group(workspace_id, None)?
+            .into_iter()
+            .next()
+            .context("No center pane group")
+    }
+
+    fn get_pane_group(
+        &self,
+        workspace_id: WorkspaceId,
+        group_id: Option<GroupId>,
+    ) -> Result<Vec<SerializedPaneGroup>> {
+        type GroupKey = (Option<GroupId>, WorkspaceId);
+        type GroupOrPane = (Option<GroupId>, Option<Axis>, Option<PaneId>, Option<bool>);
+        self.select_bound::<GroupKey, GroupOrPane>(sql!(
+            SELECT group_id, axis, pane_id, active
+                FROM (SELECT 
+                        group_id,
+                        axis,
+                        NULL as pane_id,
+                        NULL as active,
+                        position,
+                        parent_group_id,
+                        workspace_id
+                      FROM pane_groups 
+                     UNION
+                      SELECT 
+                        NULL,
+                        NULL,  
+                        center_panes.pane_id,
+                        panes.active as active,
+                        position,
+                        parent_group_id,
+                        panes.workspace_id as workspace_id
+                      FROM center_panes
+                      JOIN panes ON center_panes.pane_id = panes.pane_id) 
+            WHERE parent_group_id IS ? AND workspace_id = ?
+            ORDER BY position
+        ))?((group_id, workspace_id))?
+        .into_iter()
+        .map(|(group_id, axis, pane_id, active)| {
+            if let Some((group_id, axis)) = group_id.zip(axis) {
+                Ok(SerializedPaneGroup::Group {
+                    axis,
+                    children: self.get_pane_group(workspace_id, Some(group_id))?,
+                })
+            } else if let Some((pane_id, active)) = pane_id.zip(active) {
+                Ok(SerializedPaneGroup::Pane(SerializedPane::new(
+                    self.get_items(pane_id)?,
+                    active,
+                )))
+            } else {
+                bail!("Pane Group Child was neither a pane group or a pane");
+            }
+        })
+        // Filter out panes and pane groups which don't have any children or items
+        .filter(|pane_group| match pane_group {
+            Ok(SerializedPaneGroup::Group { children, .. }) => !children.is_empty(),
+            Ok(SerializedPaneGroup::Pane(pane)) => !pane.children.is_empty(),
+            _ => true,
+        })
+        .collect::<Result<_>>()
+    }
+
+   
+    fn save_pane_group(
+        conn: &Connection,
+        workspace_id: WorkspaceId,
+        pane_group: &SerializedPaneGroup,
+        parent: Option<(GroupId, usize)>,
+    ) -> Result<()> {
+        match pane_group {
+            SerializedPaneGroup::Group { axis, children } => {
+                let (parent_id, position) = unzip_option(parent);
+
+                let group_id = conn.select_row_bound::<_, i64>(sql!(
+                        INSERT INTO pane_groups(workspace_id, parent_group_id, position, axis) 
+                        VALUES (?, ?, ?, ?) 
+                        RETURNING group_id
+                ))?((
+                    workspace_id,
+                    parent_id,
+                    position,
+                    *axis,
+                ))?
+                .ok_or_else(|| anyhow!("Couldn't retrieve group_id from inserted pane_group"))?;
+
+                for (position, group) in children.iter().enumerate() {
+                    Self::save_pane_group(conn, workspace_id, group, Some((group_id, position)))?
+                }
+
+                Ok(())
+            }
+            SerializedPaneGroup::Pane(pane) => {
+                Self::save_pane(conn, workspace_id, &pane, parent, false)?;
+                Ok(())
+            }
+        }
+    }
+
+    fn get_dock_pane(&self, workspace_id: WorkspaceId) -> Result<SerializedPane> {
+        let (pane_id, active) = self.select_row_bound(sql!(
+            SELECT pane_id, active
+            FROM panes
+            WHERE pane_id = (SELECT dock_pane FROM workspaces WHERE workspace_id = ?)
+        ))?(
+            workspace_id,
+        )?
+        .context("No dock pane for workspace")?;
+
+        Ok(SerializedPane::new(
+            self.get_items(pane_id).context("Reading items")?,
+            active,
+        ))
+    }
+
+    fn save_pane(
+        conn: &Connection,
+        workspace_id: WorkspaceId,
+        pane: &SerializedPane,
+        parent: Option<(GroupId, usize)>, // None indicates BOTH dock pane AND center_pane
+        dock: bool,
+    ) -> Result<PaneId> {
+        let pane_id = conn.select_row_bound::<_, i64>(sql!(
+            INSERT INTO panes(workspace_id, active) 
+            VALUES (?, ?) 
+            RETURNING pane_id
+        ))?((workspace_id, pane.active))?
+        .ok_or_else(|| anyhow!("Could not retrieve inserted pane_id"))?;
+
+        if !dock {
+            let (parent_id, order) = unzip_option(parent);
+            conn.exec_bound(sql!(
+                INSERT INTO center_panes(pane_id, parent_group_id, position)
+                VALUES (?, ?, ?)
+            ))?((pane_id, parent_id, order))?;
+        }
+
+        Self::save_items(conn, workspace_id, pane_id, &pane.children).context("Saving items")?;
+
+        Ok(pane_id)
+    }
+
+    fn get_items(&self, pane_id: PaneId) -> Result<Vec<SerializedItem>> {
+        Ok(self.select_bound(sql!(
+            SELECT kind, item_id, active FROM items
+            WHERE pane_id = ?
+            ORDER BY position
+        ))?(pane_id)?)
+    }
+
+    fn save_items(
+        conn: &Connection,
+        workspace_id: WorkspaceId,
+        pane_id: PaneId,
+        items: &[SerializedItem],
+    ) -> Result<()> {
+        let mut insert = conn.exec_bound(sql!(
+            INSERT INTO items(workspace_id, pane_id, position, kind, item_id, active) VALUES (?, ?, ?, ?, ?, ?)
+        )).context("Preparing insertion")?;
+        for (position, item) in items.iter().enumerate() {
+            insert((workspace_id, pane_id, position, item))?;
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+
+    use std::sync::Arc;
+
+    use db::open_test_db;
+    use settings::DockAnchor;
+
+    use super::*;
+
+    #[gpui::test]
+    async fn test_next_id_stability() {
+        env_logger::try_init().ok();
+
+        let db = WorkspaceDb(open_test_db("test_next_id_stability").await);
+
+        db.write(|conn| {
+            conn.migrate(
+                "test_table",
+                &[sql!(
+                    CREATE TABLE test_table(
+                        text TEXT,
+                        workspace_id INTEGER,
+                        FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+                            ON DELETE CASCADE
+                    ) STRICT;
+                )],
+            )
+            .unwrap();
+        })
+        .await;
+
+        let id = db.next_id().await.unwrap();
+        // Assert the empty row got inserted
+        assert_eq!(
+            Some(id),
+            db.select_row_bound::<WorkspaceId, WorkspaceId>(sql!(
+                SELECT workspace_id FROM workspaces WHERE workspace_id = ?
+            ))
+            .unwrap()(id)
+            .unwrap()
+        );
+
+        db.write(move |conn| {
+            conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
+                .unwrap()(("test-text-1", id))
+            .unwrap()
+        })
+        .await;
+
+        let test_text_1 = db
+            .select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
+            .unwrap()(1)
+        .unwrap()
+        .unwrap();
+        assert_eq!(test_text_1, "test-text-1");
+    }
+
+    #[gpui::test]
+    async fn test_workspace_id_stability() {
+        env_logger::try_init().ok();
+
+        let db = WorkspaceDb(open_test_db("test_workspace_id_stability").await);
+
+        db.write(|conn| {
+            conn.migrate(
+                "test_table",
+                &[sql!(
+                    CREATE TABLE test_table(
+                        text TEXT,
+                        workspace_id INTEGER,
+                        FOREIGN KEY(workspace_id) 
+                            REFERENCES workspaces(workspace_id)
+                            ON DELETE CASCADE
+                    ) STRICT;)],
+            )
+        })
+        .await
+        .unwrap();
+
+        let mut workspace_1 = SerializedWorkspace {
+            id: 1,
+            location: (["/tmp", "/tmp2"]).into(),
+            dock_position: crate::dock::DockPosition::Shown(DockAnchor::Bottom),
+            center_group: Default::default(),
+            dock_pane: Default::default(),
+            left_sidebar_open: true
+        };
+
+        let mut workspace_2 = SerializedWorkspace {
+            id: 2,
+            location: (["/tmp"]).into(),
+            dock_position: crate::dock::DockPosition::Hidden(DockAnchor::Expanded),
+            center_group: Default::default(),
+            dock_pane: Default::default(),
+            left_sidebar_open: false
+        };
+
+        db.save_workspace(workspace_1.clone()).await;
+
+        db.write(|conn| {
+            conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
+                .unwrap()(("test-text-1", 1))
+            .unwrap();
+        })
+        .await;
+
+        db.save_workspace(workspace_2.clone()).await;
+
+        db.write(|conn| {
+            conn.exec_bound(sql!(INSERT INTO test_table(text, workspace_id) VALUES (?, ?)))
+                .unwrap()(("test-text-2", 2))
+            .unwrap();
+        })
+        .await;
+
+        workspace_1.location = (["/tmp", "/tmp3"]).into();
+        db.save_workspace(workspace_1.clone()).await;
+        db.save_workspace(workspace_1).await;
+
+        workspace_2.dock_pane.children.push(SerializedItem {
+            kind: Arc::from("Test"),
+            item_id: 10,
+            active: true,
+        });
+        db.save_workspace(workspace_2).await;
+
+        let test_text_2 = db
+            .select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
+            .unwrap()(2)
+        .unwrap()
+        .unwrap();
+        assert_eq!(test_text_2, "test-text-2");
+
+        let test_text_1 = db
+            .select_row_bound::<_, String>(sql!(SELECT text FROM test_table WHERE workspace_id = ?))
+            .unwrap()(1)
+        .unwrap()
+        .unwrap();
+        assert_eq!(test_text_1, "test-text-1");
+    }
+
+    #[gpui::test]
+    async fn test_full_workspace_serialization() {
+        env_logger::try_init().ok();
+
+        let db = WorkspaceDb(open_test_db("test_full_workspace_serialization").await);
+
+        let dock_pane = crate::persistence::model::SerializedPane {
+            children: vec![
+                SerializedItem::new("Terminal", 1, false),
+                SerializedItem::new("Terminal", 2, false),
+                SerializedItem::new("Terminal", 3, true),
+                SerializedItem::new("Terminal", 4, false),
+            ],
+            active: false,
+        };
+
+        //  -----------------
+        //  | 1,2   | 5,6   |
+        //  | - - - |       |
+        //  | 3,4   |       |
+        //  -----------------
+        let center_group = SerializedPaneGroup::Group {
+            axis: gpui::Axis::Horizontal,
+            children: vec![
+                SerializedPaneGroup::Group {
+                    axis: gpui::Axis::Vertical,
+                    children: vec![
+                        SerializedPaneGroup::Pane(SerializedPane::new(
+                            vec![
+                                SerializedItem::new("Terminal", 5, false),
+                                SerializedItem::new("Terminal", 6, true),
+                            ],
+                            false,
+                        )),
+                        SerializedPaneGroup::Pane(SerializedPane::new(
+                            vec![
+                                SerializedItem::new("Terminal", 7, true),
+                                SerializedItem::new("Terminal", 8, false),
+                            ],
+                            false,
+                        )),
+                    ],
+                },
+                SerializedPaneGroup::Pane(SerializedPane::new(
+                    vec![
+                        SerializedItem::new("Terminal", 9, false),
+                        SerializedItem::new("Terminal", 10, true),
+                    ],
+                    false,
+                )),
+            ],
+        };
+
+        let workspace = SerializedWorkspace {
+            id: 5,
+            location: (["/tmp", "/tmp2"]).into(),
+            dock_position: DockPosition::Shown(DockAnchor::Bottom),
+            center_group,
+            dock_pane,
+            left_sidebar_open: true
+        };
+
+        db.save_workspace(workspace.clone()).await;
+        let round_trip_workspace = db.workspace_for_roots(&["/tmp2", "/tmp"]);
+
+        assert_eq!(workspace, round_trip_workspace.unwrap());
+
+        // Test guaranteed duplicate IDs
+        db.save_workspace(workspace.clone()).await;
+        db.save_workspace(workspace.clone()).await;
+
+        let round_trip_workspace = db.workspace_for_roots(&["/tmp", "/tmp2"]);
+        assert_eq!(workspace, round_trip_workspace.unwrap());
+    }
+
+    #[gpui::test]
+    async fn test_workspace_assignment() {
+        env_logger::try_init().ok();
+
+        let db = WorkspaceDb(open_test_db("test_basic_functionality").await);
+
+        let workspace_1 = SerializedWorkspace {
+            id: 1,
+            location: (["/tmp", "/tmp2"]).into(),
+            dock_position: crate::dock::DockPosition::Shown(DockAnchor::Bottom),
+            center_group: Default::default(),
+            dock_pane: Default::default(),
+            left_sidebar_open: true,
+        };
+
+        let mut workspace_2 = SerializedWorkspace {
+            id: 2,
+            location: (["/tmp"]).into(),
+            dock_position: crate::dock::DockPosition::Hidden(DockAnchor::Expanded),
+            center_group: Default::default(),
+            dock_pane: Default::default(),
+            left_sidebar_open: false,
+        };
+
+        db.save_workspace(workspace_1.clone()).await;
+        db.save_workspace(workspace_2.clone()).await;
+
+        // Test that paths are treated as a set
+        assert_eq!(
+            db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
+            workspace_1
+        );
+        assert_eq!(
+            db.workspace_for_roots(&["/tmp2", "/tmp"]).unwrap(),
+            workspace_1
+        );
+
+        // Make sure that other keys work
+        assert_eq!(db.workspace_for_roots(&["/tmp"]).unwrap(), workspace_2);
+        assert_eq!(db.workspace_for_roots(&["/tmp3", "/tmp2", "/tmp4"]), None);
+
+        // Test 'mutate' case of updating a pre-existing id
+        workspace_2.location = (["/tmp", "/tmp2"]).into();
+
+        db.save_workspace(workspace_2.clone()).await;
+        assert_eq!(
+            db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
+            workspace_2
+        );
+
+        // Test other mechanism for mutating
+        let mut workspace_3 = SerializedWorkspace {
+            id: 3,
+            location: (&["/tmp", "/tmp2"]).into(),
+            dock_position: DockPosition::Shown(DockAnchor::Right),
+            center_group: Default::default(),
+            dock_pane: Default::default(),
+            left_sidebar_open: false
+        };
+
+        db.save_workspace(workspace_3.clone()).await;
+        assert_eq!(
+            db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
+            workspace_3
+        );
+
+        // Make sure that updating paths differently also works
+        workspace_3.location = (["/tmp3", "/tmp4", "/tmp2"]).into();
+        db.save_workspace(workspace_3.clone()).await;
+        assert_eq!(db.workspace_for_roots(&["/tmp2", "tmp"]), None);
+        assert_eq!(
+            db.workspace_for_roots(&["/tmp2", "/tmp3", "/tmp4"])
+                .unwrap(),
+            workspace_3
+        );
+    }
+
+    use crate::dock::DockPosition;
+    use crate::persistence::model::SerializedWorkspace;
+    use crate::persistence::model::{SerializedItem, SerializedPane, SerializedPaneGroup};
+
+    fn default_workspace<P: AsRef<Path>>(
+        workspace_id: &[P],
+        dock_pane: SerializedPane,
+        center_group: &SerializedPaneGroup,
+    ) -> SerializedWorkspace {
+        SerializedWorkspace {
+            id: 4,
+            location: workspace_id.into(),
+            dock_position: crate::dock::DockPosition::Hidden(DockAnchor::Right),
+            center_group: center_group.clone(),
+            dock_pane,
+            left_sidebar_open: true
+        }
+    }
+
+    #[gpui::test]
+    async fn test_basic_dock_pane() {
+        env_logger::try_init().ok();
+
+        let db = WorkspaceDb(open_test_db("basic_dock_pane").await);
+
+        let dock_pane = crate::persistence::model::SerializedPane::new(
+            vec![
+                SerializedItem::new("Terminal", 1, false),
+                SerializedItem::new("Terminal", 4, false),
+                SerializedItem::new("Terminal", 2, false),
+                SerializedItem::new("Terminal", 3, true),
+            ],
+            false,
+        );
+
+        let workspace = default_workspace(&["/tmp"], dock_pane, &Default::default());
+
+        db.save_workspace(workspace.clone()).await;
+
+        let new_workspace = db.workspace_for_roots(&["/tmp"]).unwrap();
+
+        assert_eq!(workspace.dock_pane, new_workspace.dock_pane);
+    }
+
+    #[gpui::test]
+    async fn test_simple_split() {
+        env_logger::try_init().ok();
+
+        let db = WorkspaceDb(open_test_db("simple_split").await);
+
+        //  -----------------
+        //  | 1,2   | 5,6   |
+        //  | - - - |       |
+        //  | 3,4   |       |
+        //  -----------------
+        let center_pane = SerializedPaneGroup::Group {
+            axis: gpui::Axis::Horizontal,
+            children: vec![
+                SerializedPaneGroup::Group {
+                    axis: gpui::Axis::Vertical,
+                    children: vec![
+                        SerializedPaneGroup::Pane(SerializedPane::new(
+                            vec![
+                                SerializedItem::new("Terminal", 1, false),
+                                SerializedItem::new("Terminal", 2, true),
+                            ],
+                            false,
+                        )),
+                        SerializedPaneGroup::Pane(SerializedPane::new(
+                            vec![
+                                SerializedItem::new("Terminal", 4, false),
+                                SerializedItem::new("Terminal", 3, true),
+                            ],
+                            true,
+                        )),
+                    ],
+                },
+                SerializedPaneGroup::Pane(SerializedPane::new(
+                    vec![
+                        SerializedItem::new("Terminal", 5, true),
+                        SerializedItem::new("Terminal", 6, false),
+                    ],
+                    false,
+                )),
+            ],
+        };
+
+        let workspace = default_workspace(&["/tmp"], Default::default(), &center_pane);
+
+        db.save_workspace(workspace.clone()).await;
+
+        let new_workspace = db.workspace_for_roots(&["/tmp"]).unwrap();
+
+        assert_eq!(workspace.center_group, new_workspace.center_group);
+    }
+
+    #[gpui::test]
+    async fn test_cleanup_panes() {
+        env_logger::try_init().ok();
+
+        let db = WorkspaceDb(open_test_db("test_cleanup_panes").await);
+
+        let center_pane = SerializedPaneGroup::Group {
+            axis: gpui::Axis::Horizontal,
+            children: vec![
+                SerializedPaneGroup::Group {
+                    axis: gpui::Axis::Vertical,
+                    children: vec![
+                        SerializedPaneGroup::Pane(SerializedPane::new(
+                            vec![
+                                SerializedItem::new("Terminal", 1, false),
+                                SerializedItem::new("Terminal", 2, true),
+                            ],
+                            false,
+                        )),
+                        SerializedPaneGroup::Pane(SerializedPane::new(
+                            vec![
+                                SerializedItem::new("Terminal", 4, false),
+                                SerializedItem::new("Terminal", 3, true),
+                            ],
+                            true,
+                        )),
+                    ],
+                },
+                SerializedPaneGroup::Pane(SerializedPane::new(
+                    vec![
+                        SerializedItem::new("Terminal", 5, false),
+                        SerializedItem::new("Terminal", 6, true),
+                    ],
+                    false,
+                )),
+            ],
+        };
+
+        let id = &["/tmp"];
+
+        let mut workspace = default_workspace(id, Default::default(), &center_pane);
+
+        db.save_workspace(workspace.clone()).await;
+
+        workspace.center_group = SerializedPaneGroup::Group {
+            axis: gpui::Axis::Vertical,
+            children: vec![
+                SerializedPaneGroup::Pane(SerializedPane::new(
+                    vec![
+                        SerializedItem::new("Terminal", 1, false),
+                        SerializedItem::new("Terminal", 2, true),
+                    ],
+                    false,
+                )),
+                SerializedPaneGroup::Pane(SerializedPane::new(
+                    vec![
+                        SerializedItem::new("Terminal", 4, true),
+                        SerializedItem::new("Terminal", 3, false),
+                    ],
+                    true,
+                )),
+            ],
+        };
+
+        db.save_workspace(workspace.clone()).await;
+
+        let new_workspace = db.workspace_for_roots(id).unwrap();
+
+        assert_eq!(workspace.center_group, new_workspace.center_group);
+    }
+}

crates/workspace/src/persistence/model.rs 🔗

@@ -0,0 +1,315 @@
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+
+use anyhow::{Context, Result};
+
+use async_recursion::async_recursion;
+use gpui::{AsyncAppContext, Axis, ModelHandle, Task, ViewHandle};
+
+use db::sqlez::{
+    bindable::{Bind, Column},
+    statement::Statement,
+};
+use project::Project;
+use settings::DockAnchor;
+use util::ResultExt;
+
+use crate::{
+    dock::DockPosition, ItemDeserializers, Member, Pane, PaneAxis, Workspace, WorkspaceId,
+};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WorkspaceLocation(Arc<Vec<PathBuf>>);
+
+impl WorkspaceLocation {
+    pub fn paths(&self) -> Arc<Vec<PathBuf>> {
+        self.0.clone()
+    }
+}
+
+impl<P: AsRef<Path>, T: IntoIterator<Item = P>> From<T> for WorkspaceLocation {
+    fn from(iterator: T) -> Self {
+        let mut roots = iterator
+            .into_iter()
+            .map(|p| p.as_ref().to_path_buf())
+            .collect::<Vec<_>>();
+        roots.sort();
+        Self(Arc::new(roots))
+    }
+}
+
+impl Bind for &WorkspaceLocation {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        bincode::serialize(&self.0)
+            .expect("Bincode serialization of paths should not fail")
+            .bind(statement, start_index)
+    }
+}
+
+impl Column for WorkspaceLocation {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let blob = statement.column_blob(start_index)?;
+        Ok((
+            WorkspaceLocation(bincode::deserialize(blob).context("Bincode failed")?),
+            start_index + 1,
+        ))
+    }
+}
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct SerializedWorkspace {
+    pub id: WorkspaceId,
+    pub location: WorkspaceLocation,
+    pub dock_position: DockPosition,
+    pub center_group: SerializedPaneGroup,
+    pub dock_pane: SerializedPane,
+    pub left_sidebar_open: bool,
+}
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub enum SerializedPaneGroup {
+    Group {
+        axis: Axis,
+        children: Vec<SerializedPaneGroup>,
+    },
+    Pane(SerializedPane),
+}
+
+#[cfg(test)]
+impl Default for SerializedPaneGroup {
+    fn default() -> Self {
+        Self::Pane(SerializedPane {
+            children: vec![SerializedItem::default()],
+            active: false,
+        })
+    }
+}
+
+impl SerializedPaneGroup {
+    #[async_recursion(?Send)]
+    pub(crate) async fn deserialize(
+        &self,
+        project: &ModelHandle<Project>,
+        workspace_id: WorkspaceId,
+        workspace: &ViewHandle<Workspace>,
+        cx: &mut AsyncAppContext,
+    ) -> Option<(Member, Option<ViewHandle<Pane>>)> {
+        match self {
+            SerializedPaneGroup::Group { axis, children } => {
+                let mut current_active_pane = None;
+                let mut members = Vec::new();
+                for child in children {
+                    if let Some((new_member, active_pane)) = child
+                        .deserialize(project, workspace_id, workspace, cx)
+                        .await
+                    {
+                        members.push(new_member);
+
+                        current_active_pane = current_active_pane.or(active_pane);
+                    }
+                }
+
+                if members.is_empty() {
+                    return None;
+                }
+
+                Some((
+                    Member::Axis(PaneAxis {
+                        axis: *axis,
+                        members,
+                    }),
+                    current_active_pane,
+                ))
+            }
+            SerializedPaneGroup::Pane(serialized_pane) => {
+                let pane = workspace.update(cx, |workspace, cx| workspace.add_pane(cx));
+                let active = serialized_pane.active;
+                serialized_pane
+                    .deserialize_to(project, &pane, workspace_id, workspace, cx)
+                    .await;
+
+                if pane.read_with(cx, |pane, _| pane.items().next().is_some()) {
+                    Some((Member::Pane(pane.clone()), active.then(|| pane)))
+                } else {
+                    None
+                }
+            }
+        }
+    }
+}
+
+#[derive(Debug, PartialEq, Eq, Default, Clone)]
+pub struct SerializedPane {
+    pub(crate) active: bool,
+    pub(crate) children: Vec<SerializedItem>,
+}
+
+impl SerializedPane {
+    pub fn new(children: Vec<SerializedItem>, active: bool) -> Self {
+        SerializedPane { children, active }
+    }
+
+    pub async fn deserialize_to(
+        &self,
+        project: &ModelHandle<Project>,
+        pane_handle: &ViewHandle<Pane>,
+        workspace_id: WorkspaceId,
+        workspace: &ViewHandle<Workspace>,
+        cx: &mut AsyncAppContext,
+    ) {
+        let mut active_item_index = None;
+        for (index, item) in self.children.iter().enumerate() {
+            let project = project.clone();
+            let item_handle = pane_handle
+                .update(cx, |_, cx| {
+                    if let Some(deserializer) = cx.global::<ItemDeserializers>().get(&item.kind) {
+                        deserializer(
+                            project,
+                            workspace.downgrade(),
+                            workspace_id,
+                            item.item_id,
+                            cx,
+                        )
+                    } else {
+                        Task::ready(Err(anyhow::anyhow!(
+                            "Deserializer does not exist for item kind: {}",
+                            item.kind
+                        )))
+                    }
+                })
+                .await
+                .log_err();
+
+            if let Some(item_handle) = item_handle {
+                workspace.update(cx, |workspace, cx| {
+                    Pane::add_item(workspace, &pane_handle, item_handle, false, false, None, cx);
+                })
+            }
+
+            if item.active {
+                active_item_index = Some(index);
+            }
+        }
+
+        if let Some(active_item_index) = active_item_index {
+            pane_handle.update(cx, |pane, cx| {
+                pane.activate_item(active_item_index, false, false, cx);
+            })
+        }
+    }
+}
+
+pub type GroupId = i64;
+pub type PaneId = i64;
+pub type ItemId = usize;
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct SerializedItem {
+    pub kind: Arc<str>,
+    pub item_id: ItemId,
+    pub active: bool,
+}
+
+impl SerializedItem {
+    pub fn new(kind: impl AsRef<str>, item_id: ItemId, active: bool) -> Self {
+        Self {
+            kind: Arc::from(kind.as_ref()),
+            item_id,
+            active,
+        }
+    }
+}
+
+#[cfg(test)]
+impl Default for SerializedItem {
+    fn default() -> Self {
+        SerializedItem {
+            kind: Arc::from("Terminal"),
+            item_id: 100000,
+            active: false,
+        }
+    }
+}
+
+impl Bind for &SerializedItem {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        let next_index = statement.bind(self.kind.clone(), start_index)?;
+        let next_index = statement.bind(self.item_id, next_index)?;
+        statement.bind(self.active, next_index)
+    }
+}
+
+impl Column for SerializedItem {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let (kind, next_index) = Arc::<str>::column(statement, start_index)?;
+        let (item_id, next_index) = ItemId::column(statement, next_index)?;
+        let (active, next_index) = bool::column(statement, next_index)?;
+        Ok((
+            SerializedItem {
+                kind,
+                item_id,
+                active,
+            },
+            next_index,
+        ))
+    }
+}
+
+impl Bind for DockPosition {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        let next_index = statement.bind(self.is_visible(), start_index)?;
+        statement.bind(self.anchor(), next_index)
+    }
+}
+
+impl Column for DockPosition {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let (visible, next_index) = bool::column(statement, start_index)?;
+        let (dock_anchor, next_index) = DockAnchor::column(statement, next_index)?;
+        let position = if visible {
+            DockPosition::Shown(dock_anchor)
+        } else {
+            DockPosition::Hidden(dock_anchor)
+        };
+        Ok((position, next_index))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use db::sqlez::connection::Connection;
+    use settings::DockAnchor;
+
+    use super::WorkspaceLocation;
+
+    #[test]
+    fn test_workspace_round_trips() {
+        let db = Connection::open_memory(Some("workspace_id_round_trips"));
+
+        db.exec(indoc::indoc! {"
+                CREATE TABLE workspace_id_test(
+                    workspace_id INTEGER,
+                    dock_anchor TEXT
+                );"})
+            .unwrap()()
+        .unwrap();
+
+        let workspace_id: WorkspaceLocation = WorkspaceLocation::from(&["\test2", "\test1"]);
+
+        db.exec_bound("INSERT INTO workspace_id_test(workspace_id, dock_anchor) VALUES (?,?)")
+            .unwrap()((&workspace_id, DockAnchor::Bottom))
+        .unwrap();
+
+        assert_eq!(
+            db.select_row("SELECT workspace_id, dock_anchor FROM workspace_id_test LIMIT 1")
+                .unwrap()()
+            .unwrap(),
+            Some((
+                WorkspaceLocation::from(&["\test1", "\test2"]),
+                DockAnchor::Bottom
+            ))
+        );
+    }
+}

crates/workspace/src/searchable.rs 🔗

@@ -6,7 +6,7 @@ use gpui::{
 };
 use project::search::SearchQuery;
 
-use crate::{Item, ItemHandle, WeakItemHandle};
+use crate::{item::WeakItemHandle, Item, ItemHandle};
 
 #[derive(Debug)]
 pub enum SearchEvent {

crates/workspace/src/shared_screen.rs 🔗

@@ -1,4 +1,6 @@
-use crate::{Item, ItemNavHistory};
+use crate::{
+    item::ItemEvent, persistence::model::ItemId, Item, ItemNavHistory, Pane, Workspace, WorkspaceId,
+};
 use anyhow::{anyhow, Result};
 use call::participant::{Frame, RemoteVideoTrack};
 use client::{PeerId, User};
@@ -6,8 +8,10 @@ use futures::StreamExt;
 use gpui::{
     elements::*,
     geometry::{rect::RectF, vector::vec2f},
-    Entity, ModelHandle, MouseButton, RenderContext, Task, View, ViewContext,
+    Entity, ModelHandle, MouseButton, RenderContext, Task, View, ViewContext, ViewHandle,
+    WeakViewHandle,
 };
+use project::Project;
 use settings::Settings;
 use smallvec::SmallVec;
 use std::{
@@ -142,7 +146,11 @@ impl Item for SharedScreen {
         self.nav_history = Some(history);
     }
 
-    fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> {
+    fn clone_on_split(
+        &self,
+        _workspace_id: WorkspaceId,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<Self> {
         let track = self.track.upgrade()?;
         Some(Self::new(&track, self.peer_id, self.user.clone(), cx))
     }
@@ -176,9 +184,23 @@ impl Item for SharedScreen {
         Task::ready(Err(anyhow!("Item::reload called on SharedScreen")))
     }
 
-    fn to_item_events(event: &Self::Event) -> Vec<crate::ItemEvent> {
+    fn to_item_events(event: &Self::Event) -> Vec<ItemEvent> {
         match event {
-            Event::Close => vec![crate::ItemEvent::CloseItem],
+            Event::Close => vec![ItemEvent::CloseItem],
         }
     }
+
+    fn serialized_item_kind() -> Option<&'static str> {
+        None
+    }
+
+    fn deserialize(
+        _project: ModelHandle<Project>,
+        _workspace: WeakViewHandle<Workspace>,
+        _workspace_id: WorkspaceId,
+        _item_id: ItemId,
+        _cx: &mut ViewContext<Pane>,
+    ) -> Task<Result<ViewHandle<Self>>> {
+        unreachable!("Shared screen can not be deserialized")
+    }
 }

crates/workspace/src/workspace.rs 🔗

@@ -3,14 +3,26 @@
 /// This may cause issues when you're trying to write tests that use workspace focus to add items at
 /// specific locations.
 pub mod dock;
+pub mod item;
+pub mod notifications;
 pub mod pane;
 pub mod pane_group;
+mod persistence;
 pub mod searchable;
 pub mod shared_screen;
 pub mod sidebar;
 mod status_bar;
 mod toolbar;
 
+use std::{
+    any::TypeId,
+    borrow::Cow,
+    future::Future,
+    path::{Path, PathBuf},
+    sync::Arc,
+    time::Duration,
+};
+
 use anyhow::{anyhow, Context, Result};
 use call::ActiveCall;
 use client::{proto, Client, PeerId, TypedEnvelope, UserStore};
@@ -28,56 +40,34 @@ use gpui::{
     MouseButton, MutableAppContext, PathPromptOptions, PromptLevel, RenderContext, Task, View,
     ViewContext, ViewHandle, WeakViewHandle,
 };
+use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ProjectItem};
 use language::LanguageRegistry;
+
 use log::{error, warn};
+use notifications::NotificationHandle;
 pub use pane::*;
 pub use pane_group::*;
+use persistence::{model::SerializedItem, DB};
+pub use persistence::{
+    model::{ItemId, WorkspaceLocation},
+    WorkspaceDb,
+};
 use postage::prelude::Stream;
 use project::{Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
-use searchable::SearchableItemHandle;
 use serde::Deserialize;
 use settings::{Autosave, DockAnchor, Settings};
 use shared_screen::SharedScreen;
 use sidebar::{Sidebar, SidebarButtons, SidebarSide, ToggleSidebarItem};
-use smallvec::SmallVec;
 use status_bar::StatusBar;
 pub use status_bar::StatusItemView;
-use std::{
-    any::{Any, TypeId},
-    borrow::Cow,
-    cell::RefCell,
-    fmt,
-    future::Future,
-    path::{Path, PathBuf},
-    rc::Rc,
-    sync::{
-        atomic::{AtomicBool, Ordering::SeqCst},
-        Arc,
-    },
-    time::Duration,
-};
 use theme::{Theme, ThemeRegistry};
 pub use toolbar::{ToolbarItemLocation, ToolbarItemView};
 use util::ResultExt;
 
-type ProjectItemBuilders = HashMap<
-    TypeId,
-    fn(ModelHandle<Project>, AnyModelHandle, &mut ViewContext<Pane>) -> Box<dyn ItemHandle>,
->;
-
-type FollowableItemBuilder = fn(
-    ViewHandle<Pane>,
-    ModelHandle<Project>,
-    &mut Option<proto::view::Variant>,
-    &mut MutableAppContext,
-) -> Option<Task<Result<Box<dyn FollowableItemHandle>>>>;
-type FollowableItemBuilders = HashMap<
-    TypeId,
-    (
-        FollowableItemBuilder,
-        fn(AnyViewHandle) -> Box<dyn FollowableItemHandle>,
-    ),
->;
+use crate::{
+    notifications::simple_message_notification::{MessageNotification, OsOpen},
+    persistence::model::{SerializedPane, SerializedPaneGroup, SerializedWorkspace},
+};
 
 #[derive(Clone, PartialEq)]
 pub struct RemoveWorktreeFromProject(pub WorktreeId);
@@ -147,6 +137,8 @@ pub struct OpenProjectEntryInPane {
     project_entry: ProjectEntryId,
 }
 
+pub type WorkspaceId = i64;
+
 impl_internal_actions!(
     workspace,
     [
@@ -165,6 +157,7 @@ impl_actions!(workspace, [ActivatePane]);
 pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     pane::init(cx);
     dock::init(cx);
+    notifications::init(cx);
 
     cx.add_global_action(open);
     cx.add_global_action({
@@ -179,7 +172,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
         let app_state = Arc::downgrade(&app_state);
         move |_: &NewFile, cx: &mut MutableAppContext| {
             if let Some(app_state) = app_state.upgrade() {
-                open_new(&app_state, cx)
+                open_new(&app_state, cx).detach();
             }
         }
     });
@@ -187,7 +180,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
         let app_state = Arc::downgrade(&app_state);
         move |_: &NewWindow, cx: &mut MutableAppContext| {
             if let Some(app_state) = app_state.upgrade() {
-                open_new(&app_state, cx)
+                open_new(&app_state, cx).detach();
             }
         }
     });
@@ -306,6 +299,10 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     client.add_view_message_handler(Workspace::handle_update_followers);
 }
 
+type ProjectItemBuilders = HashMap<
+    TypeId,
+    fn(ModelHandle<Project>, AnyModelHandle, &mut ViewContext<Pane>) -> Box<dyn ItemHandle>,
+>;
 pub fn register_project_item<I: ProjectItem>(cx: &mut MutableAppContext) {
     cx.update_default_global(|builders: &mut ProjectItemBuilders, _| {
         builders.insert(TypeId::of::<I::Item>(), |project, model, cx| {
@@ -315,6 +312,19 @@ pub fn register_project_item<I: ProjectItem>(cx: &mut MutableAppContext) {
     });
 }
 
+type FollowableItemBuilder = fn(
+    ViewHandle<Pane>,
+    ModelHandle<Project>,
+    &mut Option<proto::view::Variant>,
+    &mut MutableAppContext,
+) -> Option<Task<Result<Box<dyn FollowableItemHandle>>>>;
+type FollowableItemBuilders = HashMap<
+    TypeId,
+    (
+        FollowableItemBuilder,
+        fn(AnyViewHandle) -> Box<dyn FollowableItemHandle>,
+    ),
+>;
 pub fn register_followable_item<I: FollowableItem>(cx: &mut MutableAppContext) {
     cx.update_default_global(|builders: &mut FollowableItemBuilders, _| {
         builders.insert(
@@ -332,6 +342,31 @@ pub fn register_followable_item<I: FollowableItem>(cx: &mut MutableAppContext) {
     });
 }
 
+type ItemDeserializers = HashMap<
+    Arc<str>,
+    fn(
+        ModelHandle<Project>,
+        WeakViewHandle<Workspace>,
+        WorkspaceId,
+        ItemId,
+        &mut ViewContext<Pane>,
+    ) -> Task<Result<Box<dyn ItemHandle>>>,
+>;
+pub fn register_deserializable_item<I: Item>(cx: &mut MutableAppContext) {
+    cx.update_default_global(|deserializers: &mut ItemDeserializers, _cx| {
+        if let Some(serialized_item_kind) = I::serialized_item_kind() {
+            deserializers.insert(
+                Arc::from(serialized_item_kind),
+                |project, workspace, workspace_id, item_id, cx| {
+                    let task = I::deserialize(project, workspace, workspace_id, item_id, cx);
+                    cx.foreground()
+                        .spawn(async { Ok(Box::new(task.await?) as Box<_>) })
+                },
+            );
+        }
+    });
+}
+
 pub struct AppState {
     pub languages: Arc<LanguageRegistry>,
     pub themes: Arc<ThemeRegistry>,
@@ -343,189 +378,32 @@ pub struct AppState {
     pub default_item_factory: DefaultItemFactory,
 }
 
-#[derive(Eq, PartialEq, Hash)]
-pub enum ItemEvent {
-    CloseItem,
-    UpdateTab,
-    UpdateBreadcrumbs,
-    Edit,
-}
-
-pub trait Item: View {
-    fn deactivated(&mut self, _: &mut ViewContext<Self>) {}
-    fn workspace_deactivated(&mut self, _: &mut ViewContext<Self>) {}
-    fn navigate(&mut self, _: Box<dyn Any>, _: &mut ViewContext<Self>) -> bool {
-        false
-    }
-    fn tab_description<'a>(&'a self, _: usize, _: &'a AppContext) -> Option<Cow<'a, str>> {
-        None
-    }
-    fn tab_content(&self, detail: Option<usize>, style: &theme::Tab, cx: &AppContext)
-        -> ElementBox;
-    fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
-    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
-    fn is_singleton(&self, cx: &AppContext) -> bool;
-    fn set_nav_history(&mut self, _: ItemNavHistory, _: &mut ViewContext<Self>);
-    fn clone_on_split(&self, _: &mut ViewContext<Self>) -> Option<Self>
-    where
-        Self: Sized,
-    {
-        None
-    }
-    fn is_dirty(&self, _: &AppContext) -> bool {
-        false
-    }
-    fn has_conflict(&self, _: &AppContext) -> bool {
-        false
-    }
-    fn can_save(&self, cx: &AppContext) -> bool;
-    fn save(
-        &mut self,
-        project: ModelHandle<Project>,
-        cx: &mut ViewContext<Self>,
-    ) -> Task<Result<()>>;
-    fn save_as(
-        &mut self,
-        project: ModelHandle<Project>,
-        abs_path: PathBuf,
-        cx: &mut ViewContext<Self>,
-    ) -> Task<Result<()>>;
-    fn reload(
-        &mut self,
-        project: ModelHandle<Project>,
-        cx: &mut ViewContext<Self>,
-    ) -> Task<Result<()>>;
-    fn git_diff_recalc(
-        &mut self,
-        _project: ModelHandle<Project>,
-        _cx: &mut ViewContext<Self>,
-    ) -> Task<Result<()>> {
-        Task::ready(Ok(()))
-    }
-    fn to_item_events(event: &Self::Event) -> Vec<ItemEvent>;
-    fn should_close_item_on_event(_: &Self::Event) -> bool {
-        false
-    }
-    fn should_update_tab_on_event(_: &Self::Event) -> bool {
-        false
-    }
-    fn is_edit_event(_: &Self::Event) -> bool {
-        false
-    }
-    fn act_as_type(
-        &self,
-        type_id: TypeId,
-        self_handle: &ViewHandle<Self>,
-        _: &AppContext,
-    ) -> Option<AnyViewHandle> {
-        if TypeId::of::<Self>() == type_id {
-            Some(self_handle.into())
-        } else {
-            None
-        }
-    }
-    fn as_searchable(&self, _: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
-        None
-    }
-
-    fn breadcrumb_location(&self) -> ToolbarItemLocation {
-        ToolbarItemLocation::Hidden
-    }
-    fn breadcrumbs(&self, _theme: &Theme, _cx: &AppContext) -> Option<Vec<ElementBox>> {
-        None
-    }
-}
-
-pub trait ProjectItem: Item {
-    type Item: project::Item;
-
-    fn for_project_item(
-        project: ModelHandle<Project>,
-        item: ModelHandle<Self::Item>,
-        cx: &mut ViewContext<Self>,
-    ) -> Self;
-}
-
-pub trait FollowableItem: Item {
-    fn to_state_proto(&self, cx: &AppContext) -> Option<proto::view::Variant>;
-    fn from_state_proto(
-        pane: ViewHandle<Pane>,
-        project: ModelHandle<Project>,
-        state: &mut Option<proto::view::Variant>,
-        cx: &mut MutableAppContext,
-    ) -> Option<Task<Result<ViewHandle<Self>>>>;
-    fn add_event_to_update_proto(
-        &self,
-        event: &Self::Event,
-        update: &mut Option<proto::update_view::Variant>,
-        cx: &AppContext,
-    ) -> bool;
-    fn apply_update_proto(
-        &mut self,
-        message: proto::update_view::Variant,
-        cx: &mut ViewContext<Self>,
-    ) -> Result<()>;
-
-    fn set_leader_replica_id(&mut self, leader_replica_id: Option<u16>, cx: &mut ViewContext<Self>);
-    fn should_unfollow_on_event(event: &Self::Event, cx: &AppContext) -> bool;
-}
+impl AppState {
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn test(cx: &mut MutableAppContext) -> Arc<Self> {
+        use fs::HomeDir;
 
-pub trait FollowableItemHandle: ItemHandle {
-    fn set_leader_replica_id(&self, leader_replica_id: Option<u16>, cx: &mut MutableAppContext);
-    fn to_state_proto(&self, cx: &AppContext) -> Option<proto::view::Variant>;
-    fn add_event_to_update_proto(
-        &self,
-        event: &dyn Any,
-        update: &mut Option<proto::update_view::Variant>,
-        cx: &AppContext,
-    ) -> bool;
-    fn apply_update_proto(
-        &self,
-        message: proto::update_view::Variant,
-        cx: &mut MutableAppContext,
-    ) -> Result<()>;
-    fn should_unfollow_on_event(&self, event: &dyn Any, cx: &AppContext) -> bool;
-}
+        cx.set_global(HomeDir(Path::new("/tmp/").to_path_buf()));
+        let settings = Settings::test(cx);
+        cx.set_global(settings);
 
-impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
-    fn set_leader_replica_id(&self, leader_replica_id: Option<u16>, cx: &mut MutableAppContext) {
-        self.update(cx, |this, cx| {
-            this.set_leader_replica_id(leader_replica_id, cx)
+        let fs = fs::FakeFs::new(cx.background().clone());
+        let languages = Arc::new(LanguageRegistry::test());
+        let http_client = client::test::FakeHttpClient::with_404_response();
+        let client = Client::new(http_client.clone(), cx);
+        let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
+        let themes = ThemeRegistry::new((), cx.font_cache().clone());
+        Arc::new(Self {
+            client,
+            themes,
+            fs,
+            languages,
+            user_store,
+            initialize_workspace: |_, _, _| {},
+            build_window_options: Default::default,
+            default_item_factory: |_, _| unimplemented!(),
         })
     }
-
-    fn to_state_proto(&self, cx: &AppContext) -> Option<proto::view::Variant> {
-        self.read(cx).to_state_proto(cx)
-    }
-
-    fn add_event_to_update_proto(
-        &self,
-        event: &dyn Any,
-        update: &mut Option<proto::update_view::Variant>,
-        cx: &AppContext,
-    ) -> bool {
-        if let Some(event) = event.downcast_ref() {
-            self.read(cx).add_event_to_update_proto(event, update, cx)
-        } else {
-            false
-        }
-    }
-
-    fn apply_update_proto(
-        &self,
-        message: proto::update_view::Variant,
-        cx: &mut MutableAppContext,
-    ) -> Result<()> {
-        self.update(cx, |this, cx| this.apply_update_proto(message, cx))
-    }
-
-    fn should_unfollow_on_event(&self, event: &dyn Any, cx: &AppContext) -> bool {
-        if let Some(event) = event.downcast_ref() {
-            T::should_unfollow_on_event(event, cx)
-        } else {
-            false
-        }
-    }
 }
 
 struct DelayedDebouncedEditAction {
@@ -569,7 +447,7 @@ impl DelayedDebouncedEditAction {
 
             futures::select_biased! {
                 _ = receiver => return,
-                _ = timer => {}
+                    _ = timer => {}
             }
 
             if let Some(project) = project.upgrade(&cx) {
@@ -579,478 +457,23 @@ impl DelayedDebouncedEditAction {
     }
 }
 
-pub trait ItemHandle: 'static + fmt::Debug {
-    fn subscribe_to_item_events(
-        &self,
-        cx: &mut MutableAppContext,
-        handler: Box<dyn Fn(ItemEvent, &mut MutableAppContext)>,
-    ) -> gpui::Subscription;
-    fn tab_description<'a>(&self, detail: usize, cx: &'a AppContext) -> Option<Cow<'a, str>>;
-    fn tab_content(&self, detail: Option<usize>, style: &theme::Tab, cx: &AppContext)
-        -> ElementBox;
-    fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
-    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
-    fn is_singleton(&self, cx: &AppContext) -> bool;
-    fn boxed_clone(&self) -> Box<dyn ItemHandle>;
-    fn clone_on_split(&self, cx: &mut MutableAppContext) -> Option<Box<dyn ItemHandle>>;
-    fn added_to_pane(
-        &self,
-        workspace: &mut Workspace,
-        pane: ViewHandle<Pane>,
-        cx: &mut ViewContext<Workspace>,
-    );
-    fn deactivated(&self, cx: &mut MutableAppContext);
-    fn workspace_deactivated(&self, cx: &mut MutableAppContext);
-    fn navigate(&self, data: Box<dyn Any>, cx: &mut MutableAppContext) -> bool;
-    fn id(&self) -> usize;
-    fn window_id(&self) -> usize;
-    fn to_any(&self) -> AnyViewHandle;
-    fn is_dirty(&self, cx: &AppContext) -> bool;
-    fn has_conflict(&self, cx: &AppContext) -> bool;
-    fn can_save(&self, cx: &AppContext) -> bool;
-    fn save(&self, project: ModelHandle<Project>, cx: &mut MutableAppContext) -> Task<Result<()>>;
-    fn save_as(
-        &self,
-        project: ModelHandle<Project>,
-        abs_path: PathBuf,
-        cx: &mut MutableAppContext,
-    ) -> Task<Result<()>>;
-    fn reload(&self, project: ModelHandle<Project>, cx: &mut MutableAppContext)
-        -> Task<Result<()>>;
-    fn git_diff_recalc(
-        &self,
-        project: ModelHandle<Project>,
-        cx: &mut MutableAppContext,
-    ) -> Task<Result<()>>;
-    fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option<AnyViewHandle>;
-    fn to_followable_item_handle(&self, cx: &AppContext) -> Option<Box<dyn FollowableItemHandle>>;
-    fn on_release(
-        &self,
-        cx: &mut MutableAppContext,
-        callback: Box<dyn FnOnce(&mut MutableAppContext)>,
-    ) -> gpui::Subscription;
-    fn to_searchable_item_handle(&self, cx: &AppContext) -> Option<Box<dyn SearchableItemHandle>>;
-    fn breadcrumb_location(&self, cx: &AppContext) -> ToolbarItemLocation;
-    fn breadcrumbs(&self, theme: &Theme, cx: &AppContext) -> Option<Vec<ElementBox>>;
-}
-
-pub trait WeakItemHandle {
-    fn id(&self) -> usize;
-    fn window_id(&self) -> usize;
-    fn upgrade(&self, cx: &AppContext) -> Option<Box<dyn ItemHandle>>;
-}
-
-impl dyn ItemHandle {
-    pub fn downcast<T: View>(&self) -> Option<ViewHandle<T>> {
-        self.to_any().downcast()
-    }
-
-    pub fn act_as<T: View>(&self, cx: &AppContext) -> Option<ViewHandle<T>> {
-        self.act_as_type(TypeId::of::<T>(), cx)
-            .and_then(|t| t.downcast())
-    }
-}
-
-impl<T: Item> ItemHandle for ViewHandle<T> {
-    fn subscribe_to_item_events(
-        &self,
-        cx: &mut MutableAppContext,
-        handler: Box<dyn Fn(ItemEvent, &mut MutableAppContext)>,
-    ) -> gpui::Subscription {
-        cx.subscribe(self, move |_, event, cx| {
-            for item_event in T::to_item_events(event) {
-                handler(item_event, cx)
-            }
-        })
-    }
-
-    fn tab_description<'a>(&self, detail: usize, cx: &'a AppContext) -> Option<Cow<'a, str>> {
-        self.read(cx).tab_description(detail, cx)
-    }
-
-    fn tab_content(
-        &self,
-        detail: Option<usize>,
-        style: &theme::Tab,
-        cx: &AppContext,
-    ) -> ElementBox {
-        self.read(cx).tab_content(detail, style, cx)
-    }
-
-    fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
-        self.read(cx).project_path(cx)
-    }
-
-    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]> {
-        self.read(cx).project_entry_ids(cx)
-    }
-
-    fn is_singleton(&self, cx: &AppContext) -> bool {
-        self.read(cx).is_singleton(cx)
-    }
-
-    fn boxed_clone(&self) -> Box<dyn ItemHandle> {
-        Box::new(self.clone())
-    }
-
-    fn clone_on_split(&self, cx: &mut MutableAppContext) -> Option<Box<dyn ItemHandle>> {
-        self.update(cx, |item, cx| {
-            cx.add_option_view(|cx| item.clone_on_split(cx))
-        })
-        .map(|handle| Box::new(handle) as Box<dyn ItemHandle>)
-    }
-
-    fn added_to_pane(
-        &self,
-        workspace: &mut Workspace,
-        pane: ViewHandle<Pane>,
-        cx: &mut ViewContext<Workspace>,
-    ) {
-        let history = pane.read(cx).nav_history_for_item(self);
-        self.update(cx, |this, cx| this.set_nav_history(history, cx));
-
-        if let Some(followed_item) = self.to_followable_item_handle(cx) {
-            if let Some(message) = followed_item.to_state_proto(cx) {
-                workspace.update_followers(
-                    proto::update_followers::Variant::CreateView(proto::View {
-                        id: followed_item.id() as u64,
-                        variant: Some(message),
-                        leader_id: workspace.leader_for_pane(&pane).map(|id| id.0),
-                    }),
-                    cx,
-                );
-            }
-        }
-
-        if workspace
-            .panes_by_item
-            .insert(self.id(), pane.downgrade())
-            .is_none()
-        {
-            let mut pending_autosave = DelayedDebouncedEditAction::new();
-            let mut pending_git_update = DelayedDebouncedEditAction::new();
-            let pending_update = Rc::new(RefCell::new(None));
-            let pending_update_scheduled = Rc::new(AtomicBool::new(false));
-
-            let mut event_subscription =
-                Some(cx.subscribe(self, move |workspace, item, event, cx| {
-                    let pane = if let Some(pane) = workspace
-                        .panes_by_item
-                        .get(&item.id())
-                        .and_then(|pane| pane.upgrade(cx))
-                    {
-                        pane
-                    } else {
-                        log::error!("unexpected item event after pane was dropped");
-                        return;
-                    };
-
-                    if let Some(item) = item.to_followable_item_handle(cx) {
-                        let leader_id = workspace.leader_for_pane(&pane);
-
-                        if leader_id.is_some() && item.should_unfollow_on_event(event, cx) {
-                            workspace.unfollow(&pane, cx);
-                        }
-
-                        if item.add_event_to_update_proto(
-                            event,
-                            &mut *pending_update.borrow_mut(),
-                            cx,
-                        ) && !pending_update_scheduled.load(SeqCst)
-                        {
-                            pending_update_scheduled.store(true, SeqCst);
-                            cx.after_window_update({
-                                let pending_update = pending_update.clone();
-                                let pending_update_scheduled = pending_update_scheduled.clone();
-                                move |this, cx| {
-                                    pending_update_scheduled.store(false, SeqCst);
-                                    this.update_followers(
-                                        proto::update_followers::Variant::UpdateView(
-                                            proto::UpdateView {
-                                                id: item.id() as u64,
-                                                variant: pending_update.borrow_mut().take(),
-                                                leader_id: leader_id.map(|id| id.0),
-                                            },
-                                        ),
-                                        cx,
-                                    );
-                                }
-                            });
-                        }
-                    }
-
-                    for item_event in T::to_item_events(event).into_iter() {
-                        match item_event {
-                            ItemEvent::CloseItem => {
-                                Pane::close_item(workspace, pane, item.id(), cx)
-                                    .detach_and_log_err(cx);
-                                return;
-                            }
-
-                            ItemEvent::UpdateTab => {
-                                pane.update(cx, |_, cx| {
-                                    cx.emit(pane::Event::ChangeItemTitle);
-                                    cx.notify();
-                                });
-                            }
-
-                            ItemEvent::Edit => {
-                                if let Autosave::AfterDelay { milliseconds } =
-                                    cx.global::<Settings>().autosave
-                                {
-                                    let delay = Duration::from_millis(milliseconds);
-                                    let item = item.clone();
-                                    pending_autosave.fire_new(
-                                        delay,
-                                        workspace,
-                                        cx,
-                                        |project, mut cx| async move {
-                                            cx.update(|cx| Pane::autosave_item(&item, project, cx))
-                                                .await
-                                                .log_err();
-                                        },
-                                    );
-                                }
-
-                                let settings = cx.global::<Settings>();
-                                let debounce_delay = settings.git_overrides.gutter_debounce;
-
-                                let item = item.clone();
-
-                                if let Some(delay) = debounce_delay {
-                                    const MIN_GIT_DELAY: u64 = 50;
-
-                                    let delay = delay.max(MIN_GIT_DELAY);
-                                    let duration = Duration::from_millis(delay);
-
-                                    pending_git_update.fire_new(
-                                        duration,
-                                        workspace,
-                                        cx,
-                                        |project, mut cx| async move {
-                                            cx.update(|cx| item.git_diff_recalc(project, cx))
-                                                .await
-                                                .log_err();
-                                        },
-                                    );
-                                } else {
-                                    let project = workspace.project().downgrade();
-                                    cx.spawn_weak(|_, mut cx| async move {
-                                        if let Some(project) = project.upgrade(&cx) {
-                                            cx.update(|cx| item.git_diff_recalc(project, cx))
-                                                .await
-                                                .log_err();
-                                        }
-                                    })
-                                    .detach();
-                                }
-                            }
-
-                            _ => {}
-                        }
-                    }
-                }));
-
-            cx.observe_focus(self, move |workspace, item, focused, cx| {
-                if !focused && cx.global::<Settings>().autosave == Autosave::OnFocusChange {
-                    Pane::autosave_item(&item, workspace.project.clone(), cx)
-                        .detach_and_log_err(cx);
-                }
-            })
-            .detach();
-
-            let item_id = self.id();
-            cx.observe_release(self, move |workspace, _, _| {
-                workspace.panes_by_item.remove(&item_id);
-                event_subscription.take();
-            })
-            .detach();
-        }
-    }
-
-    fn deactivated(&self, cx: &mut MutableAppContext) {
-        self.update(cx, |this, cx| this.deactivated(cx));
-    }
-
-    fn workspace_deactivated(&self, cx: &mut MutableAppContext) {
-        self.update(cx, |this, cx| this.workspace_deactivated(cx));
-    }
-
-    fn navigate(&self, data: Box<dyn Any>, cx: &mut MutableAppContext) -> bool {
-        self.update(cx, |this, cx| this.navigate(data, cx))
-    }
-
-    fn id(&self) -> usize {
-        self.id()
-    }
-
-    fn window_id(&self) -> usize {
-        self.window_id()
-    }
-
-    fn to_any(&self) -> AnyViewHandle {
-        self.into()
-    }
-
-    fn is_dirty(&self, cx: &AppContext) -> bool {
-        self.read(cx).is_dirty(cx)
-    }
-
-    fn has_conflict(&self, cx: &AppContext) -> bool {
-        self.read(cx).has_conflict(cx)
-    }
-
-    fn can_save(&self, cx: &AppContext) -> bool {
-        self.read(cx).can_save(cx)
-    }
-
-    fn save(&self, project: ModelHandle<Project>, cx: &mut MutableAppContext) -> Task<Result<()>> {
-        self.update(cx, |item, cx| item.save(project, cx))
-    }
-
-    fn save_as(
-        &self,
-        project: ModelHandle<Project>,
-        abs_path: PathBuf,
-        cx: &mut MutableAppContext,
-    ) -> Task<anyhow::Result<()>> {
-        self.update(cx, |item, cx| item.save_as(project, abs_path, cx))
-    }
-
-    fn reload(
-        &self,
-        project: ModelHandle<Project>,
-        cx: &mut MutableAppContext,
-    ) -> Task<Result<()>> {
-        self.update(cx, |item, cx| item.reload(project, cx))
-    }
-
-    fn git_diff_recalc(
-        &self,
-        project: ModelHandle<Project>,
-        cx: &mut MutableAppContext,
-    ) -> Task<Result<()>> {
-        self.update(cx, |item, cx| item.git_diff_recalc(project, cx))
-    }
-
-    fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option<AnyViewHandle> {
-        self.read(cx).act_as_type(type_id, self, cx)
-    }
-
-    fn to_followable_item_handle(&self, cx: &AppContext) -> Option<Box<dyn FollowableItemHandle>> {
-        if cx.has_global::<FollowableItemBuilders>() {
-            let builders = cx.global::<FollowableItemBuilders>();
-            let item = self.to_any();
-            Some(builders.get(&item.view_type())?.1(item))
-        } else {
-            None
-        }
-    }
-
-    fn on_release(
-        &self,
-        cx: &mut MutableAppContext,
-        callback: Box<dyn FnOnce(&mut MutableAppContext)>,
-    ) -> gpui::Subscription {
-        cx.observe_release(self, move |_, cx| callback(cx))
-    }
-
-    fn to_searchable_item_handle(&self, cx: &AppContext) -> Option<Box<dyn SearchableItemHandle>> {
-        self.read(cx).as_searchable(self)
-    }
-
-    fn breadcrumb_location(&self, cx: &AppContext) -> ToolbarItemLocation {
-        self.read(cx).breadcrumb_location()
-    }
-
-    fn breadcrumbs(&self, theme: &Theme, cx: &AppContext) -> Option<Vec<ElementBox>> {
-        self.read(cx).breadcrumbs(theme, cx)
-    }
-}
-
-impl From<Box<dyn ItemHandle>> for AnyViewHandle {
-    fn from(val: Box<dyn ItemHandle>) -> Self {
-        val.to_any()
-    }
-}
-
-impl From<&Box<dyn ItemHandle>> for AnyViewHandle {
-    fn from(val: &Box<dyn ItemHandle>) -> Self {
-        val.to_any()
-    }
-}
-
-impl Clone for Box<dyn ItemHandle> {
-    fn clone(&self) -> Box<dyn ItemHandle> {
-        self.boxed_clone()
-    }
-}
-
-impl<T: Item> WeakItemHandle for WeakViewHandle<T> {
-    fn id(&self) -> usize {
-        self.id()
-    }
-
-    fn window_id(&self) -> usize {
-        self.window_id()
-    }
-
-    fn upgrade(&self, cx: &AppContext) -> Option<Box<dyn ItemHandle>> {
-        self.upgrade(cx).map(|v| Box::new(v) as Box<dyn ItemHandle>)
-    }
-}
-
-pub trait Notification: View {
-    fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool;
-}
-
-pub trait NotificationHandle {
-    fn id(&self) -> usize;
-    fn to_any(&self) -> AnyViewHandle;
+#[derive(Default)]
+struct LeaderState {
+    followers: HashSet<PeerId>,
 }
 
-impl<T: Notification> NotificationHandle for ViewHandle<T> {
-    fn id(&self) -> usize {
-        self.id()
-    }
-
-    fn to_any(&self) -> AnyViewHandle {
-        self.into()
-    }
-}
+type FollowerStatesByLeader = HashMap<PeerId, HashMap<ViewHandle<Pane>, FollowerState>>;
 
-impl From<&dyn NotificationHandle> for AnyViewHandle {
-    fn from(val: &dyn NotificationHandle) -> Self {
-        val.to_any()
-    }
+#[derive(Default)]
+struct FollowerState {
+    active_view_id: Option<u64>,
+    items_by_leader_view_id: HashMap<u64, FollowerItem>,
 }
 
-impl AppState {
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn test(cx: &mut MutableAppContext) -> Arc<Self> {
-        use fs::HomeDir;
-
-        cx.set_global(HomeDir(Path::new("/tmp/").to_path_buf()));
-        let settings = Settings::test(cx);
-        cx.set_global(settings);
-
-        let fs = fs::FakeFs::new(cx.background().clone());
-        let languages = Arc::new(LanguageRegistry::test());
-        let http_client = client::test::FakeHttpClient::with_404_response();
-        let client = Client::new(http_client.clone(), cx);
-        let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
-        let themes = ThemeRegistry::new((), cx.font_cache().clone());
-        Arc::new(Self {
-            client,
-            themes,
-            fs,
-            languages,
-            user_store,
-            initialize_workspace: |_, _, _| {},
-            build_window_options: Default::default,
-            default_item_factory: |_, _| unimplemented!(),
-        })
-    }
+#[derive(Debug)]
+enum FollowerItem {
+    Loading(Vec<proto::update_view::Variant>),
+    Loaded(Box<dyn FollowableItemHandle>),
 }
 
 pub enum Event {
@@ -1083,30 +506,14 @@ pub struct Workspace {
     last_leaders_by_pane: HashMap<WeakViewHandle<Pane>, PeerId>,
     window_edited: bool,
     active_call: Option<(ModelHandle<ActiveCall>, Vec<gpui::Subscription>)>,
+    database_id: WorkspaceId,
     _observe_current_user: Task<()>,
 }
 
-#[derive(Default)]
-struct LeaderState {
-    followers: HashSet<PeerId>,
-}
-
-type FollowerStatesByLeader = HashMap<PeerId, HashMap<ViewHandle<Pane>, FollowerState>>;
-
-#[derive(Default)]
-struct FollowerState {
-    active_view_id: Option<u64>,
-    items_by_leader_view_id: HashMap<u64, FollowerItem>,
-}
-
-#[derive(Debug)]
-enum FollowerItem {
-    Loading(Vec<proto::update_view::Variant>),
-    Loaded(Box<dyn FollowableItemHandle>),
-}
-
 impl Workspace {
     pub fn new(
+        serialized_workspace: Option<SerializedWorkspace>,
+        workspace_id: WorkspaceId,
         project: ModelHandle<Project>,
         dock_default_factory: DefaultItemFactory,
         cx: &mut ViewContext<Self>,
@@ -1126,6 +533,7 @@ impl Workspace {
                 }
                 project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded => {
                     this.update_window_title(cx);
+                    this.serialize_workspace(cx);
                 }
                 project::Event::DisconnectedFromHost => {
                     this.update_window_edited(cx);
@@ -1145,6 +553,8 @@ impl Workspace {
         .detach();
         cx.focus(&center_pane);
         cx.emit(Event::PaneAdded(center_pane.clone()));
+        let dock = Dock::new(dock_default_factory, cx);
+        let dock_pane = dock.pane().clone();
 
         let fs = project.read(cx).fs().clone();
         let user_store = project.read(cx).user_store();
@@ -1171,9 +581,6 @@ impl Workspace {
 
         cx.emit_global(WorkspaceCreated(weak_handle.clone()));
 
-        let dock = Dock::new(cx, dock_default_factory);
-        let dock_pane = dock.pane().clone();
-
         let left_sidebar = cx.add_view(|_| Sidebar::new(SidebarSide::Left));
         let right_sidebar = cx.add_view(|_| Sidebar::new(SidebarSide::Right));
         let left_sidebar_buttons = cx.add_view(|cx| SidebarButtons::new(left_sidebar.clone(), cx));
@@ -1200,39 +607,143 @@ impl Workspace {
             active_call = Some((call, subscriptions));
         }
 
-        let mut this = Workspace {
-            modal: None,
-            weak_self: weak_handle,
-            center: PaneGroup::new(center_pane.clone()),
-            dock,
-            // When removing an item, the last element remaining in this array
-            // is used to find where focus should fallback to. As such, the order
-            // of these two variables is important.
-            panes: vec![dock_pane, center_pane.clone()],
-            panes_by_item: Default::default(),
-            active_pane: center_pane.clone(),
-            last_active_center_pane: Some(center_pane.downgrade()),
-            status_bar,
-            titlebar_item: None,
-            notifications: Default::default(),
-            client,
-            remote_entity_subscription: None,
-            user_store,
-            fs,
-            left_sidebar,
-            right_sidebar,
-            project,
-            leader_state: Default::default(),
-            follower_states_by_leader: Default::default(),
-            last_leaders_by_pane: Default::default(),
-            window_edited: false,
-            active_call,
-            _observe_current_user,
-        };
-        this.project_remote_id_changed(this.project.read(cx).remote_id(), cx);
-        cx.defer(|this, cx| this.update_window_title(cx));
+        let mut this = Workspace {
+            modal: None,
+            weak_self: weak_handle.clone(),
+            center: PaneGroup::new(center_pane.clone()),
+            dock,
+            // When removing an item, the last element remaining in this array
+            // is used to find where focus should fallback to. As such, the order
+            // of these two variables is important.
+            panes: vec![dock_pane.clone(), center_pane.clone()],
+            panes_by_item: Default::default(),
+            active_pane: center_pane.clone(),
+            last_active_center_pane: Some(center_pane.downgrade()),
+            status_bar,
+            titlebar_item: None,
+            notifications: Default::default(),
+            client,
+            remote_entity_subscription: None,
+            user_store,
+            fs,
+            left_sidebar,
+            right_sidebar,
+            project: project.clone(),
+            leader_state: Default::default(),
+            follower_states_by_leader: Default::default(),
+            last_leaders_by_pane: Default::default(),
+            window_edited: false,
+            active_call,
+            database_id: workspace_id,
+            _observe_current_user,
+        };
+        this.project_remote_id_changed(project.read(cx).remote_id(), cx);
+        cx.defer(|this, cx| this.update_window_title(cx));
+
+        if let Some(serialized_workspace) = serialized_workspace {
+            cx.defer(move |_, cx| {
+                Self::load_from_serialized_workspace(weak_handle, serialized_workspace, cx)
+            });
+        }
+
+        this
+    }
+
+    fn new_local(
+        abs_paths: Vec<PathBuf>,
+        app_state: Arc<AppState>,
+        cx: &mut MutableAppContext,
+    ) -> Task<(
+        ViewHandle<Workspace>,
+        Vec<Option<Result<Box<dyn ItemHandle>, anyhow::Error>>>,
+    )> {
+        let project_handle = Project::local(
+            app_state.client.clone(),
+            app_state.user_store.clone(),
+            app_state.languages.clone(),
+            app_state.fs.clone(),
+            cx,
+        );
+
+        cx.spawn(|mut cx| async move {
+            let serialized_workspace = persistence::DB.workspace_for_roots(&abs_paths.as_slice());
+
+            let paths_to_open = serialized_workspace
+                .as_ref()
+                .map(|workspace| workspace.location.paths())
+                .unwrap_or(Arc::new(abs_paths));
+
+            // Get project paths for all of the abs_paths
+            let mut worktree_roots: HashSet<Arc<Path>> = Default::default();
+            let mut project_paths = Vec::new();
+            for path in paths_to_open.iter() {
+                if let Some((worktree, project_entry)) = cx
+                    .update(|cx| {
+                        Workspace::project_path_for_path(project_handle.clone(), &path, true, cx)
+                    })
+                    .await
+                    .log_err()
+                {
+                    worktree_roots.insert(worktree.read_with(&mut cx, |tree, _| tree.abs_path()));
+                    project_paths.push(Some(project_entry));
+                } else {
+                    project_paths.push(None);
+                }
+            }
+
+            let workspace_id = if let Some(serialized_workspace) = serialized_workspace.as_ref() {
+                serialized_workspace.id
+            } else {
+                DB.next_id().await.unwrap_or(0)
+            };
+
+            // Use the serialized workspace to construct the new window
+            let (_, workspace) = cx.add_window((app_state.build_window_options)(), |cx| {
+                let mut workspace = Workspace::new(
+                    serialized_workspace,
+                    workspace_id,
+                    project_handle,
+                    app_state.default_item_factory,
+                    cx,
+                );
+                (app_state.initialize_workspace)(&mut workspace, &app_state, cx);
+                workspace
+            });
+
+            notify_if_database_failed(&workspace, &mut cx);
+
+            // Call open path for each of the project paths
+            // (this will bring them to the front if they were in the serialized workspace)
+            debug_assert!(paths_to_open.len() == project_paths.len());
+            let tasks = paths_to_open
+                .iter()
+                .cloned()
+                .zip(project_paths.into_iter())
+                .map(|(abs_path, project_path)| {
+                    let workspace = workspace.clone();
+                    cx.spawn(|mut cx| {
+                        let fs = app_state.fs.clone();
+                        async move {
+                            let project_path = project_path?;
+                            if fs.is_file(&abs_path).await {
+                                Some(
+                                    workspace
+                                        .update(&mut cx, |workspace, cx| {
+                                            workspace.open_path(project_path, None, true, cx)
+                                        })
+                                        .await,
+                                )
+                            } else {
+                                None
+                            }
+                        }
+                    })
+                });
+
+            let opened_items = futures::future::join_all(tasks.into_iter()).await;
 
-        this
+            (workspace, opened_items)
+        })
     }
 
     pub fn weak_handle(&self) -> WeakViewHandle<Self> {

crates/zed/Cargo.toml 🔗

@@ -62,7 +62,6 @@ async-trait = "0.1"
 backtrace = "0.3"
 chrono = "0.4"
 ctor = "0.1.20"
-dirs = "3.0"
 easy-parallel = "3.1.0"
 env_logger = "0.9"
 futures = "0.3"

crates/zed/src/feedback.rs 🔗

@@ -5,7 +5,7 @@ use gpui::{
     Element, Entity, MouseButton, RenderContext, View,
 };
 use settings::Settings;
-use workspace::StatusItemView;
+use workspace::{item::ItemHandle, StatusItemView};
 
 pub const NEW_ISSUE_URL: &str = "https://github.com/zed-industries/feedback/issues/new/choose";
 
@@ -43,7 +43,7 @@ impl View for FeedbackLink {
 impl StatusItemView for FeedbackLink {
     fn set_active_pane_item(
         &mut self,
-        _: Option<&dyn workspace::ItemHandle>,
+        _: Option<&dyn ItemHandle>,
         _: &mut gpui::ViewContext<Self>,
     ) {
     }

crates/zed/src/main.rs 🔗

@@ -37,12 +37,9 @@ use terminal::terminal_container_view::{get_working_directory, TerminalContainer
 use fs::RealFs;
 use settings::watched_json::{watch_keymap_file, watch_settings_file, WatchedJsonFile};
 use theme::ThemeRegistry;
-use util::{ResultExt, TryFutureExt};
-use workspace::{self, AppState, ItemHandle, NewFile, OpenPaths, Workspace};
-use zed::{
-    self, build_window_options, initialize_workspace, languages, menus, RELEASE_CHANNEL,
-    RELEASE_CHANNEL_NAME,
-};
+use util::{channel::RELEASE_CHANNEL, paths, ResultExt, TryFutureExt};
+use workspace::{self, item::ItemHandle, AppState, NewFile, OpenPaths, Workspace};
+use zed::{self, build_window_options, initialize_workspace, languages, menus};
 
 fn main() {
     let http = http::client();
@@ -56,10 +53,6 @@ fn main() {
         .map_or("dev".to_string(), |v| v.to_string());
     init_panic_hook(app_version, http.clone(), app.background());
 
-    let db = app.background().spawn(async move {
-        project::Db::open(&*zed::paths::DB_DIR, RELEASE_CHANNEL_NAME.as_str())
-    });
-
     load_embedded_fonts(&app);
 
     let fs = Arc::new(RealFs);
@@ -91,11 +84,11 @@ fn main() {
 
     app.run(move |cx| {
         cx.set_global(*RELEASE_CHANNEL);
-        cx.set_global(HomeDir(zed::paths::HOME.to_path_buf()));
+        cx.set_global(HomeDir(paths::HOME.to_path_buf()));
 
         let client = client::Client::new(http.clone(), cx);
         let mut languages = LanguageRegistry::new(login_shell_env_loaded);
-        languages.set_language_server_download_dir(zed::paths::LANGUAGES_DIR.clone());
+        languages.set_language_server_download_dir(paths::LANGUAGES_DIR.clone());
         let languages = Arc::new(languages);
         let init_languages = cx
             .background()
@@ -106,7 +99,7 @@ fn main() {
 
         //Setup settings global before binding actions
         cx.set_global(SettingsFile::new(
-            &*zed::paths::SETTINGS,
+            &*paths::SETTINGS,
             settings_file_content.clone(),
             fs.clone(),
         ));
@@ -146,8 +139,7 @@ fn main() {
         })
         .detach();
 
-        let db = cx.background().block(db);
-        client.start_telemetry(db.clone());
+        client.start_telemetry();
         client.report_event("start app", Default::default());
 
         let app_state = Arc::new(AppState {
@@ -160,8 +152,10 @@ fn main() {
             initialize_workspace,
             default_item_factory,
         });
-        auto_update::init(db, http, cx);
+        auto_update::init(http, client::ZED_SERVER_URL.clone(), cx);
+
         workspace::init(app_state.clone(), cx);
+
         journal::init(app_state.clone(), cx);
         theme_selector::init(app_state.clone(), cx);
         zed::init(&app_state, cx);
@@ -207,10 +201,10 @@ fn main() {
 }
 
 fn init_paths() {
-    std::fs::create_dir_all(&*zed::paths::CONFIG_DIR).expect("could not create config path");
-    std::fs::create_dir_all(&*zed::paths::LANGUAGES_DIR).expect("could not create languages path");
-    std::fs::create_dir_all(&*zed::paths::DB_DIR).expect("could not create database path");
-    std::fs::create_dir_all(&*zed::paths::LOGS_DIR).expect("could not create logs path");
+    std::fs::create_dir_all(&*util::paths::CONFIG_DIR).expect("could not create config path");
+    std::fs::create_dir_all(&*util::paths::LANGUAGES_DIR).expect("could not create languages path");
+    std::fs::create_dir_all(&*util::paths::DB_DIR).expect("could not create database path");
+    std::fs::create_dir_all(&*util::paths::LOGS_DIR).expect("could not create logs path");
 }
 
 fn init_logger() {
@@ -223,16 +217,15 @@ fn init_logger() {
         const KIB: u64 = 1024;
         const MIB: u64 = 1024 * KIB;
         const MAX_LOG_BYTES: u64 = MIB;
-        if std::fs::metadata(&*zed::paths::LOG)
-            .map_or(false, |metadata| metadata.len() > MAX_LOG_BYTES)
+        if std::fs::metadata(&*paths::LOG).map_or(false, |metadata| metadata.len() > MAX_LOG_BYTES)
         {
-            let _ = std::fs::rename(&*zed::paths::LOG, &*zed::paths::OLD_LOG);
+            let _ = std::fs::rename(&*paths::LOG, &*paths::OLD_LOG);
         }
 
         let log_file = OpenOptions::new()
             .create(true)
             .append(true)
-            .open(&*zed::paths::LOG)
+            .open(&*paths::LOG)
             .expect("could not open logfile");
         simplelog::WriteLogger::init(level, simplelog::Config::default(), log_file)
             .expect("could not initialize logger");
@@ -244,7 +237,7 @@ fn init_panic_hook(app_version: String, http: Arc<dyn HttpClient>, background: A
         .spawn({
             async move {
                 let panic_report_url = format!("{}/api/panic", &*client::ZED_SERVER_URL);
-                let mut children = smol::fs::read_dir(&*zed::paths::LOGS_DIR).await?;
+                let mut children = smol::fs::read_dir(&*paths::LOGS_DIR).await?;
                 while let Some(child) = children.next().await {
                     let child = child?;
                     let child_path = child.path();
@@ -332,7 +325,7 @@ fn init_panic_hook(app_version: String, http: Arc<dyn HttpClient>, background: A
 
         let panic_filename = chrono::Utc::now().format("%Y_%m_%d %H_%M_%S").to_string();
         std::fs::write(
-            zed::paths::LOGS_DIR.join(format!("zed-{}-{}.panic", app_version, panic_filename)),
+            paths::LOGS_DIR.join(format!("zed-{}-{}.panic", app_version, panic_filename)),
             &message,
         )
         .context("error writing panic to disk")
@@ -466,8 +459,8 @@ fn load_config_files(
         .clone()
         .spawn(async move {
             let settings_file =
-                WatchedJsonFile::new(fs.clone(), &executor, zed::paths::SETTINGS.clone()).await;
-            let keymap_file = WatchedJsonFile::new(fs, &executor, zed::paths::KEYMAP.clone()).await;
+                WatchedJsonFile::new(fs.clone(), &executor, paths::SETTINGS.clone()).await;
+            let keymap_file = WatchedJsonFile::new(fs, &executor, paths::KEYMAP.clone()).await;
             tx.send((settings_file, keymap_file)).ok()
         })
         .detach();
@@ -601,6 +594,8 @@ pub fn default_item_factory(
 
     let working_directory = get_working_directory(workspace, cx, strategy);
 
-    let terminal_handle = cx.add_view(|cx| TerminalContainer::new(working_directory, false, cx));
+    let terminal_handle = cx.add_view(|cx| {
+        TerminalContainer::new(working_directory, false, workspace.database_id(), cx)
+    });
     Box::new(terminal_handle)
 }

crates/zed/src/zed.rs 🔗

@@ -1,7 +1,6 @@
 mod feedback;
 pub mod languages;
 pub mod menus;
-pub mod paths;
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
 
@@ -13,7 +12,6 @@ use collab_ui::{CollabTitlebarItem, ToggleCollaborationMenu};
 use collections::VecDeque;
 pub use editor;
 use editor::{Editor, MultiBuffer};
-use lazy_static::lazy_static;
 
 use gpui::{
     actions,
@@ -29,9 +27,9 @@ use project_panel::ProjectPanel;
 use search::{BufferSearchBar, ProjectSearchBar};
 use serde::Deserialize;
 use serde_json::to_string_pretty;
-use settings::{keymap_file_json_schema, settings_file_json_schema, ReleaseChannel, Settings};
+use settings::{keymap_file_json_schema, settings_file_json_schema, Settings};
 use std::{env, path::Path, str, sync::Arc};
-use util::ResultExt;
+use util::{channel::ReleaseChannel, paths, ResultExt};
 pub use workspace;
 use workspace::{sidebar::SidebarSide, AppState, Workspace};
 
@@ -70,17 +68,6 @@ actions!(
 
 const MIN_FONT_SIZE: f32 = 6.0;
 
-lazy_static! {
-    pub static ref RELEASE_CHANNEL_NAME: String =
-        env::var("ZED_RELEASE_CHANNEL").unwrap_or(include_str!("../RELEASE_CHANNEL").to_string());
-    pub static ref RELEASE_CHANNEL: ReleaseChannel = match RELEASE_CHANNEL_NAME.as_str() {
-        "dev" => ReleaseChannel::Dev,
-        "preview" => ReleaseChannel::Preview,
-        "stable" => ReleaseChannel::Stable,
-        _ => panic!("invalid release channel {}", *RELEASE_CHANNEL_NAME),
-    };
-}
-
 pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
     cx.add_action(about);
     cx.add_global_action(|_: &Hide, cx: &mut gpui::MutableAppContext| {
@@ -390,7 +377,7 @@ fn quit(_: &Quit, cx: &mut gpui::MutableAppContext) {
 }
 
 fn about(_: &mut Workspace, _: &About, cx: &mut gpui::ViewContext<Workspace>) {
-    let app_name = cx.global::<ReleaseChannel>().name();
+    let app_name = cx.global::<ReleaseChannel>().display_name();
     let version = env!("CARGO_PKG_VERSION");
     cx.prompt(
         gpui::PromptLevel::Info,
@@ -463,10 +450,11 @@ fn open_config_file(
 
         workspace
             .update(&mut cx, |workspace, cx| {
-                workspace.with_local_workspace(cx, app_state, |workspace, cx| {
+                workspace.with_local_workspace(&app_state, cx, |workspace, cx| {
                     workspace.open_paths(vec![path.to_path_buf()], false, cx)
                 })
             })
+            .await
             .await;
         Ok::<_, anyhow::Error>(())
     })
@@ -480,51 +468,55 @@ fn open_log_file(
 ) {
     const MAX_LINES: usize = 1000;
 
-    workspace.with_local_workspace(cx, app_state.clone(), |_, cx| {
-        cx.spawn_weak(|workspace, mut cx| async move {
-            let (old_log, new_log) = futures::join!(
-                app_state.fs.load(&paths::OLD_LOG),
-                app_state.fs.load(&paths::LOG)
-            );
+    workspace
+        .with_local_workspace(&app_state.clone(), cx, move |_, cx| {
+            cx.spawn_weak(|workspace, mut cx| async move {
+                let (old_log, new_log) = futures::join!(
+                    app_state.fs.load(&paths::OLD_LOG),
+                    app_state.fs.load(&paths::LOG)
+                );
 
-            if let Some(workspace) = workspace.upgrade(&cx) {
-                let mut lines = VecDeque::with_capacity(MAX_LINES);
-                for line in old_log
-                    .iter()
-                    .flat_map(|log| log.lines())
-                    .chain(new_log.iter().flat_map(|log| log.lines()))
-                {
-                    if lines.len() == MAX_LINES {
-                        lines.pop_front();
+                if let Some(workspace) = workspace.upgrade(&cx) {
+                    let mut lines = VecDeque::with_capacity(MAX_LINES);
+                    for line in old_log
+                        .iter()
+                        .flat_map(|log| log.lines())
+                        .chain(new_log.iter().flat_map(|log| log.lines()))
+                    {
+                        if lines.len() == MAX_LINES {
+                            lines.pop_front();
+                        }
+                        lines.push_back(line);
                     }
-                    lines.push_back(line);
-                }
-                let log = lines
-                    .into_iter()
-                    .flat_map(|line| [line, "\n"])
-                    .collect::<String>();
-
-                workspace.update(&mut cx, |workspace, cx| {
-                    let project = workspace.project().clone();
-                    let buffer = project
-                        .update(cx, |project, cx| project.create_buffer("", None, cx))
-                        .expect("creating buffers on a local workspace always succeeds");
-                    buffer.update(cx, |buffer, cx| buffer.edit([(0..0, log)], None, cx));
-
-                    let buffer = cx.add_model(|cx| {
-                        MultiBuffer::singleton(buffer, cx).with_title("Log".into())
+                    let log = lines
+                        .into_iter()
+                        .flat_map(|line| [line, "\n"])
+                        .collect::<String>();
+
+                    workspace.update(&mut cx, |workspace, cx| {
+                        let project = workspace.project().clone();
+                        let buffer = project
+                            .update(cx, |project, cx| project.create_buffer("", None, cx))
+                            .expect("creating buffers on a local workspace always succeeds");
+                        buffer.update(cx, |buffer, cx| buffer.edit([(0..0, log)], None, cx));
+
+                        let buffer = cx.add_model(|cx| {
+                            MultiBuffer::singleton(buffer, cx).with_title("Log".into())
+                        });
+                        workspace.add_item(
+                            Box::new(
+                                cx.add_view(|cx| {
+                                    Editor::for_multibuffer(buffer, Some(project), cx)
+                                }),
+                            ),
+                            cx,
+                        );
                     });
-                    workspace.add_item(
-                        Box::new(
-                            cx.add_view(|cx| Editor::for_multibuffer(buffer, Some(project), cx)),
-                        ),
-                        cx,
-                    );
-                });
-            }
+                }
+            })
+            .detach();
         })
         .detach();
-    });
 }
 
 fn open_telemetry_log_file(
@@ -532,7 +524,7 @@ fn open_telemetry_log_file(
     app_state: Arc<AppState>,
     cx: &mut ViewContext<Workspace>,
 ) {
-    workspace.with_local_workspace(cx, app_state.clone(), |_, cx| {
+    workspace.with_local_workspace(&app_state.clone(), cx, move |_, cx| {
         cx.spawn_weak(|workspace, mut cx| async move {
             let workspace = workspace.upgrade(&cx)?;
             let path = app_state.client.telemetry_log_file_path()?;
@@ -580,31 +572,36 @@ fn open_telemetry_log_file(
             Some(())
         })
         .detach();
-    });
+    }).detach();
 }
 
 fn open_bundled_config_file(
     workspace: &mut Workspace,
     app_state: Arc<AppState>,
     asset_path: &'static str,
-    title: &str,
+    title: &'static str,
     cx: &mut ViewContext<Workspace>,
 ) {
-    workspace.with_local_workspace(cx, app_state, |workspace, cx| {
-        let project = workspace.project().clone();
-        let buffer = project.update(cx, |project, cx| {
-            let text = Assets::get(asset_path).unwrap().data;
-            let text = str::from_utf8(text.as_ref()).unwrap();
-            project
-                .create_buffer(text, project.languages().get_language("JSON"), cx)
-                .expect("creating buffers on a local workspace always succeeds")
-        });
-        let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx).with_title(title.into()));
-        workspace.add_item(
-            Box::new(cx.add_view(|cx| Editor::for_multibuffer(buffer, Some(project.clone()), cx))),
-            cx,
-        );
-    });
+    workspace
+        .with_local_workspace(&app_state.clone(), cx, |workspace, cx| {
+            let project = workspace.project().clone();
+            let buffer = project.update(cx, |project, cx| {
+                let text = Assets::get(asset_path).unwrap().data;
+                let text = str::from_utf8(text.as_ref()).unwrap();
+                project
+                    .create_buffer(text, project.languages().get_language("JSON"), cx)
+                    .expect("creating buffers on a local workspace always succeeds")
+            });
+            let buffer =
+                cx.add_model(|cx| MultiBuffer::singleton(buffer, cx).with_title(title.into()));
+            workspace.add_item(
+                Box::new(
+                    cx.add_view(|cx| Editor::for_multibuffer(buffer, Some(project.clone()), cx)),
+                ),
+                cx,
+            );
+        })
+        .detach();
 }
 
 fn schema_file_match(path: &Path) -> &Path {
@@ -628,7 +625,8 @@ mod tests {
     };
     use theme::ThemeRegistry;
     use workspace::{
-        open_paths, pane, Item, ItemHandle, NewFile, Pane, SplitDirection, WorkspaceHandle,
+        item::{Item, ItemHandle},
+        open_new, open_paths, pane, NewFile, Pane, SplitDirection, WorkspaceHandle,
     };
 
     #[gpui::test]
@@ -764,7 +762,8 @@ mod tests {
     #[gpui::test]
     async fn test_new_empty_workspace(cx: &mut TestAppContext) {
         let app_state = init(cx);
-        cx.dispatch_global_action(workspace::NewFile);
+        cx.update(|cx| open_new(&app_state, cx)).await;
+
         let window_id = *cx.window_ids().first().unwrap();
         let workspace = cx.root_view::<Workspace>(window_id).unwrap();
         let editor = workspace.update(cx, |workspace, cx| {
@@ -808,8 +807,9 @@ mod tests {
             .await;
 
         let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
 
         let entries = cx.read(|cx| workspace.file_project_paths(cx));
         let file1 = entries[0].clone();
@@ -928,8 +928,9 @@ mod tests {
             .await;
 
         let project = Project::test(app_state.fs.clone(), ["/dir1".as_ref()], cx).await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
 
         // Open a file within an existing worktree.
         cx.update(|cx| {
@@ -1088,8 +1089,9 @@ mod tests {
             .await;
 
         let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
-        let (window_id, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (window_id, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
 
         // Open a file within an existing worktree.
         cx.update(|cx| {
@@ -1131,8 +1133,9 @@ mod tests {
 
         let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
         project.update(cx, |project, _| project.languages().add(rust_lang()));
-        let (window_id, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (window_id, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
         let worktree = cx.read(|cx| workspace.read(cx).worktrees(cx).next().unwrap());
 
         // Create a new untitled buffer
@@ -1221,8 +1224,9 @@ mod tests {
 
         let project = Project::test(app_state.fs.clone(), [], cx).await;
         project.update(cx, |project, _| project.languages().add(rust_lang()));
-        let (window_id, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (window_id, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
 
         // Create a new untitled buffer
         cx.dispatch_action(window_id, NewFile);
@@ -1275,8 +1279,9 @@ mod tests {
             .await;
 
         let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
-        let (window_id, workspace) =
-            cx.add_window(|cx| Workspace::new(project, |_, _| unimplemented!(), cx));
+        let (window_id, workspace) = cx.add_window(|cx| {
+            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
+        });
 
         let entries = cx.read(|cx| workspace.file_project_paths(cx));
         let file1 = entries[0].clone();
@@ -1350,8 +1355,15 @@ mod tests {
             .await;
 
         let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(
+                Default::default(),
+                0,
+                project.clone(),
+                |_, _| unimplemented!(),
+                cx,
+            )
+        });
 
         let entries = cx.read(|cx| workspace.file_project_paths(cx));
         let file1 = entries[0].clone();
@@ -1615,8 +1627,15 @@ mod tests {
             .await;
 
         let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(
+                Default::default(),
+                0,
+                project.clone(),
+                |_, _| unimplemented!(),
+                cx,
+            )
+        });
         let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
 
         let entries = cx.read(|cx| workspace.file_project_paths(cx));

styles/src/styleTree/app.ts 🔗

@@ -12,6 +12,7 @@ import sharedScreen from "./sharedScreen";
 import projectDiagnostics from "./projectDiagnostics";
 import contactNotification from "./contactNotification";
 import updateNotification from "./updateNotification";
+import simpleMessageNotification from "./simpleMessageNotification";
 import projectSharedNotification from "./projectSharedNotification";
 import tooltip from "./tooltip";
 import terminal from "./terminal";
@@ -47,6 +48,7 @@ export default function app(colorScheme: ColorScheme): Object {
       },
     },
     updateNotification: updateNotification(colorScheme),
+    simpleMessageNotification: simpleMessageNotification(colorScheme),
     tooltip: tooltip(colorScheme),
     terminal: terminal(colorScheme),
     colorScheme: {

styles/src/styleTree/simpleMessageNotification.ts 🔗

@@ -0,0 +1,31 @@
+import { ColorScheme } from "../themes/common/colorScheme";
+import { foreground, text } from "./components";
+
+const headerPadding = 8;
+
+export default function simpleMessageNotification(colorScheme: ColorScheme): Object {
+  let layer = colorScheme.middle;
+  return {
+    message: {
+      ...text(layer, "sans", { size: "md" }),
+      margin: { left: headerPadding, right: headerPadding },
+    },
+    actionMessage: {
+      ...text(layer, "sans", { size: "md" }),
+      margin: { left: headerPadding, top: 6, bottom: 6 },
+      hover: {
+        color: foreground(layer, "hovered"),
+      },
+    },
+    dismissButton: {
+      color: foreground(layer),
+      iconWidth: 8,
+      iconHeight: 8,
+      buttonWidth: 8,
+      buttonHeight: 8,
+      hover: {
+        color: foreground(layer, "hovered"),
+      },
+    },
+  };
+}