Merge pull request #109 from zed-industries/server

Nathan Sobo created

Include contents of the zed-server repo

Change summary

.dockerignore                                             |    3 
.gitignore                                                |    3 
Cargo.lock                                                |  609 ++
Cargo.toml                                                |    3 
Dockerfile                                                |   33 
Dockerfile.migrator                                       |   15 
gpui/Cargo.toml                                           |   12 
gpui/grammars/context-predicate/Cargo.toml                |    8 
gpui/src/app.rs                                           |   10 
gpui/src/executor.rs                                      |  165 
gpui/src/lib.rs                                           |    1 
gpui_macros/Cargo.toml                                    |    2 
scoped_pool/Cargo.toml                                    |    8 
scoped_pool/src/lib.rs                                    |  188 
script/build-css                                          |   10 
script/deploy                                             |   17 
script/deploy-migration                                   |   11 
script/package-lock.json                                  | 2452 +++++++++
script/package.json                                       |    6 
script/server                                             |    6 
script/sqlx                                               |   12 
script/tailwind.config.js                                 |   44 
server/.env.template.toml                                 |   11 
server/Cargo.toml                                         |   50 
server/Procfile                                           |    2 
server/README.md                                          |   17 
server/basic.conf                                         |   12 
server/manifest.yml                                       |   71 
server/migrate.yml                                        |   17 
server/migrations/20210527024318_initial_schema.sql       |   26 
server/migrations/20210607190313_create_access_tokens.sql |    7 
server/src/admin.rs                                       |  160 
server/src/assets.rs                                      |   31 
server/src/auth.rs                                        |  336 +
server/src/bin/dotenv.rs                                  |   20 
server/src/env.rs                                         |   20 
server/src/errors.rs                                      |   73 
server/src/expiring.rs                                    |   43 
server/src/github.rs                                      |  265 
server/src/home.rs                                        |  112 
server/src/main.rs                                        |  197 
server/src/rpc.rs                                         |  652 ++
server/src/team.rs                                        |   15 
server/src/tests.rs                                       |  599 ++
server/static/fonts/VisbyCF-Bold.eot                      |    0 
server/static/fonts/VisbyCF-Bold.woff                     |    0 
server/static/fonts/VisbyCF-Bold.woff2                    |    0 
server/static/fonts/VisbyCF-BoldOblique.eot               |    0 
server/static/fonts/VisbyCF-BoldOblique.woff              |    0 
server/static/fonts/VisbyCF-BoldOblique.woff2             |    0 
server/static/fonts/VisbyCF-DemiBold.eot                  |    0 
server/static/fonts/VisbyCF-DemiBold.woff                 |    0 
server/static/fonts/VisbyCF-DemiBold.woff2                |    0 
server/static/fonts/VisbyCF-DemiBoldOblique.eot           |    0 
server/static/fonts/VisbyCF-DemiBoldOblique.woff          |    0 
server/static/fonts/VisbyCF-DemiBoldOblique.woff2         |    0 
server/static/fonts/VisbyCF-ExtraBold.eot                 |    0 
server/static/fonts/VisbyCF-ExtraBold.woff                |    0 
server/static/fonts/VisbyCF-ExtraBold.woff2               |    0 
server/static/fonts/VisbyCF-ExtraBoldOblique.eot          |    0 
server/static/fonts/VisbyCF-ExtraBoldOblique.woff         |    0 
server/static/fonts/VisbyCF-ExtraBoldOblique.woff2        |    0 
server/static/fonts/VisbyCF-Heavy.eot                     |    0 
server/static/fonts/VisbyCF-Heavy.woff                    |    0 
server/static/fonts/VisbyCF-Heavy.woff2                   |    0 
server/static/fonts/VisbyCF-HeavyOblique.eot              |    0 
server/static/fonts/VisbyCF-HeavyOblique.woff             |    0 
server/static/fonts/VisbyCF-HeavyOblique.woff2            |    0 
server/static/fonts/VisbyCF-Light.eot                     |    0 
server/static/fonts/VisbyCF-Light.woff                    |    0 
server/static/fonts/VisbyCF-Light.woff2                   |    0 
server/static/fonts/VisbyCF-LightOblique.eot              |    0 
server/static/fonts/VisbyCF-LightOblique.woff             |    0 
server/static/fonts/VisbyCF-LightOblique.woff2            |    0 
server/static/fonts/VisbyCF-Medium.eot                    |    0 
server/static/fonts/VisbyCF-Medium.woff                   |    0 
server/static/fonts/VisbyCF-Medium.woff2                  |    0 
server/static/fonts/VisbyCF-MediumOblique.eot             |    0 
server/static/fonts/VisbyCF-MediumOblique.woff            |    0 
server/static/fonts/VisbyCF-MediumOblique.woff2           |    0 
server/static/fonts/VisbyCF-Regular.eot                   |    0 
server/static/fonts/VisbyCF-Regular.woff                  |    0 
server/static/fonts/VisbyCF-Regular.woff2                 |    0 
server/static/fonts/VisbyCF-RegularOblique.eot            |    0 
server/static/fonts/VisbyCF-RegularOblique.woff           |    0 
server/static/fonts/VisbyCF-RegularOblique.woff2          |    0 
server/static/fonts/VisbyCF-Thin.eot                      |    0 
server/static/fonts/VisbyCF-Thin.woff                     |    0 
server/static/fonts/VisbyCF-Thin.woff2                    |    0 
server/static/fonts/VisbyCF-ThinOblique.eot               |    0 
server/static/fonts/VisbyCF-ThinOblique.woff              |    0 
server/static/fonts/VisbyCF-ThinOblique.woff2             |    0 
server/static/images/favicon.png                          |    0 
server/static/svg/hero.svg                                |    4 
server/styles.css                                         |  108 
server/templates/admin.hbs                                |   81 
server/templates/docs.hbs                                 |   41 
server/templates/error.hbs                                |    7 
server/templates/home.hbs                                 |   69 
server/templates/partials/layout.hbs                      |   62 
server/templates/signup.hbs                               |   19 
server/templates/team.hbs                                 |   62 
zed/Cargo.toml                                            |   26 
zed/src/editor/buffer.rs                                  |   22 
zed/src/file_finder.rs                                    |  108 
zed/src/lib.rs                                            |    3 
zed/src/main.rs                                           |    6 
zed/src/rpc.rs                                            |    6 
zed/src/test.rs                                           |    9 
zed/src/time.rs                                           |    8 
zed/src/workspace.rs                                      |  403 
zed/src/worktree.rs                                       |  822 --
zed/src/worktree/fs.rs                                    |  490 +
zed/src/worktree/fuzzy.rs                                 |  147 
zrpc/Cargo.toml                                           |    8 
zrpc/build.rs                                             |    0 
zrpc/proto/zed.proto                                      |    0 
zrpc/src/auth.rs                                          |    0 
zrpc/src/lib.rs                                           |    0 
zrpc/src/peer.rs                                          |    3 
zrpc/src/proto.rs                                         |    0 
zrpc/src/test.rs                                          |    0 
122 files changed, 7,629 insertions(+), 1,239 deletions(-)

Detailed changes

.gitignore 🔗

@@ -1,3 +1,6 @@
 /target
 /zed.xcworkspace
 .DS_Store
+/script/node_modules
+/server/.env.toml
+/server/static/styles.css

Cargo.lock 🔗

@@ -29,7 +29,7 @@ version = "0.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7fc95d1bdb8e6666b2b217308eeeb09f2d6728d104be3e31916cc74d15420331"
 dependencies = [
- "generic-array",
+ "generic-array 0.14.4",
 ]
 
 [[package]]
@@ -40,7 +40,7 @@ checksum = "884391ef1066acaa41e766ba8f596341b96e93ce34f9a43e7d24bf0a0eaf0561"
 dependencies = [
  "aes-soft",
  "aesni",
- "cipher",
+ "cipher 0.2.5",
 ]
 
 [[package]]
@@ -51,7 +51,7 @@ checksum = "5278b5fabbb9bd46e24aa69b2fdea62c99088e0a950a9be40e3e0101298f88da"
 dependencies = [
  "aead",
  "aes",
- "cipher",
+ "cipher 0.2.5",
  "ctr",
  "ghash",
  "subtle",
@@ -63,8 +63,8 @@ version = "0.6.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "be14c7498ea50828a38d0e24a765ed2effe92a705885b57d029cd67d45744072"
 dependencies = [
- "cipher",
- "opaque-debug",
+ "cipher 0.2.5",
+ "opaque-debug 0.3.0",
 ]
 
 [[package]]
@@ -73,8 +73,36 @@ version = "0.10.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ea2e11f5e94c2f7d386164cc2aa1f97823fed6f259e486940a71c174dd01b0ce"
 dependencies = [
- "cipher",
- "opaque-debug",
+ "cipher 0.2.5",
+ "opaque-debug 0.3.0",
+]
+
+[[package]]
+name = "ahash"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "739f4a8db6605981345c5654f3a85b056ce52f37a39d34da03f25bf2151ea16e"
+
+[[package]]
+name = "ahash"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "796540673305a66d127804eef19ad696f1f204b8c1025aaca4958c17eab32877"
+dependencies = [
+ "getrandom 0.2.2",
+ "once_cell",
+ "version_check",
+]
+
+[[package]]
+name = "ahash"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43bb833f0bf979d8475d38fbf09ed3b8a55e1885fe93ad3f93239fc6a4f17b98"
+dependencies = [
+ "getrandom 0.2.2",
+ "once_cell",
+ "version_check",
 ]
 
 [[package]]
@@ -86,6 +114,21 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5192ec435945d87bc2f70992b4d818154b5feede43c09fb7592146374eac90a6"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "697ed7edc0f1711de49ce108c541623a0af97c6c60b2f6e2b65229847ac843c2"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
 [[package]]
 name = "ansi_term"
 version = "0.11.0"
@@ -97,9 +140,9 @@ dependencies = [
 
 [[package]]
 name = "anyhow"
-version = "1.0.38"
+version = "1.0.42"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "afddf7f520a80dbf76e6f50a35bca42a2331ef227a28b3b6dc5c2e2338d114b1"
+checksum = "595d3cfa7a60d4555cb5067b99f07142a08ea778de5cf993f7b75c7d8fabc486"
 
 [[package]]
 name = "ar"
@@ -125,6 +168,16 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bbf56136a5198c7b01a49e3afcbef6cf84597273d298f54432926024107b0109"
 
+[[package]]
+name = "async-attributes"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5"
+dependencies = [
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "async-channel"
 version = "1.6.1"
@@ -136,6 +189,30 @@ dependencies = [
  "futures-core",
 ]
 
+[[package]]
+name = "async-compression"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5443ccbb270374a2b1055fc72da40e1f237809cd6bb0e97e66d264cd138473a6"
+dependencies = [
+ "brotli",
+ "flate2",
+ "futures-core",
+ "futures-io",
+ "memchr",
+ "pin-project-lite 0.2.4",
+]
+
+[[package]]
+name = "async-dup"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7427a12b8dc09291528cfb1da2447059adb4a257388c2acd6497a79d55cf6f7c"
+dependencies = [
+ "futures-io",
+ "simple-mutex",
+]
+
 [[package]]
 name = "async-executor"
 version = "1.4.0"
@@ -177,6 +254,24 @@ dependencies = [
  "once_cell",
 ]
 
+[[package]]
+name = "async-h1"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc5142de15b549749cce62923a50714b0d7b77f5090ced141599e78899865451"
+dependencies = [
+ "async-channel",
+ "async-dup",
+ "async-std",
+ "byte-pool",
+ "futures-core",
+ "http-types",
+ "httparse",
+ "lazy_static",
+ "log",
+ "pin-project",
+]
+
 [[package]]
 name = "async-io"
 version = "1.3.1"
@@ -243,16 +338,86 @@ dependencies = [
  "winapi 0.3.9",
 ]
 
+[[package]]
+name = "async-rustls"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f38092e8f467f47aadaff680903c7cbfeee7926b058d7f40af2dd4c878fbdee"
+dependencies = [
+ "futures-lite",
+ "rustls 0.18.1",
+ "webpki",
+]
+
+[[package]]
+name = "async-rustls"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c86f33abd5a4f3e2d6d9251a9e0c6a7e52eb1113caf893dae8429bf4a53f378"
+dependencies = [
+ "futures-lite",
+ "rustls 0.19.1",
+ "webpki",
+]
+
+[[package]]
+name = "async-session"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "345022a2eed092cd105cc1b26fd61c341e100bd5fcbbd792df4baf31c2cc631f"
+dependencies = [
+ "anyhow",
+ "async-std",
+ "async-trait",
+ "base64 0.12.3",
+ "bincode",
+ "blake3",
+ "chrono",
+ "hmac 0.8.1",
+ "kv-log-macro",
+ "rand 0.7.3",
+ "serde 1.0.125",
+ "serde_json 1.0.64",
+ "sha2",
+]
+
+[[package]]
+name = "async-sqlx-session"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b66fb8c6ffbf26cdba6705c36f086b6f02f0b4778b6a348134302a2a00730bc"
+dependencies = [
+ "async-session",
+ "async-std",
+ "sqlx 0.4.2",
+]
+
+[[package]]
+name = "async-sse"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53bba003996b8fd22245cd0c59b869ba764188ed435392cf2796d03b805ade10"
+dependencies = [
+ "async-channel",
+ "async-std",
+ "http-types",
+ "log",
+ "memchr",
+ "pin-project-lite 0.1.12",
+]
+
 [[package]]
 name = "async-std"
 version = "1.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d9f06685bad74e0570f5213741bea82158279a4103d988e57bfada11ad230341"
 dependencies = [
+ "async-attributes",
  "async-channel",
  "async-global-executor",
  "async-io",
  "async-lock",
+ "async-process",
  "crossbeam-utils",
  "futures-channel",
  "futures-core",
@@ -264,7 +429,7 @@ dependencies = [
  "memchr",
  "num_cpus",
  "once_cell",
- "pin-project-lite",
+ "pin-project-lite 0.2.4",
  "pin-utils",
  "slab",
  "wasm-bindgen-futures",
@@ -283,7 +448,7 @@ checksum = "2f23d769dbf1838d5df5156e7b1ad404f4c463d1ac2c6aeb6cd943630f8a8400"
 dependencies = [
  "futures-core",
  "futures-io",
- "rustls",
+ "rustls 0.19.1",
  "webpki",
  "webpki-roots",
 ]
@@ -309,10 +474,19 @@ dependencies = [
  "futures-io",
  "futures-util",
  "log",
- "pin-project-lite",
+ "pin-project-lite 0.2.4",
  "tungstenite",
 ]
 
+[[package]]
+name = "atoi"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616896e05fc0e2649463a93a15183c6a16bf03413a7af88ef1285ddedfa9cda5"
+dependencies = [
+ "num-traits 0.2.14",
+]
+
 [[package]]
 name = "atomic"
 version = "0.5.0"
@@ -383,6 +557,21 @@ version = "0.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
 
+[[package]]
+name = "base64ct"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0d27fb6b6f1e43147af148af49d49329413ba781aa0d5e10979831c210173b5"
+
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde 1.0.125",
+]
+
 [[package]]
 name = "bindgen"
 version = "0.58.1"
@@ -392,7 +581,7 @@ dependencies = [
  "bitflags 1.2.1",
  "cexpr",
  "clang-sys",
- "clap",
+ "clap 2.33.3",
  "env_logger",
  "lazy_static",
  "lazycell",
@@ -418,6 +607,18 @@ version = "1.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
 
+[[package]]
+name = "bitvec"
+version = "0.19.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8942c8d352ae1838c9dda0b0ca2ab657696ef2232a20147cf1b30ae1a9cb4321"
+dependencies = [
+ "funty",
+ "radium",
+ "tap",
+ "wyz",
+]
+
 [[package]]
 name = "blake2b_simd"
 version = "0.5.11"
@@ -429,19 +630,55 @@ dependencies = [
  "constant_time_eq",
 ]
 
+[[package]]
+name = "blake3"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b64485778c4f16a6a5a9d335e80d449ac6c70cdd6a06d2af18a6f6f775a125b3"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "cc",
+ "cfg-if 0.1.10",
+ "constant_time_eq",
+ "crypto-mac 0.8.0",
+ "digest 0.9.0",
+]
+
 [[package]]
 name = "block"
 version = "0.1.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
 
+[[package]]
+name = "block-buffer"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b"
+dependencies = [
+ "block-padding",
+ "byte-tools",
+ "byteorder",
+ "generic-array 0.12.4",
+]
+
 [[package]]
 name = "block-buffer"
 version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
 dependencies = [
- "generic-array",
+ "generic-array 0.14.4",
+]
+
+[[package]]
+name = "block-padding"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5"
+dependencies = [
+ "byte-tools",
 ]
 
 [[package]]
@@ -458,6 +695,27 @@ dependencies = [
  "once_cell",
 ]
 
+[[package]]
+name = "brotli"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f29919120f08613aadcd4383764e00526fc9f18b6c0895814faeed0dd78613e"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1052e1c3b8d4d80eb84a8b94f0a1498797b5fb96314c001156a1c761940ef4ec"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
 [[package]]
 name = "bstr"
 version = "0.2.15"
@@ -467,12 +725,34 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "build_const"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ae4235e6dac0694637c763029ecea1a2ec9e4e06ec2729bd21ba4d9c863eb7"
+
 [[package]]
 name = "bumpalo"
 version = "3.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9c59e7af012c713f529e7a3ee57ce9b31ddd858d4b512923602f74608b009631"
 
+[[package]]
+name = "byte-pool"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8c7230ddbb427b1094d477d821a99f3f54d36333178eeb806e279bcdcecf0ca"
+dependencies = [
+ "crossbeam-queue",
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "byte-tools"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7"
+
 [[package]]
 name = "bytemuck"
 version = "1.5.1"
@@ -523,7 +803,7 @@ dependencies = [
  "ar",
  "cab",
  "chrono",
- "clap",
+ "clap 2.33.3",
  "dirs 1.0.5",
  "error-chain",
  "glob 0.2.11",
@@ -543,6 +823,28 @@ dependencies = [
  "walkdir",
 ]
 
+[[package]]
+name = "cargo-platform"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0226944a63d1bf35a3b5f948dd7c59e263db83695c9e8bffc4037de02e30f1d7"
+dependencies = [
+ "serde 1.0.125",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714a157da7991e23d90686b9524b9e12e0407a108647f52e9328f4b3d51ac7f"
+dependencies = [
+ "cargo-platform",
+ "semver 0.11.0",
+ "semver-parser 0.10.2",
+ "serde 1.0.125",
+ "serde_json 1.0.64",
+]
+
 [[package]]
 name = "cc"
 version = "1.0.67"
@@ -555,7 +857,7 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f4aedb84272dbe89af497cf81375129abda4fc0a9e7c5d317498c15cc30c0d27"
 dependencies = [
- "nom",
+ "nom 5.1.2",
 ]
 
 [[package]]
@@ -589,6 +891,7 @@ dependencies = [
  "libc",
  "num-integer",
  "num-traits 0.2.14",
+ "serde 1.0.125",
  "time 0.1.44",
  "winapi 0.3.9",
 ]
@@ -605,7 +908,16 @@ version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "12f8e7987cbd042a63249497f41aed09f8e65add917ea6566effbc56578d6801"
 dependencies = [
- "generic-array",
+ "generic-array 0.14.4",
+]
+
+[[package]]
+name = "cipher"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7"
+dependencies = [
+ "generic-array 0.14.4",
 ]
 
 [[package]]
@@ -629,11 +941,43 @@ dependencies = [
  "atty",
  "bitflags 1.2.1",
  "strsim 0.8.0",
- "textwrap",
+ "textwrap 0.11.0",
  "unicode-width",
  "vec_map",
 ]
 
+[[package]]
+name = "clap"
+version = "3.0.0-beta.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bd1061998a501ee7d4b6d449020df3266ca3124b941ec56cf2005c3779ca142"
+dependencies = [
+ "atty",
+ "bitflags 1.2.1",
+ "clap_derive",
+ "indexmap",
+ "lazy_static",
+ "os_str_bytes",
+ "strsim 0.10.0",
+ "termcolor",
+ "textwrap 0.12.1",
+ "unicode-width",
+ "vec_map",
+]
+
+[[package]]
+name = "clap_derive"
+version = "3.0.0-beta.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "370f715b81112975b1b69db93e0b56ea4cd4e5002ac43b2da8474106a54096a1"
+dependencies = [
+ "heck",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "cloudabi"
 version = "0.0.3"
@@ -652,6 +996,18 @@ dependencies = [
  "cc",
 ]
 
+[[package]]
+name = "coarsetime"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2918e2ffa91a49dabbba4965fe38a37a1ba0b6953a29e32cc250a8d59cd42232"
+dependencies = [
+ "libc",
+ "once_cell",
+ "wasi 0.10.0+wasi-snapshot-preview1",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "cocoa"
 version = "0.24.0"
@@ -687,6 +1043,25 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
 
+[[package]]
+name = "comrak"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b423acba50d5016684beaf643f9991e622633a4c858be6885653071c2da2b0c6"
+dependencies = [
+ "clap 2.33.3",
+ "entities",
+ "lazy_static",
+ "pest",
+ "pest_derive",
+ "regex",
+ "shell-words",
+ "twoway",
+ "typed-arena",
+ "unicode_categories",
+ "xdg",
+]
+
 [[package]]
 name = "concurrent-queue"
 version = "1.2.2"
@@ -696,6 +1071,12 @@ dependencies = [
  "cache-padded",
 ]
 
+[[package]]
+name = "const-oid"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c32f031ea41b4291d695026c023b95d59db2d8a2c7640800ed56bc8f510f22"
+
 [[package]]
 name = "const_fn"
 version = "0.4.8"
@@ -717,7 +1098,7 @@ dependencies = [
  "aes-gcm",
  "base64 0.13.0",
  "hkdf",
- "hmac",
+ "hmac 0.10.1",
  "percent-encoding",
  "rand 0.8.3",
  "sha2",
@@ -789,6 +1170,15 @@ version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dcb25d077389e53838a8158c8e99174c5a9d902dee4904320db714f3c653ffba"
 
+[[package]]
+name = "crc"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d663548de7f5cca343f1e0a48d14dcfb0e9eb4e079ec58883b7251539fa10aeb"
+dependencies = [
+ "build_const",
+]
+
 [[package]]
 name = "crc32fast"
 version = "1.2.1"
@@ -855,16 +1245,54 @@ dependencies = [
  "loom",
 ]
 
+[[package]]
+name = "crypto-bigint"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b32a398eb1ccfbe7e4f452bc749c44d38dd732e9a253f19da224c416f00ee7f4"
+dependencies = [
+ "generic-array 0.14.4",
+ "rand_core 0.6.2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-mac"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab"
+dependencies = [
+ "generic-array 0.14.4",
+ "subtle",
+]
+
+[[package]]
+name = "crypto-mac"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4857fd85a0c34b3c3297875b747c1e02e06b6a0ea32dd892d8192b9ce0813ea6"
+dependencies = [
+ "generic-array 0.14.4",
+ "subtle",
+]
+
 [[package]]
 name = "crypto-mac"
-version = "0.10.0"
+version = "0.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4857fd85a0c34b3c3297875b747c1e02e06b6a0ea32dd892d8192b9ce0813ea6"
+checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e"
 dependencies = [
- "generic-array",
+ "generic-array 0.14.4",
  "subtle",
 ]
 
+[[package]]
+name = "ct-codecs"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3b7eb4404b8195a9abb6356f4ac07d8ba267045c8d6d220ac4dc992e6cc75df"
+
 [[package]]
 name = "ctor"
 version = "0.1.20"
@@ -881,7 +1309,7 @@ version = "0.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fb4a30d54f7443bf3d6191dcd486aca19e67cb3c49fa7a06a319966346707e7f"
 dependencies = [
- "cipher",
+ "cipher 0.2.5",
 ]
 
 [[package]]
@@ -960,13 +1388,31 @@ dependencies = [
  "byteorder",
 ]
 
+[[package]]
+name = "der"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49f215f706081a44cb702c71c39a52c05da637822e9c1645a50b7202689e982d"
+dependencies = [
+ "const-oid",
+]
+
+[[package]]
+name = "digest"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5"
+dependencies = [
+ "generic-array 0.12.4",
+]
+
 [[package]]
 name = "digest"
 version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
 dependencies = [
- "generic-array",
+ "generic-array 0.14.4",
 ]
 
 [[package]]
@@ -1027,6 +1473,12 @@ version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0"
 
+[[package]]
+name = "dotenv"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
+
 [[package]]
 name = "dtoa"
 version = "0.4.8"
@@ -1051,12 +1503,49 @@ version = "3.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1dd4afd79212583ff429b913ad6605242ed7eec277e950b1438f300748f948f4"
 
+[[package]]
+name = "ecdsa"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05cb0ed2d2ce37766ac86c05f66973ace8c51f7f1533bedce8fb79e2b54b3f14"
+dependencies = [
+ "der",
+ "elliptic-curve",
+ "hmac 0.11.0",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-compact"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aaf396058cc7285b342f9a10ed7a377f088942396c46c4c9a7eb4f0782cb1171"
+dependencies = [
+ "getrandom 0.2.2",
+]
+
 [[package]]
 name = "either"
 version = "1.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457"
 
+[[package]]
+name = "elliptic-curve"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83e5c176479da93a0983f0a6fdc3c1b8e7d5be0d7fe3fe05a99f15b96582b9a8"
+dependencies = [
+ "crypto-bigint",
+ "ff",
+ "generic-array 0.14.4",
+ "group",
+ "pkcs8",
+ "rand_core 0.6.2",
+ "subtle",
+ "zeroize",
+]
+
 [[package]]
 name = "encoding"
 version = "0.2.33"
@@ -1130,6 +1619,12 @@ dependencies = [
  "cfg-if 1.0.0",
 ]
 
+[[package]]
+name = "entities"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
+
 [[package]]
 name = "enum_primitive"
 version = "0.1.1"
@@ -1152,6 +1647,15 @@ dependencies = [
  "termcolor",
 ]
 
+[[package]]
+name = "envy"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965"
+dependencies = [
+ "serde 1.0.125",
+]
+
 [[package]]
 name = "error-chain"
 version = "0.12.4"
@@ -1197,6 +1701,12 @@ dependencies = [
  "pkg-config",
 ]
 
+[[package]]
+name = "fake-simd"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed"
+
 [[package]]
 name = "fastrand"
 version = "1.4.0"
@@ -1206,6 +1716,32 @@ dependencies = [
  "instant",
 ]
 
+[[package]]
+name = "femme"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af1a24f391a5a94d756db5092c6576aad494b88a71a5a36b20c67b63e0df034"
+dependencies = [
+ "cfg-if 0.1.10",
+ "js-sys",
+ "log",
+ "serde 1.0.125",
+ "serde_derive",
+ "serde_json 1.0.64",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "ff"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63eec06c61e487eecf0f7e6e6372e596a81922c28d33e645d6983ca6493a1af0"
+dependencies = [
+ "rand_core 0.6.2",
+ "subtle",
+]
+
 [[package]]
 name = "filetime"
 version = "0.2.14"
@@ -1371,6 +1907,12 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
 
+[[package]]
+name = "funty"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7"
+
 [[package]]
 name = "futures"
 version = "0.3.12"
@@ -1430,7 +1972,7 @@ dependencies = [
  "futures-io",
  "memchr",
  "parking",
- "pin-project-lite",
+ "pin-project-lite 0.2.4",
  "waker-fn",
 ]
 
@@ -1471,7 +2013,7 @@ dependencies = [
  "futures-sink",
  "futures-task",
  "memchr",
- "pin-project-lite",
+ "pin-project-lite 0.2.4",
  "pin-utils",
  "proc-macro-hack",
  "proc-macro-nested",
@@ -1491,6 +2033,15 @@ dependencies = [
  "winapi 0.3.9",
 ]
 
+[[package]]
+name = "generic-array"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd"
+dependencies = [
+ "typenum",
+]
+
 [[package]]
 name = "generic-array"
 version = "0.14.4"
@@ -1519,8 +2070,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c9495705279e7140bf035dde1f6e750c162df8b625267cd52cc44e0b156732c8"
 dependencies = [
  "cfg-if 1.0.0",
+ "js-sys",
  "libc",
  "wasi 0.10.0+wasi-snapshot-preview1",
+ "wasm-bindgen",
 ]
 
 [[package]]
@@ -1529,7 +2082,7 @@ version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "97304e4cd182c3846f7575ced3890c53012ce534ad9114046b0a9e00bb30a375"
 dependencies = [
- "opaque-debug",
+ "opaque-debug 0.3.0",
  "polyval",
 ]
 
@@ -1593,6 +2146,7 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "async-task",
+ "backtrace",
  "bindgen",
  "block",
  "cc",
@@ -1619,7 +2173,6 @@ dependencies = [
  "rand 0.8.3",
  "replace_with",
  "resvg",
- "scoped-pool",
  "seahash",
  "serde 1.0.125",
  "serde_json 1.0.64",

Cargo.toml 🔗

@@ -1,10 +1,9 @@
 [workspace]
-members = ["zed", "zed-rpc", "gpui", "gpui_macros", "fsevent", "scoped_pool"]
+members = ["fsevent", "gpui", "gpui_macros", "server", "zed", "zrpc"]
 
 [patch.crates-io]
 async-task = { git = "https://github.com/zed-industries/async-task", rev = "341b57d6de98cdfd7b418567b8de2022ca993a6e" }
 tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "d72771a19f4143530b1cfd23808e344f1276e176" }
-
 # 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 = "025dcb3c0d1ef01530f57ef65f3b1deb948f5737" }
 cocoa-foundation = { git = "https://github.com/servo/core-foundation-rs", rev = "025dcb3c0d1ef01530f57ef65f3b1deb948f5737" }

Dockerfile 🔗

@@ -0,0 +1,33 @@
+# syntax = docker/dockerfile:1.2
+
+FROM rust as builder
+WORKDIR app
+RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash -
+RUN apt-get install -y nodejs
+COPY . .
+
+# Install script dependencies
+RUN --mount=type=cache,target=./script/node_modules \
+    cd ./script && npm install --quiet
+
+# Build CSS
+RUN --mount=type=cache,target=./script/node_modules \
+    script/build-css --release
+
+# Compile server
+RUN --mount=type=cache,target=./script/node_modules \
+    --mount=type=cache,target=/usr/local/cargo/registry \
+    --mount=type=cache,target=./target \
+    cargo build --release --bin zed-server
+
+# Copy server binary out of cached directory
+RUN --mount=type=cache,target=./target \
+    cp /app/target/release/zed-server /app/zed-server
+
+# Copy server binary to the runtime image
+FROM debian:buster-slim as runtime
+RUN apt-get update; \
+    apt-get install -y --no-install-recommends libcurl4-openssl-dev ca-certificates
+WORKDIR app
+COPY --from=builder /app/zed-server /app
+ENTRYPOINT ["/app/zed-server"]

Dockerfile.migrator 🔗

@@ -0,0 +1,15 @@
+# syntax = docker/dockerfile:1.2
+
+FROM rust as builder
+WORKDIR app
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+    --mount=type=cache,target=./target \
+    cargo install sqlx-cli --root=/app --target-dir=/app/target --version 0.5.5
+
+FROM debian:buster-slim as runtime
+RUN apt-get update; \
+    apt-get install -y --no-install-recommends libssl1.1
+WORKDIR app
+COPY --from=builder /app/bin/sqlx /app
+COPY ./server/migrations /app/migrations
+ENTRYPOINT ["/app/sqlx", "migrate", "run"]

gpui/Cargo.toml 🔗

@@ -6,24 +6,24 @@ version = "0.1.0"
 
 [dependencies]
 async-task = "4.0.3"
+backtrace = "0.3"
 ctor = "0.1"
 etagere = "0.2"
-gpui_macros = {path = "../gpui_macros"}
+gpui_macros = { path = "../gpui_macros" }
 log = "0.4"
 num_cpus = "1.13"
 ordered-float = "2.1.1"
 parking_lot = "0.11.1"
 pathfinder_color = "0.5"
 pathfinder_geometry = "0.5"
-postage = {version = "0.4.1", features = ["futures-traits"]}
+postage = { version = "0.4.1", features = ["futures-traits"] }
 rand = "0.8.3"
 replace_with = "0.1.7"
 resvg = "0.14"
-scoped-pool = {path = "../scoped_pool"}
 seahash = "4.1"
-serde = {version = "1.0.125", features = ["derive"]}
+serde = { version = "1.0.125", features = ["derive"] }
 serde_json = "1.0.64"
-smallvec = {version = "1.6", features = ["union"]}
+smallvec = { version = "1.6", features = ["union"] }
 smol = "1.2"
 tiny-skia = "0.5"
 tree-sitter = "0.19"
@@ -45,7 +45,7 @@ cocoa = "0.24"
 core-foundation = "0.9"
 core-graphics = "0.22.2"
 core-text = "19.2"
-font-kit = {git = "https://github.com/zed-industries/font-kit", rev = "8eaf7a918eafa28b0a37dc759e2e0e7683fa24f1"}
+font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "8eaf7a918eafa28b0a37dc759e2e0e7683fa24f1" }
 foreign-types = "0.3"
 log = "0.4"
 metal = "0.21.0"

gpui/grammars/context-predicate/Cargo.toml 🔗

@@ -7,14 +7,8 @@ categories = ["parsing", "text-editors"]
 repository = "https://github.com/tree-sitter/tree-sitter-javascript"
 edition = "2018"
 license = "MIT"
-
 build = "bindings/rust/build.rs"
-include = [
-  "bindings/rust/*",
-  "grammar.js",
-  "queries/*",
-  "src/*",
-]
+include = ["bindings/rust/*", "grammar.js", "queries/*", "src/*"]
 
 [lib]
 path = "bindings/rust/lib.rs"

gpui/src/app.rs 🔗

@@ -607,7 +607,6 @@ impl MutableAppContext {
                 values: Default::default(),
                 ref_counts: Arc::new(Mutex::new(RefCounts::default())),
                 background,
-                thread_pool: scoped_pool::Pool::new(num_cpus::get(), "app"),
                 font_cache: Arc::new(FontCache::new(fonts)),
             },
             actions: HashMap::new(),
@@ -1485,7 +1484,6 @@ pub struct AppContext {
     values: RwLock<HashMap<(TypeId, usize), Box<dyn Any>>>,
     background: Arc<executor::Background>,
     ref_counts: Arc<Mutex<RefCounts>>,
-    thread_pool: scoped_pool::Pool,
     font_cache: Arc<FontCache>,
 }
 
@@ -1530,10 +1528,6 @@ impl AppContext {
         &self.font_cache
     }
 
-    pub fn thread_pool(&self) -> &scoped_pool::Pool {
-        &self.thread_pool
-    }
-
     pub fn value<Tag: 'static, T: 'static + Default>(&self, id: usize) -> ValueHandle<T> {
         let key = (TypeId::of::<Tag>(), id);
         let mut values = self.values.write();
@@ -1716,10 +1710,6 @@ impl<'a, T: Entity> ModelContext<'a, T> {
         &self.app.cx.background
     }
 
-    pub fn thread_pool(&self) -> &scoped_pool::Pool {
-        &self.app.cx.thread_pool
-    }
-
     pub fn halt_stream(&mut self) {
         self.halt_stream = true;
     }

gpui/src/executor.rs 🔗

@@ -1,17 +1,19 @@
 use anyhow::{anyhow, Result};
 use async_task::Runnable;
 pub use async_task::Task;
+use backtrace::{Backtrace, BacktraceFmt, BytesOrWideString};
 use parking_lot::Mutex;
 use rand::prelude::*;
 use smol::{channel, prelude::*, Executor};
 use std::{
+    fmt::{self, Debug},
     marker::PhantomData,
     mem,
     pin::Pin,
     rc::Rc,
     sync::{
         atomic::{AtomicBool, Ordering::SeqCst},
-        mpsc::SyncSender,
+        mpsc::Sender,
         Arc,
     },
     thread,
@@ -32,7 +34,6 @@ pub enum Background {
     Deterministic(Arc<Deterministic>),
     Production {
         executor: Arc<smol::Executor<'static>>,
-        threads: usize,
         _stop: channel::Sender<()>,
     },
 }
@@ -40,9 +41,9 @@ pub enum Background {
 struct DeterministicState {
     rng: StdRng,
     seed: u64,
-    scheduled: Vec<Runnable>,
-    spawned_from_foreground: Vec<Runnable>,
-    waker: Option<SyncSender<()>>,
+    scheduled: Vec<(Runnable, Backtrace)>,
+    spawned_from_foreground: Vec<(Runnable, Backtrace)>,
+    waker: Option<Sender<()>>,
 }
 
 pub struct Deterministic(Arc<Mutex<DeterministicState>>);
@@ -63,14 +64,16 @@ impl Deterministic {
         T: 'static,
         F: Future<Output = T> + 'static,
     {
+        let backtrace = Backtrace::new_unresolved();
         let scheduled_once = AtomicBool::new(false);
         let state = self.0.clone();
         let (runnable, task) = async_task::spawn_local(future, move |runnable| {
             let mut state = state.lock();
+            let backtrace = backtrace.clone();
             if scheduled_once.fetch_or(true, SeqCst) {
-                state.scheduled.push(runnable);
+                state.scheduled.push((runnable, backtrace));
             } else {
-                state.spawned_from_foreground.push(runnable);
+                state.spawned_from_foreground.push((runnable, backtrace));
             }
             if let Some(waker) = state.waker.as_ref() {
                 waker.send(()).ok();
@@ -85,10 +88,11 @@ impl Deterministic {
         T: 'static + Send,
         F: 'static + Send + Future<Output = T>,
     {
+        let backtrace = Backtrace::new_unresolved();
         let state = self.0.clone();
         let (runnable, task) = async_task::spawn(future, move |runnable| {
             let mut state = state.lock();
-            state.scheduled.push(runnable);
+            state.scheduled.push((runnable, backtrace.clone()));
             if let Some(waker) = state.waker.as_ref() {
                 waker.send(()).ok();
             }
@@ -102,7 +106,7 @@ impl Deterministic {
         T: 'static,
         F: Future<Output = T> + 'static,
     {
-        let (wake_tx, wake_rx) = std::sync::mpsc::sync_channel(32);
+        let (wake_tx, wake_rx) = std::sync::mpsc::channel();
         let state = self.0.clone();
         state.lock().waker = Some(wake_tx);
 
@@ -113,6 +117,7 @@ impl Deterministic {
         })
         .detach();
 
+        let mut trace = Trace::default();
         loop {
             if let Ok(value) = output_rx.try_recv() {
                 state.lock().waker = None;
@@ -126,9 +131,13 @@ impl Deterministic {
                     .rng
                     .gen_range(0..state.scheduled.len() + state.spawned_from_foreground.len());
                 if ix < state.scheduled.len() {
-                    state.scheduled.remove(ix)
+                    let (_, backtrace) = &state.scheduled[ix];
+                    trace.record(&state, backtrace.clone());
+                    state.scheduled.remove(ix).0
                 } else {
-                    state.spawned_from_foreground.remove(0)
+                    let (_, backtrace) = &state.spawned_from_foreground[0];
+                    trace.record(&state, backtrace.clone());
+                    state.spawned_from_foreground.remove(0).0
                 }
             };
 
@@ -137,6 +146,129 @@ impl Deterministic {
     }
 }
 
+#[derive(Default)]
+struct Trace {
+    executed: Vec<Backtrace>,
+    scheduled: Vec<Vec<Backtrace>>,
+    spawned_from_foreground: Vec<Vec<Backtrace>>,
+}
+
+impl Trace {
+    fn record(&mut self, state: &DeterministicState, executed: Backtrace) {
+        self.scheduled.push(
+            state
+                .scheduled
+                .iter()
+                .map(|(_, backtrace)| backtrace.clone())
+                .collect(),
+        );
+        self.spawned_from_foreground.push(
+            state
+                .spawned_from_foreground
+                .iter()
+                .map(|(_, backtrace)| backtrace.clone())
+                .collect(),
+        );
+        self.executed.push(executed);
+    }
+
+    fn resolve(&mut self) {
+        for backtrace in &mut self.executed {
+            backtrace.resolve();
+        }
+
+        for backtraces in &mut self.scheduled {
+            for backtrace in backtraces {
+                backtrace.resolve();
+            }
+        }
+
+        for backtraces in &mut self.spawned_from_foreground {
+            for backtrace in backtraces {
+                backtrace.resolve();
+            }
+        }
+    }
+}
+
+impl Debug for Trace {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        struct FirstCwdFrameInBacktrace<'a>(&'a Backtrace);
+
+        impl<'a> Debug for FirstCwdFrameInBacktrace<'a> {
+            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result {
+                let cwd = std::env::current_dir().unwrap();
+                let mut print_path = |fmt: &mut fmt::Formatter<'_>, path: BytesOrWideString<'_>| {
+                    fmt::Display::fmt(&path, fmt)
+                };
+                let mut fmt = BacktraceFmt::new(f, backtrace::PrintFmt::Full, &mut print_path);
+                for frame in self.0.frames() {
+                    let mut formatted_frame = fmt.frame();
+                    if frame
+                        .symbols()
+                        .iter()
+                        .any(|s| s.filename().map_or(false, |f| f.starts_with(&cwd)))
+                    {
+                        formatted_frame.backtrace_frame(frame)?;
+                        break;
+                    }
+                }
+                fmt.finish()
+            }
+        }
+
+        for ((backtrace, scheduled), spawned_from_foreground) in self
+            .executed
+            .iter()
+            .zip(&self.scheduled)
+            .zip(&self.spawned_from_foreground)
+        {
+            writeln!(f, "Scheduled")?;
+            for backtrace in scheduled {
+                writeln!(f, "- {:?}", FirstCwdFrameInBacktrace(backtrace))?;
+            }
+            if scheduled.is_empty() {
+                writeln!(f, "None")?;
+            }
+            writeln!(f, "==========")?;
+
+            writeln!(f, "Spawned from foreground")?;
+            for backtrace in spawned_from_foreground {
+                writeln!(f, "- {:?}", FirstCwdFrameInBacktrace(backtrace))?;
+            }
+            if spawned_from_foreground.is_empty() {
+                writeln!(f, "None")?;
+            }
+            writeln!(f, "==========")?;
+
+            writeln!(f, "Run: {:?}", FirstCwdFrameInBacktrace(backtrace))?;
+            writeln!(f, "+++++++++++++++++++")?;
+        }
+
+        Ok(())
+    }
+}
+
+impl Drop for Trace {
+    fn drop(&mut self) {
+        let trace_on_panic = if let Ok(trace_on_panic) = std::env::var("EXECUTOR_TRACE_ON_PANIC") {
+            trace_on_panic == "1" || trace_on_panic == "true"
+        } else {
+            false
+        };
+        let trace_always = if let Ok(trace_always) = std::env::var("EXECUTOR_TRACE_ALWAYS") {
+            trace_always == "1" || trace_always == "true"
+        } else {
+            false
+        };
+
+        if trace_always || (trace_on_panic && thread::panicking()) {
+            self.resolve();
+            dbg!(self);
+        }
+    }
+}
+
 impl Foreground {
     pub fn platform(dispatcher: Arc<dyn platform::Dispatcher>) -> Result<Self> {
         if dispatcher.is_main_thread() {
@@ -191,9 +323,8 @@ impl Background {
     pub fn new() -> Self {
         let executor = Arc::new(Executor::new());
         let stop = channel::unbounded::<()>();
-        let threads = num_cpus::get();
 
-        for i in 0..threads {
+        for i in 0..2 * num_cpus::get() {
             let executor = executor.clone();
             let stop = stop.1.clone();
             thread::Builder::new()
@@ -204,16 +335,12 @@ impl Background {
 
         Self::Production {
             executor,
-            threads,
             _stop: stop.0,
         }
     }
 
-    pub fn threads(&self) -> usize {
-        match self {
-            Self::Deterministic(_) => 1,
-            Self::Production { threads, .. } => *threads,
-        }
+    pub fn num_cpus(&self) -> usize {
+        num_cpus::get()
     }
 
     pub fn spawn<T, F>(&self, future: F) -> Task<T>

gpui/src/lib.rs 🔗

@@ -30,4 +30,3 @@ pub use presenter::{
     AfterLayoutContext, Axis, DebugContext, EventContext, LayoutContext, PaintContext,
     SizeConstraint, Vector2FExt,
 };
-pub use scoped_pool;

gpui_macros/Cargo.toml 🔗

@@ -9,4 +9,4 @@ proc-macro = true
 [dependencies]
 syn = "1.0"
 quote = "1.0"
-proc-macro2 = "1.0"
+proc-macro2 = "1.0"

scoped_pool/Cargo.toml 🔗

@@ -1,8 +0,0 @@
-[package]
-name = "scoped-pool"
-version = "0.0.1"
-license = "MIT"
-edition = "2018"
-
-[dependencies]
-crossbeam-channel = "0.5"

scoped_pool/src/lib.rs 🔗

@@ -1,188 +0,0 @@
-use crossbeam_channel as chan;
-use std::{marker::PhantomData, mem::transmute, thread};
-
-#[derive(Clone)]
-pub struct Pool {
-    req_tx: chan::Sender<Request>,
-    thread_count: usize,
-}
-
-pub struct Scope<'a> {
-    req_count: usize,
-    req_tx: chan::Sender<Request>,
-    resp_tx: chan::Sender<()>,
-    resp_rx: chan::Receiver<()>,
-    phantom: PhantomData<&'a ()>,
-}
-
-struct Request {
-    callback: Box<dyn FnOnce() + Send + 'static>,
-    resp_tx: chan::Sender<()>,
-}
-
-impl Pool {
-    pub fn new(thread_count: usize, name: impl AsRef<str>) -> Self {
-        let (req_tx, req_rx) = chan::unbounded();
-        for i in 0..thread_count {
-            thread::Builder::new()
-                .name(format!("scoped_pool {} {}", name.as_ref(), i))
-                .spawn({
-                    let req_rx = req_rx.clone();
-                    move || loop {
-                        match req_rx.recv() {
-                            Err(_) => break,
-                            Ok(Request { callback, resp_tx }) => {
-                                callback();
-                                resp_tx.send(()).ok();
-                            }
-                        }
-                    }
-                })
-                .expect("scoped_pool: failed to spawn thread");
-        }
-        Self {
-            req_tx,
-            thread_count,
-        }
-    }
-
-    pub fn thread_count(&self) -> usize {
-        self.thread_count
-    }
-
-    pub fn scoped<'scope, F, R>(&self, scheduler: F) -> R
-    where
-        F: FnOnce(&mut Scope<'scope>) -> R,
-    {
-        let (resp_tx, resp_rx) = chan::bounded(1);
-        let mut scope = Scope {
-            resp_tx,
-            resp_rx,
-            req_count: 0,
-            phantom: PhantomData,
-            req_tx: self.req_tx.clone(),
-        };
-        let result = scheduler(&mut scope);
-        scope.wait();
-        result
-    }
-}
-
-impl<'scope> Scope<'scope> {
-    pub fn execute<F>(&mut self, callback: F)
-    where
-        F: FnOnce() + Send + 'scope,
-    {
-        // Transmute the callback's lifetime to be 'static. This is safe because in ::wait,
-        // we block until all the callbacks have been called and dropped.
-        let callback = unsafe {
-            transmute::<Box<dyn FnOnce() + Send + 'scope>, Box<dyn FnOnce() + Send + 'static>>(
-                Box::new(callback),
-            )
-        };
-
-        self.req_count += 1;
-        self.req_tx
-            .send(Request {
-                callback,
-                resp_tx: self.resp_tx.clone(),
-            })
-            .unwrap();
-    }
-
-    fn wait(&self) {
-        for _ in 0..self.req_count {
-            self.resp_rx.recv().unwrap();
-        }
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use std::sync::{Arc, Mutex};
-
-    #[test]
-    fn test_execute() {
-        let pool = Pool::new(3, "test");
-
-        {
-            let vec = Mutex::new(Vec::new());
-            pool.scoped(|scope| {
-                for _ in 0..3 {
-                    scope.execute(|| {
-                        for i in 0..5 {
-                            vec.lock().unwrap().push(i);
-                        }
-                    });
-                }
-            });
-
-            let mut vec = vec.into_inner().unwrap();
-            vec.sort_unstable();
-            assert_eq!(vec, [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4])
-        }
-    }
-
-    #[test]
-    fn test_clone_send_and_execute() {
-        let pool = Pool::new(3, "test");
-
-        let mut threads = Vec::new();
-        for _ in 0..3 {
-            threads.push(thread::spawn({
-                let pool = pool.clone();
-                move || {
-                    let vec = Mutex::new(Vec::new());
-                    pool.scoped(|scope| {
-                        for _ in 0..3 {
-                            scope.execute(|| {
-                                for i in 0..5 {
-                                    vec.lock().unwrap().push(i);
-                                }
-                            });
-                        }
-                    });
-                    let mut vec = vec.into_inner().unwrap();
-                    vec.sort_unstable();
-                    assert_eq!(vec, [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4])
-                }
-            }));
-        }
-
-        for thread in threads {
-            thread.join().unwrap();
-        }
-    }
-
-    #[test]
-    fn test_share_and_execute() {
-        let pool = Arc::new(Pool::new(3, "test"));
-
-        let mut threads = Vec::new();
-        for _ in 0..3 {
-            threads.push(thread::spawn({
-                let pool = pool.clone();
-                move || {
-                    let vec = Mutex::new(Vec::new());
-                    pool.scoped(|scope| {
-                        for _ in 0..3 {
-                            scope.execute(|| {
-                                for i in 0..5 {
-                                    vec.lock().unwrap().push(i);
-                                }
-                            });
-                        }
-                    });
-                    let mut vec = vec.into_inner().unwrap();
-                    vec.sort_unstable();
-                    assert_eq!(vec, [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4])
-                }
-            }));
-        }
-
-        for thread in threads {
-            thread.join().unwrap();
-        }
-    }
-}

script/build-css 🔗

@@ -0,0 +1,10 @@
+#!/bin/bash
+
+set -e
+
+cd ./script
+[ -d node_modules ] || npm install
+if [[ $1 == --release ]]; then
+    export NODE_ENV=production # Purge unused styles in --release mode
+fi
+npx tailwindcss build ../server/styles.css --output ../server/static/styles.css

script/deploy 🔗

@@ -0,0 +1,17 @@
+#!/bin/bash
+
+# Prerequisites:
+#
+# - Log in to the DigitalOcean docker registry
+#   doctl registry login
+#
+# - Set the default K8s context to production
+#   doctl kubernetes cluster kubeconfig save zed-1
+
+set -e
+
+IMAGE_ID=registry.digitalocean.com/zed/zed-server
+
+docker build . --tag $IMAGE_ID
+docker push $IMAGE_ID
+kubectl rollout restart deployment zed

script/deploy-migration 🔗

@@ -0,0 +1,11 @@
+#!/bin/bash
+
+set -e
+
+IMAGE_ID=registry.digitalocean.com/zed/zed-migrator
+
+docker build . \
+  --file ./Dockerfile.migrator \
+  --tag $IMAGE_ID
+docker push $IMAGE_ID
+kubectl apply -f ./server/migrate.yml

script/package-lock.json 🔗

@@ -0,0 +1,2452 @@
+{
+  "name": "script",
+  "lockfileVersion": 2,
+  "requires": true,
+  "packages": {
+    "": {
+      "devDependencies": {
+        "@tailwindcss/typography": "^0.4.0",
+        "tailwindcss-cli": "^0.1.2"
+      }
+    },
+    "node_modules/@fullhuman/postcss-purgecss": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/@fullhuman/postcss-purgecss/-/postcss-purgecss-3.1.3.tgz",
+      "integrity": "sha512-kwOXw8fZ0Lt1QmeOOrd+o4Ibvp4UTEBFQbzvWldjlKv5n+G9sXfIPn1hh63IQIL8K8vbvv1oYMJiIUbuy9bGaA==",
+      "dev": true,
+      "dependencies": {
+        "purgecss": "^3.1.3"
+      }
+    },
+    "node_modules/@nodelib/fs.scandir": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz",
+      "integrity": "sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "2.0.4",
+        "run-parallel": "^1.1.9"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.stat": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.4.tgz",
+      "integrity": "sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.walk": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.6.tgz",
+      "integrity": "sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.scandir": "2.1.4",
+        "fastq": "^1.6.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@tailwindcss/typography": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.4.0.tgz",
+      "integrity": "sha512-3BfOYT5MYNEq81Ism3L2qu/HRP2Q5vWqZtZRQqQrthHuaTK9qpuPfbMT5WATjAM5J1OePKBaI5pLoX4S1JGNMQ==",
+      "dev": true,
+      "dependencies": {
+        "lodash.castarray": "^4.4.0",
+        "lodash.isplainobject": "^4.0.6",
+        "lodash.merge": "^4.6.2",
+        "lodash.uniq": "^4.5.0"
+      },
+      "peerDependencies": {
+        "tailwindcss": "2.0.0-alpha.24 || ^2.0.0"
+      }
+    },
+    "node_modules/acorn": {
+      "version": "7.4.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
+      "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
+      "dev": true,
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-node": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz",
+      "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==",
+      "dev": true,
+      "dependencies": {
+        "acorn": "^7.0.0",
+        "acorn-walk": "^7.0.0",
+        "xtend": "^4.0.2"
+      }
+    },
+    "node_modules/acorn-walk": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz",
+      "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/anymatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+      "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+      "dev": true,
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/at-least-node": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
+      "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 4.0.0"
+      }
+    },
+    "node_modules/autoprefixer": {
+      "version": "10.2.5",
+      "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.2.5.tgz",
+      "integrity": "sha512-7H4AJZXvSsn62SqZyJCP+1AWwOuoYpUfK6ot9vm0e87XD6mT8lDywc9D9OTJPMULyGcvmIxzTAMeG2Cc+YX+fA==",
+      "dev": true,
+      "dependencies": {
+        "browserslist": "^4.16.3",
+        "caniuse-lite": "^1.0.30001196",
+        "colorette": "^1.2.2",
+        "fraction.js": "^4.0.13",
+        "normalize-range": "^0.1.2",
+        "postcss-value-parser": "^4.1.0"
+      },
+      "bin": {
+        "autoprefixer": "bin/autoprefixer"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/postcss/"
+      },
+      "peerDependencies": {
+        "postcss": "^8.1.0"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true
+    },
+    "node_modules/binary-extensions": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+      "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+      "dev": true,
+      "dependencies": {
+        "fill-range": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.16.6",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.6.tgz",
+      "integrity": "sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ==",
+      "dev": true,
+      "dependencies": {
+        "caniuse-lite": "^1.0.30001219",
+        "colorette": "^1.2.2",
+        "electron-to-chromium": "^1.3.723",
+        "escalade": "^3.1.1",
+        "node-releases": "^1.1.71"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/browserslist"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
+      "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/camelcase-css": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+      "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001228",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001228.tgz",
+      "integrity": "sha512-QQmLOGJ3DEgokHbMSA8cj2a+geXqmnpyOFT0lhQV6P3/YOJvGDEwoedcwxEQ30gJIwIIunHIicunJ2rzK5gB2A==",
+      "dev": true,
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/browserslist"
+      }
+    },
+    "node_modules/chalk": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
+      "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/chokidar": {
+      "version": "3.5.1",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz",
+      "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==",
+      "dev": true,
+      "dependencies": {
+        "anymatch": "~3.1.1",
+        "braces": "~3.0.2",
+        "fsevents": "~2.3.1",
+        "glob-parent": "~5.1.0",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.5.0"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.1"
+      }
+    },
+    "node_modules/color": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/color/-/color-3.1.3.tgz",
+      "integrity": "sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^1.9.1",
+        "color-string": "^1.5.4"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true
+    },
+    "node_modules/color-string": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.5.tgz",
+      "integrity": "sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "^1.0.0",
+        "simple-swizzle": "^0.2.2"
+      }
+    },
+    "node_modules/color/node_modules/color-convert": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "1.1.3"
+      }
+    },
+    "node_modules/color/node_modules/color-name": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+      "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
+      "dev": true
+    },
+    "node_modules/colorette": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz",
+      "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==",
+      "dev": true
+    },
+    "node_modules/commander": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
+      "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+      "dev": true
+    },
+    "node_modules/css-unit-converter": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz",
+      "integrity": "sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA==",
+      "dev": true
+    },
+    "node_modules/cssesc": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+      "dev": true,
+      "bin": {
+        "cssesc": "bin/cssesc"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/defined": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz",
+      "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=",
+      "dev": true
+    },
+    "node_modules/detective": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz",
+      "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==",
+      "dev": true,
+      "dependencies": {
+        "acorn-node": "^1.6.1",
+        "defined": "^1.0.0",
+        "minimist": "^1.1.1"
+      },
+      "bin": {
+        "detective": "bin/detective.js"
+      },
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/didyoumean": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.1.tgz",
+      "integrity": "sha1-6S7f2tplN9SE1zwBcv0eugxJdv8=",
+      "dev": true
+    },
+    "node_modules/dlv": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+      "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+      "dev": true
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.3.728",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.728.tgz",
+      "integrity": "sha512-SHv4ziXruBpb1Nz4aTuqEHBYi/9GNCJMYIJgDEXrp/2V01nFXMNFUTli5Z85f5ivSkioLilQatqBYFB44wNJrA==",
+      "dev": true
+    },
+    "node_modules/escalade": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+      "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/fast-glob": {
+      "version": "3.2.5",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz",
+      "integrity": "sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.0",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.2",
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/fastq": {
+      "version": "1.11.0",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.11.0.tgz",
+      "integrity": "sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g==",
+      "dev": true,
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+      "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+      "dev": true,
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/fraction.js": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.1.0.tgz",
+      "integrity": "sha512-o9lSKpK0TDqDwTL24Hxqi6I99s942l6TYkfl6WvGWgLOIFz/YonSGKfiSeMadoiNvTfqnfOa9mjb5SGVbBK9/w==",
+      "dev": true,
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/fs-extra": {
+      "version": "9.1.0",
+      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
+      "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+      "dev": true,
+      "dependencies": {
+        "at-least-node": "^1.0.0",
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^6.0.1",
+        "universalify": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+      "dev": true
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+      "dev": true
+    },
+    "node_modules/glob": {
+      "version": "7.1.7",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
+      "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+      "dev": true,
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/glob-base": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz",
+      "integrity": "sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=",
+      "dev": true,
+      "dependencies": {
+        "glob-parent": "^2.0.0",
+        "is-glob": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/glob-base/node_modules/glob-parent": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz",
+      "integrity": "sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^2.0.0"
+      }
+    },
+    "node_modules/glob-base/node_modules/is-extglob": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz",
+      "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/glob-base/node_modules/is-glob": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz",
+      "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=",
+      "dev": true,
+      "dependencies": {
+        "is-extglob": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.6",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz",
+      "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==",
+      "dev": true
+    },
+    "node_modules/has": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/html-tags": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.1.0.tgz",
+      "integrity": "sha512-1qYz89hW3lFDEazhjW0yVAV87lw8lVkrJocr72XmBkMKsoSVJCQx3W8BXsC7hO2qAt8BoVjYjtAcZ9perqGnNg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+      "dev": true,
+      "dependencies": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "dev": true
+    },
+    "node_modules/is-arrayish": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
+      "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
+      "dev": true
+    },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz",
+      "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==",
+      "dev": true,
+      "dependencies": {
+        "has": "^1.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-dotfile": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.3.tgz",
+      "integrity": "sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
+      "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
+      "dev": true,
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/jsonfile": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
+      "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+      "dev": true,
+      "dependencies": {
+        "graceful-fs": "^4.1.6",
+        "universalify": "^2.0.0"
+      },
+      "optionalDependencies": {
+        "graceful-fs": "^4.1.6"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "dev": true
+    },
+    "node_modules/lodash.castarray": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
+      "integrity": "sha1-wCUTUV4wna3dTCTGDP3c9ZdtkRU=",
+      "dev": true
+    },
+    "node_modules/lodash.isplainobject": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+      "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=",
+      "dev": true
+    },
+    "node_modules/lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true
+    },
+    "node_modules/lodash.toarray": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
+      "integrity": "sha1-JMS/zWsvuji/0FlNsRedjptlZWE=",
+      "dev": true
+    },
+    "node_modules/lodash.topath": {
+      "version": "4.5.2",
+      "resolved": "https://registry.npmjs.org/lodash.topath/-/lodash.topath-4.5.2.tgz",
+      "integrity": "sha1-NhY1Hzu6YZlKCTGYlmC9AyVP0Ak=",
+      "dev": true
+    },
+    "node_modules/lodash.uniq": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
+      "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=",
+      "dev": true
+    },
+    "node_modules/merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz",
+      "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==",
+      "dev": true,
+      "dependencies": {
+        "braces": "^3.0.1",
+        "picomatch": "^2.2.3"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+      "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
+      "dev": true
+    },
+    "node_modules/modern-normalize": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/modern-normalize/-/modern-normalize-1.1.0.tgz",
+      "integrity": "sha512-2lMlY1Yc1+CUy0gw4H95uNN7vjbpoED7NNRSBHE25nWfLBdmMzFCsPshlzbxHz+gYMcBEUN8V4pU16prcdPSgA==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.1.23",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
+      "integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==",
+      "dev": true,
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/node-emoji": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.10.0.tgz",
+      "integrity": "sha512-Yt3384If5H6BYGVHiHwTL+99OzJKHhgp82S8/dktEK73T26BazdgZ4JZh92xSVtGNJvz9UbXdNAc5hcrXV42vw==",
+      "dev": true,
+      "dependencies": {
+        "lodash.toarray": "^4.4.0"
+      }
+    },
+    "node_modules/node-releases": {
+      "version": "1.1.72",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.72.tgz",
+      "integrity": "sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw==",
+      "dev": true
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/normalize-range": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+      "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-hash": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.1.1.tgz",
+      "integrity": "sha512-VOJmgmS+7wvXf8CjbQmimtCnEx3IAoLxI3fp2fbWehxrWBcAQFbk+vcwb6vzR0VZv/eNCJ/27j151ZTwqW/JeQ==",
+      "dev": true,
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+      "dev": true,
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/parse-glob": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz",
+      "integrity": "sha1-ssN2z7EfNVE7rdFz7wu246OIORw=",
+      "dev": true,
+      "dependencies": {
+        "glob-base": "^0.3.0",
+        "is-dotfile": "^1.0.0",
+        "is-extglob": "^1.0.0",
+        "is-glob": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/parse-glob/node_modules/is-extglob": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz",
+      "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/parse-glob/node_modules/is-glob": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz",
+      "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=",
+      "dev": true,
+      "dependencies": {
+        "is-extglob": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
+      "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
+      "dev": true
+    },
+    "node_modules/picomatch": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.3.tgz",
+      "integrity": "sha512-KpELjfwcCDUb9PeigTs2mBJzXUPzAuP2oPcA989He8Rte0+YUAjw1JVedDhuTKPkHjSYzMN3npC9luThGYEKdg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.2.15",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.2.15.tgz",
+      "integrity": "sha512-2zO3b26eJD/8rb106Qu2o7Qgg52ND5HPjcyQiK2B98O388h43A448LCslC0dI2P97wCAQRJsFvwTRcXxTKds+Q==",
+      "dev": true,
+      "dependencies": {
+        "colorette": "^1.2.2",
+        "nanoid": "^3.1.23",
+        "source-map": "^0.6.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/postcss/"
+      }
+    },
+    "node_modules/postcss-functions": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-functions/-/postcss-functions-3.0.0.tgz",
+      "integrity": "sha1-DpTQFERwCkgd4g3k1V+yZAVkJQ4=",
+      "dev": true,
+      "dependencies": {
+        "glob": "^7.1.2",
+        "object-assign": "^4.1.1",
+        "postcss": "^6.0.9",
+        "postcss-value-parser": "^3.3.0"
+      }
+    },
+    "node_modules/postcss-functions/node_modules/ansi-styles": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+      "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^1.9.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postcss-functions/node_modules/chalk": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+      "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^3.2.1",
+        "escape-string-regexp": "^1.0.5",
+        "supports-color": "^5.3.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postcss-functions/node_modules/color-convert": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "1.1.3"
+      }
+    },
+    "node_modules/postcss-functions/node_modules/color-name": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+      "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
+      "dev": true
+    },
+    "node_modules/postcss-functions/node_modules/has-flag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+      "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postcss-functions/node_modules/postcss": {
+      "version": "6.0.23",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz",
+      "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==",
+      "dev": true,
+      "dependencies": {
+        "chalk": "^2.4.1",
+        "source-map": "^0.6.1",
+        "supports-color": "^5.4.0"
+      },
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/postcss-functions/node_modules/postcss-value-parser": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz",
+      "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==",
+      "dev": true
+    },
+    "node_modules/postcss-functions/node_modules/supports-color": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postcss-js": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-3.0.3.tgz",
+      "integrity": "sha512-gWnoWQXKFw65Hk/mi2+WTQTHdPD5UJdDXZmX073EY/B3BWnYjO4F4t0VneTCnCGQ5E5GsCdMkzPaTXwl3r5dJw==",
+      "dev": true,
+      "dependencies": {
+        "camelcase-css": "^2.0.1",
+        "postcss": "^8.1.6"
+      },
+      "engines": {
+        "node": ">=10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/postcss/"
+      }
+    },
+    "node_modules/postcss-nested": {
+      "version": "5.0.5",
+      "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.5.tgz",
+      "integrity": "sha512-GSRXYz5bccobpTzLQZXOnSOfKl6TwVr5CyAQJUPub4nuRJSOECK5AqurxVgmtxP48p0Kc/ndY/YyS1yqldX0Ew==",
+      "dev": true,
+      "dependencies": {
+        "postcss-selector-parser": "^6.0.4"
+      },
+      "engines": {
+        "node": ">=10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/postcss/"
+      },
+      "peerDependencies": {
+        "postcss": "^8.1.13"
+      }
+    },
+    "node_modules/postcss-selector-parser": {
+      "version": "6.0.6",
+      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.6.tgz",
+      "integrity": "sha512-9LXrvaaX3+mcv5xkg5kFwqSzSH1JIObIx51PrndZwlmznwXRfxMddDvo9gve3gVR8ZTKgoFDdWkbRFmEhT4PMg==",
+      "dev": true,
+      "dependencies": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postcss-value-parser": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz",
+      "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==",
+      "dev": true
+    },
+    "node_modules/pretty-hrtime": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz",
+      "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/purgecss": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/purgecss/-/purgecss-3.1.3.tgz",
+      "integrity": "sha512-hRSLN9mguJ2lzlIQtW4qmPS2kh6oMnA9RxdIYK8sz18QYqd6ePp4GNDl18oWHA1f2v2NEQIh51CO8s/E3YGckQ==",
+      "dev": true,
+      "dependencies": {
+        "commander": "^6.0.0",
+        "glob": "^7.0.0",
+        "postcss": "^8.2.1",
+        "postcss-selector-parser": "^6.0.2"
+      },
+      "bin": {
+        "purgecss": "bin/purgecss.js"
+      }
+    },
+    "node_modules/queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/quick-lru": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
+      "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/readdirp": {
+      "version": "3.5.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz",
+      "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==",
+      "dev": true,
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/reduce-css-calc": {
+      "version": "2.1.8",
+      "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz",
+      "integrity": "sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg==",
+      "dev": true,
+      "dependencies": {
+        "css-unit-converter": "^1.1.1",
+        "postcss-value-parser": "^3.3.0"
+      }
+    },
+    "node_modules/reduce-css-calc/node_modules/postcss-value-parser": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz",
+      "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==",
+      "dev": true
+    },
+    "node_modules/resolve": {
+      "version": "1.20.0",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
+      "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
+      "dev": true,
+      "dependencies": {
+        "is-core-module": "^2.2.0",
+        "path-parse": "^1.0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/reusify": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+      "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+      "dev": true,
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "dependencies": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "node_modules/simple-swizzle": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
+      "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=",
+      "dev": true,
+      "dependencies": {
+        "is-arrayish": "^0.3.1"
+      }
+    },
+    "node_modules/source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/tailwindcss": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-2.1.2.tgz",
+      "integrity": "sha512-T5t+wwd+/hsOyRw2HJuFuv0LTUm3MUdHm2DJ94GPVgzqwPPFa9XxX0KlwLWupUuiOUj6uiKURCzYPHFcuPch/w==",
+      "dev": true,
+      "dependencies": {
+        "@fullhuman/postcss-purgecss": "^3.1.3",
+        "bytes": "^3.0.0",
+        "chalk": "^4.1.0",
+        "chokidar": "^3.5.1",
+        "color": "^3.1.3",
+        "detective": "^5.2.0",
+        "didyoumean": "^1.2.1",
+        "dlv": "^1.1.3",
+        "fast-glob": "^3.2.5",
+        "fs-extra": "^9.1.0",
+        "html-tags": "^3.1.0",
+        "lodash": "^4.17.21",
+        "lodash.topath": "^4.5.2",
+        "modern-normalize": "^1.0.0",
+        "node-emoji": "^1.8.1",
+        "normalize-path": "^3.0.0",
+        "object-hash": "^2.1.1",
+        "parse-glob": "^3.0.4",
+        "postcss-functions": "^3",
+        "postcss-js": "^3.0.3",
+        "postcss-nested": "5.0.5",
+        "postcss-selector-parser": "^6.0.4",
+        "postcss-value-parser": "^4.1.0",
+        "pretty-hrtime": "^1.0.3",
+        "quick-lru": "^5.1.1",
+        "reduce-css-calc": "^2.1.8",
+        "resolve": "^1.20.0"
+      },
+      "bin": {
+        "tailwind": "lib/cli.js",
+        "tailwindcss": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=12.13.0"
+      },
+      "peerDependencies": {
+        "autoprefixer": "^10.0.2",
+        "postcss": "^8.0.9"
+      }
+    },
+    "node_modules/tailwindcss-cli": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/tailwindcss-cli/-/tailwindcss-cli-0.1.2.tgz",
+      "integrity": "sha512-17NuGSHKTr4twN1BFxuoTArMcBQH+7YL6x4PHFnmWsGNOX45O4Roc8EdMVhSSH2rQoSDoLvR4TmlfddMon3yKg==",
+      "dev": true,
+      "dependencies": {
+        "autoprefixer": "^10.0.2",
+        "postcss": "^8.1.8",
+        "tailwindcss": "^2.0.1"
+      },
+      "bin": {
+        "tailwindcss-cli": "index.js"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/universalify": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
+      "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
+      "dev": true,
+      "engines": {
+        "node": ">= 10.0.0"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
+      "dev": true
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+      "dev": true
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.4"
+      }
+    }
+  },
+  "dependencies": {
+    "@fullhuman/postcss-purgecss": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/@fullhuman/postcss-purgecss/-/postcss-purgecss-3.1.3.tgz",
+      "integrity": "sha512-kwOXw8fZ0Lt1QmeOOrd+o4Ibvp4UTEBFQbzvWldjlKv5n+G9sXfIPn1hh63IQIL8K8vbvv1oYMJiIUbuy9bGaA==",
+      "dev": true,
+      "requires": {
+        "purgecss": "^3.1.3"
+      }
+    },
+    "@nodelib/fs.scandir": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz",
+      "integrity": "sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.stat": "2.0.4",
+        "run-parallel": "^1.1.9"
+      }
+    },
+    "@nodelib/fs.stat": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.4.tgz",
+      "integrity": "sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q==",
+      "dev": true
+    },
+    "@nodelib/fs.walk": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.6.tgz",
+      "integrity": "sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.scandir": "2.1.4",
+        "fastq": "^1.6.0"
+      }
+    },
+    "@tailwindcss/typography": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.4.0.tgz",
+      "integrity": "sha512-3BfOYT5MYNEq81Ism3L2qu/HRP2Q5vWqZtZRQqQrthHuaTK9qpuPfbMT5WATjAM5J1OePKBaI5pLoX4S1JGNMQ==",
+      "dev": true,
+      "requires": {
+        "lodash.castarray": "^4.4.0",
+        "lodash.isplainobject": "^4.0.6",
+        "lodash.merge": "^4.6.2",
+        "lodash.uniq": "^4.5.0"
+      }
+    },
+    "acorn": {
+      "version": "7.4.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
+      "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
+      "dev": true
+    },
+    "acorn-node": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz",
+      "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==",
+      "dev": true,
+      "requires": {
+        "acorn": "^7.0.0",
+        "acorn-walk": "^7.0.0",
+        "xtend": "^4.0.2"
+      }
+    },
+    "acorn-walk": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz",
+      "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==",
+      "dev": true
+    },
+    "ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "requires": {
+        "color-convert": "^2.0.1"
+      }
+    },
+    "anymatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+      "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+      "dev": true,
+      "requires": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      }
+    },
+    "at-least-node": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
+      "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+      "dev": true
+    },
+    "autoprefixer": {
+      "version": "10.2.5",
+      "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.2.5.tgz",
+      "integrity": "sha512-7H4AJZXvSsn62SqZyJCP+1AWwOuoYpUfK6ot9vm0e87XD6mT8lDywc9D9OTJPMULyGcvmIxzTAMeG2Cc+YX+fA==",
+      "dev": true,
+      "requires": {
+        "browserslist": "^4.16.3",
+        "caniuse-lite": "^1.0.30001196",
+        "colorette": "^1.2.2",
+        "fraction.js": "^4.0.13",
+        "normalize-range": "^0.1.2",
+        "postcss-value-parser": "^4.1.0"
+      }
+    },
+    "balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true
+    },
+    "binary-extensions": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+      "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+      "dev": true
+    },
+    "brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dev": true,
+      "requires": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "braces": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+      "dev": true,
+      "requires": {
+        "fill-range": "^7.0.1"
+      }
+    },
+    "browserslist": {
+      "version": "4.16.6",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.6.tgz",
+      "integrity": "sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ==",
+      "dev": true,
+      "requires": {
+        "caniuse-lite": "^1.0.30001219",
+        "colorette": "^1.2.2",
+        "electron-to-chromium": "^1.3.723",
+        "escalade": "^3.1.1",
+        "node-releases": "^1.1.71"
+      }
+    },
+    "bytes": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
+      "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==",
+      "dev": true
+    },
+    "camelcase-css": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+      "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+      "dev": true
+    },
+    "caniuse-lite": {
+      "version": "1.0.30001228",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001228.tgz",
+      "integrity": "sha512-QQmLOGJ3DEgokHbMSA8cj2a+geXqmnpyOFT0lhQV6P3/YOJvGDEwoedcwxEQ30gJIwIIunHIicunJ2rzK5gB2A==",
+      "dev": true
+    },
+    "chalk": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
+      "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      }
+    },
+    "chokidar": {
+      "version": "3.5.1",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz",
+      "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==",
+      "dev": true,
+      "requires": {
+        "anymatch": "~3.1.1",
+        "braces": "~3.0.2",
+        "fsevents": "~2.3.1",
+        "glob-parent": "~5.1.0",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.5.0"
+      }
+    },
+    "color": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/color/-/color-3.1.3.tgz",
+      "integrity": "sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ==",
+      "dev": true,
+      "requires": {
+        "color-convert": "^1.9.1",
+        "color-string": "^1.5.4"
+      },
+      "dependencies": {
+        "color-convert": {
+          "version": "1.9.3",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+          "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+          "dev": true,
+          "requires": {
+            "color-name": "1.1.3"
+          }
+        },
+        "color-name": {
+          "version": "1.1.3",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+          "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
+          "dev": true
+        }
+      }
+    },
+    "color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "requires": {
+        "color-name": "~1.1.4"
+      }
+    },
+    "color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true
+    },
+    "color-string": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.5.tgz",
+      "integrity": "sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg==",
+      "dev": true,
+      "requires": {
+        "color-name": "^1.0.0",
+        "simple-swizzle": "^0.2.2"
+      }
+    },
+    "colorette": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz",
+      "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==",
+      "dev": true
+    },
+    "commander": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
+      "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
+      "dev": true
+    },
+    "concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+      "dev": true
+    },
+    "css-unit-converter": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz",
+      "integrity": "sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA==",
+      "dev": true
+    },
+    "cssesc": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+      "dev": true
+    },
+    "defined": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz",
+      "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=",
+      "dev": true
+    },
+    "detective": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz",
+      "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==",
+      "dev": true,
+      "requires": {
+        "acorn-node": "^1.6.1",
+        "defined": "^1.0.0",
+        "minimist": "^1.1.1"
+      }
+    },
+    "didyoumean": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.1.tgz",
+      "integrity": "sha1-6S7f2tplN9SE1zwBcv0eugxJdv8=",
+      "dev": true
+    },
+    "dlv": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+      "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+      "dev": true
+    },
+    "electron-to-chromium": {
+      "version": "1.3.728",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.728.tgz",
+      "integrity": "sha512-SHv4ziXruBpb1Nz4aTuqEHBYi/9GNCJMYIJgDEXrp/2V01nFXMNFUTli5Z85f5ivSkioLilQatqBYFB44wNJrA==",
+      "dev": true
+    },
+    "escalade": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+      "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+      "dev": true
+    },
+    "escape-string-regexp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+      "dev": true
+    },
+    "fast-glob": {
+      "version": "3.2.5",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz",
+      "integrity": "sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.0",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.2",
+        "picomatch": "^2.2.1"
+      }
+    },
+    "fastq": {
+      "version": "1.11.0",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.11.0.tgz",
+      "integrity": "sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g==",
+      "dev": true,
+      "requires": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "fill-range": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+      "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+      "dev": true,
+      "requires": {
+        "to-regex-range": "^5.0.1"
+      }
+    },
+    "fraction.js": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.1.0.tgz",
+      "integrity": "sha512-o9lSKpK0TDqDwTL24Hxqi6I99s942l6TYkfl6WvGWgLOIFz/YonSGKfiSeMadoiNvTfqnfOa9mjb5SGVbBK9/w==",
+      "dev": true
+    },
+    "fs-extra": {
+      "version": "9.1.0",
+      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
+      "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+      "dev": true,
+      "requires": {
+        "at-least-node": "^1.0.0",
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^6.0.1",
+        "universalify": "^2.0.0"
+      }
+    },
+    "fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+      "dev": true
+    },
+    "fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "optional": true
+    },
+    "function-bind": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+      "dev": true
+    },
+    "glob": {
+      "version": "7.1.7",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
+      "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+      "dev": true,
+      "requires": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      }
+    },
+    "glob-base": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz",
+      "integrity": "sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=",
+      "dev": true,
+      "requires": {
+        "glob-parent": "^2.0.0",
+        "is-glob": "^2.0.0"
+      },
+      "dependencies": {
+        "glob-parent": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz",
+          "integrity": "sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=",
+          "dev": true,
+          "requires": {
+            "is-glob": "^2.0.0"
+          }
+        },
+        "is-extglob": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz",
+          "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=",
+          "dev": true
+        },
+        "is-glob": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz",
+          "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=",
+          "dev": true,
+          "requires": {
+            "is-extglob": "^1.0.0"
+          }
+        }
+      }
+    },
+    "glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "requires": {
+        "is-glob": "^4.0.1"
+      }
+    },
+    "graceful-fs": {
+      "version": "4.2.6",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz",
+      "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==",
+      "dev": true
+    },
+    "has": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+      "dev": true,
+      "requires": {
+        "function-bind": "^1.1.1"
+      }
+    },
+    "has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true
+    },
+    "html-tags": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.1.0.tgz",
+      "integrity": "sha512-1qYz89hW3lFDEazhjW0yVAV87lw8lVkrJocr72XmBkMKsoSVJCQx3W8BXsC7hO2qAt8BoVjYjtAcZ9perqGnNg==",
+      "dev": true
+    },
+    "inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+      "dev": true,
+      "requires": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "dev": true
+    },
+    "is-arrayish": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
+      "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
+      "dev": true
+    },
+    "is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "requires": {
+        "binary-extensions": "^2.0.0"
+      }
+    },
+    "is-core-module": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz",
+      "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==",
+      "dev": true,
+      "requires": {
+        "has": "^1.0.3"
+      }
+    },
+    "is-dotfile": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.3.tgz",
+      "integrity": "sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=",
+      "dev": true
+    },
+    "is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+      "dev": true
+    },
+    "is-glob": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
+      "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
+      "dev": true,
+      "requires": {
+        "is-extglob": "^2.1.1"
+      }
+    },
+    "is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true
+    },
+    "jsonfile": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
+      "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+      "dev": true,
+      "requires": {
+        "graceful-fs": "^4.1.6",
+        "universalify": "^2.0.0"
+      }
+    },
+    "lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "dev": true
+    },
+    "lodash.castarray": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
+      "integrity": "sha1-wCUTUV4wna3dTCTGDP3c9ZdtkRU=",
+      "dev": true
+    },
+    "lodash.isplainobject": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+      "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=",
+      "dev": true
+    },
+    "lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true
+    },
+    "lodash.toarray": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
+      "integrity": "sha1-JMS/zWsvuji/0FlNsRedjptlZWE=",
+      "dev": true
+    },
+    "lodash.topath": {
+      "version": "4.5.2",
+      "resolved": "https://registry.npmjs.org/lodash.topath/-/lodash.topath-4.5.2.tgz",
+      "integrity": "sha1-NhY1Hzu6YZlKCTGYlmC9AyVP0Ak=",
+      "dev": true
+    },
+    "lodash.uniq": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
+      "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=",
+      "dev": true
+    },
+    "merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true
+    },
+    "micromatch": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz",
+      "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==",
+      "dev": true,
+      "requires": {
+        "braces": "^3.0.1",
+        "picomatch": "^2.2.3"
+      }
+    },
+    "minimatch": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+      "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+      "dev": true,
+      "requires": {
+        "brace-expansion": "^1.1.7"
+      }
+    },
+    "minimist": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
+      "dev": true
+    },
+    "modern-normalize": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/modern-normalize/-/modern-normalize-1.1.0.tgz",
+      "integrity": "sha512-2lMlY1Yc1+CUy0gw4H95uNN7vjbpoED7NNRSBHE25nWfLBdmMzFCsPshlzbxHz+gYMcBEUN8V4pU16prcdPSgA==",
+      "dev": true
+    },
+    "nanoid": {
+      "version": "3.1.23",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
+      "integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==",
+      "dev": true
+    },
+    "node-emoji": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.10.0.tgz",
+      "integrity": "sha512-Yt3384If5H6BYGVHiHwTL+99OzJKHhgp82S8/dktEK73T26BazdgZ4JZh92xSVtGNJvz9UbXdNAc5hcrXV42vw==",
+      "dev": true,
+      "requires": {
+        "lodash.toarray": "^4.4.0"
+      }
+    },
+    "node-releases": {
+      "version": "1.1.72",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.72.tgz",
+      "integrity": "sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw==",
+      "dev": true
+    },
+    "normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true
+    },
+    "normalize-range": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+      "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=",
+      "dev": true
+    },
+    "object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
+      "dev": true
+    },
+    "object-hash": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.1.1.tgz",
+      "integrity": "sha512-VOJmgmS+7wvXf8CjbQmimtCnEx3IAoLxI3fp2fbWehxrWBcAQFbk+vcwb6vzR0VZv/eNCJ/27j151ZTwqW/JeQ==",
+      "dev": true
+    },
+    "once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+      "dev": true,
+      "requires": {
+        "wrappy": "1"
+      }
+    },
+    "parse-glob": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz",
+      "integrity": "sha1-ssN2z7EfNVE7rdFz7wu246OIORw=",
+      "dev": true,
+      "requires": {
+        "glob-base": "^0.3.0",
+        "is-dotfile": "^1.0.0",
+        "is-extglob": "^1.0.0",
+        "is-glob": "^2.0.0"
+      },
+      "dependencies": {
+        "is-extglob": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz",
+          "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=",
+          "dev": true
+        },
+        "is-glob": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz",
+          "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=",
+          "dev": true,
+          "requires": {
+            "is-extglob": "^1.0.0"
+          }
+        }
+      }
+    },
+    "path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+      "dev": true
+    },
+    "path-parse": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
+      "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
+      "dev": true
+    },
+    "picomatch": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.3.tgz",
+      "integrity": "sha512-KpELjfwcCDUb9PeigTs2mBJzXUPzAuP2oPcA989He8Rte0+YUAjw1JVedDhuTKPkHjSYzMN3npC9luThGYEKdg==",
+      "dev": true
+    },
+    "postcss": {
+      "version": "8.2.15",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.2.15.tgz",
+      "integrity": "sha512-2zO3b26eJD/8rb106Qu2o7Qgg52ND5HPjcyQiK2B98O388h43A448LCslC0dI2P97wCAQRJsFvwTRcXxTKds+Q==",
+      "dev": true,
+      "requires": {
+        "colorette": "^1.2.2",
+        "nanoid": "^3.1.23",
+        "source-map": "^0.6.1"
+      }
+    },
+    "postcss-functions": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-functions/-/postcss-functions-3.0.0.tgz",
+      "integrity": "sha1-DpTQFERwCkgd4g3k1V+yZAVkJQ4=",
+      "dev": true,
+      "requires": {
+        "glob": "^7.1.2",
+        "object-assign": "^4.1.1",
+        "postcss": "^6.0.9",
+        "postcss-value-parser": "^3.3.0"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "3.2.1",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+          "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^1.9.0"
+          }
+        },
+        "chalk": {
+          "version": "2.4.2",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+          "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^3.2.1",
+            "escape-string-regexp": "^1.0.5",
+            "supports-color": "^5.3.0"
+          }
+        },
+        "color-convert": {
+          "version": "1.9.3",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+          "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+          "dev": true,
+          "requires": {
+            "color-name": "1.1.3"
+          }
+        },
+        "color-name": {
+          "version": "1.1.3",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+          "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
+          "dev": true
+        },
+        "has-flag": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+          "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+          "dev": true
+        },
+        "postcss": {
+          "version": "6.0.23",
+          "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz",
+          "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==",
+          "dev": true,
+          "requires": {
+            "chalk": "^2.4.1",
+            "source-map": "^0.6.1",
+            "supports-color": "^5.4.0"
+          }
+        },
+        "postcss-value-parser": {
+          "version": "3.3.1",
+          "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz",
+          "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==",
+          "dev": true
+        },
+        "supports-color": {
+          "version": "5.5.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+          "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^3.0.0"
+          }
+        }
+      }
+    },
+    "postcss-js": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-3.0.3.tgz",
+      "integrity": "sha512-gWnoWQXKFw65Hk/mi2+WTQTHdPD5UJdDXZmX073EY/B3BWnYjO4F4t0VneTCnCGQ5E5GsCdMkzPaTXwl3r5dJw==",
+      "dev": true,
+      "requires": {
+        "camelcase-css": "^2.0.1",
+        "postcss": "^8.1.6"
+      }
+    },
+    "postcss-nested": {
+      "version": "5.0.5",
+      "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.5.tgz",
+      "integrity": "sha512-GSRXYz5bccobpTzLQZXOnSOfKl6TwVr5CyAQJUPub4nuRJSOECK5AqurxVgmtxP48p0Kc/ndY/YyS1yqldX0Ew==",
+      "dev": true,
+      "requires": {
+        "postcss-selector-parser": "^6.0.4"
+      }
+    },
+    "postcss-selector-parser": {
+      "version": "6.0.6",
+      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.6.tgz",
+      "integrity": "sha512-9LXrvaaX3+mcv5xkg5kFwqSzSH1JIObIx51PrndZwlmznwXRfxMddDvo9gve3gVR8ZTKgoFDdWkbRFmEhT4PMg==",
+      "dev": true,
+      "requires": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      }
+    },
+    "postcss-value-parser": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz",
+      "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==",
+      "dev": true
+    },
+    "pretty-hrtime": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz",
+      "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=",
+      "dev": true
+    },
+    "purgecss": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/purgecss/-/purgecss-3.1.3.tgz",
+      "integrity": "sha512-hRSLN9mguJ2lzlIQtW4qmPS2kh6oMnA9RxdIYK8sz18QYqd6ePp4GNDl18oWHA1f2v2NEQIh51CO8s/E3YGckQ==",
+      "dev": true,
+      "requires": {
+        "commander": "^6.0.0",
+        "glob": "^7.0.0",
+        "postcss": "^8.2.1",
+        "postcss-selector-parser": "^6.0.2"
+      }
+    },
+    "queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true
+    },
+    "quick-lru": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
+      "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
+      "dev": true
+    },
+    "readdirp": {
+      "version": "3.5.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz",
+      "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==",
+      "dev": true,
+      "requires": {
+        "picomatch": "^2.2.1"
+      }
+    },
+    "reduce-css-calc": {
+      "version": "2.1.8",
+      "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz",
+      "integrity": "sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg==",
+      "dev": true,
+      "requires": {
+        "css-unit-converter": "^1.1.1",
+        "postcss-value-parser": "^3.3.0"
+      },
+      "dependencies": {
+        "postcss-value-parser": {
+          "version": "3.3.1",
+          "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz",
+          "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==",
+          "dev": true
+        }
+      }
+    },
+    "resolve": {
+      "version": "1.20.0",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
+      "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
+      "dev": true,
+      "requires": {
+        "is-core-module": "^2.2.0",
+        "path-parse": "^1.0.6"
+      }
+    },
+    "reusify": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+      "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+      "dev": true
+    },
+    "run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "requires": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "simple-swizzle": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
+      "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=",
+      "dev": true,
+      "requires": {
+        "is-arrayish": "^0.3.1"
+      }
+    },
+    "source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "dev": true
+    },
+    "supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "requires": {
+        "has-flag": "^4.0.0"
+      }
+    },
+    "tailwindcss": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-2.1.2.tgz",
+      "integrity": "sha512-T5t+wwd+/hsOyRw2HJuFuv0LTUm3MUdHm2DJ94GPVgzqwPPFa9XxX0KlwLWupUuiOUj6uiKURCzYPHFcuPch/w==",
+      "dev": true,
+      "requires": {
+        "@fullhuman/postcss-purgecss": "^3.1.3",
+        "bytes": "^3.0.0",
+        "chalk": "^4.1.0",
+        "chokidar": "^3.5.1",
+        "color": "^3.1.3",
+        "detective": "^5.2.0",
+        "didyoumean": "^1.2.1",
+        "dlv": "^1.1.3",
+        "fast-glob": "^3.2.5",
+        "fs-extra": "^9.1.0",
+        "html-tags": "^3.1.0",
+        "lodash": "^4.17.21",
+        "lodash.topath": "^4.5.2",
+        "modern-normalize": "^1.0.0",
+        "node-emoji": "^1.8.1",
+        "normalize-path": "^3.0.0",
+        "object-hash": "^2.1.1",
+        "parse-glob": "^3.0.4",
+        "postcss-functions": "^3",
+        "postcss-js": "^3.0.3",
+        "postcss-nested": "5.0.5",
+        "postcss-selector-parser": "^6.0.4",
+        "postcss-value-parser": "^4.1.0",
+        "pretty-hrtime": "^1.0.3",
+        "quick-lru": "^5.1.1",
+        "reduce-css-calc": "^2.1.8",
+        "resolve": "^1.20.0"
+      }
+    },
+    "tailwindcss-cli": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/tailwindcss-cli/-/tailwindcss-cli-0.1.2.tgz",
+      "integrity": "sha512-17NuGSHKTr4twN1BFxuoTArMcBQH+7YL6x4PHFnmWsGNOX45O4Roc8EdMVhSSH2rQoSDoLvR4TmlfddMon3yKg==",
+      "dev": true,
+      "requires": {
+        "autoprefixer": "^10.0.2",
+        "postcss": "^8.1.8",
+        "tailwindcss": "^2.0.1"
+      }
+    },
+    "to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "requires": {
+        "is-number": "^7.0.0"
+      }
+    },
+    "universalify": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
+      "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
+      "dev": true
+    },
+    "util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
+      "dev": true
+    },
+    "wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+      "dev": true
+    },
+    "xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "dev": true
+    }
+  }
+}

script/package.json 🔗

@@ -0,0 +1,6 @@
+{
+  "devDependencies": {
+    "@tailwindcss/typography": "^0.4.0",
+    "tailwindcss-cli": "^0.1.2"
+  }
+}

script/server 🔗

@@ -0,0 +1,6 @@
+#!/bin/bash
+
+set -e
+
+cd server
+cargo run $@

script/sqlx 🔗

@@ -0,0 +1,12 @@
+#!/bin/bash
+
+set -e
+
+# Install sqlx-cli if needed
+[[ "$(sqlx --version)" == "sqlx-cli 0.5.5" ]] || cargo install sqlx-cli --version 0.5.5
+
+# Export contents of .env.toml
+eval "$(cargo run --bin dotenv)"
+
+# Run sqlx command
+sqlx $@

script/tailwind.config.js 🔗

@@ -0,0 +1,44 @@
+module.exports = {
+    theme: {
+        fontFamily: {
+            display: [
+                "Visby CF", "ui-sans-serif", "system-ui", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto",
+                "Helvetica Neue", "Arial", "Noto Sans", "sans-serif", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
+                "Noto Color Emoji"
+            ],
+            body: [
+                "Open Sans", "ui-sans-serif", "system-ui", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto",
+                "Helvetica Neue", "Arial", "Noto Sans", "sans-serif", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
+                "Noto Color Emoji"
+            ],
+        },
+        extend: {
+            typography: (theme) => ({
+                DEFAULT: {
+                    css: {
+                        h1: {
+                            fontFamily: theme("fontFamily.display").join(", ")
+                        },
+                        h2: {
+                            fontFamily: theme("fontFamily.display").join(", ")
+                        },
+                        h3: {
+                            fontFamily: theme("fontFamily.display").join(", ")
+                        },
+                        h4: {
+                            fontFamily: theme("fontFamily.display").join(", ")
+                        }
+                    }
+                }
+            })
+        }
+    },
+    variants: {
+    },
+    plugins: [
+        require('@tailwindcss/typography'),
+    ],
+    purge: [
+        "../server/templates/**/*.hbs"
+    ]
+}

server/.env.template.toml 🔗

@@ -0,0 +1,11 @@
+DATABASE_URL = "postgres://postgres@localhost/zed"
+SESSION_SECRET = "6E1GS6IQNOLIBKWMEVWF1AFO4H78KNU8"
+
+HTTP_PORT = 8080
+
+# Available at https://github.com/organizations/zed-industries/settings/apps/zed-local-development
+GITHUB_APP_ID = 115633
+GITHUB_CLIENT_ID = "Iv1.768076c9becc75c4"
+GITHUB_CLIENT_SECRET = ""
+GITHUB_PRIVATE_KEY = """\
+"""

server/Cargo.toml 🔗

@@ -0,0 +1,50 @@
+[package]
+authors = ["Nathan Sobo <nathan@warp.dev>"]
+default-run = "zed-server"
+edition = "2018"
+name = "zed-server"
+version = "0.1.0"
+
+[dependencies]
+anyhow = "1.0.40"
+async-std = { version = "1.8.0", features = ["attributes"] }
+async-trait = "0.1.50"
+async-tungstenite = "0.14"
+base64 = "0.13"
+clap = "=3.0.0-beta.2"
+comrak = "0.10"
+either = "1.6"
+envy = "0.4.2"
+futures = "0.3"
+handlebars = "3.5"
+http-auth-basic = "0.1.3"
+jwt-simple = "0.10.0"
+oauth2 = { version = "4.0.0", default_features = false }
+oauth2-surf = "0.1.1"
+parking_lot = "0.11.1"
+postage = { version = "0.4.1", features = ["futures-traits"] }
+rand = "0.8"
+rust-embed = "5.9.0"
+scrypt = "0.7"
+serde = { version = "1.0", features = ["derive"] }
+sha-1 = "0.9"
+surf = "2.2.0"
+tide = "0.16.0"
+tide-compress = "0.9.0"
+toml = "0.5.8"
+zrpc = { path = "../zrpc" }
+
+[dependencies.async-sqlx-session]
+version = "0.3.0"
+features = ["pg", "rustls"]
+default-features = false
+
+[dependencies.sqlx]
+version = "0.5.2"
+features = ["runtime-async-std-rustls", "postgres"]
+
+[dev-dependencies]
+gpui = { path = "../gpui" }
+zed = { path = "../zed", features = ["test-support"] }
+lazy_static = "1.4"
+serde_json = { version = "1.0.64", features = ["preserve_order"] }

server/Procfile 🔗

@@ -0,0 +1,2 @@
+web: ./target/release/zed-server
+release: ./target/release/sqlx migrate run

server/README.md 🔗

@@ -0,0 +1,17 @@
+# Zed Server
+
+This crate is what we run at https://zed.dev.
+
+It contains our web presence as well as the backend logic for collaboration, to which we connect from the Zed client via a websocket.
+
+## Templates
+
+We use handlebars templates that are interpreted at runtime. When running in debug mode, you can change templates and see the latest content without restarting the server. This is enabled by the `rust-embed` crate, which we use to access the contents of the `/templates` folder at runtime. In debug mode it reads contents from the file system, but in release the templates will be embedded in the server binary.
+
+## Static assets
+
+We also use `rust-embed` to access the contents of the `/static` folder via the `/static/*` route. The app will pick up changes to the contents of this folder when running in debug mode.
+
+## CSS
+
+This site uses Tailwind CSS, which means our stylesheets don't need to change very frequently. We check `static/styles.css` into the repository, but it's actually compiled from `/styles.css` via `script/build-css`. This script runs the Tailwind compilation flow to regenerate `static/styles.css` via PostCSS.

server/basic.conf 🔗

@@ -0,0 +1,12 @@
+
+[Interface]
+PrivateKey = B5Fp/yVfP0QYlb+YJv9ea+EMI1mWODPD3akh91cVjvc=
+Address = fdaa:0:2ce3:a7b:bea:0:a:2/120
+DNS = fdaa:0:2ce3::3
+
+[Peer]
+PublicKey = RKAYPljEJiuaELNDdQIEJmQienT9+LRISfIHwH45HAw=
+AllowedIPs = fdaa:0:2ce3::/48
+Endpoint = ord1.gateway.6pn.dev:51820
+PersistentKeepalive = 15
+

server/manifest.yml 🔗

@@ -0,0 +1,71 @@
+---
+kind: Service
+apiVersion: v1
+metadata:
+  name: zed
+  annotations:
+    service.beta.kubernetes.io/do-loadbalancer-tls-ports: "443"
+    service.beta.kubernetes.io/do-loadbalancer-certificate-id: "606e2db9-2b58-4ae7-b12c-a0c7d56af49b"
+spec:
+  type: LoadBalancer
+  selector:
+    app: zed
+  ports:
+    - name: web
+      protocol: TCP
+      port: 443
+      targetPort: 8080
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: zed
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: zed
+  template:
+    metadata:
+      labels:
+        app: zed
+    spec:
+      containers:
+        - name: zed
+          image: registry.digitalocean.com/zed/zed-server
+          ports:
+            - containerPort: 8080
+              protocol: TCP
+          env:
+            - name: HTTP_PORT
+              value: "8080"
+            - name: DATABASE_URL
+              valueFrom:
+                secretKeyRef:
+                  name: database
+                  key: url
+            - name: SESSION_SECRET
+              valueFrom:
+                secretKeyRef:
+                  name: session
+                  key: secret
+            - name: GITHUB_APP_ID
+              valueFrom:
+                secretKeyRef:
+                  name: github
+                  key: appId
+            - name: GITHUB_CLIENT_ID
+              valueFrom:
+                secretKeyRef:
+                  name: github
+                  key: clientId
+            - name: GITHUB_CLIENT_SECRET
+              valueFrom:
+                secretKeyRef:
+                  name: github
+                  key: clientSecret
+            - name: GITHUB_PRIVATE_KEY
+              valueFrom:
+                secretKeyRef:
+                  name: github
+                  key: privateKey

server/migrate.yml 🔗

@@ -0,0 +1,17 @@
+apiVersion: batch/v1
+kind: Job
+metadata:
+  name: migrate
+spec:
+  template:
+    spec:
+      restartPolicy: Never
+      containers:
+      - name: migrator
+        image: registry.digitalocean.com/zed/zed-migrator
+        env:
+          - name: DATABASE_URL
+            valueFrom:
+              secretKeyRef:
+                name: database
+                key: url

server/migrations/20210527024318_initial_schema.sql 🔗

@@ -0,0 +1,26 @@
+CREATE TABLE IF NOT EXISTS "sessions" (
+    "id" VARCHAR NOT NULL PRIMARY KEY,
+    "expires" TIMESTAMP WITH TIME ZONE NULL,
+    "session" TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS "users" (
+    "id" SERIAL PRIMARY KEY,
+    "github_login" VARCHAR,
+    "admin" BOOLEAN
+);
+
+CREATE UNIQUE INDEX "index_users_github_login" ON "users" ("github_login");
+
+CREATE TABLE IF NOT EXISTS "signups" (
+    "id" SERIAL PRIMARY KEY,
+    "github_login" VARCHAR,
+    "email_address" VARCHAR,
+    "about" TEXT
+);
+
+INSERT INTO users (github_login, admin)
+VALUES
+    ('nathansobo', true),
+    ('maxbrunsfeld', true),
+    ('as-cii', true);

server/src/admin.rs 🔗

@@ -0,0 +1,160 @@
+use crate::{auth::RequestExt as _, AppState, DbPool, LayoutData, Request, RequestExt as _};
+use async_trait::async_trait;
+use serde::{Deserialize, Serialize};
+use sqlx::{Executor, FromRow};
+use std::sync::Arc;
+use surf::http::mime;
+
+#[async_trait]
+pub trait RequestExt {
+    async fn require_admin(&self) -> tide::Result<()>;
+}
+
+#[async_trait]
+impl RequestExt for Request {
+    async fn require_admin(&self) -> tide::Result<()> {
+        let current_user = self
+            .current_user()
+            .await?
+            .ok_or_else(|| tide::Error::from_str(401, "not logged in"))?;
+
+        if current_user.is_admin {
+            Ok(())
+        } else {
+            Err(tide::Error::from_str(
+                403,
+                "authenticated user is not an admin",
+            ))
+        }
+    }
+}
+
+pub fn add_routes(app: &mut tide::Server<Arc<AppState>>) {
+    app.at("/admin").get(get_admin_page);
+    app.at("/users").post(post_user);
+    app.at("/users/:id").put(put_user);
+    app.at("/users/:id/delete").post(delete_user);
+    app.at("/signups/:id/delete").post(delete_signup);
+}
+
+#[derive(Serialize)]
+struct AdminData {
+    #[serde(flatten)]
+    layout: Arc<LayoutData>,
+    users: Vec<User>,
+    signups: Vec<Signup>,
+}
+
+#[derive(Debug, FromRow, Serialize)]
+pub struct User {
+    pub id: i32,
+    pub github_login: String,
+    pub admin: bool,
+}
+
+#[derive(Debug, FromRow, Serialize)]
+pub struct Signup {
+    pub id: i32,
+    pub github_login: String,
+    pub email_address: String,
+    pub about: String,
+}
+
+async fn get_admin_page(mut request: Request) -> tide::Result {
+    request.require_admin().await?;
+
+    let data = AdminData {
+        layout: request.layout_data().await?,
+        users: sqlx::query_as("SELECT * FROM users ORDER BY github_login ASC")
+            .fetch_all(request.db())
+            .await?,
+        signups: sqlx::query_as("SELECT * FROM signups ORDER BY id DESC")
+            .fetch_all(request.db())
+            .await?,
+    };
+
+    Ok(tide::Response::builder(200)
+        .body(request.state().render_template("admin.hbs", &data)?)
+        .content_type(mime::HTML)
+        .build())
+}
+
+async fn post_user(mut request: Request) -> tide::Result {
+    request.require_admin().await?;
+
+    #[derive(Deserialize)]
+    struct Form {
+        github_login: String,
+        #[serde(default)]
+        admin: bool,
+    }
+
+    let form = request.body_form::<Form>().await?;
+    let github_login = form
+        .github_login
+        .strip_prefix("@")
+        .unwrap_or(&form.github_login);
+
+    if !github_login.is_empty() {
+        create_user(request.db(), github_login, form.admin).await?;
+    }
+
+    Ok(tide::Redirect::new("/admin").into())
+}
+
+async fn put_user(mut request: Request) -> tide::Result {
+    request.require_admin().await?;
+
+    let user_id = request.param("id")?.parse::<i32>()?;
+
+    #[derive(Deserialize)]
+    struct Body {
+        admin: bool,
+    }
+
+    let body: Body = request.body_json().await?;
+
+    request
+        .db()
+        .execute(
+            sqlx::query("UPDATE users SET admin = $1 WHERE id = $2;")
+                .bind(body.admin)
+                .bind(user_id),
+        )
+        .await?;
+
+    Ok(tide::Response::builder(200).build())
+}
+
+async fn delete_user(request: Request) -> tide::Result {
+    request.require_admin().await?;
+
+    let user_id = request.param("id")?.parse::<i32>()?;
+    request
+        .db()
+        .execute(sqlx::query("DELETE FROM users WHERE id = $1;").bind(user_id))
+        .await?;
+
+    Ok(tide::Redirect::new("/admin").into())
+}
+
+pub async fn create_user(db: &DbPool, github_login: &str, admin: bool) -> tide::Result<i32> {
+    let id: i32 =
+        sqlx::query_scalar("INSERT INTO users (github_login, admin) VALUES ($1, $2) RETURNING id;")
+            .bind(github_login)
+            .bind(admin)
+            .fetch_one(db)
+            .await?;
+    Ok(id)
+}
+
+async fn delete_signup(request: Request) -> tide::Result {
+    request.require_admin().await?;
+    let signup_id = request.param("id")?.parse::<i32>()?;
+    request
+        .db()
+        .execute(sqlx::query("DELETE FROM signups WHERE id = $1;").bind(signup_id))
+        .await?;
+
+    Ok(tide::Redirect::new("/admin").into())
+}

server/src/assets.rs 🔗

@@ -0,0 +1,31 @@
+use crate::{AppState, Request};
+use anyhow::anyhow;
+use rust_embed::RustEmbed;
+use std::sync::Arc;
+use tide::{http::mime, Server};
+
+#[derive(RustEmbed)]
+#[folder = "static"]
+struct Static;
+
+pub fn add_routes(app: &mut Server<Arc<AppState>>) {
+    app.at("/static/*path").get(get_static_asset);
+}
+
+async fn get_static_asset(request: Request) -> tide::Result {
+    let path = request.param("path").unwrap();
+    let content = Static::get(path).ok_or_else(|| anyhow!("asset not found at {}", path))?;
+
+    let content_type = if path.starts_with("svg") {
+        mime::SVG
+    } else if path.starts_with("styles") {
+        mime::CSS
+    } else {
+        mime::BYTE_STREAM
+    };
+
+    Ok(tide::Response::builder(200)
+        .content_type(content_type)
+        .body(content.as_ref())
+        .build())
+}

server/src/auth.rs 🔗

@@ -0,0 +1,336 @@
+use super::errors::TideResultExt;
+use crate::{github, rpc, AppState, DbPool, Request, RequestExt as _};
+use anyhow::{anyhow, Context};
+use async_std::stream::StreamExt;
+use async_trait::async_trait;
+pub use oauth2::basic::BasicClient as Client;
+use oauth2::{
+    AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl,
+    TokenResponse as _, TokenUrl,
+};
+use rand::thread_rng;
+use scrypt::{
+    password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
+    Scrypt,
+};
+use serde::{Deserialize, Serialize};
+use sqlx::FromRow;
+use std::{borrow::Cow, convert::TryFrom, sync::Arc};
+use surf::Url;
+use tide::Server;
+use zrpc::{auth as zed_auth, proto, Peer};
+
+static CURRENT_GITHUB_USER: &'static str = "current_github_user";
+static GITHUB_AUTH_URL: &'static str = "https://github.com/login/oauth/authorize";
+static GITHUB_TOKEN_URL: &'static str = "https://github.com/login/oauth/access_token";
+
+#[derive(Serialize)]
+pub struct User {
+    pub github_login: String,
+    pub avatar_url: String,
+    pub is_insider: bool,
+    pub is_admin: bool,
+}
+
+pub struct VerifyToken;
+
+#[derive(Clone, Copy)]
+pub struct UserId(pub i32);
+
+#[async_trait]
+impl tide::Middleware<Arc<AppState>> for VerifyToken {
+    async fn handle(
+        &self,
+        mut request: Request,
+        next: tide::Next<'_, Arc<AppState>>,
+    ) -> tide::Result {
+        let mut auth_header = request
+            .header("Authorization")
+            .ok_or_else(|| anyhow!("no authorization header"))?
+            .last()
+            .as_str()
+            .split_whitespace();
+
+        let user_id: i32 = auth_header
+            .next()
+            .ok_or_else(|| anyhow!("missing user id in authorization header"))?
+            .parse()?;
+        let access_token = auth_header
+            .next()
+            .ok_or_else(|| anyhow!("missing access token in authorization header"))?;
+
+        let state = request.state().clone();
+
+        let mut password_hashes =
+            sqlx::query_scalar::<_, String>("SELECT hash FROM access_tokens WHERE user_id = $1")
+                .bind(&user_id)
+                .fetch_many(&state.db);
+
+        let mut credentials_valid = false;
+        while let Some(password_hash) = password_hashes.next().await {
+            if let either::Either::Right(password_hash) = password_hash? {
+                if verify_access_token(&access_token, &password_hash)? {
+                    credentials_valid = true;
+                    break;
+                }
+            }
+        }
+
+        if credentials_valid {
+            request.set_ext(UserId(user_id));
+            Ok(next.run(request).await)
+        } else {
+            Err(anyhow!("invalid credentials").into())
+        }
+    }
+}
+
+#[async_trait]
+pub trait RequestExt {
+    async fn current_user(&self) -> tide::Result<Option<User>>;
+}
+
+#[async_trait]
+impl RequestExt for Request {
+    async fn current_user(&self) -> tide::Result<Option<User>> {
+        if let Some(details) = self.session().get::<github::User>(CURRENT_GITHUB_USER) {
+            #[derive(FromRow)]
+            struct UserRow {
+                admin: bool,
+            }
+
+            let user_row: Option<UserRow> =
+                sqlx::query_as("SELECT admin FROM users WHERE github_login = $1")
+                    .bind(&details.login)
+                    .fetch_optional(self.db())
+                    .await?;
+
+            let is_insider = user_row.is_some();
+            let is_admin = user_row.map_or(false, |row| row.admin);
+
+            Ok(Some(User {
+                github_login: details.login,
+                avatar_url: details.avatar_url,
+                is_insider,
+                is_admin,
+            }))
+        } else {
+            Ok(None)
+        }
+    }
+}
+
+#[async_trait]
+pub trait PeerExt {
+    async fn sign_out(
+        self: &Arc<Self>,
+        connection_id: zrpc::ConnectionId,
+        state: &AppState,
+    ) -> tide::Result<()>;
+}
+
+#[async_trait]
+impl PeerExt for Peer {
+    async fn sign_out(
+        self: &Arc<Self>,
+        connection_id: zrpc::ConnectionId,
+        state: &AppState,
+    ) -> tide::Result<()> {
+        self.disconnect(connection_id).await;
+        let worktree_ids = state.rpc.write().await.remove_connection(connection_id);
+        for worktree_id in worktree_ids {
+            let state = state.rpc.read().await;
+            if let Some(worktree) = state.worktrees.get(&worktree_id) {
+                rpc::broadcast(connection_id, worktree.connection_ids(), |conn_id| {
+                    self.send(
+                        conn_id,
+                        proto::RemovePeer {
+                            worktree_id,
+                            peer_id: connection_id.0,
+                        },
+                    )
+                })
+                .await?;
+            }
+        }
+        Ok(())
+    }
+}
+
+pub fn build_client(client_id: &str, client_secret: &str) -> Client {
+    Client::new(
+        ClientId::new(client_id.to_string()),
+        Some(oauth2::ClientSecret::new(client_secret.to_string())),
+        AuthUrl::new(GITHUB_AUTH_URL.into()).unwrap(),
+        Some(TokenUrl::new(GITHUB_TOKEN_URL.into()).unwrap()),
+    )
+}
+
+pub fn add_routes(app: &mut Server<Arc<AppState>>) {
+    app.at("/sign_in").get(get_sign_in);
+    app.at("/sign_out").post(post_sign_out);
+    app.at("/auth_callback").get(get_auth_callback);
+}
+
+#[derive(Debug, Deserialize)]
+struct NativeAppSignInParams {
+    native_app_port: String,
+    native_app_public_key: String,
+}
+
+async fn get_sign_in(mut request: Request) -> tide::Result {
+    let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
+
+    request
+        .session_mut()
+        .insert("pkce_verifier", pkce_verifier)?;
+
+    let mut redirect_url = Url::parse(&format!(
+        "{}://{}/auth_callback",
+        request
+            .header("X-Forwarded-Proto")
+            .and_then(|values| values.get(0))
+            .map(|value| value.as_str())
+            .unwrap_or("http"),
+        request.host().unwrap()
+    ))?;
+
+    let app_sign_in_params: Option<NativeAppSignInParams> = request.query().ok();
+    if let Some(query) = app_sign_in_params {
+        redirect_url
+            .query_pairs_mut()
+            .clear()
+            .append_pair("native_app_port", &query.native_app_port)
+            .append_pair("native_app_public_key", &query.native_app_public_key);
+    }
+
+    let (auth_url, csrf_token) = request
+        .state()
+        .auth_client
+        .authorize_url(CsrfToken::new_random)
+        .set_redirect_uri(Cow::Owned(RedirectUrl::from_url(redirect_url)))
+        .set_pkce_challenge(pkce_challenge)
+        .url();
+
+    request
+        .session_mut()
+        .insert("auth_csrf_token", csrf_token)?;
+
+    Ok(tide::Redirect::new(auth_url).into())
+}
+
+async fn get_auth_callback(mut request: Request) -> tide::Result {
+    #[derive(Debug, Deserialize)]
+    struct Query {
+        code: String,
+        state: String,
+
+        #[serde(flatten)]
+        native_app_sign_in_params: Option<NativeAppSignInParams>,
+    }
+
+    let query: Query = request.query()?;
+
+    let pkce_verifier = request
+        .session()
+        .get("pkce_verifier")
+        .ok_or_else(|| anyhow!("could not retrieve pkce_verifier from session"))?;
+
+    let csrf_token = request
+        .session()
+        .get::<CsrfToken>("auth_csrf_token")
+        .ok_or_else(|| anyhow!("could not retrieve auth_csrf_token from session"))?;
+
+    if &query.state != csrf_token.secret() {
+        return Err(anyhow!("csrf token does not match").into());
+    }
+
+    let github_access_token = request
+        .state()
+        .auth_client
+        .exchange_code(AuthorizationCode::new(query.code))
+        .set_pkce_verifier(pkce_verifier)
+        .request_async(oauth2_surf::http_client)
+        .await
+        .context("failed to exchange oauth code")?
+        .access_token()
+        .secret()
+        .clone();
+
+    let user_details = request
+        .state()
+        .github_client
+        .user(github_access_token)
+        .details()
+        .await
+        .context("failed to fetch user")?;
+
+    let user_id: Option<i32> = sqlx::query_scalar("SELECT id from users where github_login = $1")
+        .bind(&user_details.login)
+        .fetch_optional(request.db())
+        .await?;
+
+    request
+        .session_mut()
+        .insert(CURRENT_GITHUB_USER, user_details.clone())?;
+
+    // When signing in from the native app, generate a new access token for the current user. Return
+    // a redirect so that the user's browser sends this access token to the locally-running app.
+    if let Some((user_id, app_sign_in_params)) = user_id.zip(query.native_app_sign_in_params) {
+        let access_token = create_access_token(request.db(), user_id).await?;
+        let native_app_public_key =
+            zed_auth::PublicKey::try_from(app_sign_in_params.native_app_public_key.clone())
+                .context("failed to parse app public key")?;
+        let encrypted_access_token = native_app_public_key
+            .encrypt_string(&access_token)
+            .context("failed to encrypt access token with public key")?;
+
+        return Ok(tide::Redirect::new(&format!(
+            "http://127.0.0.1:{}?user_id={}&access_token={}",
+            app_sign_in_params.native_app_port, user_id, encrypted_access_token,
+        ))
+        .into());
+    }
+
+    Ok(tide::Redirect::new("/").into())
+}
+
+async fn post_sign_out(mut request: Request) -> tide::Result {
+    request.session_mut().remove(CURRENT_GITHUB_USER);
+    Ok(tide::Redirect::new("/").into())
+}
+
+pub async fn create_access_token(db: &DbPool, user_id: i32) -> tide::Result<String> {
+    let access_token = zed_auth::random_token();
+    let access_token_hash =
+        hash_access_token(&access_token).context("failed to hash access token")?;
+    sqlx::query("INSERT INTO access_tokens (user_id, hash) values ($1, $2)")
+        .bind(user_id)
+        .bind(access_token_hash)
+        .fetch_optional(db)
+        .await?;
+    Ok(access_token)
+}
+
+fn hash_access_token(token: &str) -> tide::Result<String> {
+    // Avoid slow hashing in debug mode.
+    let params = if cfg!(debug_assertions) {
+        scrypt::Params::new(1, 1, 1).unwrap()
+    } else {
+        scrypt::Params::recommended()
+    };
+
+    Ok(Scrypt
+        .hash_password(
+            token.as_bytes(),
+            None,
+            params,
+            &SaltString::generate(thread_rng()),
+        )?
+        .to_string())
+}
+
+pub fn verify_access_token(token: &str, hash: &str) -> tide::Result<bool> {
+    let hash = PasswordHash::new(hash)?;
+    Ok(Scrypt.verify_password(token.as_bytes(), &hash).is_ok())
+}

server/src/bin/dotenv.rs 🔗

@@ -0,0 +1,20 @@
+use anyhow::anyhow;
+use std::fs;
+
+fn main() -> anyhow::Result<()> {
+    let env: toml::map::Map<String, toml::Value> = toml::de::from_str(
+        &fs::read_to_string("./.env.toml").map_err(|_| anyhow!("no .env.toml file found"))?,
+    )?;
+
+    for (key, value) in env {
+        let value = match value {
+            toml::Value::String(value) => value,
+            toml::Value::Integer(value) => value.to_string(),
+            toml::Value::Float(value) => value.to_string(),
+            _ => panic!("unsupported TOML value in .env.toml for key {}", key),
+        };
+        println!("export {}=\"{}\"", key, value);
+    }
+
+    Ok(())
+}

server/src/env.rs 🔗

@@ -0,0 +1,20 @@
+use anyhow::anyhow;
+use std::fs;
+
+pub fn load_dotenv() -> anyhow::Result<()> {
+    let env: toml::map::Map<String, toml::Value> = toml::de::from_str(
+        &fs::read_to_string("./.env.toml").map_err(|_| anyhow!("no .env.toml file found"))?,
+    )?;
+
+    for (key, value) in env {
+        let value = match value {
+            toml::Value::String(value) => value,
+            toml::Value::Integer(value) => value.to_string(),
+            toml::Value::Float(value) => value.to_string(),
+            _ => panic!("unsupported TOML value in .env.toml for key {}", key),
+        };
+        std::env::set_var(key, value);
+    }
+
+    Ok(())
+}

server/src/errors.rs 🔗

@@ -0,0 +1,73 @@
+use crate::{AppState, LayoutData, Request, RequestExt};
+use async_trait::async_trait;
+use serde::Serialize;
+use std::sync::Arc;
+use tide::http::mime;
+
+pub struct Middleware;
+
+#[async_trait]
+impl tide::Middleware<Arc<AppState>> for Middleware {
+    async fn handle(
+        &self,
+        mut request: Request,
+        next: tide::Next<'_, Arc<AppState>>,
+    ) -> tide::Result {
+        let app = request.state().clone();
+        let layout_data = request.layout_data().await?;
+
+        let mut response = next.run(request).await;
+
+        #[derive(Serialize)]
+        struct ErrorData {
+            #[serde(flatten)]
+            layout: Arc<LayoutData>,
+            status: u16,
+            reason: &'static str,
+        }
+
+        if !response.status().is_success() {
+            response.set_body(app.render_template(
+                "error.hbs",
+                &ErrorData {
+                    layout: layout_data,
+                    status: response.status().into(),
+                    reason: response.status().canonical_reason(),
+                },
+            )?);
+            response.set_content_type(mime::HTML);
+        }
+
+        Ok(response)
+    }
+}
+
+// Allow tide Results to accept context like other Results do when
+// using anyhow.
+pub trait TideResultExt {
+    fn context<C>(self, cx: C) -> Self
+    where
+        C: std::fmt::Display + Send + Sync + 'static;
+
+    fn with_context<C, F>(self, f: F) -> Self
+    where
+        C: std::fmt::Display + Send + Sync + 'static,
+        F: FnOnce() -> C;
+}
+
+impl<T> TideResultExt for tide::Result<T> {
+    fn context<C>(self, cx: C) -> Self
+    where
+        C: std::fmt::Display + Send + Sync + 'static,
+    {
+        self.map_err(|e| tide::Error::new(e.status(), e.into_inner().context(cx)))
+    }
+
+    fn with_context<C, F>(self, f: F) -> Self
+    where
+        C: std::fmt::Display + Send + Sync + 'static,
+        F: FnOnce() -> C,
+    {
+        self.map_err(|e| tide::Error::new(e.status(), e.into_inner().context(f())))
+    }
+}

server/src/expiring.rs 🔗

@@ -0,0 +1,43 @@
+use std::{future::Future, time::Instant};
+
+use async_std::sync::Mutex;
+
+#[derive(Default)]
+pub struct Expiring<T>(Mutex<Option<ExpiringState<T>>>);
+
+pub struct ExpiringState<T> {
+    value: T,
+    expires_at: Instant,
+}
+
+impl<T: Clone> Expiring<T> {
+    pub async fn get_or_refresh<F, G>(&self, f: F) -> tide::Result<T>
+    where
+        F: FnOnce() -> G,
+        G: Future<Output = tide::Result<(T, Instant)>>,
+    {
+        let mut state = self.0.lock().await;
+
+        if let Some(state) = state.as_mut() {
+            if Instant::now() >= state.expires_at {
+                let (value, expires_at) = f().await?;
+                state.value = value.clone();
+                state.expires_at = expires_at;
+                Ok(value)
+            } else {
+                Ok(state.value.clone())
+            }
+        } else {
+            let (value, expires_at) = f().await?;
+            *state = Some(ExpiringState {
+                value: value.clone(),
+                expires_at,
+            });
+            Ok(value)
+        }
+    }
+
+    pub async fn clear(&self) {
+        self.0.lock().await.take();
+    }
+}

server/src/github.rs 🔗

@@ -0,0 +1,265 @@
+use crate::expiring::Expiring;
+use anyhow::{anyhow, Context};
+use serde::{de::DeserializeOwned, Deserialize, Serialize};
+use std::{
+    future::Future,
+    sync::Arc,
+    time::{Duration, Instant},
+};
+use surf::{http::Method, RequestBuilder, Url};
+
+#[derive(Debug, Deserialize, Serialize)]
+pub struct Release {
+    pub tag_name: String,
+    pub name: String,
+    pub body: String,
+    pub draft: bool,
+    pub assets: Vec<Asset>,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+pub struct Asset {
+    pub name: String,
+    pub url: String,
+}
+
+pub struct AppClient {
+    id: usize,
+    private_key: String,
+    jwt_bearer_header: Expiring<String>,
+}
+
+#[derive(Deserialize)]
+struct Installation {
+    #[allow(unused)]
+    id: usize,
+}
+
+impl AppClient {
+    #[cfg(test)]
+    pub fn test() -> Arc<Self> {
+        Arc::new(Self {
+            id: Default::default(),
+            private_key: Default::default(),
+            jwt_bearer_header: Default::default(),
+        })
+    }
+
+    pub fn new(id: usize, private_key: String) -> Arc<Self> {
+        Arc::new(Self {
+            id,
+            private_key,
+            jwt_bearer_header: Default::default(),
+        })
+    }
+
+    pub async fn repo(self: &Arc<Self>, nwo: String) -> tide::Result<RepoClient> {
+        let installation: Installation = self
+            .request(
+                Method::Get,
+                &format!("/repos/{}/installation", &nwo),
+                |refresh| self.bearer_header(refresh),
+            )
+            .await?;
+
+        Ok(RepoClient {
+            app: self.clone(),
+            nwo,
+            installation_id: installation.id,
+            installation_token_header: Default::default(),
+        })
+    }
+
+    pub fn user(self: &Arc<Self>, access_token: String) -> UserClient {
+        UserClient {
+            app: self.clone(),
+            access_token,
+        }
+    }
+
+    async fn request<T, F, G>(
+        &self,
+        method: Method,
+        path: &str,
+        get_auth_header: F,
+    ) -> tide::Result<T>
+    where
+        T: DeserializeOwned,
+        F: Fn(bool) -> G,
+        G: Future<Output = tide::Result<String>>,
+    {
+        let mut retried = false;
+
+        loop {
+            let response = RequestBuilder::new(
+                method,
+                Url::parse(&format!("https://api.github.com{}", path))?,
+            )
+            .header("Accept", "application/vnd.github.v3+json")
+            .header("Authorization", get_auth_header(retried).await?)
+            .recv_json()
+            .await;
+
+            if let Err(error) = response.as_ref() {
+                if error.status() == 401 && !retried {
+                    retried = true;
+                    continue;
+                }
+            }
+
+            return response;
+        }
+    }
+
+    async fn bearer_header(&self, refresh: bool) -> tide::Result<String> {
+        if refresh {
+            self.jwt_bearer_header.clear().await;
+        }
+
+        self.jwt_bearer_header
+            .get_or_refresh(|| async {
+                use jwt_simple::{algorithms::RS256KeyPair, prelude::*};
+                use std::time;
+
+                let key_pair = RS256KeyPair::from_pem(&self.private_key)
+                    .with_context(|| format!("invalid private key {:?}", self.private_key))?;
+                let mut claims = Claims::create(Duration::from_mins(10));
+                claims.issued_at = Some(Clock::now_since_epoch() - Duration::from_mins(1));
+                claims.issuer = Some(self.id.to_string());
+                let token = key_pair.sign(claims).context("failed to sign claims")?;
+                let expires_at = time::Instant::now() + time::Duration::from_secs(9 * 60);
+
+                Ok((format!("Bearer {}", token), expires_at))
+            })
+            .await
+    }
+
+    async fn installation_token_header(
+        &self,
+        header: &Expiring<String>,
+        installation_id: usize,
+        refresh: bool,
+    ) -> tide::Result<String> {
+        if refresh {
+            header.clear().await;
+        }
+
+        header
+            .get_or_refresh(|| async {
+                #[derive(Debug, Deserialize)]
+                struct AccessToken {
+                    token: String,
+                }
+
+                let access_token: AccessToken = self
+                    .request(
+                        Method::Post,
+                        &format!("/app/installations/{}/access_tokens", installation_id),
+                        |refresh| self.bearer_header(refresh),
+                    )
+                    .await?;
+
+                let header = format!("Token {}", access_token.token);
+                let expires_at = Instant::now() + Duration::from_secs(60 * 30);
+
+                Ok((header, expires_at))
+            })
+            .await
+    }
+}
+
+pub struct RepoClient {
+    app: Arc<AppClient>,
+    nwo: String,
+    installation_id: usize,
+    installation_token_header: Expiring<String>,
+}
+
+impl RepoClient {
+    #[cfg(test)]
+    pub fn test(app_client: &Arc<AppClient>) -> Self {
+        Self {
+            app: app_client.clone(),
+            nwo: String::new(),
+            installation_id: 0,
+            installation_token_header: Default::default(),
+        }
+    }
+
+    pub async fn releases(&self) -> tide::Result<Vec<Release>> {
+        self.get(&format!("/repos/{}/releases?per_page=100", self.nwo))
+            .await
+    }
+
+    pub async fn release_asset(&self, tag: &str, name: &str) -> tide::Result<surf::Body> {
+        let release: Release = self
+            .get(&format!("/repos/{}/releases/tags/{}", self.nwo, tag))
+            .await?;
+
+        let asset = release
+            .assets
+            .iter()
+            .find(|asset| asset.name == name)
+            .ok_or_else(|| anyhow!("no asset found with name {}", name))?;
+
+        let request = surf::get(&asset.url)
+            .header("Accept", "application/octet-stream'")
+            .header(
+                "Authorization",
+                self.installation_token_header(false).await?,
+            );
+        let client = surf::client().with(surf::middleware::Redirect::new(5));
+        let mut response = client.send(request).await?;
+
+        Ok(response.take_body())
+    }
+
+    async fn get<T: DeserializeOwned>(&self, path: &str) -> tide::Result<T> {
+        self.request::<T>(Method::Get, path).await
+    }
+
+    async fn request<T: DeserializeOwned>(&self, method: Method, path: &str) -> tide::Result<T> {
+        Ok(self
+            .app
+            .request(method, path, |refresh| {
+                self.installation_token_header(refresh)
+            })
+            .await?)
+    }
+
+    async fn installation_token_header(&self, refresh: bool) -> tide::Result<String> {
+        self.app
+            .installation_token_header(
+                &self.installation_token_header,
+                self.installation_id,
+                refresh,
+            )
+            .await
+    }
+}
+
+pub struct UserClient {
+    app: Arc<AppClient>,
+    access_token: String,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct User {
+    pub login: String,
+    pub avatar_url: String,
+}
+
+impl UserClient {
+    pub async fn details(&self) -> tide::Result<User> {
+        Ok(self
+            .app
+            .request(Method::Get, "/user", |_| async {
+                Ok(self.access_token_header())
+            })
+            .await?)
+    }
+
+    fn access_token_header(&self) -> String {
+        format!("Token {}", self.access_token)
+    }
+}

server/src/home.rs 🔗

@@ -0,0 +1,112 @@
+use crate::{
+    auth::RequestExt as _, github::Release, AppState, LayoutData, Request, RequestExt as _,
+};
+use comrak::ComrakOptions;
+use serde::{Deserialize, Serialize};
+use sqlx::Executor as _;
+use std::sync::Arc;
+use tide::{http::mime, log, Server};
+
+pub fn add_routes(app: &mut Server<Arc<AppState>>) {
+    app.at("/").get(get_home);
+    app.at("/signups").post(post_signup);
+    app.at("/releases/:tag_name/:name").get(get_release_asset);
+}
+
+async fn get_home(mut request: Request) -> tide::Result {
+    #[derive(Serialize)]
+    struct HomeData {
+        #[serde(flatten)]
+        layout: Arc<LayoutData>,
+        releases: Option<Vec<Release>>,
+    }
+
+    let mut data = HomeData {
+        layout: request.layout_data().await?,
+        releases: None,
+    };
+
+    if let Some(user) = request.current_user().await? {
+        if user.is_insider {
+            data.releases = Some(
+                request
+                    .state()
+                    .repo_client
+                    .releases()
+                    .await?
+                    .into_iter()
+                    .filter_map(|mut release| {
+                        if release.draft {
+                            None
+                        } else {
+                            let mut options = ComrakOptions::default();
+                            options.render.unsafe_ = true; // Allow raw HTML in the markup. We control these release notes anyway.
+                            release.body = comrak::markdown_to_html(&release.body, &options);
+                            Some(release)
+                        }
+                    })
+                    .collect(),
+            );
+        }
+    }
+
+    Ok(tide::Response::builder(200)
+        .body(request.state().render_template("home.hbs", &data)?)
+        .content_type(mime::HTML)
+        .build())
+}
+
+async fn post_signup(mut request: Request) -> tide::Result {
+    #[derive(Debug, Deserialize)]
+    struct Form {
+        github_login: String,
+        email_address: String,
+        about: String,
+    }
+
+    let mut form: Form = request.body_form().await?;
+    form.github_login = form
+        .github_login
+        .strip_prefix("@")
+        .map(str::to_string)
+        .unwrap_or(form.github_login);
+
+    log::info!("Signup submitted: {:?}", form);
+
+    // Save signup in the database
+    request
+        .db()
+        .execute(
+            sqlx::query(
+                "INSERT INTO signups (github_login, email_address, about) VALUES ($1, $2, $3);",
+            )
+            .bind(&form.github_login)
+            .bind(&form.email_address)
+            .bind(&form.about),
+        )
+        .await?;
+
+    let layout_data = request.layout_data().await?;
+    Ok(tide::Response::builder(200)
+        .body(
+            request
+                .state()
+                .render_template("signup.hbs", &layout_data)?,
+        )
+        .content_type(mime::HTML)
+        .build())
+}
+
+async fn get_release_asset(request: Request) -> tide::Result {
+    let body = request
+        .state()
+        .repo_client
+        .release_asset(request.param("tag_name")?, request.param("name")?)
+        .await?;
+
+    Ok(tide::Response::builder(200)
+        .header("Cache-Control", "no-transform")
+        .content_type(mime::BYTE_STREAM)
+        .body(body)
+        .build())
+}

server/src/main.rs 🔗

@@ -0,0 +1,197 @@
+mod admin;
+mod assets;
+mod auth;
+mod env;
+mod errors;
+mod expiring;
+mod github;
+mod home;
+mod rpc;
+mod team;
+#[cfg(test)]
+mod tests;
+
+use self::errors::TideResultExt as _;
+use anyhow::{Context, Result};
+use async_sqlx_session::PostgresSessionStore;
+use async_std::{net::TcpListener, sync::RwLock as AsyncRwLock};
+use async_trait::async_trait;
+use auth::RequestExt as _;
+use handlebars::{Handlebars, TemplateRenderError};
+use parking_lot::RwLock;
+use rust_embed::RustEmbed;
+use serde::{Deserialize, Serialize};
+use sqlx::postgres::{PgPool, PgPoolOptions};
+use std::sync::Arc;
+use surf::http::cookies::SameSite;
+use tide::{log, sessions::SessionMiddleware};
+use tide_compress::CompressMiddleware;
+use zrpc::Peer;
+
+type Request = tide::Request<Arc<AppState>>;
+type DbPool = PgPool;
+
+#[derive(RustEmbed)]
+#[folder = "templates"]
+struct Templates;
+
+#[derive(Default, Deserialize)]
+pub struct Config {
+    pub http_port: u16,
+    pub database_url: String,
+    pub session_secret: String,
+    pub github_app_id: usize,
+    pub github_client_id: String,
+    pub github_client_secret: String,
+    pub github_private_key: String,
+}
+
+pub struct AppState {
+    db: sqlx::PgPool,
+    handlebars: RwLock<Handlebars<'static>>,
+    auth_client: auth::Client,
+    github_client: Arc<github::AppClient>,
+    repo_client: github::RepoClient,
+    rpc: AsyncRwLock<rpc::State>,
+    config: Config,
+}
+
+impl AppState {
+    async fn new(config: Config) -> tide::Result<Arc<Self>> {
+        let db = PgPoolOptions::new()
+            .max_connections(5)
+            .connect(&config.database_url)
+            .await
+            .context("failed to connect to postgres database")?;
+
+        let github_client =
+            github::AppClient::new(config.github_app_id, config.github_private_key.clone());
+        let repo_client = github_client
+            .repo("zed-industries/zed".into())
+            .await
+            .context("failed to initialize github client")?;
+
+        let this = Self {
+            db,
+            handlebars: Default::default(),
+            auth_client: auth::build_client(&config.github_client_id, &config.github_client_secret),
+            github_client,
+            repo_client,
+            rpc: Default::default(),
+            config,
+        };
+        this.register_partials();
+        Ok(Arc::new(this))
+    }
+
+    fn register_partials(&self) {
+        for path in Templates::iter() {
+            if let Some(partial_name) = path
+                .strip_prefix("partials/")
+                .and_then(|path| path.strip_suffix(".hbs"))
+            {
+                let partial = Templates::get(path.as_ref()).unwrap();
+                self.handlebars
+                    .write()
+                    .register_partial(partial_name, std::str::from_utf8(partial.as_ref()).unwrap())
+                    .unwrap()
+            }
+        }
+    }
+
+    fn render_template(
+        &self,
+        path: &'static str,
+        data: &impl Serialize,
+    ) -> Result<String, TemplateRenderError> {
+        #[cfg(debug_assertions)]
+        self.register_partials();
+
+        self.handlebars.read().render_template(
+            std::str::from_utf8(Templates::get(path).unwrap().as_ref()).unwrap(),
+            data,
+        )
+    }
+}
+
+#[async_trait]
+trait RequestExt {
+    async fn layout_data(&mut self) -> tide::Result<Arc<LayoutData>>;
+    fn db(&self) -> &DbPool;
+}
+
+#[async_trait]
+impl RequestExt for Request {
+    async fn layout_data(&mut self) -> tide::Result<Arc<LayoutData>> {
+        if self.ext::<Arc<LayoutData>>().is_none() {
+            self.set_ext(Arc::new(LayoutData {
+                current_user: self.current_user().await?,
+            }));
+        }
+        Ok(self.ext::<Arc<LayoutData>>().unwrap().clone())
+    }
+
+    fn db(&self) -> &DbPool {
+        &self.state().db
+    }
+}
+
+#[derive(Serialize)]
+struct LayoutData {
+    current_user: Option<auth::User>,
+}
+
+#[async_std::main]
+async fn main() -> tide::Result<()> {
+    log::start();
+
+    if let Err(error) = env::load_dotenv() {
+        log::error!(
+            "error loading .env.toml (this is expected in production): {}",
+            error
+        );
+    }
+
+    let config = envy::from_env::<Config>().expect("error loading config");
+    let state = AppState::new(config).await?;
+    let rpc = Peer::new();
+    run_server(
+        state.clone(),
+        rpc,
+        TcpListener::bind(&format!("0.0.0.0:{}", state.config.http_port)).await?,
+    )
+    .await?;
+    Ok(())
+}
+
+pub async fn run_server(
+    state: Arc<AppState>,
+    rpc: Arc<Peer>,
+    listener: TcpListener,
+) -> tide::Result<()> {
+    let mut web = tide::with_state(state.clone());
+    web.with(CompressMiddleware::new());
+    web.with(
+        SessionMiddleware::new(
+            PostgresSessionStore::new_with_table_name(&state.config.database_url, "sessions")
+                .await
+                .unwrap(),
+            state.config.session_secret.as_bytes(),
+        )
+        .with_same_site_policy(SameSite::Lax), // Required obtain our session in /auth_callback
+    );
+    web.with(errors::Middleware);
+    home::add_routes(&mut web);
+    team::add_routes(&mut web);
+    admin::add_routes(&mut web);
+    auth::add_routes(&mut web);
+    assets::add_routes(&mut web);
+
+    let mut app = tide::with_state(state.clone());
+    rpc::add_routes(&mut app, &rpc);
+    app.at("/").nest(web);
+
+    app.listen(listener).await?;
+
+    Ok(())
+}

server/src/rpc.rs 🔗

@@ -0,0 +1,652 @@
+use crate::auth::{self, UserId};
+
+use super::{auth::PeerExt as _, AppState};
+use anyhow::anyhow;
+use async_std::task;
+use async_tungstenite::{
+    tungstenite::{protocol::Role, Error as WebSocketError, Message as WebSocketMessage},
+    WebSocketStream,
+};
+use sha1::{Digest as _, Sha1};
+use std::{
+    collections::{HashMap, HashSet},
+    future::Future,
+    mem,
+    sync::Arc,
+    time::Instant,
+};
+use surf::StatusCode;
+use tide::log;
+use tide::{
+    http::headers::{HeaderName, CONNECTION, UPGRADE},
+    Request, Response,
+};
+use zrpc::{
+    auth::random_token,
+    proto::{self, EnvelopedMessage},
+    ConnectionId, Peer, Router, TypedEnvelope,
+};
+
+type ReplicaId = u16;
+
+#[derive(Default)]
+pub struct State {
+    connections: HashMap<ConnectionId, ConnectionState>,
+    pub worktrees: HashMap<u64, WorktreeState>,
+    next_worktree_id: u64,
+}
+
+struct ConnectionState {
+    _user_id: i32,
+    worktrees: HashSet<u64>,
+}
+
+pub struct WorktreeState {
+    host_connection_id: Option<ConnectionId>,
+    guest_connection_ids: HashMap<ConnectionId, ReplicaId>,
+    active_replica_ids: HashSet<ReplicaId>,
+    access_token: String,
+    root_name: String,
+    entries: HashMap<u64, proto::Entry>,
+}
+
+impl WorktreeState {
+    pub fn connection_ids(&self) -> Vec<ConnectionId> {
+        self.guest_connection_ids
+            .keys()
+            .copied()
+            .chain(self.host_connection_id)
+            .collect()
+    }
+
+    fn host_connection_id(&self) -> tide::Result<ConnectionId> {
+        Ok(self
+            .host_connection_id
+            .ok_or_else(|| anyhow!("host disconnected from worktree"))?)
+    }
+}
+
+impl State {
+    // Add a new connection associated with a given user.
+    pub fn add_connection(&mut self, connection_id: ConnectionId, _user_id: i32) {
+        self.connections.insert(
+            connection_id,
+            ConnectionState {
+                _user_id,
+                worktrees: Default::default(),
+            },
+        );
+    }
+
+    // Remove the given connection and its association with any worktrees.
+    pub fn remove_connection(&mut self, connection_id: ConnectionId) -> Vec<u64> {
+        let mut worktree_ids = Vec::new();
+        if let Some(connection_state) = self.connections.remove(&connection_id) {
+            for worktree_id in connection_state.worktrees {
+                if let Some(worktree) = self.worktrees.get_mut(&worktree_id) {
+                    if worktree.host_connection_id == Some(connection_id) {
+                        worktree_ids.push(worktree_id);
+                    } else if let Some(replica_id) =
+                        worktree.guest_connection_ids.remove(&connection_id)
+                    {
+                        worktree.active_replica_ids.remove(&replica_id);
+                        worktree_ids.push(worktree_id);
+                    }
+                }
+            }
+        }
+        worktree_ids
+    }
+
+    // Add the given connection as a guest of the given worktree
+    pub fn join_worktree(
+        &mut self,
+        connection_id: ConnectionId,
+        worktree_id: u64,
+        access_token: &str,
+    ) -> Option<(ReplicaId, &WorktreeState)> {
+        if let Some(worktree_state) = self.worktrees.get_mut(&worktree_id) {
+            if access_token == worktree_state.access_token {
+                if let Some(connection_state) = self.connections.get_mut(&connection_id) {
+                    connection_state.worktrees.insert(worktree_id);
+                }
+
+                let mut replica_id = 1;
+                while worktree_state.active_replica_ids.contains(&replica_id) {
+                    replica_id += 1;
+                }
+                worktree_state.active_replica_ids.insert(replica_id);
+                worktree_state
+                    .guest_connection_ids
+                    .insert(connection_id, replica_id);
+                Some((replica_id, worktree_state))
+            } else {
+                None
+            }
+        } else {
+            None
+        }
+    }
+
+    fn read_worktree(
+        &self,
+        worktree_id: u64,
+        connection_id: ConnectionId,
+    ) -> tide::Result<&WorktreeState> {
+        let worktree = self
+            .worktrees
+            .get(&worktree_id)
+            .ok_or_else(|| anyhow!("worktree not found"))?;
+
+        if worktree.host_connection_id == Some(connection_id)
+            || worktree.guest_connection_ids.contains_key(&connection_id)
+        {
+            Ok(worktree)
+        } else {
+            Err(anyhow!(
+                "{} is not a member of worktree {}",
+                connection_id,
+                worktree_id
+            ))?
+        }
+    }
+
+    fn write_worktree(
+        &mut self,
+        worktree_id: u64,
+        connection_id: ConnectionId,
+    ) -> tide::Result<&mut WorktreeState> {
+        let worktree = self
+            .worktrees
+            .get_mut(&worktree_id)
+            .ok_or_else(|| anyhow!("worktree not found"))?;
+
+        if worktree.host_connection_id == Some(connection_id)
+            || worktree.guest_connection_ids.contains_key(&connection_id)
+        {
+            Ok(worktree)
+        } else {
+            Err(anyhow!(
+                "{} is not a member of worktree {}",
+                connection_id,
+                worktree_id
+            ))?
+        }
+    }
+}
+
+trait MessageHandler<'a, M: proto::EnvelopedMessage> {
+    type Output: 'a + Send + Future<Output = tide::Result<()>>;
+
+    fn handle(
+        &self,
+        message: TypedEnvelope<M>,
+        rpc: &'a Arc<Peer>,
+        app_state: &'a Arc<AppState>,
+    ) -> Self::Output;
+}
+
+impl<'a, M, F, Fut> MessageHandler<'a, M> for F
+where
+    M: proto::EnvelopedMessage,
+    F: Fn(TypedEnvelope<M>, &'a Arc<Peer>, &'a Arc<AppState>) -> Fut,
+    Fut: 'a + Send + Future<Output = tide::Result<()>>,
+{
+    type Output = Fut;
+
+    fn handle(
+        &self,
+        message: TypedEnvelope<M>,
+        rpc: &'a Arc<Peer>,
+        app_state: &'a Arc<AppState>,
+    ) -> Self::Output {
+        (self)(message, rpc, app_state)
+    }
+}
+
+fn on_message<M, H>(router: &mut Router, rpc: &Arc<Peer>, app_state: &Arc<AppState>, handler: H)
+where
+    M: EnvelopedMessage,
+    H: 'static + Clone + Send + Sync + for<'a> MessageHandler<'a, M>,
+{
+    let rpc = rpc.clone();
+    let handler = handler.clone();
+    let app_state = app_state.clone();
+    router.add_message_handler(move |message| {
+        let rpc = rpc.clone();
+        let handler = handler.clone();
+        let app_state = app_state.clone();
+        async move {
+            let sender_id = message.sender_id;
+            let message_id = message.message_id;
+            let start_time = Instant::now();
+            log::info!(
+                "RPC message received. id: {}.{}, type:{}",
+                sender_id,
+                message_id,
+                M::NAME
+            );
+            if let Err(err) = handler.handle(message, &rpc, &app_state).await {
+                log::error!("error handling message: {:?}", err);
+            } else {
+                log::info!(
+                    "RPC message handled. id:{}.{}, duration:{:?}",
+                    sender_id,
+                    message_id,
+                    start_time.elapsed()
+                );
+            }
+
+            Ok(())
+        }
+    });
+}
+
+pub fn add_rpc_routes(router: &mut Router, state: &Arc<AppState>, rpc: &Arc<Peer>) {
+    on_message(router, rpc, state, share_worktree);
+    on_message(router, rpc, state, join_worktree);
+    on_message(router, rpc, state, update_worktree);
+    on_message(router, rpc, state, close_worktree);
+    on_message(router, rpc, state, open_buffer);
+    on_message(router, rpc, state, close_buffer);
+    on_message(router, rpc, state, update_buffer);
+    on_message(router, rpc, state, buffer_saved);
+    on_message(router, rpc, state, save_buffer);
+}
+
+pub fn add_routes(app: &mut tide::Server<Arc<AppState>>, rpc: &Arc<Peer>) {
+    let mut router = Router::new();
+    add_rpc_routes(&mut router, app.state(), rpc);
+    let router = Arc::new(router);
+
+    let rpc = rpc.clone();
+    app.at("/rpc").with(auth::VerifyToken).get(move |request: Request<Arc<AppState>>| {
+        let user_id = request.ext::<UserId>().copied();
+        let rpc = rpc.clone();
+        let router = router.clone();
+        async move {
+            const WEBSOCKET_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
+
+            let connection_upgrade = header_contains_ignore_case(&request, CONNECTION, "upgrade");
+            let upgrade_to_websocket = header_contains_ignore_case(&request, UPGRADE, "websocket");
+            let upgrade_requested = connection_upgrade && upgrade_to_websocket;
+
+            if !upgrade_requested {
+                return Ok(Response::new(StatusCode::UpgradeRequired));
+            }
+
+            let header = match request.header("Sec-Websocket-Key") {
+                Some(h) => h.as_str(),
+                None => return Err(anyhow!("expected sec-websocket-key"))?,
+            };
+
+            let mut response = Response::new(StatusCode::SwitchingProtocols);
+            response.insert_header(UPGRADE, "websocket");
+            response.insert_header(CONNECTION, "Upgrade");
+            let hash = Sha1::new().chain(header).chain(WEBSOCKET_GUID).finalize();
+            response.insert_header("Sec-Websocket-Accept", base64::encode(&hash[..]));
+            response.insert_header("Sec-Websocket-Version", "13");
+
+            let http_res: &mut tide::http::Response = response.as_mut();
+            let upgrade_receiver = http_res.recv_upgrade().await;
+            let addr = request.remote().unwrap_or("unknown").to_string();
+            let state = request.state().clone();
+            let user_id = user_id.ok_or_else(|| anyhow!("user_id is not present on request. ensure auth::VerifyToken middleware is present"))?.0;
+            task::spawn(async move {
+                if let Some(stream) = upgrade_receiver.await {
+                    let stream = WebSocketStream::from_raw_socket(stream, Role::Server, None).await;
+                    handle_connection(rpc, router, state, addr, stream, user_id).await;
+                }
+            });
+
+            Ok(response)
+        }
+    });
+}
+
+pub async fn handle_connection<Conn>(
+    rpc: Arc<Peer>,
+    router: Arc<Router>,
+    state: Arc<AppState>,
+    addr: String,
+    stream: Conn,
+    user_id: i32,
+) where
+    Conn: 'static
+        + futures::Sink<WebSocketMessage, Error = WebSocketError>
+        + futures::Stream<Item = Result<WebSocketMessage, WebSocketError>>
+        + Send
+        + Unpin,
+{
+    log::info!("accepted rpc connection: {:?}", addr);
+    let (connection_id, handle_io, handle_messages) = rpc.add_connection(stream, router).await;
+    state
+        .rpc
+        .write()
+        .await
+        .add_connection(connection_id, user_id);
+
+    let handle_messages = async move {
+        handle_messages.await;
+        Ok(())
+    };
+
+    if let Err(e) = futures::try_join!(handle_messages, handle_io) {
+        log::error!("error handling rpc connection {:?} - {:?}", addr, e);
+    }
+
+    log::info!("closing connection to {:?}", addr);
+    if let Err(e) = rpc.sign_out(connection_id, &state).await {
+        log::error!("error signing out connection {:?} - {:?}", addr, e);
+    }
+}
+
+async fn share_worktree(
+    mut request: TypedEnvelope<proto::ShareWorktree>,
+    rpc: &Arc<Peer>,
+    state: &Arc<AppState>,
+) -> tide::Result<()> {
+    let mut state = state.rpc.write().await;
+    let worktree_id = state.next_worktree_id;
+    state.next_worktree_id += 1;
+    let access_token = random_token();
+    let worktree = request
+        .payload
+        .worktree
+        .as_mut()
+        .ok_or_else(|| anyhow!("missing worktree"))?;
+    let entries = mem::take(&mut worktree.entries)
+        .into_iter()
+        .map(|entry| (entry.id, entry))
+        .collect();
+    state.worktrees.insert(
+        worktree_id,
+        WorktreeState {
+            host_connection_id: Some(request.sender_id),
+            guest_connection_ids: Default::default(),
+            active_replica_ids: Default::default(),
+            access_token: access_token.clone(),
+            root_name: mem::take(&mut worktree.root_name),
+            entries,
+        },
+    );
+
+    rpc.respond(
+        request.receipt(),
+        proto::ShareWorktreeResponse {
+            worktree_id,
+            access_token,
+        },
+    )
+    .await?;
+    Ok(())
+}
+
+async fn join_worktree(
+    request: TypedEnvelope<proto::OpenWorktree>,
+    rpc: &Arc<Peer>,
+    state: &Arc<AppState>,
+) -> tide::Result<()> {
+    let worktree_id = request.payload.worktree_id;
+    let access_token = &request.payload.access_token;
+
+    let mut state = state.rpc.write().await;
+    if let Some((peer_replica_id, worktree)) =
+        state.join_worktree(request.sender_id, worktree_id, access_token)
+    {
+        let mut peers = Vec::new();
+        if let Some(host_connection_id) = worktree.host_connection_id {
+            peers.push(proto::Peer {
+                peer_id: host_connection_id.0,
+                replica_id: 0,
+            });
+        }
+        for (peer_conn_id, peer_replica_id) in &worktree.guest_connection_ids {
+            if *peer_conn_id != request.sender_id {
+                peers.push(proto::Peer {
+                    peer_id: peer_conn_id.0,
+                    replica_id: *peer_replica_id as u32,
+                });
+            }
+        }
+
+        broadcast(request.sender_id, worktree.connection_ids(), |conn_id| {
+            rpc.send(
+                conn_id,
+                proto::AddPeer {
+                    worktree_id,
+                    peer: Some(proto::Peer {
+                        peer_id: request.sender_id.0,
+                        replica_id: peer_replica_id as u32,
+                    }),
+                },
+            )
+        })
+        .await?;
+        rpc.respond(
+            request.receipt(),
+            proto::OpenWorktreeResponse {
+                worktree_id,
+                worktree: Some(proto::Worktree {
+                    root_name: worktree.root_name.clone(),
+                    entries: worktree.entries.values().cloned().collect(),
+                }),
+                replica_id: peer_replica_id as u32,
+                peers,
+            },
+        )
+        .await?;
+    } else {
+        rpc.respond(
+            request.receipt(),
+            proto::OpenWorktreeResponse {
+                worktree_id,
+                worktree: None,
+                replica_id: 0,
+                peers: Vec::new(),
+            },
+        )
+        .await?;
+    }
+
+    Ok(())
+}
+
+async fn update_worktree(
+    request: TypedEnvelope<proto::UpdateWorktree>,
+    rpc: &Arc<Peer>,
+    state: &Arc<AppState>,
+) -> tide::Result<()> {
+    {
+        let mut state = state.rpc.write().await;
+        let worktree = state.write_worktree(request.payload.worktree_id, request.sender_id)?;
+        for entry_id in &request.payload.removed_entries {
+            worktree.entries.remove(&entry_id);
+        }
+
+        for entry in &request.payload.updated_entries {
+            worktree.entries.insert(entry.id, entry.clone());
+        }
+    }
+
+    broadcast_in_worktree(request.payload.worktree_id, request, rpc, state).await?;
+    Ok(())
+}
+
+async fn close_worktree(
+    request: TypedEnvelope<proto::CloseWorktree>,
+    rpc: &Arc<Peer>,
+    state: &Arc<AppState>,
+) -> tide::Result<()> {
+    let connection_ids;
+    {
+        let mut state = state.rpc.write().await;
+        let worktree = state.write_worktree(request.payload.worktree_id, request.sender_id)?;
+        connection_ids = worktree.connection_ids();
+        if worktree.host_connection_id == Some(request.sender_id) {
+            worktree.host_connection_id = None;
+        } else if let Some(replica_id) = worktree.guest_connection_ids.remove(&request.sender_id) {
+            worktree.active_replica_ids.remove(&replica_id);
+        }
+    }
+
+    broadcast(request.sender_id, connection_ids, |conn_id| {
+        rpc.send(
+            conn_id,
+            proto::RemovePeer {
+                worktree_id: request.payload.worktree_id,
+                peer_id: request.sender_id.0,
+            },
+        )
+    })
+    .await?;
+
+    Ok(())
+}
+
+async fn open_buffer(
+    request: TypedEnvelope<proto::OpenBuffer>,
+    rpc: &Arc<Peer>,
+    state: &Arc<AppState>,
+) -> tide::Result<()> {
+    let receipt = request.receipt();
+    let worktree_id = request.payload.worktree_id;
+    let host_connection_id = state
+        .rpc
+        .read()
+        .await
+        .read_worktree(worktree_id, request.sender_id)?
+        .host_connection_id()?;
+
+    let response = rpc
+        .forward_request(request.sender_id, host_connection_id, request.payload)
+        .await?;
+    rpc.respond(receipt, response).await?;
+    Ok(())
+}
+
+async fn close_buffer(
+    request: TypedEnvelope<proto::CloseBuffer>,
+    rpc: &Arc<Peer>,
+    state: &Arc<AppState>,
+) -> tide::Result<()> {
+    let host_connection_id = state
+        .rpc
+        .read()
+        .await
+        .read_worktree(request.payload.worktree_id, request.sender_id)?
+        .host_connection_id()?;
+
+    rpc.forward_send(request.sender_id, host_connection_id, request.payload)
+        .await?;
+
+    Ok(())
+}
+
+async fn save_buffer(
+    request: TypedEnvelope<proto::SaveBuffer>,
+    rpc: &Arc<Peer>,
+    state: &Arc<AppState>,
+) -> tide::Result<()> {
+    let host;
+    let guests;
+    {
+        let state = state.rpc.read().await;
+        let worktree = state.read_worktree(request.payload.worktree_id, request.sender_id)?;
+        host = worktree.host_connection_id()?;
+        guests = worktree
+            .guest_connection_ids
+            .keys()
+            .copied()
+            .collect::<Vec<_>>();
+    }
+
+    let sender = request.sender_id;
+    let receipt = request.receipt();
+    let response = rpc
+        .forward_request(sender, host, request.payload.clone())
+        .await?;
+
+    broadcast(host, guests, |conn_id| {
+        let response = response.clone();
+        async move {
+            if conn_id == sender {
+                rpc.respond(receipt, response).await
+            } else {
+                rpc.forward_send(host, conn_id, response).await
+            }
+        }
+    })
+    .await?;
+
+    Ok(())
+}
+
+async fn update_buffer(
+    request: TypedEnvelope<proto::UpdateBuffer>,
+    rpc: &Arc<Peer>,
+    state: &Arc<AppState>,
+) -> tide::Result<()> {
+    broadcast_in_worktree(request.payload.worktree_id, request, rpc, state).await
+}
+
+async fn buffer_saved(
+    request: TypedEnvelope<proto::BufferSaved>,
+    rpc: &Arc<Peer>,
+    state: &Arc<AppState>,
+) -> tide::Result<()> {
+    broadcast_in_worktree(request.payload.worktree_id, request, rpc, state).await
+}
+
+async fn broadcast_in_worktree<T: proto::EnvelopedMessage>(
+    worktree_id: u64,
+    request: TypedEnvelope<T>,
+    rpc: &Arc<Peer>,
+    state: &Arc<AppState>,
+) -> tide::Result<()> {
+    let connection_ids = state
+        .rpc
+        .read()
+        .await
+        .read_worktree(worktree_id, request.sender_id)?
+        .connection_ids();
+
+    broadcast(request.sender_id, connection_ids, |conn_id| {
+        rpc.forward_send(request.sender_id, conn_id, request.payload.clone())
+    })
+    .await?;
+
+    Ok(())
+}
+
+pub async fn broadcast<F, T>(
+    sender_id: ConnectionId,
+    receiver_ids: Vec<ConnectionId>,
+    mut f: F,
+) -> anyhow::Result<()>
+where
+    F: FnMut(ConnectionId) -> T,
+    T: Future<Output = anyhow::Result<()>>,
+{
+    let futures = receiver_ids
+        .into_iter()
+        .filter(|id| *id != sender_id)
+        .map(|id| f(id));
+    futures::future::try_join_all(futures).await?;
+    Ok(())
+}
+
+fn header_contains_ignore_case<T>(
+    request: &tide::Request<T>,
+    header_name: HeaderName,
+    value: &str,
+) -> bool {
+    request
+        .header(header_name)
+        .map(|h| {
+            h.as_str()
+                .split(',')
+                .any(|s| s.trim().eq_ignore_ascii_case(value.trim()))
+        })
+        .unwrap_or(false)
+}

server/src/team.rs 🔗

@@ -0,0 +1,15 @@
+use crate::{AppState, Request, RequestExt};
+use std::sync::Arc;
+use tide::http::mime;
+
+pub fn add_routes(app: &mut tide::Server<Arc<AppState>>) {
+    app.at("/team").get(get_team);
+}
+
+async fn get_team(mut request: Request) -> tide::Result {
+    let data = request.layout_data().await?;
+    Ok(tide::Response::builder(200)
+        .body(request.state().render_template("team.hbs", &data)?)
+        .content_type(mime::HTML)
+        .build())
+}

server/src/tests.rs 🔗

@@ -0,0 +1,599 @@
+use crate::{
+    admin, auth, github,
+    rpc::{self, add_rpc_routes},
+    AppState, Config,
+};
+use async_std::task;
+use gpui::TestAppContext;
+use rand::prelude::*;
+use serde_json::json;
+use sqlx::{
+    migrate::{MigrateDatabase, Migrator},
+    postgres::PgPoolOptions,
+    Executor as _, Postgres,
+};
+use std::{path::Path, sync::Arc};
+use zed::{
+    editor::Editor,
+    language::LanguageRegistry,
+    rpc::Client,
+    settings,
+    test::Channel,
+    worktree::{FakeFs, Fs as _, Worktree},
+};
+use zrpc::{ForegroundRouter, Peer, Router};
+
+#[gpui::test]
+async fn test_share_worktree(mut cx_a: TestAppContext, mut cx_b: TestAppContext) {
+    let (window_b, _) = cx_b.add_window(|_| EmptyView);
+    let settings = settings::channel(&cx_b.font_cache()).unwrap().1;
+    let lang_registry = Arc::new(LanguageRegistry::new());
+
+    // Connect to a server as 2 clients.
+    let mut server = TestServer::start().await;
+    let client_a = server.create_client(&mut cx_a, "user_a").await;
+    let client_b = server.create_client(&mut cx_b, "user_b").await;
+
+    // Share a local worktree as client A
+    let fs = Arc::new(FakeFs::new());
+    fs.insert_tree(
+        "/a",
+        json!({
+            "a.txt": "a-contents",
+            "b.txt": "b-contents",
+        }),
+    )
+    .await;
+    let worktree_a = Worktree::open_local(
+        "/a".as_ref(),
+        lang_registry.clone(),
+        fs,
+        &mut cx_a.to_async(),
+    )
+    .await
+    .unwrap();
+    worktree_a
+        .read_with(&cx_a, |tree, _| tree.as_local().unwrap().scan_complete())
+        .await;
+    let (worktree_id, worktree_token) = worktree_a
+        .update(&mut cx_a, |tree, cx| {
+            tree.as_local_mut().unwrap().share(client_a.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    // Join that worktree as client B, and see that a guest has joined as client A.
+    let worktree_b = Worktree::open_remote(
+        client_b.clone(),
+        worktree_id,
+        worktree_token,
+        lang_registry.clone(),
+        &mut cx_b.to_async(),
+    )
+    .await
+    .unwrap();
+    let replica_id_b = worktree_b.read_with(&cx_b, |tree, _| tree.replica_id());
+    worktree_a
+        .condition(&cx_a, |tree, _| {
+            tree.peers()
+                .values()
+                .any(|replica_id| *replica_id == replica_id_b)
+        })
+        .await;
+
+    // Open the same file as client B and client A.
+    let buffer_b = worktree_b
+        .update(&mut cx_b, |worktree, cx| worktree.open_buffer("b.txt", cx))
+        .await
+        .unwrap();
+    buffer_b.read_with(&cx_b, |buf, _| assert_eq!(buf.text(), "b-contents"));
+    worktree_a.read_with(&cx_a, |tree, cx| assert!(tree.has_open_buffer("b.txt", cx)));
+    let buffer_a = worktree_a
+        .update(&mut cx_a, |tree, cx| tree.open_buffer("b.txt", cx))
+        .await
+        .unwrap();
+
+    // Create a selection set as client B and see that selection set as client A.
+    let editor_b = cx_b.add_view(window_b, |cx| Editor::for_buffer(buffer_b, settings, cx));
+    buffer_a
+        .condition(&cx_a, |buffer, _| buffer.selection_sets().count() == 1)
+        .await;
+
+    // Edit the buffer as client B and see that edit as client A.
+    editor_b.update(&mut cx_b, |editor, cx| {
+        editor.insert(&"ok, ".to_string(), cx)
+    });
+    buffer_a
+        .condition(&cx_a, |buffer, _| buffer.text() == "ok, b-contents")
+        .await;
+
+    // Remove the selection set as client B, see those selections disappear as client A.
+    cx_b.update(move |_| drop(editor_b));
+    buffer_a
+        .condition(&cx_a, |buffer, _| buffer.selection_sets().count() == 0)
+        .await;
+
+    // Close the buffer as client A, see that the buffer is closed.
+    drop(buffer_a);
+    worktree_a
+        .condition(&cx_a, |tree, cx| !tree.has_open_buffer("b.txt", cx))
+        .await;
+
+    // Dropping the worktree removes client B from client A's peers.
+    cx_b.update(move |_| drop(worktree_b));
+    worktree_a
+        .condition(&cx_a, |tree, _| tree.peers().is_empty())
+        .await;
+}
+
+#[gpui::test]
+async fn test_propagate_saves_and_fs_changes_in_shared_worktree(
+    mut cx_a: TestAppContext,
+    mut cx_b: TestAppContext,
+    mut cx_c: TestAppContext,
+) {
+    let lang_registry = Arc::new(LanguageRegistry::new());
+
+    // Connect to a server as 3 clients.
+    let mut server = TestServer::start().await;
+    let client_a = server.create_client(&mut cx_a, "user_a").await;
+    let client_b = server.create_client(&mut cx_b, "user_b").await;
+    let client_c = server.create_client(&mut cx_c, "user_c").await;
+
+    let fs = Arc::new(FakeFs::new());
+
+    // Share a worktree as client A.
+    fs.insert_tree(
+        "/a",
+        json!({
+            "file1": "",
+            "file2": ""
+        }),
+    )
+    .await;
+
+    let worktree_a = Worktree::open_local(
+        "/a".as_ref(),
+        lang_registry.clone(),
+        fs.clone(),
+        &mut cx_a.to_async(),
+    )
+    .await
+    .unwrap();
+    worktree_a
+        .read_with(&cx_a, |tree, _| tree.as_local().unwrap().scan_complete())
+        .await;
+    let (worktree_id, worktree_token) = worktree_a
+        .update(&mut cx_a, |tree, cx| {
+            tree.as_local_mut().unwrap().share(client_a.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    // Join that worktree as clients B and C.
+    let worktree_b = Worktree::open_remote(
+        client_b.clone(),
+        worktree_id,
+        worktree_token.clone(),
+        lang_registry.clone(),
+        &mut cx_b.to_async(),
+    )
+    .await
+    .unwrap();
+    let worktree_c = Worktree::open_remote(
+        client_c.clone(),
+        worktree_id,
+        worktree_token,
+        lang_registry.clone(),
+        &mut cx_c.to_async(),
+    )
+    .await
+    .unwrap();
+
+    // Open and edit a buffer as both guests B and C.
+    let buffer_b = worktree_b
+        .update(&mut cx_b, |tree, cx| tree.open_buffer("file1", cx))
+        .await
+        .unwrap();
+    let buffer_c = worktree_c
+        .update(&mut cx_c, |tree, cx| tree.open_buffer("file1", cx))
+        .await
+        .unwrap();
+    buffer_b.update(&mut cx_b, |buf, cx| buf.edit([0..0], "i-am-b, ", cx));
+    buffer_c.update(&mut cx_c, |buf, cx| buf.edit([0..0], "i-am-c, ", cx));
+
+    // Open and edit that buffer as the host.
+    let buffer_a = worktree_a
+        .update(&mut cx_a, |tree, cx| tree.open_buffer("file1", cx))
+        .await
+        .unwrap();
+
+    buffer_a
+        .condition(&mut cx_a, |buf, _| buf.text() == "i-am-c, i-am-b, ")
+        .await;
+    buffer_a.update(&mut cx_a, |buf, cx| {
+        buf.edit([buf.len()..buf.len()], "i-am-a", cx)
+    });
+
+    // Wait for edits to propagate
+    buffer_a
+        .condition(&mut cx_a, |buf, _| buf.text() == "i-am-c, i-am-b, i-am-a")
+        .await;
+    buffer_b
+        .condition(&mut cx_b, |buf, _| buf.text() == "i-am-c, i-am-b, i-am-a")
+        .await;
+    buffer_c
+        .condition(&mut cx_c, |buf, _| buf.text() == "i-am-c, i-am-b, i-am-a")
+        .await;
+
+    // Edit the buffer as the host and concurrently save as guest B.
+    let save_b = buffer_b.update(&mut cx_b, |buf, cx| buf.save(cx).unwrap());
+    buffer_a.update(&mut cx_a, |buf, cx| buf.edit([0..0], "hi-a, ", cx));
+    save_b.await.unwrap();
+    assert_eq!(
+        fs.load("/a/file1".as_ref()).await.unwrap(),
+        "hi-a, i-am-c, i-am-b, i-am-a"
+    );
+    buffer_a.read_with(&cx_a, |buf, _| assert!(!buf.is_dirty()));
+    buffer_b.read_with(&cx_b, |buf, _| assert!(!buf.is_dirty()));
+    buffer_c.condition(&cx_c, |buf, _| !buf.is_dirty()).await;
+
+    // Make changes on host's file system, see those changes on the guests.
+    fs.rename("/a/file2".as_ref(), "/a/file3".as_ref())
+        .await
+        .unwrap();
+    fs.insert_file(Path::new("/a/file4"), "4".into())
+        .await
+        .unwrap();
+
+    worktree_b
+        .condition(&cx_b, |tree, _| tree.file_count() == 3)
+        .await;
+    worktree_c
+        .condition(&cx_c, |tree, _| tree.file_count() == 3)
+        .await;
+    worktree_b.read_with(&cx_b, |tree, _| {
+        assert_eq!(
+            tree.paths()
+                .map(|p| p.to_string_lossy())
+                .collect::<Vec<_>>(),
+            &["file1", "file3", "file4"]
+        )
+    });
+    worktree_c.read_with(&cx_c, |tree, _| {
+        assert_eq!(
+            tree.paths()
+                .map(|p| p.to_string_lossy())
+                .collect::<Vec<_>>(),
+            &["file1", "file3", "file4"]
+        )
+    });
+}
+
+#[gpui::test]
+async fn test_buffer_conflict_after_save(mut cx_a: TestAppContext, mut cx_b: TestAppContext) {
+    let lang_registry = Arc::new(LanguageRegistry::new());
+
+    // Connect to a server as 2 clients.
+    let mut server = TestServer::start().await;
+    let client_a = server.create_client(&mut cx_a, "user_a").await;
+    let client_b = server.create_client(&mut cx_b, "user_b").await;
+
+    // Share a local worktree as client A
+    let fs = Arc::new(FakeFs::new());
+    fs.save(Path::new("/a.txt"), &"a-contents".into())
+        .await
+        .unwrap();
+    let worktree_a = Worktree::open_local(
+        "/".as_ref(),
+        lang_registry.clone(),
+        fs,
+        &mut cx_a.to_async(),
+    )
+    .await
+    .unwrap();
+    worktree_a
+        .read_with(&cx_a, |tree, _| tree.as_local().unwrap().scan_complete())
+        .await;
+    let (worktree_id, worktree_token) = worktree_a
+        .update(&mut cx_a, |tree, cx| {
+            tree.as_local_mut().unwrap().share(client_a.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    // Join that worktree as client B, and see that a guest has joined as client A.
+    let worktree_b = Worktree::open_remote(
+        client_b.clone(),
+        worktree_id,
+        worktree_token,
+        lang_registry.clone(),
+        &mut cx_b.to_async(),
+    )
+    .await
+    .unwrap();
+
+    let buffer_b = worktree_b
+        .update(&mut cx_b, |worktree, cx| worktree.open_buffer("a.txt", cx))
+        .await
+        .unwrap();
+    let mtime = buffer_b.read_with(&cx_b, |buf, _| buf.file().unwrap().mtime);
+
+    buffer_b.update(&mut cx_b, |buf, cx| buf.edit([0..0], "world ", cx));
+    buffer_b.read_with(&cx_b, |buf, _| {
+        assert!(buf.is_dirty());
+        assert!(!buf.has_conflict());
+    });
+
+    buffer_b
+        .update(&mut cx_b, |buf, cx| buf.save(cx))
+        .unwrap()
+        .await
+        .unwrap();
+    worktree_b
+        .condition(&cx_b, |_, cx| {
+            buffer_b.read(cx).file().unwrap().mtime != mtime
+        })
+        .await;
+    buffer_b.read_with(&cx_b, |buf, _| {
+        assert!(!buf.is_dirty());
+        assert!(!buf.has_conflict());
+    });
+
+    buffer_b.update(&mut cx_b, |buf, cx| buf.edit([0..0], "hello ", cx));
+    buffer_b.read_with(&cx_b, |buf, _| {
+        assert!(buf.is_dirty());
+        assert!(!buf.has_conflict());
+    });
+}
+
+#[gpui::test]
+async fn test_editing_while_guest_opens_buffer(mut cx_a: TestAppContext, mut cx_b: TestAppContext) {
+    let lang_registry = Arc::new(LanguageRegistry::new());
+
+    // Connect to a server as 2 clients.
+    let mut server = TestServer::start().await;
+    let client_a = server.create_client(&mut cx_a, "user_a").await;
+    let client_b = server.create_client(&mut cx_b, "user_b").await;
+
+    // Share a local worktree as client A
+    let fs = Arc::new(FakeFs::new());
+    fs.save(Path::new("/a.txt"), &"a-contents".into())
+        .await
+        .unwrap();
+    let worktree_a = Worktree::open_local(
+        "/".as_ref(),
+        lang_registry.clone(),
+        fs,
+        &mut cx_a.to_async(),
+    )
+    .await
+    .unwrap();
+    worktree_a
+        .read_with(&cx_a, |tree, _| tree.as_local().unwrap().scan_complete())
+        .await;
+    let (worktree_id, worktree_token) = worktree_a
+        .update(&mut cx_a, |tree, cx| {
+            tree.as_local_mut().unwrap().share(client_a.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    // Join that worktree as client B, and see that a guest has joined as client A.
+    let worktree_b = Worktree::open_remote(
+        client_b.clone(),
+        worktree_id,
+        worktree_token,
+        lang_registry.clone(),
+        &mut cx_b.to_async(),
+    )
+    .await
+    .unwrap();
+
+    let buffer_a = worktree_a
+        .update(&mut cx_a, |tree, cx| tree.open_buffer("a.txt", cx))
+        .await
+        .unwrap();
+    let buffer_b = cx_b
+        .background()
+        .spawn(worktree_b.update(&mut cx_b, |worktree, cx| worktree.open_buffer("a.txt", cx)));
+
+    task::yield_now().await;
+    buffer_a.update(&mut cx_a, |buf, cx| buf.edit([0..0], "z", cx));
+
+    let text = buffer_a.read_with(&cx_a, |buf, _| buf.text());
+    let buffer_b = buffer_b.await.unwrap();
+    buffer_b.condition(&cx_b, |buf, _| buf.text() == text).await;
+}
+
+#[gpui::test]
+async fn test_peer_disconnection(mut cx_a: TestAppContext, cx_b: TestAppContext) {
+    let lang_registry = Arc::new(LanguageRegistry::new());
+
+    // Connect to a server as 2 clients.
+    let mut server = TestServer::start().await;
+    let client_a = server.create_client(&mut cx_a, "user_a").await;
+    let client_b = server.create_client(&mut cx_a, "user_b").await;
+
+    // Share a local worktree as client A
+    let fs = Arc::new(FakeFs::new());
+    fs.insert_tree(
+        "/a",
+        json!({
+            "a.txt": "a-contents",
+            "b.txt": "b-contents",
+        }),
+    )
+    .await;
+    let worktree_a = Worktree::open_local(
+        "/a".as_ref(),
+        lang_registry.clone(),
+        fs,
+        &mut cx_a.to_async(),
+    )
+    .await
+    .unwrap();
+    worktree_a
+        .read_with(&cx_a, |tree, _| tree.as_local().unwrap().scan_complete())
+        .await;
+    let (worktree_id, worktree_token) = worktree_a
+        .update(&mut cx_a, |tree, cx| {
+            tree.as_local_mut().unwrap().share(client_a.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    // Join that worktree as client B, and see that a guest has joined as client A.
+    let _worktree_b = Worktree::open_remote(
+        client_b.clone(),
+        worktree_id,
+        worktree_token,
+        lang_registry.clone(),
+        &mut cx_b.to_async(),
+    )
+    .await
+    .unwrap();
+    worktree_a
+        .condition(&cx_a, |tree, _| tree.peers().len() == 1)
+        .await;
+
+    // Drop client B's connection and ensure client A observes client B leaving the worktree.
+    client_b.disconnect().await.unwrap();
+    worktree_a
+        .condition(&cx_a, |tree, _| tree.peers().len() == 0)
+        .await;
+}
+
+struct TestServer {
+    peer: Arc<Peer>,
+    app_state: Arc<AppState>,
+    db_name: String,
+    router: Arc<Router>,
+}
+
+impl TestServer {
+    async fn start() -> Self {
+        let mut rng = StdRng::from_entropy();
+        let db_name = format!("zed-test-{}", rng.gen::<u128>());
+        let app_state = Self::build_app_state(&db_name).await;
+        let peer = Peer::new();
+        let mut router = Router::new();
+        add_rpc_routes(&mut router, &app_state, &peer);
+        Self {
+            peer,
+            router: Arc::new(router),
+            app_state,
+            db_name,
+        }
+    }
+
+    async fn create_client(&mut self, cx: &mut TestAppContext, name: &str) -> Client {
+        let user_id = admin::create_user(&self.app_state.db, name, false)
+            .await
+            .unwrap();
+        let lang_registry = Arc::new(LanguageRegistry::new());
+        let client = Client::new(lang_registry.clone());
+        let mut client_router = ForegroundRouter::new();
+        cx.update(|cx| zed::worktree::init(cx, &client, &mut client_router));
+
+        let (client_conn, server_conn) = Channel::bidirectional();
+        cx.background()
+            .spawn(rpc::handle_connection(
+                self.peer.clone(),
+                self.router.clone(),
+                self.app_state.clone(),
+                name.to_string(),
+                server_conn,
+                user_id,
+            ))
+            .detach();
+        client
+            .add_connection(client_conn, Arc::new(client_router), cx.to_async())
+            .await
+            .unwrap();
+
+        // Reset the executor because running SQL queries has a non-deterministic impact on it.
+        cx.foreground().reset();
+        client
+    }
+
+    async fn build_app_state(db_name: &str) -> Arc<AppState> {
+        let mut config = Config::default();
+        config.session_secret = "a".repeat(32);
+        config.database_url = format!("postgres://postgres@localhost/{}", db_name);
+
+        Self::create_db(&config.database_url).await;
+        let db = PgPoolOptions::new()
+            .max_connections(5)
+            .connect(&config.database_url)
+            .await
+            .expect("failed to connect to postgres database");
+        let migrator = Migrator::new(Path::new("./migrations")).await.unwrap();
+        migrator.run(&db).await.unwrap();
+
+        let github_client = github::AppClient::test();
+        Arc::new(AppState {
+            db,
+            handlebars: Default::default(),
+            auth_client: auth::build_client("", ""),
+            repo_client: github::RepoClient::test(&github_client),
+            github_client,
+            rpc: Default::default(),
+            config,
+        })
+    }
+
+    async fn create_db(url: &str) {
+        // Enable tests to run in parallel by serializing the creation of each test database.
+        lazy_static::lazy_static! {
+            static ref DB_CREATION: async_std::sync::Mutex<()> = async_std::sync::Mutex::new(());
+        }
+
+        let _lock = DB_CREATION.lock().await;
+        Postgres::create_database(url)
+            .await
+            .expect("failed to create test database");
+    }
+}
+
+impl Drop for TestServer {
+    fn drop(&mut self) {
+        task::block_on(async {
+            self.peer.reset().await;
+            self.app_state
+                .db
+                .execute(
+                    format!(
+                        "
+                        SELECT pg_terminate_backend(pg_stat_activity.pid)
+                        FROM pg_stat_activity
+                        WHERE pg_stat_activity.datname = '{}' AND pid <> pg_backend_pid();",
+                        self.db_name,
+                    )
+                    .as_str(),
+                )
+                .await
+                .unwrap();
+            self.app_state.db.close().await;
+            Postgres::drop_database(&self.app_state.config.database_url)
+                .await
+                .unwrap();
+        });
+    }
+}
+
+struct EmptyView;
+
+impl gpui::Entity for EmptyView {
+    type Event = ();
+}
+
+impl gpui::View for EmptyView {
+    fn ui_name() -> &'static str {
+        "empty view"
+    }
+
+    fn render<'a>(&self, _: &gpui::AppContext) -> gpui::ElementBox {
+        gpui::Element::boxed(gpui::elements::Empty)
+    }
+}

server/static/svg/hero.svg 🔗

@@ -0,0 +1,6 @@
+<svg width="1147" height="310" viewBox="0 0 1147 310" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.34388 233H166.2V186.632H82.1039L169.368 28.52H7.79988V74.888H91.6079L4.34388 233ZM233.094 186.632V152.936H291.27V107.144H233.094V74.888H303.078V28.52H182.406V233H305.382V186.632H233.094ZM323.108 233H406.916C460.484 233 504.548 190.376 504.548 130.76C504.548 71.144 460.484 28.52 406.916 28.52H323.108V233ZM373.796 185.192V76.328H405.188C436.292 76.328 451.556 99.08 451.556 130.76C451.556 162.44 436.292 185.192 405.188 185.192H373.796Z" fill="black"/>
+<rect x="372" y="222" width="773" height="86" rx="2" fill="black" stroke="black" stroke-width="4"/>
+<rect x="351" y="205" width="773" height="85" rx="2" fill="#F9FAFB" stroke="black" stroke-width="4"/>

server/styles.css 🔗

@@ -0,0 +1,108 @@
+/* This file is compiled to /assets/styles/tailwind.css via script/tailwind */
+
+@import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400;0,700;1,400;1,700&display=swap');
+
+@font-face {
+    font-family: 'Visby CF';
+    src:
+        url('/static/fonts/VisbyCF-Thin.woff2') format('woff2'),
+        url('/static/fonts/VisbyCF-Thin.woff') format('woff');
+    font-weight: 100;
+    font-style: normal;
+}
+
+
+@font-face {
+    font-family: 'Visby CF';
+    src:
+        url('/static/fonts/VisbyCF-Light.woff2') format('woff2'),
+        url('/static/fonts/VisbyCF-Light.woff') format('woff');
+    font-weight: 300;
+    font-style: normal;
+}
+ 
+@font-face {
+    font-family: 'Visby CF';
+    src:
+        url('/static/fonts/VisbyCF-Regular.woff2') format('woff2'),
+        url('/static/fonts/VisbyCF-Regular.woff') format('woff');
+    font-weight: 400;
+    font-style: normal;
+}
+
+@font-face {
+    font-family: 'Visby CF';
+    src:
+        url('/static/fonts/VisbyCF-Medium.woff2') format('woff2'),
+        url('/static/fonts/VisbyCF-Medium.woff') format('woff');
+    font-weight: 500;
+    font-style: normal;
+}
+
+@font-face {
+    font-family: 'Visby CF';
+    src:
+        url('/static/fonts/VisbyCF-DemiBold.woff2') format('woff2'),
+        url('/static/fonts/VisbyCF-DemiBold.woff') format('woff');
+    font-weight: 600;
+    font-style: normal;
+}
+
+@font-face {
+    font-family: 'Visby CF';
+    src:
+        url('/static/fonts/VisbyCF-Bold.woff2') format('woff2'),
+        url('/static/fonts/VisbyCF-Bold.woff') format('woff');
+    font-weight: 700;
+    font-style: normal;
+}
+
+@font-face {
+    font-family: 'Visby CF';
+    src:
+        url('/static/fonts/VisbyCF-ExtraBold.woff2') format('woff2'),
+        url('/static/fonts/VisbyCF-ExtraBold.woff') format('woff');
+    font-weight: 800;
+    font-style: normal;
+}
+
+@font-face {
+    font-family: 'Visby CF';
+    src:
+        url('/static/fonts/VisbyCF-Heavy.woff2') format('woff2'),
+        url('/static/fonts/VisbyCF-Heavy.woff') format('woff');
+    font-weight: 900;
+    font-style: normal;
+}
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer utilities {
+    @responsive {
+        .bg-dotgrid-sm {
+            background:
+                linear-gradient(90deg, theme('colors.gray.50') 38px, transparent 1%) center,
+                linear-gradient(theme('colors.gray.50') 38px, transparent 1%) center,
+                theme('colors.gray.600');
+            background-size: 40px 40px;
+        }
+
+        .bg-dotgrid-md {
+            background:
+                linear-gradient(90deg, theme('colors.gray.50') 58px, transparent 1%) center,
+                linear-gradient(theme('colors.gray.50') 58px, transparent 1%) center,
+                theme('colors.gray.600');
+            background-size: 60px 60px;
+        }
+
+        .bg-dotgrid-lg {
+            background:
+                linear-gradient(90deg, theme('colors.gray.50') 88px, transparent 1%) center,
+                linear-gradient(theme('colors.gray.50') 88px, transparent 1%) center,
+                theme('colors.gray.600');
+            background-size: 90px 90px;
+        }       
+    }
+}

server/templates/admin.hbs 🔗

@@ -0,0 +1,81 @@
+{{#> layout }}
+<script>
+    window.addEventListener("DOMContentLoaded", function () {
+        let users = document.getElementById("users");
+        if (users) {
+            users.addEventListener("change", async function (event) {
+                const action = event.target.getAttribute("action");
+                if (action) {
+                    console.log(action, event.target.checked);
+                    const response = await fetch(action, {
+                        method: 'PUT',
+                        headers: {
+                            'Content-Type': 'application/json'
+                        },
+                        body: JSON.stringify({ admin: event.target.checked })
+                    });
+                }
+            });
+        }
+    });
+</script>
+
+<div class="bg-white">
+    <div class="container py-4 px-8 md:px-12 mx-auto">
+        <h1 class="text-xl font-bold border-b border-gray-300 mb-4">Users</h1>
+        <table class="table" id="users">
+            <tr>
+                <th class="text-left pr-2">GitHub Login</th>
+                <th class="text-left pr-2">Admin</th>
+                <th></th>
+            </tr>
+            <form action="/users" method="post" class="m-0 mb-4">
+                <tr>
+                    <td>
+                        <input name="github_login" type="text" class="border border-gray-300 p-1 mr-2 w-48"
+                            placeholder="@github_handle">
+                    </td>
+                    <td>
+                        <input type="checkbox" id="admin" name="admin" value="true">
+                    </td>
+                    <td class="text-right">
+                        <button class="p-1 w-20 text-white rounded-md bg-gray-600 hover:bg-black">Add</button>
+                    </td>
+                </tr>
+            </form>
+
+            {{#each users}}
+            <tr>
+                <form action="/users/{{id}}/delete" method="post">
+                    <td class="py-1">
+                        {{github_login}}
+                    </td>
+                    <td>
+                        <input action="/users/{{id}}" type="checkbox" {{#if admin}}checked{{/if}}>
+                    </td>
+                    <td class="text-right">
+                        <button class="p-1 w-20 rounded-md bg-gray-600 hover:bg-black text-white">Remove</button>
+                    </td>
+                </form>
+            </tr>
+            {{/each}}
+        </table>
+
+        <h1 class="text-xl font-bold border-b border-gray-300 mb-4 mt-8">Signups</h1>
+        <table class="table">
+            {{#each signups}}
+            <tr>
+                <form action="/signups/{{id}}/delete" method="post">
+                    <td class="align-top">{{github_login}}</td>
+                    <td class="pl-4 align-top">{{email_address}}</td>
+                    <td class="pl-4 align-top">{{about}}</td>
+                    <td class="text-right">
+                        <button class="p-1 w-20 rounded-md bg-gray-600 hover:bg-black text-white">Remove</button>
+                    </td>
+                </form>
+            </tr>
+            {{/each}}
+        </table>
+    </div>
+</div>
+{{/layout}}

server/templates/docs.hbs 🔗

@@ -0,0 +1,41 @@
+{{#> layout }}
+
+<div class="bg-white">
+    <div class="container mx-auto py-12 px-8 md:px-12">
+        <h1 class="text-4xl font-black font-display mb-8">Bypassing code signing restrictions</h1>
+        <div class="lg:flex lg:flex-row items-start">
+            <div class="prose xl:prose-xl lg:mr-12">
+                <p>
+                    We haven't yet applied to Apple for the required certificate to sign our application bundle, which
+                    means there's a small speed bump when you run our app.
+                </p>
+                <p>
+                    Instead of double-clicking the app, right click it and choose Open.
+                </p>
+                <p>
+                    You need to attempt open the app <b>twice</b>. On the second attempt, you should see the option
+                    to open the application anyway in the dialog.
+                </p>
+            </div>
+            <img class="float-1 lg:w-1/3 object-contain mt-8 lg:mt-0" alt="Screen Shot 2021-06-02 at 2 38 12 PM"
+                src="https://user-images.githubusercontent.com/1789/120550754-86514480-c3b2-11eb-8995-32f5eea79664.png">
+            <img class="float-1 lg:w-1/3 object-contain -ml-10 lg:ml-0 lg:-mt-10"
+                alt="Screen Shot 2021-06-02 at 2 38 19 PM"
+                src="https://user-images.githubusercontent.com/1789/120550759-88b39e80-c3b2-11eb-88e2-ddfc1b1c7a03.png">
+        </div>
+
+        <h1 class="text-4xl font-black font-display my-8">Key bindings</h1>
+        <div class="prose">
+            <dl>
+                <dt>
+                    <pre>cmd-shift-L</pre>
+                </dt>
+                <dd>
+                    Split selection into lines
+                </dd>
+            </dl>
+        </div>
+    </div>
+</div>
+
+{{/layout}}

server/templates/error.hbs 🔗

@@ -0,0 +1,7 @@
+{{#> layout }}
+<div class="bg-white py-8">
+    <div class="container mx-auto my-16 px-8 md:px-12 text-2xl md:text-4xl">
+        Sorry, we encountered a {{status}} error: {{reason}}.
+    </div>
+</div>
+{{/layout}}

server/templates/home.hbs 🔗

@@ -0,0 +1,69 @@
+{{#> layout }}
+{{#if releases}}
+
+<div class="bg-white">
+    <div class="container mx-auto py-12 px-8 md:px-12 lg:flex lg:flex-row">
+        {{#each releases}}
+        <div class="md:flex md:flex-row">
+            <div class="font-display mb-8 md:mb-0 md:text-right">
+                <div class="text-2xl font-bold whitespace-nowrap">
+                    VERSION {{name}}
+                </div>
+                <a class="text-md underline text-yellow-600 hover:text-yellow-700"
+                    href="/releases/{{tag_name}}/{{assets.0.name}}">
+                    DOWNLOAD
+                </a>
+            </div>
+            <div
+                class="prose prose-lg xl:prose-xl border-t md:border-t-0 pt-8 md:border-l border-gray-400 md:ml-8 md:pl-8 md:pt-0 xl:ml-16 xl:pl-16 max-w-5xl font-body">
+                {{{body}}}
+            </div>
+        </div>
+        {{/each}}
+    </div>
+</div>
+
+{{else}}
+
+<div class="bg-dotgrid-sm md:bg-dotgrid-md lg:bg-dotgrid-lg">
+    <img src="/static/svg/hero.svg" class="container mx-auto px-8 md:px-12 py-16 md:py-24 lg:py-32" />
+</div>
+
+<div class="container mx-auto py-24 lg:py-32 px-8 md:px-12 lg:flex lg:flex-row lg:items-center">
+    <div class="prose prose-xl md:prose-2xl text-gray-50 prose-gray-50 w-full lg:w-1/2">
+        <p>
+            We’re the team behind GitHub’s Atom text editor, and we’re building something new:
+        </p>
+
+        <p>
+            <b>Zed</b> is a fully-native desktop code editor focused on high performance,
+            clean design, and seamless collaboration.
+        </p>
+
+        <p>
+            We’re in early development, but we’d like to build a small community of developers who care deeply about
+            their tools and are willing to give us feedback. We'll be sharing alpha builds with community members and
+            telling our story along the way.
+        </p>
+
+        <p>
+            If you’re interested in joining us, please let us know.
+        </p>
+    </div>
+
+    <form class="my-16 lg:my-0 lg:ml-16 flex-1 text-xl md:text-2xl" action="/signups" method="post">
+        <input name="github_login" placeholder="@github_handle"
+            class="w-3/5 xl:w-1/2 p-3 mb-8 block bg-gray-50 placeholder-gray-500">
+        <input name="email_address" placeholder="email@addre.ss"
+            class="w-4/5 xl:w-3/4 p-3 my-8 block bg-gray-50 placeholder-gray-500">
+        <textarea name="about" class="block w-full xl:w-full h-48 p-3 my-8 bg-gray-50 placeholder-gray-500 my-6"
+            placeholder="Please tell us a bit about you and why you're interested in Zed. What code editor do you use today? What do you love and hate about it?"></textarea>
+        <button
+            class="p-4 rounded-md text-gray-50 bg-gray-500 inline-block cursor-pointer hover:bg-gray-400 font-display">
+            ENGAGE
+        </button>
+    </form>
+</div>
+
+{{/if}}
+{{/layout}}

server/templates/partials/layout.hbs 🔗

@@ -0,0 +1,62 @@
+<html>
+
+<head>
+    <link rel="icon" href="/static/images/favicon.png">
+    <link rel="stylesheet" href="/static/styles.css">
+    <title>Zed Industries</title>
+
+
+    <script>
+        window.addEventListener("DOMContentLoaded", function () {
+            let avatar = document.getElementById("avatar");
+            let sign_out = document.getElementById("sign_out");
+            if (avatar && sign_out) {
+                avatar.addEventListener("click", function (event) {
+                    sign_out.classList.toggle("hidden");
+                    event.stopPropagation();
+                });
+                document.addEventListener("click", function (event) {
+                    sign_out.classList.add("hidden");
+                });
+            }
+        });
+    </script>
+</head>
+
+<body class="font-body bg-black">
+    <div class="text-lg text-gray-50">
+        <div class="container mx-auto flex flex-row items-center py-4 px-8 md:px-12 font-display">
+            <a href="/" class="font-display">
+                <span class="font-black">ZED</span><span class="font-light" style="padding-left: 1px">INDUSTRIES</span>
+            </a>
+            <div class="flex-1"></div>
+            <a href="/team" class="text-sm mr-4 hover:underline">
+                Team
+            </a>
+            {{#if current_user}}
+            {{#if current_user.is_admin }}
+            <a href="/admin" class="text-sm mr-4 hover:underline">
+                Admin
+            </a>
+            {{/if}}
+            <div class="relative">
+                <img id="avatar" src="{{current_user.avatar_url}}"
+                    class="w-8 rounded-full border-gray-400 border cursor-pointer" />
+                <form id="sign_out" action="/sign_out" method="post"
+                    class="hidden absolute mt-1 right-0 bg-black rounded border border-gray-400 text-center text-sm p-2 px-4 whitespace-nowrap">
+                    <button class="hover:underline">Sign out</button>
+                </form>
+            </div>
+            {{else}}
+            <a href=" /sign_in"
+                class="text-sm align-middle p-1 px-2 rounded-md border border-gray-50 cursor-pointer hover:bg-gray-800">
+                Log in
+            </a>
+            {{/if}}
+        </div>
+    </div>
+
+    {{> @partial-block}}
+</body>
+
+</html>

server/templates/signup.hbs 🔗

@@ -0,0 +1,19 @@
+{{#> layout }}
+<div class="bg-gray-50 py-10 text-black">
+    <div class="container mx-auto px-8 md:px-12">
+        <div class=" text-6xl font-black mb-8">
+            THANKS
+        </div>
+        <div class="text-xl max-w-md">
+            <p class="mb-8">
+                Thanks a ton for your interest! We'll add you to our list and let you know when we have something ready
+                for you to try out.
+            </p>
+
+            <p>
+                <a href="/" class="font-bold text-yellow-600 hover:text-yellow-700">Back to /</a>
+            </p>
+        </div>
+    </div>
+</div>
+{{/layout}}

server/templates/team.hbs 🔗

@@ -0,0 +1,62 @@
+{{#> layout }}
+
+<div class="bg-white">
+    <div class="container mx-auto py-12 px-8 md:px-12 lg:flex lg:flex-row">
+        <div class="mb-16 lg:mb-0 lg:flex-1 lg:mr-8 xl:mr-16">
+            <img src="https://github.com/nathansobo.png?size=200" class="mx-auto mb-4 h-28 rounded-full">
+            <div>
+                <a href="https://github.com/nathansobo"
+                    class="block text-center mb-4 font-display text-2xl font-bold whitespace-nowrap hover:underline">
+                    NATHAN SOBO
+                </a>
+                <div class="prose md:prose-lg lg:prose xl:prose-lg">
+                    Nathan joined GitHub in late 2011 to build the <a href="https://atom.io">Atom text editor</a>, and
+                    he led the Atom team until 2018. He also co-led development of <a
+                        href="https://teletype.atom.io">Teletype for Atom</a>, pioneering one of the first production
+                    uses of conflict-free replicated data types for collaborative text editing. He's been dreaming about
+                    building the world’s best text editor since he graduated from college, and is excited to finally
+                    have
+                    the knowledge, tools, and resources to achieve this vision.
+                </div>
+            </div>
+        </div>
+        <div class="mb-16 lg:mb-0 lg:flex-1 lg:mr-8 xl:mr-16">
+            <img src="https://github.com/as-cii.png?size=200" class="mx-auto mb-4 h-28 rounded-full">
+            <div>
+                <a href="https://github.com/as-cii"
+                    class="block text-center mb-4 font-display text-2xl font-bold whitespace-nowrap hover:underline">
+                    ANTONIO SCANDURRA
+                </a>
+                <div class="prose md:prose-lg lg:prose xl:prose-lg">
+                    Antonio joined the Atom team in 2014 while still in university after his outstanding open source
+                    contributions caught the attention of the team. He later joined Nathan in architecting <a
+                        href="https://teletype.atom.io">Teletype for
+                        Atom</a> and researching the foundations of what has turned into Zed. For the last two years,
+                    he’s
+                    become an expert in distributed systems and conflict-free replicated data types through the
+                    development of a real-time, distributed, conflict-free database implemented in Rust for <a
+                        href="https://ditto.live">Ditto</a>.
+                </div>
+            </div>
+        </div>
+        <div class="mb-16 lg:mb-0 lg:flex-1">
+            <img src="https://github.com/maxbrunsfeld.png?size=200" class="mx-auto mb-4 h-28 rounded-full">
+            <div>
+                <a href="https://github.com/maxbrunsfeld"
+                    class="block text-center mb-4 font-display text-2xl font-bold whitespace-nowrap hover:underline">
+                    MAX BRUNSFELD
+                </a>
+                <div class="prose md:prose-lg lg:prose xl:prose-lg">
+                    Max joined the Atom team in 2013 after working at Pivotal Labs. While driving Atom towards its 1.0
+                    launch during the day, Max spent nights and weekends building <a
+                        href="https://tree-sitter.github.io">Tree-sitter</a>, a blazing-fast and
+                    expressive incremental parsing framework that currently powers all code analysis at GitHub. Before
+                    leaving to start Zed, Max helped GitHub's semantic analysis team integrate Tree-sitter to support
+                    syntax highlighting and code navigation on <a href="https://github.com">github.com</a>.
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+{{/layout}}

zed/Cargo.toml 🔗

@@ -14,20 +14,20 @@ name = "Zed"
 path = "src/main.rs"
 
 [features]
-test-support = ["tempdir", "serde_json", "zed-rpc/test-support"]
+test-support = ["tempdir", "serde_json", "zrpc/test-support"]
 
 [dependencies]
 anyhow = "1.0.38"
 arrayvec = "0.5.2"
 async-trait = "0.1"
-async-tungstenite = { version="0.14", features=["async-tls"] }
+async-tungstenite = { version = "0.14", features = ["async-tls"] }
 crossbeam-channel = "0.5.0"
 ctor = "0.1.20"
 dirs = "3.0"
 easy-parallel = "3.1.0"
-fsevent = { path="../fsevent" }
+fsevent = { path = "../fsevent" }
 futures = "0.3"
-gpui = { path="../gpui" }
+gpui = { path = "../gpui" }
 http-auth-basic = "0.1.3"
 ignore = "0.4"
 lazy_static = "1.4.0"
@@ -35,31 +35,33 @@ libc = "0.2"
 log = "0.4"
 num_cpus = "1.13.0"
 parking_lot = "0.11.1"
-postage = { version="0.4.1", features=["futures-traits"] }
+postage = { version = "0.4.1", features = ["futures-traits"] }
 rand = "0.8.3"
 rsa = "0.4"
 rust-embed = "5.9.0"
 seahash = "4.1"
-serde = { version="1", features=["derive"] }
-serde_json = { version="1.0.64", features=["preserve_order"], optional=true }
+serde = { version = "1", features = ["derive"] }
+serde_json = { version = "1.0.64", features = [
+  "preserve_order",
+], optional = true }
 similar = "1.3"
 simplelog = "0.9"
-smallvec = { version="1.6", features=["union"] }
+smallvec = { version = "1.6", features = ["union"] }
 smol = "1.2.5"
 surf = "2.2"
-tempdir = { version="0.3.7", optional=true }
+tempdir = { version = "0.3.7", optional = true }
 tiny_http = "0.8"
 toml = "0.5"
 tree-sitter = "0.19.5"
 tree-sitter-rust = "0.19.0"
 url = "2.2"
-zed-rpc = { path="../zed-rpc" }
+zrpc = { path = "../zrpc" }
 
 [dev-dependencies]
 cargo-bundle = "0.5.0"
 env_logger = "0.8"
-serde_json = { version="1.0.64", features=["preserve_order"] }
-tempdir = { version="0.3.7" }
+serde_json = { version = "1.0.64", features = ["preserve_order"] }
+tempdir = { version = "0.3.7" }
 unindent = "0.1.7"
 
 [package.metadata.bundle]

zed/src/editor/buffer.rs 🔗

@@ -11,7 +11,7 @@ use seahash::SeaHasher;
 pub use selection::*;
 use similar::{ChangeTag, TextDiff};
 use tree_sitter::{InputEdit, Parser, QueryCursor};
-use zed_rpc::proto;
+use zrpc::proto;
 
 use crate::{
     language::{Language, Tree},
@@ -2734,7 +2734,7 @@ mod tests {
     use crate::{
         test::{build_app_state, temp_tree},
         util::RandomCharIter,
-        worktree::{Worktree, WorktreeHandle},
+        worktree::{RealFs, Worktree, WorktreeHandle},
     };
     use gpui::ModelHandle;
     use rand::prelude::*;
@@ -3209,7 +3209,14 @@ mod tests {
             "file2": "def",
             "file3": "ghi",
         }));
-        let tree = cx.add_model(|cx| Worktree::local(dir.path(), Default::default(), cx));
+        let tree = Worktree::open_local(
+            dir.path(),
+            Default::default(),
+            Arc::new(RealFs),
+            &mut cx.to_async(),
+        )
+        .await
+        .unwrap();
         tree.flush_fs_events(&cx).await;
         cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
             .await;
@@ -3321,7 +3328,14 @@ mod tests {
     async fn test_file_changes_on_disk(mut cx: gpui::TestAppContext) {
         let initial_contents = "aaa\nbbbbb\nc\n";
         let dir = temp_tree(json!({ "the-file": initial_contents }));
-        let tree = cx.add_model(|cx| Worktree::local(dir.path(), Default::default(), cx));
+        let tree = Worktree::open_local(
+            dir.path(),
+            Default::default(),
+            Arc::new(RealFs),
+            &mut cx.to_async(),
+        )
+        .await
+        .unwrap();
         cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
             .await;
 

zed/src/file_finder.rs 🔗

@@ -399,11 +399,11 @@ impl FileFinder {
             .map(|tree| tree.read(cx).snapshot())
             .collect::<Vec<_>>();
         let search_id = util::post_inc(&mut self.search_count);
-        let pool = cx.as_ref().thread_pool().clone();
+        let background = cx.as_ref().background().clone();
         self.cancel_flag.store(true, atomic::Ordering::Relaxed);
         self.cancel_flag = Arc::new(AtomicBool::new(false));
         let cancel_flag = self.cancel_flag.clone();
-        let background_task = cx.background_executor().spawn(async move {
+        Some(cx.spawn(|this, mut cx| async move {
             let include_root_name = snapshots.len() > 1;
             let matches = match_paths(
                 snapshots.iter(),
@@ -413,15 +413,13 @@ impl FileFinder {
                 false,
                 100,
                 cancel_flag.clone(),
-                pool,
-            );
+                background,
+            )
+            .await;
             let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
-            (search_id, did_cancel, query, matches)
-        });
-
-        Some(cx.spawn(|this, mut cx| async move {
-            let matches = background_task.await;
-            this.update(&mut cx, |this, cx| this.update_matches(matches, cx));
+            this.update(&mut cx, |this, cx| {
+                this.update_matches((search_id, did_cancel, query, matches), cx)
+            });
         }))
     }
 
@@ -461,6 +459,7 @@ mod tests {
         editor,
         test::{build_app_state, temp_tree},
         workspace::Workspace,
+        worktree::FakeFs,
     };
     use serde_json::json;
     use std::fs;
@@ -478,16 +477,13 @@ mod tests {
         });
 
         let app_state = cx.read(build_app_state);
-        let (window_id, workspace) = cx.add_window(|cx| {
-            let mut workspace = Workspace::new(
-                app_state.settings.clone(),
-                app_state.languages.clone(),
-                app_state.rpc.clone(),
-                cx,
-            );
-            workspace.add_worktree(tmp_dir.path(), cx);
-            workspace
-        });
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
+        workspace
+            .update(&mut cx, |workspace, cx| {
+                workspace.add_worktree(tmp_dir.path(), cx)
+            })
+            .await
+            .unwrap();
         cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
             .await;
         cx.dispatch_action(
@@ -540,26 +536,31 @@ mod tests {
 
     #[gpui::test]
     async fn test_matching_cancellation(mut cx: gpui::TestAppContext) {
-        let tmp_dir = temp_tree(json!({
-            "hello": "",
-            "goodbye": "",
-            "halogen-light": "",
-            "happiness": "",
-            "height": "",
-            "hi": "",
-            "hiccup": "",
-        }));
-        let app_state = cx.read(build_app_state);
-        let (_, workspace) = cx.add_window(|cx| {
-            let mut workspace = Workspace::new(
-                app_state.settings.clone(),
-                app_state.languages.clone(),
-                app_state.rpc.clone(),
-                cx,
-            );
-            workspace.add_worktree(tmp_dir.path(), cx);
-            workspace
-        });
+        let fs = Arc::new(FakeFs::new());
+        fs.insert_tree(
+            "/dir",
+            json!({
+                "hello": "",
+                "goodbye": "",
+                "halogen-light": "",
+                "happiness": "",
+                "height": "",
+                "hi": "",
+                "hiccup": "",
+            }),
+        )
+        .await;
+
+        let mut app_state = cx.read(build_app_state);
+        Arc::get_mut(&mut app_state).unwrap().fs = fs;
+
+        let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
+        workspace
+            .update(&mut cx, |workspace, cx| {
+                workspace.add_worktree("/dir".as_ref(), cx)
+            })
+            .await
+            .unwrap();
         cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
             .await;
         let (_, finder) =
@@ -613,16 +614,13 @@ mod tests {
         fs::write(&file_path, "").unwrap();
 
         let app_state = cx.read(build_app_state);
-        let (_, workspace) = cx.add_window(|cx| {
-            let mut workspace = Workspace::new(
-                app_state.settings.clone(),
-                app_state.languages.clone(),
-                app_state.rpc.clone(),
-                cx,
-            );
-            workspace.add_worktree(&file_path, cx);
-            workspace
-        });
+        let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
+        workspace
+            .update(&mut cx, |workspace, cx| {
+                workspace.add_worktree(&file_path, cx)
+            })
+            .await
+            .unwrap();
         cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
             .await;
         let (_, finder) =
@@ -663,15 +661,7 @@ mod tests {
         }));
 
         let app_state = cx.read(build_app_state);
-
-        let (_, workspace) = cx.add_window(|cx| {
-            Workspace::new(
-                app_state.settings.clone(),
-                app_state.languages.clone(),
-                app_state.rpc.clone(),
-                cx,
-            )
-        });
+        let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
 
         workspace
             .update(&mut cx, |workspace, cx| {

zed/src/lib.rs 🔗

@@ -1,4 +1,4 @@
-use zed_rpc::ForegroundRouter;
+use zrpc::ForegroundRouter;
 
 pub mod assets;
 pub mod editor;
@@ -21,6 +21,7 @@ pub struct AppState {
     pub languages: std::sync::Arc<language::LanguageRegistry>,
     pub rpc_router: std::sync::Arc<ForegroundRouter>,
     pub rpc: rpc::Client,
+    pub fs: std::sync::Arc<dyn worktree::Fs>,
 }
 
 pub fn init(cx: &mut gpui::MutableAppContext) {

zed/src/main.rs 🔗

@@ -8,9 +8,10 @@ use std::{fs, path::PathBuf, sync::Arc};
 use zed::{
     self, assets, editor, file_finder, language, menus, rpc, settings,
     workspace::{self, OpenParams},
-    worktree, AppState,
+    worktree::{self, RealFs},
+    AppState,
 };
-use zed_rpc::ForegroundRouter;
+use zrpc::ForegroundRouter;
 
 fn main() {
     init_logger();
@@ -26,6 +27,7 @@ fn main() {
         settings,
         rpc_router: Arc::new(ForegroundRouter::new()),
         rpc: rpc::Client::new(languages),
+        fs: Arc::new(RealFs),
     };
 
     app.run(move |cx| {

zed/src/rpc.rs 🔗

@@ -9,8 +9,8 @@ use std::collections::HashMap;
 use std::time::Duration;
 use std::{convert::TryFrom, future::Future, sync::Arc};
 use surf::Url;
-pub use zed_rpc::{proto, ConnectionId, PeerId, TypedEnvelope};
-use zed_rpc::{
+pub use zrpc::{proto, ConnectionId, PeerId, TypedEnvelope};
+use zrpc::{
     proto::{EnvelopedMessage, RequestMessage},
     ForegroundRouter, Peer, Receipt,
 };
@@ -158,7 +158,7 @@ impl Client {
             // zed server to encrypt the user's access token, so that it can'be intercepted by
             // any other app running on the user's device.
             let (public_key, private_key) =
-                zed_rpc::auth::keypair().expect("failed to generate keypair for auth");
+                zrpc::auth::keypair().expect("failed to generate keypair for auth");
             let public_key_string =
                 String::try_from(public_key).expect("failed to serialize public key for auth");
 

zed/src/test.rs 🔗

@@ -1,14 +1,16 @@
-use crate::{language::LanguageRegistry, rpc, settings, time::ReplicaId, AppState};
+use crate::{
+    language::LanguageRegistry, rpc, settings, time::ReplicaId, worktree::RealFs, AppState,
+};
 use gpui::AppContext;
 use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
 use tempdir::TempDir;
-use zed_rpc::ForegroundRouter;
+use zrpc::ForegroundRouter;
 
 #[cfg(feature = "test-support")]
-pub use zed_rpc::test::Channel;
+pub use zrpc::test::Channel;
 
 #[cfg(test)]
 #[ctor::ctor]
@@ -152,5 +154,6 @@ pub fn build_app_state(cx: &AppContext) -> Arc<AppState> {
         languages: languages.clone(),
         rpc_router: Arc::new(ForegroundRouter::new()),
         rpc: rpc::Client::new(languages),
+        fs: Arc::new(RealFs),
     })
 }

zed/src/time.rs 🔗

@@ -61,8 +61,8 @@ impl<'a> AddAssign<&'a Local> for Local {
 #[derive(Clone, Default, Hash, Eq, PartialEq)]
 pub struct Global(SmallVec<[Local; 3]>);
 
-impl From<Vec<zed_rpc::proto::VectorClockEntry>> for Global {
-    fn from(message: Vec<zed_rpc::proto::VectorClockEntry>) -> Self {
+impl From<Vec<zrpc::proto::VectorClockEntry>> for Global {
+    fn from(message: Vec<zrpc::proto::VectorClockEntry>) -> Self {
         let mut version = Self::new();
         for entry in message {
             version.observe(Local {
@@ -74,11 +74,11 @@ impl From<Vec<zed_rpc::proto::VectorClockEntry>> for Global {
     }
 }
 
-impl<'a> From<&'a Global> for Vec<zed_rpc::proto::VectorClockEntry> {
+impl<'a> From<&'a Global> for Vec<zrpc::proto::VectorClockEntry> {
     fn from(version: &'a Global) -> Self {
         version
             .iter()
-            .map(|entry| zed_rpc::proto::VectorClockEntry {
+            .map(|entry| zrpc::proto::VectorClockEntry {
                 replica_id: entry.replica_id as u32,
                 timestamp: entry.value,
             })

zed/src/workspace.rs 🔗

@@ -6,7 +6,7 @@ use crate::{
     language::LanguageRegistry,
     rpc,
     settings::Settings,
-    worktree::{File, Worktree},
+    worktree::{File, Fs, Worktree},
     AppState,
 };
 use anyhow::{anyhow, Result};
@@ -29,7 +29,10 @@ use std::{
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_global_action("workspace:open", open);
-    cx.add_global_action("workspace:open_paths", open_paths);
+    cx.add_global_action(
+        "workspace:open_paths",
+        |params: &OpenParams, cx: &mut MutableAppContext| open_paths(params, cx).detach(),
+    );
     cx.add_global_action("workspace:new_file", open_new);
     cx.add_global_action("workspace:join_worktree", join_worktree);
     cx.add_action("workspace:save", Workspace::save_active_item);
@@ -65,23 +68,23 @@ fn open(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
     );
 }
 
-fn open_paths(params: &OpenParams, cx: &mut MutableAppContext) {
+fn open_paths(params: &OpenParams, cx: &mut MutableAppContext) -> Task<()> {
     log::info!("open paths {:?}", params.paths);
 
     // Open paths in existing workspace if possible
     for window_id in cx.window_ids().collect::<Vec<_>>() {
         if let Some(handle) = cx.root_view::<Workspace>(window_id) {
-            if handle.update(cx, |view, cx| {
+            let task = handle.update(cx, |view, cx| {
                 if view.contains_paths(&params.paths, cx.as_ref()) {
-                    let open_paths = view.open_paths(&params.paths, cx);
-                    cx.foreground().spawn(open_paths).detach();
                     log::info!("open paths on existing workspace");
-                    true
+                    Some(view.open_paths(&params.paths, cx))
                 } else {
-                    false
+                    None
                 }
-            }) {
-                return;
+            });
+
+            if let Some(task) = task {
+                return task;
             }
         }
     }
@@ -89,27 +92,13 @@ fn open_paths(params: &OpenParams, cx: &mut MutableAppContext) {
     log::info!("open new workspace");
 
     // Add a new workspace if necessary
-    cx.add_window(|cx| {
-        let mut view = Workspace::new(
-            params.app_state.settings.clone(),
-            params.app_state.languages.clone(),
-            params.app_state.rpc.clone(),
-            cx,
-        );
-        let open_paths = view.open_paths(&params.paths, cx);
-        cx.foreground().spawn(open_paths).detach();
-        view
-    });
+    let (_, workspace) = cx.add_window(|cx| Workspace::new(&params.app_state, cx));
+    workspace.update(cx, |workspace, cx| workspace.open_paths(&params.paths, cx))
 }
 
 fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
     cx.add_window(|cx| {
-        let mut view = Workspace::new(
-            app_state.settings.clone(),
-            app_state.languages.clone(),
-            app_state.rpc.clone(),
-            cx,
-        );
+        let mut view = Workspace::new(app_state.as_ref(), cx);
         view.open_new_file(&app_state, cx);
         view
     });
@@ -117,12 +106,7 @@ fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
 
 fn join_worktree(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
     cx.add_window(|cx| {
-        let mut view = Workspace::new(
-            app_state.settings.clone(),
-            app_state.languages.clone(),
-            app_state.rpc.clone(),
-            cx,
-        );
+        let mut view = Workspace::new(app_state.as_ref(), cx);
         view.join_worktree(&app_state, cx);
         view
     });
@@ -328,6 +312,7 @@ pub struct Workspace {
     pub settings: watch::Receiver<Settings>,
     languages: Arc<LanguageRegistry>,
     rpc: rpc::Client,
+    fs: Arc<dyn Fs>,
     modal: Option<AnyViewHandle>,
     center: PaneGroup,
     panes: Vec<ViewHandle<Pane>>,
@@ -341,13 +326,8 @@ pub struct Workspace {
 }
 
 impl Workspace {
-    pub fn new(
-        settings: watch::Receiver<Settings>,
-        languages: Arc<LanguageRegistry>,
-        rpc: rpc::Client,
-        cx: &mut ViewContext<Self>,
-    ) -> Self {
-        let pane = cx.add_view(|_| Pane::new(settings.clone()));
+    pub fn new(app_state: &AppState, cx: &mut ViewContext<Self>) -> Self {
+        let pane = cx.add_view(|_| Pane::new(app_state.settings.clone()));
         let pane_id = pane.id();
         cx.subscribe_to_view(&pane, move |me, _, event, cx| {
             me.handle_pane_event(pane_id, event, cx)
@@ -359,9 +339,10 @@ impl Workspace {
             center: PaneGroup::new(pane.id()),
             panes: vec![pane.clone()],
             active_pane: pane.clone(),
-            settings,
-            languages: languages,
-            rpc,
+            settings: app_state.settings.clone(),
+            languages: app_state.languages.clone(),
+            rpc: app_state.rpc.clone(),
+            fs: app_state.fs.clone(),
             worktrees: Default::default(),
             items: Default::default(),
             loading_items: Default::default(),
@@ -400,87 +381,106 @@ impl Workspace {
         }
     }
 
-    pub fn open_paths(
-        &mut self,
-        abs_paths: &[PathBuf],
-        cx: &mut ViewContext<Self>,
-    ) -> impl Future<Output = ()> {
+    pub fn open_paths(&mut self, abs_paths: &[PathBuf], cx: &mut ViewContext<Self>) -> Task<()> {
         let entries = abs_paths
             .iter()
             .cloned()
             .map(|path| self.entry_id_for_path(&path, cx))
             .collect::<Vec<_>>();
 
-        let bg = cx.background_executor().clone();
+        let fs = self.fs.clone();
         let tasks = abs_paths
             .iter()
             .cloned()
             .zip(entries.into_iter())
             .map(|(abs_path, entry_id)| {
-                let is_file = bg.spawn(async move { abs_path.is_file() });
-                cx.spawn(|this, mut cx| async move {
-                    if is_file.await {
-                        return this.update(&mut cx, |this, cx| this.open_entry(entry_id, cx));
-                    } else {
-                        None
+                cx.spawn(|this, mut cx| {
+                    let fs = fs.clone();
+                    async move {
+                        let entry_id = entry_id.await?;
+                        if fs.is_file(&abs_path).await {
+                            if let Some(entry) =
+                                this.update(&mut cx, |this, cx| this.open_entry(entry_id, cx))
+                            {
+                                entry.await;
+                            }
+                        }
+                        Ok(())
                     }
                 })
             })
-            .collect::<Vec<_>>();
-        async move {
+            .collect::<Vec<Task<Result<()>>>>();
+
+        cx.foreground().spawn(async move {
             for task in tasks {
-                if let Some(task) = task.await {
-                    task.await;
+                if let Err(error) = task.await {
+                    log::error!("error opening paths {}", error);
                 }
             }
-        }
+        })
     }
 
     fn worktree_for_abs_path(
-        &mut self,
+        &self,
         abs_path: &Path,
         cx: &mut ViewContext<Self>,
-    ) -> (ModelHandle<Worktree>, PathBuf) {
-        for tree in self.worktrees.iter() {
-            if let Some(path) = tree
-                .read(cx)
-                .as_local()
-                .and_then(|tree| abs_path.strip_prefix(&tree.abs_path()).ok())
-            {
-                return (tree.clone(), path.to_path_buf());
+    ) -> Task<Result<(ModelHandle<Worktree>, PathBuf)>> {
+        let abs_path: Arc<Path> = Arc::from(abs_path);
+        cx.spawn(|this, mut cx| async move {
+            let mut entry_id = None;
+            this.read_with(&cx, |this, cx| {
+                for tree in this.worktrees.iter() {
+                    if let Some(relative_path) = tree
+                        .read(cx)
+                        .as_local()
+                        .and_then(|t| abs_path.strip_prefix(t.abs_path()).ok())
+                    {
+                        entry_id = Some((tree.clone(), relative_path.into()));
+                        break;
+                    }
+                }
+            });
+
+            if let Some(entry_id) = entry_id {
+                Ok(entry_id)
+            } else {
+                let worktree = this
+                    .update(&mut cx, |this, cx| this.add_worktree(&abs_path, cx))
+                    .await?;
+                Ok((worktree, PathBuf::new()))
             }
-        }
-        (self.add_worktree(abs_path, cx), PathBuf::new())
+        })
     }
 
     fn entry_id_for_path(
-        &mut self,
+        &self,
         abs_path: &Path,
         cx: &mut ViewContext<Self>,
-    ) -> (usize, Arc<Path>) {
-        for tree in self.worktrees.iter() {
-            if let Some(relative_path) = tree
-                .read(cx)
-                .as_local()
-                .and_then(|t| abs_path.strip_prefix(t.abs_path()).ok())
-            {
-                return (tree.id(), relative_path.into());
-            }
-        }
-        let worktree = self.add_worktree(&abs_path, cx);
-        (worktree.id(), Path::new("").into())
+    ) -> Task<Result<(usize, Arc<Path>)>> {
+        let entry = self.worktree_for_abs_path(abs_path, cx);
+        cx.spawn(|_, _| async move {
+            let (worktree, path) = entry.await?;
+            Ok((worktree.id(), path.into()))
+        })
     }
 
     pub fn add_worktree(
-        &mut self,
+        &self,
         path: &Path,
         cx: &mut ViewContext<Self>,
-    ) -> ModelHandle<Worktree> {
-        let worktree = cx.add_model(|cx| Worktree::local(path, self.languages.clone(), cx));
-        cx.observe_model(&worktree, |_, _, cx| cx.notify());
-        self.worktrees.insert(worktree.clone());
-        cx.notify();
-        worktree
+    ) -> Task<Result<ModelHandle<Worktree>>> {
+        let languages = self.languages.clone();
+        let fs = self.fs.clone();
+        let path = Arc::from(path);
+        cx.spawn(|this, mut cx| async move {
+            let worktree = Worktree::open_local(path, languages, fs, &mut cx).await?;
+            this.update(&mut cx, |this, cx| {
+                cx.observe_model(&worktree, |_, _, cx| cx.notify());
+                this.worktrees.insert(worktree.clone());
+                cx.notify();
+            });
+            Ok(worktree)
+        })
     }
 
     pub fn toggle_modal<V, F>(&mut self, cx: &mut ViewContext<Self>, add_view: F)
@@ -655,12 +655,22 @@ impl Workspace {
                 cx.prompt_for_new_path(&start_abs_path, move |abs_path, cx| {
                     if let Some(abs_path) = abs_path {
                         cx.spawn(|mut cx| async move {
-                            let result = handle
-                                .update(&mut cx, |me, cx| {
-                                    let (worktree, path) = me.worktree_for_abs_path(&abs_path, cx);
-                                    item.save_as(&worktree, &path, cx.as_mut())
+                            let result = match handle
+                                .update(&mut cx, |this, cx| {
+                                    this.worktree_for_abs_path(&abs_path, cx)
                                 })
-                                .await;
+                                .await
+                            {
+                                Ok((worktree, path)) => {
+                                    handle
+                                        .update(&mut cx, |_, cx| {
+                                            item.save_as(&worktree, &path, cx.as_mut())
+                                        })
+                                        .await
+                                }
+                                Err(error) => Err(error),
+                            };
+
                             if let Err(error) = result {
                                 error!("failed to save item: {:?}, ", error);
                             }
@@ -912,18 +922,15 @@ mod tests {
     use crate::{
         editor::Editor,
         test::{build_app_state, temp_tree},
-        worktree::WorktreeHandle,
+        worktree::{FakeFs, WorktreeHandle},
     };
     use serde_json::json;
     use std::{collections::HashSet, fs};
     use tempdir::TempDir;
 
     #[gpui::test]
-    fn test_open_paths_action(cx: &mut gpui::MutableAppContext) {
-        let app_state = build_app_state(cx.as_ref());
-
-        init(cx);
-
+    async fn test_open_paths_action(mut cx: gpui::TestAppContext) {
+        let app_state = cx.read(build_app_state);
         let dir = temp_tree(json!({
             "a": {
                 "aa": null,
@@ -939,42 +946,51 @@ mod tests {
             },
         }));
 
-        cx.dispatch_global_action(
-            "workspace:open_paths",
-            OpenParams {
-                paths: vec![
-                    dir.path().join("a").to_path_buf(),
-                    dir.path().join("b").to_path_buf(),
-                ],
-                app_state: app_state.clone(),
-            },
-        );
-        assert_eq!(cx.window_ids().count(), 1);
-
-        cx.dispatch_global_action(
-            "workspace:open_paths",
-            OpenParams {
-                paths: vec![dir.path().join("a").to_path_buf()],
-                app_state: app_state.clone(),
-            },
-        );
-        assert_eq!(cx.window_ids().count(), 1);
-        let workspace_view_1 = cx
-            .root_view::<Workspace>(cx.window_ids().next().unwrap())
-            .unwrap();
-        assert_eq!(workspace_view_1.read(cx).worktrees().len(), 2);
-
-        cx.dispatch_global_action(
-            "workspace:open_paths",
-            OpenParams {
-                paths: vec![
-                    dir.path().join("b").to_path_buf(),
-                    dir.path().join("c").to_path_buf(),
-                ],
-                app_state: app_state.clone(),
-            },
-        );
-        assert_eq!(cx.window_ids().count(), 2);
+        cx.update(|cx| {
+            open_paths(
+                &OpenParams {
+                    paths: vec![
+                        dir.path().join("a").to_path_buf(),
+                        dir.path().join("b").to_path_buf(),
+                    ],
+                    app_state: app_state.clone(),
+                },
+                cx,
+            )
+        })
+        .await;
+        assert_eq!(cx.window_ids().len(), 1);
+
+        cx.update(|cx| {
+            open_paths(
+                &OpenParams {
+                    paths: vec![dir.path().join("a").to_path_buf()],
+                    app_state: app_state.clone(),
+                },
+                cx,
+            )
+        })
+        .await;
+        assert_eq!(cx.window_ids().len(), 1);
+        let workspace_view_1 = cx.root_view::<Workspace>(cx.window_ids()[0]).unwrap();
+        workspace_view_1.read_with(&cx, |workspace, _| {
+            assert_eq!(workspace.worktrees().len(), 2)
+        });
+
+        cx.update(|cx| {
+            open_paths(
+                &OpenParams {
+                    paths: vec![
+                        dir.path().join("b").to_path_buf(),
+                        dir.path().join("c").to_path_buf(),
+                    ],
+                    app_state: app_state.clone(),
+                },
+                cx,
+            )
+        })
+        .await;
+        assert_eq!(cx.window_ids().len(), 2);
     }
 
     #[gpui::test]
@@ -989,16 +1005,13 @@ mod tests {
 
         let app_state = cx.read(build_app_state);
 
-        let (_, workspace) = cx.add_window(|cx| {
-            let mut workspace = Workspace::new(
-                app_state.settings.clone(),
-                app_state.languages.clone(),
-                app_state.rpc.clone(),
-                cx,
-            );
-            workspace.add_worktree(dir.path(), cx);
-            workspace
-        });
+        let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
+        workspace
+            .update(&mut cx, |workspace, cx| {
+                workspace.add_worktree(dir.path(), cx)
+            })
+            .await
+            .unwrap();
 
         cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
             .await;
@@ -1088,32 +1101,28 @@ mod tests {
 
     #[gpui::test]
     async fn test_open_paths(mut cx: gpui::TestAppContext) {
-        let dir1 = temp_tree(json!({
-            "a.txt": "",
-        }));
-        let dir2 = temp_tree(json!({
-            "b.txt": "",
-        }));
+        let fs = FakeFs::new();
+        fs.insert_dir("/dir1").await.unwrap();
+        fs.insert_dir("/dir2").await.unwrap();
+        fs.insert_file("/dir1/a.txt", "".into()).await.unwrap();
+        fs.insert_file("/dir2/b.txt", "".into()).await.unwrap();
 
-        let app_state = cx.read(build_app_state);
-        let (_, workspace) = cx.add_window(|cx| {
-            let mut workspace = Workspace::new(
-                app_state.settings.clone(),
-                app_state.languages.clone(),
-                app_state.rpc.clone(),
-                cx,
-            );
-            workspace.add_worktree(dir1.path(), cx);
-            workspace
-        });
+        let mut app_state = cx.read(build_app_state);
+        Arc::get_mut(&mut app_state).unwrap().fs = Arc::new(fs);
+
+        let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
+        workspace
+            .update(&mut cx, |workspace, cx| {
+                workspace.add_worktree("/dir1".as_ref(), cx)
+            })
+            .await
+            .unwrap();
         cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
             .await;
 
         // Open a file within an existing worktree.
         cx.update(|cx| {
-            workspace.update(cx, |view, cx| {
-                view.open_paths(&[dir1.path().join("a.txt")], cx)
-            })
+            workspace.update(cx, |view, cx| view.open_paths(&["/dir1/a.txt".into()], cx))
         })
         .await;
         cx.read(|cx| {
@@ -1131,9 +1140,7 @@ mod tests {
 
         // Open a file outside of any existing worktree.
         cx.update(|cx| {
-            workspace.update(cx, |view, cx| {
-                view.open_paths(&[dir2.path().join("b.txt")], cx)
-            })
+            workspace.update(cx, |view, cx| view.open_paths(&["/dir2/b.txt".into()], cx))
         })
         .await;
         cx.read(|cx| {
@@ -1145,8 +1152,9 @@ mod tests {
                 .collect::<HashSet<_>>();
             assert_eq!(
                 worktree_roots,
-                vec![dir1.path(), &dir2.path().join("b.txt")]
+                vec!["/dir1", "/dir2/b.txt"]
                     .into_iter()
+                    .map(Path::new)
                     .collect(),
             );
             assert_eq!(
@@ -1169,16 +1177,13 @@ mod tests {
         }));
 
         let app_state = cx.read(build_app_state);
-        let (window_id, workspace) = cx.add_window(|cx| {
-            let mut workspace = Workspace::new(
-                app_state.settings.clone(),
-                app_state.languages.clone(),
-                app_state.rpc.clone(),
-                cx,
-            );
-            workspace.add_worktree(dir.path(), cx);
-            workspace
-        });
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
+        workspace
+            .update(&mut cx, |workspace, cx| {
+                workspace.add_worktree(dir.path(), cx)
+            })
+            .await
+            .unwrap();
         let tree = cx.read(|cx| {
             let mut trees = workspace.read(cx).worktrees().iter();
             trees.next().unwrap().clone()
@@ -1217,16 +1222,13 @@ mod tests {
     async fn test_open_and_save_new_file(mut cx: gpui::TestAppContext) {
         let dir = TempDir::new("test-new-file").unwrap();
         let app_state = cx.read(build_app_state);
-        let (_, workspace) = cx.add_window(|cx| {
-            let mut workspace = Workspace::new(
-                app_state.settings.clone(),
-                app_state.languages.clone(),
-                app_state.rpc.clone(),
-                cx,
-            );
-            workspace.add_worktree(dir.path(), cx);
-            workspace
-        });
+        let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
+        workspace
+            .update(&mut cx, |workspace, cx| {
+                workspace.add_worktree(dir.path(), cx)
+            })
+            .await
+            .unwrap();
         let tree = cx.read(|cx| {
             workspace
                 .read(cx)
@@ -1342,16 +1344,13 @@ mod tests {
         }));
 
         let app_state = cx.read(build_app_state);
-        let (window_id, workspace) = cx.add_window(|cx| {
-            let mut workspace = Workspace::new(
-                app_state.settings.clone(),
-                app_state.languages.clone(),
-                app_state.rpc.clone(),
-                cx,
-            );
-            workspace.add_worktree(dir.path(), cx);
-            workspace
-        });
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx));
+        workspace
+            .update(&mut cx, |workspace, cx| {
+                workspace.add_worktree(dir.path(), cx)
+            })
+            .await
+            .unwrap();
         cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
             .await;
         let entries = cx.read(|cx| workspace.file_entries(cx));

zed/src/worktree.rs 🔗

@@ -1,4 +1,5 @@
 mod char_bag;
+mod fs;
 mod fuzzy;
 mod ignore;
 
@@ -12,8 +13,8 @@ use crate::{
     util::Bias,
 };
 use ::ignore::gitignore::Gitignore;
-use anyhow::{anyhow, Context, Result};
-use atomic::Ordering::SeqCst;
+use anyhow::{anyhow, Result};
+pub use fs::*;
 use futures::{Stream, StreamExt};
 pub use fuzzy::{match_paths, PathMatch};
 use gpui::{
@@ -26,10 +27,7 @@ use postage::{
     prelude::{Sink as _, Stream as _},
     watch,
 };
-use smol::{
-    channel::{self, Sender},
-    io::{AsyncReadExt, AsyncWriteExt},
-};
+use smol::channel::{self, Sender};
 use std::{
     cmp::{self, Ordering},
     collections::HashMap,
@@ -37,18 +35,12 @@ use std::{
     ffi::{OsStr, OsString},
     fmt,
     future::Future,
-    io,
     ops::Deref,
-    os::unix::fs::MetadataExt,
     path::{Path, PathBuf},
-    pin::Pin,
-    sync::{
-        atomic::{self, AtomicUsize},
-        Arc,
-    },
+    sync::{atomic::AtomicUsize, Arc},
     time::{Duration, SystemTime},
 };
-use zed_rpc::{ForegroundRouter, PeerId, TypedEnvelope};
+use zrpc::{ForegroundRouter, PeerId, TypedEnvelope};
 
 lazy_static! {
     static ref GITIGNORE: &'static OsStr = OsStr::new(".gitignore");
@@ -65,370 +57,6 @@ pub fn init(cx: &mut MutableAppContext, rpc: &rpc::Client, router: &mut Foregrou
     rpc.on_message(router, remote::save_buffer, cx);
 }
 
-#[async_trait::async_trait]
-pub trait Fs: Send + Sync {
-    async fn entry(
-        &self,
-        root_char_bag: CharBag,
-        next_entry_id: &AtomicUsize,
-        path: Arc<Path>,
-        abs_path: &Path,
-    ) -> Result<Option<Entry>>;
-    async fn child_entries<'a>(
-        &self,
-        root_char_bag: CharBag,
-        next_entry_id: &'a AtomicUsize,
-        path: &'a Path,
-        abs_path: &'a Path,
-    ) -> Result<Pin<Box<dyn 'a + Stream<Item = Result<Entry>> + Send>>>;
-    async fn load(&self, path: &Path) -> Result<String>;
-    async fn save(&self, path: &Path, text: &Rope) -> Result<()>;
-    async fn canonicalize(&self, path: &Path) -> Result<PathBuf>;
-}
-
-struct ProductionFs;
-
-#[async_trait::async_trait]
-impl Fs for ProductionFs {
-    async fn entry(
-        &self,
-        root_char_bag: CharBag,
-        next_entry_id: &AtomicUsize,
-        path: Arc<Path>,
-        abs_path: &Path,
-    ) -> Result<Option<Entry>> {
-        let metadata = match smol::fs::metadata(&abs_path).await {
-            Err(err) => {
-                return match (err.kind(), err.raw_os_error()) {
-                    (io::ErrorKind::NotFound, _) => Ok(None),
-                    (io::ErrorKind::Other, Some(libc::ENOTDIR)) => Ok(None),
-                    _ => Err(anyhow::Error::new(err)),
-                }
-            }
-            Ok(metadata) => metadata,
-        };
-        let inode = metadata.ino();
-        let mtime = metadata.modified()?;
-        let is_symlink = smol::fs::symlink_metadata(&abs_path)
-            .await
-            .context("failed to read symlink metadata")?
-            .file_type()
-            .is_symlink();
-
-        let entry = Entry {
-            id: next_entry_id.fetch_add(1, SeqCst),
-            kind: if metadata.file_type().is_dir() {
-                EntryKind::PendingDir
-            } else {
-                EntryKind::File(char_bag_for_path(root_char_bag, &path))
-            },
-            path: Arc::from(path),
-            inode,
-            mtime,
-            is_symlink,
-            is_ignored: false,
-        };
-
-        Ok(Some(entry))
-    }
-
-    async fn child_entries<'a>(
-        &self,
-        root_char_bag: CharBag,
-        next_entry_id: &'a AtomicUsize,
-        path: &'a Path,
-        abs_path: &'a Path,
-    ) -> Result<Pin<Box<dyn 'a + Stream<Item = Result<Entry>> + Send>>> {
-        let entries = smol::fs::read_dir(abs_path).await?;
-        Ok(entries
-            .then(move |entry| async move {
-                let child_entry = entry?;
-                let child_name = child_entry.file_name();
-                let child_path: Arc<Path> = path.join(&child_name).into();
-                let child_abs_path = abs_path.join(&child_name);
-                let child_is_symlink = child_entry.metadata().await?.file_type().is_symlink();
-                let child_metadata = smol::fs::metadata(child_abs_path).await?;
-                let child_inode = child_metadata.ino();
-                let child_mtime = child_metadata.modified()?;
-                Ok(Entry {
-                    id: next_entry_id.fetch_add(1, SeqCst),
-                    kind: if child_metadata.file_type().is_dir() {
-                        EntryKind::PendingDir
-                    } else {
-                        EntryKind::File(char_bag_for_path(root_char_bag, &child_path))
-                    },
-                    path: child_path,
-                    inode: child_inode,
-                    mtime: child_mtime,
-                    is_symlink: child_is_symlink,
-                    is_ignored: false,
-                })
-            })
-            .boxed())
-    }
-
-    async fn load(&self, path: &Path) -> Result<String> {
-        let mut file = smol::fs::File::open(path).await?;
-        let mut text = String::new();
-        file.read_to_string(&mut text).await?;
-        Ok(text)
-    }
-
-    async fn save(&self, path: &Path, text: &Rope) -> Result<()> {
-        let buffer_size = text.summary().bytes.min(10 * 1024);
-        let file = smol::fs::File::create(path).await?;
-        let mut writer = smol::io::BufWriter::with_capacity(buffer_size, file);
-        for chunk in text.chunks() {
-            writer.write_all(chunk.as_bytes()).await?;
-        }
-        writer.flush().await?;
-        Ok(())
-    }
-
-    async fn canonicalize(&self, path: &Path) -> Result<PathBuf> {
-        Ok(smol::fs::canonicalize(path).await?)
-    }
-}
-
-#[derive(Clone, Debug)]
-struct InMemoryEntry {
-    inode: u64,
-    mtime: SystemTime,
-    is_dir: bool,
-    is_symlink: bool,
-    content: Option<String>,
-}
-
-#[cfg(any(test, feature = "test-support"))]
-struct InMemoryFsState {
-    entries: std::collections::BTreeMap<PathBuf, InMemoryEntry>,
-    next_inode: u64,
-    events_tx: postage::broadcast::Sender<fsevent::Event>,
-}
-
-#[cfg(any(test, feature = "test-support"))]
-impl InMemoryFsState {
-    fn validate_path(&self, path: &Path) -> Result<()> {
-        if path.is_absolute()
-            && path
-                .parent()
-                .and_then(|path| self.entries.get(path))
-                .map_or(false, |e| e.is_dir)
-        {
-            Ok(())
-        } else {
-            Err(anyhow!("invalid path {:?}", path))
-        }
-    }
-
-    async fn emit_event(&mut self, path: &Path) {
-        let _ = self
-            .events_tx
-            .send(fsevent::Event {
-                event_id: 0,
-                flags: fsevent::StreamFlags::empty(),
-                path: path.to_path_buf(),
-            })
-            .await;
-    }
-}
-
-#[cfg(any(test, feature = "test-support"))]
-pub struct InMemoryFs {
-    state: smol::lock::RwLock<InMemoryFsState>,
-}
-
-#[cfg(any(test, feature = "test-support"))]
-impl InMemoryFs {
-    pub fn new() -> Self {
-        let (events_tx, _) = postage::broadcast::channel(2048);
-        let mut entries = std::collections::BTreeMap::new();
-        entries.insert(
-            Path::new("/").to_path_buf(),
-            InMemoryEntry {
-                inode: 0,
-                mtime: SystemTime::now(),
-                is_dir: true,
-                is_symlink: false,
-                content: None,
-            },
-        );
-        Self {
-            state: smol::lock::RwLock::new(InMemoryFsState {
-                entries,
-                next_inode: 1,
-                events_tx,
-            }),
-        }
-    }
-
-    pub async fn insert_dir(&self, path: &Path) -> Result<()> {
-        let mut state = self.state.write().await;
-        state.validate_path(path)?;
-
-        let inode = state.next_inode;
-        state.next_inode += 1;
-        state.entries.insert(
-            path.to_path_buf(),
-            InMemoryEntry {
-                inode,
-                mtime: SystemTime::now(),
-                is_dir: true,
-                is_symlink: false,
-                content: None,
-            },
-        );
-        state.emit_event(path).await;
-        Ok(())
-    }
-
-    pub async fn remove(&self, path: &Path) -> Result<()> {
-        let mut state = self.state.write().await;
-        state.validate_path(path)?;
-        state.entries.retain(|path, _| !path.starts_with(path));
-        state.emit_event(&path).await;
-        Ok(())
-    }
-
-    pub async fn rename(&self, source: &Path, target: &Path) -> Result<()> {
-        let mut state = self.state.write().await;
-        state.validate_path(source)?;
-        state.validate_path(target)?;
-        if state.entries.contains_key(target) {
-            Err(anyhow!("target path already exists"))
-        } else {
-            let mut removed = Vec::new();
-            state.entries.retain(|path, entry| {
-                if let Ok(relative_path) = path.strip_prefix(source) {
-                    removed.push((relative_path.to_path_buf(), entry.clone()));
-                    false
-                } else {
-                    true
-                }
-            });
-
-            for (relative_path, entry) in removed {
-                let new_path = target.join(relative_path);
-                state.entries.insert(new_path, entry);
-            }
-
-            state.emit_event(source).await;
-            state.emit_event(target).await;
-
-            Ok(())
-        }
-    }
-
-    pub async fn events(&self) -> postage::broadcast::Receiver<fsevent::Event> {
-        self.state.read().await.events_tx.subscribe()
-    }
-}
-
-#[cfg(any(test, feature = "test-support"))]
-#[async_trait::async_trait]
-impl Fs for InMemoryFs {
-    async fn entry(
-        &self,
-        root_char_bag: CharBag,
-        next_entry_id: &AtomicUsize,
-        path: Arc<Path>,
-        abs_path: &Path,
-    ) -> Result<Option<Entry>> {
-        let state = self.state.read().await;
-        if let Some(entry) = state.entries.get(abs_path) {
-            Ok(Some(Entry {
-                id: next_entry_id.fetch_add(1, SeqCst),
-                kind: if entry.is_dir {
-                    EntryKind::PendingDir
-                } else {
-                    EntryKind::File(char_bag_for_path(root_char_bag, &path))
-                },
-                path: Arc::from(path),
-                inode: entry.inode,
-                mtime: entry.mtime,
-                is_symlink: entry.is_symlink,
-                is_ignored: false,
-            }))
-        } else {
-            Ok(None)
-        }
-    }
-
-    async fn child_entries<'a>(
-        &self,
-        root_char_bag: CharBag,
-        next_entry_id: &'a AtomicUsize,
-        path: &'a Path,
-        abs_path: &'a Path,
-    ) -> Result<Pin<Box<dyn 'a + Stream<Item = Result<Entry>> + Send>>> {
-        use futures::{future, stream};
-
-        let state = self.state.read().await;
-        Ok(stream::iter(state.entries.clone())
-            .filter(move |(child_path, _)| future::ready(child_path.parent() == Some(abs_path)))
-            .then(move |(child_abs_path, child_entry)| async move {
-                smol::future::yield_now().await;
-                let child_path = Arc::from(path.join(child_abs_path.file_name().unwrap()));
-                Ok(Entry {
-                    id: next_entry_id.fetch_add(1, SeqCst),
-                    kind: if child_entry.is_dir {
-                        EntryKind::PendingDir
-                    } else {
-                        EntryKind::File(char_bag_for_path(root_char_bag, &child_path))
-                    },
-                    path: child_path,
-                    inode: child_entry.inode,
-                    mtime: child_entry.mtime,
-                    is_symlink: child_entry.is_symlink,
-                    is_ignored: false,
-                })
-            })
-            .boxed())
-    }
-
-    async fn load(&self, path: &Path) -> Result<String> {
-        let state = self.state.read().await;
-        let text = state
-            .entries
-            .get(path)
-            .and_then(|e| e.content.as_ref())
-            .ok_or_else(|| anyhow!("file {:?} does not exist", path))?;
-        Ok(text.clone())
-    }
-
-    async fn save(&self, path: &Path, text: &Rope) -> Result<()> {
-        let mut state = self.state.write().await;
-        state.validate_path(path)?;
-        if let Some(entry) = state.entries.get_mut(path) {
-            if entry.is_dir {
-                Err(anyhow!("cannot overwrite a directory with a file"))
-            } else {
-                entry.content = Some(text.chunks().collect());
-                entry.mtime = SystemTime::now();
-                state.emit_event(path).await;
-                Ok(())
-            }
-        } else {
-            let inode = state.next_inode;
-            state.next_inode += 1;
-            let entry = InMemoryEntry {
-                inode,
-                mtime: SystemTime::now(),
-                is_dir: false,
-                is_symlink: false,
-                content: Some(text.chunks().collect()),
-            };
-            state.entries.insert(path.to_path_buf(), entry);
-            state.emit_event(path).await;
-            Ok(())
-        }
-    }
-
-    async fn canonicalize(&self, path: &Path) -> Result<PathBuf> {
-        Ok(path.to_path_buf())
-    }
-}
-
 #[derive(Clone, Debug)]
 enum ScanState {
     Idle,
@@ -467,53 +95,26 @@ impl Entity for Worktree {
 }
 
 impl Worktree {
-    pub fn local(
+    pub async fn open_local(
         path: impl Into<Arc<Path>>,
         languages: Arc<LanguageRegistry>,
-        cx: &mut ModelContext<Worktree>,
-    ) -> Self {
-        let fs = Arc::new(ProductionFs);
-        let (mut tree, scan_states_tx) =
-            LocalWorktree::new(path, languages, fs.clone(), Duration::from_millis(100), cx);
-        let (event_stream, event_stream_handle) = fsevent::EventStream::new(
-            &[tree.snapshot.abs_path.as_ref()],
-            Duration::from_millis(100),
-        );
-        let background_snapshot = tree.background_snapshot.clone();
-        std::thread::spawn(move || {
-            let scanner = BackgroundScanner::new(
-                background_snapshot,
-                scan_states_tx,
-                fs,
-                Arc::new(executor::Background::new()),
-            );
-            scanner.run(event_stream);
-        });
-        tree._event_stream_handle = Some(event_stream_handle);
-        Worktree::Local(tree)
-    }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn test(
-        path: impl Into<Arc<Path>>,
-        languages: Arc<LanguageRegistry>,
-        fs: Arc<InMemoryFs>,
-        cx: &mut ModelContext<Worktree>,
-    ) -> Self {
-        let (tree, scan_states_tx) =
-            LocalWorktree::new(path, languages, fs.clone(), Duration::ZERO, cx);
-        let background_snapshot = tree.background_snapshot.clone();
-        let fs = fs.clone();
-        let background = cx.background().clone();
-        cx.background()
-            .spawn(async move {
-                let events_rx = fs.events().await;
+        fs: Arc<dyn Fs>,
+        cx: &mut AsyncAppContext,
+    ) -> Result<ModelHandle<Self>> {
+        let (tree, scan_states_tx) = LocalWorktree::new(path, languages, fs.clone(), cx).await?;
+        tree.update(cx, |tree, cx| {
+            let tree = tree.as_local_mut().unwrap();
+            let abs_path = tree.snapshot.abs_path.clone();
+            let background_snapshot = tree.background_snapshot.clone();
+            let background = cx.background().clone();
+            tree._background_scanner_task = Some(cx.background().spawn(async move {
+                let events = fs.watch(&abs_path, Duration::from_millis(100)).await;
                 let scanner =
                     BackgroundScanner::new(background_snapshot, scan_states_tx, fs, background);
-                scanner.run_test(events_rx).await;
-            })
-            .detach();
-        Worktree::Local(tree)
+                scanner.run(events).await;
+            }));
+        });
+        Ok(tree)
     }
 
     pub async fn open_remote(
@@ -831,25 +432,24 @@ impl Worktree {
     fn poll_snapshot(&mut self, cx: &mut ModelContext<Self>) {
         match self {
             Self::Local(worktree) => {
-                let poll_interval = worktree.poll_interval;
+                let is_fake_fs = worktree.fs.is_fake();
                 worktree.snapshot = worktree.background_snapshot.lock().clone();
                 if worktree.is_scanning() {
-                    if !worktree.poll_scheduled {
-                        cx.spawn(|this, mut cx| async move {
-                            if poll_interval.is_zero() {
+                    if worktree.poll_task.is_none() {
+                        worktree.poll_task = Some(cx.spawn(|this, mut cx| async move {
+                            if is_fake_fs {
                                 smol::future::yield_now().await;
                             } else {
-                                smol::Timer::after(poll_interval).await;
+                                smol::Timer::after(Duration::from_millis(100)).await;
                             }
                             this.update(&mut cx, |this, cx| {
-                                this.as_local_mut().unwrap().poll_scheduled = false;
+                                this.as_local_mut().unwrap().poll_task = None;
                                 this.poll_snapshot(cx);
                             })
-                        })
-                        .detach();
-                        worktree.poll_scheduled = true;
+                        }));
                     }
                 } else {
+                    worktree.poll_task.take();
                     self.update_open_buffers(cx);
                 }
             }
@@ -961,83 +561,110 @@ pub struct LocalWorktree {
     background_snapshot: Arc<Mutex<Snapshot>>,
     snapshots_to_send_tx: Option<Sender<Snapshot>>,
     last_scan_state_rx: watch::Receiver<ScanState>,
-    _event_stream_handle: Option<fsevent::Handle>,
-    poll_scheduled: bool,
+    _background_scanner_task: Option<Task<()>>,
+    poll_task: Option<Task<()>>,
     rpc: Option<(rpc::Client, u64)>,
     open_buffers: HashMap<usize, WeakModelHandle<Buffer>>,
     shared_buffers: HashMap<PeerId, HashMap<u64, ModelHandle<Buffer>>>,
     peers: HashMap<PeerId, ReplicaId>,
     languages: Arc<LanguageRegistry>,
     fs: Arc<dyn Fs>,
-    poll_interval: Duration,
 }
 
 impl LocalWorktree {
-    fn new(
+    async fn new(
         path: impl Into<Arc<Path>>,
         languages: Arc<LanguageRegistry>,
         fs: Arc<dyn Fs>,
-        poll_interval: Duration,
-        cx: &mut ModelContext<Worktree>,
-    ) -> (Self, Sender<ScanState>) {
+        cx: &mut AsyncAppContext,
+    ) -> Result<(ModelHandle<Worktree>, Sender<ScanState>)> {
         let abs_path = path.into();
+        let path: Arc<Path> = Arc::from(Path::new(""));
+        let next_entry_id = AtomicUsize::new(0);
+
+        // After determining whether the root entry is a file or a directory, populate the
+        // snapshot's "root name", which will be used for the purpose of fuzzy matching.
+        let mut root_name = abs_path
+            .file_name()
+            .map_or(String::new(), |f| f.to_string_lossy().to_string());
+        let root_char_bag = root_name.chars().map(|c| c.to_ascii_lowercase()).collect();
+        let entry = fs
+            .entry(root_char_bag, &next_entry_id, path.clone(), &abs_path)
+            .await?
+            .ok_or_else(|| anyhow!("root entry does not exist"))?;
+        let is_dir = entry.is_dir();
+        if is_dir {
+            root_name.push('/');
+        }
+
         let (scan_states_tx, scan_states_rx) = smol::channel::unbounded();
         let (mut last_scan_state_tx, last_scan_state_rx) = watch::channel_with(ScanState::Scanning);
-        let id = cx.model_id();
-        let snapshot = Snapshot {
-            id,
-            scan_id: 0,
-            abs_path,
-            root_name: Default::default(),
-            root_char_bag: Default::default(),
-            ignores: Default::default(),
-            entries_by_path: Default::default(),
-            entries_by_id: Default::default(),
-            removed_entry_ids: Default::default(),
-            next_entry_id: Default::default(),
-        };
-
-        let tree = Self {
-            snapshot: snapshot.clone(),
-            background_snapshot: Arc::new(Mutex::new(snapshot)),
-            snapshots_to_send_tx: None,
-            last_scan_state_rx,
-            _event_stream_handle: None,
-            poll_scheduled: false,
-            open_buffers: Default::default(),
-            shared_buffers: Default::default(),
-            peers: Default::default(),
-            rpc: None,
-            languages,
-            fs,
-            poll_interval,
-        };
+        let tree = cx.add_model(move |cx: &mut ModelContext<Worktree>| {
+            let mut snapshot = Snapshot {
+                id: cx.model_id(),
+                scan_id: 0,
+                abs_path,
+                root_name,
+                root_char_bag,
+                ignores: Default::default(),
+                entries_by_path: Default::default(),
+                entries_by_id: Default::default(),
+                removed_entry_ids: Default::default(),
+                next_entry_id: Arc::new(next_entry_id),
+            };
+            snapshot.insert_entry(entry);
+
+            let tree = Self {
+                snapshot: snapshot.clone(),
+                background_snapshot: Arc::new(Mutex::new(snapshot)),
+                snapshots_to_send_tx: None,
+                last_scan_state_rx,
+                _background_scanner_task: None,
+                poll_task: None,
+                open_buffers: Default::default(),
+                shared_buffers: Default::default(),
+                peers: Default::default(),
+                rpc: None,
+                languages,
+                fs,
+            };
 
-        cx.spawn_weak(|this, mut cx| async move {
-            while let Ok(scan_state) = scan_states_rx.recv().await {
-                if let Some(handle) = cx.read(|cx| this.upgrade(&cx)) {
-                    handle.update(&mut cx, |this, cx| {
-                        last_scan_state_tx.blocking_send(scan_state).ok();
-                        this.poll_snapshot(cx);
-                        let tree = this.as_local_mut().unwrap();
-                        if !tree.is_scanning() {
-                            if let Some(snapshots_to_send_tx) = tree.snapshots_to_send_tx.clone() {
-                                if let Err(err) =
-                                    smol::block_on(snapshots_to_send_tx.send(tree.snapshot()))
+            cx.spawn_weak(|this, mut cx| async move {
+                while let Ok(scan_state) = scan_states_rx.recv().await {
+                    if let Some(handle) = cx.read(|cx| this.upgrade(&cx)) {
+                        let to_send = handle.update(&mut cx, |this, cx| {
+                            last_scan_state_tx.blocking_send(scan_state).ok();
+                            this.poll_snapshot(cx);
+                            let tree = this.as_local_mut().unwrap();
+                            if !tree.is_scanning() {
+                                if let Some(snapshots_to_send_tx) =
+                                    tree.snapshots_to_send_tx.clone()
                                 {
-                                    log::error!("error submitting snapshot to send {}", err);
+                                    Some((tree.snapshot(), snapshots_to_send_tx))
+                                } else {
+                                    None
                                 }
+                            } else {
+                                None
+                            }
+                        });
+
+                        if let Some((snapshot, snapshots_to_send_tx)) = to_send {
+                            if let Err(err) = snapshots_to_send_tx.send(snapshot).await {
+                                log::error!("error submitting snapshot to send {}", err);
                             }
                         }
-                    });
-                } else {
-                    break;
+                    } else {
+                        break;
+                    }
                 }
-            }
-        })
-        .detach();
+            })
+            .detach();
+
+            Worktree::Local(tree)
+        });
 
-        (tree, scan_states_tx)
+        Ok((tree, scan_states_tx))
     }
 
     pub fn open_buffer(
@@ -2158,40 +1785,7 @@ impl BackgroundScanner {
         self.snapshot.lock().clone()
     }
 
-    fn run(mut self, event_stream: fsevent::EventStream) {
-        if smol::block_on(self.notify.send(ScanState::Scanning)).is_err() {
-            return;
-        }
-
-        if let Err(err) = smol::block_on(self.scan_dirs()) {
-            if smol::block_on(self.notify.send(ScanState::Err(Arc::new(err)))).is_err() {
-                return;
-            }
-        }
-
-        if smol::block_on(self.notify.send(ScanState::Idle)).is_err() {
-            return;
-        }
-
-        event_stream.run(move |events| {
-            if smol::block_on(self.notify.send(ScanState::Scanning)).is_err() {
-                return false;
-            }
-
-            if !smol::block_on(self.process_events(events)) {
-                return false;
-            }
-
-            if smol::block_on(self.notify.send(ScanState::Idle)).is_err() {
-                return false;
-            }
-
-            true
-        });
-    }
-
-    #[cfg(any(test, feature = "test-support"))]
-    async fn run_test(mut self, mut events_rx: postage::broadcast::Receiver<fsevent::Event>) {
+    async fn run(mut self, events_rx: impl Stream<Item = Vec<fsevent::Event>>) {
         if self.notify.send(ScanState::Scanning).await.is_err() {
             return;
         }
@@ -2211,12 +1805,8 @@ impl BackgroundScanner {
             return;
         }
 
-        while let Some(event) = events_rx.recv().await {
-            let mut events = vec![event];
-            while let Ok(event) = events_rx.try_recv() {
-                events.push(event);
-            }
-
+        futures::pin_mut!(events_rx);
+        while let Some(events) = events_rx.next().await {
             if self.notify.send(ScanState::Scanning).await.is_err() {
                 break;
             }
@@ -2232,40 +1822,19 @@ impl BackgroundScanner {
     }
 
     async fn scan_dirs(&mut self) -> Result<()> {
+        let root_char_bag;
         let next_entry_id;
+        let is_dir;
         {
-            let mut snapshot = self.snapshot.lock();
-            snapshot.scan_id += 1;
+            let snapshot = self.snapshot.lock();
+            root_char_bag = snapshot.root_char_bag;
             next_entry_id = snapshot.next_entry_id.clone();
+            is_dir = snapshot.root_entry().is_dir();
         }
 
-        let path: Arc<Path> = Arc::from(Path::new(""));
-        let abs_path = self.abs_path();
-
-        // After determining whether the root entry is a file or a directory, populate the
-        // snapshot's "root name", which will be used for the purpose of fuzzy matching.
-        let mut root_name = abs_path
-            .file_name()
-            .map_or(String::new(), |f| f.to_string_lossy().to_string());
-        let root_char_bag = root_name.chars().map(|c| c.to_ascii_lowercase()).collect();
-        let entry = self
-            .fs
-            .entry(root_char_bag, &next_entry_id, path.clone(), &abs_path)
-            .await?
-            .ok_or_else(|| anyhow!("root entry does not exist"))?;
-        let is_dir = entry.is_dir();
-        if is_dir {
-            root_name.push('/');
-        }
-
-        {
-            let mut snapshot = self.snapshot.lock();
-            snapshot.root_name = root_name;
-            snapshot.root_char_bag = root_char_bag;
-        }
-
-        self.snapshot.lock().insert_entry(entry);
         if is_dir {
+            let path: Arc<Path> = Arc::from(Path::new(""));
+            let abs_path = self.abs_path();
             let (tx, rx) = channel::unbounded();
             tx.send(ScanJob {
                 abs_path: abs_path.to_path_buf(),
@@ -2279,7 +1848,7 @@ impl BackgroundScanner {
 
             self.executor
                 .scoped(|scope| {
-                    for _ in 0..self.executor.threads() {
+                    for _ in 0..self.executor.num_cpus() {
                         scope.spawn(async {
                             while let Ok(job) = rx.recv().await {
                                 if let Err(err) = self
@@ -2471,7 +2040,7 @@ impl BackgroundScanner {
         drop(scan_queue_tx);
         self.executor
             .scoped(|scope| {
-                for _ in 0..self.executor.threads() {
+                for _ in 0..self.executor.num_cpus() {
                     scope.spawn(async {
                         while let Ok(job) = scan_queue_rx.recv().await {
                             if let Err(err) = self
@@ -2539,7 +2108,7 @@ impl BackgroundScanner {
 
         self.executor
             .scoped(|scope| {
-                for _ in 0..self.executor.threads() {
+                for _ in 0..self.executor.num_cpus() {
                     scope.spawn(async {
                         while let Ok(job) = ignore_queue_rx.recv().await {
                             self.update_ignore_status(job, &snapshot).await;
@@ -2949,8 +2518,6 @@ mod remote {
         rpc: &rpc::Client,
         cx: &mut AsyncAppContext,
     ) -> anyhow::Result<()> {
-        eprintln!("got buffer_saved {:?}", envelope.payload);
-
         rpc.state
             .read()
             .await
@@ -2973,7 +2540,7 @@ mod tests {
     use std::{env, fmt::Write, os::unix, time::SystemTime};
 
     #[gpui::test]
-    async fn test_populate_and_search(mut cx: gpui::TestAppContext) {
+    async fn test_populate_and_search(cx: gpui::TestAppContext) {
         let dir = temp_tree(json!({
             "root": {
                 "apple": "",
@@ -2997,40 +2564,51 @@ mod tests {
         )
         .unwrap();
 
-        let tree = cx.add_model(|cx| Worktree::local(root_link_path, Default::default(), cx));
+        let tree = Worktree::open_local(
+            root_link_path,
+            Default::default(),
+            Arc::new(RealFs),
+            &mut cx.to_async(),
+        )
+        .await
+        .unwrap();
 
         cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
             .await;
-        cx.read(|cx| {
+        let snapshot = cx.read(|cx| {
             let tree = tree.read(cx);
             assert_eq!(tree.file_count(), 5);
-
             assert_eq!(
                 tree.inode_for_path("fennel/grape"),
                 tree.inode_for_path("finnochio/grape")
             );
 
-            let results = match_paths(
-                Some(tree.snapshot()).iter(),
-                "bna",
-                false,
-                false,
-                false,
-                10,
-                Default::default(),
-                cx.thread_pool().clone(),
-            )
-            .into_iter()
-            .map(|result| result.path)
-            .collect::<Vec<Arc<Path>>>();
-            assert_eq!(
-                results,
-                vec![
-                    PathBuf::from("banana/carrot/date").into(),
-                    PathBuf::from("banana/carrot/endive").into(),
-                ]
-            );
-        })
+            tree.snapshot()
+        });
+        let results = cx
+            .read(|cx| {
+                match_paths(
+                    Some(&snapshot).into_iter(),
+                    "bna",
+                    false,
+                    false,
+                    false,
+                    10,
+                    Default::default(),
+                    cx.background().clone(),
+                )
+            })
+            .await;
+        assert_eq!(
+            results
+                .into_iter()
+                .map(|result| result.path)
+                .collect::<Vec<Arc<Path>>>(),
+            vec![
+                PathBuf::from("banana/carrot/date").into(),
+                PathBuf::from("banana/carrot/endive").into(),
+            ]
+        );
     }
 
     #[gpui::test]
@@ -3039,7 +2617,14 @@ mod tests {
         let dir = temp_tree(json!({
             "file1": "the old contents",
         }));
-        let tree = cx.add_model(|cx| Worktree::local(dir.path(), app_state.languages.clone(), cx));
+        let tree = Worktree::open_local(
+            dir.path(),
+            app_state.languages.clone(),
+            Arc::new(RealFs),
+            &mut cx.to_async(),
+        )
+        .await
+        .unwrap();
         let buffer = tree
             .update(&mut cx, |tree, cx| tree.open_buffer("file1", cx))
             .await
@@ -3062,8 +2647,14 @@ mod tests {
         }));
         let file_path = dir.path().join("file1");
 
-        let tree =
-            cx.add_model(|cx| Worktree::local(file_path.clone(), app_state.languages.clone(), cx));
+        let tree = Worktree::open_local(
+            file_path.clone(),
+            app_state.languages.clone(),
+            Arc::new(RealFs),
+            &mut cx.to_async(),
+        )
+        .await
+        .unwrap();
         cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
             .await;
         cx.read(|cx| assert_eq!(tree.read(cx).file_count(), 1));
@@ -3098,7 +2689,14 @@ mod tests {
             }
         }));
 
-        let tree = cx.add_model(|cx| Worktree::local(dir.path(), Default::default(), cx));
+        let tree = Worktree::open_local(
+            dir.path(),
+            Default::default(),
+            Arc::new(RealFs),
+            &mut cx.to_async(),
+        )
+        .await
+        .unwrap();
 
         let buffer_for_path = |path: &'static str, cx: &mut gpui::TestAppContext| {
             let buffer = tree.update(cx, |tree, cx| tree.open_buffer(path, cx));
@@ -3233,7 +2831,7 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_rescan_with_gitignore(mut cx: gpui::TestAppContext) {
+    async fn test_rescan_with_gitignore(cx: gpui::TestAppContext) {
         let dir = temp_tree(json!({
             ".git": {},
             ".gitignore": "ignored-dir\n",
@@ -3245,7 +2843,14 @@ mod tests {
             }
         }));
 
-        let tree = cx.add_model(|cx| Worktree::local(dir.path(), Default::default(), cx));
+        let tree = Worktree::open_local(
+            dir.path(),
+            Default::default(),
+            Arc::new(RealFs),
+            &mut cx.to_async(),
+        )
+        .await
+        .unwrap();
         cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
             .await;
         tree.flush_fs_events(&cx).await;
@@ -3299,21 +2904,34 @@ mod tests {
             log::info!("Generated initial tree");
 
             let (notify_tx, _notify_rx) = smol::channel::unbounded();
+            let fs = Arc::new(RealFs);
+            let next_entry_id = Arc::new(AtomicUsize::new(0));
+            let mut initial_snapshot = Snapshot {
+                id: 0,
+                scan_id: 0,
+                abs_path: root_dir.path().into(),
+                entries_by_path: Default::default(),
+                entries_by_id: Default::default(),
+                removed_entry_ids: Default::default(),
+                ignores: Default::default(),
+                root_name: Default::default(),
+                root_char_bag: Default::default(),
+                next_entry_id: next_entry_id.clone(),
+            };
+            initial_snapshot.insert_entry(
+                smol::block_on(fs.entry(
+                    Default::default(),
+                    &next_entry_id,
+                    Path::new("").into(),
+                    root_dir.path().into(),
+                ))
+                .unwrap()
+                .unwrap(),
+            );
             let mut scanner = BackgroundScanner::new(
-                Arc::new(Mutex::new(Snapshot {
-                    id: 0,
-                    scan_id: 0,
-                    abs_path: root_dir.path().into(),
-                    entries_by_path: Default::default(),
-                    entries_by_id: Default::default(),
-                    removed_entry_ids: Default::default(),
-                    ignores: Default::default(),
-                    root_name: Default::default(),
-                    root_char_bag: Default::default(),
-                    next_entry_id: Default::default(),
-                })),
+                Arc::new(Mutex::new(initial_snapshot.clone())),
                 notify_tx,
-                Arc::new(ProductionFs),
+                fs.clone(),
                 Arc::new(gpui::executor::Background::new()),
             );
             smol::block_on(scanner.scan_dirs()).unwrap();

zed/src/worktree/fs.rs 🔗

@@ -0,0 +1,490 @@
+use super::{char_bag::CharBag, char_bag_for_path, Entry, EntryKind, Rope};
+use anyhow::{anyhow, Context, Result};
+use atomic::Ordering::SeqCst;
+use fsevent::EventStream;
+use futures::{future::BoxFuture, Stream, StreamExt};
+use postage::prelude::Sink as _;
+use smol::io::{AsyncReadExt, AsyncWriteExt};
+use std::{
+    io,
+    os::unix::fs::MetadataExt,
+    path::{Path, PathBuf},
+    pin::Pin,
+    sync::{
+        atomic::{self, AtomicUsize},
+        Arc,
+    },
+    time::{Duration, SystemTime},
+};
+
+#[async_trait::async_trait]
+pub trait Fs: Send + Sync {
+    async fn entry(
+        &self,
+        root_char_bag: CharBag,
+        next_entry_id: &AtomicUsize,
+        path: Arc<Path>,
+        abs_path: &Path,
+    ) -> Result<Option<Entry>>;
+    async fn child_entries<'a>(
+        &self,
+        root_char_bag: CharBag,
+        next_entry_id: &'a AtomicUsize,
+        path: &'a Path,
+        abs_path: &'a Path,
+    ) -> Result<Pin<Box<dyn 'a + Stream<Item = Result<Entry>> + Send>>>;
+    async fn load(&self, path: &Path) -> Result<String>;
+    async fn save(&self, path: &Path, text: &Rope) -> Result<()>;
+    async fn canonicalize(&self, path: &Path) -> Result<PathBuf>;
+    async fn is_file(&self, path: &Path) -> bool;
+    async fn watch(
+        &self,
+        path: &Path,
+        latency: Duration,
+    ) -> Pin<Box<dyn Send + Stream<Item = Vec<fsevent::Event>>>>;
+    fn is_fake(&self) -> bool;
+}
+
+pub struct RealFs;
+
+#[async_trait::async_trait]
+impl Fs for RealFs {
+    async fn entry(
+        &self,
+        root_char_bag: CharBag,
+        next_entry_id: &AtomicUsize,
+        path: Arc<Path>,
+        abs_path: &Path,
+    ) -> Result<Option<Entry>> {
+        let metadata = match smol::fs::metadata(&abs_path).await {
+            Err(err) => {
+                return match (err.kind(), err.raw_os_error()) {
+                    (io::ErrorKind::NotFound, _) => Ok(None),
+                    (io::ErrorKind::Other, Some(libc::ENOTDIR)) => Ok(None),
+                    _ => Err(anyhow::Error::new(err)),
+                }
+            }
+            Ok(metadata) => metadata,
+        };
+        let inode = metadata.ino();
+        let mtime = metadata.modified()?;
+        let is_symlink = smol::fs::symlink_metadata(&abs_path)
+            .await
+            .context("failed to read symlink metadata")?
+            .file_type()
+            .is_symlink();
+
+        let entry = Entry {
+            id: next_entry_id.fetch_add(1, SeqCst),
+            kind: if metadata.file_type().is_dir() {
+                EntryKind::PendingDir
+            } else {
+                EntryKind::File(char_bag_for_path(root_char_bag, &path))
+            },
+            path: Arc::from(path),
+            inode,
+            mtime,
+            is_symlink,
+            is_ignored: false,
+        };
+
+        Ok(Some(entry))
+    }
+
+    async fn child_entries<'a>(
+        &self,
+        root_char_bag: CharBag,
+        next_entry_id: &'a AtomicUsize,
+        path: &'a Path,
+        abs_path: &'a Path,
+    ) -> Result<Pin<Box<dyn 'a + Stream<Item = Result<Entry>> + Send>>> {
+        let entries = smol::fs::read_dir(abs_path).await?;
+        Ok(entries
+            .then(move |entry| async move {
+                let child_entry = entry?;
+                let child_name = child_entry.file_name();
+                let child_path: Arc<Path> = path.join(&child_name).into();
+                let child_abs_path = abs_path.join(&child_name);
+                let child_is_symlink = child_entry.metadata().await?.file_type().is_symlink();
+                let child_metadata = smol::fs::metadata(child_abs_path).await?;
+                let child_inode = child_metadata.ino();
+                let child_mtime = child_metadata.modified()?;
+                Ok(Entry {
+                    id: next_entry_id.fetch_add(1, SeqCst),
+                    kind: if child_metadata.file_type().is_dir() {
+                        EntryKind::PendingDir
+                    } else {
+                        EntryKind::File(char_bag_for_path(root_char_bag, &child_path))
+                    },
+                    path: child_path,
+                    inode: child_inode,
+                    mtime: child_mtime,
+                    is_symlink: child_is_symlink,
+                    is_ignored: false,
+                })
+            })
+            .boxed())
+    }
+
+    async fn load(&self, path: &Path) -> Result<String> {
+        let mut file = smol::fs::File::open(path).await?;
+        let mut text = String::new();
+        file.read_to_string(&mut text).await?;
+        Ok(text)
+    }
+
+    async fn save(&self, path: &Path, text: &Rope) -> Result<()> {
+        let buffer_size = text.summary().bytes.min(10 * 1024);
+        let file = smol::fs::File::create(path).await?;
+        let mut writer = smol::io::BufWriter::with_capacity(buffer_size, file);
+        for chunk in text.chunks() {
+            writer.write_all(chunk.as_bytes()).await?;
+        }
+        writer.flush().await?;
+        Ok(())
+    }
+
+    async fn canonicalize(&self, path: &Path) -> Result<PathBuf> {
+        Ok(smol::fs::canonicalize(path).await?)
+    }
+
+    async fn is_file(&self, path: &Path) -> bool {
+        smol::fs::metadata(path)
+            .await
+            .map_or(false, |metadata| metadata.is_file())
+    }
+
+    async fn watch(
+        &self,
+        path: &Path,
+        latency: Duration,
+    ) -> Pin<Box<dyn Send + Stream<Item = Vec<fsevent::Event>>>> {
+        let (mut tx, rx) = postage::mpsc::channel(64);
+        let (stream, handle) = EventStream::new(&[path], latency);
+        std::mem::forget(handle);
+        std::thread::spawn(move || {
+            stream.run(move |events| smol::block_on(tx.send(events)).is_ok());
+        });
+        Box::pin(rx)
+    }
+
+    fn is_fake(&self) -> bool {
+        false
+    }
+}
+
+#[derive(Clone, Debug)]
+struct FakeFsEntry {
+    inode: u64,
+    mtime: SystemTime,
+    is_dir: bool,
+    is_symlink: bool,
+    content: Option<String>,
+}
+
+#[cfg(any(test, feature = "test-support"))]
+struct FakeFsState {
+    entries: std::collections::BTreeMap<PathBuf, FakeFsEntry>,
+    next_inode: u64,
+    events_tx: postage::broadcast::Sender<Vec<fsevent::Event>>,
+}
+
+#[cfg(any(test, feature = "test-support"))]
+impl FakeFsState {
+    fn validate_path(&self, path: &Path) -> Result<()> {
+        if path.is_absolute()
+            && path
+                .parent()
+                .and_then(|path| self.entries.get(path))
+                .map_or(false, |e| e.is_dir)
+        {
+            Ok(())
+        } else {
+            Err(anyhow!("invalid path {:?}", path))
+        }
+    }
+
+    async fn emit_event(&mut self, paths: &[&Path]) {
+        let events = paths
+            .iter()
+            .map(|path| fsevent::Event {
+                event_id: 0,
+                flags: fsevent::StreamFlags::empty(),
+                path: path.to_path_buf(),
+            })
+            .collect();
+
+        let _ = self.events_tx.send(events).await;
+    }
+}
+
+#[cfg(any(test, feature = "test-support"))]
+pub struct FakeFs {
+    // Use an unfair lock to ensure tests are deterministic.
+    state: futures::lock::Mutex<FakeFsState>,
+}
+
+#[cfg(any(test, feature = "test-support"))]
+impl FakeFs {
+    pub fn new() -> Self {
+        let (events_tx, _) = postage::broadcast::channel(2048);
+        let mut entries = std::collections::BTreeMap::new();
+        entries.insert(
+            Path::new("/").to_path_buf(),
+            FakeFsEntry {
+                inode: 0,
+                mtime: SystemTime::now(),
+                is_dir: true,
+                is_symlink: false,
+                content: None,
+            },
+        );
+        Self {
+            state: futures::lock::Mutex::new(FakeFsState {
+                entries,
+                next_inode: 1,
+                events_tx,
+            }),
+        }
+    }
+
+    pub async fn insert_dir(&self, path: impl AsRef<Path>) -> Result<()> {
+        let mut state = self.state.lock().await;
+        let path = path.as_ref();
+        state.validate_path(path)?;
+
+        let inode = state.next_inode;
+        state.next_inode += 1;
+        state.entries.insert(
+            path.to_path_buf(),
+            FakeFsEntry {
+                inode,
+                mtime: SystemTime::now(),
+                is_dir: true,
+                is_symlink: false,
+                content: None,
+            },
+        );
+        state.emit_event(&[path]).await;
+        Ok(())
+    }
+
+    pub async fn insert_file(&self, path: impl AsRef<Path>, content: String) -> Result<()> {
+        let mut state = self.state.lock().await;
+        let path = path.as_ref();
+        state.validate_path(path)?;
+
+        let inode = state.next_inode;
+        state.next_inode += 1;
+        state.entries.insert(
+            path.to_path_buf(),
+            FakeFsEntry {
+                inode,
+                mtime: SystemTime::now(),
+                is_dir: false,
+                is_symlink: false,
+                content: Some(content),
+            },
+        );
+        state.emit_event(&[path]).await;
+        Ok(())
+    }
+
+    #[must_use]
+    pub fn insert_tree<'a>(
+        &'a self,
+        path: impl 'a + AsRef<Path> + Send,
+        tree: serde_json::Value,
+    ) -> BoxFuture<'a, ()> {
+        use futures::FutureExt as _;
+        use serde_json::Value::*;
+
+        async move {
+            let path = path.as_ref();
+
+            match tree {
+                Object(map) => {
+                    self.insert_dir(path).await.unwrap();
+                    for (name, contents) in map {
+                        let mut path = PathBuf::from(path);
+                        path.push(name);
+                        self.insert_tree(&path, contents).await;
+                    }
+                }
+                Null => {
+                    self.insert_dir(&path).await.unwrap();
+                }
+                String(contents) => {
+                    self.insert_file(&path, contents).await.unwrap();
+                }
+                _ => {
+                    panic!("JSON object must contain only objects, strings, or null");
+                }
+            }
+        }
+        .boxed()
+    }
+
+    pub async fn remove(&self, path: &Path) -> Result<()> {
+        let mut state = self.state.lock().await;
+        state.validate_path(path)?;
+        state.entries.retain(|path, _| !path.starts_with(path));
+        state.emit_event(&[path]).await;
+        Ok(())
+    }
+
+    pub async fn rename(&self, source: &Path, target: &Path) -> Result<()> {
+        let mut state = self.state.lock().await;
+        state.validate_path(source)?;
+        state.validate_path(target)?;
+        if state.entries.contains_key(target) {
+            Err(anyhow!("target path already exists"))
+        } else {
+            let mut removed = Vec::new();
+            state.entries.retain(|path, entry| {
+                if let Ok(relative_path) = path.strip_prefix(source) {
+                    removed.push((relative_path.to_path_buf(), entry.clone()));
+                    false
+                } else {
+                    true
+                }
+            });
+
+            for (relative_path, entry) in removed {
+                let new_path = target.join(relative_path);
+                state.entries.insert(new_path, entry);
+            }
+
+            state.emit_event(&[source, target]).await;
+            Ok(())
+        }
+    }
+}
+
+#[cfg(any(test, feature = "test-support"))]
+#[async_trait::async_trait]
+impl Fs for FakeFs {
+    async fn entry(
+        &self,
+        root_char_bag: CharBag,
+        next_entry_id: &AtomicUsize,
+        path: Arc<Path>,
+        abs_path: &Path,
+    ) -> Result<Option<Entry>> {
+        let state = self.state.lock().await;
+        if let Some(entry) = state.entries.get(abs_path) {
+            Ok(Some(Entry {
+                id: next_entry_id.fetch_add(1, SeqCst),
+                kind: if entry.is_dir {
+                    EntryKind::PendingDir
+                } else {
+                    EntryKind::File(char_bag_for_path(root_char_bag, &path))
+                },
+                path: Arc::from(path),
+                inode: entry.inode,
+                mtime: entry.mtime,
+                is_symlink: entry.is_symlink,
+                is_ignored: false,
+            }))
+        } else {
+            Ok(None)
+        }
+    }
+
+    async fn child_entries<'a>(
+        &self,
+        root_char_bag: CharBag,
+        next_entry_id: &'a AtomicUsize,
+        path: &'a Path,
+        abs_path: &'a Path,
+    ) -> Result<Pin<Box<dyn 'a + Stream<Item = Result<Entry>> + Send>>> {
+        use futures::{future, stream};
+
+        let state = self.state.lock().await;
+        Ok(stream::iter(state.entries.clone())
+            .filter(move |(child_path, _)| future::ready(child_path.parent() == Some(abs_path)))
+            .then(move |(child_abs_path, child_entry)| async move {
+                smol::future::yield_now().await;
+                let child_path = Arc::from(path.join(child_abs_path.file_name().unwrap()));
+                Ok(Entry {
+                    id: next_entry_id.fetch_add(1, SeqCst),
+                    kind: if child_entry.is_dir {
+                        EntryKind::PendingDir
+                    } else {
+                        EntryKind::File(char_bag_for_path(root_char_bag, &child_path))
+                    },
+                    path: child_path,
+                    inode: child_entry.inode,
+                    mtime: child_entry.mtime,
+                    is_symlink: child_entry.is_symlink,
+                    is_ignored: false,
+                })
+            })
+            .boxed())
+    }
+
+    async fn load(&self, path: &Path) -> Result<String> {
+        let state = self.state.lock().await;
+        let text = state
+            .entries
+            .get(path)
+            .and_then(|e| e.content.as_ref())
+            .ok_or_else(|| anyhow!("file {:?} does not exist", path))?;
+        Ok(text.clone())
+    }
+
+    async fn save(&self, path: &Path, text: &Rope) -> Result<()> {
+        let mut state = self.state.lock().await;
+        state.validate_path(path)?;
+        if let Some(entry) = state.entries.get_mut(path) {
+            if entry.is_dir {
+                Err(anyhow!("cannot overwrite a directory with a file"))
+            } else {
+                entry.content = Some(text.chunks().collect());
+                entry.mtime = SystemTime::now();
+                state.emit_event(&[path]).await;
+                Ok(())
+            }
+        } else {
+            let inode = state.next_inode;
+            state.next_inode += 1;
+            let entry = FakeFsEntry {
+                inode,
+                mtime: SystemTime::now(),
+                is_dir: false,
+                is_symlink: false,
+                content: Some(text.chunks().collect()),
+            };
+            state.entries.insert(path.to_path_buf(), entry);
+            state.emit_event(&[path]).await;
+            Ok(())
+        }
+    }
+
+    async fn canonicalize(&self, path: &Path) -> Result<PathBuf> {
+        Ok(path.to_path_buf())
+    }
+
+    async fn is_file(&self, path: &Path) -> bool {
+        let state = self.state.lock().await;
+        state.entries.get(path).map_or(false, |entry| !entry.is_dir)
+    }
+
+    async fn watch(
+        &self,
+        path: &Path,
+        _: Duration,
+    ) -> Pin<Box<dyn Send + Stream<Item = Vec<fsevent::Event>>>> {
+        let state = self.state.lock().await;
+        let rx = state.events_tx.subscribe();
+        let path = path.to_path_buf();
+        Box::pin(futures::StreamExt::filter(rx, move |events| {
+            let result = events.iter().any(|event| event.path.starts_with(&path));
+            async move { result }
+        }))
+    }
+
+    fn is_fake(&self) -> bool {
+        true
+    }
+}

zed/src/worktree/fuzzy.rs 🔗

@@ -1,6 +1,6 @@
 use super::{char_bag::CharBag, EntryKind, Snapshot};
 use crate::util;
-use gpui::scoped_pool;
+use gpui::executor;
 use std::{
     cmp::{max, min, Ordering},
     path::Path,
@@ -51,7 +51,7 @@ impl Ord for PathMatch {
     }
 }
 
-pub fn match_paths<'a, T>(
+pub async fn match_paths<'a, T>(
     snapshots: T,
     query: &str,
     include_root_name: bool,
@@ -59,7 +59,7 @@ pub fn match_paths<'a, T>(
     smart_case: bool,
     max_results: usize,
     cancel_flag: Arc<AtomicBool>,
-    pool: scoped_pool::Pool,
+    background: Arc<executor::Background>,
 ) -> Vec<PathMatch>
 where
     T: Clone + Send + Iterator<Item = &'a Snapshot> + 'a,
@@ -71,88 +71,91 @@ where
     let query = &query;
     let query_chars = CharBag::from(&lowercase_query[..]);
 
-    let cpus = num_cpus::get();
     let path_count: usize = if include_ignored {
         snapshots.clone().map(Snapshot::file_count).sum()
     } else {
         snapshots.clone().map(Snapshot::visible_file_count).sum()
     };
 
-    let segment_size = (path_count + cpus - 1) / cpus;
-    let mut segment_results = (0..cpus)
+    let num_cpus = background.num_cpus().min(path_count);
+    let segment_size = (path_count + num_cpus - 1) / num_cpus;
+    let mut segment_results = (0..num_cpus)
         .map(|_| Vec::with_capacity(max_results))
         .collect::<Vec<_>>();
 
-    pool.scoped(|scope| {
-        for (segment_idx, results) in segment_results.iter_mut().enumerate() {
-            let snapshots = snapshots.clone();
-            let cancel_flag = &cancel_flag;
-            scope.execute(move || {
-                let segment_start = segment_idx * segment_size;
-                let segment_end = segment_start + segment_size;
-
-                let mut min_score = 0.0;
-                let mut last_positions = Vec::new();
-                last_positions.resize(query.len(), 0);
-                let mut match_positions = Vec::new();
-                match_positions.resize(query.len(), 0);
-                let mut score_matrix = Vec::new();
-                let mut best_position_matrix = Vec::new();
-
-                let mut tree_start = 0;
-                for snapshot in snapshots {
-                    let tree_end = if include_ignored {
-                        tree_start + snapshot.file_count()
-                    } else {
-                        tree_start + snapshot.visible_file_count()
-                    };
-
-                    let include_root_name = include_root_name || snapshot.root_entry().is_file();
-                    if tree_start < segment_end && segment_start < tree_end {
-                        let start = max(tree_start, segment_start) - tree_start;
-                        let end = min(tree_end, segment_end) - tree_start;
-                        let entries = if include_ignored {
-                            snapshot.files(start).take(end - start)
+    background
+        .scoped(|scope| {
+            for (segment_idx, results) in segment_results.iter_mut().enumerate() {
+                let snapshots = snapshots.clone();
+                let cancel_flag = &cancel_flag;
+                scope.spawn(async move {
+                    let segment_start = segment_idx * segment_size;
+                    let segment_end = segment_start + segment_size;
+
+                    let mut min_score = 0.0;
+                    let mut last_positions = Vec::new();
+                    last_positions.resize(query.len(), 0);
+                    let mut match_positions = Vec::new();
+                    match_positions.resize(query.len(), 0);
+                    let mut score_matrix = Vec::new();
+                    let mut best_position_matrix = Vec::new();
+
+                    let mut tree_start = 0;
+                    for snapshot in snapshots {
+                        let tree_end = if include_ignored {
+                            tree_start + snapshot.file_count()
                         } else {
-                            snapshot.visible_files(start).take(end - start)
+                            tree_start + snapshot.visible_file_count()
                         };
-                        let paths = entries.map(|entry| {
-                            if let EntryKind::File(char_bag) = entry.kind {
-                                MatchCandidate {
-                                    path: &entry.path,
-                                    char_bag,
-                                }
+
+                        let include_root_name =
+                            include_root_name || snapshot.root_entry().is_file();
+                        if tree_start < segment_end && segment_start < tree_end {
+                            let start = max(tree_start, segment_start) - tree_start;
+                            let end = min(tree_end, segment_end) - tree_start;
+                            let entries = if include_ignored {
+                                snapshot.files(start).take(end - start)
                             } else {
-                                unreachable!()
-                            }
-                        });
-
-                        match_single_tree_paths(
-                            snapshot,
-                            include_root_name,
-                            paths,
-                            query,
-                            lowercase_query,
-                            query_chars,
-                            smart_case,
-                            results,
-                            max_results,
-                            &mut min_score,
-                            &mut match_positions,
-                            &mut last_positions,
-                            &mut score_matrix,
-                            &mut best_position_matrix,
-                            &cancel_flag,
-                        );
-                    }
-                    if tree_end >= segment_end {
-                        break;
+                                snapshot.visible_files(start).take(end - start)
+                            };
+                            let paths = entries.map(|entry| {
+                                if let EntryKind::File(char_bag) = entry.kind {
+                                    MatchCandidate {
+                                        path: &entry.path,
+                                        char_bag,
+                                    }
+                                } else {
+                                    unreachable!()
+                                }
+                            });
+
+                            match_single_tree_paths(
+                                snapshot,
+                                include_root_name,
+                                paths,
+                                query,
+                                lowercase_query,
+                                query_chars,
+                                smart_case,
+                                results,
+                                max_results,
+                                &mut min_score,
+                                &mut match_positions,
+                                &mut last_positions,
+                                &mut score_matrix,
+                                &mut best_position_matrix,
+                                &cancel_flag,
+                            );
+                        }
+                        if tree_end >= segment_end {
+                            break;
+                        }
+                        tree_start = tree_end;
                     }
-                    tree_start = tree_end;
-                }
-            })
-        }
-    });
+                })
+            }
+        })
+        .await;
 
     let mut results = Vec::new();
     for segment_result in segment_results {

zed-rpc/Cargo.toml → zrpc/Cargo.toml 🔗

@@ -1,7 +1,7 @@
 [package]
 description = "Shared logic for communication between the Zed app and the zed.dev server"
 edition = "2018"
-name = "zed-rpc"
+name = "zrpc"
 version = "0.1.0"
 
 [features]
@@ -15,14 +15,14 @@ base64 = "0.13"
 futures = "0.3"
 log = "0.4"
 parking_lot = "0.11.1"
-postage = {version = "0.4.1", features = ["futures-traits"]}
+postage = { version = "0.4.1", features = ["futures-traits"] }
 prost = "0.7"
 rand = "0.8"
 rsa = "0.4"
-serde = {version = "1", features = ["derive"]}
+serde = { version = "1", features = ["derive"] }
 
 [build-dependencies]
-prost-build = {git = "https://github.com/tokio-rs/prost", rev = "6cf97ea422b09d98de34643c4dda2d4f8b7e23e6"}
+prost-build = { git = "https://github.com/tokio-rs/prost", rev = "6cf97ea422b09d98de34643c4dda2d4f8b7e23e6" }
 
 [dev-dependencies]
 smol = "1.2.5"

zed-rpc/src/peer.rs → zrpc/src/peer.rs 🔗

@@ -227,7 +227,8 @@ impl Peer {
             connection
                 .outgoing_tx
                 .send(request.into_envelope(message_id, None, original_sender_id.map(|id| id.0)))
-                .await?;
+                .await
+                .map_err(|_| anyhow!("connection was closed"))?;
             let response = rx
                 .recv()
                 .await