diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cef9497074..866d0acc0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,7 @@ jobs: MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }} APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} + ZED_AMPLITUDE_API_KEY: ${{ secrets.ZED_AMPLITUDE_API_KEY }} steps: - name: Install Rust run: | diff --git a/.github/workflows/release_actions.yml b/.github/workflows/release_actions.yml new file mode 100644 index 0000000000..9a3b2376df --- /dev/null +++ b/.github/workflows/release_actions.yml @@ -0,0 +1,33 @@ +on: + release: + types: [published] + +jobs: + discord_release: + runs-on: ubuntu-latest + steps: + - name: Discord Webhook Action + uses: tsickert/discord-webhook@v5.3.0 + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} + content: | + 📣 Zed ${{ github.event.release.tag_name }} was just released! + + Restart your Zed or head to https://zed.dev/releases to grab it. + + ```md + ### Changelog + + ${{ github.event.release.body }} + ``` + amplitude_release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.10.5" + architecture: "x64" + cache: "pip" + - run: pip install -r script/amplitude_release/requirements.txt + - run: python script/amplitude_release/main.py ${{ github.event.release.tag_name }} ${{ secrets.ZED_AMPLITUDE_API_KEY }} ${{ secrets.ZED_AMPLITUDE_SECRET_KEY }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5e6963ba8b..2d721f8ad2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ /vendor/bin /assets/themes/*.json /assets/themes/internal/*.json -/assets/themes/experiments/*.json \ No newline at end of file +/assets/themes/experiments/*.json +**/venv \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index a885db0b13..bc11e4d7da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.1.0" dependencies = [ "auto_update", "editor", - "futures", + "futures 0.3.24", "gpui", "language", "project", @@ -52,9 +52,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +checksum = "b4f55bd91a0978cbfd91c457a164bab8b4001c833b7f323132c0a4e1922dd44e" dependencies = [ "memchr", ] @@ -113,6 +113,15 @@ version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec8ad6edb4840b78c5c3d88de606b22252d552b55f3a4699fbb10fc070ec3049" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "ansi_term" version = "0.12.1" @@ -124,9 +133,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.58" +version = "1.0.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb07d2053ccdbe10e2af2995a2f116c1330396493dc1269f6a91d0ae82e19704" +checksum = "98161a4e3e2184da77bb14f02184cdd111e83bbbcc9979dfee3c44b9a85f5602" [[package]] name = "arrayref" @@ -148,9 +157,9 @@ checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6" [[package]] name = "ascii" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbf56136a5198c7b01a49e3afcbef6cf84597273d298f54432926024107b0109" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" [[package]] name = "assets" @@ -174,9 +183,9 @@ dependencies = [ [[package]] name = "async-channel" -version = "1.6.1" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2114d64672151c0c5eaa5e131ec84a74f06e1e559830dabba01ca30605d66319" +checksum = "e14485364214912d3b19cc3435dde4df66065127f05fa0d75c712f36f12c2f28" dependencies = [ "concurrent-queue", "event-listener", @@ -184,10 +193,23 @@ dependencies = [ ] [[package]] -name = "async-compression" -version = "0.3.14" +name = "async-compat" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "345fd392ab01f746c717b1357165b76f0b67a60192007b234058c9045fdcf695" +checksum = "9b48b4ff0c2026db683dea961cd8ea874737f56cffca86fa84415eaddc51c00d" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite 0.2.9", + "tokio", +] + +[[package]] +name = "async-compression" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "942c7cd7ae39e91bde4820d74132e9862e62c2f386c3aa90ccf55949f5bad63a" dependencies = [ "flate2", "futures-core", @@ -212,21 +234,23 @@ dependencies = [ [[package]] name = "async-fs" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b3ca4f8ff117c37c278a2f7415ce9be55560b846b5bc4412aaa5d29c1c3dae2" +checksum = "279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06" dependencies = [ "async-lock", + "autocfg 1.1.0", "blocking", "futures-lite", ] [[package]] name = "async-io" -version = "1.7.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5e18f61464ae81cde0a23e713ae8fd299580c54d697a35820cfd0625b8b0e07" +checksum = "83e21f3a490c72b3b0cf44962180e60045de2925d8dff97918f7ee43c8f637c7" dependencies = [ + "autocfg 1.1.0", "concurrent-queue", "futures-lite", "libc", @@ -251,11 +275,12 @@ dependencies = [ [[package]] name = "async-net" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5373304df79b9b4395068fb080369ec7178608827306ce4d081cba51cac551df" +checksum = "4051e67316bc7eff608fe723df5d32ed639946adcd69e07df41fd42a7b411f1f" dependencies = [ "async-io", + "autocfg 1.1.0", "blocking", "futures-lite", ] @@ -265,17 +290,18 @@ name = "async-pipe" version = "0.1.3" source = "git+https://github.com/zed-industries/async-pipe-rs?rev=82d00a04211cf4e1236029aa03e6b6ce2a74c553#82d00a04211cf4e1236029aa03e6b6ce2a74c553" dependencies = [ - "futures", + "futures 0.3.24", "log", ] [[package]] name = "async-process" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf2c06e30a24e8c78a3987d07f0930edf76ef35e027e7bdb063fccafdad1f60c" +checksum = "02111fd8655a613c25069ea89fc8d9bb89331fa77486eb3bc059ee757cfa481c" dependencies = [ "async-io", + "autocfg 1.1.0", "blocking", "cfg-if 1.0.0", "event-listener", @@ -338,9 +364,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.56" +version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96cf8829f67d2eab0b2dfa42c5d0ef737e0724e4a82b01b3e292456202b19716" +checksum = "76464446b8bc32758d7e88ee1a804d9914cd9b1cb264c029899680b0be29826f" dependencies = [ "proc-macro2", "quote", @@ -435,15 +461,15 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "axum" -version = "0.5.11" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2cc6e8e8c993cb61a005fab8c1e5093a29199b7253b05a6883999312935c1ff" +checksum = "c9e3356844c4d6a6d6467b8da2cffb4a2820be256f50a3a386c9d152bab31043" dependencies = [ "async-trait", "axum-core", "base64", "bitflags", - "bytes", + "bytes 1.2.1", "futures-util", "headers", "http", @@ -470,26 +496,28 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.2.6" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4d047478b986f14a13edad31a009e2e05cb241f9805d0d75e4cba4e129ad4d" +checksum = "d9f0c0a60006f2a293d82d571f635042a72edf927539b7685bd62d361963839b" dependencies = [ "async-trait", - "bytes", + "bytes 1.2.1", "futures-util", "http", "http-body", "mime", + "tower-layer", + "tower-service", ] [[package]] name = "axum-extra" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "277c75e6c814b061ae4947d02335d9659db9771b9950cca670002ae986372f44" +checksum = "69034b3b0fd97923eee2ce8a47540edb21e07f48f87f67d44bb4271cec622bdb" dependencies = [ "axum", - "bytes", + "bytes 1.2.1", "futures-util", "http", "mime", @@ -505,16 +533,16 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.65" +version = "0.3.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11a17d453482a265fd5f8479f2a3f405566e6ca627837aaddb85af8b1ab8ef61" +checksum = "cab84319d616cfb654d03394f38ab7e6f0919e181b1b57e1fd15e7fb4077d9a7" dependencies = [ "addr2line", "cc", "cfg-if 1.0.0", "libc", - "miniz_oxide 0.5.3", - "object", + "miniz_oxide 0.5.4", + "object 0.29.0", "rustc-demangle", ] @@ -526,9 +554,9 @@ checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" [[package]] name = "base64ct" -version = "1.5.1" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bdca834647821e0b13d9539a8634eb62d3501b6b6c2cec1722786ee6671b851" +checksum = "ea2b2456fd614d856680dcd9fcc660a51a820fa09daef2e49772b56a193c8474" [[package]] name = "bincode" @@ -585,9 +613,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" +checksum = "69cce20737498f97b993470a6e536b8523f0af7892a4f928cceb1ac5e52ebe7e" dependencies = [ "generic-array", ] @@ -645,15 +673,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.10.0" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37ccbd214614c6783386c1af30caf03192f17891059cecc394b4fb119e363de3" +checksum = "c1ad822118d20d2c234f427000d5acc36eabe1e29a348c89b63dd60b13f28e5d" [[package]] name = "bytemuck" -version = "1.10.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c53dfa917ec274df8ed3c572698f381a24eef2efba9492d797301b72b6db408a" +checksum = "2f5715e491b5a1598fc2bef5a606847b5dc1d48ea625bd3c02c00de8285591da" [[package]] name = "byteorder" @@ -661,6 +689,16 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +[[package]] +name = "bytes" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206fdffcfa2df7cbe15601ef46c813fce0965eb3286db6b56c583b814b51c81c" +dependencies = [ + "byteorder", + "iovec", +] + [[package]] name = "bytes" version = "1.2.1" @@ -684,6 +722,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1db59621ec70f09c5e9b597b220c7a2b43611f4710dc03ceb8748637775692c" +[[package]] +name = "call" +version = "0.1.0" +dependencies = [ + "anyhow", + "client", + "collections", + "futures 0.3.24", + "gpui", + "postage", + "project", + "util", +] + [[package]] name = "cap-fs-ext" version = "0.24.4" @@ -758,12 +810,12 @@ dependencies = [ "bindgen", "block", "byteorder", - "bytes", + "bytes 1.2.1", "cocoa", "core-foundation", "core-graphics", "foreign-types", - "futures", + "futures 0.3.24", "gpui", "hmac 0.12.1", "jwt", @@ -774,7 +826,7 @@ dependencies = [ "parking_lot 0.11.2", "postage", "serde", - "sha2 0.10.2", + "sha2 0.10.6", "simplelog", ] @@ -816,14 +868,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.19" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1" dependencies = [ - "libc", + "iana-time-zone", + "js-sys", "num-integer", "num-traits", "time 0.1.44", + "wasm-bindgen", "winapi 0.3.9", ] @@ -844,9 +898,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.3.3" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a050e2153c5be08febd6734e29298e844fdb0fa21aeddd63b4eb7baa106c69b" +checksum = "fa2e27ae6ab525c3d369ded447057bca5438d86dc3a68f6faafb8269ba82ebf3" dependencies = [ "glob", "libc", @@ -870,9 +924,9 @@ dependencies = [ [[package]] name = "clap" -version = "3.2.8" +version = "3.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "190814073e85d238f31ff738fcb0bf6910cedeb73376c87cd69291028966fd83" +checksum = "86447ad904c7fb335a790c9d7fe3d0d971dc523b8ccd1561a520de9a85302750" dependencies = [ "atty", "bitflags", @@ -882,14 +936,14 @@ dependencies = [ "once_cell", "strsim 0.10.0", "termcolor", - "textwrap 0.15.0", + "textwrap 0.15.1", ] [[package]] name = "clap_derive" -version = "3.2.7" +version = "3.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759bf187376e1afa7b85b959e6a664a3e7a95203415dba952ad19139e798f902" +checksum = "ea0c8bce528c4be4da13ea6fead8965e95b6073585a2f05204bd8f4119f82a65" dependencies = [ "heck 0.4.0", "proc-macro-error", @@ -912,7 +966,7 @@ name = "cli" version = "0.1.0" dependencies = [ "anyhow", - "clap 3.2.8", + "clap 3.2.22", "core-foundation", "core-services", "dirs 3.0.2", @@ -929,7 +983,8 @@ dependencies = [ "async-recursion", "async-tungstenite", "collections", - "futures", + "db", + "futures 0.3.24", "gpui", "image", "isahc", @@ -939,13 +994,16 @@ dependencies = [ "postage", "rand 0.8.5", "rpc", + "serde", "smol", "sum_tree", + "tempfile", "thiserror", - "time 0.3.11", + "time 0.3.15", "tiny_http", "url", "util", + "uuid 1.2.1", ] [[package]] @@ -993,6 +1051,16 @@ dependencies = [ "objc", ] +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + [[package]] name = "collab" version = "0.1.0" @@ -1003,14 +1071,17 @@ dependencies = [ "axum", "axum-extra", "base64", - "clap 3.2.8", + "call", + "clap 3.2.22", "client", "collections", "ctor", "editor", "env_logger", "envy", - "futures", + "fs", + "futures 0.3.24", + "git", "gpui", "hyper", "language", @@ -1032,7 +1103,7 @@ dependencies = [ "sha-1 0.9.8", "sqlx", "theme", - "time 0.3.11", + "time 0.3.15", "tokio", "tokio-tungstenite", "toml", @@ -1041,6 +1112,32 @@ dependencies = [ "tracing", "tracing-log", "tracing-subscriber", + "unindent", + "util", + "workspace", +] + +[[package]] +name = "collab_ui" +version = "0.1.0" +dependencies = [ + "anyhow", + "call", + "client", + "clock", + "collections", + "editor", + "futures 0.3.24", + "fuzzy", + "gpui", + "log", + "menu", + "picker", + "postage", + "project", + "serde", + "settings", + "theme", "util", "workspace", ] @@ -1079,61 +1176,13 @@ dependencies = [ [[package]] name = "concurrent-queue" -version = "1.2.2" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30ed07550be01594c6026cff2a1d7fe9c8f683caa798e12b68694ac9e88286a3" +checksum = "af4780a44ab5696ea9e28294517f1fffb421a83a25af521333c838635509db9c" dependencies = [ "cache-padded", ] -[[package]] -name = "contacts_panel" -version = "0.1.0" -dependencies = [ - "anyhow", - "client", - "collections", - "editor", - "futures", - "fuzzy", - "gpui", - "language", - "log", - "menu", - "picker", - "postage", - "project", - "serde", - "settings", - "theme", - "util", - "workspace", -] - -[[package]] -name = "contacts_status_item" -version = "0.1.0" -dependencies = [ - "anyhow", - "client", - "collections", - "editor", - "futures", - "fuzzy", - "gpui", - "language", - "log", - "menu", - "picker", - "postage", - "project", - "serde", - "settings", - "theme", - "util", - "workspace", -] - [[package]] name = "context_menu" version = "0.1.0" @@ -1214,27 +1263,27 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b" +checksum = "28d997bd5e24a5928dd43e46dc529867e207907fe0b239c3477d924f7f2ca320" dependencies = [ "libc", ] [[package]] name = "cranelift-bforest" -version = "0.85.1" +version = "0.85.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7901fbba05decc537080b07cb3f1cadf53be7b7602ca8255786288a8692ae29a" +checksum = "749d0d6022c9038dccf480bdde2a38d435937335bf2bb0f14e815d94517cdce8" dependencies = [ "cranelift-entity", ] [[package]] name = "cranelift-codegen" -version = "0.85.1" +version = "0.85.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37ba1b45d243a4a28e12d26cd5f2507da74e77c45927d40de8b6ffbf088b46b5" +checksum = "e94370cc7b37bf652ccd8bb8f09bd900997f7ccf97520edfc75554bb5c4abbea" dependencies = [ "cranelift-bforest", "cranelift-codegen-meta", @@ -1250,33 +1299,33 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.85.1" +version = "0.85.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54cc30032171bf230ce22b99c07c3a1de1221cb5375bd6dbe6dbe77d0eed743c" +checksum = "e0a3cea8fdab90e44018c5b9a1dfd460d8ee265ac354337150222a354628bdb6" dependencies = [ "cranelift-codegen-shared", ] [[package]] name = "cranelift-codegen-shared" -version = "0.85.1" +version = "0.85.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23f2672426d2bb4c9c3ef53e023076cfc4d8922f0eeaebaf372c92fae8b5c69" +checksum = "5ac72f76f2698598951ab26d8c96eaa854810e693e7dd52523958b5909fde6b2" [[package]] name = "cranelift-entity" -version = "0.85.1" +version = "0.85.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "886c59a5e0de1f06dbb7da80db149c75de10d5e2caca07cdd9fef8a5918a6336" +checksum = "09eaeacfcd2356fe0e66b295e8f9d59fdd1ac3ace53ba50de14d628ec902f72d" dependencies = [ "serde", ] [[package]] name = "cranelift-frontend" -version = "0.85.1" +version = "0.85.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace74eeca11c439a9d4ed1a5cb9df31a54cd0f7fbddf82c8ce4ea8e9ad2a8fe0" +checksum = "dba69c9980d5ffd62c18a2bde927855fcd7c8dc92f29feaf8636052662cbd99c" dependencies = [ "cranelift-codegen", "log", @@ -1286,15 +1335,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.85.1" +version = "0.85.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db1ae52a5cc2cad0d86fdd3dcb16b7217d2f1e65ab4f5814aa4f014ad335fa43" +checksum = "d2920dc1e05cac40304456ed3301fde2c09bd6a9b0210bcfa2f101398d628d5b" [[package]] name = "cranelift-native" -version = "0.85.1" +version = "0.85.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dadcfb7852900780d37102bce5698bcd401736403f07b52e714ff7a180e0e22f" +checksum = "f04dfa45f9b2a6f587c564d6b63388e00cd6589d2df6ea2758cf79e1a13285e6" dependencies = [ "cranelift-codegen", "libc", @@ -1303,9 +1352,9 @@ dependencies = [ [[package]] name = "cranelift-wasm" -version = "0.85.1" +version = "0.85.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c84e3410960389110b88f97776f39f6d2c8becdaa4cd59e390e6b76d9d0e7190" +checksum = "31a46513ae6f26f3f267d8d75b5373d555fbbd1e68681f348d99df43f747ec54" dependencies = [ "cranelift-codegen", "cranelift-entity", @@ -1353,47 +1402,46 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c02a4d71819009c192cf4872265391563fd6a84c81ff2c0f2a7026ca4c1d85c" +checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521" dependencies = [ "cfg-if 1.0.0", - "crossbeam-utils 0.8.10", + "crossbeam-utils 0.8.12", ] [[package]] name = "crossbeam-deque" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6455c0ca19f0d2fbf751b908d5c55c1f5cbc65e03c4225427254b46890bdde1e" +checksum = "715e8152b692bba2d374b53d4875445368fdf21a94751410af607a5ac677d1fc" dependencies = [ "cfg-if 1.0.0", "crossbeam-epoch", - "crossbeam-utils 0.8.10", + "crossbeam-utils 0.8.12", ] [[package]] name = "crossbeam-epoch" -version = "0.9.9" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07db9d94cbd326813772c968ccd25999e5f8ae22f4f8d1b11effa37ef6ce281d" +checksum = "f916dfc5d356b0ed9dae65f1db9fc9770aa2851d2662b988ccf4fe3516e86348" dependencies = [ "autocfg 1.1.0", "cfg-if 1.0.0", - "crossbeam-utils 0.8.10", + "crossbeam-utils 0.8.12", "memoffset", - "once_cell", "scopeguard", ] [[package]] name = "crossbeam-queue" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f25d8400f4a7a5778f0e4e52384a48cbd9b5c495d110786187fc750075277a2" +checksum = "1cd42583b04998a5363558e5f9291ee5a5ff6b49944332103f251e7479a82aa7" dependencies = [ "cfg-if 1.0.0", - "crossbeam-utils 0.8.10", + "crossbeam-utils 0.8.12", ] [[package]] @@ -1409,19 +1457,18 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.10" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d82ee10ce34d7bc12c2122495e7593a9c41347ecdd64185af4ecf72cb1a7f83" +checksum = "edbafec5fa1f196ca66527c1b12c2ec4745ca14b50f1ad8f9f6f720b55d11fac" dependencies = [ "cfg-if 1.0.0", - "once_cell", ] [[package]] name = "crypto-common" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5999502d32b9c48d492abe66392408144895020ec4709e549e840799f3bb74c0" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", @@ -1439,9 +1486,9 @@ dependencies = [ [[package]] name = "ctor" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f877be4f7c9f246b183111634f75baa039715e3f46ce860677d3b19a69fb229c" +checksum = "cdffe87e1d521a10f9696f833fe502293ea446d7f256c06128293a4119bdf4cb" dependencies = [ "quote", "syn", @@ -1449,9 +1496,9 @@ dependencies = [ [[package]] name = "curl" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37d855aeef205b43f65a5001e0997d81f8efca7badad4fad7d897aa7f0d0651f" +checksum = "509bd11746c7ac09ebd19f0b17782eae80aadee26237658a6b4808afb5c11a22" dependencies = [ "curl-sys", "libc", @@ -1464,9 +1511,9 @@ dependencies = [ [[package]] name = "curl-sys" -version = "0.4.55+curl-7.83.1" +version = "0.4.56+curl-7.83.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23734ec77368ec583c2e61dd3f0b0e5c98b93abe6d2a004ca06b91dd7e3e2762" +checksum = "6093e169dd4de29e468fa649fbae11cdcd5551c81fe5bf1b0677adad7ef3d26f" dependencies = [ "cc", "libc", @@ -1478,6 +1525,50 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "cxx" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f83d0ebf42c6eafb8d7c52f7e5f2d3003b89c7aa4fd2b79229209459a849af8" +dependencies = [ + "cc", + "cxxbridge-flags", + "cxxbridge-macro", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07d050484b55975889284352b0ffc2ecbda25c0c55978017c132b29ba0818a86" +dependencies = [ + "cc", + "codespan-reporting", + "once_cell", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d2199b00553eda8012dfec8d3b1c75fce747cf27c169a270b3b99e3448ab78" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb67a6de1f602736dd7eaead0080cf3435df806c61b24b13328db128c58868f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "data-url" version = "0.1.1" @@ -1487,6 +1578,19 @@ dependencies = [ "matches", ] +[[package]] +name = "db" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "collections", + "gpui", + "parking_lot 0.11.2", + "rocksdb", + "tempdir", +] + [[package]] name = "deflate" version = "0.8.6" @@ -1499,13 +1603,13 @@ dependencies = [ [[package]] name = "dhat" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47003dc9f6368a88e85956c3b2573a7e6872746a3e5d762a8885da3a136a0381" +checksum = "0684eaa19a59be283a6f99369917b679bd4d1d06604b2eb2e2f87b4bbd67668d" dependencies = [ "backtrace", "lazy_static", - "parking_lot 0.11.2", + "parking_lot 0.12.1", "rustc-hash", "serde", "serde_json", @@ -1544,11 +1648,11 @@ dependencies = [ [[package]] name = "digest" -version = "0.10.3" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" +checksum = "adfbc57365a37acbd2ebf2b64d7e69bb766e2fea813521ed536f5d0520dcf86c" dependencies = [ - "block-buffer 0.10.2", + "block-buffer 0.10.3", "crypto-common", "subtle", ] @@ -1614,10 +1718,10 @@ dependencies = [ ] [[package]] -name = "dotenv" -version = "0.15.0" +name = "dotenvy" +version = "0.15.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +checksum = "03d8c417d7a8cb362e0c37e5d815f5eb7c37f79ff93707329d5a194e42e54ca0" [[package]] name = "drag_and_drop" @@ -1641,9 +1745,9 @@ dependencies = [ [[package]] name = "dyn-clone" -version = "1.0.6" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "140206b78fb2bc3edbcfc9b5ccbd0b30699cfe8d348b8b31b330e47df5291a5a" +checksum = "4f94fa09c2aeea5b8839e414b7b841bf429fd25b9c522116ac97ee87856d88b2" [[package]] name = "easy-parallel" @@ -1662,8 +1766,9 @@ dependencies = [ "context_menu", "ctor", "env_logger", - "futures", + "futures 0.3.24", "fuzzy", + "git", "gpui", "indoc", "itertools", @@ -1686,6 +1791,8 @@ dependencies = [ "text", "theme", "tree-sitter", + "tree-sitter-html", + "tree-sitter-javascript", "tree-sitter-rust", "unindent", "util", @@ -1694,9 +1801,9 @@ dependencies = [ [[package]] name = "either" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be" +checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" [[package]] name = "encoding_rs" @@ -1709,9 +1816,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b2cf0344971ee6c64c31be0d530793fba457d322dfec2810c453d0ef228f9c3" +checksum = "c90bf5f19754d10198ccb95b70664fc925bd1fc090a0fd9a6ebc54acc8cd6272" dependencies = [ "atty", "humantime", @@ -1731,9 +1838,9 @@ dependencies = [ [[package]] name = "erased-serde" -version = "0.3.21" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81d013529d5574a60caeda29e179e695125448e5de52e3874f7b4c1d7360e18e" +checksum = "54558e0ba96fbe24280072642eceb9d7d442e32c7ec0ea9e7ecd7b4ea2cf4e11" dependencies = [ "serde", ] @@ -1780,9 +1887,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "2.5.2" +version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77f3309417938f28bf8228fcff79a4a37103981e3e186d2ccd19c74b38f4eb71" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "expat-sys" @@ -1802,9 +1909,9 @@ checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" [[package]] name = "fastrand" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf" +checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" dependencies = [ "instant", ] @@ -1852,7 +1959,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6" dependencies = [ "crc32fast", - "miniz_oxide 0.5.3", + "miniz_oxide 0.5.4", ] [[package]] @@ -1925,11 +2032,10 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" dependencies = [ - "matches", "percent-encoding", ] @@ -1954,6 +2060,31 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fs" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "collections", + "fsevent", + "futures 0.3.24", + "git2", + "gpui", + "lazy_static", + "libc", + "log", + "lsp", + "parking_lot 0.11.2", + "regex", + "rope", + "serde", + "serde_json", + "smol", + "tempfile", + "util", +] + [[package]] name = "fs-set-times" version = "0.15.0" @@ -2008,9 +2139,15 @@ checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" [[package]] name = "futures" -version = "0.3.21" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e" +checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" + +[[package]] +name = "futures" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f21eda599937fba36daeb58a22e8f5cee2d14c4a17b5b7739c7c8e5e3b8230c" dependencies = [ "futures-channel", "futures-core", @@ -2023,9 +2160,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" +checksum = "30bdd20c28fadd505d0fd6712cdfcb0d4b5648baf45faef7f852afb2399bb050" dependencies = [ "futures-core", "futures-sink", @@ -2033,15 +2170,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" +checksum = "4e5aa3de05362c3fb88de6531e6296e85cde7739cccad4b9dfeeb7f6ebce56bf" [[package]] name = "futures-executor" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6" +checksum = "9ff63c23854bee61b6e9cd331d523909f238fc7636290b96826e9cfa5faa00ab" dependencies = [ "futures-core", "futures-task", @@ -2061,9 +2198,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b" +checksum = "bbf4d2a7a308fd4578637c0b17c7e1c7ba127b8f6ba00b29f717e9655d85eb68" [[package]] name = "futures-lite" @@ -2082,9 +2219,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512" +checksum = "42cd15d1c7456c04dbdf7e88bcd69760d74f3a798d6444e16974b505b0e62f17" dependencies = [ "proc-macro2", "quote", @@ -2093,22 +2230,23 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868" +checksum = "21b20ba5a92e727ba30e72834706623d94ac93a725410b6a6b6fbc1b07f7ba56" [[package]] name = "futures-task" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" +checksum = "a6508c467c73851293f390476d4491cf4d227dbabcd4170f3bb6044959b294f1" [[package]] name = "futures-util" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" +checksum = "44fb6cb1be61cc1d2e43b262516aafcf63b241cffdb1d3fa115f91d9c7b09c90" dependencies = [ + "futures 0.1.31", "futures-channel", "futures-core", "futures-io", @@ -2119,6 +2257,7 @@ dependencies = [ "pin-project-lite 0.2.9", "pin-utils", "slab", + "tokio-io", ] [[package]] @@ -2140,9 +2279,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803" +checksum = "bff49e947297f3312447abdca79f45f4738097cc82b06e72054d2223f601f1b9" dependencies = [ "typenum", "version_check", @@ -2182,15 +2321,48 @@ dependencies = [ [[package]] name = "gimli" -version = "0.26.1" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78cc372d058dcf6d5ecd98510e7fbc9e5aec4d21de70f65fea8fecebcd881bd4" +checksum = "22030e2c5a68ec659fde1e949a745124b48e6fa8b045b7ed5bd1fe4ccc5c4e5d" dependencies = [ "fallible-iterator", "indexmap", "stable_deref_trait", ] +[[package]] +name = "git" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "clock", + "collections", + "futures 0.3.24", + "git2", + "lazy_static", + "log", + "parking_lot 0.11.2", + "smol", + "sum_tree", + "text", + "unindent", + "util", +] + +[[package]] +name = "git2" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2994bee4a3a6a51eb90c218523be382fd7ea09b16380b9312e9dbe955ff7c7d1" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "url", +] + [[package]] name = "glob" version = "0.3.0" @@ -2244,9 +2416,10 @@ dependencies = [ "etagere", "font-kit", "foreign-types", - "futures", + "futures 0.3.24", "gpui_macros", "image", + "itertools", "lazy_static", "log", "media", @@ -2269,7 +2442,7 @@ dependencies = [ "smallvec", "smol", "sum_tree", - "time 0.3.11", + "time 0.3.15", "tiny-skia", "tree-sitter", "usvg", @@ -2288,11 +2461,11 @@ dependencies = [ [[package]] name = "h2" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37a82c6d637fc9515a4694bbf1cb2457b79d81ce52b3108bdeea58b07dd34a57" +checksum = "5ca32592cf21ac7ccab1825cd87f6c9b3d9022c44d086172ed0966bec8af30be" dependencies = [ - "bytes", + "bytes 1.2.1", "fnv", "futures-core", "futures-sink", @@ -2301,7 +2474,7 @@ dependencies = [ "indexmap", "slab", "tokio", - "tokio-util 0.7.3", + "tokio-util 0.7.4", "tracing", ] @@ -2316,36 +2489,36 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.12.1" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ "ahash", ] [[package]] name = "hashlink" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d452c155cb93fecdfb02a73dd57b5d8e442c2063bd7aac72f1bc5e4263a43086" +checksum = "69fe1fcf8b4278d860ad0548329f892a3631fb63f82574df68275f34cdbe0ffa" dependencies = [ - "hashbrown 0.12.1", + "hashbrown 0.12.3", ] [[package]] name = "headers" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cff78e5788be1e0ab65b04d306b2ed5092c815ec97ec70f4ebd5aee158aa55d" +checksum = "f3e372db8e5c0d213e0cd0b9be18be2aca3d44cf2fe30a9d46a65581cd454584" dependencies = [ "base64", "bitflags", - "bytes", + "bytes 1.2.1", "headers-core", "http", "httpdate", "mime", - "sha-1 0.10.0", + "sha1", ] [[package]] @@ -2386,9 +2559,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.2.3" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d37fb7dc756218a0559bfc21e4381f03cbb696cdaf959e7e95e927496f0564cd" +checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" dependencies = [ "libc", ] @@ -2424,7 +2597,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.3", + "digest 0.10.5", ] [[package]] @@ -2433,7 +2606,7 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" dependencies = [ - "bytes", + "bytes 1.2.1", "fnv", "itoa", ] @@ -2444,7 +2617,7 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" dependencies = [ - "bytes", + "bytes 1.2.1", "http", "pin-project-lite 0.2.9", ] @@ -2457,9 +2630,9 @@ checksum = "0bfe8eed0a9285ef776bb792479ea3834e8b94e13d615c2f66d03dd50a435a29" [[package]] name = "httparse" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "496ce29bb5a52785b44e0f7ca2847ae0bb839c9bd28f69acac9b99d461c0c04c" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" [[package]] name = "httpdate" @@ -2475,11 +2648,11 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "0.14.19" +version = "0.14.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42dc3c131584288d375f2d07f822b0cb012d8c6fb899a5b9fdb3cb7eb9b6004f" +checksum = "02c929dc5c39e335a03c405292728118860721b10190d98c2a0f0efd5baafbac" dependencies = [ - "bytes", + "bytes 1.2.1", "futures-channel", "futures-core", "futures-util", @@ -2515,7 +2688,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ - "bytes", + "bytes 1.2.1", "hyper", "native-tls", "tokio", @@ -2523,12 +2696,35 @@ dependencies = [ ] [[package]] -name = "idna" -version = "0.2.3" +name = "iana-time-zone" +version = "0.1.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +checksum = "f5a6ef98976b22b3b7f2f3a806f858cb862044cfa66805aa3ad84cb3d3b785ed" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "winapi 0.3.9", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +dependencies = [ + "cxx", + "cxx-build", +] + +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" dependencies = [ - "matches", "unicode-bidi", "unicode-normalization", ] @@ -2539,7 +2735,7 @@ version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "713f1b139373f96a2e0ce3ac931cd01ee973c3c5dd7c40c0c2efe96ad2b6751d" dependencies = [ - "crossbeam-utils 0.8.10", + "crossbeam-utils 0.8.12", "globset", "lazy_static", "log", @@ -2577,15 +2773,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" dependencies = [ "autocfg 1.1.0", - "hashbrown 0.12.1", + "hashbrown 0.12.3", "serde", ] [[package]] name = "indoc" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05a0bd019339e5d968b37855180087b7b9d512c5046fbd244cf8c95687927d6e" +checksum = "adab1eaa3408fb7f0c777a73e7465fd5656136fc93b670eb6df3c88c2c1344e3" [[package]] name = "instant" @@ -2656,7 +2852,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c89a757e762896bdbdfadf2860d0f8b0cea5e363d8cf3e7bdfeb63d1d976352" dependencies = [ - "hermit-abi 0.2.3", + "hermit-abi 0.2.6", "io-lifetimes", "rustix", "winapi 0.3.9", @@ -2670,7 +2866,7 @@ checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9" dependencies = [ "async-channel", "castaway", - "crossbeam-utils 0.8.10", + "crossbeam-utils 0.8.12", "curl", "curl-sys", "encoding_rs", @@ -2691,18 +2887,18 @@ dependencies = [ [[package]] name = "itertools" -version = "0.10.3" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9a9d19fa1e79b6215ff29b9d6880b706147f16e9b1dbb1e4e5947b5b02bc5e3" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" +checksum = "4217ad341ebadf8d8e724e264f13e593e0648f5b3e94b3896a5df283be015ecc" [[package]] name = "ittapi-rs" @@ -2715,9 +2911,9 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af25a77299a7f711a01975c35a6a424eb6862092cc2d6c72c4ed6cbc56dfc1fa" +checksum = "068b1ee6743e4d11fb9c6a1e6064b3693a1b600e7f5f5988047d98b3dc9fb90b" dependencies = [ "libc", ] @@ -2731,6 +2927,8 @@ dependencies = [ "editor", "gpui", "log", + "settings", + "shellexpand", "util", "workspace", ] @@ -2746,9 +2944,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.58" +version = "0.3.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3fac17f7123a73ca62df411b1bf727ccc805daa070338fda671c86dac1bdc27" +checksum = "49409df3e3bf0856b916e2ceaca09ee28e6871cf7d9ce97a692cacfdb2a25a47" dependencies = [ "wasm-bindgen", ] @@ -2767,11 +2965,11 @@ checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f" dependencies = [ "base64", "crypto-common", - "digest 0.10.3", + "digest 0.10.5", "hmac 0.12.1", "serde", "serde_json", - "sha2 0.10.2", + "sha2 0.10.6", ] [[package]] @@ -2805,8 +3003,10 @@ dependencies = [ "collections", "ctor", "env_logger", - "futures", + "fs", + "futures 0.3.24", "fuzzy", + "git", "gpui", "lazy_static", "log", @@ -2826,6 +3026,8 @@ dependencies = [ "text", "theme", "tree-sitter", + "tree-sitter-html", + "tree-sitter-javascript", "tree-sitter-json 0.19.0", "tree-sitter-python", "tree-sitter-rust", @@ -2857,9 +3059,21 @@ checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" [[package]] name = "libc" -version = "0.2.126" +version = "0.2.135" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" +checksum = "68783febc7782c6c5cb401fbda4de5a9898be1762314da0bb2c10ced61f18b0c" + +[[package]] +name = "libgit2-sys" +version = "0.14.0+1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a00859c70c8a4f7218e6d1cc32875c4b55f6799445b842b0d8ed5e4c3d959b" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] [[package]] name = "libloading" @@ -2873,9 +3087,9 @@ dependencies = [ [[package]] name = "libm" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33a33a362ce288760ec6a508b94caaec573ae7d3bbbd91b87aa0bad4456839db" +checksum = "292a948cd991e376cf75541fe5b97a1081d713c618b4f1b9500f8844e49eb565" [[package]] name = "libnghttp2-sys" @@ -2922,6 +3136,15 @@ dependencies = [ "safemem", ] +[[package]] +name = "link-cplusplus" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9272ab7b96c9046fbc5bc56c06c117cb639fe2d509df0c421cad82d2915cf369" +dependencies = [ + "cc", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -2951,7 +3174,7 @@ dependencies = [ "anyhow", "core-foundation", "core-graphics", - "futures", + "futures 0.3.24", "media", "parking_lot 0.11.2", "serde", @@ -2960,9 +3183,9 @@ dependencies = [ [[package]] name = "lock_api" -version = "0.4.7" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53" +checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" dependencies = [ "autocfg 1.1.0", "scopeguard", @@ -2988,7 +3211,7 @@ dependencies = [ "collections", "ctor", "env_logger", - "futures", + "futures 0.3.24", "gpui", "log", "lsp-types", @@ -3067,11 +3290,11 @@ checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" [[package]] name = "md-5" -version = "0.10.1" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658646b21e0b72f7866c7038ab086d3d5e1cd6271f060fd37defb241949d0582" +checksum = "6365506850d44bff6e2fbcb5176cf63650e48bd45ef2fe2665ae1570e0f4b9ca" dependencies = [ - "digest 0.10.3", + "digest 0.10.5", ] [[package]] @@ -3081,7 +3304,7 @@ dependencies = [ "anyhow", "bindgen", "block", - "bytes", + "bytes 1.2.1", "core-foundation", "foreign-types", "metal", @@ -3175,9 +3398,9 @@ dependencies = [ [[package]] name = "miniz_oxide" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f5c75688da582b8ffc1f1799e9db273f32133c49e048f614d22ec3256773ccc" +checksum = "96590ba8f175222643a85693f33d26e9c8a015f599c216509b1a6894af675d34" dependencies = [ "adler", ] @@ -3210,7 +3433,7 @@ dependencies = [ "libc", "log", "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys", + "windows-sys 0.36.1", ] [[package]] @@ -3351,6 +3574,16 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi 0.3.9", +] + [[package]] name = "num-bigint" version = "0.4.3" @@ -3441,6 +3674,21 @@ dependencies = [ "libc", ] +[[package]] +name = "nvim-rs" +version = "0.5.0" +source = "git+https://github.com/KillTheMule/nvim-rs?branch=master#d701c2790dcb2579f8f4d7003ba30e2100a7d25b" +dependencies = [ + "async-trait", + "futures 0.3.24", + "log", + "parity-tokio-ipc", + "rmp", + "rmpv", + "tokio", + "tokio-util 0.7.4", +] + [[package]] name = "objc" version = "0.2.7" @@ -3473,10 +3721,19 @@ dependencies = [ ] [[package]] -name = "once_cell" -version = "1.13.0" +name = "object" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1" +checksum = "21158b2c33aa6d4561f1c0a6ea283ca92bc54802a93b263e910746d679a7eb53" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e82dad04139b71a90c080c8463fe0dc7902db5192d939bd0950f074d014339e1" [[package]] name = "opaque-debug" @@ -3486,9 +3743,9 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" [[package]] name = "openssl" -version = "0.10.40" +version = "0.10.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb81a6430ac911acb25fe5ac8f1d2af1b4ea8a4fdfda0f1ee4292af2e2d8eb0e" +checksum = "12fc0523e3bd51a692c8850d075d74dc062ccf251c0110668cbd921917118a13" dependencies = [ "bitflags", "cfg-if 1.0.0", @@ -3518,9 +3775,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.74" +version = "0.9.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "835363342df5fba8354c5b453325b110ffd54044e588c539cf2f20a8014e4cb1" +checksum = "5230151e44c0f05157effb743e8d517472843121cf9243e8b81393edb5acd9ce" dependencies = [ "autocfg 1.1.0", "cc", @@ -3540,9 +3797,9 @@ dependencies = [ [[package]] name = "os_str_bytes" -version = "6.1.0" +version = "6.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21326818e99cfe6ce1e524c2a805c189a99b5ae555a35d19f9a284b427d86afa" +checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff" [[package]] name = "outline" @@ -3561,6 +3818,26 @@ dependencies = [ "workspace", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parity-tokio-ipc" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9981e32fb75e004cc148f5fb70342f393830e0a4aa62e3cc93b50976218d42b6" +dependencies = [ + "futures 0.3.24", + "libc", + "log", + "rand 0.7.3", + "tokio", + "winapi 0.3.9", +] + [[package]] name = "parking" version = "2.0.0" @@ -3585,7 +3862,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" dependencies = [ "lock_api", - "parking_lot_core 0.9.3", + "parking_lot_core 0.9.4", ] [[package]] @@ -3604,15 +3881,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" +checksum = "4dc9e0dc2adc1c69d09143aff38d3d30c5c3f0df0dad82e6d25547af174ebec0" dependencies = [ "cfg-if 1.0.0", "libc", "redox_syscall", "smallvec", - "windows-sys", + "windows-sys 0.42.0", ] [[package]] @@ -3622,15 +3899,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77e0b28ace46c5a396546bcf443bf422b57049617433d8854227352a4a9b24e7" dependencies = [ "base64ct", - "rand_core 0.6.3", + "rand_core 0.6.4", "subtle", ] [[package]] name = "paste" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c520e05135d6e763148b6426a837e239041653ba7becd2e538c076c738025fc" +checksum = "b1de2e551fb905ac83f73f7aedf2f0cb4a0da7e35efa24a202a936269f1f18e1" [[package]] name = "pathfinder_color" @@ -3688,16 +3965,17 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" +checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" [[package]] name = "pest" -version = "2.1.3" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" +checksum = "dbc7bc69c062e492337d74d59b120c274fd3d261b6bf6d3207d499b4b379c41a" dependencies = [ + "thiserror", "ucd-trie", ] @@ -3735,18 +4013,18 @@ checksum = "db8bcd96cb740d03149cbad5518db9fd87126a10ab519c011893b1754134c468" [[package]] name = "pin-project" -version = "1.0.11" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78203e83c48cffbe01e4a2d35d566ca4de445d79a85372fc64e378bfc812a260" +checksum = "ad29a609b6bcd67fee905812e544992d216af9d755757c05ed2d0e15a74c6ecc" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.0.11" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "710faf75e1b33345361201d36d04e98ac1ed8909151a017ed384700836104c74" +checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55" dependencies = [ "proc-macro2", "quote", @@ -3787,7 +4065,7 @@ dependencies = [ "indexmap", "line-wrap", "serde", - "time 0.3.11", + "time 0.3.15", "xml-rs", ] @@ -3840,10 +4118,11 @@ dependencies = [ [[package]] name = "polling" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685404d509889fade3e86fe3a5803bca2ec09b0c0778d5ada6ec8bf7a8de5259" +checksum = "899b00b9c8ab553c743b3e11e87c5c7d423b2a2de229ba95b24a756344748011" dependencies = [ + "autocfg 1.1.0", "cfg-if 1.0.0", "libc", "log", @@ -3865,7 +4144,7 @@ checksum = "a63d25391d04a097954b76aba742b6b5b74f213dfe3dbaeeb36e8ddc1c657f0b" dependencies = [ "atomic", "crossbeam-queue", - "futures", + "futures 0.3.24", "log", "pin-project", "pollster", @@ -3905,9 +4184,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.40" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd96a1e8ed2596c337f8eae5f24924ec83f5ad5ab21ea8e455d3566c69fbcaf7" +checksum = "5ea3d908b0e36316caf9e9e2c4625cdde190a7e6f440d794667ed17a1855e725" dependencies = [ "unicode-ident", ] @@ -3933,14 +4212,16 @@ dependencies = [ "client", "clock", "collections", + "db", + "fs", "fsevent", - "futures", + "futures 0.3.24", "fuzzy", + "git", "gpui", "ignore", "language", "lazy_static", - "libc", "log", "lsp", "parking_lot 0.11.2", @@ -3953,7 +4234,7 @@ dependencies = [ "serde", "serde_json", "settings", - "sha2 0.10.2", + "sha2 0.10.6", "similar", "smol", "sum_tree", @@ -3971,7 +4252,7 @@ version = "0.1.0" dependencies = [ "context_menu", "editor", - "futures", + "futures 0.3.24", "gpui", "menu", "postage", @@ -3990,7 +4271,7 @@ version = "0.1.0" dependencies = [ "anyhow", "editor", - "futures", + "futures 0.3.24", "fuzzy", "gpui", "language", @@ -4008,9 +4289,9 @@ dependencies = [ [[package]] name = "prometheus" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cface98dfa6d645ea4c789839f176e4b072265d085bfcc48eaa8d137f58d3c39" +checksum = "45c8babc29389186697fe5a2a4859d697825496b83db5d0b65271cdc0488e88c" dependencies = [ "cfg-if 1.0.0", "fnv", @@ -4027,7 +4308,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de5e2533f59d08fcf364fd374ebda0692a70bd6d7e66ef97f306f45c6c5d8020" dependencies = [ - "bytes", + "bytes 1.2.1", "prost-derive 0.8.0", ] @@ -4037,7 +4318,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "444879275cb4fd84958b1a1d5420d15e6fcf7c235fe47f053c9c2a80aceb6001" dependencies = [ - "bytes", + "bytes 1.2.1", "prost-derive 0.9.0", ] @@ -4047,7 +4328,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62941722fb675d463659e49c4f3fe1fe792ff24fe5bbaa9c08cd3b98a1c354f5" dependencies = [ - "bytes", + "bytes 1.2.1", "heck 0.3.3", "itertools", "lazy_static", @@ -4093,30 +4374,30 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "534b7a0e836e3c482d2693070f982e39e7611da9695d4d1f5a4b186b51faef0a" dependencies = [ - "bytes", + "bytes 1.2.1", "prost 0.9.0", ] [[package]] name = "protobuf" -version = "2.27.1" +version = "2.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf7e6d18738ecd0902d30d1ad232c9125985a3422929b16c65517b38adc14f96" +checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" [[package]] name = "psm" -version = "0.1.19" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd89aa18fbf9533a581355a22438101fe9c2ed8c9e2f0dcf520552a3afddf2" +checksum = "5787f7cda34e3033a72192c018bc5883100330f362ef279a8cbccfce8bb4e874" dependencies = [ "cc", ] [[package]] name = "pulldown-cmark" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34f197a544b0c9ab3ae46c359a7ec9cbbb5c7bf97054266fecb7ead794a181d6" +checksum = "2d9cc634bc78768157b5cbfe988ffcd1dcba95cd2b2f03a88316c08c6d00ed63" dependencies = [ "bitflags", "memchr", @@ -4125,9 +4406,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804" +checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" dependencies = [ "proc-macro2", ] @@ -4166,7 +4447,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha 0.3.1", - "rand_core 0.6.3", + "rand_core 0.6.4", ] [[package]] @@ -4186,7 +4467,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.3", + "rand_core 0.6.4", ] [[package]] @@ -4215,9 +4496,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom 0.2.7", ] @@ -4249,9 +4530,9 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "258bcdb5ac6dad48491bb2992db6b7cf74878b0384908af124823d118c99683f" dependencies = [ - "crossbeam-channel 0.5.5", + "crossbeam-channel 0.5.6", "crossbeam-deque", - "crossbeam-utils 0.8.10", + "crossbeam-utils 0.8.12", "num_cpus", ] @@ -4272,9 +4553,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.2.13" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" dependencies = [ "bitflags", ] @@ -4351,12 +4632,12 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75aa69a3f06bbcc66ede33af2af253c6f7a86b1ca0033f60c580a27074fbf92" +checksum = "431949c384f4e2ae07605ccaa56d1d9d2ecdb5cadd4f9577ccfab29f2e5149fc" dependencies = [ "base64", - "bytes", + "bytes 1.2.1", "encoding_rs", "futures-core", "futures-util", @@ -4367,10 +4648,10 @@ dependencies = [ "hyper-tls", "ipnet", "js-sys", - "lazy_static", "log", "mime", "native-tls", + "once_cell", "percent-encoding", "pin-project-lite 0.2.9", "serde", @@ -4404,9 +4685,9 @@ dependencies = [ [[package]] name = "rgb" -version = "0.8.33" +version = "0.8.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b221de559e4a29df3b957eec92bc0de6bc8eaf6ca9cfed43e5e1d67ff65a34" +checksum = "3603b7d71ca82644f79b5a06d1220e9a58ede60bd32255f698cb1af8838b8db3" dependencies = [ "bytemuck", ] @@ -4426,6 +4707,27 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "rmp" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44519172358fd6d58656c86ab8e7fbc9e1490c3e8f14d35ed78ca0dd07403c9f" +dependencies = [ + "byteorder", + "num-traits", + "paste", +] + +[[package]] +name = "rmpv" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de8813b3a2f95c5138fe5925bfb8784175d88d6bff059ba8ce090aa891319754" +dependencies = [ + "num-traits", + "rmp", +] + [[package]] name = "rocksdb" version = "0.18.0" @@ -4435,6 +4737,20 @@ dependencies = [ "librocksdb-sys", ] +[[package]] +name = "rope" +version = "0.1.0" +dependencies = [ + "arrayvec 0.7.2", + "bromberg_sl2", + "gpui", + "log", + "rand 0.8.5", + "smallvec", + "sum_tree", + "util", +] + [[package]] name = "roxmltree" version = "0.14.1" @@ -4456,7 +4772,7 @@ dependencies = [ "collections", "ctor", "env_logger", - "futures", + "futures 0.3.24", "gpui", "parking_lot 0.11.2", "prost 0.8.0", @@ -4494,9 +4810,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "6.4.0" +version = "6.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a17e5ac65b318f397182ae94e532da0ba56b88dd1200b774715d36c4943b1c3" +checksum = "e26934cd67a1da1165efe61cba4047cc1b4a526019da609fcce13a1000afb5fa" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -4505,9 +4821,9 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "6.2.0" +version = "6.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94e763e24ba2bf0c72bc6be883f967f794a019fafd1b86ba1daff9c91a7edd30" +checksum = "e35d7b402e273544cc08e0824aa3404333fab8a90ac43589d3d5b72f4b346e12" dependencies = [ "proc-macro2", "quote", @@ -4518,12 +4834,12 @@ dependencies = [ [[package]] name = "rust-embed-utils" -version = "7.2.0" +version = "7.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756feca3afcbb1487a1d01f4ecd94cf8ec98ea074c55a69e7136d29fb6166029" +checksum = "c1669d81dfabd1b5f8e2856b8bbe146c6192b0ba22162edc738ac0a5de18f054" dependencies = [ "globset", - "sha2 0.9.9", + "sha2 0.10.6", "walkdir", ] @@ -4579,9 +4895,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.20.6" +version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aab8ee6c7097ed6057f43c187a62418d0c05a4bd5f18b3571db50ee0f9ce033" +checksum = "539a2bfe908f471bfa933876bd1eb6a19cf2176d375f82ef7f99530a40e48c2c" dependencies = [ "log", "ring", @@ -4591,9 +4907,9 @@ dependencies = [ [[package]] name = "rustls-pemfile" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7522c9de787ff061458fe9a829dc790a3f5b22dc571694fc5883f448b94d9a9" +checksum = "0864aeff53f8c05aa08d86e5ef839d3dfcf07aeba2db32f12db0ef716e87bd55" dependencies = [ "base64", ] @@ -4616,9 +4932,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695" +checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" [[package]] name = "safe_arch" @@ -4660,14 +4976,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88d6731146462ea25d9244b2ed5fd1d716d25c52e4d54aa4fb0f3c4e9854dbe2" dependencies = [ "lazy_static", - "windows-sys", + "windows-sys 0.36.1", ] [[package]] name = "schemars" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1847b767a3d62d95cbf3d8a9f0e421cf57a0d8aa4f411d4b16525afb0284d4ed" +checksum = "2a5fb6c61f29e723026dc8e923d94c694313212abbecbbe5f55a7748eec5b307" dependencies = [ "dyn-clone", "schemars_derive", @@ -4677,9 +4993,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af4d7e1b012cb3d9129567661a63755ea4b8a7386d339dc945ae187e403c6743" +checksum = "f188d036977451159430f3b8dc82ec76364a42b7e289c2b18a9a18f4470058e9" dependencies = [ "proc-macro2", "quote", @@ -4699,6 +5015,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "scratch" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8132065adcfd6e02db789d9285a0deb2f3fcb04002865ab67d5fb103533898" + [[package]] name = "scrypt" version = "0.7.0" @@ -4764,9 +5086,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dc14f172faf8a0194a3aded622712b0de276821addc574fa54fc0a1167e10dc" +checksum = "2bc1bb97804af6631813c55739f771071e0f2ed33ee20b68c86ec505d906356c" dependencies = [ "bitflags", "core-foundation", @@ -4811,18 +5133,18 @@ checksum = "5a9f47faea3cad316faa914d013d24f471cd90bfca1a0c70f05a3f42c6441e99" [[package]] name = "serde" -version = "1.0.138" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1578c6245786b9d168c5447eeacfb96856573ca56c9d68fdcf394be134882a47" +checksum = "728eb6351430bccb993660dfffc5a72f91ccc1295abaa8ce19b27ebe4f75568b" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.138" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "023e9b1467aef8a10fb88f25611870ada9800ef7e22afce356bb0d2387b6f27c" +checksum = "81fa1584d3d1bcacd84c277a0dfe21f5b0f6accf4a23d04d4c6d61f1af522b4c" dependencies = [ "proc-macro2", "quote", @@ -4851,9 +5173,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.82" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82c2c1fdcd807d1098552c5b9a36e425e42e9fbd7c6a37a8425f390f781f7fa7" +checksum = "41feea4228a6f1cd09ec7a3593a682276702cd67b5273544757dae23c096f074" dependencies = [ "indexmap", "itoa", @@ -4863,18 +5185,18 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7868ad3b8196a8a0aea99a8220b124278ee5320a55e4fde97794b6f85b1a377" +checksum = "184c643044780f7ceb59104cef98a5a6f12cb2288a7bc701ab93a362b49fd47d" dependencies = [ "serde", ] [[package]] name = "serde_repr" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2ad84e47328a31223de7fed7a4f5087f2d6ddfe586cf3ca25b7a165bc0a5aed" +checksum = "1fe39d9fbb0ebf5eb2c7cb7e2a47e4f462fad1379f1166b8ae49ad9eae89a7ca" dependencies = [ "proc-macro2", "quote", @@ -4895,9 +5217,9 @@ dependencies = [ [[package]] name = "serde_yaml" -version = "0.8.24" +version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707d15895415db6628332b737c838b88c598522e4dc70647e59b72312924aebc" +checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b" dependencies = [ "indexmap", "ryu", @@ -4933,14 +5255,20 @@ dependencies = [ "anyhow", "assets", "collections", + "fs", + "futures 0.3.24", "gpui", "json_comments", + "postage", "schemars", "serde", "serde_json", "serde_path_to_error", "theme", "toml", + "tree-sitter", + "tree-sitter-json 0.19.0", + "unindent", "util", ] @@ -4965,7 +5293,18 @@ checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f" dependencies = [ "cfg-if 1.0.0", "cpufeatures", - "digest 0.10.3", + "digest 0.10.5", +] + +[[package]] +name = "sha1" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +dependencies = [ + "cfg-if 1.0.0", + "cpufeatures", + "digest 0.10.5", ] [[package]] @@ -4983,13 +5322,13 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.2" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676" +checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" dependencies = [ "cfg-if 1.0.0", "cpufeatures", - "digest 0.10.3", + "digest 0.10.5", ] [[package]] @@ -5003,11 +5342,11 @@ dependencies = [ [[package]] name = "shellexpand" -version = "2.1.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83bdb7831b2d85ddf4a7b148aa19d0587eddbe8671a436b7bd1182eaad0f2829" +checksum = "7ccc8076840c4da029af4f87e4e8daeb0fca6b87bbb02e10cb60b791450e11e4" dependencies = [ - "dirs-next", + "dirs 4.0.0", ] [[package]] @@ -5093,9 +5432,12 @@ checksum = "0b8de496cf83d4ed58b6be86c3a275b8602f6ffe98d3024a869e124147a9a3ac" [[package]] name = "slab" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb703cfe953bccee95685111adeedb76fabe4e97549a58d16f03ea7b9367bb32" +checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef" +dependencies = [ + "autocfg 1.1.0", +] [[package]] name = "slice-group-by" @@ -5116,9 +5458,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1" +checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" [[package]] name = "smol" @@ -5158,9 +5500,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.4.4" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0" +checksum = "02e2d2db9033d13a1567121ddd7a095ee144db4e1ca1b1bda3419bc0da294ebd" dependencies = [ "libc", "winapi 0.3.9", @@ -5180,9 +5522,9 @@ checksum = "be6c3f39c37a4283ee4b43d1311c828f2e1fb0541e76ea0cb1a2abd9ef2f5b3b" [[package]] name = "sqlformat" -version = "0.1.8" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4b7922be017ee70900be125523f38bdd644f4f06a1b16e8fa5a8ee8c34bffd4" +checksum = "f87e292b4291f154971a43c3774364e2cbcaec599d3f5bf6fa9d122885dbc38a" dependencies = [ "itertools", "nom", @@ -5191,9 +5533,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f82cbe94f41641d6c410ded25bbf5097c240cefdf8e3b06d04198d0a96af6a4" +checksum = "9249290c05928352f71c077cc44a464d880c63f26f7534728cca008e135c0428" dependencies = [ "sqlx-core", "sqlx-macros", @@ -5201,19 +5543,20 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b69bf218860335ddda60d6ce85ee39f6cf6e5630e300e19757d1de15886a093" +checksum = "dcbc16ddba161afc99e14d1713a453747a2b07fc097d2009f4c300ec99286105" dependencies = [ "ahash", "atoi", "base64", "bitflags", "byteorder", - "bytes", + "bytes 1.2.1", "crc", "crossbeam-queue", "dirs 4.0.0", + "dotenvy", "either", "event-listener", "futures-channel", @@ -5234,38 +5577,38 @@ dependencies = [ "paste", "percent-encoding", "rand 0.8.5", - "rustls 0.20.6", + "rustls 0.20.7", "rustls-pemfile", "serde", "serde_json", - "sha-1 0.10.0", - "sha2 0.10.2", + "sha1", + "sha2 0.10.6", "smallvec", "sqlformat", "sqlx-rt", "stringprep", "thiserror", - "time 0.3.11", + "time 0.3.15", "tokio-stream", "url", - "uuid 1.1.2", - "webpki-roots 0.22.3", + "uuid 1.2.1", + "webpki-roots 0.22.5", "whoami", ] [[package]] name = "sqlx-macros" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40c63177cf23d356b159b60acd27c54af7423f1736988502e36bae9a712118f" +checksum = "b850fa514dc11f2ee85be9d055c512aa866746adfacd1cb42d867d68e6a5b0d9" dependencies = [ - "dotenv", + "dotenvy", "either", "heck 0.4.0", "once_cell", "proc-macro2", "quote", - "sha2 0.10.2", + "sha2 0.10.6", "sqlx-core", "sqlx-rt", "syn", @@ -5274,9 +5617,9 @@ dependencies = [ [[package]] name = "sqlx-rt" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874e93a365a598dc3dadb197565952cb143ae4aa716f7bcc933a8d836f6bf89f" +checksum = "24c5b2d25fa654cc5f841750b8e1cdedbe21189bf9a9382ee90bfa9dd3562396" dependencies = [ "once_cell", "tokio", @@ -5371,9 +5714,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.98" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" +checksum = "3fcd952facd492f9be3ef0d0b7032a6e442ee9b361d4acc2b1d0c4aaa5f613a1" dependencies = [ "proc-macro2", "quote", @@ -5463,14 +5806,17 @@ dependencies = [ "context_menu", "dirs 4.0.0", "editor", - "futures", + "futures 0.3.24", "gpui", "itertools", + "lazy_static", "libc", "mio-extras", "ordered-float", "procinfo", "project", + "rand 0.8.5", + "serde", "settings", "shellexpand", "smallvec", @@ -5486,13 +5832,12 @@ name = "text" version = "0.1.0" dependencies = [ "anyhow", - "arrayvec 0.7.2", - "bromberg_sl2", "clock", "collections", "ctor", "digest 0.9.0", "env_logger", + "fs", "gpui", "lazy_static", "log", @@ -5500,6 +5845,7 @@ dependencies = [ "postage", "rand 0.8.5", "regex", + "rope", "smallvec", "sum_tree", "util", @@ -5516,9 +5862,9 @@ dependencies = [ [[package]] name = "textwrap" -version = "0.15.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" +checksum = "949517c0cf1bf4ee812e2e07e08ab448e3ae0d23472aee8a06c985f0c8815b16" [[package]] name = "theme" @@ -5565,18 +5911,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.31" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" +checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.31" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" +checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb" dependencies = [ "proc-macro2", "quote", @@ -5622,9 +5968,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.11" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72c91f41dcb2f096c05f0873d667dceec1087ce5bcf984ec8ffb19acddbb3217" +checksum = "d634a985c4d4238ec39cacaed2e7ae552fbd3c476b552c1deac3021b7d7eaf0c" dependencies = [ "itoa", "libc", @@ -5683,16 +6029,16 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.19.2" +version = "1.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51a52ed6686dd62c320f9b89299e9dfb46f730c7a48e635c19f21d116cb1439" +checksum = "a9e03c497dc955702ba729190dc4aac6f2a0ce97f913e5b1b5912fc5039d9099" dependencies = [ - "bytes", + "autocfg 1.1.0", + "bytes 1.2.1", "libc", "memchr", "mio 0.8.4", "num_cpus", - "once_cell", "parking_lot 0.12.1", "pin-project-lite 0.2.9", "signal-hook-registry", @@ -5701,6 +6047,17 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "tokio-io" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fc868aae093479e3131e3d165c93b1c7474109d13c90ec0dda2a1bbfff0674" +dependencies = [ + "bytes 0.4.12", + "futures 0.1.31", + "log", +] + [[package]] name = "tokio-io-timeout" version = "1.2.0" @@ -5738,16 +6095,16 @@ version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" dependencies = [ - "rustls 0.20.6", + "rustls 0.20.7", "tokio", "webpki 0.22.0", ] [[package]] name = "tokio-stream" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df54d54117d6fdc4e4fea40fe1e4e566b3505700e148a6827e59b34b0d2600d9" +checksum = "d660770404473ccd7bc9f8b28494a811bc18542b915c0855c51e8f419d5223ce" dependencies = [ "futures-core", "pin-project-lite 0.2.9", @@ -5756,14 +6113,14 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.17.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06cda1232a49558c46f8a504d5b93101d42c0bf7f911f12a105ba48168f821ae" +checksum = "f714dd15bead90401d77e04243611caec13726c2408afd5b31901dfcdcb3b181" dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.17.2", + "tungstenite 0.17.3", ] [[package]] @@ -5772,7 +6129,7 @@ version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36943ee01a6d67977dd3f84a5a1d2efeb4ada3a1ae771cadfaa535d9d9fc6507" dependencies = [ - "bytes", + "bytes 1.2.1", "futures-core", "futures-sink", "log", @@ -5782,12 +6139,13 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc463cd8deddc3770d20f9852143d50bf6094e640b485cb2e189a2099085ff45" +checksum = "0bb2e075f03b3d66d8d8785356224ba688d2906a371015e225beeb65ca92c740" dependencies = [ - "bytes", + "bytes 1.2.1", "futures-core", + "futures-io", "futures-sink", "pin-project-lite 0.2.9", "tokio", @@ -5812,7 +6170,7 @@ dependencies = [ "async-stream", "async-trait", "base64", - "bytes", + "bytes 1.2.1", "futures-core", "futures-util", "h2", @@ -5848,7 +6206,7 @@ dependencies = [ "rand 0.8.5", "slab", "tokio", - "tokio-util 0.7.3", + "tokio-util 0.7.4", "tower-layer", "tower-service", "tracing", @@ -5861,7 +6219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c530c8675c1dbf98facee631536fa116b5fb6382d7dd6dc1b118d970eafe3ba" dependencies = [ "bitflags", - "bytes", + "bytes 1.2.1", "futures-core", "futures-util", "http", @@ -5875,9 +6233,9 @@ dependencies = [ [[package]] name = "tower-layer" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343bc9466d3fe6b0f960ef45960509f84480bf4fd96f92901afe7ff3df9d3a62" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" [[package]] name = "tower-service" @@ -5887,9 +6245,9 @@ checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" [[package]] name = "tracing" -version = "0.1.35" +version = "0.1.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a400e31aa60b9d44a52a8ee0343b5b18566b03a8321e0d321f695cf56e940160" +checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" dependencies = [ "cfg-if 1.0.0", "log", @@ -5900,9 +6258,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11c75893af559bc8e10716548bdef5cb2b983f8e637db9d0e15126b61b484ee2" +checksum = "4017f8f45139870ca7e672686113917c71c7a6e02d4924eda67186083c03081a" dependencies = [ "proc-macro2", "quote", @@ -5911,9 +6269,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.28" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7358be39f2f274f322d2aaed611acc57f382e8eb1e5b48cb9ae30933495ce7" +checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" dependencies = [ "once_cell", "valuable", @@ -5952,12 +6310,12 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.14" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a713421342a5a666b7577783721d3117f1b69a393df803ee17bb73b1e122a59" +checksum = "a6176eae26dd70d0c919749377897b54a9276bd7061339665dd68777926b5a70" dependencies = [ - "ansi_term", "matchers", + "nu-ansi-term", "once_cell", "regex", "serde", @@ -5982,9 +6340,9 @@ dependencies = [ [[package]] name = "tree-sitter-c" -version = "0.20.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bdc5574c6cbc39c409246caeb1dd4d3c4bd6d30d4e9b399776086c20365fd24" +checksum = "cca211f4827d4b4dc79f388bf67b6fa3bc8a8cfa642161ef24f99f371ba34c7b" dependencies = [ "cc", "tree-sitter", @@ -6000,6 +6358,15 @@ dependencies = [ "tree-sitter", ] +[[package]] +name = "tree-sitter-css" +version = "0.19.0" +source = "git+https://github.com/tree-sitter/tree-sitter-css?rev=769203d0f9abe1a9a691ac2b9fe4bb4397a73c51#769203d0f9abe1a9a691ac2b9fe4bb4397a73c51" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "tree-sitter-elixir" version = "0.19.0" @@ -6018,6 +6385,26 @@ dependencies = [ "tree-sitter", ] +[[package]] +name = "tree-sitter-html" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "184e6b77953a354303dc87bf5fe36558c83569ce92606e7b382a0dc1b7443443" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2490fab08630b2c8943c320f7b63473cbf65511c8d83aec551beb9b4375906ed" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "tree-sitter-json" version = "0.19.0" @@ -6111,7 +6498,7 @@ checksum = "6ad3713a14ae247f22a728a0456a545df14acf3867f905adff84be99e23b3ad1" dependencies = [ "base64", "byteorder", - "bytes", + "bytes 1.2.1", "http", "httparse", "log", @@ -6124,13 +6511,13 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.17.2" +version = "0.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96a2dea40e7570482f28eb57afbe42d97551905da6a9400acc5c328d24004f5" +checksum = "e27992fd6a8c29ee7eef28fc78349aa244134e10ad447ce3b9f0ac0ed0fa4ce0" dependencies = [ "base64", "byteorder", - "bytes", + "bytes 1.2.1", "http", "httparse", "log", @@ -6149,9 +6536,9 @@ checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" [[package]] name = "ucd-trie" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89570599c4fe5585de2b388aab47e99f7fa4e9238a1399f707a02e356058141c" +checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81" [[package]] name = "unicase" @@ -6188,30 +6575,30 @@ checksum = "7f9af028e052a610d99e066b33304625dea9613170a2563314490a4e6ec5cf7f" [[package]] name = "unicode-ident" -version = "1.0.1" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c" +checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3" [[package]] name = "unicode-normalization" -version = "0.1.21" +version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854cbdc4f7bc6ae19c820d44abdc3277ac3e1b2b93db20a636825d9322fb60e6" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" dependencies = [ "tinyvec", ] [[package]] name = "unicode-script" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58dd944fd05f2f0b5c674917aea8a4df6af84f2d8de3fe8d988b95d28fb8fb09" +checksum = "7d817255e1bed6dfd4ca47258685d14d2bdcfbc64fdc9e3819bd5848057b8ecc" [[package]] name = "unicode-segmentation" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" +checksum = "0fdbf052a0783de01e944a6ce7a8cb939e295b1e7be835a1112c3b9a7f047a5a" [[package]] name = "unicode-vo" @@ -6221,15 +6608,15 @@ checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" [[package]] name = "unicode-width" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" [[package]] name = "unicode-xid" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04" +checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" [[package]] name = "unicode_categories" @@ -6239,9 +6626,9 @@ checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" [[package]] name = "unindent" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52fee519a3e570f7df377a06a1a7775cdbfb7aa460be7e08de2b1f0e69973a44" +checksum = "58ee9362deb4a96cef4d437d1ad49cffc9b9e92d202b6995674e928ce684f112" [[package]] name = "untrusted" @@ -6251,13 +6638,12 @@ checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" [[package]] name = "url" -version = "2.2.2" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" dependencies = [ "form_urlencoded", "idna", - "matches", "percent-encoding", "serde", ] @@ -6306,7 +6692,9 @@ name = "util" version = "0.1.0" dependencies = [ "anyhow", - "futures", + "futures 0.3.24", + "git2", + "lazy_static", "log", "rand 0.8.5", "serde_json", @@ -6324,9 +6712,12 @@ dependencies = [ [[package]] name = "uuid" -version = "1.1.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6469f4314d5f1ffec476e05f17cc9a78bc7a27a6a857842170bdf8d6f98d2f" +checksum = "feb41e78f93363bb2df8b0e86a2ca30eed7806ea16ea0c790d757cf93f79be83" +dependencies = [ + "getrandom 0.2.7", +] [[package]] name = "valuable" @@ -6371,6 +6762,8 @@ name = "vim" version = "0.1.0" dependencies = [ "assets", + "async-compat", + "async-trait", "collections", "command_palette", "editor", @@ -6378,11 +6771,16 @@ dependencies = [ "indoc", "itertools", "language", + "lazy_static", "log", + "nvim-rs", + "parking_lot 0.11.2", "project", "search", "serde", + "serde_json", "settings", + "tokio", "util", "workspace", ] @@ -6454,9 +6852,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasi-cap-std-sync" -version = "0.38.1" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c4e73ed64b92ae87b416f4274b3c827180b02b67f835f66a86fc4267b77349" +checksum = "f086c5026d2fc3b268d138e65373f46422cc810f46d6e0776859c5027cb18728" dependencies = [ "anyhow", "async-trait", @@ -6478,9 +6876,9 @@ dependencies = [ [[package]] name = "wasi-common" -version = "0.38.1" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc983eb93607a61f64152ec8728bf453f4dfdf22e7ab1784faac3297fe9a035e" +checksum = "4e8844fede1c3787cc08853872f47e8bd91f6c939c7406bc7a5dba496b260c08" dependencies = [ "anyhow", "bitflags", @@ -6496,9 +6894,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.81" +version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c53b543413a17a202f4be280a7e5c62a1c69345f5de525ee64f8cfdbc954994" +checksum = "eaf9f5aceeec8be17c128b2e93e031fb8a4d469bb9c4ae2d7dc1888b26887268" dependencies = [ "cfg-if 1.0.0", "wasm-bindgen-macro", @@ -6506,13 +6904,13 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.81" +version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5491a68ab4500fa6b4d726bd67408630c3dbe9c4fe7bda16d5c82a1fd8c7340a" +checksum = "4c8ffb332579b0557b52d268b91feab8df3615f265d5270fec2a8c95b17c1142" dependencies = [ "bumpalo", - "lazy_static", "log", + "once_cell", "proc-macro2", "quote", "syn", @@ -6521,9 +6919,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.31" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de9a9cec1733468a8c657e57fa2413d2ae2c0129b95e87c5b72b8ace4d13f31f" +checksum = "23639446165ca5a5de86ae1d8896b737ae80319560fbaa4c2887b7da6e7ebd7d" dependencies = [ "cfg-if 1.0.0", "js-sys", @@ -6533,9 +6931,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.81" +version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c441e177922bc58f1e12c022624b6216378e5febc2f0533e41ba443d505b80aa" +checksum = "052be0f94026e6cbc75cdefc9bae13fd6052cdcaf532fa6c45e7ae33a1e6c810" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6543,9 +6941,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.81" +version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d94ac45fcf608c1f45ef53e748d35660f168490c10b23704c7779ab8f5c3048" +checksum = "07bc0c051dc5f23e307b13285f9d75df86bfdf816c5721e573dec1f9b8aa193c" dependencies = [ "proc-macro2", "quote", @@ -6556,15 +6954,15 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.81" +version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a89911bd99e5f3659ec4acf9c4d93b0a90fe4a2a11f15328472058edc5261be" +checksum = "1c38c045535d93ec4f0b4defec448e4291638ee608530863b1e2ba115d4fff7f" [[package]] name = "wasm-encoder" -version = "0.14.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f76068e87fe9b837a6bc2ccded66784173eadb828c4168643e9fddf6f9ed2e61" +checksum = "c64ac98d5d61192cc45c701b7e4bd0b9aff91e2edfc7a088406cfe2288581e2c" dependencies = [ "leb128", ] @@ -6580,9 +6978,9 @@ dependencies = [ [[package]] name = "wasmtime" -version = "0.38.1" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e76e2b2833bb0ece666ccdbed7b71b617d447da11f1bb61f4f2bab2648f745ee" +checksum = "1f50eadf868ab6a04b7b511460233377d0bfbb92e417b2f6a98b98fef2e098f5" dependencies = [ "anyhow", "async-trait", @@ -6593,7 +6991,7 @@ dependencies = [ "lazy_static", "libc", "log", - "object", + "object 0.28.4", "once_cell", "paste", "psm", @@ -6614,9 +7012,9 @@ dependencies = [ [[package]] name = "wasmtime-cache" -version = "0.38.1" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743a9f142d93318262d7e1fe329394ff2e8f86a1df45ae5e4f0eedba215ca5ce" +checksum = "d1df23c642e1376892f3b72f311596976979cbf8b85469680cdd3a8a063d12a2" dependencies = [ "anyhow", "base64", @@ -6634,9 +7032,9 @@ dependencies = [ [[package]] name = "wasmtime-cranelift" -version = "0.38.1" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dc0f80afa1ce97083a7168e6b6948d015d6237369e9f4a511d38c9c4ac8fbb9" +checksum = "f264ff6b4df247d15584f2f53d009fbc90032cfdc2605b52b961bffc71b6eccd" dependencies = [ "anyhow", "cranelift-codegen", @@ -6647,7 +7045,7 @@ dependencies = [ "gimli", "log", "more-asserts", - "object", + "object 0.28.4", "target-lexicon", "thiserror", "wasmparser", @@ -6656,9 +7054,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "0.38.1" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0816d9365196f1f447060087e0f87239ccded830bd54970a1168b0c9c8e824c9" +checksum = "839d2820e4b830f4b9e7aa08d4c0acabf4a5036105d639f6dfa1c6891c73bdc6" dependencies = [ "anyhow", "cranelift-entity", @@ -6666,7 +7064,7 @@ dependencies = [ "indexmap", "log", "more-asserts", - "object", + "object 0.28.4", "serde", "target-lexicon", "thiserror", @@ -6676,9 +7074,9 @@ dependencies = [ [[package]] name = "wasmtime-fiber" -version = "0.38.1" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "715afdb87a3bcf1eae3f098c742d650fb783abdb8a7ca87076ea1cabecabea5d" +checksum = "3248be3c4911233535356025f6562193614a40155ee9094bb6a2b43f0dc82803" dependencies = [ "cc", "rustix", @@ -6687,9 +7085,9 @@ dependencies = [ [[package]] name = "wasmtime-jit" -version = "0.38.1" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c687f33cfa0f89ec1646929d0ff102087052cf9f0d15533de56526b0da0d1b3" +checksum = "ef0a0bcbfa18b946d890078ba0e1bc76bcc53eccfb40806c0020ec29dcd1bd49" dependencies = [ "addr2line", "anyhow", @@ -6699,7 +7097,7 @@ dependencies = [ "gimli", "ittapi-rs", "log", - "object", + "object 0.28.4", "region", "rustc-demangle", "rustix", @@ -6714,20 +7112,20 @@ dependencies = [ [[package]] name = "wasmtime-jit-debug" -version = "0.38.1" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b252d1d025f94f3954ba2111f12f3a22826a0764a11c150c2d46623115a69e27" +checksum = "4f4779d976206c458edd643d1ac622b6c37e4a0800a8b1d25dfbf245ac2f2cac" dependencies = [ "lazy_static", - "object", + "object 0.28.4", "rustix", ] [[package]] name = "wasmtime-runtime" -version = "0.38.1" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace251693103c9facbbd7df87a29a75e68016e48bc83c09133f2fda6b575e0ab" +checksum = "b7eb6ffa169eb5dcd18ac9473c817358cd57bc62c244622210566d473397954a" dependencies = [ "anyhow", "backtrace", @@ -6752,9 +7150,9 @@ dependencies = [ [[package]] name = "wasmtime-types" -version = "0.38.1" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d129b0487a95986692af8708ffde9c50b0568dcefd79200941d475713b4f40bb" +checksum = "8d932b0ac5336f7308d869703dd225610a6a3aeaa8e968c52b43eed96cefb1c2" dependencies = [ "cranelift-entity", "serde", @@ -6764,9 +7162,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi" -version = "0.38.1" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb49791530b3a3375897a6d5a8bfa9914101ef8a672d01c951e70b46fd953c15" +checksum = "b68b7d77fb6f2975a6fe6cc4d0015d6b0cebb65c39fce1dd4cc00880dbf7789c" dependencies = [ "anyhow", "wasi-cap-std-sync", @@ -6786,9 +7184,9 @@ dependencies = [ [[package]] name = "wast" -version = "43.0.0" +version = "47.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "408feaebf6dbf9d154957873b14d00e8fba4cbc17a8cbb1bc9e4c1db425c50a8" +checksum = "02b98502f3978adea49551e801a6687678e6015317d7d9470a67fe813393f2a8" dependencies = [ "leb128", "memchr", @@ -6798,18 +7196,18 @@ dependencies = [ [[package]] name = "wat" -version = "1.0.45" +version = "1.0.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b70bfff0cfaf33dc9d641196dbcd0023a2da8b4b9030c59535cb44e2884983b" +checksum = "7aab4e20c60429fbba9670a6cae0fff9520046ba0aa3e6d0b1cd2653bea14898" dependencies = [ - "wast 43.0.0", + "wast 47.0.1", ] [[package]] name = "web-sys" -version = "0.3.58" +version = "0.3.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fed94beee57daf8dd7d51f2b15dc2bcde92d7a72304cdf662a4371008b71b90" +checksum = "bcda906d8be16e728fd5adc5b729afad4e444e106ab28cd1c7256e54fa61510f" dependencies = [ "js-sys", "wasm-bindgen", @@ -6846,18 +7244,18 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.22.3" +version = "0.22.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d8de8415c823c8abd270ad483c6feeac771fad964890779f9a8cb24fbbc1bf" +checksum = "368bfe657969fb01238bb756d351dcade285e0f6fcbd36dcb23359a5169975be" dependencies = [ "webpki 0.22.0", ] [[package]] name = "weezl" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c97e489d8f836838d497091de568cf16b117486d529ec5579233521065bd5e4" +checksum = "9193164d4de03a926d909d3bc7c30543cecb35400c02114792c2cae20d5e2dbb" [[package]] name = "wepoll-ffi" @@ -6870,30 +7268,31 @@ dependencies = [ [[package]] name = "which" -version = "4.2.5" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c4fb54e6113b6a8772ee41c3404fb0301ac79604489467e0a9ce1f3e97c24ae" +checksum = "1c831fbbee9e129a8cf93e7747a82da9d95ba8e16621cae60ec2cdc849bacb7b" dependencies = [ "either", - "lazy_static", "libc", + "once_cell", ] [[package]] name = "whoami" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524b58fa5a20a2fb3014dd6358b70e6579692a56ef6fce928834e488f42f65e8" +checksum = "d6631b6a2fd59b1841b622e8f1a7ad241ef0a46f2d580464ce8140ac94cbd571" dependencies = [ + "bumpalo", "wasm-bindgen", "web-sys", ] [[package]] name = "wiggle" -version = "0.38.1" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c38020359fabec5e5ce5a3f667af72e9a203bc6fe8caeb8931d3a870754d9d" +checksum = "67dadac11343d2aabc8a906a0db0aaf7cb5046ec3d6fffccdaf2847dccdef8d6" dependencies = [ "anyhow", "async-trait", @@ -6906,9 +7305,9 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "0.38.1" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc4e4420b496b04920ae3e41424029aba95c15a5e2e2b4012d14ec83770a3ef" +checksum = "63a1dccd6b3fbd9a27417f5d30ce9aa3ee9cf529aad453abbf88a49c5d605b79" dependencies = [ "anyhow", "heck 0.4.0", @@ -6921,9 +7320,9 @@ dependencies = [ [[package]] name = "wiggle-macro" -version = "0.38.1" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e541a0be1f2c4d53471d8a9df81c2d8725a3f023d8259f555c65b03d515aaab" +checksum = "f1c368d57d9560c34deaa67e06b0953ccf65edb906c525e5a2c866c849b48ec2" dependencies = [ "proc-macro2", "quote", @@ -6980,43 +7379,100 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" dependencies = [ - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_msvc", + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", ] +[[package]] +name = "windows-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc 0.42.0", + "windows_i686_gnu 0.42.0", + "windows_i686_msvc 0.42.0", + "windows_x86_64_gnu 0.42.0", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc 0.42.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" + [[package]] name = "windows_aarch64_msvc" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" + [[package]] name = "windows_i686_gnu" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" +[[package]] +name = "windows_i686_gnu" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" + [[package]] name = "windows_i686_msvc" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" +[[package]] +name = "windows_i686_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" + [[package]] name = "windows_x86_64_gnu" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" + [[package]] name = "windows_x86_64_msvc" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" + [[package]] name = "winreg" version = "0.10.1" @@ -7063,12 +7519,13 @@ name = "workspace" version = "0.1.0" dependencies = [ "anyhow", + "call", "client", - "clock", "collections", "context_menu", "drag_and_drop", - "futures", + "fs", + "futures 0.3.24", "gpui", "language", "log", @@ -7102,9 +7559,9 @@ checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3" [[package]] name = "xmlparser" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "114ba2b24d2167ef6d67d7d04c8cc86522b87f490025f39f0303b7db5bf5e3d8" +checksum = "4d25c75bf9ea12c4040a97f829154768bbbce366287e2dc044af160cd79a13fd" [[package]] name = "xmlwriter" @@ -7123,7 +7580,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.54.1" +version = "0.60.4" dependencies = [ "activity_indicator", "anyhow", @@ -7134,14 +7591,14 @@ dependencies = [ "auto_update", "backtrace", "breadcrumbs", + "call", "chrono", "cli", "client", "clock", + "collab_ui", "collections", "command_palette", - "contacts_panel", - "contacts_status_item", "context_menu", "ctor", "diagnostics", @@ -7150,8 +7607,9 @@ dependencies = [ "editor", "env_logger", "file_finder", + "fs", "fsevent", - "futures", + "futures 0.3.24", "fuzzy", "go_to_line", "gpui", @@ -7199,8 +7657,10 @@ dependencies = [ "tree-sitter", "tree-sitter-c", "tree-sitter-cpp", + "tree-sitter-css", "tree-sitter-elixir", "tree-sitter-go", + "tree-sitter-html", "tree-sitter-json 0.20.0", "tree-sitter-markdown", "tree-sitter-python", diff --git a/Cargo.toml b/Cargo.toml index 31a9118a1a..8d2a3fcc40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,11 @@ members = ["crates/*"] default-members = ["crates/zed"] resolver = "2" +[workspace.dependencies] +serde = { version = "1.0", features = ["derive", "rc"] } +serde_json = { version = "1.0", features = ["preserve_order", "raw_value"] } +rand = { version = "0.8" } + [patch.crates-io] tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "366210ae925d7ea0891bc7a0c738f60c77c04d7b" } async-task = { git = "https://github.com/zed-industries/async-task", rev = "341b57d6de98cdfd7b418567b8de2022ca993a6e" } @@ -21,3 +26,4 @@ split-debuginfo = "unpacked" [profile.release] debug = true + diff --git a/Dockerfile b/Dockerfile index 6bfd49be55..122600bf94 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.2 -FROM rust:1.62-bullseye as builder +FROM rust:1.64-bullseye as builder WORKDIR app COPY . . diff --git a/Dockerfile.migrator b/Dockerfile.migrator index b6393ed1b5..482228a2eb 100644 --- a/Dockerfile.migrator +++ b/Dockerfile.migrator @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.2 -FROM rust:1.62-bullseye as builder +FROM rust:1.64-bullseye as builder WORKDIR app RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=./target \ diff --git a/assets/icons/zed_22.svg b/assets/icons/zed_22.svg deleted file mode 100644 index 68e7dc8e57..0000000000 --- a/assets/icons/zed_22.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 7a25dc19d3..40d486c161 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -3,8 +3,12 @@ { "bindings": { "up": "menu::SelectPrev", + "pageup": "menu::SelectFirst", + "shift-pageup": "menu::SelectFirst", "ctrl-p": "menu::SelectPrev", "down": "menu::SelectNext", + "pagedown": "menu::SelectLast", + "shift-pagedown": "menu::SelectFirst", "ctrl-n": "menu::SelectNext", "cmd-up": "menu::SelectFirst", "cmd-down": "menu::SelectLast", @@ -60,13 +64,18 @@ "cmd-z": "editor::Undo", "cmd-shift-z": "editor::Redo", "up": "editor::MoveUp", + "pageup": "editor::PageUp", + "shift-pageup": "editor::MovePageUp", "down": "editor::MoveDown", + "pagedown": "editor::PageDown", + "shift-pagedown": "editor::MovePageDown", "left": "editor::MoveLeft", "right": "editor::MoveRight", "ctrl-p": "editor::MoveUp", "ctrl-n": "editor::MoveDown", "ctrl-b": "editor::MoveLeft", "ctrl-f": "editor::MoveRight", + "ctrl-l": "editor::CenterScreen", "alt-left": "editor::MoveToPreviousWordStart", "alt-b": "editor::MoveToPreviousWordStart", "alt-right": "editor::MoveToNextWordEnd", @@ -93,6 +102,7 @@ "cmd-shift-down": "editor::SelectToEnd", "cmd-a": "editor::SelectAll", "cmd-l": "editor::SelectLine", + "cmd-shift-i": "editor::Format", "cmd-shift-left": [ "editor::SelectToBeginningOfLine", { @@ -117,8 +127,18 @@ "stop_at_soft_wraps": true } ], - "pageup": "editor::PageUp", - "pagedown": "editor::PageDown", + "ctrl-v": [ + "editor::MovePageDown", + { + "center_cursor": true + } + ], + "alt-v": [ + "editor::MovePageUp", + { + "center_cursor": true + } + ], "ctrl-cmd-space": "editor::ShowCharacterPalette" } }, @@ -375,6 +395,7 @@ { "bindings": { "ctrl-alt-cmd-f": "workspace::FollowNextCollaborator", + "cmd-shift-c": "collab::ToggleCollaborationMenu", "cmd-alt-i": "zed::DebugElements" } }, @@ -394,7 +415,6 @@ "context": "Workspace", "bindings": { "shift-escape": "dock::FocusDock", - "cmd-shift-c": "contacts_panel::ToggleFocus", "cmd-shift-b": "workspace::ToggleRightSidebar" } }, @@ -427,17 +447,53 @@ { "context": "Terminal", "bindings": { - // Overrides for global bindings, remove at your own risk: - "up": "terminal::Up", - "down": "terminal::Down", - "escape": "terminal::Escape", - "enter": "terminal::Enter", - "ctrl-c": "terminal::CtrlC", - // Useful terminal actions: "ctrl-cmd-space": "terminal::ShowCharacterPalette", "cmd-c": "terminal::Copy", "cmd-v": "terminal::Paste", - "cmd-k": "terminal::Clear" + "cmd-k": "terminal::Clear", + // Some nice conveniences + "cmd-backspace": [ + "terminal::SendText", + "\u0015" + ], + "cmd-right": [ + "terminal::SendText", + "\u0005" + ], + "cmd-left": [ + "terminal::SendText", + "\u0001" + ], + // There are conflicting bindings for these keys in the global context. + // these bindings override them, remove at your own risk: + "up": [ + "terminal::SendKeystroke", + "up" + ], + "pageup": [ + "terminal::SendKeystroke", + "pageup" + ], + "down": [ + "terminal::SendKeystroke", + "down" + ], + "pagedown": [ + "terminal::SendKeystroke", + "pagedown" + ], + "escape": [ + "terminal::SendKeystroke", + "escape" + ], + "enter": [ + "terminal::SendKeystroke", + "enter" + ], + "ctrl-c": [ + "terminal::SendKeystroke", + "ctrl-c" + ] } } ] \ No newline at end of file diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 5262daab5f..94729af21f 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -9,11 +9,10 @@ } ], "h": "vim::Left", - "backspace": "vim::Left", + "backspace": "vim::Backspace", "j": "vim::Down", "k": "vim::Up", "l": "vim::Right", - "0": "vim::StartOfLine", "$": "vim::EndOfLine", "shift-g": "vim::EndOfDocument", "w": "vim::NextWordStart", @@ -38,7 +37,60 @@ } ], "%": "vim::Matching", - "escape": "editor::Cancel" + "escape": "editor::Cancel", + "i": [ + "vim::PushOperator", + { + "Object": { + "around": false + } + } + ], + "a": [ + "vim::PushOperator", + { + "Object": { + "around": true + } + } + ], + "0": "vim::StartOfLine", // When no number operator present, use start of line motion + "1": [ + "vim::Number", + 1 + ], + "2": [ + "vim::Number", + 2 + ], + "3": [ + "vim::Number", + 3 + ], + "4": [ + "vim::Number", + 4 + ], + "5": [ + "vim::Number", + 5 + ], + "6": [ + "vim::Number", + 6 + ], + "7": [ + "vim::Number", + 7 + ], + "8": [ + "vim::Number", + 8 + ], + "9": [ + "vim::Number", + 9 + ] } }, { @@ -98,6 +150,15 @@ ] } }, + { + "context": "Editor && vim_operator == n", + "bindings": { + "0": [ + "vim::Number", + 0 + ] + } + }, { "context": "Editor && vim_operator == g", "bindings": { @@ -112,13 +173,6 @@ { "context": "Editor && vim_operator == c", "bindings": { - "w": "vim::ChangeWord", - "shift-w": [ - "vim::ChangeWord", - { - "ignorePunctuation": true - } - ], "c": "vim::CurrentLine" } }, @@ -134,9 +188,34 @@ "y": "vim::CurrentLine" } }, + { + "context": "Editor && VimObject", + "bindings": { + "w": "vim::Word", + "shift-w": [ + "vim::Word", + { + "ignorePunctuation": true + } + ], + "s": "vim::Sentence", + "'": "vim::Quotes", + "`": "vim::BackQuotes", + "\"": "vim::DoubleQuotes", + "(": "vim::Parentheses", + ")": "vim::Parentheses", + "[": "vim::SquareBrackets", + "]": "vim::SquareBrackets", + "{": "vim::CurlyBrackets", + "}": "vim::CurlyBrackets", + "<": "vim::AngleBrackets", + ">": "vim::AngleBrackets" + } + }, { "context": "Editor && vim_mode == visual", "bindings": { + "u": "editor::Undo", "c": "vim::VisualChange", "d": "vim::VisualDelete", "x": "vim::VisualDelete", diff --git a/assets/settings/default.json b/assets/settings/default.json index d8efdc41ff..2ccd2c5f97 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -42,21 +42,20 @@ // 3. Position the dock full screen over the entire workspace" // "default_dock_anchor": "expanded" "default_dock_anchor": "right", - // How to auto-format modified buffers when saving them. This - // setting can take three values: + // Whether or not to perform a buffer format before saving + "format_on_save": "on", + // How to perform a buffer format. This setting can take two values: // - // 1. Don't format code - // "format_on_save": "off" - // 2. Format code using the current language server: + // 1. Format code using the current language server: // "format_on_save": "language_server" - // 3. Format code using an external command: + // 2. Format code using an external command: // "format_on_save": { // "external": { // "command": "prettier", // "arguments": ["--stdin-filepath", "{buffer_path}"] // } // } - "format_on_save": "language_server", + "formatter": "language_server", // How to soft-wrap long lines of text. This setting can take // three values: // @@ -75,9 +74,28 @@ "hard_tabs": false, // How many columns a tab should occupy. "tab_size": 4, + // Git gutter behavior configuration. + "git": { + // Control whether the git gutter is shown. May take 2 values: + // 1. Show the gutter + // "git_gutter": "tracked_files" + // 2. Hide the gutter + // "git_gutter": "hide" + "git_gutter": "tracked_files" + }, + // Settings specific to journaling + "journal": { + // The path of the directory where journal entries are stored + "path": "~", + // What format to display the hours in + // May take 2 values: + // 1. hour12 + // 2. hour24 + "hour_format": "hour12" + }, // Settings specific to the terminal "terminal": { - // What shell to use when opening a terminal. May take 3 values: + // What shell to use when opening a terminal. May take 3 values: // 1. Use the system's default terminal configuration (e.g. $TERM). // "shell": "system" // 2. A program: @@ -94,7 +112,7 @@ "shell": "system", // What working directory to use when launching the terminal. // May take 4 values: - // 1. Use the current file's project directory. Will Fallback to the + // 1. Use the current file's project directory. Will Fallback to the // first project directory strategy if unsuccessful // "working_directory": "current_project_directory" // 2. Use the first project in this workspace's directory @@ -104,7 +122,7 @@ // 4. Always use a specific directory. This value will be shell expanded. // If this path is not a valid directory the terminal will default to // this platform's home directory (if we can find it) - // "working_directory": { + // "working_directory": { // "always": { // "directory": "~/zed/projects/" // } @@ -116,7 +134,7 @@ // May take 4 values: // 1. Never blink the cursor, ignoring the terminal mode // "blinking": "off", - // 2. Default the cursor blink to off, but allow the terminal to + // 2. Default the cursor blink to off, but allow the terminal to // set blinking // "blinking": "terminal_controlled", // 3. Always blink the cursor, ignoring the terminal mode @@ -124,7 +142,7 @@ "blinking": "terminal_controlled", // Set whether Alternate Scroll mode (code: ?1007) is active by default. // Alternate Scroll mode converts mouse scroll events into up / down key - // presses when in the alternate screen (e.g. when running applications + // presses when in the alternate screen (e.g. when running applications // like vim or less). The terminal can still set and unset this mode. // May take 2 values: // 1. Default alternate scroll mode to on @@ -140,6 +158,9 @@ // 2. Make the option keys behave as a 'meta' key, e.g. for emacs // "option_to_meta": true, "option_as_meta": false, + // Whether or not selecting text in the terminal will automatically + // copy to the system clipboard. + "copy_on_select": false, // Any key-value pairs added to this list will be added to the terminal's // enviroment. Use `:` to seperate multiple values. "env": { diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index 8f6f4bf627..cc788c1e48 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -46,6 +46,7 @@ impl ActivityIndicator { cx: &mut ViewContext, ) -> ViewHandle { let project = workspace.project().clone(); + let auto_updater = AutoUpdater::get(cx); let this = cx.add_view(|cx: &mut ViewContext| { let mut status_events = languages.language_server_binary_statuses(); cx.spawn_weak(|this, mut cx| async move { @@ -66,11 +67,14 @@ impl ActivityIndicator { }) .detach(); cx.observe(&project, |_, _, cx| cx.notify()).detach(); + if let Some(auto_updater) = auto_updater.as_ref() { + cx.observe(auto_updater, |_, _, cx| cx.notify()).detach(); + } Self { statuses: Default::default(), project: project.clone(), - auto_updater: AutoUpdater::get(cx), + auto_updater, } }); cx.subscribe(&this, move |workspace, _, event, cx| match event { @@ -285,7 +289,7 @@ impl View for ActivityIndicator { .workspace .status_bar .lsp_status; - let style = if state.hovered && action.is_some() { + let style = if state.hovered() && action.is_some() { theme.hover.as_ref().unwrap_or(&theme.default) } else { &theme.default diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml new file mode 100644 index 0000000000..e725c7cfe3 --- /dev/null +++ b/crates/call/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "call" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/call.rs" +doctest = false + +[features] +test-support = [ + "client/test-support", + "collections/test-support", + "gpui/test-support", + "project/test-support", + "util/test-support" +] + +[dependencies] +client = { path = "../client" } +collections = { path = "../collections" } +gpui = { path = "../gpui" } +project = { path = "../project" } +util = { path = "../util" } + +anyhow = "1.0.38" +futures = "0.3" +postage = { version = "0.4.1", features = ["futures-traits"] } + +[dev-dependencies] +client = { path = "../client", features = ["test-support"] } +collections = { path = "../collections", features = ["test-support"] } +gpui = { path = "../gpui", features = ["test-support"] } +project = { path = "../project", features = ["test-support"] } +util = { path = "../util", features = ["test-support"] } diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs new file mode 100644 index 0000000000..6b06d04375 --- /dev/null +++ b/crates/call/src/call.rs @@ -0,0 +1,261 @@ +mod participant; +pub mod room; + +use anyhow::{anyhow, Result}; +use client::{proto, Client, TypedEnvelope, User, UserStore}; +use gpui::{ + AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, + Subscription, Task, +}; +pub use participant::ParticipantLocation; +use postage::watch; +use project::Project; +pub use room::Room; +use std::sync::Arc; + +pub fn init(client: Arc, user_store: ModelHandle, cx: &mut MutableAppContext) { + let active_call = cx.add_model(|cx| ActiveCall::new(client, user_store, cx)); + cx.set_global(active_call); +} + +#[derive(Clone)] +pub struct IncomingCall { + pub room_id: u64, + pub caller: Arc, + pub participants: Vec>, + pub initial_project: Option, +} + +pub struct ActiveCall { + room: Option<(ModelHandle, Vec)>, + incoming_call: ( + watch::Sender>, + watch::Receiver>, + ), + client: Arc, + user_store: ModelHandle, + _subscriptions: Vec, +} + +impl Entity for ActiveCall { + type Event = room::Event; +} + +impl ActiveCall { + fn new( + client: Arc, + user_store: ModelHandle, + cx: &mut ModelContext, + ) -> Self { + Self { + room: None, + incoming_call: watch::channel(), + _subscriptions: vec![ + client.add_request_handler(cx.handle(), Self::handle_incoming_call), + client.add_message_handler(cx.handle(), Self::handle_call_canceled), + ], + client, + user_store, + } + } + + async fn handle_incoming_call( + this: ModelHandle, + envelope: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result { + let user_store = this.read_with(&cx, |this, _| this.user_store.clone()); + let call = IncomingCall { + room_id: envelope.payload.room_id, + participants: user_store + .update(&mut cx, |user_store, cx| { + user_store.get_users(envelope.payload.participant_user_ids, cx) + }) + .await?, + caller: user_store + .update(&mut cx, |user_store, cx| { + user_store.get_user(envelope.payload.caller_user_id, cx) + }) + .await?, + initial_project: envelope.payload.initial_project, + }; + this.update(&mut cx, |this, _| { + *this.incoming_call.0.borrow_mut() = Some(call); + }); + + Ok(proto::Ack {}) + } + + async fn handle_call_canceled( + this: ModelHandle, + _: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, _| { + *this.incoming_call.0.borrow_mut() = None; + }); + Ok(()) + } + + pub fn global(cx: &AppContext) -> ModelHandle { + cx.global::>().clone() + } + + pub fn invite( + &mut self, + recipient_user_id: u64, + initial_project: Option>, + cx: &mut ModelContext, + ) -> Task> { + let client = self.client.clone(); + let user_store = self.user_store.clone(); + cx.spawn(|this, mut cx| async move { + if let Some(room) = this.read_with(&cx, |this, _| this.room().cloned()) { + let initial_project_id = if let Some(initial_project) = initial_project { + Some( + room.update(&mut cx, |room, cx| room.share_project(initial_project, cx)) + .await?, + ) + } else { + None + }; + + room.update(&mut cx, |room, cx| { + room.call(recipient_user_id, initial_project_id, cx) + }) + .await?; + } else { + let room = cx + .update(|cx| { + Room::create(recipient_user_id, initial_project, client, user_store, cx) + }) + .await?; + this.update(&mut cx, |this, cx| this.set_room(Some(room), cx)); + }; + + Ok(()) + }) + } + + pub fn cancel_invite( + &mut self, + recipient_user_id: u64, + cx: &mut ModelContext, + ) -> Task> { + let room_id = if let Some(room) = self.room() { + room.read(cx).id() + } else { + return Task::ready(Err(anyhow!("no active call"))); + }; + + let client = self.client.clone(); + cx.foreground().spawn(async move { + client + .request(proto::CancelCall { + room_id, + recipient_user_id, + }) + .await?; + anyhow::Ok(()) + }) + } + + pub fn incoming(&self) -> watch::Receiver> { + self.incoming_call.1.clone() + } + + pub fn accept_incoming(&mut self, cx: &mut ModelContext) -> Task> { + if self.room.is_some() { + return Task::ready(Err(anyhow!("cannot join while on another call"))); + } + + let call = if let Some(call) = self.incoming_call.1.borrow().clone() { + call + } else { + return Task::ready(Err(anyhow!("no incoming call"))); + }; + + let join = Room::join(&call, self.client.clone(), self.user_store.clone(), cx); + cx.spawn(|this, mut cx| async move { + let room = join.await?; + this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx)); + Ok(()) + }) + } + + pub fn decline_incoming(&mut self) -> Result<()> { + let call = self + .incoming_call + .0 + .borrow_mut() + .take() + .ok_or_else(|| anyhow!("no incoming call"))?; + self.client.send(proto::DeclineCall { + room_id: call.room_id, + })?; + Ok(()) + } + + pub fn hang_up(&mut self, cx: &mut ModelContext) -> Result<()> { + if let Some((room, _)) = self.room.take() { + room.update(cx, |room, cx| room.leave(cx))?; + cx.notify(); + } + Ok(()) + } + + pub fn share_project( + &mut self, + project: ModelHandle, + cx: &mut ModelContext, + ) -> Task> { + if let Some((room, _)) = self.room.as_ref() { + room.update(cx, |room, cx| room.share_project(project, cx)) + } else { + Task::ready(Err(anyhow!("no active call"))) + } + } + + pub fn set_location( + &mut self, + project: Option<&ModelHandle>, + cx: &mut ModelContext, + ) -> Task> { + if let Some((room, _)) = self.room.as_ref() { + room.update(cx, |room, cx| room.set_location(project, cx)) + } else { + Task::ready(Err(anyhow!("no active call"))) + } + } + + fn set_room(&mut self, room: Option>, cx: &mut ModelContext) { + if room.as_ref() != self.room.as_ref().map(|room| &room.0) { + if let Some(room) = room { + if room.read(cx).status().is_offline() { + self.room = None; + } else { + let subscriptions = vec![ + cx.observe(&room, |this, room, cx| { + if room.read(cx).status().is_offline() { + this.set_room(None, cx); + } + + cx.notify(); + }), + cx.subscribe(&room, |_, _, event, cx| cx.emit(event.clone())), + ]; + self.room = Some((room, subscriptions)); + } + } else { + self.room = None; + } + cx.notify(); + } + } + + pub fn room(&self) -> Option<&ModelHandle> { + self.room.as_ref().map(|(room, _)| room) + } +} diff --git a/crates/call/src/participant.rs b/crates/call/src/participant.rs new file mode 100644 index 0000000000..a5be5b4af2 --- /dev/null +++ b/crates/call/src/participant.rs @@ -0,0 +1,42 @@ +use anyhow::{anyhow, Result}; +use client::{proto, User}; +use gpui::WeakModelHandle; +use project::Project; +use std::sync::Arc; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ParticipantLocation { + SharedProject { project_id: u64 }, + UnsharedProject, + External, +} + +impl ParticipantLocation { + pub fn from_proto(location: Option) -> Result { + match location.and_then(|l| l.variant) { + Some(proto::participant_location::Variant::SharedProject(project)) => { + Ok(Self::SharedProject { + project_id: project.id, + }) + } + Some(proto::participant_location::Variant::UnsharedProject(_)) => { + Ok(Self::UnsharedProject) + } + Some(proto::participant_location::Variant::External(_)) => Ok(Self::External), + None => Err(anyhow!("participant location was not provided")), + } + } +} + +#[derive(Clone, Default)] +pub struct LocalParticipant { + pub projects: Vec, + pub active_project: Option>, +} + +#[derive(Clone, Debug)] +pub struct RemoteParticipant { + pub user: Arc, + pub projects: Vec, + pub location: ParticipantLocation, +} diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs new file mode 100644 index 0000000000..09b49716e0 --- /dev/null +++ b/crates/call/src/room.rs @@ -0,0 +1,472 @@ +use crate::{ + participant::{LocalParticipant, ParticipantLocation, RemoteParticipant}, + IncomingCall, +}; +use anyhow::{anyhow, Result}; +use client::{proto, Client, PeerId, TypedEnvelope, User, UserStore}; +use collections::{BTreeMap, HashSet}; +use futures::StreamExt; +use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task}; +use project::Project; +use std::sync::Arc; +use util::ResultExt; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Event { + RemoteProjectShared { + owner: Arc, + project_id: u64, + worktree_root_names: Vec, + }, + RemoteProjectUnshared { + project_id: u64, + }, + Left, +} + +pub struct Room { + id: u64, + status: RoomStatus, + local_participant: LocalParticipant, + remote_participants: BTreeMap, + pending_participants: Vec>, + participant_user_ids: HashSet, + pending_call_count: usize, + leave_when_empty: bool, + client: Arc, + user_store: ModelHandle, + subscriptions: Vec, + pending_room_update: Option>, +} + +impl Entity for Room { + type Event = Event; + + fn release(&mut self, _: &mut MutableAppContext) { + self.client.send(proto::LeaveRoom { id: self.id }).log_err(); + } +} + +impl Room { + fn new( + id: u64, + client: Arc, + user_store: ModelHandle, + cx: &mut ModelContext, + ) -> Self { + let mut client_status = client.status(); + cx.spawn_weak(|this, mut cx| async move { + let is_connected = client_status + .next() + .await + .map_or(false, |s| s.is_connected()); + // Even if we're initially connected, any future change of the status means we momentarily disconnected. + if !is_connected || client_status.next().await.is_some() { + if let Some(this) = this.upgrade(&cx) { + let _ = this.update(&mut cx, |this, cx| this.leave(cx)); + } + } + }) + .detach(); + + Self { + id, + status: RoomStatus::Online, + participant_user_ids: Default::default(), + local_participant: Default::default(), + remote_participants: Default::default(), + pending_participants: Default::default(), + pending_call_count: 0, + subscriptions: vec![client.add_message_handler(cx.handle(), Self::handle_room_updated)], + leave_when_empty: false, + pending_room_update: None, + client, + user_store, + } + } + + pub(crate) fn create( + recipient_user_id: u64, + initial_project: Option>, + client: Arc, + user_store: ModelHandle, + cx: &mut MutableAppContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + let response = client.request(proto::CreateRoom {}).await?; + let room = cx.add_model(|cx| Self::new(response.id, client, user_store, cx)); + + let initial_project_id = if let Some(initial_project) = initial_project { + let initial_project_id = room + .update(&mut cx, |room, cx| { + room.share_project(initial_project.clone(), cx) + }) + .await?; + Some(initial_project_id) + } else { + None + }; + + match room + .update(&mut cx, |room, cx| { + room.leave_when_empty = true; + room.call(recipient_user_id, initial_project_id, cx) + }) + .await + { + Ok(()) => Ok(room), + Err(error) => Err(anyhow!("room creation failed: {:?}", error)), + } + }) + } + + pub(crate) fn join( + call: &IncomingCall, + client: Arc, + user_store: ModelHandle, + cx: &mut MutableAppContext, + ) -> Task>> { + let room_id = call.room_id; + cx.spawn(|mut cx| async move { + let response = client.request(proto::JoinRoom { id: room_id }).await?; + let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?; + let room = cx.add_model(|cx| Self::new(room_id, client, user_store, cx)); + room.update(&mut cx, |room, cx| { + room.leave_when_empty = true; + room.apply_room_update(room_proto, cx)?; + anyhow::Ok(()) + })?; + Ok(room) + }) + } + + fn should_leave(&self) -> bool { + self.leave_when_empty + && self.pending_room_update.is_none() + && self.pending_participants.is_empty() + && self.remote_participants.is_empty() + && self.pending_call_count == 0 + } + + pub(crate) fn leave(&mut self, cx: &mut ModelContext) -> Result<()> { + if self.status.is_offline() { + return Err(anyhow!("room is offline")); + } + + cx.notify(); + cx.emit(Event::Left); + self.status = RoomStatus::Offline; + self.remote_participants.clear(); + self.pending_participants.clear(); + self.participant_user_ids.clear(); + self.subscriptions.clear(); + self.client.send(proto::LeaveRoom { id: self.id })?; + Ok(()) + } + + pub fn id(&self) -> u64 { + self.id + } + + pub fn status(&self) -> RoomStatus { + self.status + } + + pub fn local_participant(&self) -> &LocalParticipant { + &self.local_participant + } + + pub fn remote_participants(&self) -> &BTreeMap { + &self.remote_participants + } + + pub fn pending_participants(&self) -> &[Arc] { + &self.pending_participants + } + + pub fn contains_participant(&self, user_id: u64) -> bool { + self.participant_user_ids.contains(&user_id) + } + + async fn handle_room_updated( + this: ModelHandle, + envelope: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + let room = envelope + .payload + .room + .ok_or_else(|| anyhow!("invalid room"))?; + this.update(&mut cx, |this, cx| this.apply_room_update(room, cx)) + } + + fn apply_room_update( + &mut self, + mut room: proto::Room, + cx: &mut ModelContext, + ) -> Result<()> { + // Filter ourselves out from the room's participants. + let local_participant_ix = room + .participants + .iter() + .position(|participant| Some(participant.user_id) == self.client.user_id()); + let local_participant = local_participant_ix.map(|ix| room.participants.swap_remove(ix)); + + let remote_participant_user_ids = room + .participants + .iter() + .map(|p| p.user_id) + .collect::>(); + let (remote_participants, pending_participants) = + self.user_store.update(cx, move |user_store, cx| { + ( + user_store.get_users(remote_participant_user_ids, cx), + user_store.get_users(room.pending_participant_user_ids, cx), + ) + }); + self.pending_room_update = Some(cx.spawn(|this, mut cx| async move { + let (remote_participants, pending_participants) = + futures::join!(remote_participants, pending_participants); + + this.update(&mut cx, |this, cx| { + this.participant_user_ids.clear(); + + if let Some(participant) = local_participant { + this.local_participant.projects = participant.projects; + } else { + this.local_participant.projects.clear(); + } + + if let Some(participants) = remote_participants.log_err() { + for (participant, user) in room.participants.into_iter().zip(participants) { + let peer_id = PeerId(participant.peer_id); + this.participant_user_ids.insert(participant.user_id); + + let old_projects = this + .remote_participants + .get(&peer_id) + .into_iter() + .flat_map(|existing| &existing.projects) + .map(|project| project.id) + .collect::>(); + let new_projects = participant + .projects + .iter() + .map(|project| project.id) + .collect::>(); + + for project in &participant.projects { + if !old_projects.contains(&project.id) { + cx.emit(Event::RemoteProjectShared { + owner: user.clone(), + project_id: project.id, + worktree_root_names: project.worktree_root_names.clone(), + }); + } + } + + for unshared_project_id in old_projects.difference(&new_projects) { + cx.emit(Event::RemoteProjectUnshared { + project_id: *unshared_project_id, + }); + } + + this.remote_participants.insert( + peer_id, + RemoteParticipant { + user: user.clone(), + projects: participant.projects, + location: ParticipantLocation::from_proto(participant.location) + .unwrap_or(ParticipantLocation::External), + }, + ); + } + + this.remote_participants.retain(|_, participant| { + if this.participant_user_ids.contains(&participant.user.id) { + true + } else { + for project in &participant.projects { + cx.emit(Event::RemoteProjectUnshared { + project_id: project.id, + }); + } + false + } + }); + } + + if let Some(pending_participants) = pending_participants.log_err() { + this.pending_participants = pending_participants; + for participant in &this.pending_participants { + this.participant_user_ids.insert(participant.id); + } + } + + this.pending_room_update.take(); + if this.should_leave() { + let _ = this.leave(cx); + } + + this.check_invariants(); + cx.notify(); + }); + })); + + cx.notify(); + Ok(()) + } + + fn check_invariants(&self) { + #[cfg(any(test, feature = "test-support"))] + { + for participant in self.remote_participants.values() { + assert!(self.participant_user_ids.contains(&participant.user.id)); + } + + for participant in &self.pending_participants { + assert!(self.participant_user_ids.contains(&participant.id)); + } + + assert_eq!( + self.participant_user_ids.len(), + self.remote_participants.len() + self.pending_participants.len() + ); + } + } + + pub(crate) fn call( + &mut self, + recipient_user_id: u64, + initial_project_id: Option, + cx: &mut ModelContext, + ) -> Task> { + if self.status.is_offline() { + return Task::ready(Err(anyhow!("room is offline"))); + } + + cx.notify(); + let client = self.client.clone(); + let room_id = self.id; + self.pending_call_count += 1; + cx.spawn(|this, mut cx| async move { + let result = client + .request(proto::Call { + room_id, + recipient_user_id, + initial_project_id, + }) + .await; + this.update(&mut cx, |this, cx| { + this.pending_call_count -= 1; + if this.should_leave() { + this.leave(cx)?; + } + result + })?; + Ok(()) + }) + } + + pub(crate) fn share_project( + &mut self, + project: ModelHandle, + cx: &mut ModelContext, + ) -> Task> { + if let Some(project_id) = project.read(cx).remote_id() { + return Task::ready(Ok(project_id)); + } + + let request = self.client.request(proto::ShareProject { + room_id: self.id(), + worktrees: project + .read(cx) + .worktrees(cx) + .map(|worktree| { + let worktree = worktree.read(cx); + proto::WorktreeMetadata { + id: worktree.id().to_proto(), + root_name: worktree.root_name().into(), + visible: worktree.is_visible(), + } + }) + .collect(), + }); + cx.spawn(|this, mut cx| async move { + let response = request.await?; + + project.update(&mut cx, |project, cx| { + project + .shared(response.project_id, cx) + .detach_and_log_err(cx) + }); + + // If the user's location is in this project, it changes from UnsharedProject to SharedProject. + this.update(&mut cx, |this, cx| { + let active_project = this.local_participant.active_project.as_ref(); + if active_project.map_or(false, |location| *location == project) { + this.set_location(Some(&project), cx) + } else { + Task::ready(Ok(())) + } + }) + .await?; + + Ok(response.project_id) + }) + } + + pub fn set_location( + &mut self, + project: Option<&ModelHandle>, + cx: &mut ModelContext, + ) -> Task> { + if self.status.is_offline() { + return Task::ready(Err(anyhow!("room is offline"))); + } + + let client = self.client.clone(); + let room_id = self.id; + let location = if let Some(project) = project { + self.local_participant.active_project = Some(project.downgrade()); + if let Some(project_id) = project.read(cx).remote_id() { + proto::participant_location::Variant::SharedProject( + proto::participant_location::SharedProject { id: project_id }, + ) + } else { + proto::participant_location::Variant::UnsharedProject( + proto::participant_location::UnsharedProject {}, + ) + } + } else { + self.local_participant.active_project = None; + proto::participant_location::Variant::External(proto::participant_location::External {}) + }; + + cx.notify(); + cx.foreground().spawn(async move { + client + .request(proto::UpdateParticipantLocation { + room_id, + location: Some(proto::ParticipantLocation { + variant: Some(location), + }), + }) + .await?; + Ok(()) + }) + } +} + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum RoomStatus { + Online, + Offline, +} + +impl RoomStatus { + pub fn is_offline(&self) -> bool { + matches!(self, RoomStatus::Offline) + } +} diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index a7888b8965..c9c783c659 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -12,6 +12,7 @@ test-support = ["collections/test-support", "gpui/test-support", "rpc/test-suppo [dependencies] collections = { path = "../collections" } +db = { path = "../db" } gpui = { path = "../gpui" } util = { path = "../util" } rpc = { path = "../rpc" } @@ -31,7 +32,10 @@ smol = "1.2.5" thiserror = "1.0.29" time = { version = "0.3", features = ["serde", "serde-well-known"] } tiny_http = "0.8" +uuid = { version = "1.1.2", features = ["v4"] } url = "2.2" +serde = { version = "*", features = ["derive"] } +tempfile = "3" [dev-dependencies] collections = { path = "../collections", features = ["test-support"] } diff --git a/crates/client/src/channel.rs b/crates/client/src/channel.rs index a88f872d11..7b4f6073ce 100644 --- a/crates/client/src/channel.rs +++ b/crates/client/src/channel.rs @@ -530,7 +530,7 @@ impl ChannelMessage { ) -> Result { let sender = user_store .update(cx, |user_store, cx| { - user_store.fetch_user(message.sender_id, cx) + user_store.get_user(message.sender_id, cx) }) .await?; Ok(ChannelMessage { @@ -601,7 +601,7 @@ mod tests { let user_id = 5; let http_client = FakeHttpClient::with_404_response(); - let client = Client::new(http_client.clone()); + let client = cx.update(|cx| Client::new(http_client.clone(), cx)); let server = FakeServer::for_client(user_id, &client, cx).await; Channel::init(&client); diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index e328108a52..cc6bdf6279 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -3,6 +3,7 @@ pub mod test; pub mod channel; pub mod http; +pub mod telemetry; pub mod user; use anyhow::{anyhow, Context, Result}; @@ -11,10 +12,12 @@ use async_tungstenite::tungstenite::{ error::Error as WebsocketError, http::{Request, StatusCode}, }; +use db::Db; use futures::{future::LocalBoxFuture, FutureExt, SinkExt, StreamExt, TryStreamExt}; use gpui::{ - actions, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AnyWeakViewHandle, AsyncAppContext, - Entity, ModelContext, ModelHandle, MutableAppContext, Task, View, ViewContext, ViewHandle, + actions, serde_json::Value, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, + AnyWeakViewHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, + MutableAppContext, Task, View, ViewContext, ViewHandle, }; use http::HttpClient; use lazy_static::lazy_static; @@ -28,9 +31,11 @@ use std::{ convert::TryFrom, fmt::Write as _, future::Future, + path::PathBuf, sync::{Arc, Weak}, time::{Duration, Instant}, }; +use telemetry::Telemetry; use thiserror::Error; use url::Url; use util::{ResultExt, TryFutureExt}; @@ -48,14 +53,21 @@ lazy_static! { } pub const ZED_SECRET_CLIENT_TOKEN: &str = "618033988749894"; +pub const INITIAL_RECONNECTION_DELAY: Duration = Duration::from_millis(100); +pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); actions!(client, [Authenticate]); -pub fn init(rpc: Arc, cx: &mut MutableAppContext) { - cx.add_global_action(move |_: &Authenticate, cx| { - let rpc = rpc.clone(); - cx.spawn(|cx| async move { rpc.authenticate_and_connect(true, &cx).log_err().await }) +pub fn init(client: Arc, cx: &mut MutableAppContext) { + cx.add_global_action({ + let client = client.clone(); + move |_: &Authenticate, cx| { + let client = client.clone(); + cx.spawn( + |cx| async move { client.authenticate_and_connect(true, &cx).log_err().await }, + ) .detach(); + } }); } @@ -63,6 +75,7 @@ pub struct Client { id: usize, peer: Arc, http: Arc, + telemetry: Arc, state: RwLock, #[allow(clippy::type_complexity)] @@ -232,10 +245,11 @@ impl Drop for Subscription { } impl Client { - pub fn new(http: Arc) -> Arc { + pub fn new(http: Arc, cx: &AppContext) -> Arc { Arc::new(Self { id: 0, peer: Peer::new(), + telemetry: Telemetry::new(http.clone(), cx), http, state: Default::default(), @@ -318,7 +332,7 @@ impl Client { let reconnect_interval = state.reconnect_interval; state._reconnect_task = Some(cx.spawn(|cx| async move { let mut rng = StdRng::from_entropy(); - let mut delay = Duration::from_millis(100); + let mut delay = INITIAL_RECONNECTION_DELAY; while let Err(error) = this.authenticate_and_connect(true, &cx).await { log::error!("failed to connect {}", error); if matches!(*this.status().borrow(), Status::ConnectionError) { @@ -339,6 +353,7 @@ impl Client { })); } Status::SignedOut | Status::UpgradeRequired => { + self.telemetry.set_authenticated_user_info(None, false); state._reconnect_task.take(); } _ => {} @@ -421,6 +436,29 @@ impl Client { } } + pub fn add_request_handler( + self: &Arc, + model: ModelHandle, + handler: H, + ) -> Subscription + where + M: RequestMessage, + E: Entity, + H: 'static + + Send + + Sync + + Fn(ModelHandle, TypedEnvelope, Arc, AsyncAppContext) -> F, + F: 'static + Future>, + { + self.add_message_handler(model, move |handle, envelope, this, cx| { + Self::respond_to_request( + envelope.receipt(), + handler(handle, envelope, this.clone(), cx), + this, + ) + }) + } + pub fn add_view_message_handler(self: &Arc, handler: H) where M: EntityMessage, @@ -595,6 +633,9 @@ impl Client { if credentials.is_none() && try_keychain { credentials = read_credentials_from_keychain(cx); read_from_keychain = credentials.is_some(); + if read_from_keychain { + self.report_event("read credentials from keychain", Default::default()); + } } if credentials.is_none() { let mut status_rx = self.status(); @@ -622,44 +663,51 @@ impl Client { self.set_status(Status::Reconnecting, cx); } - match self.establish_connection(&credentials, cx).await { - Ok(conn) => { - self.state.write().credentials = Some(credentials.clone()); - if !read_from_keychain && IMPERSONATE_LOGIN.is_none() { - write_credentials_to_keychain(&credentials, cx).log_err(); - } - self.set_connection(conn, cx).await; - Ok(()) - } - Err(EstablishConnectionError::Unauthorized) => { - self.state.write().credentials.take(); - if read_from_keychain { - cx.platform().delete_credentials(&ZED_SERVER_URL).log_err(); - self.set_status(Status::SignedOut, cx); - self.authenticate_and_connect(false, cx).await - } else { - self.set_status(Status::ConnectionError, cx); - Err(EstablishConnectionError::Unauthorized)? + futures::select_biased! { + connection = self.establish_connection(&credentials, cx).fuse() => { + match connection { + Ok(conn) => { + self.state.write().credentials = Some(credentials.clone()); + if !read_from_keychain && IMPERSONATE_LOGIN.is_none() { + write_credentials_to_keychain(&credentials, cx).log_err(); + } + self.set_connection(conn, cx); + Ok(()) + } + Err(EstablishConnectionError::Unauthorized) => { + self.state.write().credentials.take(); + if read_from_keychain { + cx.platform().delete_credentials(&ZED_SERVER_URL).log_err(); + self.set_status(Status::SignedOut, cx); + self.authenticate_and_connect(false, cx).await + } else { + self.set_status(Status::ConnectionError, cx); + Err(EstablishConnectionError::Unauthorized)? + } + } + Err(EstablishConnectionError::UpgradeRequired) => { + self.set_status(Status::UpgradeRequired, cx); + Err(EstablishConnectionError::UpgradeRequired)? + } + Err(error) => { + self.set_status(Status::ConnectionError, cx); + Err(error)? + } } } - Err(EstablishConnectionError::UpgradeRequired) => { - self.set_status(Status::UpgradeRequired, cx); - Err(EstablishConnectionError::UpgradeRequired)? - } - Err(error) => { + _ = cx.background().timer(CONNECTION_TIMEOUT).fuse() => { self.set_status(Status::ConnectionError, cx); - Err(error)? + Err(anyhow!("timed out trying to establish connection")) } } } - async fn set_connection(self: &Arc, conn: Connection, cx: &AsyncAppContext) { + fn set_connection(self: &Arc, conn: Connection, cx: &AsyncAppContext) { let executor = cx.background(); log::info!("add connection to peer"); let (connection_id, handle_io, mut incoming) = self .peer - .add_connection(conn, move |duration| executor.timer(duration)) - .await; + .add_connection(conn, move |duration| executor.timer(duration)); log::info!("set status to connected {}", connection_id); self.set_status(Status::Connected { connection_id }, cx); cx.foreground() @@ -878,6 +926,7 @@ impl Client { ) -> Task> { let platform = cx.platform(); let executor = cx.background(); + let telemetry = self.telemetry.clone(); executor.clone().spawn(async move { // Generate a pair of asymmetric encryption keys. The public key will be used by the // zed server to encrypt the user's access token, so that it can'be intercepted by @@ -956,6 +1005,8 @@ impl Client { .context("failed to decrypt access token")?; platform.activate(true); + telemetry.report_event("authenticate with browser", Default::default()); + Ok(Credentials { user_id: user_id.parse()?, access_token, @@ -1020,6 +1071,18 @@ impl Client { log::debug!("rpc respond. client_id:{}. name:{}", self.id, T::NAME); self.peer.respond_with_error(receipt, error) } + + pub fn start_telemetry(&self, db: Arc) { + self.telemetry.start(db); + } + + pub fn report_event(&self, kind: &str, properties: Value) { + self.telemetry.report_event(kind, properties) + } + + pub fn telemetry_log_file_path(&self) -> Option { + self.telemetry.log_file_path() + } } impl AnyWeakEntityHandle { @@ -1085,7 +1148,7 @@ mod tests { cx.foreground().forbid_parking(); let user_id = 5; - let client = Client::new(FakeHttpClient::with_404_response()); + let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let server = FakeServer::for_client(user_id, &client, cx).await; let mut status = client.status(); assert!(matches!( @@ -1115,6 +1178,76 @@ mod tests { assert_eq!(server.auth_count(), 2); // Client re-authenticated due to an invalid token } + #[gpui::test(iterations = 10)] + async fn test_connection_timeout(deterministic: Arc, cx: &mut TestAppContext) { + deterministic.forbid_parking(); + + let user_id = 5; + let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); + let mut status = client.status(); + + // Time out when client tries to connect. + client.override_authenticate(move |cx| { + cx.foreground().spawn(async move { + Ok(Credentials { + user_id, + access_token: "token".into(), + }) + }) + }); + client.override_establish_connection(|_, cx| { + cx.foreground().spawn(async move { + future::pending::<()>().await; + unreachable!() + }) + }); + let auth_and_connect = cx.spawn({ + let client = client.clone(); + |cx| async move { client.authenticate_and_connect(false, &cx).await } + }); + deterministic.run_until_parked(); + assert!(matches!(status.next().await, Some(Status::Connecting))); + + deterministic.advance_clock(CONNECTION_TIMEOUT); + assert!(matches!( + status.next().await, + Some(Status::ConnectionError { .. }) + )); + auth_and_connect.await.unwrap_err(); + + // Allow the connection to be established. + let server = FakeServer::for_client(user_id, &client, cx).await; + assert!(matches!( + status.next().await, + Some(Status::Connected { .. }) + )); + + // Disconnect client. + server.forbid_connections(); + server.disconnect(); + while !matches!(status.next().await, Some(Status::ReconnectionError { .. })) {} + + // Time out when re-establishing the connection. + server.allow_connections(); + client.override_establish_connection(|_, cx| { + cx.foreground().spawn(async move { + future::pending::<()>().await; + unreachable!() + }) + }); + deterministic.advance_clock(2 * INITIAL_RECONNECTION_DELAY); + assert!(matches!( + status.next().await, + Some(Status::Reconnecting { .. }) + )); + + deterministic.advance_clock(CONNECTION_TIMEOUT); + assert!(matches!( + status.next().await, + Some(Status::ReconnectionError { .. }) + )); + } + #[gpui::test(iterations = 10)] async fn test_authenticating_more_than_once( cx: &mut TestAppContext, @@ -1124,7 +1257,7 @@ mod tests { let auth_count = Arc::new(Mutex::new(0)); let dropped_auth_count = Arc::new(Mutex::new(0)); - let client = Client::new(FakeHttpClient::with_404_response()); + let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); client.override_authenticate({ let auth_count = auth_count.clone(); let dropped_auth_count = dropped_auth_count.clone(); @@ -1173,7 +1306,7 @@ mod tests { cx.foreground().forbid_parking(); let user_id = 5; - let client = Client::new(FakeHttpClient::with_404_response()); + let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let server = FakeServer::for_client(user_id, &client, cx).await; let (done_tx1, mut done_rx1) = smol::channel::unbounded(); @@ -1219,7 +1352,7 @@ mod tests { cx.foreground().forbid_parking(); let user_id = 5; - let client = Client::new(FakeHttpClient::with_404_response()); + let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let server = FakeServer::for_client(user_id, &client, cx).await; let model = cx.add_model(|_| Model::default()); @@ -1247,7 +1380,7 @@ mod tests { cx.foreground().forbid_parking(); let user_id = 5; - let client = Client::new(FakeHttpClient::with_404_response()); + let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let server = FakeServer::for_client(user_id, &client, cx).await; let model = cx.add_model(|_| Model::default()); diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs new file mode 100644 index 0000000000..0c162580d4 --- /dev/null +++ b/crates/client/src/telemetry.rs @@ -0,0 +1,283 @@ +use crate::http::HttpClient; +use db::Db; +use gpui::{ + executor::Background, + serde_json::{self, value::Map, Value}, + AppContext, Task, +}; +use isahc::Request; +use lazy_static::lazy_static; +use parking_lot::Mutex; +use serde::Serialize; +use serde_json::json; +use std::{ + io::Write, + mem, + path::PathBuf, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tempfile::NamedTempFile; +use util::{post_inc, ResultExt, TryFutureExt}; +use uuid::Uuid; + +pub struct Telemetry { + http_client: Arc, + executor: Arc, + session_id: u128, + state: Mutex, +} + +#[derive(Default)] +struct TelemetryState { + metrics_id: Option>, + device_id: Option>, + app_version: Option>, + os_version: Option>, + os_name: &'static str, + queue: Vec, + next_event_id: usize, + flush_task: Option>, + log_file: Option, +} + +const AMPLITUDE_EVENTS_URL: &'static str = "https://api2.amplitude.com/batch"; + +lazy_static! { + static ref AMPLITUDE_API_KEY: Option = std::env::var("ZED_AMPLITUDE_API_KEY") + .ok() + .or_else(|| option_env!("ZED_AMPLITUDE_API_KEY").map(|key| key.to_string())); +} + +#[derive(Serialize)] +struct AmplitudeEventBatch { + api_key: &'static str, + events: Vec, +} + +#[derive(Serialize)] +struct AmplitudeEvent { + #[serde(skip_serializing_if = "Option::is_none")] + user_id: Option>, + device_id: Option>, + event_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + event_properties: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + user_properties: Option>, + os_name: &'static str, + os_version: Option>, + app_version: Option>, + platform: &'static str, + event_id: usize, + session_id: u128, + time: u128, +} + +#[cfg(debug_assertions)] +const MAX_QUEUE_LEN: usize = 1; + +#[cfg(not(debug_assertions))] +const MAX_QUEUE_LEN: usize = 10; + +#[cfg(debug_assertions)] +const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(1); + +#[cfg(not(debug_assertions))] +const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(30); + +impl Telemetry { + pub fn new(client: Arc, cx: &AppContext) -> Arc { + let platform = cx.platform(); + let this = Arc::new(Self { + http_client: client, + executor: cx.background().clone(), + session_id: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(), + state: Mutex::new(TelemetryState { + os_version: platform + .os_version() + .log_err() + .map(|v| v.to_string().into()), + os_name: platform.os_name().into(), + app_version: platform + .app_version() + .log_err() + .map(|v| v.to_string().into()), + device_id: None, + queue: Default::default(), + flush_task: Default::default(), + next_event_id: 0, + log_file: None, + metrics_id: None, + }), + }); + + if AMPLITUDE_API_KEY.is_some() { + this.executor + .spawn({ + let this = this.clone(); + async move { + if let Some(tempfile) = NamedTempFile::new().log_err() { + this.state.lock().log_file = Some(tempfile); + } + } + }) + .detach(); + } + + this + } + + pub fn log_file_path(&self) -> Option { + Some(self.state.lock().log_file.as_ref()?.path().to_path_buf()) + } + + pub fn start(self: &Arc, db: Arc) { + let this = self.clone(); + self.executor + .spawn( + async move { + let device_id = if let Some(device_id) = db + .read(["device_id"])? + .into_iter() + .flatten() + .next() + .and_then(|bytes| String::from_utf8(bytes).ok()) + { + device_id + } else { + let device_id = Uuid::new_v4().to_string(); + db.write([("device_id", device_id.as_bytes())])?; + device_id + }; + + let device_id = Some(Arc::from(device_id)); + let mut state = this.state.lock(); + state.device_id = device_id.clone(); + for event in &mut state.queue { + event.device_id = device_id.clone(); + } + if !state.queue.is_empty() { + drop(state); + this.flush(); + } + + anyhow::Ok(()) + } + .log_err(), + ) + .detach(); + } + + pub fn set_authenticated_user_info( + self: &Arc, + metrics_id: Option, + is_staff: bool, + ) { + let is_signed_in = metrics_id.is_some(); + self.state.lock().metrics_id = metrics_id.map(|s| s.into()); + if is_signed_in { + self.report_event_with_user_properties( + "$identify", + Default::default(), + json!({ "$set": { "staff": is_staff } }), + ) + } + } + + pub fn report_event(self: &Arc, kind: &str, properties: Value) { + self.report_event_with_user_properties(kind, properties, Default::default()); + } + + fn report_event_with_user_properties( + self: &Arc, + kind: &str, + properties: Value, + user_properties: Value, + ) { + if AMPLITUDE_API_KEY.is_none() { + return; + } + + let mut state = self.state.lock(); + let event = AmplitudeEvent { + event_type: kind.to_string(), + time: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(), + session_id: self.session_id, + event_properties: if let Value::Object(properties) = properties { + Some(properties) + } else { + None + }, + user_properties: if let Value::Object(user_properties) = user_properties { + Some(user_properties) + } else { + None + }, + user_id: state.metrics_id.clone(), + device_id: state.device_id.clone(), + os_name: state.os_name, + platform: "Zed", + os_version: state.os_version.clone(), + app_version: state.app_version.clone(), + event_id: post_inc(&mut state.next_event_id), + }; + state.queue.push(event); + if state.device_id.is_some() { + if state.queue.len() >= MAX_QUEUE_LEN { + drop(state); + self.flush(); + } else { + let this = self.clone(); + let executor = self.executor.clone(); + state.flush_task = Some(self.executor.spawn(async move { + executor.timer(DEBOUNCE_INTERVAL).await; + this.flush(); + })); + } + } + } + + fn flush(self: &Arc) { + let mut state = self.state.lock(); + let events = mem::take(&mut state.queue); + state.flush_task.take(); + drop(state); + + if let Some(api_key) = AMPLITUDE_API_KEY.as_ref() { + let this = self.clone(); + self.executor + .spawn( + async move { + let mut json_bytes = Vec::new(); + + if let Some(file) = &mut this.state.lock().log_file { + let file = file.as_file_mut(); + for event in &events { + json_bytes.clear(); + serde_json::to_writer(&mut json_bytes, event)?; + file.write_all(&json_bytes)?; + file.write(b"\n")?; + } + } + + let batch = AmplitudeEventBatch { api_key, events }; + json_bytes.clear(); + serde_json::to_writer(&mut json_bytes, &batch)?; + let request = + Request::post(AMPLITUDE_EVENTS_URL).body(json_bytes.into())?; + this.http_client.send(request).await?; + Ok(()) + } + .log_err(), + ) + .detach(); + } + } +} diff --git a/crates/client/src/test.rs b/crates/client/src/test.rs index c634978a57..ade21f02f4 100644 --- a/crates/client/src/test.rs +++ b/crates/client/src/test.rs @@ -6,7 +6,10 @@ use anyhow::{anyhow, Result}; use futures::{future::BoxFuture, stream::BoxStream, Future, StreamExt}; use gpui::{executor, ModelHandle, TestAppContext}; use parking_lot::Mutex; -use rpc::{proto, ConnectionId, Peer, Receipt, TypedEnvelope}; +use rpc::{ + proto::{self, GetPrivateUserInfo, GetPrivateUserInfoResponse}, + ConnectionId, Peer, Receipt, TypedEnvelope, +}; use std::{fmt, rc::Rc, sync::Arc}; pub struct FakeServer { @@ -79,7 +82,7 @@ impl FakeServer { let (client_conn, server_conn, _) = Connection::in_memory(cx.background()); let (connection_id, io, incoming) = - peer.add_test_connection(server_conn, cx.background()).await; + peer.add_test_connection(server_conn, cx.background()); cx.background().spawn(io).detach(); let mut state = state.lock(); state.connection_id = Some(connection_id); @@ -93,14 +96,17 @@ impl FakeServer { .authenticate_and_connect(false, &cx.to_async()) .await .unwrap(); + server } pub fn disconnect(&self) { - self.peer.disconnect(self.connection_id()); - let mut state = self.state.lock(); - state.connection_id.take(); - state.incoming.take(); + if self.state.lock().connection_id.is_some() { + self.peer.disconnect(self.connection_id()); + let mut state = self.state.lock(); + state.connection_id.take(); + state.incoming.take(); + } } pub fn auth_count(&self) -> usize { @@ -126,26 +132,45 @@ impl FakeServer { #[allow(clippy::await_holding_lock)] pub async fn receive(&self) -> Result> { self.executor.start_waiting(); - let message = self - .state - .lock() - .incoming - .as_mut() - .expect("not connected") - .next() - .await - .ok_or_else(|| anyhow!("other half hung up"))?; - self.executor.finish_waiting(); - let type_name = message.payload_type_name(); - Ok(*message - .into_any() - .downcast::>() - .unwrap_or_else(|_| { - panic!( - "fake server received unexpected message type: {:?}", - type_name - ); - })) + + loop { + let message = self + .state + .lock() + .incoming + .as_mut() + .expect("not connected") + .next() + .await + .ok_or_else(|| anyhow!("other half hung up"))?; + self.executor.finish_waiting(); + let type_name = message.payload_type_name(); + let message = message.into_any(); + + if message.is::>() { + return Ok(*message.downcast().unwrap()); + } + + if message.is::>() { + self.respond( + message + .downcast::>() + .unwrap() + .receipt(), + GetPrivateUserInfoResponse { + metrics_id: "the-metrics-id".into(), + staff: false, + }, + ) + .await; + continue; + } + + panic!( + "fake server received unexpected message type: {:?}", + type_name + ); + } } pub async fn respond( diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 149d22e77a..3c3d7e7fb3 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -1,14 +1,14 @@ use super::{http::HttpClient, proto, Client, Status, TypedEnvelope}; use anyhow::{anyhow, Context, Result}; -use collections::{hash_map::Entry, BTreeSet, HashMap, HashSet}; +use collections::{hash_map::Entry, HashMap, HashSet}; use futures::{channel::mpsc, future, AsyncReadExt, Future, StreamExt}; use gpui::{AsyncAppContext, Entity, ImageData, ModelContext, ModelHandle, Task}; -use postage::{prelude::Stream, sink::Sink, watch}; +use postage::{sink::Sink, watch}; use rpc::proto::{RequestMessage, UsersResponse}; use std::sync::{Arc, Weak}; use util::TryFutureExt as _; -#[derive(Debug)] +#[derive(Default, Debug)] pub struct User { pub id: u64, pub github_login: String, @@ -39,14 +39,7 @@ impl Eq for User {} pub struct Contact { pub user: Arc, pub online: bool, - pub projects: Vec, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct ProjectMetadata { - pub id: u64, - pub visible_worktree_root_names: Vec, - pub guests: BTreeSet>, + pub busy: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -138,14 +131,25 @@ impl UserStore { }), _maintain_current_user: cx.spawn_weak(|this, mut cx| async move { let mut status = client.status(); - while let Some(status) = status.recv().await { + while let Some(status) = status.next().await { match status { Status::Connected { .. } => { if let Some((this, user_id)) = this.upgrade(&cx).zip(client.user_id()) { - let user = this - .update(&mut cx, |this, cx| this.fetch_user(user_id, cx)) - .log_err() - .await; + let fetch_user = this + .update(&mut cx, |this, cx| this.get_user(user_id, cx)) + .log_err(); + let fetch_metrics_id = + client.request(proto::GetPrivateUserInfo {}).log_err(); + let (user, info) = futures::join!(fetch_user, fetch_metrics_id); + if let Some(info) = info { + client.telemetry.set_authenticated_user_info( + Some(info.metrics_id), + info.staff, + ); + } else { + client.telemetry.set_authenticated_user_info(None, false); + } + client.telemetry.report_event("sign in", Default::default()); current_user_tx.send(user).await.ok(); } } @@ -233,7 +237,6 @@ impl UserStore { let mut user_ids = HashSet::default(); for contact in &message.contacts { user_ids.insert(contact.user_id); - user_ids.extend(contact.projects.iter().flat_map(|w| &w.guests).copied()); } user_ids.extend(message.incoming_requests.iter().map(|req| req.requester_id)); user_ids.extend(message.outgoing_requests.iter()); @@ -257,9 +260,7 @@ impl UserStore { for request in message.incoming_requests { incoming_requests.push({ let user = this - .update(&mut cx, |this, cx| { - this.fetch_user(request.requester_id, cx) - }) + .update(&mut cx, |this, cx| this.get_user(request.requester_id, cx)) .await?; (user, request.should_notify) }); @@ -268,7 +269,7 @@ impl UserStore { let mut outgoing_requests = Vec::new(); for requested_user_id in message.outgoing_requests { outgoing_requests.push( - this.update(&mut cx, |this, cx| this.fetch_user(requested_user_id, cx)) + this.update(&mut cx, |this, cx| this.get_user(requested_user_id, cx)) .await?, ); } @@ -493,7 +494,7 @@ impl UserStore { .unbounded_send(UpdateContacts::Clear(tx)) .unwrap(); async move { - rx.recv().await; + rx.next().await; } } @@ -503,25 +504,43 @@ impl UserStore { .unbounded_send(UpdateContacts::Wait(tx)) .unwrap(); async move { - rx.recv().await; + rx.next().await; } } pub fn get_users( &mut self, - mut user_ids: Vec, + user_ids: Vec, cx: &mut ModelContext, - ) -> Task> { - user_ids.retain(|id| !self.users.contains_key(id)); - if user_ids.is_empty() { - Task::ready(Ok(())) - } else { - let load = self.load_users(proto::GetUsers { user_ids }, cx); - cx.foreground().spawn(async move { - load.await?; - Ok(()) + ) -> Task>>> { + let mut user_ids_to_fetch = user_ids.clone(); + user_ids_to_fetch.retain(|id| !self.users.contains_key(id)); + + cx.spawn(|this, mut cx| async move { + if !user_ids_to_fetch.is_empty() { + this.update(&mut cx, |this, cx| { + this.load_users( + proto::GetUsers { + user_ids: user_ids_to_fetch, + }, + cx, + ) + }) + .await?; + } + + this.read_with(&cx, |this, _| { + user_ids + .iter() + .map(|user_id| { + this.users + .get(user_id) + .cloned() + .ok_or_else(|| anyhow!("user {} not found", user_id)) + }) + .collect() }) - } + }) } pub fn fuzzy_search_users( @@ -532,7 +551,7 @@ impl UserStore { self.load_users(proto::FuzzySearchUsers { query }, cx) } - pub fn fetch_user( + pub fn get_user( &mut self, user_id: u64, cx: &mut ModelContext, @@ -612,39 +631,15 @@ impl Contact { ) -> Result { let user = user_store .update(cx, |user_store, cx| { - user_store.fetch_user(contact.user_id, cx) + user_store.get_user(contact.user_id, cx) }) .await?; - let mut projects = Vec::new(); - for project in contact.projects { - let mut guests = BTreeSet::new(); - for participant_id in project.guests { - guests.insert( - user_store - .update(cx, |user_store, cx| { - user_store.fetch_user(participant_id, cx) - }) - .await?, - ); - } - projects.push(ProjectMetadata { - id: project.id, - visible_worktree_root_names: project.visible_worktree_root_names.clone(), - guests, - }); - } Ok(Self { user, online: contact.online, - projects, + busy: contact.busy, }) } - - pub fn non_empty_projects(&self) -> impl Iterator { - self.projects - .iter() - .filter(|project| !project.visible_worktree_root_names.is_empty()) - } } async fn fetch_avatar(http: &dyn HttpClient, url: &str) -> Result> { diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 9b3603e6e4..de41e8a1f3 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -1,5 +1,5 @@ [package] -authors = ["Nathan Sobo "] +authors = ["Nathan Sobo "] default-run = "collab" edition = "2021" name = "collab" @@ -16,7 +16,6 @@ required-features = ["seed-support"] collections = { path = "../collections" } rpc = { path = "../rpc" } util = { path = "../util" } - anyhow = "1.0.40" async-trait = "0.1.50" async-tungstenite = "0.16" @@ -55,13 +54,16 @@ features = ["runtime-tokio-rustls", "postgres", "time", "uuid"] [dev-dependencies] collections = { path = "../collections", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] } -rpc = { path = "../rpc", features = ["test-support"] } +call = { path = "../call", features = ["test-support"] } client = { path = "../client", features = ["test-support"] } editor = { path = "../editor", features = ["test-support"] } language = { path = "../language", features = ["test-support"] } +fs = { path = "../fs", features = ["test-support"] } +git = { path = "../git", features = ["test-support"] } log = { version = "0.4.16", features = ["kv_unstable_serde"] } lsp = { path = "../lsp", features = ["test-support"] } project = { path = "../project", features = ["test-support"] } +rpc = { path = "../rpc", features = ["test-support"] } settings = { path = "../settings", features = ["test-support"] } theme = { path = "../theme" } workspace = { path = "../workspace", features = ["test-support"] } @@ -70,6 +72,7 @@ env_logger = "0.9" util = { path = "../util" } lazy_static = "1.4" serde_json = { version = "1.0", features = ["preserve_order"] } +unindent = "0.1" [features] seed-support = ["clap", "lipsum", "reqwest"] diff --git a/crates/collab/migrations/20220913211150_create_signups.sql b/crates/collab/migrations/20220913211150_create_signups.sql new file mode 100644 index 0000000000..19559b747c --- /dev/null +++ b/crates/collab/migrations/20220913211150_create_signups.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS "signups" ( + "id" SERIAL PRIMARY KEY, + "email_address" VARCHAR NOT NULL, + "email_confirmation_code" VARCHAR(64) NOT NULL, + "email_confirmation_sent" BOOLEAN NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "device_id" VARCHAR, + "user_id" INTEGER REFERENCES users (id) ON DELETE CASCADE, + "inviting_user_id" INTEGER REFERENCES users (id) ON DELETE SET NULL, + + "platform_mac" BOOLEAN NOT NULL, + "platform_linux" BOOLEAN NOT NULL, + "platform_windows" BOOLEAN NOT NULL, + "platform_unknown" BOOLEAN NOT NULL, + + "editor_features" VARCHAR[], + "programming_languages" VARCHAR[] +); + +CREATE UNIQUE INDEX "index_signups_on_email_address" ON "signups" ("email_address"); +CREATE INDEX "index_signups_on_email_confirmation_sent" ON "signups" ("email_confirmation_sent"); + +ALTER TABLE "users" + ADD "github_user_id" INTEGER; + +CREATE INDEX "index_users_on_email_address" ON "users" ("email_address"); +CREATE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id"); diff --git a/crates/collab/migrations/20220929182110_add_metrics_id.sql b/crates/collab/migrations/20220929182110_add_metrics_id.sql new file mode 100644 index 0000000000..665d6323bf --- /dev/null +++ b/crates/collab/migrations/20220929182110_add_metrics_id.sql @@ -0,0 +1,2 @@ +ALTER TABLE "users" + ADD "metrics_id" uuid NOT NULL DEFAULT gen_random_uuid(); diff --git a/crates/collab/src/api.rs b/crates/collab/src/api.rs index eafeae0864..08dfa91ba9 100644 --- a/crates/collab/src/api.rs +++ b/crates/collab/src/api.rs @@ -1,6 +1,6 @@ use crate::{ auth, - db::{ProjectId, User, UserId}, + db::{Invite, NewUserParams, ProjectId, Signup, User, UserId, WaitlistSummary}, rpc::{self, ResultExt}, AppState, Error, Result, }; @@ -24,13 +24,10 @@ use tracing::instrument; pub fn routes(rpc_server: &Arc, state: Arc) -> Router { Router::new() + .route("/user", get(get_authenticated_user)) .route("/users", get(get_users).post(create_user)) - .route( - "/users/:id", - put(update_user).delete(destroy_user).get(get_user), - ) + .route("/users/:id", put(update_user).delete(destroy_user)) .route("/users/:id/access_tokens", post(create_access_token)) - .route("/bulk_users", post(create_users)) .route("/users_with_no_invites", get(get_users_with_no_invites)) .route("/invite_codes/:code", get(get_user_for_invite_code)) .route("/panic", post(trace_panic)) @@ -45,6 +42,11 @@ pub fn routes(rpc_server: &Arc, state: Arc) -> Router(req: Request, next: Next) -> impl IntoR Ok::<_, Error>(next.run(req).await) } +#[derive(Debug, Deserialize)] +struct AuthenticatedUserParams { + github_user_id: i32, + github_login: String, +} + +#[derive(Debug, Serialize)] +struct AuthenticatedUserResponse { + user: User, + metrics_id: String, +} + +async fn get_authenticated_user( + Query(params): Query, + Extension(app): Extension>, +) -> Result> { + let user = app + .db + .get_user_by_github_account(¶ms.github_login, Some(params.github_user_id)) + .await? + .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "user not found".into()))?; + let metrics_id = app.db.get_user_metrics_id(user.id).await?; + return Ok(Json(AuthenticatedUserResponse { user, metrics_id })); +} + #[derive(Debug, Deserialize)] struct GetUsersQueryParams { query: Option, @@ -108,48 +135,76 @@ async fn get_users( #[derive(Deserialize, Debug)] struct CreateUserParams { + github_user_id: i32, github_login: String, - invite_code: Option, - email_address: Option, + email_address: String, + email_confirmation_code: Option, + #[serde(default)] admin: bool, + #[serde(default)] + invite_count: i32, +} + +#[derive(Serialize, Debug)] +struct CreateUserResponse { + user: User, + signup_device_id: Option, + metrics_id: String, } async fn create_user( Json(params): Json, Extension(app): Extension>, Extension(rpc_server): Extension>, -) -> Result> { - let user_id = if let Some(invite_code) = params.invite_code { - let invitee_id = app - .db - .redeem_invite_code( - &invite_code, - ¶ms.github_login, - params.email_address.as_deref(), - ) - .await?; - rpc_server - .invite_code_redeemed(&invite_code, invitee_id) - .await - .trace_err(); - invitee_id - } else { +) -> Result> { + let user = NewUserParams { + github_login: params.github_login, + github_user_id: params.github_user_id, + invite_count: params.invite_count, + }; + + // Creating a user via the normal signup process + let result = if let Some(email_confirmation_code) = params.email_confirmation_code { app.db - .create_user( - ¶ms.github_login, - params.email_address.as_deref(), - params.admin, + .create_user_from_invite( + &Invite { + email_address: params.email_address, + email_confirmation_code, + }, + user, ) .await? + } + // Creating a user as an admin + else if params.admin { + app.db + .create_user(¶ms.email_address, false, user) + .await? + } else { + Err(Error::Http( + StatusCode::UNPROCESSABLE_ENTITY, + "email confirmation code is required".into(), + ))? }; + if let Some(inviter_id) = result.inviting_user_id { + rpc_server + .invite_code_redeemed(inviter_id, result.user_id) + .await + .trace_err(); + } + let user = app .db - .get_user_by_id(user_id) + .get_user_by_id(result.user_id) .await? .ok_or_else(|| anyhow!("couldn't find the user we just created"))?; - Ok(Json(user)) + Ok(Json(CreateUserResponse { + user, + metrics_id: result.metrics_id, + signup_device_id: result.signup_device_id, + })) } #[derive(Deserialize)] @@ -171,7 +226,9 @@ async fn update_user( } if let Some(invite_count) = params.invite_count { - app.db.set_invite_count(user_id, invite_count).await?; + app.db + .set_invite_count_for_user(user_id, invite_count) + .await?; rpc_server.invite_count_updated(user_id).await.trace_err(); } @@ -186,54 +243,6 @@ async fn destroy_user( Ok(()) } -async fn get_user( - Path(login): Path, - Extension(app): Extension>, -) -> Result> { - let user = app - .db - .get_user_by_github_login(&login) - .await? - .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "User not found".to_string()))?; - Ok(Json(user)) -} - -#[derive(Deserialize)] -struct CreateUsersParams { - users: Vec, -} - -#[derive(Deserialize)] -struct CreateUsersEntry { - github_login: String, - email_address: String, - invite_count: usize, -} - -async fn create_users( - Json(params): Json, - Extension(app): Extension>, -) -> Result>> { - let user_ids = app - .db - .create_users( - params - .users - .into_iter() - .map(|params| { - ( - params.github_login, - params.email_address, - params.invite_count, - ) - }) - .collect(), - ) - .await?; - let users = app.db.get_users_by_ids(user_ids).await?; - Ok(Json(users)) -} - #[derive(Debug, Deserialize)] struct GetUsersWithNoInvites { invited_by_another_user: bool, @@ -368,22 +377,24 @@ struct CreateAccessTokenResponse { } async fn create_access_token( - Path(login): Path, + Path(user_id): Path, Query(params): Query, Extension(app): Extension>, ) -> Result> { - // request.require_token().await?; - let user = app .db - .get_user_by_github_login(&login) + .get_user_by_id(user_id) .await? .ok_or_else(|| anyhow!("user not found"))?; let mut user_id = user.id; if let Some(impersonate) = params.impersonate { if user.admin { - if let Some(impersonated_user) = app.db.get_user_by_github_login(&impersonate).await? { + if let Some(impersonated_user) = app + .db + .get_user_by_github_account(&impersonate, None) + .await? + { user_id = impersonated_user.id; } else { return Err(Error::Http( @@ -415,3 +426,59 @@ async fn get_user_for_invite_code( ) -> Result> { Ok(Json(app.db.get_user_for_invite_code(&code).await?)) } + +async fn create_signup( + Json(params): Json, + Extension(app): Extension>, +) -> Result<()> { + app.db.create_signup(params).await?; + Ok(()) +} + +async fn get_waitlist_summary( + Extension(app): Extension>, +) -> Result> { + Ok(Json(app.db.get_waitlist_summary().await?)) +} + +#[derive(Deserialize)] +pub struct CreateInviteFromCodeParams { + invite_code: String, + email_address: String, + device_id: Option, +} + +async fn create_invite_from_code( + Json(params): Json, + Extension(app): Extension>, +) -> Result> { + Ok(Json( + app.db + .create_invite_from_code( + ¶ms.invite_code, + ¶ms.email_address, + params.device_id.as_deref(), + ) + .await?, + )) +} + +#[derive(Deserialize)] +pub struct GetUnsentInvitesParams { + pub count: usize, +} + +async fn get_unsent_invites( + Query(params): Query, + Extension(app): Extension>, +) -> Result>> { + Ok(Json(app.db.get_unsent_invites(params.count).await?)) +} + +async fn record_sent_invites( + Json(params): Json>, + Extension(app): Extension>, +) -> Result<()> { + app.db.record_sent_invites(¶ms).await?; + Ok(()) +} diff --git a/crates/collab/src/bin/seed.rs b/crates/collab/src/bin/seed.rs index dba7d14939..cabea7d013 100644 --- a/crates/collab/src/bin/seed.rs +++ b/crates/collab/src/bin/seed.rs @@ -11,7 +11,7 @@ mod db; #[derive(Debug, Deserialize)] struct GitHubUser { - id: usize, + id: i32, login: String, email: Option, } @@ -26,8 +26,11 @@ async fn main() { let github_token = std::env::var("GITHUB_TOKEN").expect("missing GITHUB_TOKEN env var"); let client = reqwest::Client::new(); - let current_user = + let mut current_user = fetch_github::(&client, &github_token, "https://api.github.com/user").await; + current_user + .email + .get_or_insert_with(|| "placeholder@example.com".to_string()); let staff_users = fetch_github::>( &client, &github_token, @@ -64,16 +67,40 @@ async fn main() { let mut zed_user_ids = Vec::::new(); for (github_user, admin) in zed_users { if let Some(user) = db - .get_user_by_github_login(&github_user.login) + .get_user_by_github_account(&github_user.login, Some(github_user.id)) .await .expect("failed to fetch user") { zed_user_ids.push(user.id); - } else { + } else if let Some(email) = &github_user.email { zed_user_ids.push( - db.create_user(&github_user.login, github_user.email.as_deref(), admin) - .await - .expect("failed to insert user"), + db.create_user( + email, + admin, + db::NewUserParams { + github_login: github_user.login, + github_user_id: github_user.id, + invite_count: 5, + }, + ) + .await + .expect("failed to insert user") + .user_id, + ); + } else if admin { + zed_user_ids.push( + db.create_user( + &format!("{}@zed.dev", github_user.login), + admin, + db::NewUserParams { + github_login: github_user.login, + github_user_id: github_user.id, + invite_count: 5, + }, + ) + .await + .expect("failed to insert user") + .user_id, ); } } diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index eeb598413e..9b3dca1f2c 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1,5 +1,3 @@ -use std::{cmp, ops::Range, time::Duration}; - use crate::{Error, Result}; use anyhow::{anyhow, Context}; use async_trait::async_trait; @@ -8,37 +6,52 @@ use collections::HashMap; use futures::StreamExt; use serde::{Deserialize, Serialize}; pub use sqlx::postgres::PgPoolOptions as DbOptions; -use sqlx::{types::Uuid, FromRow, QueryBuilder, Row}; +use sqlx::{types::Uuid, FromRow, QueryBuilder}; +use std::{cmp, ops::Range, time::Duration}; use time::{OffsetDateTime, PrimitiveDateTime}; #[async_trait] pub trait Db: Send + Sync { async fn create_user( &self, - github_login: &str, - email_address: Option<&str>, + email_address: &str, admin: bool, - ) -> Result; + params: NewUserParams, + ) -> Result; async fn get_all_users(&self, page: u32, limit: u32) -> Result>; - async fn create_users(&self, users: Vec<(String, String, usize)>) -> Result>; async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result>; async fn get_user_by_id(&self, id: UserId) -> Result>; + async fn get_user_metrics_id(&self, id: UserId) -> Result; async fn get_users_by_ids(&self, ids: Vec) -> Result>; async fn get_users_with_no_invites(&self, invited_by_another_user: bool) -> Result>; - async fn get_user_by_github_login(&self, github_login: &str) -> Result>; + async fn get_user_by_github_account( + &self, + github_login: &str, + github_user_id: Option, + ) -> Result>; async fn set_user_is_admin(&self, id: UserId, is_admin: bool) -> Result<()>; async fn set_user_connected_once(&self, id: UserId, connected_once: bool) -> Result<()>; async fn destroy_user(&self, id: UserId) -> Result<()>; - async fn set_invite_count(&self, id: UserId, count: u32) -> Result<()>; + async fn set_invite_count_for_user(&self, id: UserId, count: u32) -> Result<()>; async fn get_invite_code_for_user(&self, id: UserId) -> Result>; async fn get_user_for_invite_code(&self, code: &str) -> Result; - async fn redeem_invite_code( + async fn create_invite_from_code( &self, code: &str, - login: &str, - email_address: Option<&str>, - ) -> Result; + email_address: &str, + device_id: Option<&str>, + ) -> Result; + + async fn create_signup(&self, signup: Signup) -> Result<()>; + async fn get_waitlist_summary(&self) -> Result; + async fn get_unsent_invites(&self, count: usize) -> Result>; + async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()>; + async fn create_user_from_invite( + &self, + invite: &Invite, + user: NewUserParams, + ) -> Result; /// Registers a new project for the given user. async fn register_project(&self, host_user_id: UserId) -> Result; @@ -115,8 +128,8 @@ pub trait Db: Send + Sync { max_access_token_count: usize, ) -> Result<()>; async fn get_access_token_hashes(&self, user_id: UserId) -> Result>; - #[cfg(any(test, feature = "seed-support"))] + #[cfg(any(test, feature = "seed-support"))] async fn find_org_by_slug(&self, slug: &str) -> Result>; #[cfg(any(test, feature = "seed-support"))] async fn create_org(&self, name: &str, slug: &str) -> Result; @@ -130,6 +143,7 @@ pub trait Db: Send + Sync { async fn get_accessible_channels(&self, user_id: UserId) -> Result>; async fn can_user_access_channel(&self, user_id: UserId, channel_id: ChannelId) -> Result; + #[cfg(any(test, feature = "seed-support"))] async fn add_channel_member( &self, @@ -151,10 +165,12 @@ pub trait Db: Send + Sync { count: usize, before_id: Option, ) -> Result>; + #[cfg(test)] async fn teardown(&self, url: &str); + #[cfg(test)] - fn as_fake(&self) -> Option<&tests::FakeDb>; + fn as_fake(&self) -> Option<&FakeDb>; } pub struct PostgresDb { @@ -170,6 +186,18 @@ impl PostgresDb { .context("failed to connect to postgres database")?; Ok(Self { pool }) } + + pub fn fuzzy_like_string(string: &str) -> String { + let mut result = String::with_capacity(string.len() * 2 + 1); + for c in string.chars() { + if c.is_alphanumeric() { + result.push('%'); + result.push(c); + } + } + result.push('%'); + result + } } #[async_trait] @@ -178,23 +206,29 @@ impl Db for PostgresDb { async fn create_user( &self, - github_login: &str, - email_address: Option<&str>, + email_address: &str, admin: bool, - ) -> Result { + params: NewUserParams, + ) -> Result { let query = " - INSERT INTO users (github_login, email_address, admin) - VALUES ($1, $2, $3) + INSERT INTO users (email_address, github_login, github_user_id, admin) + VALUES ($1, $2, $3, $4) ON CONFLICT (github_login) DO UPDATE SET github_login = excluded.github_login - RETURNING id + RETURNING id, metrics_id::text "; - Ok(sqlx::query_scalar(query) - .bind(github_login) + let (user_id, metrics_id): (UserId, String) = sqlx::query_as(query) .bind(email_address) + .bind(params.github_login) + .bind(params.github_user_id) .bind(admin) .fetch_one(&self.pool) - .await - .map(UserId)?) + .await?; + Ok(NewUserResult { + user_id, + metrics_id, + signup_device_id: None, + inviting_user_id: None, + }) } async fn get_all_users(&self, page: u32, limit: u32) -> Result> { @@ -206,43 +240,8 @@ impl Db for PostgresDb { .await?) } - async fn create_users(&self, users: Vec<(String, String, usize)>) -> Result> { - let mut query = QueryBuilder::new( - "INSERT INTO users (github_login, email_address, admin, invite_code, invite_count)", - ); - query.push_values( - users, - |mut query, (github_login, email_address, invite_count)| { - query - .push_bind(github_login) - .push_bind(email_address) - .push_bind(false) - .push_bind(random_invite_code()) - .push_bind(invite_count as i32); - }, - ); - query.push( - " - ON CONFLICT (github_login) DO UPDATE SET - github_login = excluded.github_login, - invite_count = excluded.invite_count, - invite_code = CASE WHEN users.invite_code IS NULL - THEN excluded.invite_code - ELSE users.invite_code - END - RETURNING id - ", - ); - - let rows = query.build().fetch_all(&self.pool).await?; - Ok(rows - .into_iter() - .filter_map(|row| row.try_get::(0).ok()) - .collect()) - } - async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result> { - let like_string = fuzzy_like_string(name_query); + let like_string = Self::fuzzy_like_string(name_query); let query = " SELECT users.* FROM users @@ -263,6 +262,18 @@ impl Db for PostgresDb { Ok(users.into_iter().next()) } + async fn get_user_metrics_id(&self, id: UserId) -> Result { + let query = " + SELECT metrics_id::text + FROM users + WHERE id = $1 + "; + Ok(sqlx::query_scalar(query) + .bind(id) + .fetch_one(&self.pool) + .await?) + } + async fn get_users_by_ids(&self, ids: Vec) -> Result> { let ids = ids.into_iter().map(|id| id.0).collect::>(); let query = " @@ -290,12 +301,53 @@ impl Db for PostgresDb { Ok(sqlx::query_as(&query).fetch_all(&self.pool).await?) } - async fn get_user_by_github_login(&self, github_login: &str) -> Result> { - let query = "SELECT * FROM users WHERE github_login = $1 LIMIT 1"; - Ok(sqlx::query_as(query) + async fn get_user_by_github_account( + &self, + github_login: &str, + github_user_id: Option, + ) -> Result> { + if let Some(github_user_id) = github_user_id { + let mut user = sqlx::query_as::<_, User>( + " + UPDATE users + SET github_login = $1 + WHERE github_user_id = $2 + RETURNING * + ", + ) + .bind(github_login) + .bind(github_user_id) + .fetch_optional(&self.pool) + .await?; + + if user.is_none() { + user = sqlx::query_as::<_, User>( + " + UPDATE users + SET github_user_id = $1 + WHERE github_login = $2 + RETURNING * + ", + ) + .bind(github_user_id) + .bind(github_login) + .fetch_optional(&self.pool) + .await?; + } + + Ok(user) + } else { + Ok(sqlx::query_as( + " + SELECT * FROM users + WHERE github_login = $1 + LIMIT 1 + ", + ) .bind(github_login) .fetch_optional(&self.pool) .await?) + } } async fn set_user_is_admin(&self, id: UserId, is_admin: bool) -> Result<()> { @@ -333,9 +385,208 @@ impl Db for PostgresDb { .map(drop)?) } + // signups + + async fn create_signup(&self, signup: Signup) -> Result<()> { + sqlx::query( + " + INSERT INTO signups + ( + email_address, + email_confirmation_code, + email_confirmation_sent, + platform_linux, + platform_mac, + platform_windows, + platform_unknown, + editor_features, + programming_languages, + device_id + ) + VALUES + ($1, $2, 'f', $3, $4, $5, 'f', $6, $7, $8) + RETURNING id + ", + ) + .bind(&signup.email_address) + .bind(&random_email_confirmation_code()) + .bind(&signup.platform_linux) + .bind(&signup.platform_mac) + .bind(&signup.platform_windows) + .bind(&signup.editor_features) + .bind(&signup.programming_languages) + .bind(&signup.device_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_waitlist_summary(&self) -> Result { + Ok(sqlx::query_as( + " + SELECT + COUNT(*) as count, + COALESCE(SUM(CASE WHEN platform_linux THEN 1 ELSE 0 END), 0) as linux_count, + COALESCE(SUM(CASE WHEN platform_mac THEN 1 ELSE 0 END), 0) as mac_count, + COALESCE(SUM(CASE WHEN platform_windows THEN 1 ELSE 0 END), 0) as windows_count, + COALESCE(SUM(CASE WHEN platform_unknown THEN 1 ELSE 0 END), 0) as unknown_count + FROM ( + SELECT * + FROM signups + WHERE + NOT email_confirmation_sent + ) AS unsent + ", + ) + .fetch_one(&self.pool) + .await?) + } + + async fn get_unsent_invites(&self, count: usize) -> Result> { + Ok(sqlx::query_as( + " + SELECT + email_address, email_confirmation_code + FROM signups + WHERE + NOT email_confirmation_sent AND + (platform_mac OR platform_unknown) + LIMIT $1 + ", + ) + .bind(count as i32) + .fetch_all(&self.pool) + .await?) + } + + async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()> { + sqlx::query( + " + UPDATE signups + SET email_confirmation_sent = 't' + WHERE email_address = ANY ($1) + ", + ) + .bind( + &invites + .iter() + .map(|s| s.email_address.as_str()) + .collect::>(), + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn create_user_from_invite( + &self, + invite: &Invite, + user: NewUserParams, + ) -> Result { + let mut tx = self.pool.begin().await?; + + let (signup_id, existing_user_id, inviting_user_id, signup_device_id): ( + i32, + Option, + Option, + Option, + ) = sqlx::query_as( + " + SELECT id, user_id, inviting_user_id, device_id + FROM signups + WHERE + email_address = $1 AND + email_confirmation_code = $2 + ", + ) + .bind(&invite.email_address) + .bind(&invite.email_confirmation_code) + .fetch_optional(&mut tx) + .await? + .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "no such invite".to_string()))?; + + if existing_user_id.is_some() { + Err(Error::Http( + StatusCode::UNPROCESSABLE_ENTITY, + "invitation already redeemed".to_string(), + ))?; + } + + let (user_id, metrics_id): (UserId, String) = sqlx::query_as( + " + INSERT INTO users + (email_address, github_login, github_user_id, admin, invite_count, invite_code) + VALUES + ($1, $2, $3, 'f', $4, $5) + RETURNING id, metrics_id::text + ", + ) + .bind(&invite.email_address) + .bind(&user.github_login) + .bind(&user.github_user_id) + .bind(&user.invite_count) + .bind(random_invite_code()) + .fetch_one(&mut tx) + .await?; + + sqlx::query( + " + UPDATE signups + SET user_id = $1 + WHERE id = $2 + ", + ) + .bind(&user_id) + .bind(&signup_id) + .execute(&mut tx) + .await?; + + if let Some(inviting_user_id) = inviting_user_id { + let id: Option = sqlx::query_scalar( + " + UPDATE users + SET invite_count = invite_count - 1 + WHERE id = $1 AND invite_count > 0 + RETURNING id + ", + ) + .bind(&inviting_user_id) + .fetch_optional(&mut tx) + .await?; + + if id.is_none() { + Err(Error::Http( + StatusCode::UNAUTHORIZED, + "no invites remaining".to_string(), + ))?; + } + + sqlx::query( + " + INSERT INTO contacts + (user_id_a, user_id_b, a_to_b, should_notify, accepted) + VALUES + ($1, $2, 't', 't', 't') + ", + ) + .bind(inviting_user_id) + .bind(user_id) + .execute(&mut tx) + .await?; + } + + tx.commit().await?; + Ok(NewUserResult { + user_id, + metrics_id, + inviting_user_id, + signup_device_id, + }) + } + // invite codes - async fn set_invite_count(&self, id: UserId, count: u32) -> Result<()> { + async fn set_invite_count_for_user(&self, id: UserId, count: u32) -> Result<()> { let mut tx = self.pool.begin().await?; if count > 0 { sqlx::query( @@ -403,83 +654,89 @@ impl Db for PostgresDb { }) } - async fn redeem_invite_code( + async fn create_invite_from_code( &self, code: &str, - login: &str, - email_address: Option<&str>, - ) -> Result { + email_address: &str, + device_id: Option<&str>, + ) -> Result { let mut tx = self.pool.begin().await?; - let inviter_id: Option = sqlx::query_scalar( + let existing_user: Option = sqlx::query_scalar( " - UPDATE users - SET invite_count = invite_count - 1 - WHERE - invite_code = $1 AND - invite_count > 0 - RETURNING id + SELECT id + FROM users + WHERE email_address = $1 + ", + ) + .bind(email_address) + .fetch_optional(&mut tx) + .await?; + if existing_user.is_some() { + Err(anyhow!("email address is already in use"))?; + } + + let row: Option<(UserId, i32)> = sqlx::query_as( + " + SELECT id, invite_count + FROM users + WHERE invite_code = $1 ", ) .bind(code) .fetch_optional(&mut tx) .await?; - let inviter_id = match inviter_id { - Some(inviter_id) => inviter_id, - None => { - if sqlx::query_scalar::<_, i32>("SELECT 1 FROM users WHERE invite_code = $1") - .bind(code) - .fetch_optional(&mut tx) - .await? - .is_some() - { - Err(Error::Http( - StatusCode::UNAUTHORIZED, - "no invites remaining".to_string(), - ))? - } else { - Err(Error::Http( - StatusCode::NOT_FOUND, - "invite code not found".to_string(), - ))? - } - } + let (inviter_id, invite_count) = match row { + Some(row) => row, + None => Err(Error::Http( + StatusCode::NOT_FOUND, + "invite code not found".to_string(), + ))?, }; - let invitee_id = sqlx::query_scalar( - " - INSERT INTO users - (github_login, email_address, admin, inviter_id, invite_code, invite_count) - VALUES - ($1, $2, 'f', $3, $4, $5) - RETURNING id - ", - ) - .bind(login) - .bind(email_address) - .bind(inviter_id) - .bind(random_invite_code()) - .bind(5) - .fetch_one(&mut tx) - .await - .map(UserId)?; + if invite_count == 0 { + Err(Error::Http( + StatusCode::UNAUTHORIZED, + "no invites remaining".to_string(), + ))?; + } - sqlx::query( + let email_confirmation_code: String = sqlx::query_scalar( " - INSERT INTO contacts - (user_id_a, user_id_b, a_to_b, should_notify, accepted) - VALUES - ($1, $2, 't', 't', 't') + INSERT INTO signups + ( + email_address, + email_confirmation_code, + email_confirmation_sent, + inviting_user_id, + platform_linux, + platform_mac, + platform_windows, + platform_unknown, + device_id + ) + VALUES + ($1, $2, 'f', $3, 'f', 'f', 'f', 't', $4) + ON CONFLICT (email_address) + DO UPDATE SET + inviting_user_id = excluded.inviting_user_id + RETURNING email_confirmation_code ", ) - .bind(inviter_id) - .bind(invitee_id) - .execute(&mut tx) + .bind(&email_address) + .bind(&random_email_confirmation_code()) + .bind(&inviter_id) + .bind(&device_id) + .fetch_one(&mut tx) .await?; tx.commit().await?; - Ok(invitee_id) + + Ok(Invite { + email_address: email_address.into(), + email_confirmation_code, + }) } // projects @@ -842,10 +1099,7 @@ impl Db for PostgresDb { .bind(user_id) .fetch(&self.pool); - let mut contacts = vec![Contact::Accepted { - user_id, - should_notify: false, - }]; + let mut contacts = Vec::new(); while let Some(row) = rows.next().await { let (user_id_a, user_id_b, a_to_b, accepted, should_notify) = row?; @@ -1294,7 +1548,7 @@ impl Db for PostgresDb { } #[cfg(test)] - fn as_fake(&self) -> Option<&tests::FakeDb> { + fn as_fake(&self) -> Option<&FakeDb> { None } } @@ -1347,6 +1601,7 @@ id_type!(UserId); pub struct User { pub id: UserId, pub github_login: String, + pub github_user_id: Option, pub email_address: Option, pub admin: bool, pub invite_code: Option, @@ -1371,19 +1626,19 @@ pub struct UserActivitySummary { #[derive(Clone, Debug, PartialEq, Serialize)] pub struct ProjectActivitySummary { - id: ProjectId, - duration: Duration, - max_collaborators: usize, + pub id: ProjectId, + pub duration: Duration, + pub max_collaborators: usize, } #[derive(Clone, Debug, PartialEq, Serialize)] pub struct UserActivityPeriod { - project_id: ProjectId, + pub project_id: ProjectId, #[serde(with = "time::serde::iso8601")] - start: OffsetDateTime, + pub start: OffsetDateTime, #[serde(with = "time::serde::iso8601")] - end: OffsetDateTime, - extensions: HashMap, + pub end: OffsetDateTime, + pub extensions: HashMap, } id_type!(OrgId); @@ -1445,28 +1700,69 @@ pub struct IncomingContactRequest { pub should_notify: bool, } -fn fuzzy_like_string(string: &str) -> String { - let mut result = String::with_capacity(string.len() * 2 + 1); - for c in string.chars() { - if c.is_alphanumeric() { - result.push('%'); - result.push(c); - } - } - result.push('%'); - result +#[derive(Clone, Deserialize)] +pub struct Signup { + pub email_address: String, + pub platform_mac: bool, + pub platform_windows: bool, + pub platform_linux: bool, + pub editor_features: Vec, + pub programming_languages: Vec, + pub device_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, FromRow)] +pub struct WaitlistSummary { + #[sqlx(default)] + pub count: i64, + #[sqlx(default)] + pub linux_count: i64, + #[sqlx(default)] + pub mac_count: i64, + #[sqlx(default)] + pub windows_count: i64, + #[sqlx(default)] + pub unknown_count: i64, +} + +#[derive(FromRow, PartialEq, Debug, Serialize, Deserialize)] +pub struct Invite { + pub email_address: String, + pub email_confirmation_code: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NewUserParams { + pub github_login: String, + pub github_user_id: i32, + pub invite_count: i32, +} + +#[derive(Debug)] +pub struct NewUserResult { + pub user_id: UserId, + pub metrics_id: String, + pub inviting_user_id: Option, + pub signup_device_id: Option, } fn random_invite_code() -> String { nanoid::nanoid!(16) } +fn random_email_confirmation_code() -> String { + nanoid::nanoid!(64) +} + #[cfg(test)] -pub mod tests { +pub use test::*; + +#[cfg(test)] +mod test { use super::*; use anyhow::anyhow; use collections::BTreeMap; - use gpui::executor::{Background, Deterministic}; + use gpui::executor::Background; use lazy_static::lazy_static; use parking_lot::Mutex; use rand::prelude::*; @@ -1477,978 +1773,6 @@ pub mod tests { use std::{path::Path, sync::Arc}; use util::post_inc; - #[tokio::test(flavor = "multi_thread")] - async fn test_get_users_by_ids() { - for test_db in [ - TestDb::postgres().await, - TestDb::fake(build_background_executor()), - ] { - let db = test_db.db(); - - let user = db.create_user("user", None, false).await.unwrap(); - let friend1 = db.create_user("friend-1", None, false).await.unwrap(); - let friend2 = db.create_user("friend-2", None, false).await.unwrap(); - let friend3 = db.create_user("friend-3", None, false).await.unwrap(); - - assert_eq!( - db.get_users_by_ids(vec![user, friend1, friend2, friend3]) - .await - .unwrap(), - vec![ - User { - id: user, - github_login: "user".to_string(), - admin: false, - ..Default::default() - }, - User { - id: friend1, - github_login: "friend-1".to_string(), - admin: false, - ..Default::default() - }, - User { - id: friend2, - github_login: "friend-2".to_string(), - admin: false, - ..Default::default() - }, - User { - id: friend3, - github_login: "friend-3".to_string(), - admin: false, - ..Default::default() - } - ] - ); - } - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_create_users() { - let db = TestDb::postgres().await; - let db = db.db(); - - // Create the first batch of users, ensuring invite counts are assigned - // correctly and the respective invite codes are unique. - let user_ids_batch_1 = db - .create_users(vec![ - ("user1".to_string(), "hi@user1.com".to_string(), 5), - ("user2".to_string(), "hi@user2.com".to_string(), 4), - ("user3".to_string(), "hi@user3.com".to_string(), 3), - ]) - .await - .unwrap(); - assert_eq!(user_ids_batch_1.len(), 3); - - let users = db.get_users_by_ids(user_ids_batch_1.clone()).await.unwrap(); - assert_eq!(users.len(), 3); - assert_eq!(users[0].github_login, "user1"); - assert_eq!(users[0].email_address.as_deref(), Some("hi@user1.com")); - assert_eq!(users[0].invite_count, 5); - assert_eq!(users[1].github_login, "user2"); - assert_eq!(users[1].email_address.as_deref(), Some("hi@user2.com")); - assert_eq!(users[1].invite_count, 4); - assert_eq!(users[2].github_login, "user3"); - assert_eq!(users[2].email_address.as_deref(), Some("hi@user3.com")); - assert_eq!(users[2].invite_count, 3); - - let invite_code_1 = users[0].invite_code.clone().unwrap(); - let invite_code_2 = users[1].invite_code.clone().unwrap(); - let invite_code_3 = users[2].invite_code.clone().unwrap(); - assert_ne!(invite_code_1, invite_code_2); - assert_ne!(invite_code_1, invite_code_3); - assert_ne!(invite_code_2, invite_code_3); - - // Create the second batch of users and include a user that is already in the database, ensuring - // the invite count for the existing user is updated without changing their invite code. - let user_ids_batch_2 = db - .create_users(vec![ - ("user2".to_string(), "hi@user2.com".to_string(), 10), - ("user4".to_string(), "hi@user4.com".to_string(), 2), - ]) - .await - .unwrap(); - assert_eq!(user_ids_batch_2.len(), 2); - assert_eq!(user_ids_batch_2[0], user_ids_batch_1[1]); - - let users = db.get_users_by_ids(user_ids_batch_2).await.unwrap(); - assert_eq!(users.len(), 2); - assert_eq!(users[0].github_login, "user2"); - assert_eq!(users[0].email_address.as_deref(), Some("hi@user2.com")); - assert_eq!(users[0].invite_count, 10); - assert_eq!(users[0].invite_code, Some(invite_code_2.clone())); - assert_eq!(users[1].github_login, "user4"); - assert_eq!(users[1].email_address.as_deref(), Some("hi@user4.com")); - assert_eq!(users[1].invite_count, 2); - - let invite_code_4 = users[1].invite_code.clone().unwrap(); - assert_ne!(invite_code_4, invite_code_1); - assert_ne!(invite_code_4, invite_code_2); - assert_ne!(invite_code_4, invite_code_3); - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_worktree_extensions() { - let test_db = TestDb::postgres().await; - let db = test_db.db(); - - let user = db.create_user("user_1", None, false).await.unwrap(); - let project = db.register_project(user).await.unwrap(); - - db.update_worktree_extensions(project, 100, Default::default()) - .await - .unwrap(); - db.update_worktree_extensions( - project, - 100, - [("rs".to_string(), 5), ("md".to_string(), 3)] - .into_iter() - .collect(), - ) - .await - .unwrap(); - db.update_worktree_extensions( - project, - 100, - [("rs".to_string(), 6), ("md".to_string(), 5)] - .into_iter() - .collect(), - ) - .await - .unwrap(); - db.update_worktree_extensions( - project, - 101, - [("ts".to_string(), 2), ("md".to_string(), 1)] - .into_iter() - .collect(), - ) - .await - .unwrap(); - - assert_eq!( - db.get_project_extensions(project).await.unwrap(), - [ - ( - 100, - [("rs".into(), 6), ("md".into(), 5),] - .into_iter() - .collect::>() - ), - ( - 101, - [("ts".into(), 2), ("md".into(), 1),] - .into_iter() - .collect::>() - ) - ] - .into_iter() - .collect() - ); - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_user_activity() { - let test_db = TestDb::postgres().await; - let db = test_db.db(); - - let user_1 = db.create_user("user_1", None, false).await.unwrap(); - let user_2 = db.create_user("user_2", None, false).await.unwrap(); - let user_3 = db.create_user("user_3", None, false).await.unwrap(); - let project_1 = db.register_project(user_1).await.unwrap(); - db.update_worktree_extensions( - project_1, - 1, - HashMap::from_iter([("rs".into(), 5), ("md".into(), 7)]), - ) - .await - .unwrap(); - let project_2 = db.register_project(user_2).await.unwrap(); - let t0 = OffsetDateTime::now_utc() - Duration::from_secs(60 * 60); - - // User 2 opens a project - let t1 = t0 + Duration::from_secs(10); - db.record_user_activity(t0..t1, &[(user_2, project_2)]) - .await - .unwrap(); - - let t2 = t1 + Duration::from_secs(10); - db.record_user_activity(t1..t2, &[(user_2, project_2)]) - .await - .unwrap(); - - // User 1 joins the project - let t3 = t2 + Duration::from_secs(10); - db.record_user_activity(t2..t3, &[(user_2, project_2), (user_1, project_2)]) - .await - .unwrap(); - - // User 1 opens another project - let t4 = t3 + Duration::from_secs(10); - db.record_user_activity( - t3..t4, - &[ - (user_2, project_2), - (user_1, project_2), - (user_1, project_1), - ], - ) - .await - .unwrap(); - - // User 3 joins that project - let t5 = t4 + Duration::from_secs(10); - db.record_user_activity( - t4..t5, - &[ - (user_2, project_2), - (user_1, project_2), - (user_1, project_1), - (user_3, project_1), - ], - ) - .await - .unwrap(); - - // User 2 leaves - let t6 = t5 + Duration::from_secs(5); - db.record_user_activity(t5..t6, &[(user_1, project_1), (user_3, project_1)]) - .await - .unwrap(); - - let t7 = t6 + Duration::from_secs(60); - let t8 = t7 + Duration::from_secs(10); - db.record_user_activity(t7..t8, &[(user_1, project_1)]) - .await - .unwrap(); - - assert_eq!( - db.get_top_users_activity_summary(t0..t6, 10).await.unwrap(), - &[ - UserActivitySummary { - id: user_1, - github_login: "user_1".to_string(), - project_activity: vec![ - ProjectActivitySummary { - id: project_1, - duration: Duration::from_secs(25), - max_collaborators: 2 - }, - ProjectActivitySummary { - id: project_2, - duration: Duration::from_secs(30), - max_collaborators: 2 - } - ] - }, - UserActivitySummary { - id: user_2, - github_login: "user_2".to_string(), - project_activity: vec![ProjectActivitySummary { - id: project_2, - duration: Duration::from_secs(50), - max_collaborators: 2 - }] - }, - UserActivitySummary { - id: user_3, - github_login: "user_3".to_string(), - project_activity: vec![ProjectActivitySummary { - id: project_1, - duration: Duration::from_secs(15), - max_collaborators: 2 - }] - }, - ] - ); - - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(56), false) - .await - .unwrap(), - 0 - ); - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(56), true) - .await - .unwrap(), - 0 - ); - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(54), false) - .await - .unwrap(), - 1 - ); - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(54), true) - .await - .unwrap(), - 1 - ); - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(30), false) - .await - .unwrap(), - 2 - ); - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(30), true) - .await - .unwrap(), - 2 - ); - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(10), false) - .await - .unwrap(), - 3 - ); - assert_eq!( - db.get_active_user_count(t0..t6, Duration::from_secs(10), true) - .await - .unwrap(), - 3 - ); - assert_eq!( - db.get_active_user_count(t0..t1, Duration::from_secs(5), false) - .await - .unwrap(), - 1 - ); - assert_eq!( - db.get_active_user_count(t0..t1, Duration::from_secs(5), true) - .await - .unwrap(), - 0 - ); - - assert_eq!( - db.get_user_activity_timeline(t3..t6, user_1).await.unwrap(), - &[ - UserActivityPeriod { - project_id: project_1, - start: t3, - end: t6, - extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]), - }, - UserActivityPeriod { - project_id: project_2, - start: t3, - end: t5, - extensions: Default::default(), - }, - ] - ); - assert_eq!( - db.get_user_activity_timeline(t0..t8, user_1).await.unwrap(), - &[ - UserActivityPeriod { - project_id: project_2, - start: t2, - end: t5, - extensions: Default::default(), - }, - UserActivityPeriod { - project_id: project_1, - start: t3, - end: t6, - extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]), - }, - UserActivityPeriod { - project_id: project_1, - start: t7, - end: t8, - extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]), - }, - ] - ); - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_recent_channel_messages() { - for test_db in [ - TestDb::postgres().await, - TestDb::fake(build_background_executor()), - ] { - let db = test_db.db(); - let user = db.create_user("user", None, false).await.unwrap(); - let org = db.create_org("org", "org").await.unwrap(); - let channel = db.create_org_channel(org, "channel").await.unwrap(); - for i in 0..10 { - db.create_channel_message( - channel, - user, - &i.to_string(), - OffsetDateTime::now_utc(), - i, - ) - .await - .unwrap(); - } - - let messages = db.get_channel_messages(channel, 5, None).await.unwrap(); - assert_eq!( - messages.iter().map(|m| &m.body).collect::>(), - ["5", "6", "7", "8", "9"] - ); - - let prev_messages = db - .get_channel_messages(channel, 4, Some(messages[0].id)) - .await - .unwrap(); - assert_eq!( - prev_messages.iter().map(|m| &m.body).collect::>(), - ["1", "2", "3", "4"] - ); - } - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_channel_message_nonces() { - for test_db in [ - TestDb::postgres().await, - TestDb::fake(build_background_executor()), - ] { - let db = test_db.db(); - let user = db.create_user("user", None, false).await.unwrap(); - let org = db.create_org("org", "org").await.unwrap(); - let channel = db.create_org_channel(org, "channel").await.unwrap(); - - let msg1_id = db - .create_channel_message(channel, user, "1", OffsetDateTime::now_utc(), 1) - .await - .unwrap(); - let msg2_id = db - .create_channel_message(channel, user, "2", OffsetDateTime::now_utc(), 2) - .await - .unwrap(); - let msg3_id = db - .create_channel_message(channel, user, "3", OffsetDateTime::now_utc(), 1) - .await - .unwrap(); - let msg4_id = db - .create_channel_message(channel, user, "4", OffsetDateTime::now_utc(), 2) - .await - .unwrap(); - - assert_ne!(msg1_id, msg2_id); - assert_eq!(msg1_id, msg3_id); - assert_eq!(msg2_id, msg4_id); - } - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_create_access_tokens() { - let test_db = TestDb::postgres().await; - let db = test_db.db(); - let user = db.create_user("the-user", None, false).await.unwrap(); - - db.create_access_token_hash(user, "h1", 3).await.unwrap(); - db.create_access_token_hash(user, "h2", 3).await.unwrap(); - assert_eq!( - db.get_access_token_hashes(user).await.unwrap(), - &["h2".to_string(), "h1".to_string()] - ); - - db.create_access_token_hash(user, "h3", 3).await.unwrap(); - assert_eq!( - db.get_access_token_hashes(user).await.unwrap(), - &["h3".to_string(), "h2".to_string(), "h1".to_string(),] - ); - - db.create_access_token_hash(user, "h4", 3).await.unwrap(); - assert_eq!( - db.get_access_token_hashes(user).await.unwrap(), - &["h4".to_string(), "h3".to_string(), "h2".to_string(),] - ); - - db.create_access_token_hash(user, "h5", 3).await.unwrap(); - assert_eq!( - db.get_access_token_hashes(user).await.unwrap(), - &["h5".to_string(), "h4".to_string(), "h3".to_string()] - ); - } - - #[test] - fn test_fuzzy_like_string() { - assert_eq!(fuzzy_like_string("abcd"), "%a%b%c%d%"); - assert_eq!(fuzzy_like_string("x y"), "%x%y%"); - assert_eq!(fuzzy_like_string(" z "), "%z%"); - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_fuzzy_search_users() { - let test_db = TestDb::postgres().await; - let db = test_db.db(); - for github_login in [ - "California", - "colorado", - "oregon", - "washington", - "florida", - "delaware", - "rhode-island", - ] { - db.create_user(github_login, None, false).await.unwrap(); - } - - assert_eq!( - fuzzy_search_user_names(db, "clr").await, - &["colorado", "California"] - ); - assert_eq!( - fuzzy_search_user_names(db, "ro").await, - &["rhode-island", "colorado", "oregon"], - ); - - async fn fuzzy_search_user_names(db: &Arc, query: &str) -> Vec { - db.fuzzy_search_users(query, 10) - .await - .unwrap() - .into_iter() - .map(|user| user.github_login) - .collect::>() - } - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_add_contacts() { - for test_db in [ - TestDb::postgres().await, - TestDb::fake(build_background_executor()), - ] { - let db = test_db.db(); - - let user_1 = db.create_user("user1", None, false).await.unwrap(); - let user_2 = db.create_user("user2", None, false).await.unwrap(); - let user_3 = db.create_user("user3", None, false).await.unwrap(); - - // User starts with no contacts - assert_eq!( - db.get_contacts(user_1).await.unwrap(), - vec![Contact::Accepted { - user_id: user_1, - should_notify: false - }], - ); - - // User requests a contact. Both users see the pending request. - db.send_contact_request(user_1, user_2).await.unwrap(); - assert!(!db.has_contact(user_1, user_2).await.unwrap()); - assert!(!db.has_contact(user_2, user_1).await.unwrap()); - assert_eq!( - db.get_contacts(user_1).await.unwrap(), - &[ - Contact::Accepted { - user_id: user_1, - should_notify: false - }, - Contact::Outgoing { user_id: user_2 } - ], - ); - assert_eq!( - db.get_contacts(user_2).await.unwrap(), - &[ - Contact::Incoming { - user_id: user_1, - should_notify: true - }, - Contact::Accepted { - user_id: user_2, - should_notify: false - }, - ] - ); - - // User 2 dismisses the contact request notification without accepting or rejecting. - // We shouldn't notify them again. - db.dismiss_contact_notification(user_1, user_2) - .await - .unwrap_err(); - db.dismiss_contact_notification(user_2, user_1) - .await - .unwrap(); - assert_eq!( - db.get_contacts(user_2).await.unwrap(), - &[ - Contact::Incoming { - user_id: user_1, - should_notify: false - }, - Contact::Accepted { - user_id: user_2, - should_notify: false - }, - ] - ); - - // User can't accept their own contact request - db.respond_to_contact_request(user_1, user_2, true) - .await - .unwrap_err(); - - // User accepts a contact request. Both users see the contact. - db.respond_to_contact_request(user_2, user_1, true) - .await - .unwrap(); - assert_eq!( - db.get_contacts(user_1).await.unwrap(), - &[ - Contact::Accepted { - user_id: user_1, - should_notify: false - }, - Contact::Accepted { - user_id: user_2, - should_notify: true - } - ], - ); - assert!(db.has_contact(user_1, user_2).await.unwrap()); - assert!(db.has_contact(user_2, user_1).await.unwrap()); - assert_eq!( - db.get_contacts(user_2).await.unwrap(), - &[ - Contact::Accepted { - user_id: user_1, - should_notify: false, - }, - Contact::Accepted { - user_id: user_2, - should_notify: false, - }, - ] - ); - - // Users cannot re-request existing contacts. - db.send_contact_request(user_1, user_2).await.unwrap_err(); - db.send_contact_request(user_2, user_1).await.unwrap_err(); - - // Users can't dismiss notifications of them accepting other users' requests. - db.dismiss_contact_notification(user_2, user_1) - .await - .unwrap_err(); - assert_eq!( - db.get_contacts(user_1).await.unwrap(), - &[ - Contact::Accepted { - user_id: user_1, - should_notify: false - }, - Contact::Accepted { - user_id: user_2, - should_notify: true, - }, - ] - ); - - // Users can dismiss notifications of other users accepting their requests. - db.dismiss_contact_notification(user_1, user_2) - .await - .unwrap(); - assert_eq!( - db.get_contacts(user_1).await.unwrap(), - &[ - Contact::Accepted { - user_id: user_1, - should_notify: false - }, - Contact::Accepted { - user_id: user_2, - should_notify: false, - }, - ] - ); - - // Users send each other concurrent contact requests and - // see that they are immediately accepted. - db.send_contact_request(user_1, user_3).await.unwrap(); - db.send_contact_request(user_3, user_1).await.unwrap(); - assert_eq!( - db.get_contacts(user_1).await.unwrap(), - &[ - Contact::Accepted { - user_id: user_1, - should_notify: false - }, - Contact::Accepted { - user_id: user_2, - should_notify: false, - }, - Contact::Accepted { - user_id: user_3, - should_notify: false - }, - ] - ); - assert_eq!( - db.get_contacts(user_3).await.unwrap(), - &[ - Contact::Accepted { - user_id: user_1, - should_notify: false - }, - Contact::Accepted { - user_id: user_3, - should_notify: false - } - ], - ); - - // User declines a contact request. Both users see that it is gone. - db.send_contact_request(user_2, user_3).await.unwrap(); - db.respond_to_contact_request(user_3, user_2, false) - .await - .unwrap(); - assert!(!db.has_contact(user_2, user_3).await.unwrap()); - assert!(!db.has_contact(user_3, user_2).await.unwrap()); - assert_eq!( - db.get_contacts(user_2).await.unwrap(), - &[ - Contact::Accepted { - user_id: user_1, - should_notify: false - }, - Contact::Accepted { - user_id: user_2, - should_notify: false - } - ] - ); - assert_eq!( - db.get_contacts(user_3).await.unwrap(), - &[ - Contact::Accepted { - user_id: user_1, - should_notify: false - }, - Contact::Accepted { - user_id: user_3, - should_notify: false - } - ], - ); - } - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_invite_codes() { - let postgres = TestDb::postgres().await; - let db = postgres.db(); - let user1 = db.create_user("user-1", None, false).await.unwrap(); - - // Initially, user 1 has no invite code - assert_eq!(db.get_invite_code_for_user(user1).await.unwrap(), None); - - // Setting invite count to 0 when no code is assigned does not assign a new code - db.set_invite_count(user1, 0).await.unwrap(); - assert!(db.get_invite_code_for_user(user1).await.unwrap().is_none()); - - // User 1 creates an invite code that can be used twice. - db.set_invite_count(user1, 2).await.unwrap(); - let (invite_code, invite_count) = - db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(invite_count, 2); - - // User 2 redeems the invite code and becomes a contact of user 1. - let user2 = db - .redeem_invite_code(&invite_code, "user-2", None) - .await - .unwrap(); - let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(invite_count, 1); - assert_eq!( - db.get_contacts(user1).await.unwrap(), - [ - Contact::Accepted { - user_id: user1, - should_notify: false - }, - Contact::Accepted { - user_id: user2, - should_notify: true - } - ] - ); - assert_eq!( - db.get_contacts(user2).await.unwrap(), - [ - Contact::Accepted { - user_id: user1, - should_notify: false - }, - Contact::Accepted { - user_id: user2, - should_notify: false - } - ] - ); - - // User 3 redeems the invite code and becomes a contact of user 1. - let user3 = db - .redeem_invite_code(&invite_code, "user-3", None) - .await - .unwrap(); - let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(invite_count, 0); - assert_eq!( - db.get_contacts(user1).await.unwrap(), - [ - Contact::Accepted { - user_id: user1, - should_notify: false - }, - Contact::Accepted { - user_id: user2, - should_notify: true - }, - Contact::Accepted { - user_id: user3, - should_notify: true - } - ] - ); - assert_eq!( - db.get_contacts(user3).await.unwrap(), - [ - Contact::Accepted { - user_id: user1, - should_notify: false - }, - Contact::Accepted { - user_id: user3, - should_notify: false - }, - ] - ); - - // Trying to reedem the code for the third time results in an error. - db.redeem_invite_code(&invite_code, "user-4", None) - .await - .unwrap_err(); - - // Invite count can be updated after the code has been created. - db.set_invite_count(user1, 2).await.unwrap(); - let (latest_code, invite_count) = - db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(latest_code, invite_code); // Invite code doesn't change when we increment above 0 - assert_eq!(invite_count, 2); - - // User 4 can now redeem the invite code and becomes a contact of user 1. - let user4 = db - .redeem_invite_code(&invite_code, "user-4", None) - .await - .unwrap(); - let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(invite_count, 1); - assert_eq!( - db.get_contacts(user1).await.unwrap(), - [ - Contact::Accepted { - user_id: user1, - should_notify: false - }, - Contact::Accepted { - user_id: user2, - should_notify: true - }, - Contact::Accepted { - user_id: user3, - should_notify: true - }, - Contact::Accepted { - user_id: user4, - should_notify: true - } - ] - ); - assert_eq!( - db.get_contacts(user4).await.unwrap(), - [ - Contact::Accepted { - user_id: user1, - should_notify: false - }, - Contact::Accepted { - user_id: user4, - should_notify: false - }, - ] - ); - - // An existing user cannot redeem invite codes. - db.redeem_invite_code(&invite_code, "user-2", None) - .await - .unwrap_err(); - let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); - assert_eq!(invite_count, 1); - - // Ensure invited users get invite codes too. - assert_eq!( - db.get_invite_code_for_user(user2).await.unwrap().unwrap().1, - 5 - ); - assert_eq!( - db.get_invite_code_for_user(user3).await.unwrap().unwrap().1, - 5 - ); - assert_eq!( - db.get_invite_code_for_user(user4).await.unwrap().unwrap().1, - 5 - ); - } - - pub struct TestDb { - pub db: Option>, - pub url: String, - } - - impl TestDb { - #[allow(clippy::await_holding_lock)] - pub async fn postgres() -> Self { - lazy_static! { - static ref LOCK: Mutex<()> = Mutex::new(()); - } - - let _guard = LOCK.lock(); - let mut rng = StdRng::from_entropy(); - let name = format!("zed-test-{}", rng.gen::()); - let url = format!("postgres://postgres@localhost/{}", name); - let migrations_path = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations")); - Postgres::create_database(&url) - .await - .expect("failed to create test db"); - let db = PostgresDb::new(&url, 5).await.unwrap(); - let migrator = Migrator::new(migrations_path).await.unwrap(); - migrator.run(&db.pool).await.unwrap(); - Self { - db: Some(Arc::new(db)), - url, - } - } - - pub fn fake(background: Arc) -> Self { - Self { - db: Some(Arc::new(FakeDb::new(background))), - url: Default::default(), - } - } - - pub fn db(&self) -> &Arc { - self.db.as_ref().unwrap() - } - } - - impl Drop for TestDb { - fn drop(&mut self) { - if let Some(db) = self.db.take() { - futures::executor::block_on(db.teardown(&self.url)); - } - } - } - pub struct FakeDb { background: Arc, pub users: Mutex>, @@ -2501,44 +1825,48 @@ pub mod tests { impl Db for FakeDb { async fn create_user( &self, - github_login: &str, - email_address: Option<&str>, + email_address: &str, admin: bool, - ) -> Result { + params: NewUserParams, + ) -> Result { self.background.simulate_random_delay().await; let mut users = self.users.lock(); - if let Some(user) = users + let user_id = if let Some(user) = users .values() - .find(|user| user.github_login == github_login) + .find(|user| user.github_login == params.github_login) { - Ok(user.id) + user.id } else { - let user_id = UserId(post_inc(&mut *self.next_user_id.lock())); + let id = post_inc(&mut *self.next_user_id.lock()); + let user_id = UserId(id); users.insert( user_id, User { id: user_id, - github_login: github_login.to_string(), - email_address: email_address.map(str::to_string), + github_login: params.github_login, + github_user_id: Some(params.github_user_id), + email_address: Some(email_address.to_string()), admin, invite_code: None, invite_count: 0, connected_once: false, }, ); - Ok(user_id) - } + user_id + }; + Ok(NewUserResult { + user_id, + metrics_id: "the-metrics-id".to_string(), + inviting_user_id: None, + signup_device_id: None, + }) } async fn get_all_users(&self, _page: u32, _limit: u32) -> Result> { unimplemented!() } - async fn create_users(&self, _users: Vec<(String, String, usize)>) -> Result> { - unimplemented!() - } - async fn fuzzy_search_users(&self, _: &str, _: u32) -> Result> { unimplemented!() } @@ -2548,6 +1876,10 @@ pub mod tests { Ok(self.get_users_by_ids(vec![id]).await?.into_iter().next()) } + async fn get_user_metrics_id(&self, _id: UserId) -> Result { + Ok("the-metrics-id".to_string()) + } + async fn get_users_by_ids(&self, ids: Vec) -> Result> { self.background.simulate_random_delay().await; let users = self.users.lock(); @@ -2558,14 +1890,32 @@ pub mod tests { unimplemented!() } - async fn get_user_by_github_login(&self, github_login: &str) -> Result> { + async fn get_user_by_github_account( + &self, + github_login: &str, + github_user_id: Option, + ) -> Result> { self.background.simulate_random_delay().await; - Ok(self - .users - .lock() - .values() - .find(|user| user.github_login == github_login) - .cloned()) + if let Some(github_user_id) = github_user_id { + for user in self.users.lock().values_mut() { + if user.github_user_id == Some(github_user_id) { + user.github_login = github_login.into(); + return Ok(Some(user.clone())); + } + if user.github_login == github_login { + user.github_user_id = Some(github_user_id); + return Ok(Some(user.clone())); + } + } + Ok(None) + } else { + Ok(self + .users + .lock() + .values() + .find(|user| user.github_login == github_login) + .cloned()) + } } async fn set_user_is_admin(&self, _id: UserId, _is_admin: bool) -> Result<()> { @@ -2586,9 +1936,35 @@ pub mod tests { unimplemented!() } + // signups + + async fn create_signup(&self, _signup: Signup) -> Result<()> { + unimplemented!() + } + + async fn get_waitlist_summary(&self) -> Result { + unimplemented!() + } + + async fn get_unsent_invites(&self, _count: usize) -> Result> { + unimplemented!() + } + + async fn record_sent_invites(&self, _invites: &[Invite]) -> Result<()> { + unimplemented!() + } + + async fn create_user_from_invite( + &self, + _invite: &Invite, + _user: NewUserParams, + ) -> Result { + unimplemented!() + } + // invite codes - async fn set_invite_count(&self, _id: UserId, _count: u32) -> Result<()> { + async fn set_invite_count_for_user(&self, _id: UserId, _count: u32) -> Result<()> { unimplemented!() } @@ -2601,12 +1977,12 @@ pub mod tests { unimplemented!() } - async fn redeem_invite_code( + async fn create_invite_from_code( &self, _code: &str, - _login: &str, - _email_address: Option<&str>, - ) -> Result { + _email_address: &str, + _device_id: Option<&str>, + ) -> Result { unimplemented!() } @@ -2704,10 +2080,7 @@ pub mod tests { async fn get_contacts(&self, id: UserId) -> Result> { self.background.simulate_random_delay().await; - let mut contacts = vec![Contact::Accepted { - user_id: id, - should_notify: false, - }]; + let mut contacts = Vec::new(); for contact in self.contacts.lock().iter() { if contact.requester_id == id { @@ -3044,7 +2417,52 @@ pub mod tests { } } - fn build_background_executor() -> Arc { - Deterministic::new(0).build_background() + pub struct TestDb { + pub db: Option>, + pub url: String, + } + + impl TestDb { + #[allow(clippy::await_holding_lock)] + pub async fn postgres() -> Self { + lazy_static! { + static ref LOCK: Mutex<()> = Mutex::new(()); + } + + let _guard = LOCK.lock(); + let mut rng = StdRng::from_entropy(); + let name = format!("zed-test-{}", rng.gen::()); + let url = format!("postgres://postgres@localhost/{}", name); + let migrations_path = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations")); + Postgres::create_database(&url) + .await + .expect("failed to create test db"); + let db = PostgresDb::new(&url, 5).await.unwrap(); + let migrator = Migrator::new(migrations_path).await.unwrap(); + migrator.run(&db.pool).await.unwrap(); + Self { + db: Some(Arc::new(db)), + url, + } + } + + pub fn fake(background: Arc) -> Self { + Self { + db: Some(Arc::new(FakeDb::new(background))), + url: Default::default(), + } + } + + pub fn db(&self) -> &Arc { + self.db.as_ref().unwrap() + } + } + + impl Drop for TestDb { + fn drop(&mut self) { + if let Some(db) = self.db.take() { + futures::executor::block_on(db.teardown(&self.url)); + } + } } } diff --git a/crates/collab/src/db_tests.rs b/crates/collab/src/db_tests.rs new file mode 100644 index 0000000000..d5ef045e66 --- /dev/null +++ b/crates/collab/src/db_tests.rs @@ -0,0 +1,1188 @@ +use super::db::*; +use collections::HashMap; +use gpui::executor::{Background, Deterministic}; +use std::{sync::Arc, time::Duration}; +use time::OffsetDateTime; + +#[tokio::test(flavor = "multi_thread")] +async fn test_get_users_by_ids() { + for test_db in [ + TestDb::postgres().await, + TestDb::fake(build_background_executor()), + ] { + let db = test_db.db(); + + let mut user_ids = Vec::new(); + for i in 1..=4 { + user_ids.push( + db.create_user( + &format!("user{i}@example.com"), + false, + NewUserParams { + github_login: format!("user{i}"), + github_user_id: i, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id, + ); + } + + assert_eq!( + db.get_users_by_ids(user_ids.clone()).await.unwrap(), + vec![ + User { + id: user_ids[0], + github_login: "user1".to_string(), + github_user_id: Some(1), + email_address: Some("user1@example.com".to_string()), + admin: false, + ..Default::default() + }, + User { + id: user_ids[1], + github_login: "user2".to_string(), + github_user_id: Some(2), + email_address: Some("user2@example.com".to_string()), + admin: false, + ..Default::default() + }, + User { + id: user_ids[2], + github_login: "user3".to_string(), + github_user_id: Some(3), + email_address: Some("user3@example.com".to_string()), + admin: false, + ..Default::default() + }, + User { + id: user_ids[3], + github_login: "user4".to_string(), + github_user_id: Some(4), + email_address: Some("user4@example.com".to_string()), + admin: false, + ..Default::default() + } + ] + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_get_user_by_github_account() { + for test_db in [ + TestDb::postgres().await, + TestDb::fake(build_background_executor()), + ] { + let db = test_db.db(); + let user_id1 = db + .create_user( + "user1@example.com", + false, + NewUserParams { + github_login: "login1".into(), + github_user_id: 101, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + let user_id2 = db + .create_user( + "user2@example.com", + false, + NewUserParams { + github_login: "login2".into(), + github_user_id: 102, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + + let user = db + .get_user_by_github_account("login1", None) + .await + .unwrap() + .unwrap(); + assert_eq!(user.id, user_id1); + assert_eq!(&user.github_login, "login1"); + assert_eq!(user.github_user_id, Some(101)); + + assert!(db + .get_user_by_github_account("non-existent-login", None) + .await + .unwrap() + .is_none()); + + let user = db + .get_user_by_github_account("the-new-login2", Some(102)) + .await + .unwrap() + .unwrap(); + assert_eq!(user.id, user_id2); + assert_eq!(&user.github_login, "the-new-login2"); + assert_eq!(user.github_user_id, Some(102)); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_worktree_extensions() { + let test_db = TestDb::postgres().await; + let db = test_db.db(); + + let user = db + .create_user( + "u1@example.com", + false, + NewUserParams { + github_login: "u1".into(), + github_user_id: 0, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + let project = db.register_project(user).await.unwrap(); + + db.update_worktree_extensions(project, 100, Default::default()) + .await + .unwrap(); + db.update_worktree_extensions( + project, + 100, + [("rs".to_string(), 5), ("md".to_string(), 3)] + .into_iter() + .collect(), + ) + .await + .unwrap(); + db.update_worktree_extensions( + project, + 100, + [("rs".to_string(), 6), ("md".to_string(), 5)] + .into_iter() + .collect(), + ) + .await + .unwrap(); + db.update_worktree_extensions( + project, + 101, + [("ts".to_string(), 2), ("md".to_string(), 1)] + .into_iter() + .collect(), + ) + .await + .unwrap(); + + assert_eq!( + db.get_project_extensions(project).await.unwrap(), + [ + ( + 100, + [("rs".into(), 6), ("md".into(), 5),] + .into_iter() + .collect::>() + ), + ( + 101, + [("ts".into(), 2), ("md".into(), 1),] + .into_iter() + .collect::>() + ) + ] + .into_iter() + .collect() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_user_activity() { + let test_db = TestDb::postgres().await; + let db = test_db.db(); + + let mut user_ids = Vec::new(); + for i in 0..=2 { + user_ids.push( + db.create_user( + &format!("user{i}@example.com"), + false, + NewUserParams { + github_login: format!("user{i}"), + github_user_id: i, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id, + ); + } + + let project_1 = db.register_project(user_ids[0]).await.unwrap(); + db.update_worktree_extensions( + project_1, + 1, + HashMap::from_iter([("rs".into(), 5), ("md".into(), 7)]), + ) + .await + .unwrap(); + let project_2 = db.register_project(user_ids[1]).await.unwrap(); + let t0 = OffsetDateTime::now_utc() - Duration::from_secs(60 * 60); + + // User 2 opens a project + let t1 = t0 + Duration::from_secs(10); + db.record_user_activity(t0..t1, &[(user_ids[1], project_2)]) + .await + .unwrap(); + + let t2 = t1 + Duration::from_secs(10); + db.record_user_activity(t1..t2, &[(user_ids[1], project_2)]) + .await + .unwrap(); + + // User 1 joins the project + let t3 = t2 + Duration::from_secs(10); + db.record_user_activity( + t2..t3, + &[(user_ids[1], project_2), (user_ids[0], project_2)], + ) + .await + .unwrap(); + + // User 1 opens another project + let t4 = t3 + Duration::from_secs(10); + db.record_user_activity( + t3..t4, + &[ + (user_ids[1], project_2), + (user_ids[0], project_2), + (user_ids[0], project_1), + ], + ) + .await + .unwrap(); + + // User 3 joins that project + let t5 = t4 + Duration::from_secs(10); + db.record_user_activity( + t4..t5, + &[ + (user_ids[1], project_2), + (user_ids[0], project_2), + (user_ids[0], project_1), + (user_ids[2], project_1), + ], + ) + .await + .unwrap(); + + // User 2 leaves + let t6 = t5 + Duration::from_secs(5); + db.record_user_activity( + t5..t6, + &[(user_ids[0], project_1), (user_ids[2], project_1)], + ) + .await + .unwrap(); + + let t7 = t6 + Duration::from_secs(60); + let t8 = t7 + Duration::from_secs(10); + db.record_user_activity(t7..t8, &[(user_ids[0], project_1)]) + .await + .unwrap(); + + assert_eq!( + db.get_top_users_activity_summary(t0..t6, 10).await.unwrap(), + &[ + UserActivitySummary { + id: user_ids[0], + github_login: "user0".to_string(), + project_activity: vec![ + ProjectActivitySummary { + id: project_1, + duration: Duration::from_secs(25), + max_collaborators: 2 + }, + ProjectActivitySummary { + id: project_2, + duration: Duration::from_secs(30), + max_collaborators: 2 + } + ] + }, + UserActivitySummary { + id: user_ids[1], + github_login: "user1".to_string(), + project_activity: vec![ProjectActivitySummary { + id: project_2, + duration: Duration::from_secs(50), + max_collaborators: 2 + }] + }, + UserActivitySummary { + id: user_ids[2], + github_login: "user2".to_string(), + project_activity: vec![ProjectActivitySummary { + id: project_1, + duration: Duration::from_secs(15), + max_collaborators: 2 + }] + }, + ] + ); + + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(56), false) + .await + .unwrap(), + 0 + ); + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(56), true) + .await + .unwrap(), + 0 + ); + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(54), false) + .await + .unwrap(), + 1 + ); + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(54), true) + .await + .unwrap(), + 1 + ); + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(30), false) + .await + .unwrap(), + 2 + ); + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(30), true) + .await + .unwrap(), + 2 + ); + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(10), false) + .await + .unwrap(), + 3 + ); + assert_eq!( + db.get_active_user_count(t0..t6, Duration::from_secs(10), true) + .await + .unwrap(), + 3 + ); + assert_eq!( + db.get_active_user_count(t0..t1, Duration::from_secs(5), false) + .await + .unwrap(), + 1 + ); + assert_eq!( + db.get_active_user_count(t0..t1, Duration::from_secs(5), true) + .await + .unwrap(), + 0 + ); + + assert_eq!( + db.get_user_activity_timeline(t3..t6, user_ids[0]) + .await + .unwrap(), + &[ + UserActivityPeriod { + project_id: project_1, + start: t3, + end: t6, + extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]), + }, + UserActivityPeriod { + project_id: project_2, + start: t3, + end: t5, + extensions: Default::default(), + }, + ] + ); + assert_eq!( + db.get_user_activity_timeline(t0..t8, user_ids[0]) + .await + .unwrap(), + &[ + UserActivityPeriod { + project_id: project_2, + start: t2, + end: t5, + extensions: Default::default(), + }, + UserActivityPeriod { + project_id: project_1, + start: t3, + end: t6, + extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]), + }, + UserActivityPeriod { + project_id: project_1, + start: t7, + end: t8, + extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]), + }, + ] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_recent_channel_messages() { + for test_db in [ + TestDb::postgres().await, + TestDb::fake(build_background_executor()), + ] { + let db = test_db.db(); + let user = db + .create_user( + "u@example.com", + false, + NewUserParams { + github_login: "u".into(), + github_user_id: 1, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + let org = db.create_org("org", "org").await.unwrap(); + let channel = db.create_org_channel(org, "channel").await.unwrap(); + for i in 0..10 { + db.create_channel_message(channel, user, &i.to_string(), OffsetDateTime::now_utc(), i) + .await + .unwrap(); + } + + let messages = db.get_channel_messages(channel, 5, None).await.unwrap(); + assert_eq!( + messages.iter().map(|m| &m.body).collect::>(), + ["5", "6", "7", "8", "9"] + ); + + let prev_messages = db + .get_channel_messages(channel, 4, Some(messages[0].id)) + .await + .unwrap(); + assert_eq!( + prev_messages.iter().map(|m| &m.body).collect::>(), + ["1", "2", "3", "4"] + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_channel_message_nonces() { + for test_db in [ + TestDb::postgres().await, + TestDb::fake(build_background_executor()), + ] { + let db = test_db.db(); + let user = db + .create_user( + "user@example.com", + false, + NewUserParams { + github_login: "user".into(), + github_user_id: 1, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + let org = db.create_org("org", "org").await.unwrap(); + let channel = db.create_org_channel(org, "channel").await.unwrap(); + + let msg1_id = db + .create_channel_message(channel, user, "1", OffsetDateTime::now_utc(), 1) + .await + .unwrap(); + let msg2_id = db + .create_channel_message(channel, user, "2", OffsetDateTime::now_utc(), 2) + .await + .unwrap(); + let msg3_id = db + .create_channel_message(channel, user, "3", OffsetDateTime::now_utc(), 1) + .await + .unwrap(); + let msg4_id = db + .create_channel_message(channel, user, "4", OffsetDateTime::now_utc(), 2) + .await + .unwrap(); + + assert_ne!(msg1_id, msg2_id); + assert_eq!(msg1_id, msg3_id); + assert_eq!(msg2_id, msg4_id); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_create_access_tokens() { + let test_db = TestDb::postgres().await; + let db = test_db.db(); + let user = db + .create_user( + "u1@example.com", + false, + NewUserParams { + github_login: "u1".into(), + github_user_id: 1, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + + db.create_access_token_hash(user, "h1", 3).await.unwrap(); + db.create_access_token_hash(user, "h2", 3).await.unwrap(); + assert_eq!( + db.get_access_token_hashes(user).await.unwrap(), + &["h2".to_string(), "h1".to_string()] + ); + + db.create_access_token_hash(user, "h3", 3).await.unwrap(); + assert_eq!( + db.get_access_token_hashes(user).await.unwrap(), + &["h3".to_string(), "h2".to_string(), "h1".to_string(),] + ); + + db.create_access_token_hash(user, "h4", 3).await.unwrap(); + assert_eq!( + db.get_access_token_hashes(user).await.unwrap(), + &["h4".to_string(), "h3".to_string(), "h2".to_string(),] + ); + + db.create_access_token_hash(user, "h5", 3).await.unwrap(); + assert_eq!( + db.get_access_token_hashes(user).await.unwrap(), + &["h5".to_string(), "h4".to_string(), "h3".to_string()] + ); +} + +#[test] +fn test_fuzzy_like_string() { + assert_eq!(PostgresDb::fuzzy_like_string("abcd"), "%a%b%c%d%"); + assert_eq!(PostgresDb::fuzzy_like_string("x y"), "%x%y%"); + assert_eq!(PostgresDb::fuzzy_like_string(" z "), "%z%"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fuzzy_search_users() { + let test_db = TestDb::postgres().await; + let db = test_db.db(); + for (i, github_login) in [ + "California", + "colorado", + "oregon", + "washington", + "florida", + "delaware", + "rhode-island", + ] + .into_iter() + .enumerate() + { + db.create_user( + &format!("{github_login}@example.com"), + false, + NewUserParams { + github_login: github_login.into(), + github_user_id: i as i32, + invite_count: 0, + }, + ) + .await + .unwrap(); + } + + assert_eq!( + fuzzy_search_user_names(db, "clr").await, + &["colorado", "California"] + ); + assert_eq!( + fuzzy_search_user_names(db, "ro").await, + &["rhode-island", "colorado", "oregon"], + ); + + async fn fuzzy_search_user_names(db: &Arc, query: &str) -> Vec { + db.fuzzy_search_users(query, 10) + .await + .unwrap() + .into_iter() + .map(|user| user.github_login) + .collect::>() + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_add_contacts() { + for test_db in [ + TestDb::postgres().await, + TestDb::fake(build_background_executor()), + ] { + let db = test_db.db(); + + let mut user_ids = Vec::new(); + for i in 0..3 { + user_ids.push( + db.create_user( + &format!("user{i}@example.com"), + false, + NewUserParams { + github_login: format!("user{i}"), + github_user_id: i, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id, + ); + } + + let user_1 = user_ids[0]; + let user_2 = user_ids[1]; + let user_3 = user_ids[2]; + + // User starts with no contacts + assert_eq!(db.get_contacts(user_1).await.unwrap(), &[]); + + // User requests a contact. Both users see the pending request. + db.send_contact_request(user_1, user_2).await.unwrap(); + assert!(!db.has_contact(user_1, user_2).await.unwrap()); + assert!(!db.has_contact(user_2, user_1).await.unwrap()); + assert_eq!( + db.get_contacts(user_1).await.unwrap(), + &[Contact::Outgoing { user_id: user_2 }], + ); + assert_eq!( + db.get_contacts(user_2).await.unwrap(), + &[Contact::Incoming { + user_id: user_1, + should_notify: true + }] + ); + + // User 2 dismisses the contact request notification without accepting or rejecting. + // We shouldn't notify them again. + db.dismiss_contact_notification(user_1, user_2) + .await + .unwrap_err(); + db.dismiss_contact_notification(user_2, user_1) + .await + .unwrap(); + assert_eq!( + db.get_contacts(user_2).await.unwrap(), + &[Contact::Incoming { + user_id: user_1, + should_notify: false + }] + ); + + // User can't accept their own contact request + db.respond_to_contact_request(user_1, user_2, true) + .await + .unwrap_err(); + + // User accepts a contact request. Both users see the contact. + db.respond_to_contact_request(user_2, user_1, true) + .await + .unwrap(); + assert_eq!( + db.get_contacts(user_1).await.unwrap(), + &[Contact::Accepted { + user_id: user_2, + should_notify: true + }], + ); + assert!(db.has_contact(user_1, user_2).await.unwrap()); + assert!(db.has_contact(user_2, user_1).await.unwrap()); + assert_eq!( + db.get_contacts(user_2).await.unwrap(), + &[Contact::Accepted { + user_id: user_1, + should_notify: false, + }] + ); + + // Users cannot re-request existing contacts. + db.send_contact_request(user_1, user_2).await.unwrap_err(); + db.send_contact_request(user_2, user_1).await.unwrap_err(); + + // Users can't dismiss notifications of them accepting other users' requests. + db.dismiss_contact_notification(user_2, user_1) + .await + .unwrap_err(); + assert_eq!( + db.get_contacts(user_1).await.unwrap(), + &[Contact::Accepted { + user_id: user_2, + should_notify: true, + }] + ); + + // Users can dismiss notifications of other users accepting their requests. + db.dismiss_contact_notification(user_1, user_2) + .await + .unwrap(); + assert_eq!( + db.get_contacts(user_1).await.unwrap(), + &[Contact::Accepted { + user_id: user_2, + should_notify: false, + }] + ); + + // Users send each other concurrent contact requests and + // see that they are immediately accepted. + db.send_contact_request(user_1, user_3).await.unwrap(); + db.send_contact_request(user_3, user_1).await.unwrap(); + assert_eq!( + db.get_contacts(user_1).await.unwrap(), + &[ + Contact::Accepted { + user_id: user_2, + should_notify: false, + }, + Contact::Accepted { + user_id: user_3, + should_notify: false + } + ] + ); + assert_eq!( + db.get_contacts(user_3).await.unwrap(), + &[Contact::Accepted { + user_id: user_1, + should_notify: false + }], + ); + + // User declines a contact request. Both users see that it is gone. + db.send_contact_request(user_2, user_3).await.unwrap(); + db.respond_to_contact_request(user_3, user_2, false) + .await + .unwrap(); + assert!(!db.has_contact(user_2, user_3).await.unwrap()); + assert!(!db.has_contact(user_3, user_2).await.unwrap()); + assert_eq!( + db.get_contacts(user_2).await.unwrap(), + &[Contact::Accepted { + user_id: user_1, + should_notify: false + }] + ); + assert_eq!( + db.get_contacts(user_3).await.unwrap(), + &[Contact::Accepted { + user_id: user_1, + should_notify: false + }], + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invite_codes() { + let postgres = TestDb::postgres().await; + let db = postgres.db(); + let NewUserResult { user_id: user1, .. } = db + .create_user( + "user1@example.com", + false, + NewUserParams { + github_login: "user1".into(), + github_user_id: 0, + invite_count: 0, + }, + ) + .await + .unwrap(); + + // Initially, user 1 has no invite code + assert_eq!(db.get_invite_code_for_user(user1).await.unwrap(), None); + + // Setting invite count to 0 when no code is assigned does not assign a new code + db.set_invite_count_for_user(user1, 0).await.unwrap(); + assert!(db.get_invite_code_for_user(user1).await.unwrap().is_none()); + + // User 1 creates an invite code that can be used twice. + db.set_invite_count_for_user(user1, 2).await.unwrap(); + let (invite_code, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(invite_count, 2); + + // User 2 redeems the invite code and becomes a contact of user 1. + let user2_invite = db + .create_invite_from_code(&invite_code, "user2@example.com", Some("user-2-device-id")) + .await + .unwrap(); + let NewUserResult { + user_id: user2, + inviting_user_id, + signup_device_id, + metrics_id, + } = db + .create_user_from_invite( + &user2_invite, + NewUserParams { + github_login: "user2".into(), + github_user_id: 2, + invite_count: 7, + }, + ) + .await + .unwrap(); + let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(invite_count, 1); + assert_eq!(inviting_user_id, Some(user1)); + assert_eq!(signup_device_id.unwrap(), "user-2-device-id"); + assert_eq!(db.get_user_metrics_id(user2).await.unwrap(), metrics_id); + assert_eq!( + db.get_contacts(user1).await.unwrap(), + [Contact::Accepted { + user_id: user2, + should_notify: true + }] + ); + assert_eq!( + db.get_contacts(user2).await.unwrap(), + [Contact::Accepted { + user_id: user1, + should_notify: false + }] + ); + assert_eq!( + db.get_invite_code_for_user(user2).await.unwrap().unwrap().1, + 7 + ); + + // User 3 redeems the invite code and becomes a contact of user 1. + let user3_invite = db + .create_invite_from_code(&invite_code, "user3@example.com", None) + .await + .unwrap(); + let NewUserResult { + user_id: user3, + inviting_user_id, + signup_device_id, + .. + } = db + .create_user_from_invite( + &user3_invite, + NewUserParams { + github_login: "user-3".into(), + github_user_id: 3, + invite_count: 3, + }, + ) + .await + .unwrap(); + let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(invite_count, 0); + assert_eq!(inviting_user_id, Some(user1)); + assert!(signup_device_id.is_none()); + assert_eq!( + db.get_contacts(user1).await.unwrap(), + [ + Contact::Accepted { + user_id: user2, + should_notify: true + }, + Contact::Accepted { + user_id: user3, + should_notify: true + } + ] + ); + assert_eq!( + db.get_contacts(user3).await.unwrap(), + [Contact::Accepted { + user_id: user1, + should_notify: false + }] + ); + assert_eq!( + db.get_invite_code_for_user(user3).await.unwrap().unwrap().1, + 3 + ); + + // Trying to reedem the code for the third time results in an error. + db.create_invite_from_code(&invite_code, "user4@example.com", Some("user-4-device-id")) + .await + .unwrap_err(); + + // Invite count can be updated after the code has been created. + db.set_invite_count_for_user(user1, 2).await.unwrap(); + let (latest_code, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(latest_code, invite_code); // Invite code doesn't change when we increment above 0 + assert_eq!(invite_count, 2); + + // User 4 can now redeem the invite code and becomes a contact of user 1. + let user4_invite = db + .create_invite_from_code(&invite_code, "user4@example.com", Some("user-4-device-id")) + .await + .unwrap(); + let user4 = db + .create_user_from_invite( + &user4_invite, + NewUserParams { + github_login: "user-4".into(), + github_user_id: 4, + invite_count: 5, + }, + ) + .await + .unwrap() + .user_id; + + let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(invite_count, 1); + assert_eq!( + db.get_contacts(user1).await.unwrap(), + [ + Contact::Accepted { + user_id: user2, + should_notify: true + }, + Contact::Accepted { + user_id: user3, + should_notify: true + }, + Contact::Accepted { + user_id: user4, + should_notify: true + } + ] + ); + assert_eq!( + db.get_contacts(user4).await.unwrap(), + [Contact::Accepted { + user_id: user1, + should_notify: false + }] + ); + assert_eq!( + db.get_invite_code_for_user(user4).await.unwrap().unwrap().1, + 5 + ); + + // An existing user cannot redeem invite codes. + db.create_invite_from_code(&invite_code, "user2@example.com", Some("user-2-device-id")) + .await + .unwrap_err(); + let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(invite_count, 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_signups() { + let postgres = TestDb::postgres().await; + let db = postgres.db(); + + // people sign up on the waitlist + for i in 0..8 { + db.create_signup(Signup { + email_address: format!("person-{i}@example.com"), + platform_mac: true, + platform_linux: i % 2 == 0, + platform_windows: i % 4 == 0, + editor_features: vec!["speed".into()], + programming_languages: vec!["rust".into(), "c".into()], + device_id: Some(format!("device_id_{i}")), + }) + .await + .unwrap(); + } + + assert_eq!( + db.get_waitlist_summary().await.unwrap(), + WaitlistSummary { + count: 8, + mac_count: 8, + linux_count: 4, + windows_count: 2, + unknown_count: 0, + } + ); + + // retrieve the next batch of signup emails to send + let signups_batch1 = db.get_unsent_invites(3).await.unwrap(); + let addresses = signups_batch1 + .iter() + .map(|s| &s.email_address) + .collect::>(); + assert_eq!( + addresses, + &[ + "person-0@example.com", + "person-1@example.com", + "person-2@example.com" + ] + ); + assert_ne!( + signups_batch1[0].email_confirmation_code, + signups_batch1[1].email_confirmation_code + ); + + // the waitlist isn't updated until we record that the emails + // were successfully sent. + let signups_batch = db.get_unsent_invites(3).await.unwrap(); + assert_eq!(signups_batch, signups_batch1); + + // once the emails go out, we can retrieve the next batch + // of signups. + db.record_sent_invites(&signups_batch1).await.unwrap(); + let signups_batch2 = db.get_unsent_invites(3).await.unwrap(); + let addresses = signups_batch2 + .iter() + .map(|s| &s.email_address) + .collect::>(); + assert_eq!( + addresses, + &[ + "person-3@example.com", + "person-4@example.com", + "person-5@example.com" + ] + ); + + // the sent invites are excluded from the summary. + assert_eq!( + db.get_waitlist_summary().await.unwrap(), + WaitlistSummary { + count: 5, + mac_count: 5, + linux_count: 2, + windows_count: 1, + unknown_count: 0, + } + ); + + // user completes the signup process by providing their + // github account. + let NewUserResult { + user_id, + inviting_user_id, + signup_device_id, + .. + } = db + .create_user_from_invite( + &Invite { + email_address: signups_batch1[0].email_address.clone(), + email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(), + }, + NewUserParams { + github_login: "person-0".into(), + github_user_id: 0, + invite_count: 5, + }, + ) + .await + .unwrap(); + let user = db.get_user_by_id(user_id).await.unwrap().unwrap(); + assert!(inviting_user_id.is_none()); + assert_eq!(user.github_login, "person-0"); + assert_eq!(user.email_address.as_deref(), Some("person-0@example.com")); + assert_eq!(user.invite_count, 5); + assert_eq!(signup_device_id.unwrap(), "device_id_0"); + + // cannot redeem the same signup again. + db.create_user_from_invite( + &Invite { + email_address: signups_batch1[0].email_address.clone(), + email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(), + }, + NewUserParams { + github_login: "some-other-github_account".into(), + github_user_id: 1, + invite_count: 5, + }, + ) + .await + .unwrap_err(); + + // cannot redeem a signup with the wrong confirmation code. + db.create_user_from_invite( + &Invite { + email_address: signups_batch1[1].email_address.clone(), + email_confirmation_code: "the-wrong-code".to_string(), + }, + NewUserParams { + github_login: "person-1".into(), + github_user_id: 2, + invite_count: 5, + }, + ) + .await + .unwrap_err(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_metrics_id() { + let postgres = TestDb::postgres().await; + let db = postgres.db(); + + let NewUserResult { + user_id: user1, + metrics_id: metrics_id1, + .. + } = db + .create_user( + "person1@example.com", + false, + NewUserParams { + github_login: "person1".into(), + github_user_id: 101, + invite_count: 5, + }, + ) + .await + .unwrap(); + let NewUserResult { + user_id: user2, + metrics_id: metrics_id2, + .. + } = db + .create_user( + "person2@example.com", + false, + NewUserParams { + github_login: "person2".into(), + github_user_id: 102, + invite_count: 5, + }, + ) + .await + .unwrap(); + + assert_eq!(db.get_user_metrics_id(user1).await.unwrap(), metrics_id1); + assert_eq!(db.get_user_metrics_id(user2).await.unwrap(), metrics_id2); + assert_eq!(metrics_id1.len(), 36); + assert_eq!(metrics_id2.len(), 36); + assert_ne!(metrics_id1, metrics_id2); +} + +fn build_background_executor() -> Arc { + Deterministic::new(0).build_background() +} diff --git a/crates/collab/src/integration_tests.rs b/crates/collab/src/integration_tests.rs index 6b512d950f..90d7b6d4b5 100644 --- a/crates/collab/src/integration_tests.rs +++ b/crates/collab/src/integration_tests.rs @@ -1,19 +1,21 @@ use crate::{ - db::{tests::TestDb, ProjectId, UserId}, + db::{NewUserParams, ProjectId, TestDb, UserId}, rpc::{Executor, Server, Store}, AppState, }; use ::rpc::Peer; use anyhow::anyhow; +use call::{room, ActiveCall, ParticipantLocation, Room}; use client::{ - self, proto, test::FakeHttpClient, Channel, ChannelDetails, ChannelList, Client, Connection, - Credentials, EstablishConnectionError, ProjectMetadata, UserStore, RECEIVE_TIMEOUT, + self, test::FakeHttpClient, Channel, ChannelDetails, ChannelList, Client, Connection, + Credentials, EstablishConnectionError, User, UserStore, RECEIVE_TIMEOUT, }; use collections::{BTreeMap, HashMap, HashSet}; use editor::{ self, ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Editor, Redo, Rename, ToOffset, ToggleCodeActions, Undo, }; +use fs::{FakeFs, Fs as _, LineEnding}; use futures::{channel::mpsc, Future, StreamExt as _}; use gpui::{ executor::{self, Deterministic}, @@ -23,24 +25,22 @@ use gpui::{ }; use language::{ range_to_lsp, tree_sitter_rust, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language, - LanguageConfig, LanguageRegistry, LineEnding, OffsetRangeExt, Point, Rope, + LanguageConfig, LanguageRegistry, OffsetRangeExt, Point, Rope, }; use lsp::{self, FakeLanguageServer}; use parking_lot::Mutex; use project::{ - fs::{FakeFs, Fs as _}, - search::SearchQuery, - worktree::WorktreeHandle, - DiagnosticSummary, Project, ProjectPath, ProjectStore, WorktreeId, + search::SearchQuery, worktree::WorktreeHandle, DiagnosticSummary, Project, ProjectPath, + ProjectStore, WorktreeId, }; use rand::prelude::*; use rpc::PeerId; use serde_json::json; -use settings::{FormatOnSave, Settings}; +use settings::{Formatter, Settings}; use sqlx::types::time::OffsetDateTime; use std::{ - cell::RefCell, - env, + cell::{Cell, RefCell}, + env, mem, ops::Deref, path::{Path, PathBuf}, rc::Rc, @@ -51,6 +51,7 @@ use std::{ time::Duration, }; use theme::ThemeRegistry; +use unindent::Unindent as _; use workspace::{Item, SplitDirection, ToggleFollow, Workspace}; #[ctor::ctor] @@ -61,21 +62,491 @@ fn init_logger() { } #[gpui::test(iterations = 10)] -async fn test_share_project( +async fn test_basic_calls( deterministic: Arc, cx_a: &mut TestAppContext, cx_b: &mut TestAppContext, cx_b2: &mut TestAppContext, + cx_c: &mut TestAppContext, ) { - cx_a.foreground().forbid_parking(); - let (_, window_b) = cx_b.add_window(|_| EmptyView); + deterministic.forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + server + .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) + .await; + + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + let active_call_c = cx_c.read(ActiveCall::global); + + // Call user B from client A. + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone()); + deterministic.run_until_parked(); + assert_eq!( + room_participants(&room_a, cx_a), + RoomParticipants { + remote: Default::default(), + pending: vec!["user_b".to_string()] + } + ); + + // User B receives the call. + let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming()); + let call_b = incoming_call_b.next().await.unwrap().unwrap(); + assert_eq!(call_b.caller.github_login, "user_a"); + + // User B connects via another client and also receives a ring on the newly-connected client. + let _client_b2 = server.create_client(cx_b2, "user_b").await; + let active_call_b2 = cx_b2.read(ActiveCall::global); + let mut incoming_call_b2 = active_call_b2.read_with(cx_b2, |call, _| call.incoming()); + deterministic.run_until_parked(); + let call_b2 = incoming_call_b2.next().await.unwrap().unwrap(); + assert_eq!(call_b2.caller.github_login, "user_a"); + + // User B joins the room using the first client. + active_call_b + .update(cx_b, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone()); + assert!(incoming_call_b.next().await.unwrap().is_none()); + + deterministic.run_until_parked(); + assert_eq!( + room_participants(&room_a, cx_a), + RoomParticipants { + remote: vec!["user_b".to_string()], + pending: Default::default() + } + ); + assert_eq!( + room_participants(&room_b, cx_b), + RoomParticipants { + remote: vec!["user_a".to_string()], + pending: Default::default() + } + ); + + // Call user C from client B. + let mut incoming_call_c = active_call_c.read_with(cx_c, |call, _| call.incoming()); + active_call_b + .update(cx_b, |call, cx| { + call.invite(client_c.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + + deterministic.run_until_parked(); + assert_eq!( + room_participants(&room_a, cx_a), + RoomParticipants { + remote: vec!["user_b".to_string()], + pending: vec!["user_c".to_string()] + } + ); + assert_eq!( + room_participants(&room_b, cx_b), + RoomParticipants { + remote: vec!["user_a".to_string()], + pending: vec!["user_c".to_string()] + } + ); + + // User C receives the call, but declines it. + let call_c = incoming_call_c.next().await.unwrap().unwrap(); + assert_eq!(call_c.caller.github_login, "user_b"); + active_call_c.update(cx_c, |call, _| call.decline_incoming().unwrap()); + assert!(incoming_call_c.next().await.unwrap().is_none()); + + deterministic.run_until_parked(); + assert_eq!( + room_participants(&room_a, cx_a), + RoomParticipants { + remote: vec!["user_b".to_string()], + pending: Default::default() + } + ); + assert_eq!( + room_participants(&room_b, cx_b), + RoomParticipants { + remote: vec!["user_a".to_string()], + pending: Default::default() + } + ); + + // User A leaves the room. + active_call_a.update(cx_a, |call, cx| { + call.hang_up(cx).unwrap(); + assert!(call.room().is_none()); + }); + deterministic.run_until_parked(); + assert_eq!( + room_participants(&room_a, cx_a), + RoomParticipants { + remote: Default::default(), + pending: Default::default() + } + ); + assert_eq!( + room_participants(&room_b, cx_b), + RoomParticipants { + remote: Default::default(), + pending: Default::default() + } + ); + + // User B leaves the room. + active_call_b.update(cx_b, |call, cx| { + call.hang_up(cx).unwrap(); + assert!(call.room().is_none()); + }); + deterministic.run_until_parked(); + assert_eq!( + room_participants(&room_a, cx_a), + RoomParticipants { + remote: Default::default(), + pending: Default::default() + } + ); + assert_eq!( + room_participants(&room_b, cx_b), + RoomParticipants { + remote: Default::default(), + pending: Default::default() + } + ); +} + +#[gpui::test(iterations = 10)] +async fn test_room_uniqueness( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_a2: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_b2: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + deterministic.forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let _client_a2 = server.create_client(cx_a2, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let _client_b2 = server.create_client(cx_b2, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + server + .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) + .await; + + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_a2 = cx_a2.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + let active_call_b2 = cx_b2.read(ActiveCall::global); + let active_call_c = cx_c.read(ActiveCall::global); + + // Call user B from client A. + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + + // Ensure a new room can't be created given user A just created one. + active_call_a2 + .update(cx_a2, |call, cx| { + call.invite(client_c.user_id().unwrap(), None, cx) + }) + .await + .unwrap_err(); + active_call_a2.read_with(cx_a2, |call, _| assert!(call.room().is_none())); + + // User B receives the call from user A. + let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming()); + let call_b1 = incoming_call_b.next().await.unwrap().unwrap(); + assert_eq!(call_b1.caller.github_login, "user_a"); + + // Ensure calling users A and B from client C fails. + active_call_c + .update(cx_c, |call, cx| { + call.invite(client_a.user_id().unwrap(), None, cx) + }) + .await + .unwrap_err(); + active_call_c + .update(cx_c, |call, cx| { + call.invite(client_b.user_id().unwrap(), None, cx) + }) + .await + .unwrap_err(); + + // Ensure User B can't create a room while they still have an incoming call. + active_call_b2 + .update(cx_b2, |call, cx| { + call.invite(client_c.user_id().unwrap(), None, cx) + }) + .await + .unwrap_err(); + active_call_b2.read_with(cx_b2, |call, _| assert!(call.room().is_none())); + + // User B joins the room and calling them after they've joined still fails. + active_call_b + .update(cx_b, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + active_call_c + .update(cx_c, |call, cx| { + call.invite(client_b.user_id().unwrap(), None, cx) + }) + .await + .unwrap_err(); + + // Ensure User B can't create a room while they belong to another room. + active_call_b2 + .update(cx_b2, |call, cx| { + call.invite(client_c.user_id().unwrap(), None, cx) + }) + .await + .unwrap_err(); + active_call_b2.read_with(cx_b2, |call, _| assert!(call.room().is_none())); + + // Client C can successfully call client B after client B leaves the room. + active_call_b + .update(cx_b, |call, cx| call.hang_up(cx)) + .unwrap(); + deterministic.run_until_parked(); + active_call_c + .update(cx_c, |call, cx| { + call.invite(client_b.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + let call_b2 = incoming_call_b.next().await.unwrap().unwrap(); + assert_eq!(call_b2.caller.github_login, "user_c"); +} + +#[gpui::test(iterations = 10)] +async fn test_leaving_room_on_disconnection( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + deterministic.forbid_parking(); let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + // Call user B from client A. + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone()); + + // User B receives the call and joins the room. + let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming()); + incoming_call_b.next().await.unwrap().unwrap(); + active_call_b + .update(cx_b, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone()); + deterministic.run_until_parked(); + assert_eq!( + room_participants(&room_a, cx_a), + RoomParticipants { + remote: vec!["user_b".to_string()], + pending: Default::default() + } + ); + assert_eq!( + room_participants(&room_b, cx_b), + RoomParticipants { + remote: vec!["user_a".to_string()], + pending: Default::default() + } + ); + + // When user A disconnects, both client A and B clear their room on the active call. + server.disconnect_client(client_a.current_user_id(cx_a)); + cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT); + active_call_a.read_with(cx_a, |call, _| assert!(call.room().is_none())); + active_call_b.read_with(cx_b, |call, _| assert!(call.room().is_none())); + assert_eq!( + room_participants(&room_a, cx_a), + RoomParticipants { + remote: Default::default(), + pending: Default::default() + } + ); + assert_eq!( + room_participants(&room_b, cx_b), + RoomParticipants { + remote: Default::default(), + pending: Default::default() + } + ); +} + +#[gpui::test(iterations = 10)] +async fn test_calls_on_multiple_connections( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b1: &mut TestAppContext, + cx_b2: &mut TestAppContext, +) { + deterministic.forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b1 = server.create_client(cx_b1, "user_b").await; + let _client_b2 = server.create_client(cx_b2, "user_b").await; + server + .make_contacts(&mut [(&client_a, cx_a), (&client_b1, cx_b1)]) + .await; + + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b1 = cx_b1.read(ActiveCall::global); + let active_call_b2 = cx_b2.read(ActiveCall::global); + let mut incoming_call_b1 = active_call_b1.read_with(cx_b1, |call, _| call.incoming()); + let mut incoming_call_b2 = active_call_b2.read_with(cx_b2, |call, _| call.incoming()); + assert!(incoming_call_b1.next().await.unwrap().is_none()); + assert!(incoming_call_b2.next().await.unwrap().is_none()); + + // Call user B from client A, ensuring both clients for user B ring. + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b1.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + assert!(incoming_call_b1.next().await.unwrap().is_some()); + assert!(incoming_call_b2.next().await.unwrap().is_some()); + + // User B declines the call on one of the two connections, causing both connections + // to stop ringing. + active_call_b2.update(cx_b2, |call, _| call.decline_incoming().unwrap()); + deterministic.run_until_parked(); + assert!(incoming_call_b1.next().await.unwrap().is_none()); + assert!(incoming_call_b2.next().await.unwrap().is_none()); + + // Call user B again from client A. + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b1.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + assert!(incoming_call_b1.next().await.unwrap().is_some()); + assert!(incoming_call_b2.next().await.unwrap().is_some()); + + // User B accepts the call on one of the two connections, causing both connections + // to stop ringing. + active_call_b2 + .update(cx_b2, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + deterministic.run_until_parked(); + assert!(incoming_call_b1.next().await.unwrap().is_none()); + assert!(incoming_call_b2.next().await.unwrap().is_none()); + + // User B hangs up, and user A calls them again. + active_call_b2.update(cx_b2, |call, cx| call.hang_up(cx).unwrap()); + deterministic.run_until_parked(); + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b1.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + assert!(incoming_call_b1.next().await.unwrap().is_some()); + assert!(incoming_call_b2.next().await.unwrap().is_some()); + + // User A cancels the call, causing both connections to stop ringing. + active_call_a + .update(cx_a, |call, cx| { + call.cancel_invite(client_b1.user_id().unwrap(), cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + assert!(incoming_call_b1.next().await.unwrap().is_none()); + assert!(incoming_call_b2.next().await.unwrap().is_none()); + + // User A calls user B again. + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b1.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + assert!(incoming_call_b1.next().await.unwrap().is_some()); + assert!(incoming_call_b2.next().await.unwrap().is_some()); + + // User A hangs up, causing both connections to stop ringing. + active_call_a.update(cx_a, |call, cx| call.hang_up(cx).unwrap()); + deterministic.run_until_parked(); + assert!(incoming_call_b1.next().await.unwrap().is_none()); + assert!(incoming_call_b2.next().await.unwrap().is_none()); + + // User A calls user B again. + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b1.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + assert!(incoming_call_b1.next().await.unwrap().is_some()); + assert!(incoming_call_b2.next().await.unwrap().is_some()); + + // User A disconnects up, causing both connections to stop ringing. + server.disconnect_client(client_a.current_user_id(cx_a)); + cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT); + assert!(incoming_call_b1.next().await.unwrap().is_none()); + assert!(incoming_call_b2.next().await.unwrap().is_none()); +} + +#[gpui::test(iterations = 10)] +async fn test_share_project( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + deterministic.forbid_parking(); + let (_, window_b) = cx_b.add_window(|_| EmptyView); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + server + .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + let active_call_c = cx_c.read(ActiveCall::global); + client_a .fs .insert_tree( @@ -92,30 +563,35 @@ async fn test_share_project( ) .await; + // Invite client B to collaborate on a project let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; - let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap()); + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b.user_id().unwrap(), Some(project_a.clone()), cx) + }) + .await + .unwrap(); // Join that project as client B + let incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming()); + deterministic.run_until_parked(); + let call = incoming_call_b.borrow().clone().unwrap(); + assert_eq!(call.caller.github_login, "user_a"); + let initial_project = call.initial_project.unwrap(); + active_call_b + .update(cx_b, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); let client_b_peer_id = client_b.peer_id; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - let replica_id_b = project_b.read_with(cx_b, |project, _| { - assert_eq!( - project - .collaborators() - .get(&client_a.peer_id) - .unwrap() - .user - .github_login, - "user_a" - ); - project.replica_id() - }); + let project_b = client_b + .build_remote_project(initial_project.id, cx_b) + .await; + let replica_id_b = project_b.read_with(cx_b, |project, _| project.replica_id()); deterministic.run_until_parked(); project_a.read_with(cx_a, |project, _| { let client_b_collaborator = project.collaborators().get(&client_b_peer_id).unwrap(); assert_eq!(client_b_collaborator.replica_id, replica_id_b); - assert_eq!(client_b_collaborator.user.github_login, "user_b"); }); project_b.read_with(cx_b, |project, cx| { let worktree = project.worktrees(cx).next().unwrap().read(cx); @@ -160,46 +636,33 @@ async fn test_share_project( .condition(cx_a, |buffer, _| buffer.text() == "ok, b-contents") .await; + // Client B can invite client C on a project shared by client A. + active_call_b + .update(cx_b, |call, cx| { + call.invite(client_c.user_id().unwrap(), Some(project_b.clone()), cx) + }) + .await + .unwrap(); + + let incoming_call_c = active_call_c.read_with(cx_c, |call, _| call.incoming()); + deterministic.run_until_parked(); + let call = incoming_call_c.borrow().clone().unwrap(); + assert_eq!(call.caller.github_login, "user_b"); + let initial_project = call.initial_project.unwrap(); + active_call_c + .update(cx_c, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + let _project_c = client_c + .build_remote_project(initial_project.id, cx_c) + .await; + // TODO // // 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; - - // Client B can join again on a different window because they are already a participant. - let client_b2 = server.create_client(cx_b2, "user_b").await; - let project_b2 = Project::remote( - project_id, - client_b2.client.clone(), - client_b2.user_store.clone(), - client_b2.project_store.clone(), - client_b2.language_registry.clone(), - FakeFs::new(cx_b2.background()), - cx_b2.to_async(), - ) - .await - .unwrap(); - deterministic.run_until_parked(); - project_a.read_with(cx_a, |project, _| { - assert_eq!(project.collaborators().len(), 2); - }); - project_b.read_with(cx_b, |project, _| { - assert_eq!(project.collaborators().len(), 2); - }); - project_b2.read_with(cx_b2, |project, _| { - assert_eq!(project.collaborators().len(), 2); - }); - - // Dropping client B's first project removes only that from client A's collaborators. - cx_b.update(move |_| drop(project_b)); - deterministic.run_until_parked(); - project_a.read_with(cx_a, |project, _| { - assert_eq!(project.collaborators().len(), 1); - }); - project_b2.read_with(cx_b2, |project, _| { - assert_eq!(project.collaborators().len(), 1); - }); } #[gpui::test(iterations = 10)] @@ -207,15 +670,20 @@ async fn test_unshare_project( deterministic: Arc, cx_a: &mut TestAppContext, cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, ) { - cx_a.foreground().forbid_parking(); + deterministic.forbid_parking(); let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + client_a .fs .insert_tree( @@ -228,8 +696,12 @@ async fn test_unshare_project( .await; let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap()); - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_b = client_b.build_remote_project(project_id, cx_b).await; assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); project_b @@ -237,23 +709,39 @@ async fn test_unshare_project( .await .unwrap(); - // When client B leaves the project, it gets automatically unshared. - cx_b.update(|_| drop(project_b)); + // When client B leaves the room, the project becomes read-only. + active_call_b.update(cx_b, |call, cx| call.hang_up(cx).unwrap()); + deterministic.run_until_parked(); + assert!(project_b.read_with(cx_b, |project, _| project.is_read_only())); + + // Client C opens the project. + let project_c = client_c.build_remote_project(project_id, cx_c).await; + + // When client A unshares the project, client C's project becomes read-only. + project_a + .update(cx_a, |project, cx| project.unshare(cx)) + .unwrap(); deterministic.run_until_parked(); assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared())); + assert!(project_c.read_with(cx_c, |project, _| project.is_read_only())); - // When client B joins again, the project gets re-shared. - let project_b2 = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + // Client C can open the project again after client A re-shares. + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_c2 = client_c.build_remote_project(project_id, cx_c).await; assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); - project_b2 - .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) + project_c2 + .update(cx_c, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) .await .unwrap(); - // When client A (the host) leaves, the project gets unshared and guests are notified. - cx_a.update(|_| drop(project_a)); + // When client A (the host) leaves the room, the project gets unshared and guests are notified. + active_call_a.update(cx_a, |call, cx| call.hang_up(cx).unwrap()); deterministic.run_until_parked(); - project_b2.read_with(cx_b, |project, _| { + project_a.read_with(cx_a, |project, _| assert!(!project.is_shared())); + project_c2.read_with(cx_c, |project, _| { assert!(project.is_read_only()); assert!(project.collaborators().is_empty()); }); @@ -273,11 +761,7 @@ async fn test_host_disconnect( let client_b = server.create_client(cx_b, "user_b").await; let client_c = server.create_client(cx_c, "user_c").await; server - .make_contacts(vec![ - (&client_a, cx_a), - (&client_b, cx_b), - (&client_c, cx_c), - ]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) .await; client_a @@ -291,11 +775,15 @@ async fn test_host_disconnect( ) .await; + let active_call_a = cx_a.read(ActiveCall::global); let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap()); - let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap()); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_b = client_b.build_remote_project(project_id, cx_b).await; assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); let (_, workspace_b) = @@ -317,23 +805,9 @@ async fn test_host_disconnect( editor_b.update(cx_b, |editor, cx| editor.insert("X", cx)); assert!(cx_b.is_window_edited(workspace_b.window_id())); - // Request to join that project as client C - let project_c = cx_c.spawn(|cx| { - Project::remote( - project_id, - client_c.client.clone(), - client_c.user_store.clone(), - client_c.project_store.clone(), - client_c.language_registry.clone(), - FakeFs::new(cx.background()), - cx, - ) - }); - deterministic.run_until_parked(); - // Drop client A's connection. Collaborators should disappear and the project should not be shown as shared. server.disconnect_client(client_a.current_user_id(cx_a)); - cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT); + deterministic.advance_clock(rpc::RECEIVE_TIMEOUT); project_a .condition(cx_a, |project, _| project.collaborators().is_empty()) .await; @@ -342,10 +816,6 @@ async fn test_host_disconnect( .condition(cx_b, |project, _| project.is_read_only()) .await; assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared())); - assert!(matches!( - project_c.await.unwrap_err(), - project::JoinProjectError::HostWentOffline - )); // Ensure client B's edited state is reset and that the whole window is blurred. cx_b.read(|cx| { @@ -354,447 +824,288 @@ async fn test_host_disconnect( assert!(!cx_b.is_window_edited(workspace_b.window_id())); // Ensure client B is not prompted to save edits when closing window after disconnecting. - workspace_b - .update(cx_b, |workspace, cx| { - workspace.close(&Default::default(), cx) - }) - .unwrap() + let can_close = workspace_b + .update(cx_b, |workspace, cx| workspace.prepare_to_close(true, cx)) .await .unwrap(); - assert_eq!(cx_b.window_ids().len(), 0); - cx_b.update(|_| { - drop(workspace_b); - drop(project_b); - }); + assert!(can_close); - // Ensure guests can still join. - let project_b2 = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); - project_b2 - .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) - .await - .unwrap(); -} - -#[gpui::test(iterations = 10)] -async fn test_decline_join_request( - deterministic: Arc, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, -) { - cx_a.foreground().forbid_parking(); - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - client_a.fs.insert_tree("/a", json!({})).await; - - let (project_a, _) = client_a.build_local_project("/a", cx_a).await; - let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap()); - - // Request to join that project as client B - let project_b = cx_b.spawn(|cx| { - Project::remote( - project_id, - client_b.client.clone(), - client_b.user_store.clone(), - client_b.project_store.clone(), - client_b.language_registry.clone(), - FakeFs::new(cx.background()), - cx, - ) - }); - deterministic.run_until_parked(); - project_a.update(cx_a, |project, cx| { - project.respond_to_join_request(client_b.user_id().unwrap(), false, cx) - }); - assert!(matches!( - project_b.await.unwrap_err(), - project::JoinProjectError::HostDeclined - )); - - // Request to join the project again as client B - let project_b = cx_b.spawn(|cx| { - Project::remote( - project_id, - client_b.client.clone(), - client_b.user_store.clone(), - client_b.project_store.clone(), - client_b.language_registry.clone(), - FakeFs::new(cx.background()), - cx, - ) - }); - - // Close the project on the host - deterministic.run_until_parked(); - cx_a.update(|_| drop(project_a)); - deterministic.run_until_parked(); - assert!(matches!( - project_b.await.unwrap_err(), - project::JoinProjectError::HostClosedProject - )); -} - -#[gpui::test(iterations = 10)] -async fn test_cancel_join_request( - deterministic: Arc, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, -) { - cx_a.foreground().forbid_parking(); - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - client_a.fs.insert_tree("/a", json!({})).await; - let (project_a, _) = client_a.build_local_project("/a", cx_a).await; - let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap()); - - let user_b = client_a - .user_store - .update(cx_a, |store, cx| { - store.fetch_user(client_b.user_id().unwrap(), cx) + let active_call_b = cx_b.read(ActiveCall::global); + active_call_b + .update(cx_b, |call, cx| { + call.invite(client_a.user_id().unwrap(), None, cx) }) .await .unwrap(); - - let project_a_events = Rc::new(RefCell::new(Vec::new())); - project_a.update(cx_a, { - let project_a_events = project_a_events.clone(); - move |_, cx| { - cx.subscribe(&cx.handle(), move |_, _, event, _| { - project_a_events.borrow_mut().push(event.clone()); - }) - .detach(); - } - }); - - // Request to join that project as client B - let project_b = cx_b.spawn(|cx| { - Project::remote( - project_id, - client_b.client.clone(), - client_b.user_store.clone(), - client_b.project_store.clone(), - client_b.language_registry.clone(), - FakeFs::new(cx.background()), - cx, - ) - }); deterministic.run_until_parked(); - assert_eq!( - &*project_a_events.borrow(), - &[project::Event::ContactRequestedJoin(user_b.clone())] - ); - project_a_events.borrow_mut().clear(); - - // Cancel the join request by leaving the project - client_b - .client - .send(proto::LeaveProject { project_id }) + active_call_a + .update(cx_a, |call, cx| call.accept_incoming(cx)) + .await .unwrap(); - drop(project_b); - deterministic.run_until_parked(); - assert_eq!( - &*project_a_events.borrow(), - &[project::Event::ContactCancelledJoinRequest(user_b)] - ); + active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + // Drop client A's connection again. We should still unshare it successfully. + server.disconnect_client(client_a.current_user_id(cx_a)); + deterministic.advance_clock(rpc::RECEIVE_TIMEOUT); + project_a.read_with(cx_a, |project, _| assert!(!project.is_shared())); } #[gpui::test(iterations = 10)] -async fn test_offline_projects( +async fn test_active_call_events( deterministic: Arc, cx_a: &mut TestAppContext, cx_b: &mut TestAppContext, - cx_c: &mut TestAppContext, ) { - cx_a.foreground().forbid_parking(); + deterministic.forbid_parking(); let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; - let client_c = server.create_client(cx_c, "user_c").await; - let user_a = UserId::from_proto(client_a.user_id().unwrap()); + client_a.fs.insert_tree("/a", json!({})).await; + client_b.fs.insert_tree("/b", json!({})).await; + + let (project_a, _) = client_a.build_local_project("/a", cx_a).await; + let (project_b, _) = client_b.build_local_project("/b", cx_b).await; + server - .make_contacts(vec![ - (&client_a, cx_a), - (&client_b, cx_b), - (&client_c, cx_c), - ]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); - // Set up observers of the project and user stores. Any time either of - // these models update, they should be in a consistent state with each - // other. There should not be an observable moment where the current - // user's contact entry contains a project that does not match one of - // the current open projects. That would cause a duplicate entry to be - // shown in the contacts panel. - let mut subscriptions = vec![]; - let (window_id, view) = cx_a.add_window(|cx| { - subscriptions.push(cx.observe(&client_a.user_store, { - let project_store = client_a.project_store.clone(); - let user_store = client_a.user_store.clone(); - move |_, _, cx| check_project_list(project_store.clone(), user_store.clone(), cx) - })); + let events_a = active_call_events(cx_a); + let events_b = active_call_events(cx_b); - subscriptions.push(cx.observe(&client_a.project_store, { - let project_store = client_a.project_store.clone(); - let user_store = client_a.user_store.clone(); - move |_, _, cx| check_project_list(project_store.clone(), user_store.clone(), cx) - })); - - fn check_project_list( - project_store: ModelHandle, - user_store: ModelHandle, - cx: &mut gpui::MutableAppContext, - ) { - let user_store = user_store.read(cx); - for contact in user_store.contacts() { - if contact.user.id == user_store.current_user().unwrap().id { - for project in &contact.projects { - let store_contains_project = project_store - .read(cx) - .projects(cx) - .filter_map(|project| project.read(cx).remote_id()) - .any(|x| x == project.id); - - if !store_contains_project { - panic!( - concat!( - "current user's contact data has a project", - "that doesn't match any open project {:?}", - ), - project - ); - } - } - } - } - } - - EmptyView - }); - - // Build an offline project with two worktrees. - client_a - .fs - .insert_tree( - "/code", - json!({ - "crate1": { "a.rs": "" }, - "crate2": { "b.rs": "" }, + let project_a_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + deterministic.run_until_parked(); + assert_eq!(mem::take(&mut *events_a.borrow_mut()), vec![]); + assert_eq!( + mem::take(&mut *events_b.borrow_mut()), + vec![room::Event::RemoteProjectShared { + owner: Arc::new(User { + id: client_a.user_id().unwrap(), + github_login: "user_a".to_string(), + avatar: None, }), - ) + project_id: project_a_id, + worktree_root_names: vec!["a".to_string()], + }] + ); + + let project_b_id = active_call_b + .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx)) + .await + .unwrap(); + deterministic.run_until_parked(); + assert_eq!( + mem::take(&mut *events_a.borrow_mut()), + vec![room::Event::RemoteProjectShared { + owner: Arc::new(User { + id: client_b.user_id().unwrap(), + github_login: "user_b".to_string(), + avatar: None, + }), + project_id: project_b_id, + worktree_root_names: vec!["b".to_string()] + }] + ); + assert_eq!(mem::take(&mut *events_b.borrow_mut()), vec![]); + + // Sharing a project twice is idempotent. + let project_b_id_2 = active_call_b + .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx)) + .await + .unwrap(); + assert_eq!(project_b_id_2, project_b_id); + deterministic.run_until_parked(); + assert_eq!(mem::take(&mut *events_a.borrow_mut()), vec![]); + assert_eq!(mem::take(&mut *events_b.borrow_mut()), vec![]); + + fn active_call_events(cx: &mut TestAppContext) -> Rc>> { + let events = Rc::new(RefCell::new(Vec::new())); + let active_call = cx.read(ActiveCall::global); + cx.update({ + let events = events.clone(); + |cx| { + cx.subscribe(&active_call, move |_, event, _| { + events.borrow_mut().push(event.clone()) + }) + .detach() + } + }); + events + } +} + +#[gpui::test(iterations = 10)] +async fn test_room_location( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + deterministic.forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + client_a.fs.insert_tree("/a", json!({})).await; + client_b.fs.insert_tree("/b", json!({})).await; + + let (project_a, _) = client_a.build_local_project("/a", cx_a).await; + let (project_b, _) = client_b.build_local_project("/b", cx_b).await; + + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; - let project = cx_a.update(|cx| { - Project::local( - false, - client_a.client.clone(), - client_a.user_store.clone(), - client_a.project_store.clone(), - client_a.language_registry.clone(), - client_a.fs.clone(), - cx, - ) + + let active_call_a = cx_a.read(ActiveCall::global); + let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone()); + let a_notified = Rc::new(Cell::new(false)); + cx_a.update({ + let notified = a_notified.clone(); + |cx| { + cx.observe(&active_call_a, move |_, _| notified.set(true)) + .detach() + } }); - project - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/code/crate1", true, cx) + + let active_call_b = cx_b.read(ActiveCall::global); + let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone()); + let b_notified = Rc::new(Cell::new(false)); + cx_b.update({ + let b_notified = b_notified.clone(); + |cx| { + cx.observe(&active_call_b, move |_, _| b_notified.set(true)) + .detach() + } + }); + + room_a + .update(cx_a, |room, cx| room.set_location(Some(&project_a), cx)) + .await + .unwrap(); + deterministic.run_until_parked(); + assert!(a_notified.take()); + assert_eq!( + participant_locations(&room_a, cx_a), + vec![("user_b".to_string(), ParticipantLocation::External)] + ); + assert!(b_notified.take()); + assert_eq!( + participant_locations(&room_b, cx_b), + vec![("user_a".to_string(), ParticipantLocation::UnsharedProject)] + ); + + let project_a_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + deterministic.run_until_parked(); + assert!(a_notified.take()); + assert_eq!( + participant_locations(&room_a, cx_a), + vec![("user_b".to_string(), ParticipantLocation::External)] + ); + assert!(b_notified.take()); + assert_eq!( + participant_locations(&room_b, cx_b), + vec![( + "user_a".to_string(), + ParticipantLocation::SharedProject { + project_id: project_a_id + } + )] + ); + + let project_b_id = active_call_b + .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx)) + .await + .unwrap(); + deterministic.run_until_parked(); + assert!(a_notified.take()); + assert_eq!( + participant_locations(&room_a, cx_a), + vec![("user_b".to_string(), ParticipantLocation::External)] + ); + assert!(b_notified.take()); + assert_eq!( + participant_locations(&room_b, cx_b), + vec![( + "user_a".to_string(), + ParticipantLocation::SharedProject { + project_id: project_a_id + } + )] + ); + + room_b + .update(cx_b, |room, cx| room.set_location(Some(&project_b), cx)) + .await + .unwrap(); + deterministic.run_until_parked(); + assert!(a_notified.take()); + assert_eq!( + participant_locations(&room_a, cx_a), + vec![( + "user_b".to_string(), + ParticipantLocation::SharedProject { + project_id: project_b_id + } + )] + ); + assert!(b_notified.take()); + assert_eq!( + participant_locations(&room_b, cx_b), + vec![( + "user_a".to_string(), + ParticipantLocation::SharedProject { + project_id: project_a_id + } + )] + ); + + room_b + .update(cx_b, |room, cx| room.set_location(None, cx)) + .await + .unwrap(); + deterministic.run_until_parked(); + assert!(a_notified.take()); + assert_eq!( + participant_locations(&room_a, cx_a), + vec![("user_b".to_string(), ParticipantLocation::External)] + ); + assert!(b_notified.take()); + assert_eq!( + participant_locations(&room_b, cx_b), + vec![( + "user_a".to_string(), + ParticipantLocation::SharedProject { + project_id: project_a_id + } + )] + ); + + fn participant_locations( + room: &ModelHandle, + cx: &TestAppContext, + ) -> Vec<(String, ParticipantLocation)> { + room.read_with(cx, |room, _| { + room.remote_participants() + .values() + .map(|participant| { + ( + participant.user.github_login.to_string(), + participant.location, + ) + }) + .collect() }) - .await - .unwrap(); - project - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/code/crate2", true, cx) - }) - .await - .unwrap(); - project - .update(cx_a, |p, cx| p.restore_state(cx)) - .await - .unwrap(); - - // When a project is offline, we still create it on the server but is invisible - // to other users. - deterministic.run_until_parked(); - assert!(server - .store - .lock() - .await - .project_metadata_for_user(user_a) - .is_empty()); - project.read_with(cx_a, |project, _| { - assert!(project.remote_id().is_some()); - assert!(!project.is_online()); - }); - assert!(client_b - .user_store - .read_with(cx_b, |store, _| { store.contacts()[0].projects.is_empty() })); - - // When the project is taken online, its metadata is sent to the server - // and broadcasted to other users. - project.update(cx_a, |p, cx| p.set_online(true, cx)); - deterministic.run_until_parked(); - let project_id = project.read_with(cx_a, |p, _| p.remote_id()).unwrap(); - client_b.user_store.read_with(cx_b, |store, _| { - assert_eq!( - store.contacts()[0].projects, - &[ProjectMetadata { - id: project_id, - visible_worktree_root_names: vec!["crate1".into(), "crate2".into()], - guests: Default::default(), - }] - ); - }); - - // The project is registered again when the host loses and regains connection. - server.disconnect_client(user_a); - server.forbid_connections(); - cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT); - assert!(server - .store - .lock() - .await - .project_metadata_for_user(user_a) - .is_empty()); - assert!(project.read_with(cx_a, |p, _| p.remote_id().is_none())); - assert!(client_b - .user_store - .read_with(cx_b, |store, _| { store.contacts()[0].projects.is_empty() })); - - server.allow_connections(); - cx_b.foreground().advance_clock(Duration::from_secs(10)); - let project_id = project.read_with(cx_a, |p, _| p.remote_id()).unwrap(); - client_b.user_store.read_with(cx_b, |store, _| { - assert_eq!( - store.contacts()[0].projects, - &[ProjectMetadata { - id: project_id, - visible_worktree_root_names: vec!["crate1".into(), "crate2".into()], - guests: Default::default(), - }] - ); - }); - - project - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/code/crate3", true, cx) - }) - .await - .unwrap(); - deterministic.run_until_parked(); - client_b.user_store.read_with(cx_b, |store, _| { - assert_eq!( - store.contacts()[0].projects, - &[ProjectMetadata { - id: project_id, - visible_worktree_root_names: vec![ - "crate1".into(), - "crate2".into(), - "crate3".into() - ], - guests: Default::default(), - }] - ); - }); - - // Build another project using a directory which was previously part of - // an online project. Restore the project's state from the host's database. - let project2_a = cx_a.update(|cx| { - Project::local( - false, - client_a.client.clone(), - client_a.user_store.clone(), - client_a.project_store.clone(), - client_a.language_registry.clone(), - client_a.fs.clone(), - cx, - ) - }); - project2_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/code/crate3", true, cx) - }) - .await - .unwrap(); - project2_a - .update(cx_a, |project, cx| project.restore_state(cx)) - .await - .unwrap(); - - // This project is now online, because its directory was previously online. - project2_a.read_with(cx_a, |project, _| assert!(project.is_online())); - deterministic.run_until_parked(); - let project2_id = project2_a.read_with(cx_a, |p, _| p.remote_id()).unwrap(); - client_b.user_store.read_with(cx_b, |store, _| { - assert_eq!( - store.contacts()[0].projects, - &[ - ProjectMetadata { - id: project_id, - visible_worktree_root_names: vec![ - "crate1".into(), - "crate2".into(), - "crate3".into() - ], - guests: Default::default(), - }, - ProjectMetadata { - id: project2_id, - visible_worktree_root_names: vec!["crate3".into()], - guests: Default::default(), - } - ] - ); - }); - - let project2_b = client_b.build_remote_project(&project2_a, cx_a, cx_b).await; - let project2_c = cx_c.foreground().spawn(Project::remote( - project2_id, - client_c.client.clone(), - client_c.user_store.clone(), - client_c.project_store.clone(), - client_c.language_registry.clone(), - FakeFs::new(cx_c.background()), - cx_c.to_async(), - )); - deterministic.run_until_parked(); - - // Taking a project offline unshares the project, rejects any pending join request and - // disconnects existing guests. - project2_a.update(cx_a, |project, cx| project.set_online(false, cx)); - deterministic.run_until_parked(); - project2_a.read_with(cx_a, |project, _| assert!(!project.is_shared())); - project2_b.read_with(cx_b, |project, _| assert!(project.is_read_only())); - project2_c.await.unwrap_err(); - - client_b.user_store.read_with(cx_b, |store, _| { - assert_eq!( - store.contacts()[0].projects, - &[ProjectMetadata { - id: project_id, - visible_worktree_root_names: vec![ - "crate1".into(), - "crate2".into(), - "crate3".into() - ], - guests: Default::default(), - },] - ); - }); - - cx_a.update(|cx| { - drop(subscriptions); - drop(view); - cx.remove_window(window_id); - }); + } } #[gpui::test(iterations = 10)] @@ -809,12 +1120,9 @@ async fn test_propagate_saves_and_fs_changes( let client_b = server.create_client(cx_b, "user_b").await; let client_c = server.create_client(cx_c, "user_c").await; server - .make_contacts(vec![ - (&client_a, cx_a), - (&client_b, cx_b), - (&client_c, cx_c), - ]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); client_a .fs @@ -828,10 +1136,14 @@ async fn test_propagate_saves_and_fs_changes( .await; let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; let worktree_a = project_a.read_with(cx_a, |p, cx| p.worktrees(cx).next().unwrap()); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); // Join that worktree as clients B and C. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - let project_c = client_c.build_remote_project(&project_a, cx_a, cx_c).await; + let project_b = client_b.build_remote_project(project_id, cx_b).await; + let project_c = client_c.build_remote_project(project_id, cx_c).await; let worktree_b = project_b.read_with(cx_b, |p, cx| p.worktrees(cx).next().unwrap()); let worktree_c = project_c.read_with(cx_c, |p, cx| p.worktrees(cx).next().unwrap()); @@ -946,6 +1258,258 @@ async fn test_propagate_saves_and_fs_changes( .await; } +#[gpui::test(iterations = 10)] +async fn test_git_diff_base_change( + executor: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + executor.forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + client_a + .fs + .insert_tree( + "/dir", + json!({ + ".git": {}, + "sub": { + ".git": {}, + "b.txt": " + one + two + three + ".unindent(), + }, + "a.txt": " + one + two + three + ".unindent(), + }), + ) + .await; + + let (project_local, worktree_id) = client_a.build_local_project("/dir", cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| { + call.share_project(project_local.clone(), cx) + }) + .await + .unwrap(); + + let project_remote = client_b.build_remote_project(project_id, cx_b).await; + + let diff_base = " + one + three + " + .unindent(); + + let new_diff_base = " + one + two + " + .unindent(); + + client_a + .fs + .as_fake() + .set_index_for_repo( + Path::new("/dir/.git"), + &[(Path::new("a.txt"), diff_base.clone())], + ) + .await; + + // Create the buffer + let buffer_local_a = project_local + .update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) + .await + .unwrap(); + + // Wait for it to catch up to the new diff + executor.run_until_parked(); + + // Smoke test diffing + buffer_local_a.read_with(cx_a, |buffer, _| { + assert_eq!(buffer.diff_base(), Some(diff_base.as_ref())); + git::diff::assert_hunks( + buffer.snapshot().git_diff_hunks_in_range(0..4), + &buffer, + &diff_base, + &[(1..2, "", "two\n")], + ); + }); + + // Create remote buffer + let buffer_remote_a = project_remote + .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) + .await + .unwrap(); + + // Wait remote buffer to catch up to the new diff + executor.run_until_parked(); + + // Smoke test diffing + buffer_remote_a.read_with(cx_b, |buffer, _| { + assert_eq!(buffer.diff_base(), Some(diff_base.as_ref())); + git::diff::assert_hunks( + buffer.snapshot().git_diff_hunks_in_range(0..4), + &buffer, + &diff_base, + &[(1..2, "", "two\n")], + ); + }); + + client_a + .fs + .as_fake() + .set_index_for_repo( + Path::new("/dir/.git"), + &[(Path::new("a.txt"), new_diff_base.clone())], + ) + .await; + + // Wait for buffer_local_a to receive it + executor.run_until_parked(); + + // Smoke test new diffing + buffer_local_a.read_with(cx_a, |buffer, _| { + assert_eq!(buffer.diff_base(), Some(new_diff_base.as_ref())); + + git::diff::assert_hunks( + buffer.snapshot().git_diff_hunks_in_range(0..4), + &buffer, + &diff_base, + &[(2..3, "", "three\n")], + ); + }); + + // Smoke test B + buffer_remote_a.read_with(cx_b, |buffer, _| { + assert_eq!(buffer.diff_base(), Some(new_diff_base.as_ref())); + git::diff::assert_hunks( + buffer.snapshot().git_diff_hunks_in_range(0..4), + &buffer, + &diff_base, + &[(2..3, "", "three\n")], + ); + }); + + //Nested git dir + + let diff_base = " + one + three + " + .unindent(); + + let new_diff_base = " + one + two + " + .unindent(); + + client_a + .fs + .as_fake() + .set_index_for_repo( + Path::new("/dir/sub/.git"), + &[(Path::new("b.txt"), diff_base.clone())], + ) + .await; + + // Create the buffer + let buffer_local_b = project_local + .update(cx_a, |p, cx| p.open_buffer((worktree_id, "sub/b.txt"), cx)) + .await + .unwrap(); + + // Wait for it to catch up to the new diff + executor.run_until_parked(); + + // Smoke test diffing + buffer_local_b.read_with(cx_a, |buffer, _| { + assert_eq!(buffer.diff_base(), Some(diff_base.as_ref())); + git::diff::assert_hunks( + buffer.snapshot().git_diff_hunks_in_range(0..4), + &buffer, + &diff_base, + &[(1..2, "", "two\n")], + ); + }); + + // Create remote buffer + let buffer_remote_b = project_remote + .update(cx_b, |p, cx| p.open_buffer((worktree_id, "sub/b.txt"), cx)) + .await + .unwrap(); + + // Wait remote buffer to catch up to the new diff + executor.run_until_parked(); + + // Smoke test diffing + buffer_remote_b.read_with(cx_b, |buffer, _| { + assert_eq!(buffer.diff_base(), Some(diff_base.as_ref())); + git::diff::assert_hunks( + buffer.snapshot().git_diff_hunks_in_range(0..4), + &buffer, + &diff_base, + &[(1..2, "", "two\n")], + ); + }); + + client_a + .fs + .as_fake() + .set_index_for_repo( + Path::new("/dir/sub/.git"), + &[(Path::new("b.txt"), new_diff_base.clone())], + ) + .await; + + // Wait for buffer_local_b to receive it + executor.run_until_parked(); + + // Smoke test new diffing + buffer_local_b.read_with(cx_a, |buffer, _| { + assert_eq!(buffer.diff_base(), Some(new_diff_base.as_ref())); + println!("{:?}", buffer.as_rope().to_string()); + println!("{:?}", buffer.diff_base()); + println!( + "{:?}", + buffer + .snapshot() + .git_diff_hunks_in_range(0..4) + .collect::>() + ); + + git::diff::assert_hunks( + buffer.snapshot().git_diff_hunks_in_range(0..4), + &buffer, + &diff_base, + &[(2..3, "", "three\n")], + ); + }); + + // Smoke test B + buffer_remote_b.read_with(cx_b, |buffer, _| { + assert_eq!(buffer.diff_base(), Some(new_diff_base.as_ref())); + git::diff::assert_hunks( + buffer.snapshot().git_diff_hunks_in_range(0..4), + &buffer, + &diff_base, + &[(2..3, "", "three\n")], + ); + }); +} + #[gpui::test(iterations = 10)] async fn test_fs_operations( executor: Arc, @@ -957,8 +1521,9 @@ async fn test_fs_operations( let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); client_a .fs @@ -971,7 +1536,11 @@ async fn test_fs_operations( ) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap()); let worktree_b = project_b.read_with(cx_b, |project, cx| project.worktrees(cx).next().unwrap()); @@ -1218,8 +1787,9 @@ async fn test_buffer_conflict_after_save(cx_a: &mut TestAppContext, cx_b: &mut T let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); client_a .fs @@ -1231,7 +1801,11 @@ async fn test_buffer_conflict_after_save(cx_a: &mut TestAppContext, cx_b: &mut T ) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; // Open a buffer as client B let buffer_b = project_b @@ -1267,8 +1841,9 @@ async fn test_buffer_reloading(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); client_a .fs @@ -1280,7 +1855,11 @@ async fn test_buffer_reloading(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont ) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; // Open a buffer as client B let buffer_b = project_b @@ -1321,15 +1900,20 @@ async fn test_editing_while_guest_opens_buffer( let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); client_a .fs .insert_tree("/dir", json!({ "a.txt": "a-contents" })) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; // Open a buffer as client A let buffer_a = project_a @@ -1363,15 +1947,20 @@ async fn test_leaving_worktree_while_opening_buffer( let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); client_a .fs .insert_tree("/dir", json!({ "a.txt": "a-contents" })) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; // See that a guest has joined as client A. project_a @@ -1403,8 +1992,9 @@ async fn test_canceling_buffer_opening( let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); client_a .fs @@ -1416,7 +2006,11 @@ async fn test_canceling_buffer_opening( ) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; let buffer_a = project_a .update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) @@ -1438,14 +2032,21 @@ async fn test_canceling_buffer_opening( } #[gpui::test(iterations = 10)] -async fn test_leaving_project(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); +async fn test_leaving_project( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + deterministic.forbid_parking(); let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); client_a .fs @@ -1458,34 +2059,109 @@ async fn test_leaving_project(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte ) .await; let (project_a, _) = client_a.build_local_project("/a", cx_a).await; - let _project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b1 = client_b.build_remote_project(project_id, cx_b).await; + let project_c = client_c.build_remote_project(project_id, cx_c).await; // Client A sees that a guest has joined. - project_a - .condition(cx_a, |p, _| p.collaborators().len() == 1) - .await; + deterministic.run_until_parked(); + project_a.read_with(cx_a, |project, _| { + assert_eq!(project.collaborators().len(), 2); + }); + project_b1.read_with(cx_b, |project, _| { + assert_eq!(project.collaborators().len(), 2); + }); + project_c.read_with(cx_c, |project, _| { + assert_eq!(project.collaborators().len(), 2); + }); - // Drop client B's connection and ensure client A observes client B leaving the project. + // Client B opens a buffer. + let buffer_b1 = project_b1 + .update(cx_b, |project, cx| { + let worktree_id = project.worktrees(cx).next().unwrap().read(cx).id(); + project.open_buffer((worktree_id, "a.txt"), cx) + }) + .await + .unwrap(); + buffer_b1.read_with(cx_b, |buffer, _| assert_eq!(buffer.text(), "a-contents")); + + // Drop client B's project and ensure client A and client C observe client B leaving. + cx_b.update(|_| drop(project_b1)); + deterministic.run_until_parked(); + project_a.read_with(cx_a, |project, _| { + assert_eq!(project.collaborators().len(), 1); + }); + project_c.read_with(cx_c, |project, _| { + assert_eq!(project.collaborators().len(), 1); + }); + + // Client B re-joins the project and can open buffers as before. + let project_b2 = client_b.build_remote_project(project_id, cx_b).await; + deterministic.run_until_parked(); + project_a.read_with(cx_a, |project, _| { + assert_eq!(project.collaborators().len(), 2); + }); + project_b2.read_with(cx_b, |project, _| { + assert_eq!(project.collaborators().len(), 2); + }); + project_c.read_with(cx_c, |project, _| { + assert_eq!(project.collaborators().len(), 2); + }); + + let buffer_b2 = project_b2 + .update(cx_b, |project, cx| { + let worktree_id = project.worktrees(cx).next().unwrap().read(cx).id(); + project.open_buffer((worktree_id, "a.txt"), cx) + }) + .await + .unwrap(); + buffer_b2.read_with(cx_b, |buffer, _| assert_eq!(buffer.text(), "a-contents")); + + // Drop client B's connection and ensure client A and client C observe client B leaving. client_b.disconnect(&cx_b.to_async()).unwrap(); - project_a - .condition(cx_a, |p, _| p.collaborators().is_empty()) - .await; + deterministic.run_until_parked(); + project_a.read_with(cx_a, |project, _| { + assert_eq!(project.collaborators().len(), 1); + }); + project_b2.read_with(cx_b, |project, _| { + assert!(project.is_read_only()); + }); + project_c.read_with(cx_c, |project, _| { + assert_eq!(project.collaborators().len(), 1); + }); - // Rejoin the project as client B - let _project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + // Client B can't join the project, unless they re-join the room. + cx_b.spawn(|cx| { + Project::remote( + project_id, + client_b.client.clone(), + client_b.user_store.clone(), + client_b.project_store.clone(), + client_b.language_registry.clone(), + FakeFs::new(cx.background()), + cx, + ) + }) + .await + .unwrap_err(); - // Client A sees that a guest has re-joined. - project_a - .condition(cx_a, |p, _| p.collaborators().len() == 1) - .await; - - // Simulate connection loss for client B and ensure client A observes client B leaving the project. - client_b.wait_for_current_user(cx_b).await; - server.disconnect_client(client_b.current_user_id(cx_b)); + // Simulate connection loss for client C and ensure client A observes client C leaving the project. + client_c.wait_for_current_user(cx_c).await; + server.disconnect_client(client_c.current_user_id(cx_c)); cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT); - project_a - .condition(cx_a, |p, _| p.collaborators().is_empty()) - .await; + deterministic.run_until_parked(); + project_a.read_with(cx_a, |project, _| { + assert_eq!(project.collaborators().len(), 0); + }); + project_b2.read_with(cx_b, |project, _| { + assert!(project.is_read_only()); + }); + project_c.read_with(cx_c, |project, _| { + assert!(project.is_read_only()); + }); } #[gpui::test(iterations = 10)] @@ -1501,12 +2177,9 @@ async fn test_collaborating_with_diagnostics( let client_b = server.create_client(cx_b, "user_b").await; let client_c = server.create_client(cx_c, "user_c").await; server - .make_contacts(vec![ - (&client_a, cx_a), - (&client_b, cx_b), - (&client_c, cx_c), - ]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); // Set up a fake language server. let mut language = Language::new( @@ -1532,12 +2205,10 @@ async fn test_collaborating_with_diagnostics( ) .await; let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; - let project_id = project_a.update(cx_a, |p, _| p.next_remote_id()).await; // Cause the language server to start. - let _buffer = cx_a - .background() - .spawn(project_a.update(cx_a, |project, cx| { + let _buffer = project_a + .update(cx_a, |project, cx| { project.open_buffer( ProjectPath { worktree_id, @@ -1545,18 +2216,35 @@ async fn test_collaborating_with_diagnostics( }, cx, ) - })) + }) .await .unwrap(); - // Join the worktree as client B. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - // Simulate a language server reporting errors for a file. let mut fake_language_server = fake_language_servers.next().await.unwrap(); fake_language_server .receive_notification::() .await; + fake_language_server.notify::( + lsp::PublishDiagnosticsParams { + uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), + version: None, + diagnostics: vec![lsp::Diagnostic { + severity: Some(lsp::DiagnosticSeverity::WARNING), + range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 7)), + message: "message 0".to_string(), + ..Default::default() + }], + }, + ); + + // Client A shares the project and, simultaneously, the language server + // publishes a diagnostic. This is done to ensure that the server always + // observes the latest diagnostics for a worktree. + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); fake_language_server.notify::( lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), @@ -1570,6 +2258,9 @@ async fn test_collaborating_with_diagnostics( }, ); + // Join the worktree as client B. + let project_b = client_b.build_remote_project(project_id, cx_b).await; + // Wait for server to see the diagnostics update. deterministic.run_until_parked(); { @@ -1598,25 +2289,36 @@ async fn test_collaborating_with_diagnostics( }); // Join project as client C and observe the diagnostics. - let project_c = client_c.build_remote_project(&project_a, cx_a, cx_c).await; - deterministic.run_until_parked(); - project_c.read_with(cx_c, |project, cx| { - assert_eq!( - project.diagnostic_summaries(cx).collect::>(), - &[( - ProjectPath { - worktree_id, - path: Arc::from(Path::new("a.rs")), - }, - DiagnosticSummary { - error_count: 1, - warning_count: 0, - ..Default::default() - }, - )] - ) + let project_c = client_c.build_remote_project(project_id, cx_c).await; + let project_c_diagnostic_summaries = Rc::new(RefCell::new(Vec::new())); + project_c.update(cx_c, |_, cx| { + let summaries = project_c_diagnostic_summaries.clone(); + cx.subscribe(&project_c, { + move |p, _, event, cx| { + if let project::Event::DiskBasedDiagnosticsFinished { .. } = event { + *summaries.borrow_mut() = p.diagnostic_summaries(cx).collect(); + } + } + }) + .detach(); }); + deterministic.run_until_parked(); + assert_eq!( + project_c_diagnostic_summaries.borrow().as_slice(), + &[( + ProjectPath { + worktree_id, + path: Arc::from(Path::new("a.rs")), + }, + DiagnosticSummary { + error_count: 1, + warning_count: 0, + ..Default::default() + }, + )] + ); + // Simulate a language server reporting more errors for a file. fake_language_server.notify::( lsp::PublishDiagnosticsParams { @@ -1691,7 +2393,7 @@ async fn test_collaborating_with_diagnostics( DiagnosticEntry { range: Point::new(0, 4)..Point::new(0, 7), diagnostic: Diagnostic { - group_id: 1, + group_id: 2, message: "message 1".to_string(), severity: lsp::DiagnosticSeverity::ERROR, is_primary: true, @@ -1701,7 +2403,7 @@ async fn test_collaborating_with_diagnostics( DiagnosticEntry { range: Point::new(0, 10)..Point::new(0, 13), diagnostic: Diagnostic { - group_id: 2, + group_id: 3, severity: lsp::DiagnosticSeverity::WARNING, message: "message 2".to_string(), is_primary: true, @@ -1739,8 +2441,9 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); // Set up a fake language server. let mut language = Language::new( @@ -1776,7 +2479,11 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu ) .await; let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; // Open a file in an editor as the guest. let buffer_b = project_b @@ -1908,8 +2615,9 @@ async fn test_reloading_buffer_manually(cx_a: &mut TestAppContext, cx_b: &mut Te let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); client_a .fs @@ -1920,8 +2628,12 @@ async fn test_reloading_buffer_manually(cx_a: &mut TestAppContext, cx_b: &mut Te .update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx)) .await .unwrap(); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_b = client_b.build_remote_project(project_id, cx_b).await; let buffer_b = cx_b .background() @@ -1990,12 +2702,15 @@ async fn test_reloading_buffer_manually(cx_a: &mut TestAppContext, cx_b: &mut Te #[gpui::test(iterations = 10)] async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + use project::FormatTrigger; + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); // Set up a fake language server. let mut language = Language::new( @@ -2018,7 +2733,11 @@ async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppCon .insert_tree(&directory, json!({ "a.rs": "let one = \"two\"" })) .await; let (project_a, worktree_id) = client_a.build_local_project(&directory, cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; let buffer_b = cx_b .background() @@ -2042,7 +2761,12 @@ async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppCon project_b .update(cx_b, |project, cx| { - project.format(HashSet::from_iter([buffer_b.clone()]), true, cx) + project.format( + HashSet::from_iter([buffer_b.clone()]), + true, + FormatTrigger::Save, + cx, + ) }) .await .unwrap(); @@ -2055,7 +2779,7 @@ async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppCon // host's configuration is honored as opposed to using the guest's settings. cx_a.update(|cx| { cx.update_global(|settings: &mut Settings, _| { - settings.editor_defaults.format_on_save = Some(FormatOnSave::External { + settings.editor_defaults.formatter = Some(Formatter::External { command: "awk".to_string(), arguments: vec!["{sub(/two/,\"{buffer_path}\")}1".to_string()], }); @@ -2063,7 +2787,12 @@ async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppCon }); project_b .update(cx_b, |project, cx| { - project.format(HashSet::from_iter([buffer_b.clone()]), true, cx) + project.format( + HashSet::from_iter([buffer_b.clone()]), + true, + FormatTrigger::Save, + cx, + ) }) .await .unwrap(); @@ -2080,8 +2809,9 @@ async fn test_definition(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); // Set up a fake language server. let mut language = Language::new( @@ -2111,7 +2841,11 @@ async fn test_definition(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { ) .await; let (project_a, worktree_id) = client_a.build_local_project("/root/dir-1", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; // Open the file on client B. let buffer_b = cx_b @@ -2219,8 +2953,9 @@ async fn test_references(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); // Set up a fake language server. let mut language = Language::new( @@ -2250,7 +2985,11 @@ async fn test_references(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { ) .await; let (project_a, worktree_id) = client_a.build_local_project("/root/dir-1", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; // Open the file on client B. let buffer_b = cx_b @@ -2315,8 +3054,9 @@ async fn test_project_search(cx_a: &mut TestAppContext, cx_b: &mut TestAppContex let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); client_a .fs @@ -2345,8 +3085,12 @@ async fn test_project_search(cx_a: &mut TestAppContext, cx_b: &mut TestAppContex worktree_2 .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) .await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_b = client_b.build_remote_project(project_id, cx_b).await; // Perform a search as the guest. let results = project_b @@ -2389,8 +3133,9 @@ async fn test_document_highlights(cx_a: &mut TestAppContext, cx_b: &mut TestAppC let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); client_a .fs @@ -2415,7 +3160,11 @@ async fn test_document_highlights(cx_a: &mut TestAppContext, cx_b: &mut TestAppC client_a.language_registry.add(Arc::new(language)); let (project_a, worktree_id) = client_a.build_local_project("/root-1", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; // Open the file on client B. let buffer_b = cx_b @@ -2486,8 +3235,9 @@ async fn test_lsp_hover(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); client_a .fs @@ -2512,7 +3262,11 @@ async fn test_lsp_hover(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { client_a.language_registry.add(Arc::new(language)); let (project_a, worktree_id) = client_a.build_local_project("/root-1", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; // Open the file as the guest let buffer_b = cx_b @@ -2584,8 +3338,9 @@ async fn test_project_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); // Set up a fake language server. let mut language = Language::new( @@ -2617,7 +3372,11 @@ async fn test_project_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte ) .await; let (project_a, worktree_id) = client_a.build_local_project("/code/crate-1", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; // Cause the language server to start. let _buffer = cx_b @@ -2687,8 +3446,9 @@ async fn test_open_buffer_while_getting_definition_pointing_to_it( let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); // Set up a fake language server. let mut language = Language::new( @@ -2713,7 +3473,11 @@ async fn test_open_buffer_while_getting_definition_pointing_to_it( ) .await; let (project_a, worktree_id) = client_a.build_local_project("/root", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; let buffer_b1 = cx_b .background() @@ -2758,8 +3522,9 @@ async fn test_collaborating_with_code_actions( let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); // Set up a fake language server. let mut language = Language::new( @@ -2784,9 +3549,13 @@ async fn test_collaborating_with_code_actions( ) .await; let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); // Join the project as client B. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_b = client_b.build_remote_project(project_id, cx_b).await; let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::new(project_b.clone(), |_, _| unimplemented!(), cx)); let editor_b = workspace_b @@ -2964,8 +3733,9 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); // Set up a fake language server. let mut language = Language::new( @@ -3001,7 +3771,11 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T ) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::new(project_b.clone(), |_, _| unimplemented!(), cx)); @@ -3151,8 +3925,9 @@ async fn test_language_server_statuses( let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); // Set up a fake language server. let mut language = Language::new( @@ -3209,7 +3984,12 @@ async fn test_language_server_statuses( ); }); - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + deterministic.run_until_parked(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; project_b.read_with(cx_b, |project, _| { let status = project.language_server_statuses().next().unwrap(); assert_eq!(status.name, "the-language-server"); @@ -3667,130 +4447,53 @@ async fn test_contacts( let client_b = server.create_client(cx_b, "user_b").await; let client_c = server.create_client(cx_c, "user_c").await; server - .make_contacts(vec![ - (&client_a, cx_a), - (&client_b, cx_b), - (&client_c, cx_c), - ]) + .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + let active_call_c = cx_c.read(ActiveCall::global); deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![]), - ("user_b", true, vec![]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } - - // Share a project as client A. - client_a.fs.create_dir(Path::new("/a")).await.unwrap(); - let (project_a, _) = client_a.build_local_project("/a", cx_a).await; - - deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![("a", vec![])]), - ("user_b", true, vec![]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } - - let _project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![("a", vec!["user_b"])]), - ("user_b", true, vec![]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } - - // Add a local project as client B - client_a.fs.create_dir("/b".as_ref()).await.unwrap(); - let (_project_b, _) = client_b.build_local_project("/b", cx_b).await; - - deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![("a", vec!["user_b"])]), - ("user_b", true, vec![("b", vec![])]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } - - project_a - .condition(cx_a, |project, _| { - project.collaborators().contains_key(&client_b.peer_id) - }) - .await; - - cx_a.update(move |_| drop(project_a)); - deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![]), - ("user_b", true, vec![("b", vec![])]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "free"), + ("user_b".to_string(), "online", "free") + ] + ); server.disconnect_client(client_c.current_user_id(cx_c)); server.forbid_connections(); deterministic.advance_clock(rpc::RECEIVE_TIMEOUT); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![]), - ("user_b", true, vec![("b", vec![])]), - ("user_c", false, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } - client_c - .user_store - .read_with(cx_c, |store, _| assert_eq!(contacts(store), [])); + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "free"), + ("user_c".to_string(), "offline", "free") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "free"), + ("user_c".to_string(), "offline", "free") + ] + ); + assert_eq!(contacts(&client_c, cx_c), []); server.allow_connections(); client_c @@ -3799,40 +4502,256 @@ async fn test_contacts( .unwrap(); deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![]), - ("user_b", true, vec![("b", vec![])]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "free"), + ("user_b".to_string(), "online", "free") + ] + ); + + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "busy"), + ("user_b".to_string(), "online", "busy") + ] + ); + + active_call_b.update(cx_b, |call, _| call.decline_incoming().unwrap()); + deterministic.run_until_parked(); + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "free"), + ("user_b".to_string(), "online", "free") + ] + ); + + active_call_c + .update(cx_c, |call, cx| { + call.invite(client_a.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "free"), + ("user_c".to_string(), "online", "busy") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "busy") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "busy"), + ("user_b".to_string(), "online", "free") + ] + ); + + active_call_a + .update(cx_a, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + deterministic.run_until_parked(); + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "free"), + ("user_c".to_string(), "online", "busy") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "busy") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "busy"), + ("user_b".to_string(), "online", "free") + ] + ); + + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "busy") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "busy") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "busy"), + ("user_b".to_string(), "online", "busy") + ] + ); + + active_call_a.update(cx_a, |call, cx| call.hang_up(cx).unwrap()); + deterministic.run_until_parked(); + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "free"), + ("user_b".to_string(), "online", "free") + ] + ); + + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + assert_eq!( + contacts(&client_a, cx_a), + [ + ("user_b".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "online", "busy"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "online", "busy"), + ("user_b".to_string(), "online", "busy") + ] + ); + + server.forbid_connections(); + server.disconnect_client(client_a.current_user_id(cx_a)); + deterministic.advance_clock(rpc::RECEIVE_TIMEOUT); + assert_eq!(contacts(&client_a, cx_a), []); + assert_eq!( + contacts(&client_b, cx_b), + [ + ("user_a".to_string(), "offline", "free"), + ("user_c".to_string(), "online", "free") + ] + ); + assert_eq!( + contacts(&client_c, cx_c), + [ + ("user_a".to_string(), "offline", "free"), + ("user_b".to_string(), "online", "free") + ] + ); #[allow(clippy::type_complexity)] - fn contacts(user_store: &UserStore) -> Vec<(&str, bool, Vec<(&str, Vec<&str>)>)> { - user_store - .contacts() - .iter() - .map(|contact| { - let projects = contact - .projects - .iter() - .map(|p| { - ( - p.visible_worktree_root_names[0].as_str(), - p.guests.iter().map(|p| p.github_login.as_str()).collect(), - ) - }) - .collect(); - (contact.user.github_login.as_str(), contact.online, projects) - }) - .collect() + fn contacts( + client: &TestClient, + cx: &TestAppContext, + ) -> Vec<(String, &'static str, &'static str)> { + client.user_store.read_with(cx, |store, _| { + store + .contacts() + .iter() + .map(|contact| { + ( + contact.user.github_login.clone(), + if contact.online { "online" } else { "offline" }, + if contact.busy { "busy" } else { "free" }, + ) + }) + .collect() + }) } } @@ -3935,18 +4854,18 @@ async fn test_contact_requests( // User B sees user A as their contact now in all client, and the incoming request from them is removed. let contacts_b = client_b.summarize_contacts(cx_b); - assert_eq!(contacts_b.current, &["user_a", "user_b"]); + assert_eq!(contacts_b.current, &["user_a"]); assert_eq!(contacts_b.incoming_requests, &["user_c"]); let contacts_b2 = client_b2.summarize_contacts(cx_b2); - assert_eq!(contacts_b2.current, &["user_a", "user_b"]); + assert_eq!(contacts_b2.current, &["user_a"]); assert_eq!(contacts_b2.incoming_requests, &["user_c"]); // User A sees user B as their contact now in all clients, and the outgoing request to them is removed. let contacts_a = client_a.summarize_contacts(cx_a); - assert_eq!(contacts_a.current, &["user_a", "user_b"]); + assert_eq!(contacts_a.current, &["user_b"]); assert!(contacts_a.outgoing_requests.is_empty()); let contacts_a2 = client_a2.summarize_contacts(cx_a2); - assert_eq!(contacts_a2.current, &["user_a", "user_b"]); + assert_eq!(contacts_a2.current, &["user_b"]); assert!(contacts_a2.outgoing_requests.is_empty()); // Contacts are present upon connecting (tested here via disconnect/reconnect) @@ -3954,19 +4873,13 @@ async fn test_contact_requests( disconnect_and_reconnect(&client_b, cx_b).await; disconnect_and_reconnect(&client_c, cx_c).await; executor.run_until_parked(); - assert_eq!( - client_a.summarize_contacts(cx_a).current, - &["user_a", "user_b"] - ); - assert_eq!( - client_b.summarize_contacts(cx_b).current, - &["user_a", "user_b"] - ); + assert_eq!(client_a.summarize_contacts(cx_a).current, &["user_b"]); + assert_eq!(client_b.summarize_contacts(cx_b).current, &["user_a"]); assert_eq!( client_b.summarize_contacts(cx_b).incoming_requests, &["user_c"] ); - assert_eq!(client_c.summarize_contacts(cx_c).current, &["user_c"]); + assert!(client_c.summarize_contacts(cx_c).current.is_empty()); assert_eq!( client_c.summarize_contacts(cx_c).outgoing_requests, &["user_b"] @@ -3985,18 +4898,18 @@ async fn test_contact_requests( // User B doesn't see user C as their contact, and the incoming request from them is removed. let contacts_b = client_b.summarize_contacts(cx_b); - assert_eq!(contacts_b.current, &["user_a", "user_b"]); + assert_eq!(contacts_b.current, &["user_a"]); assert!(contacts_b.incoming_requests.is_empty()); let contacts_b2 = client_b2.summarize_contacts(cx_b2); - assert_eq!(contacts_b2.current, &["user_a", "user_b"]); + assert_eq!(contacts_b2.current, &["user_a"]); assert!(contacts_b2.incoming_requests.is_empty()); // User C doesn't see user B as their contact, and the outgoing request to them is removed. let contacts_c = client_c.summarize_contacts(cx_c); - assert_eq!(contacts_c.current, &["user_c"]); + assert!(contacts_c.current.is_empty()); assert!(contacts_c.outgoing_requests.is_empty()); let contacts_c2 = client_c2.summarize_contacts(cx_c2); - assert_eq!(contacts_c2.current, &["user_c"]); + assert!(contacts_c2.current.is_empty()); assert!(contacts_c2.outgoing_requests.is_empty()); // Incoming/outgoing requests are not present upon connecting (tested here via disconnect/reconnect) @@ -4004,19 +4917,13 @@ async fn test_contact_requests( disconnect_and_reconnect(&client_b, cx_b).await; disconnect_and_reconnect(&client_c, cx_c).await; executor.run_until_parked(); - assert_eq!( - client_a.summarize_contacts(cx_a).current, - &["user_a", "user_b"] - ); - assert_eq!( - client_b.summarize_contacts(cx_b).current, - &["user_a", "user_b"] - ); + assert_eq!(client_a.summarize_contacts(cx_a).current, &["user_b"]); + assert_eq!(client_b.summarize_contacts(cx_b).current, &["user_a"]); assert!(client_b .summarize_contacts(cx_b) .incoming_requests .is_empty()); - assert_eq!(client_c.summarize_contacts(cx_c).current, &["user_c"]); + assert!(client_c.summarize_contacts(cx_c).current.is_empty()); assert!(client_c .summarize_contacts(cx_c) .outgoing_requests @@ -4035,14 +4942,16 @@ async fn test_contact_requests( #[gpui::test(iterations = 10)] async fn test_following(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { cx_a.foreground().forbid_parking(); + cx_a.update(editor::init); + cx_b.update(editor::init); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; - cx_a.update(editor::init); - cx_b.update(editor::init); + let active_call_a = cx_a.read(ActiveCall::global); client_a .fs @@ -4056,8 +4965,11 @@ async fn test_following(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { ) .await; let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; - - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; // Client A opens some editors. let workspace_a = client_a.build_workspace(&project_a, cx_a); @@ -4241,14 +5153,16 @@ async fn test_following(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { #[gpui::test(iterations = 10)] async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { cx_a.foreground().forbid_parking(); + cx_a.update(editor::init); + cx_b.update(editor::init); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; - cx_a.update(editor::init); - cx_b.update(editor::init); + let active_call_a = cx_a.read(ActiveCall::global); // Client A shares a project. client_a @@ -4264,9 +5178,13 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T ) .await; let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); // Client B joins the project. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_b = client_b.build_remote_project(project_id, cx_b).await; // Client A opens some editors. let workspace_a = client_a.build_workspace(&project_a, cx_a); @@ -4403,16 +5321,17 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T #[gpui::test(iterations = 10)] async fn test_auto_unfollowing(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { cx_a.foreground().forbid_parking(); + cx_a.update(editor::init); + cx_b.update(editor::init); // 2 clients connect to a server. let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; - cx_a.update(editor::init); - cx_b.update(editor::init); + let active_call_a = cx_a.read(ActiveCall::global); // Client A shares a project. client_a @@ -4427,7 +5346,11 @@ async fn test_auto_unfollowing(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont ) .await; let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; // Client A opens some editors. let workspace_a = client_a.build_workspace(&project_a, cx_a); @@ -4563,21 +5486,26 @@ async fn test_peers_simultaneously_following_each_other( cx_b: &mut TestAppContext, ) { deterministic.forbid_parking(); + cx_a.update(editor::init); + cx_b.update(editor::init); let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; - cx_a.update(editor::init); - cx_b.update(editor::init); + let active_call_a = cx_a.read(ActiveCall::global); client_a.fs.insert_tree("/a", json!({})).await; let (project_a, _) = client_a.build_local_project("/a", cx_a).await; let workspace_a = client_a.build_workspace(&project_a, cx_a); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_b = client_b.build_remote_project(project_id, cx_b).await; let workspace_b = client_b.build_workspace(&project_b, cx_b); deterministic.run_until_parked(); @@ -4640,7 +5568,20 @@ async fn test_random_collaboration( let mut server = TestServer::start(cx.foreground(), cx.background()).await; let db = server.app_state.db.clone(); - let host_user_id = db.create_user("host", None, false).await.unwrap(); + + let room_creator_user_id = db + .create_user( + "room-creator@example.com", + false, + NewUserParams { + github_login: "room-creator".into(), + github_user_id: 0, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; let mut available_guests = vec![ "guest-1".to_string(), "guest-2".to_string(), @@ -4648,23 +5589,41 @@ async fn test_random_collaboration( "guest-4".to_string(), ]; - for username in &available_guests { - let guest_user_id = db.create_user(username, None, false).await.unwrap(); - assert_eq!(*username, format!("guest-{}", guest_user_id)); + for (ix, username) in Some(&"host".to_string()) + .into_iter() + .chain(&available_guests) + .enumerate() + { + let user_id = db + .create_user( + &format!("{username}@example.com"), + false, + NewUserParams { + github_login: username.into(), + github_user_id: (ix + 1) as i32, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; server .app_state .db - .send_contact_request(guest_user_id, host_user_id) + .send_contact_request(user_id, room_creator_user_id) .await .unwrap(); server .app_state .db - .respond_to_contact_request(host_user_id, guest_user_id, true) + .respond_to_contact_request(room_creator_user_id, user_id, true) .await .unwrap(); } + let _room_creator = server.create_client(cx, "room-creator").await; + let active_call = cx.read(ActiveCall::global); + let mut clients = Vec::new(); let mut user_ids = Vec::new(); let mut op_start_signals = Vec::new(); @@ -4678,11 +5637,11 @@ async fn test_random_collaboration( cx.font_cache(), cx.leak_detector(), next_entity_id, + cx.function_name.clone(), ); let host = server.create_client(&mut host_cx, "host").await; let host_project = host_cx.update(|cx| { Project::local( - true, host.client.clone(), host.user_store.clone(), host.project_store.clone(), @@ -4691,9 +5650,6 @@ async fn test_random_collaboration( cx, ) }); - let host_project_id = host_project - .update(&mut host_cx, |p, _| p.next_remote_id()) - .await; let (collab_worktree, _) = host_project .update(&mut host_cx, |project, cx| { @@ -4834,8 +5790,30 @@ async fn test_random_collaboration( .await; host_language_registry.add(Arc::new(language)); + let host_user_id = host.current_user_id(&host_cx); + active_call + .update(cx, |call, cx| { + call.invite(host_user_id.to_proto(), None, cx) + }) + .await + .unwrap(); + active_call.read_with(cx, |call, cx| call.room().unwrap().read(cx).id()); + deterministic.run_until_parked(); + let host_active_call = host_cx.read(ActiveCall::global); + host_active_call + .update(&mut host_cx, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + + let host_project_id = host_active_call + .update(&mut host_cx, |call, cx| { + call.share_project(host_project.clone(), cx) + }) + .await + .unwrap(); + let op_start_signal = futures::channel::mpsc::unbounded(); - user_ids.push(host.current_user_id(&host_cx)); + user_ids.push(host_user_id); op_start_signals.push(op_start_signal.0); clients.push(host_cx.foreground().spawn(host.simulate_host( host_project, @@ -4872,26 +5850,16 @@ async fn test_random_collaboration( log::error!("{} error - {:?}", guest.username, guest_err); } - let contacts = server - .app_state - .db - .get_contacts(guest.current_user_id(&guest_cx)) - .await - .unwrap(); - let contacts = server - .store - .lock() - .await - .build_initial_contacts_update(contacts) - .contacts; - assert!(!contacts - .iter() - .flat_map(|contact| &contact.projects) - .any(|project| project.id == host_project_id)); guest_project.read_with(&guest_cx, |project, _| assert!(project.is_read_only())); - guest_cx.update(|_| drop((guest, guest_project))); + guest_cx.update(|cx| { + cx.clear_globals(); + drop((guest, guest_project)); + }); } - host_cx.update(|_| drop((host, host_project))); + host_cx.update(|cx| { + cx.clear_globals(); + drop((host, host_project)); + }); return; } @@ -4911,10 +5879,26 @@ async fn test_random_collaboration( cx.font_cache(), cx.leak_detector(), next_entity_id, + cx.function_name.clone(), ); deterministic.start_waiting(); let guest = server.create_client(&mut guest_cx, &guest_username).await; + let guest_user_id = guest.current_user_id(&guest_cx); + + active_call + .update(cx, |call, cx| { + call.invite(guest_user_id.to_proto(), None, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + guest_cx + .read(ActiveCall::global) + .update(&mut guest_cx, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + let guest_project = Project::remote( host_project_id, guest.client.clone(), @@ -4929,7 +5913,7 @@ async fn test_random_collaboration( deterministic.finish_waiting(); let op_start_signal = futures::channel::mpsc::unbounded(); - user_ids.push(guest.current_user_id(&guest_cx)); + user_ids.push(guest_user_id); op_start_signals.push(op_start_signal.0); clients.push(guest_cx.foreground().spawn(guest.simulate_guest( guest_username.clone(), @@ -4976,20 +5960,15 @@ async fn test_random_collaboration( "removed guest is still a contact of another peer" ); } - for project in contact.projects { - for project_guest_id in project.guests { - assert_ne!( - project_guest_id, removed_guest_id.0 as u64, - "removed guest appears as still participating on a project" - ); - } - } } } log::info!("{} removed", guest.username); available_guests.push(guest.username.clone()); - guest_cx.update(|_| drop((guest, guest_project))); + guest_cx.update(|cx| { + cx.clear_globals(); + drop((guest, guest_project)); + }); operations += 1; } @@ -5104,10 +6083,16 @@ async fn test_random_collaboration( ); } - guest_cx.update(|_| drop((guest_project, guest_client))); + guest_cx.update(|cx| { + cx.clear_globals(); + drop((guest_project, guest_client)); + }); } - host_cx.update(|_| drop((host_client, host_project))); + host_cx.update(|cx| { + cx.clear_globals(); + drop((host_client, host_project)) + }); } struct TestServer { @@ -5151,18 +6136,31 @@ impl TestServer { }); let http = FakeHttpClient::with_404_response(); - let user_id = if let Ok(Some(user)) = self.app_state.db.get_user_by_github_login(name).await + let user_id = if let Ok(Some(user)) = self + .app_state + .db + .get_user_by_github_account(name, None) + .await { user.id } else { self.app_state .db - .create_user(name, None, false) + .create_user( + &format!("{name}@example.com"), + false, + NewUserParams { + github_login: name.into(), + github_user_id: 0, + invite_count: 0, + }, + ) .await .unwrap() + .user_id }; let client_name = name.to_string(); - let mut client = Client::new(http.clone()); + let mut client = cx.read(|cx| Client::new(http.clone(), cx)); let server = self.server.clone(); let db = self.app_state.db.clone(); let connection_killers = self.connection_killers.clone(); @@ -5217,7 +6215,7 @@ impl TestServer { let fs = FakeFs::new(cx.background()); let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx)); - let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake())); + let project_store = cx.add_model(|_| ProjectStore::new()); let app_state = Arc::new(workspace::AppState { client: client.clone(), user_store: user_store.clone(), @@ -5232,7 +6230,10 @@ impl TestServer { Channel::init(&client); Project::init(&client); - cx.update(|cx| workspace::init(app_state.clone(), cx)); + cx.update(|cx| { + workspace::init(app_state.clone(), cx); + call::init(client.clone(), user_store.clone(), cx); + }); client .authenticate_and_connect(false, &cx.to_async()) @@ -5270,12 +6271,14 @@ impl TestServer { self.forbid_connections.store(false, SeqCst); } - async fn make_contacts(&self, mut clients: Vec<(&TestClient, &mut TestAppContext)>) { - while let Some((client_a, cx_a)) = clients.pop() { - for (client_b, cx_b) in &mut clients { + async fn make_contacts(&self, clients: &mut [(&TestClient, &mut TestAppContext)]) { + for ix in 1..clients.len() { + let (left, right) = clients.split_at_mut(ix); + let (client_a, cx_a) = left.last_mut().unwrap(); + for (client_b, cx_b) in right { client_a .user_store - .update(cx_a, |store, cx| { + .update(*cx_a, |store, cx| { store.request_contact(client_b.user_id().unwrap(), cx) }) .await @@ -5292,6 +6295,29 @@ impl TestServer { } } + async fn create_room(&self, clients: &mut [(&TestClient, &mut TestAppContext)]) { + self.make_contacts(clients).await; + + let (left, right) = clients.split_at_mut(1); + let (_client_a, cx_a) = &mut left[0]; + let active_call_a = cx_a.read(ActiveCall::global); + + for (client_b, cx_b) in right { + let user_id_b = client_b.current_user_id(*cx_b).to_proto(); + active_call_a + .update(*cx_a, |call, cx| call.invite(user_id_b, None, cx)) + .await + .unwrap(); + + cx_b.foreground().run_until_parked(); + let active_call_b = cx_b.read(ActiveCall::global); + active_call_b + .update(*cx_b, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + } + } + async fn build_app_state(test_db: &TestDb) -> Arc { Arc::new(AppState { db: test_db.db().clone(), @@ -5403,7 +6429,6 @@ impl TestClient { ) -> (ModelHandle, WorktreeId) { let project = cx.update(|cx| { Project::local( - true, self.client.clone(), self.user_store.clone(), self.project_store.clone(), @@ -5421,40 +6446,26 @@ impl TestClient { worktree .read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete()) .await; - project - .update(cx, |project, _| project.next_remote_id()) - .await; (project, worktree.read_with(cx, |tree, _| tree.id())) } async fn build_remote_project( &self, - host_project: &ModelHandle, - host_cx: &mut TestAppContext, + host_project_id: u64, guest_cx: &mut TestAppContext, ) -> ModelHandle { - let host_project_id = host_project - .read_with(host_cx, |project, _| project.next_remote_id()) - .await; - let guest_user_id = self.user_id().unwrap(); - let languages = host_project.read_with(host_cx, |project, _| project.languages().clone()); let project_b = guest_cx.spawn(|cx| { Project::remote( host_project_id, self.client.clone(), self.user_store.clone(), self.project_store.clone(), - languages, + self.language_registry.clone(), FakeFs::new(cx.background()), cx, ) }); - host_cx.foreground().run_until_parked(); - host_project.update(host_cx, |project, cx| { - project.respond_to_join_request(guest_user_id, true, cx) - }); - let project = project_b.await.unwrap(); - project + project_b.await.unwrap() } fn build_workspace( @@ -5489,18 +6500,6 @@ impl TestClient { ) -> anyhow::Result<()> { let fs = project.read_with(cx, |project, _| project.fs().clone()); - cx.update(|cx| { - cx.subscribe(&project, move |project, event, cx| { - if let project::Event::ContactRequestedJoin(user) = event { - log::info!("Host: accepting join request from {}", user.github_login); - project.update(cx, |project, cx| { - project.respond_to_join_request(user.id, true, cx) - }); - } - }) - .detach(); - }); - while op_start_signal.next().await.is_some() { let distribution = rng.lock().gen_range::(0..100); let files = fs.as_fake().files().await; @@ -5978,3 +6977,24 @@ fn channel_messages(channel: &Channel) -> Vec<(String, String, bool)> { }) .collect() } + +#[derive(Debug, Eq, PartialEq)] +struct RoomParticipants { + remote: Vec, + pending: Vec, +} + +fn room_participants(room: &ModelHandle, cx: &mut TestAppContext) -> RoomParticipants { + room.read_with(cx, |room, _| RoomParticipants { + remote: room + .remote_participants() + .iter() + .map(|(_, participant)| participant.user.github_login.clone()) + .collect(), + pending: room + .pending_participants() + .iter() + .map(|user| user.github_login.clone()) + .collect(), + }) +} diff --git a/crates/collab/src/main.rs b/crates/collab/src/main.rs index 2c2c6a94f4..272d52cc95 100644 --- a/crates/collab/src/main.rs +++ b/crates/collab/src/main.rs @@ -4,6 +4,8 @@ mod db; mod env; mod rpc; +#[cfg(test)] +mod db_tests; #[cfg(test)] mod integration_tests; diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index dab7df3e67..564e173fec 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -22,7 +22,7 @@ use axum::{ routing::get, Extension, Router, TypedHeader, }; -use collections::HashMap; +use collections::{HashMap, HashSet}; use futures::{ channel::mpsc, future::{self, BoxFuture}, @@ -88,11 +88,6 @@ impl Response { self.server.peer.respond(self.receipt, payload)?; Ok(()) } - - fn into_receipt(self) -> Receipt { - self.responded.store(true, SeqCst); - self.receipt - } } pub struct Server { @@ -151,11 +146,17 @@ impl Server { server .add_request_handler(Server::ping) - .add_request_handler(Server::register_project) - .add_request_handler(Server::unregister_project) + .add_request_handler(Server::create_room) + .add_request_handler(Server::join_room) + .add_message_handler(Server::leave_room) + .add_request_handler(Server::call) + .add_request_handler(Server::cancel_call) + .add_message_handler(Server::decline_call) + .add_request_handler(Server::update_participant_location) + .add_request_handler(Server::share_project) + .add_message_handler(Server::unshare_project) .add_request_handler(Server::join_project) .add_message_handler(Server::leave_project) - .add_message_handler(Server::respond_to_join_project_request) .add_message_handler(Server::update_project) .add_message_handler(Server::register_project_activity) .add_request_handler(Server::update_worktree) @@ -205,7 +206,9 @@ impl Server { .add_request_handler(Server::follow) .add_message_handler(Server::unfollow) .add_message_handler(Server::update_followers) - .add_request_handler(Server::get_channel_messages); + .add_request_handler(Server::get_channel_messages) + .add_message_handler(Server::update_diff_base) + .add_request_handler(Server::get_private_user_info); Arc::new(server) } @@ -362,8 +365,7 @@ impl Server { timer.await; } } - }) - .await; + }); tracing::info!(%user_id, %login, %connection_id, %address, "connection opened"); @@ -383,7 +385,11 @@ impl Server { { let mut store = this.store().await; - store.add_connection(connection_id, user_id, user.admin); + let incoming_call = store.add_connection(connection_id, user_id, user.admin); + if let Some(incoming_call) = incoming_call { + this.peer.send(connection_id, incoming_call)?; + } + this.peer.send(connection_id, store.build_initial_contacts_update(contacts))?; if let Some((code, count)) = invite_code { @@ -466,69 +472,58 @@ impl Server { async fn sign_out(self: &mut Arc, connection_id: ConnectionId) -> Result<()> { self.peer.disconnect(connection_id); - let mut projects_to_unregister = Vec::new(); - let removed_user_id; + let mut projects_to_unshare = Vec::new(); + let mut contacts_to_update = HashSet::default(); { let mut store = self.store().await; let removed_connection = store.remove_connection(connection_id)?; - for (project_id, project) in removed_connection.hosted_projects { - projects_to_unregister.push(project_id); + for project in removed_connection.hosted_projects { + projects_to_unshare.push(project.id); broadcast(connection_id, project.guests.keys().copied(), |conn_id| { self.peer.send( conn_id, - proto::UnregisterProject { - project_id: project_id.to_proto(), + proto::UnshareProject { + project_id: project.id.to_proto(), }, ) }); - - for (_, receipts) in project.join_requests { - for receipt in receipts { - self.peer.respond( - receipt, - proto::JoinProjectResponse { - variant: Some(proto::join_project_response::Variant::Decline( - proto::join_project_response::Decline { - reason: proto::join_project_response::decline::Reason::WentOffline as i32 - }, - )), - }, - )?; - } - } } - for project_id in removed_connection.guest_project_ids { - if let Some(project) = store.project(project_id).trace_err() { - broadcast(connection_id, project.connection_ids(), |conn_id| { - self.peer.send( - conn_id, - proto::RemoveProjectCollaborator { - project_id: project_id.to_proto(), - peer_id: connection_id.0, - }, - ) - }); - if project.guests.is_empty() { - self.peer - .send( - project.host_connection_id, - proto::ProjectUnshared { - project_id: project_id.to_proto(), - }, - ) - .trace_err(); - } - } + for project in removed_connection.guest_projects { + broadcast(connection_id, project.connection_ids, |conn_id| { + self.peer.send( + conn_id, + proto::RemoveProjectCollaborator { + project_id: project.id.to_proto(), + peer_id: connection_id.0, + }, + ) + }); } - removed_user_id = removed_connection.user_id; + for connection_id in removed_connection.canceled_call_connection_ids { + self.peer + .send(connection_id, proto::CallCanceled {}) + .trace_err(); + contacts_to_update.extend(store.user_id_for_connection(connection_id).ok()); + } + + if let Some(room) = removed_connection + .room_id + .and_then(|room_id| store.room(room_id)) + { + self.room_updated(room); + } + + contacts_to_update.insert(removed_connection.user_id); }; - self.update_user_contacts(removed_user_id).await.trace_err(); + for user_id in contacts_to_update { + self.update_user_contacts(user_id).await.trace_err(); + } - for project_id in projects_to_unregister { + for project_id in projects_to_unshare { self.app_state .db .unregister_project(project_id) @@ -541,27 +536,30 @@ impl Server { pub async fn invite_code_redeemed( self: &Arc, - code: &str, + inviter_id: UserId, invitee_id: UserId, ) -> Result<()> { - let user = self.app_state.db.get_user_for_invite_code(code).await?; - let store = self.store().await; - let invitee_contact = store.contact_for_user(invitee_id, true); - for connection_id in store.connection_ids_for_user(user.id) { - self.peer.send( - connection_id, - proto::UpdateContacts { - contacts: vec![invitee_contact.clone()], - ..Default::default() - }, - )?; - self.peer.send( - connection_id, - proto::UpdateInviteInfo { - url: format!("{}{}", self.app_state.invite_link_prefix, code), - count: user.invite_count as u32, - }, - )?; + if let Some(user) = self.app_state.db.get_user_by_id(inviter_id).await? { + if let Some(code) = &user.invite_code { + let store = self.store().await; + let invitee_contact = store.contact_for_user(invitee_id, true); + for connection_id in store.connection_ids_for_user(inviter_id) { + self.peer.send( + connection_id, + proto::UpdateContacts { + contacts: vec![invitee_contact.clone()], + ..Default::default() + }, + )?; + self.peer.send( + connection_id, + proto::UpdateInviteInfo { + url: format!("{}{}", self.app_state.invite_link_prefix, &code), + count: user.invite_count as u32, + }, + )?; + } + } } Ok(()) } @@ -593,76 +591,286 @@ impl Server { Ok(()) } - async fn register_project( + async fn create_room( self: Arc, - request: TypedEnvelope, - response: Response, + request: TypedEnvelope, + response: Response, + ) -> Result<()> { + let user_id; + let room_id; + { + let mut store = self.store().await; + user_id = store.user_id_for_connection(request.sender_id)?; + room_id = store.create_room(request.sender_id)?; + } + response.send(proto::CreateRoomResponse { id: room_id })?; + self.update_user_contacts(user_id).await?; + Ok(()) + } + + async fn join_room( + self: Arc, + request: TypedEnvelope, + response: Response, + ) -> Result<()> { + let user_id; + { + let mut store = self.store().await; + user_id = store.user_id_for_connection(request.sender_id)?; + let (room, recipient_connection_ids) = + store.join_room(request.payload.id, request.sender_id)?; + for recipient_id in recipient_connection_ids { + self.peer + .send(recipient_id, proto::CallCanceled {}) + .trace_err(); + } + response.send(proto::JoinRoomResponse { + room: Some(room.clone()), + })?; + self.room_updated(room); + } + self.update_user_contacts(user_id).await?; + Ok(()) + } + + async fn leave_room(self: Arc, message: TypedEnvelope) -> Result<()> { + let mut contacts_to_update = HashSet::default(); + { + let mut store = self.store().await; + let user_id = store.user_id_for_connection(message.sender_id)?; + let left_room = store.leave_room(message.payload.id, message.sender_id)?; + contacts_to_update.insert(user_id); + + for project in left_room.unshared_projects { + for connection_id in project.connection_ids() { + self.peer.send( + connection_id, + proto::UnshareProject { + project_id: project.id.to_proto(), + }, + )?; + } + } + + for project in left_room.left_projects { + if project.remove_collaborator { + for connection_id in project.connection_ids { + self.peer.send( + connection_id, + proto::RemoveProjectCollaborator { + project_id: project.id.to_proto(), + peer_id: message.sender_id.0, + }, + )?; + } + + self.peer.send( + message.sender_id, + proto::UnshareProject { + project_id: project.id.to_proto(), + }, + )?; + } + } + + if let Some(room) = left_room.room { + self.room_updated(room); + } + + for connection_id in left_room.canceled_call_connection_ids { + self.peer + .send(connection_id, proto::CallCanceled {}) + .trace_err(); + contacts_to_update.extend(store.user_id_for_connection(connection_id).ok()); + } + } + + for user_id in contacts_to_update { + self.update_user_contacts(user_id).await?; + } + + Ok(()) + } + + async fn call( + self: Arc, + request: TypedEnvelope, + response: Response, + ) -> Result<()> { + let caller_user_id = self + .store() + .await + .user_id_for_connection(request.sender_id)?; + let recipient_user_id = UserId::from_proto(request.payload.recipient_user_id); + let initial_project_id = request + .payload + .initial_project_id + .map(ProjectId::from_proto); + if !self + .app_state + .db + .has_contact(caller_user_id, recipient_user_id) + .await? + { + return Err(anyhow!("cannot call a user who isn't a contact"))?; + } + + let room_id = request.payload.room_id; + let mut calls = { + let mut store = self.store().await; + let (room, recipient_connection_ids, incoming_call) = store.call( + room_id, + recipient_user_id, + initial_project_id, + request.sender_id, + )?; + self.room_updated(room); + recipient_connection_ids + .into_iter() + .map(|recipient_connection_id| { + self.peer + .request(recipient_connection_id, incoming_call.clone()) + }) + .collect::>() + }; + self.update_user_contacts(recipient_user_id).await?; + + while let Some(call_response) = calls.next().await { + match call_response.as_ref() { + Ok(_) => { + response.send(proto::Ack {})?; + return Ok(()); + } + Err(_) => { + call_response.trace_err(); + } + } + } + + { + let mut store = self.store().await; + let room = store.call_failed(room_id, recipient_user_id)?; + self.room_updated(&room); + } + self.update_user_contacts(recipient_user_id).await?; + + Err(anyhow!("failed to ring call recipient"))? + } + + async fn cancel_call( + self: Arc, + request: TypedEnvelope, + response: Response, + ) -> Result<()> { + let recipient_user_id = UserId::from_proto(request.payload.recipient_user_id); + { + let mut store = self.store().await; + let (room, recipient_connection_ids) = store.cancel_call( + request.payload.room_id, + recipient_user_id, + request.sender_id, + )?; + for recipient_id in recipient_connection_ids { + self.peer + .send(recipient_id, proto::CallCanceled {}) + .trace_err(); + } + self.room_updated(room); + response.send(proto::Ack {})?; + } + self.update_user_contacts(recipient_user_id).await?; + Ok(()) + } + + async fn decline_call( + self: Arc, + message: TypedEnvelope, + ) -> Result<()> { + let recipient_user_id; + { + let mut store = self.store().await; + recipient_user_id = store.user_id_for_connection(message.sender_id)?; + let (room, recipient_connection_ids) = + store.decline_call(message.payload.room_id, message.sender_id)?; + for recipient_id in recipient_connection_ids { + self.peer + .send(recipient_id, proto::CallCanceled {}) + .trace_err(); + } + self.room_updated(room); + } + self.update_user_contacts(recipient_user_id).await?; + Ok(()) + } + + async fn update_participant_location( + self: Arc, + request: TypedEnvelope, + response: Response, + ) -> Result<()> { + let room_id = request.payload.room_id; + let location = request + .payload + .location + .ok_or_else(|| anyhow!("invalid location"))?; + let mut store = self.store().await; + let room = store.update_participant_location(room_id, location, request.sender_id)?; + self.room_updated(room); + response.send(proto::Ack {})?; + Ok(()) + } + + fn room_updated(&self, room: &proto::Room) { + for participant in &room.participants { + self.peer + .send( + ConnectionId(participant.peer_id), + proto::RoomUpdated { + room: Some(room.clone()), + }, + ) + .trace_err(); + } + } + + async fn share_project( + self: Arc, + request: TypedEnvelope, + response: Response, ) -> Result<()> { let user_id = self .store() .await .user_id_for_connection(request.sender_id)?; let project_id = self.app_state.db.register_project(user_id).await?; - self.store().await.register_project( - request.sender_id, + let mut store = self.store().await; + let room = store.share_project( + request.payload.room_id, project_id, - request.payload.online, + request.payload.worktrees, + request.sender_id, )?; - - response.send(proto::RegisterProjectResponse { + response.send(proto::ShareProjectResponse { project_id: project_id.to_proto(), })?; + self.room_updated(room); Ok(()) } - async fn unregister_project( + async fn unshare_project( self: Arc, - request: TypedEnvelope, - response: Response, + message: TypedEnvelope, ) -> Result<()> { - let project_id = ProjectId::from_proto(request.payload.project_id); - let (user_id, project) = { - let mut state = self.store().await; - let project = state.unregister_project(project_id, request.sender_id)?; - (state.user_id_for_connection(request.sender_id)?, project) - }; - self.app_state.db.unregister_project(project_id).await?; - + let project_id = ProjectId::from_proto(message.payload.project_id); + let mut store = self.store().await; + let (room, project) = store.unshare_project(project_id, message.sender_id)?; broadcast( - request.sender_id, - project.guests.keys().copied(), - |conn_id| { - self.peer.send( - conn_id, - proto::UnregisterProject { - project_id: project_id.to_proto(), - }, - ) - }, + message.sender_id, + project.guest_connection_ids(), + |conn_id| self.peer.send(conn_id, message.payload.clone()), ); - for (_, receipts) in project.join_requests { - for receipt in receipts { - self.peer.respond( - receipt, - proto::JoinProjectResponse { - variant: Some(proto::join_project_response::Variant::Decline( - proto::join_project_response::Decline { - reason: proto::join_project_response::decline::Reason::Closed - as i32, - }, - )), - }, - )?; - } - } - - // Send out the `UpdateContacts` message before responding to the unregister - // request. This way, when the project's host can keep track of the project's - // remote id until after they've received the `UpdateContacts` message for - // themself. - self.update_user_contacts(user_id).await?; - response.send(proto::Ack {})?; + self.room_updated(room); Ok(()) } @@ -716,176 +924,109 @@ impl Server { }; tracing::info!(%project_id, %host_user_id, %host_connection_id, "join project"); - let has_contact = self - .app_state - .db - .has_contact(guest_user_id, host_user_id) - .await?; - if !has_contact { - return Err(anyhow!("no such project"))?; + + let mut store = self.store().await; + let (project, replica_id) = store.join_project(request.sender_id, project_id)?; + let peer_count = project.guests.len(); + let mut collaborators = Vec::with_capacity(peer_count); + collaborators.push(proto::Collaborator { + peer_id: project.host_connection_id.0, + replica_id: 0, + user_id: project.host.user_id.to_proto(), + }); + let worktrees = project + .worktrees + .iter() + .map(|(id, worktree)| proto::WorktreeMetadata { + id: *id, + root_name: worktree.root_name.clone(), + visible: worktree.visible, + }) + .collect::>(); + + // Add all guests other than the requesting user's own connections as collaborators + for (guest_conn_id, guest) in &project.guests { + if request.sender_id != *guest_conn_id { + collaborators.push(proto::Collaborator { + peer_id: guest_conn_id.0, + replica_id: guest.replica_id as u32, + user_id: guest.user_id.to_proto(), + }); + } } - self.store().await.request_join_project( - guest_user_id, - project_id, - response.into_receipt(), - )?; - self.peer.send( - host_connection_id, - proto::RequestJoinProject { - project_id: project_id.to_proto(), - requester_id: guest_user_id.to_proto(), - }, - )?; - Ok(()) - } - - async fn respond_to_join_project_request( - self: Arc, - request: TypedEnvelope, - ) -> Result<()> { - let host_user_id; - - { - let mut state = self.store().await; - let project_id = ProjectId::from_proto(request.payload.project_id); - let project = state.project(project_id)?; - if project.host_connection_id != request.sender_id { - Err(anyhow!("no such connection"))?; - } - - host_user_id = project.host.user_id; - let guest_user_id = UserId::from_proto(request.payload.requester_id); - - if !request.payload.allow { - let receipts = state - .deny_join_project_request(request.sender_id, guest_user_id, project_id) - .ok_or_else(|| anyhow!("no such request"))?; - for receipt in receipts { - self.peer.respond( - receipt, - proto::JoinProjectResponse { - variant: Some(proto::join_project_response::Variant::Decline( - proto::join_project_response::Decline { - reason: proto::join_project_response::decline::Reason::Declined - as i32, - }, - )), - }, - )?; - } - return Ok(()); - } - - let (receipts_with_replica_ids, project) = state - .accept_join_project_request(request.sender_id, guest_user_id, project_id) - .ok_or_else(|| anyhow!("no such request"))?; - - let peer_count = project.guests.len(); - let mut collaborators = Vec::with_capacity(peer_count); - collaborators.push(proto::Collaborator { - peer_id: project.host_connection_id.0, - replica_id: 0, - user_id: project.host.user_id.to_proto(), - }); - let worktrees = project - .worktrees - .iter() - .map(|(id, worktree)| proto::WorktreeMetadata { - id: *id, - root_name: worktree.root_name.clone(), - visible: worktree.visible, - }) - .collect::>(); - - // Add all guests other than the requesting user's own connections as collaborators - for (guest_conn_id, guest) in &project.guests { - if receipts_with_replica_ids - .iter() - .all(|(receipt, _)| receipt.sender_id != *guest_conn_id) - { - collaborators.push(proto::Collaborator { - peer_id: guest_conn_id.0, - replica_id: guest.replica_id as u32, - user_id: guest.user_id.to_proto(), - }); - } - } - - for conn_id in project.connection_ids() { - for (receipt, replica_id) in &receipts_with_replica_ids { - if conn_id != receipt.sender_id { - self.peer.send( - conn_id, - proto::AddProjectCollaborator { - project_id: project_id.to_proto(), - collaborator: Some(proto::Collaborator { - peer_id: receipt.sender_id.0, - replica_id: *replica_id as u32, - user_id: guest_user_id.to_proto(), - }), - }, - )?; - } - } - } - - // First, we send the metadata associated with each worktree. - for (receipt, replica_id) in &receipts_with_replica_ids { - self.peer.respond( - *receipt, - proto::JoinProjectResponse { - variant: Some(proto::join_project_response::Variant::Accept( - proto::join_project_response::Accept { - worktrees: worktrees.clone(), - replica_id: *replica_id as u32, - collaborators: collaborators.clone(), - language_servers: project.language_servers.clone(), - }, - )), + for conn_id in project.connection_ids() { + if conn_id != request.sender_id { + self.peer.send( + conn_id, + proto::AddProjectCollaborator { + project_id: project_id.to_proto(), + collaborator: Some(proto::Collaborator { + peer_id: request.sender_id.0, + replica_id: replica_id as u32, + user_id: guest_user_id.to_proto(), + }), }, )?; } + } - for (worktree_id, worktree) in &project.worktrees { - #[cfg(any(test, feature = "test-support"))] - const MAX_CHUNK_SIZE: usize = 2; - #[cfg(not(any(test, feature = "test-support")))] - const MAX_CHUNK_SIZE: usize = 256; + // First, we send the metadata associated with each worktree. + response.send(proto::JoinProjectResponse { + worktrees: worktrees.clone(), + replica_id: replica_id as u32, + collaborators: collaborators.clone(), + language_servers: project.language_servers.clone(), + })?; - // Stream this worktree's entries. - let message = proto::UpdateWorktree { - project_id: project_id.to_proto(), - worktree_id: *worktree_id, - root_name: worktree.root_name.clone(), - updated_entries: worktree.entries.values().cloned().collect(), - removed_entries: Default::default(), - scan_id: worktree.scan_id, - is_last_update: worktree.is_complete, - }; - for update in proto::split_worktree_update(message, MAX_CHUNK_SIZE) { - for (receipt, _) in &receipts_with_replica_ids { - self.peer.send(receipt.sender_id, update.clone())?; - } - } + for (worktree_id, worktree) in &project.worktrees { + #[cfg(any(test, feature = "test-support"))] + const MAX_CHUNK_SIZE: usize = 2; + #[cfg(not(any(test, feature = "test-support")))] + const MAX_CHUNK_SIZE: usize = 256; - // Stream this worktree's diagnostics. - for summary in worktree.diagnostic_summaries.values() { - for (receipt, _) in &receipts_with_replica_ids { - self.peer.send( - receipt.sender_id, - proto::UpdateDiagnosticSummary { - project_id: project_id.to_proto(), - worktree_id: *worktree_id, - summary: Some(summary.clone()), - }, - )?; - } - } + // Stream this worktree's entries. + let message = proto::UpdateWorktree { + project_id: project_id.to_proto(), + worktree_id: *worktree_id, + root_name: worktree.root_name.clone(), + updated_entries: worktree.entries.values().cloned().collect(), + removed_entries: Default::default(), + scan_id: worktree.scan_id, + is_last_update: worktree.is_complete, + }; + for update in proto::split_worktree_update(message, MAX_CHUNK_SIZE) { + self.peer.send(request.sender_id, update.clone())?; + } + + // Stream this worktree's diagnostics. + for summary in worktree.diagnostic_summaries.values() { + self.peer.send( + request.sender_id, + proto::UpdateDiagnosticSummary { + project_id: project_id.to_proto(), + worktree_id: *worktree_id, + summary: Some(summary.clone()), + }, + )?; } } - self.update_user_contacts(host_user_id).await?; + for language_server in &project.language_servers { + self.peer.send( + request.sender_id, + proto::UpdateLanguageServer { + project_id: project_id.to_proto(), + language_server_id: language_server.id, + variant: Some( + proto::update_language_server::Variant::DiskBasedDiagnosticsUpdated( + proto::LspDiskBasedDiagnosticsUpdated {}, + ), + ), + }, + )?; + } + Ok(()) } @@ -898,7 +1039,7 @@ impl Server { let project; { let mut store = self.store().await; - project = store.leave_project(sender_id, project_id)?; + project = store.leave_project(project_id, sender_id)?; tracing::info!( %project_id, host_user_id = %project.host_user_id, @@ -917,27 +1058,8 @@ impl Server { ) }); } - - if let Some(requester_id) = project.cancel_request { - self.peer.send( - project.host_connection_id, - proto::JoinProjectRequestCancelled { - project_id: project_id.to_proto(), - requester_id: requester_id.to_proto(), - }, - )?; - } - - if project.unshare { - self.peer.send( - project.host_connection_id, - proto::ProjectUnshared { - project_id: project_id.to_proto(), - }, - )?; - } } - self.update_user_contacts(project.host_user_id).await?; + Ok(()) } @@ -946,61 +1068,20 @@ impl Server { request: TypedEnvelope, ) -> Result<()> { let project_id = ProjectId::from_proto(request.payload.project_id); - let user_id; { let mut state = self.store().await; - user_id = state.user_id_for_connection(request.sender_id)?; let guest_connection_ids = state .read_project(project_id, request.sender_id)? .guest_connection_ids(); - let unshared_project = state.update_project( - project_id, - &request.payload.worktrees, - request.payload.online, - request.sender_id, - )?; - - if let Some(unshared_project) = unshared_project { - broadcast( - request.sender_id, - unshared_project.guests.keys().copied(), - |conn_id| { - self.peer.send( - conn_id, - proto::UnregisterProject { - project_id: project_id.to_proto(), - }, - ) - }, - ); - for (_, receipts) in unshared_project.pending_join_requests { - for receipt in receipts { - self.peer.respond( - receipt, - proto::JoinProjectResponse { - variant: Some(proto::join_project_response::Variant::Decline( - proto::join_project_response::Decline { - reason: - proto::join_project_response::decline::Reason::Closed - as i32, - }, - )), - }, - )?; - } - } - } else { - broadcast(request.sender_id, guest_connection_ids, |connection_id| { - self.peer.forward_send( - request.sender_id, - connection_id, - request.payload.clone(), - ) - }); - } + let room = + state.update_project(project_id, &request.payload.worktrees, request.sender_id)?; + broadcast(request.sender_id, guest_connection_ids, |connection_id| { + self.peer + .forward_send(request.sender_id, connection_id, request.payload.clone()) + }); + self.room_updated(room); }; - self.update_user_contacts(user_id).await?; Ok(()) } @@ -1022,32 +1103,21 @@ impl Server { ) -> Result<()> { let project_id = ProjectId::from_proto(request.payload.project_id); let worktree_id = request.payload.worktree_id; - let (connection_ids, metadata_changed) = { - let mut store = self.store().await; - let (connection_ids, metadata_changed) = store.update_worktree( - request.sender_id, - project_id, - worktree_id, - &request.payload.root_name, - &request.payload.removed_entries, - &request.payload.updated_entries, - request.payload.scan_id, - request.payload.is_last_update, - )?; - (connection_ids, metadata_changed) - }; + let connection_ids = self.store().await.update_worktree( + request.sender_id, + project_id, + worktree_id, + &request.payload.root_name, + &request.payload.removed_entries, + &request.payload.updated_entries, + request.payload.scan_id, + request.payload.is_last_update, + )?; broadcast(request.sender_id, connection_ids, |connection_id| { self.peer .forward_send(request.sender_id, connection_id, request.payload.clone()) }); - if metadata_changed { - let user_id = self - .store() - .await - .user_id_for_connection(request.sender_id)?; - self.update_user_contacts(user_id).await?; - } response.send(proto::Ack {})?; Ok(()) } @@ -1401,7 +1471,7 @@ impl Server { let users = match query.len() { 0 => vec![], 1 | 2 => db - .get_user_by_github_login(&query) + .get_user_by_github_account(&query, None) .await? .into_iter() .collect(), @@ -1724,6 +1794,44 @@ impl Server { Ok(()) } + async fn update_diff_base( + self: Arc, + request: TypedEnvelope, + ) -> Result<()> { + let receiver_ids = self.store().await.project_connection_ids( + ProjectId::from_proto(request.payload.project_id), + request.sender_id, + )?; + broadcast(request.sender_id, receiver_ids, |connection_id| { + self.peer + .forward_send(request.sender_id, connection_id, request.payload.clone()) + }); + Ok(()) + } + + async fn get_private_user_info( + self: Arc, + request: TypedEnvelope, + response: Response, + ) -> Result<()> { + let user_id = self + .store() + .await + .user_id_for_connection(request.sender_id)?; + let metrics_id = self.app_state.db.get_user_metrics_id(user_id).await?; + let user = self + .app_state + .db + .get_user_by_id(user_id) + .await? + .ok_or_else(|| anyhow!("user not found"))?; + response.send(proto::GetPrivateUserInfoResponse { + metrics_id, + staff: user.admin, + })?; + Ok(()) + } + pub(crate) async fn store(&self) -> StoreGuard<'_> { #[cfg(test)] tokio::task::yield_now().await; diff --git a/crates/collab/src/rpc/store.rs b/crates/collab/src/rpc/store.rs index fe18e0404b..b7dd39cff1 100644 --- a/crates/collab/src/rpc/store.rs +++ b/crates/collab/src/rpc/store.rs @@ -1,38 +1,55 @@ use crate::db::{self, ChannelId, ProjectId, UserId}; use anyhow::{anyhow, Result}; -use collections::{btree_map, hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet}; -use rpc::{proto, ConnectionId, Receipt}; +use collections::{btree_map, BTreeMap, BTreeSet, HashMap, HashSet}; +use rpc::{proto, ConnectionId}; use serde::Serialize; use std::{mem, path::PathBuf, str, time::Duration}; use time::OffsetDateTime; use tracing::instrument; +use util::post_inc; + +pub type RoomId = u64; #[derive(Default, Serialize)] pub struct Store { connections: BTreeMap, - connections_by_user_id: BTreeMap>, + connected_users: BTreeMap, + next_room_id: RoomId, + rooms: BTreeMap, projects: BTreeMap, #[serde(skip)] channels: BTreeMap, } +#[derive(Default, Serialize)] +struct ConnectedUser { + connection_ids: HashSet, + active_call: Option, +} + #[derive(Serialize)] struct ConnectionState { user_id: UserId, admin: bool, projects: BTreeSet, - requested_projects: HashSet, channels: HashSet, } +#[derive(Copy, Clone, Eq, PartialEq, Serialize)] +pub struct Call { + pub caller_user_id: UserId, + pub room_id: RoomId, + pub connection_id: Option, + pub initial_project_id: Option, +} + #[derive(Serialize)] pub struct Project { - pub online: bool, + pub id: ProjectId, + pub room_id: RoomId, pub host_connection_id: ConnectionId, pub host: Collaborator, pub guests: HashMap, - #[serde(skip)] - pub join_requests: HashMap>>, pub active_replica_ids: HashSet, pub worktrees: BTreeMap, pub language_servers: Vec, @@ -69,23 +86,26 @@ pub type ReplicaId = u16; #[derive(Default)] pub struct RemovedConnectionState { pub user_id: UserId, - pub hosted_projects: HashMap, - pub guest_project_ids: HashSet, + pub hosted_projects: Vec, + pub guest_projects: Vec, pub contact_ids: HashSet, + pub room_id: Option, + pub canceled_call_connection_ids: Vec, } pub struct LeftProject { + pub id: ProjectId, pub host_user_id: UserId, pub host_connection_id: ConnectionId, pub connection_ids: Vec, pub remove_collaborator: bool, - pub cancel_request: Option, - pub unshare: bool, } -pub struct UnsharedProject { - pub guests: HashMap, - pub pending_join_requests: HashMap>>, +pub struct LeftRoom<'a> { + pub room: Option<&'a proto::Room>, + pub unshared_projects: Vec, + pub left_projects: Vec, + pub canceled_call_connection_ids: Vec, } #[derive(Copy, Clone)] @@ -128,21 +148,44 @@ impl Store { } #[instrument(skip(self))] - pub fn add_connection(&mut self, connection_id: ConnectionId, user_id: UserId, admin: bool) { + pub fn add_connection( + &mut self, + connection_id: ConnectionId, + user_id: UserId, + admin: bool, + ) -> Option { self.connections.insert( connection_id, ConnectionState { user_id, admin, projects: Default::default(), - requested_projects: Default::default(), channels: Default::default(), }, ); - self.connections_by_user_id - .entry(user_id) - .or_default() - .insert(connection_id); + let connected_user = self.connected_users.entry(user_id).or_default(); + connected_user.connection_ids.insert(connection_id); + if let Some(active_call) = connected_user.active_call { + if active_call.connection_id.is_some() { + None + } else { + let room = self.room(active_call.room_id)?; + Some(proto::IncomingCall { + room_id: active_call.room_id, + caller_user_id: active_call.caller_user_id.to_proto(), + participant_user_ids: room + .participants + .iter() + .map(|participant| participant.user_id) + .collect(), + initial_project: active_call + .initial_project_id + .and_then(|id| Self::build_participant_project(id, &self.projects)), + }) + } + } else { + None + } } #[instrument(skip(self))] @@ -156,7 +199,6 @@ impl Store { .ok_or_else(|| anyhow!("no such connection"))?; let user_id = connection.user_id; - let connection_projects = mem::take(&mut connection.projects); let connection_channels = mem::take(&mut connection.channels); let mut result = RemovedConnectionState { @@ -169,21 +211,21 @@ impl Store { self.leave_channel(connection_id, channel_id); } - // Unregister and leave all projects. - for project_id in connection_projects { - if let Ok(project) = self.unregister_project(project_id, connection_id) { - result.hosted_projects.insert(project_id, project); - } else if self.leave_project(connection_id, project_id).is_ok() { - result.guest_project_ids.insert(project_id); - } + let connected_user = self.connected_users.get(&user_id).unwrap(); + if let Some(active_call) = connected_user.active_call.as_ref() { + let room_id = active_call.room_id; + let left_room = self.leave_room(room_id, connection_id)?; + result.hosted_projects = left_room.unshared_projects; + result.guest_projects = left_room.left_projects; + result.room_id = Some(room_id); + result.canceled_call_connection_ids = left_room.canceled_call_connection_ids; } - let user_connections = self.connections_by_user_id.get_mut(&user_id).unwrap(); - user_connections.remove(&connection_id); - if user_connections.is_empty() { - self.connections_by_user_id.remove(&user_id); + let connected_user = self.connected_users.get_mut(&user_id).unwrap(); + connected_user.connection_ids.remove(&connection_id); + if connected_user.connection_ids.is_empty() { + self.connected_users.remove(&user_id); } - self.connections.remove(&connection_id).unwrap(); Ok(result) @@ -229,21 +271,31 @@ impl Store { &self, user_id: UserId, ) -> impl Iterator + '_ { - self.connections_by_user_id + self.connected_users .get(&user_id) .into_iter() + .map(|state| &state.connection_ids) .flatten() .copied() } pub fn is_user_online(&self, user_id: UserId) -> bool { !self - .connections_by_user_id + .connected_users .get(&user_id) .unwrap_or(&Default::default()) + .connection_ids .is_empty() } + fn is_user_busy(&self, user_id: UserId) -> bool { + self.connected_users + .get(&user_id) + .unwrap_or(&Default::default()) + .active_call + .is_some() + } + pub fn build_initial_contacts_update( &self, contacts: Vec, @@ -281,61 +333,407 @@ impl Store { pub fn contact_for_user(&self, user_id: UserId, should_notify: bool) -> proto::Contact { proto::Contact { user_id: user_id.to_proto(), - projects: self.project_metadata_for_user(user_id), online: self.is_user_online(user_id), + busy: self.is_user_busy(user_id), should_notify, } } - pub fn project_metadata_for_user(&self, user_id: UserId) -> Vec { - let connection_ids = self.connections_by_user_id.get(&user_id); - let project_ids = connection_ids.iter().flat_map(|connection_ids| { - connection_ids - .iter() - .filter_map(|connection_id| self.connections.get(connection_id)) - .flat_map(|connection| connection.projects.iter().copied()) + pub fn create_room(&mut self, creator_connection_id: ConnectionId) -> Result { + let connection = self + .connections + .get_mut(&creator_connection_id) + .ok_or_else(|| anyhow!("no such connection"))?; + let connected_user = self + .connected_users + .get_mut(&connection.user_id) + .ok_or_else(|| anyhow!("no such connection"))?; + anyhow::ensure!( + connected_user.active_call.is_none(), + "can't create a room with an active call" + ); + + let mut room = proto::Room::default(); + room.participants.push(proto::Participant { + user_id: connection.user_id.to_proto(), + peer_id: creator_connection_id.0, + projects: Default::default(), + location: Some(proto::ParticipantLocation { + variant: Some(proto::participant_location::Variant::External( + proto::participant_location::External {}, + )), + }), }); - let mut metadata = Vec::new(); - for project_id in project_ids { - if let Some(project) = self.projects.get(&project_id) { - if project.host.user_id == user_id && project.online { - metadata.push(proto::ProjectMetadata { - id: project_id.to_proto(), - visible_worktree_root_names: project - .worktrees - .values() - .filter(|worktree| worktree.visible) - .map(|worktree| worktree.root_name.clone()) - .collect(), - guests: project - .guests - .values() - .map(|guest| guest.user_id.to_proto()) - .collect(), - }); - } - } - } - - metadata + let room_id = post_inc(&mut self.next_room_id); + self.rooms.insert(room_id, room); + connected_user.active_call = Some(Call { + caller_user_id: connection.user_id, + room_id, + connection_id: Some(creator_connection_id), + initial_project_id: None, + }); + Ok(room_id) } - pub fn register_project( + pub fn join_room( &mut self, - host_connection_id: ConnectionId, + room_id: RoomId, + connection_id: ConnectionId, + ) -> Result<(&proto::Room, Vec)> { + let connection = self + .connections + .get_mut(&connection_id) + .ok_or_else(|| anyhow!("no such connection"))?; + let user_id = connection.user_id; + let recipient_connection_ids = self.connection_ids_for_user(user_id).collect::>(); + + let connected_user = self + .connected_users + .get_mut(&user_id) + .ok_or_else(|| anyhow!("no such connection"))?; + let active_call = connected_user + .active_call + .as_mut() + .ok_or_else(|| anyhow!("not being called"))?; + anyhow::ensure!( + active_call.room_id == room_id && active_call.connection_id.is_none(), + "not being called on this room" + ); + + let room = self + .rooms + .get_mut(&room_id) + .ok_or_else(|| anyhow!("no such room"))?; + anyhow::ensure!( + room.pending_participant_user_ids + .contains(&user_id.to_proto()), + anyhow!("no such room") + ); + room.pending_participant_user_ids + .retain(|pending| *pending != user_id.to_proto()); + room.participants.push(proto::Participant { + user_id: user_id.to_proto(), + peer_id: connection_id.0, + projects: Default::default(), + location: Some(proto::ParticipantLocation { + variant: Some(proto::participant_location::Variant::External( + proto::participant_location::External {}, + )), + }), + }); + active_call.connection_id = Some(connection_id); + + Ok((room, recipient_connection_ids)) + } + + pub fn leave_room(&mut self, room_id: RoomId, connection_id: ConnectionId) -> Result { + let connection = self + .connections + .get_mut(&connection_id) + .ok_or_else(|| anyhow!("no such connection"))?; + let user_id = connection.user_id; + + let connected_user = self + .connected_users + .get(&user_id) + .ok_or_else(|| anyhow!("no such connection"))?; + anyhow::ensure!( + connected_user + .active_call + .map_or(false, |call| call.room_id == room_id + && call.connection_id == Some(connection_id)), + "cannot leave a room before joining it" + ); + + // Given that users can only join one room at a time, we can safely unshare + // and leave all projects associated with the connection. + let mut unshared_projects = Vec::new(); + let mut left_projects = Vec::new(); + for project_id in connection.projects.clone() { + if let Ok((_, project)) = self.unshare_project(project_id, connection_id) { + unshared_projects.push(project); + } else if let Ok(project) = self.leave_project(project_id, connection_id) { + left_projects.push(project); + } + } + self.connected_users.get_mut(&user_id).unwrap().active_call = None; + + let room = self + .rooms + .get_mut(&room_id) + .ok_or_else(|| anyhow!("no such room"))?; + room.participants + .retain(|participant| participant.peer_id != connection_id.0); + + let mut canceled_call_connection_ids = Vec::new(); + room.pending_participant_user_ids + .retain(|pending_participant_user_id| { + if let Some(connected_user) = self + .connected_users + .get_mut(&UserId::from_proto(*pending_participant_user_id)) + { + if let Some(call) = connected_user.active_call.as_ref() { + if call.caller_user_id == user_id { + connected_user.active_call.take(); + canceled_call_connection_ids + .extend(connected_user.connection_ids.iter().copied()); + false + } else { + true + } + } else { + true + } + } else { + true + } + }); + + if room.participants.is_empty() && room.pending_participant_user_ids.is_empty() { + self.rooms.remove(&room_id); + } + + Ok(LeftRoom { + room: self.rooms.get(&room_id), + unshared_projects, + left_projects, + canceled_call_connection_ids, + }) + } + + pub fn room(&self, room_id: RoomId) -> Option<&proto::Room> { + self.rooms.get(&room_id) + } + + pub fn call( + &mut self, + room_id: RoomId, + recipient_user_id: UserId, + initial_project_id: Option, + from_connection_id: ConnectionId, + ) -> Result<(&proto::Room, Vec, proto::IncomingCall)> { + let caller_user_id = self.user_id_for_connection(from_connection_id)?; + + let recipient_connection_ids = self + .connection_ids_for_user(recipient_user_id) + .collect::>(); + let mut recipient = self + .connected_users + .get_mut(&recipient_user_id) + .ok_or_else(|| anyhow!("no such connection"))?; + anyhow::ensure!( + recipient.active_call.is_none(), + "recipient is already on another call" + ); + + let room = self + .rooms + .get_mut(&room_id) + .ok_or_else(|| anyhow!("no such room"))?; + anyhow::ensure!( + room.participants + .iter() + .any(|participant| participant.peer_id == from_connection_id.0), + "no such room" + ); + anyhow::ensure!( + room.pending_participant_user_ids + .iter() + .all(|user_id| UserId::from_proto(*user_id) != recipient_user_id), + "cannot call the same user more than once" + ); + room.pending_participant_user_ids + .push(recipient_user_id.to_proto()); + + if let Some(initial_project_id) = initial_project_id { + let project = self + .projects + .get(&initial_project_id) + .ok_or_else(|| anyhow!("no such project"))?; + anyhow::ensure!(project.room_id == room_id, "no such project"); + } + + recipient.active_call = Some(Call { + caller_user_id, + room_id, + connection_id: None, + initial_project_id, + }); + + Ok(( + room, + recipient_connection_ids, + proto::IncomingCall { + room_id, + caller_user_id: caller_user_id.to_proto(), + participant_user_ids: room + .participants + .iter() + .map(|participant| participant.user_id) + .collect(), + initial_project: initial_project_id + .and_then(|id| Self::build_participant_project(id, &self.projects)), + }, + )) + } + + pub fn call_failed(&mut self, room_id: RoomId, to_user_id: UserId) -> Result<&proto::Room> { + let mut recipient = self + .connected_users + .get_mut(&to_user_id) + .ok_or_else(|| anyhow!("no such connection"))?; + anyhow::ensure!(recipient + .active_call + .map_or(false, |call| call.room_id == room_id + && call.connection_id.is_none())); + recipient.active_call = None; + let room = self + .rooms + .get_mut(&room_id) + .ok_or_else(|| anyhow!("no such room"))?; + room.pending_participant_user_ids + .retain(|user_id| UserId::from_proto(*user_id) != to_user_id); + Ok(room) + } + + pub fn cancel_call( + &mut self, + room_id: RoomId, + recipient_user_id: UserId, + canceller_connection_id: ConnectionId, + ) -> Result<(&proto::Room, HashSet)> { + let canceller_user_id = self.user_id_for_connection(canceller_connection_id)?; + let canceller = self + .connected_users + .get(&canceller_user_id) + .ok_or_else(|| anyhow!("no such connection"))?; + let recipient = self + .connected_users + .get(&recipient_user_id) + .ok_or_else(|| anyhow!("no such connection"))?; + let canceller_active_call = canceller + .active_call + .as_ref() + .ok_or_else(|| anyhow!("no active call"))?; + let recipient_active_call = recipient + .active_call + .as_ref() + .ok_or_else(|| anyhow!("no active call for recipient"))?; + + anyhow::ensure!( + canceller_active_call.room_id == room_id, + "users are on different calls" + ); + anyhow::ensure!( + recipient_active_call.room_id == room_id, + "users are on different calls" + ); + anyhow::ensure!( + recipient_active_call.connection_id.is_none(), + "recipient has already answered" + ); + let room_id = recipient_active_call.room_id; + let room = self + .rooms + .get_mut(&room_id) + .ok_or_else(|| anyhow!("no such room"))?; + room.pending_participant_user_ids + .retain(|user_id| UserId::from_proto(*user_id) != recipient_user_id); + + let recipient = self.connected_users.get_mut(&recipient_user_id).unwrap(); + recipient.active_call.take(); + + Ok((room, recipient.connection_ids.clone())) + } + + pub fn decline_call( + &mut self, + room_id: RoomId, + recipient_connection_id: ConnectionId, + ) -> Result<(&proto::Room, Vec)> { + let recipient_user_id = self.user_id_for_connection(recipient_connection_id)?; + let recipient = self + .connected_users + .get_mut(&recipient_user_id) + .ok_or_else(|| anyhow!("no such connection"))?; + if let Some(active_call) = recipient.active_call.take() { + anyhow::ensure!(active_call.room_id == room_id, "no such room"); + let recipient_connection_ids = self + .connection_ids_for_user(recipient_user_id) + .collect::>(); + let room = self + .rooms + .get_mut(&active_call.room_id) + .ok_or_else(|| anyhow!("no such room"))?; + room.pending_participant_user_ids + .retain(|user_id| UserId::from_proto(*user_id) != recipient_user_id); + Ok((room, recipient_connection_ids)) + } else { + Err(anyhow!("user is not being called")) + } + } + + pub fn update_participant_location( + &mut self, + room_id: RoomId, + location: proto::ParticipantLocation, + connection_id: ConnectionId, + ) -> Result<&proto::Room> { + let room = self + .rooms + .get_mut(&room_id) + .ok_or_else(|| anyhow!("no such room"))?; + if let Some(proto::participant_location::Variant::SharedProject(project)) = + location.variant.as_ref() + { + anyhow::ensure!( + room.participants + .iter() + .flat_map(|participant| &participant.projects) + .any(|participant_project| participant_project.id == project.id), + "no such project" + ); + } + + let participant = room + .participants + .iter_mut() + .find(|participant| participant.peer_id == connection_id.0) + .ok_or_else(|| anyhow!("no such room"))?; + participant.location = Some(location); + + Ok(room) + } + + pub fn share_project( + &mut self, + room_id: RoomId, project_id: ProjectId, - online: bool, - ) -> Result<()> { + worktrees: Vec, + host_connection_id: ConnectionId, + ) -> Result<&proto::Room> { let connection = self .connections .get_mut(&host_connection_id) .ok_or_else(|| anyhow!("no such connection"))?; + + let room = self + .rooms + .get_mut(&room_id) + .ok_or_else(|| anyhow!("no such room"))?; + let participant = room + .participants + .iter_mut() + .find(|participant| participant.peer_id == host_connection_id.0) + .ok_or_else(|| anyhow!("no such room"))?; + connection.projects.insert(project_id); self.projects.insert( project_id, Project { - online, + id: project_id, + room_id, host_connection_id, host: Collaborator { user_id: connection.user_id, @@ -344,22 +742,79 @@ impl Store { admin: connection.admin, }, guests: Default::default(), - join_requests: Default::default(), active_replica_ids: Default::default(), - worktrees: Default::default(), + worktrees: worktrees + .into_iter() + .map(|worktree| { + ( + worktree.id, + Worktree { + root_name: worktree.root_name, + visible: worktree.visible, + ..Default::default() + }, + ) + }) + .collect(), language_servers: Default::default(), }, ); - Ok(()) + + participant + .projects + .extend(Self::build_participant_project(project_id, &self.projects)); + + Ok(room) + } + + pub fn unshare_project( + &mut self, + project_id: ProjectId, + connection_id: ConnectionId, + ) -> Result<(&proto::Room, Project)> { + match self.projects.entry(project_id) { + btree_map::Entry::Occupied(e) => { + if e.get().host_connection_id == connection_id { + let project = e.remove(); + + if let Some(host_connection) = self.connections.get_mut(&connection_id) { + host_connection.projects.remove(&project_id); + } + + for guest_connection in project.guests.keys() { + if let Some(connection) = self.connections.get_mut(guest_connection) { + connection.projects.remove(&project_id); + } + } + + let room = self + .rooms + .get_mut(&project.room_id) + .ok_or_else(|| anyhow!("no such room"))?; + let participant = room + .participants + .iter_mut() + .find(|participant| participant.peer_id == connection_id.0) + .ok_or_else(|| anyhow!("no such room"))?; + participant + .projects + .retain(|project| project.id != project_id.to_proto()); + + Ok((room, project)) + } else { + Err(anyhow!("no such project"))? + } + } + btree_map::Entry::Vacant(_) => Err(anyhow!("no such project"))?, + } } pub fn update_project( &mut self, project_id: ProjectId, worktrees: &[proto::WorktreeMetadata], - online: bool, connection_id: ConnectionId, - ) -> Result> { + ) -> Result<&proto::Room> { let project = self .projects .get_mut(&project_id) @@ -381,80 +836,28 @@ impl Store { } } - if online != project.online { - project.online = online; - if project.online { - Ok(None) - } else { - for connection_id in project.guest_connection_ids() { - if let Some(connection) = self.connections.get_mut(&connection_id) { - connection.projects.remove(&project_id); - } - } + let room = self + .rooms + .get_mut(&project.room_id) + .ok_or_else(|| anyhow!("no such room"))?; + let participant_project = room + .participants + .iter_mut() + .flat_map(|participant| &mut participant.projects) + .find(|project| project.id == project_id.to_proto()) + .ok_or_else(|| anyhow!("no such project"))?; + participant_project.worktree_root_names = worktrees + .iter() + .filter(|worktree| worktree.visible) + .map(|worktree| worktree.root_name.clone()) + .collect(); - project.active_replica_ids.clear(); - project.language_servers.clear(); - for worktree in project.worktrees.values_mut() { - worktree.diagnostic_summaries.clear(); - worktree.entries.clear(); - } - - Ok(Some(UnsharedProject { - guests: mem::take(&mut project.guests), - pending_join_requests: mem::take(&mut project.join_requests), - })) - } - } else { - Ok(None) - } + Ok(room) } else { Err(anyhow!("no such project"))? } } - pub fn unregister_project( - &mut self, - project_id: ProjectId, - connection_id: ConnectionId, - ) -> Result { - match self.projects.entry(project_id) { - btree_map::Entry::Occupied(e) => { - if e.get().host_connection_id == connection_id { - let project = e.remove(); - - if let Some(host_connection) = self.connections.get_mut(&connection_id) { - host_connection.projects.remove(&project_id); - } - - for guest_connection in project.guests.keys() { - if let Some(connection) = self.connections.get_mut(guest_connection) { - connection.projects.remove(&project_id); - } - } - - for requester_user_id in project.join_requests.keys() { - if let Some(requester_connection_ids) = - self.connections_by_user_id.get_mut(requester_user_id) - { - for requester_connection_id in requester_connection_ids.iter() { - if let Some(requester_connection) = - self.connections.get_mut(requester_connection_id) - { - requester_connection.requested_projects.remove(&project_id); - } - } - } - } - - Ok(project) - } else { - Err(anyhow!("no such project"))? - } - } - btree_map::Entry::Vacant(_) => Err(anyhow!("no such project"))?, - } - } - pub fn update_diagnostic_summary( &mut self, project_id: ProjectId, @@ -498,99 +901,56 @@ impl Store { Err(anyhow!("no such project"))? } - pub fn request_join_project( + pub fn join_project( &mut self, - requester_id: UserId, + requester_connection_id: ConnectionId, project_id: ProjectId, - receipt: Receipt, - ) -> Result<()> { + ) -> Result<(&Project, ReplicaId)> { let connection = self .connections - .get_mut(&receipt.sender_id) + .get_mut(&requester_connection_id) .ok_or_else(|| anyhow!("no such connection"))?; + let user = self + .connected_users + .get(&connection.user_id) + .ok_or_else(|| anyhow!("no such connection"))?; + let active_call = user.active_call.ok_or_else(|| anyhow!("no such project"))?; + anyhow::ensure!( + active_call.connection_id == Some(requester_connection_id), + "no such project" + ); + let project = self .projects .get_mut(&project_id) .ok_or_else(|| anyhow!("no such project"))?; - if project.online { - connection.requested_projects.insert(project_id); - project - .join_requests - .entry(requester_id) - .or_default() - .push(receipt); - Ok(()) - } else { - Err(anyhow!("no such project")) - } - } + anyhow::ensure!(project.room_id == active_call.room_id, "no such project"); - pub fn deny_join_project_request( - &mut self, - responder_connection_id: ConnectionId, - requester_id: UserId, - project_id: ProjectId, - ) -> Option>> { - let project = self.projects.get_mut(&project_id)?; - if responder_connection_id != project.host_connection_id { - return None; - } - - let receipts = project.join_requests.remove(&requester_id)?; - for receipt in &receipts { - let requester_connection = self.connections.get_mut(&receipt.sender_id)?; - requester_connection.requested_projects.remove(&project_id); - } - project.host.last_activity = Some(OffsetDateTime::now_utc()); - - Some(receipts) - } - - #[allow(clippy::type_complexity)] - pub fn accept_join_project_request( - &mut self, - responder_connection_id: ConnectionId, - requester_id: UserId, - project_id: ProjectId, - ) -> Option<(Vec<(Receipt, ReplicaId)>, &Project)> { - let project = self.projects.get_mut(&project_id)?; - if responder_connection_id != project.host_connection_id { - return None; - } - - let receipts = project.join_requests.remove(&requester_id)?; - let mut receipts_with_replica_ids = Vec::new(); - for receipt in receipts { - let requester_connection = self.connections.get_mut(&receipt.sender_id)?; - requester_connection.requested_projects.remove(&project_id); - requester_connection.projects.insert(project_id); - let mut replica_id = 1; - while project.active_replica_ids.contains(&replica_id) { - replica_id += 1; - } - project.active_replica_ids.insert(replica_id); - project.guests.insert( - receipt.sender_id, - Collaborator { - replica_id, - user_id: requester_id, - last_activity: Some(OffsetDateTime::now_utc()), - admin: requester_connection.admin, - }, - ); - receipts_with_replica_ids.push((receipt, replica_id)); + connection.projects.insert(project_id); + let mut replica_id = 1; + while project.active_replica_ids.contains(&replica_id) { + replica_id += 1; } + project.active_replica_ids.insert(replica_id); + project.guests.insert( + requester_connection_id, + Collaborator { + replica_id, + user_id: connection.user_id, + last_activity: Some(OffsetDateTime::now_utc()), + admin: connection.admin, + }, + ); project.host.last_activity = Some(OffsetDateTime::now_utc()); - Some((receipts_with_replica_ids, project)) + Ok((project, replica_id)) } pub fn leave_project( &mut self, - connection_id: ConnectionId, project_id: ProjectId, + connection_id: ConnectionId, ) -> Result { - let user_id = self.user_id_for_connection(connection_id)?; let project = self .projects .get_mut(&project_id) @@ -604,39 +964,15 @@ impl Store { false }; - // If the connection leaving the project has a pending request, remove it. - // If that user has no other pending requests on other connections, indicate that the request should be cancelled. - let mut cancel_request = None; - if let Entry::Occupied(mut entry) = project.join_requests.entry(user_id) { - entry - .get_mut() - .retain(|receipt| receipt.sender_id != connection_id); - if entry.get().is_empty() { - entry.remove(); - cancel_request = Some(user_id); - } - } - if let Some(connection) = self.connections.get_mut(&connection_id) { connection.projects.remove(&project_id); } - let connection_ids = project.connection_ids(); - let unshare = connection_ids.len() <= 1 && project.join_requests.is_empty(); - if unshare { - project.language_servers.clear(); - for worktree in project.worktrees.values_mut() { - worktree.diagnostic_summaries.clear(); - worktree.entries.clear(); - } - } - Ok(LeftProject { + id: project.id, host_connection_id: project.host_connection_id, host_user_id: project.host.user_id, - connection_ids, - cancel_request, - unshare, + connection_ids: project.connection_ids(), remove_collaborator, }) } @@ -652,15 +988,11 @@ impl Store { updated_entries: &[proto::Entry], scan_id: u64, is_last_update: bool, - ) -> Result<(Vec, bool)> { + ) -> Result> { let project = self.write_project(project_id, connection_id)?; - if !project.online { - return Err(anyhow!("project is not online")); - } let connection_ids = project.connection_ids(); let mut worktree = project.worktrees.entry(worktree_id).or_default(); - let metadata_changed = worktree_root_name != worktree.root_name; worktree.root_name = worktree_root_name.to_string(); for entry_id in removed_entries { @@ -673,7 +1005,23 @@ impl Store { worktree.scan_id = scan_id; worktree.is_complete = is_last_update; - Ok((connection_ids, metadata_changed)) + Ok(connection_ids) + } + + fn build_participant_project( + project_id: ProjectId, + projects: &BTreeMap, + ) -> Option { + Some(proto::ParticipantProject { + id: project_id.to_proto(), + worktree_root_names: projects + .get(&project_id)? + .worktrees + .values() + .filter(|worktree| worktree.visible) + .map(|worktree| worktree.root_name.clone()) + .collect(), + }) } pub fn project_connection_ids( @@ -789,19 +1137,64 @@ impl Store { assert!(channel.connection_ids.contains(connection_id)); } assert!(self - .connections_by_user_id + .connected_users .get(&connection.user_id) .unwrap() + .connection_ids .contains(connection_id)); } - for (user_id, connection_ids) in &self.connections_by_user_id { - for connection_id in connection_ids { + for (user_id, state) in &self.connected_users { + for connection_id in &state.connection_ids { assert_eq!( self.connections.get(connection_id).unwrap().user_id, *user_id ); } + + if let Some(active_call) = state.active_call.as_ref() { + if let Some(active_call_connection_id) = active_call.connection_id { + assert!( + state.connection_ids.contains(&active_call_connection_id), + "call is active on a dead connection" + ); + assert!( + state.connection_ids.contains(&active_call_connection_id), + "call is active on a dead connection" + ); + } + } + } + + for (room_id, room) in &self.rooms { + for pending_user_id in &room.pending_participant_user_ids { + assert!( + self.connected_users + .contains_key(&UserId::from_proto(*pending_user_id)), + "call is active on a user that has disconnected" + ); + } + + for participant in &room.participants { + assert!( + self.connections + .contains_key(&ConnectionId(participant.peer_id)), + "room contains participant that has disconnected" + ); + + for participant_project in &participant.projects { + let project = &self.projects[&ProjectId::from_proto(participant_project.id)]; + assert_eq!( + project.room_id, *room_id, + "project was shared on a different room" + ); + } + } + + assert!( + !room.pending_participant_user_ids.is_empty() || !room.participants.is_empty(), + "room can't be empty" + ); } for (project_id, project) in &self.projects { @@ -812,7 +1205,7 @@ impl Store { let guest_connection = self.connections.get(guest_connection_id).unwrap(); assert!(guest_connection.projects.contains(project_id)); } - assert_eq!(project.active_replica_ids.len(), project.guests.len(),); + assert_eq!(project.active_replica_ids.len(), project.guests.len()); assert_eq!( project.active_replica_ids, project @@ -821,6 +1214,20 @@ impl Store { .map(|guest| guest.replica_id) .collect::>(), ); + + let room = &self.rooms[&project.room_id]; + let room_participant = room + .participants + .iter() + .find(|participant| participant.peer_id == project.host_connection_id.0) + .unwrap(); + assert!( + room_participant + .projects + .iter() + .any(|project| project.id == project_id.to_proto()), + "project was not shared in room" + ); } for (channel_id, channel) in &self.channels { diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml new file mode 100644 index 0000000000..20db066ce7 --- /dev/null +++ b/crates/collab_ui/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "collab_ui" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/collab_ui.rs" +doctest = false + +[features] +test-support = [ + "call/test-support", + "client/test-support", + "collections/test-support", + "editor/test-support", + "gpui/test-support", + "project/test-support", + "settings/test-support", + "util/test-support", + "workspace/test-support", +] + +[dependencies] +call = { path = "../call" } +client = { path = "../client" } +clock = { path = "../clock" } +collections = { path = "../collections" } +editor = { path = "../editor" } +fuzzy = { path = "../fuzzy" } +gpui = { path = "../gpui" } +menu = { path = "../menu" } +picker = { path = "../picker" } +project = { path = "../project" } +settings = { path = "../settings" } +theme = { path = "../theme" } +util = { path = "../util" } +workspace = { path = "../workspace" } +anyhow = "1.0" +futures = "0.3" +log = "0.4" +postage = { version = "0.4.1", features = ["futures-traits"] } +serde = { version = "1.0", features = ["derive", "rc"] } + +[dev-dependencies] +call = { path = "../call", features = ["test-support"] } +client = { path = "../client", features = ["test-support"] } +collections = { path = "../collections", features = ["test-support"] } +editor = { path = "../editor", features = ["test-support"] } +gpui = { path = "../gpui", features = ["test-support"] } +project = { path = "../project", features = ["test-support"] } +settings = { path = "../settings", features = ["test-support"] } +util = { path = "../util", features = ["test-support"] } +workspace = { path = "../workspace", features = ["test-support"] } diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs new file mode 100644 index 0000000000..702d8a9121 --- /dev/null +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -0,0 +1,566 @@ +use crate::{contact_notification::ContactNotification, contacts_popover}; +use call::{ActiveCall, ParticipantLocation}; +use client::{Authenticate, ContactEventKind, PeerId, User, UserStore}; +use clock::ReplicaId; +use contacts_popover::ContactsPopover; +use gpui::{ + actions, + color::Color, + elements::*, + geometry::{rect::RectF, vector::vec2f, PathBuilder}, + json::{self, ToJson}, + Border, CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext, + Subscription, View, ViewContext, ViewHandle, WeakViewHandle, +}; +use settings::Settings; +use std::ops::Range; +use theme::Theme; +use workspace::{FollowNextCollaborator, JoinProject, ToggleFollow, Workspace}; + +actions!(collab, [ToggleCollaborationMenu, ShareProject]); + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(CollabTitlebarItem::toggle_contacts_popover); + cx.add_action(CollabTitlebarItem::share_project); +} + +pub struct CollabTitlebarItem { + workspace: WeakViewHandle, + user_store: ModelHandle, + contacts_popover: Option>, + _subscriptions: Vec, +} + +impl Entity for CollabTitlebarItem { + type Event = (); +} + +impl View for CollabTitlebarItem { + fn ui_name() -> &'static str { + "CollabTitlebarItem" + } + + fn render(&mut self, cx: &mut RenderContext) -> ElementBox { + let workspace = if let Some(workspace) = self.workspace.upgrade(cx) { + workspace + } else { + return Empty::new().boxed(); + }; + + let theme = cx.global::().theme.clone(); + let project = workspace.read(cx).project().read(cx); + + let mut container = Flex::row(); + if workspace.read(cx).client().status().borrow().is_connected() { + if project.is_shared() + || project.is_remote() + || ActiveCall::global(cx).read(cx).room().is_none() + { + container.add_child(self.render_toggle_contacts_button(&theme, cx)); + } else { + container.add_child(self.render_share_button(&theme, cx)); + } + } + container.add_children(self.render_collaborators(&workspace, &theme, cx)); + container.add_children(self.render_current_user(&workspace, &theme, cx)); + container.add_children(self.render_connection_status(&workspace, cx)); + container.boxed() + } +} + +impl CollabTitlebarItem { + pub fn new( + workspace: &ViewHandle, + user_store: &ModelHandle, + cx: &mut ViewContext, + ) -> Self { + let active_call = ActiveCall::global(cx); + let mut subscriptions = Vec::new(); + subscriptions.push(cx.observe(workspace, |_, _, cx| cx.notify())); + subscriptions.push(cx.observe(&active_call, |_, _, cx| cx.notify())); + subscriptions.push(cx.observe_window_activation(|this, active, cx| { + this.window_activation_changed(active, cx) + })); + subscriptions.push(cx.observe(user_store, |_, _, cx| cx.notify())); + subscriptions.push( + cx.subscribe(user_store, move |this, user_store, event, cx| { + if let Some(workspace) = this.workspace.upgrade(cx) { + workspace.update(cx, |workspace, cx| { + if let client::Event::Contact { user, kind } = event { + if let ContactEventKind::Requested | ContactEventKind::Accepted = kind { + workspace.show_notification(user.id as usize, cx, |cx| { + cx.add_view(|cx| { + ContactNotification::new( + user.clone(), + *kind, + user_store, + cx, + ) + }) + }) + } + } + }); + } + }), + ); + + Self { + workspace: workspace.downgrade(), + user_store: user_store.clone(), + contacts_popover: None, + _subscriptions: subscriptions, + } + } + + fn window_activation_changed(&mut self, active: bool, cx: &mut ViewContext) { + let workspace = self.workspace.upgrade(cx); + let room = ActiveCall::global(cx).read(cx).room().cloned(); + if let Some((workspace, room)) = workspace.zip(room) { + let workspace = workspace.read(cx); + let project = if active { + Some(workspace.project().clone()) + } else { + None + }; + room.update(cx, |room, cx| { + room.set_location(project.as_ref(), cx) + .detach_and_log_err(cx); + }); + } + } + + fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext) { + if let Some(workspace) = self.workspace.upgrade(cx) { + let active_call = ActiveCall::global(cx); + let project = workspace.read(cx).project().clone(); + active_call + .update(cx, |call, cx| call.share_project(project, cx)) + .detach_and_log_err(cx); + } + } + + pub fn toggle_contacts_popover( + &mut self, + _: &ToggleCollaborationMenu, + cx: &mut ViewContext, + ) { + match self.contacts_popover.take() { + Some(_) => {} + None => { + if let Some(workspace) = self.workspace.upgrade(cx) { + let project = workspace.read(cx).project().clone(); + let user_store = workspace.read(cx).user_store().clone(); + let view = cx.add_view(|cx| ContactsPopover::new(project, user_store, cx)); + cx.subscribe(&view, |this, _, event, cx| { + match event { + contacts_popover::Event::Dismissed => { + this.contacts_popover = None; + } + } + + cx.notify(); + }) + .detach(); + self.contacts_popover = Some(view); + } + } + } + cx.notify(); + } + + fn render_toggle_contacts_button( + &self, + theme: &Theme, + cx: &mut RenderContext, + ) -> ElementBox { + let titlebar = &theme.workspace.titlebar; + let badge = if self + .user_store + .read(cx) + .incoming_contact_requests() + .is_empty() + { + None + } else { + Some( + Empty::new() + .collapsed() + .contained() + .with_style(titlebar.toggle_contacts_badge) + .contained() + .with_margin_left(titlebar.toggle_contacts_button.default.icon_width) + .with_margin_top(titlebar.toggle_contacts_button.default.icon_width) + .aligned() + .boxed(), + ) + }; + Stack::new() + .with_child( + MouseEventHandler::::new(0, cx, |state, _| { + let style = titlebar + .toggle_contacts_button + .style_for(state, self.contacts_popover.is_some()); + Svg::new("icons/plus_8.svg") + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) + .contained() + .with_style(style.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(ToggleCollaborationMenu); + }) + .aligned() + .boxed(), + ) + .with_children(badge) + .with_children(self.contacts_popover.as_ref().map(|popover| { + Overlay::new( + ChildView::new(popover, cx) + .contained() + .with_margin_top(titlebar.height) + .with_margin_left(titlebar.toggle_contacts_button.default.button_width) + .with_margin_right(-titlebar.toggle_contacts_button.default.button_width) + .boxed(), + ) + .with_fit_mode(OverlayFitMode::SwitchAnchor) + .with_anchor_corner(AnchorCorner::BottomLeft) + .boxed() + })) + .boxed() + } + + fn render_share_button(&self, theme: &Theme, cx: &mut RenderContext) -> ElementBox { + enum Share {} + + let titlebar = &theme.workspace.titlebar; + MouseEventHandler::::new(0, cx, |state, _| { + let style = titlebar.share_button.style_for(state, false); + Label::new("Share".into(), style.text.clone()) + .contained() + .with_style(style.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(ShareProject)) + .with_tooltip::( + 0, + "Share project with call participants".into(), + None, + theme.tooltip.clone(), + cx, + ) + .aligned() + .boxed() + } + + fn render_collaborators( + &self, + workspace: &ViewHandle, + theme: &Theme, + cx: &mut RenderContext, + ) -> Vec { + let active_call = ActiveCall::global(cx); + if let Some(room) = active_call.read(cx).room().cloned() { + let project = workspace.read(cx).project().read(cx); + let mut participants = room + .read(cx) + .remote_participants() + .iter() + .map(|(peer_id, collaborator)| (*peer_id, collaborator.clone())) + .collect::>(); + participants + .sort_by_key(|(peer_id, _)| Some(project.collaborators().get(peer_id)?.replica_id)); + participants + .into_iter() + .filter_map(|(peer_id, participant)| { + let project = workspace.read(cx).project().read(cx); + let replica_id = project + .collaborators() + .get(&peer_id) + .map(|collaborator| collaborator.replica_id); + let user = participant.user.clone(); + Some(self.render_avatar( + &user, + replica_id, + Some((peer_id, &user.github_login, participant.location)), + workspace, + theme, + cx, + )) + }) + .collect() + } else { + Default::default() + } + } + + fn render_current_user( + &self, + workspace: &ViewHandle, + theme: &Theme, + cx: &mut RenderContext, + ) -> Option { + let user = workspace.read(cx).user_store().read(cx).current_user(); + let replica_id = workspace.read(cx).project().read(cx).replica_id(); + let status = *workspace.read(cx).client().status().borrow(); + if let Some(user) = user { + Some(self.render_avatar(&user, Some(replica_id), None, workspace, theme, cx)) + } else if matches!(status, client::Status::UpgradeRequired) { + None + } else { + Some( + MouseEventHandler::::new(0, cx, |state, _| { + let style = theme + .workspace + .titlebar + .sign_in_prompt + .style_for(state, false); + Label::new("Sign in".to_string(), style.text.clone()) + .contained() + .with_style(style.container) + .boxed() + }) + .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate)) + .with_cursor_style(CursorStyle::PointingHand) + .aligned() + .boxed(), + ) + } + } + + fn render_avatar( + &self, + user: &User, + replica_id: Option, + peer: Option<(PeerId, &str, ParticipantLocation)>, + workspace: &ViewHandle, + theme: &Theme, + cx: &mut RenderContext, + ) -> ElementBox { + let is_followed = peer.map_or(false, |(peer_id, _, _)| { + workspace.read(cx).is_following(peer_id) + }); + + let mut avatar_style; + if let Some((_, _, location)) = peer.as_ref() { + if let ParticipantLocation::SharedProject { project_id } = *location { + if Some(project_id) == workspace.read(cx).project().read(cx).remote_id() { + avatar_style = theme.workspace.titlebar.avatar; + } else { + avatar_style = theme.workspace.titlebar.inactive_avatar; + } + } else { + avatar_style = theme.workspace.titlebar.inactive_avatar; + } + } else { + avatar_style = theme.workspace.titlebar.avatar; + } + + let mut replica_color = None; + if let Some(replica_id) = replica_id { + let color = theme.editor.replica_selection_style(replica_id).cursor; + replica_color = Some(color); + if is_followed { + avatar_style.border = Border::all(1.0, color); + } + } + + let content = Stack::new() + .with_children(user.avatar.as_ref().map(|avatar| { + Image::new(avatar.clone()) + .with_style(avatar_style) + .constrained() + .with_width(theme.workspace.titlebar.avatar_width) + .aligned() + .boxed() + })) + .with_children(replica_color.map(|replica_color| { + AvatarRibbon::new(replica_color) + .constrained() + .with_width(theme.workspace.titlebar.avatar_ribbon.width) + .with_height(theme.workspace.titlebar.avatar_ribbon.height) + .aligned() + .bottom() + .boxed() + })) + .constrained() + .with_width(theme.workspace.titlebar.avatar_width) + .contained() + .with_margin_left(theme.workspace.titlebar.avatar_margin) + .boxed(); + + if let Some((peer_id, peer_github_login, location)) = peer { + if let Some(replica_id) = replica_id { + MouseEventHandler::::new(replica_id.into(), cx, move |_, _| content) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(ToggleFollow(peer_id)) + }) + .with_tooltip::( + peer_id.0 as usize, + if is_followed { + format!("Unfollow {}", peer_github_login) + } else { + format!("Follow {}", peer_github_login) + }, + Some(Box::new(FollowNextCollaborator)), + theme.tooltip.clone(), + cx, + ) + .boxed() + } else if let ParticipantLocation::SharedProject { project_id } = location { + let user_id = user.id; + MouseEventHandler::::new(peer_id.0 as usize, cx, move |_, _| content) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(JoinProject { + project_id, + follow_user_id: user_id, + }) + }) + .with_tooltip::( + peer_id.0 as usize, + format!("Follow {} into external project", peer_github_login), + Some(Box::new(FollowNextCollaborator)), + theme.tooltip.clone(), + cx, + ) + .boxed() + } else { + content + } + } else { + content + } + } + + fn render_connection_status( + &self, + workspace: &ViewHandle, + cx: &mut RenderContext, + ) -> Option { + let theme = &cx.global::().theme; + match &*workspace.read(cx).client().status().borrow() { + client::Status::ConnectionError + | client::Status::ConnectionLost + | client::Status::Reauthenticating { .. } + | client::Status::Reconnecting { .. } + | client::Status::ReconnectionError { .. } => Some( + Container::new( + Align::new( + ConstrainedBox::new( + Svg::new("icons/cloud_slash_12.svg") + .with_color(theme.workspace.titlebar.offline_icon.color) + .boxed(), + ) + .with_width(theme.workspace.titlebar.offline_icon.width) + .boxed(), + ) + .boxed(), + ) + .with_style(theme.workspace.titlebar.offline_icon.container) + .boxed(), + ), + client::Status::UpgradeRequired => Some( + Label::new( + "Please update Zed to collaborate".to_string(), + theme.workspace.titlebar.outdated_warning.text.clone(), + ) + .contained() + .with_style(theme.workspace.titlebar.outdated_warning.container) + .aligned() + .boxed(), + ), + _ => None, + } + } +} + +pub struct AvatarRibbon { + color: Color, +} + +impl AvatarRibbon { + pub fn new(color: Color) -> AvatarRibbon { + AvatarRibbon { color } + } +} + +impl Element for AvatarRibbon { + type LayoutState = (); + + type PaintState = (); + + fn layout( + &mut self, + constraint: gpui::SizeConstraint, + _: &mut gpui::LayoutContext, + ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) { + (constraint.max, ()) + } + + fn paint( + &mut self, + bounds: gpui::geometry::rect::RectF, + _: gpui::geometry::rect::RectF, + _: &mut Self::LayoutState, + cx: &mut gpui::PaintContext, + ) -> Self::PaintState { + let mut path = PathBuilder::new(); + path.reset(bounds.lower_left()); + path.curve_to( + bounds.origin() + vec2f(bounds.height(), 0.), + bounds.origin(), + ); + path.line_to(bounds.upper_right() - vec2f(bounds.height(), 0.)); + path.curve_to(bounds.lower_right(), bounds.upper_right()); + path.line_to(bounds.lower_left()); + cx.scene.push_path(path.build(self.color, None)); + } + + fn dispatch_event( + &mut self, + _: &gpui::Event, + _: RectF, + _: RectF, + _: &mut Self::LayoutState, + _: &mut Self::PaintState, + _: &mut gpui::EventContext, + ) -> bool { + false + } + + fn rect_for_text_range( + &self, + _: Range, + _: RectF, + _: RectF, + _: &Self::LayoutState, + _: &Self::PaintState, + _: &gpui::MeasurementContext, + ) -> Option { + None + } + + fn debug( + &self, + bounds: gpui::geometry::rect::RectF, + _: &Self::LayoutState, + _: &Self::PaintState, + _: &gpui::DebugContext, + ) -> gpui::json::Value { + json::json!({ + "type": "AvatarRibbon", + "bounds": bounds.to_json(), + "color": self.color.to_json(), + }) + } +} diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs new file mode 100644 index 0000000000..f5f508ce5b --- /dev/null +++ b/crates/collab_ui/src/collab_ui.rs @@ -0,0 +1,97 @@ +mod collab_titlebar_item; +mod contact_finder; +mod contact_list; +mod contact_notification; +mod contacts_popover; +mod incoming_call_notification; +mod notifications; +mod project_shared_notification; + +use call::ActiveCall; +pub use collab_titlebar_item::{CollabTitlebarItem, ToggleCollaborationMenu}; +use gpui::MutableAppContext; +use project::Project; +use std::sync::Arc; +use workspace::{AppState, JoinProject, ToggleFollow, Workspace}; + +pub fn init(app_state: Arc, cx: &mut MutableAppContext) { + collab_titlebar_item::init(cx); + contact_notification::init(cx); + contact_list::init(cx); + contact_finder::init(cx); + contacts_popover::init(cx); + incoming_call_notification::init(cx); + project_shared_notification::init(cx); + + cx.add_global_action(move |action: &JoinProject, cx| { + let project_id = action.project_id; + let follow_user_id = action.follow_user_id; + let app_state = app_state.clone(); + cx.spawn(|mut cx| async move { + let existing_workspace = cx.update(|cx| { + cx.window_ids() + .filter_map(|window_id| cx.root_view::(window_id)) + .find(|workspace| { + workspace.read(cx).project().read(cx).remote_id() == Some(project_id) + }) + }); + + let workspace = if let Some(existing_workspace) = existing_workspace { + existing_workspace + } else { + let project = Project::remote( + project_id, + app_state.client.clone(), + app_state.user_store.clone(), + app_state.project_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + cx.clone(), + ) + .await?; + + let (_, workspace) = cx.add_window((app_state.build_window_options)(), |cx| { + let mut workspace = Workspace::new(project, app_state.default_item_factory, cx); + (app_state.initialize_workspace)(&mut workspace, &app_state, cx); + workspace + }); + workspace + }; + + cx.activate_window(workspace.window_id()); + cx.platform().activate(true); + + workspace.update(&mut cx, |workspace, cx| { + if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { + let follow_peer_id = room + .read(cx) + .remote_participants() + .iter() + .find(|(_, participant)| participant.user.id == follow_user_id) + .map(|(peer_id, _)| *peer_id) + .or_else(|| { + // If we couldn't follow the given user, follow the host instead. + let collaborator = workspace + .project() + .read(cx) + .collaborators() + .values() + .find(|collaborator| collaborator.replica_id == 0)?; + Some(collaborator.peer_id) + }); + + if let Some(follow_peer_id) = follow_peer_id { + if !workspace.is_following(follow_peer_id) { + workspace + .toggle_follow(&ToggleFollow(follow_peer_id), cx) + .map(|follow| follow.detach_and_log_err(cx)); + } + } + } + }); + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + }); +} diff --git a/crates/contacts_panel/src/contact_finder.rs b/crates/collab_ui/src/contact_finder.rs similarity index 74% rename from crates/contacts_panel/src/contact_finder.rs rename to crates/collab_ui/src/contact_finder.rs index 1831c1ba72..a4ec02d2f0 100644 --- a/crates/contacts_panel/src/contact_finder.rs +++ b/crates/collab_ui/src/contact_finder.rs @@ -1,21 +1,15 @@ use client::{ContactRequestStatus, User, UserStore}; use gpui::{ - actions, elements::*, AnyViewHandle, Entity, ModelHandle, MouseState, MutableAppContext, - RenderContext, Task, View, ViewContext, ViewHandle, + elements::*, AnyViewHandle, Entity, ModelHandle, MouseState, MutableAppContext, RenderContext, + Task, View, ViewContext, ViewHandle, }; use picker::{Picker, PickerDelegate}; use settings::Settings; use std::sync::Arc; use util::TryFutureExt; -use workspace::Workspace; - -use crate::render_icon_button; - -actions!(contact_finder, [Toggle]); pub fn init(cx: &mut MutableAppContext) { Picker::::init(cx); - cx.add_action(ContactFinder::toggle); } pub struct ContactFinder { @@ -38,8 +32,8 @@ impl View for ContactFinder { "ContactFinder" } - fn render(&mut self, _: &mut RenderContext) -> ElementBox { - ChildView::new(self.picker.clone()).boxed() + fn render(&mut self, cx: &mut RenderContext) -> ElementBox { + ChildView::new(self.picker.clone(), cx).boxed() } fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { @@ -107,7 +101,7 @@ impl PickerDelegate for ContactFinder { fn render_match( &self, ix: usize, - mouse_state: MouseState, + mouse_state: &mut MouseState, selected: bool, cx: &gpui::AppContext, ) -> ElementBox { @@ -117,18 +111,21 @@ impl PickerDelegate for ContactFinder { let icon_path = match request_status { ContactRequestStatus::None | ContactRequestStatus::RequestReceived => { - "icons/check_8.svg" - } - ContactRequestStatus::RequestSent | ContactRequestStatus::RequestAccepted => { - "icons/x_mark_8.svg" + Some("icons/check_8.svg") } + ContactRequestStatus::RequestSent => Some("icons/x_mark_8.svg"), + ContactRequestStatus::RequestAccepted => None, }; let button_style = if self.user_store.read(cx).is_contact_request_pending(user) { &theme.contact_finder.disabled_contact_button } else { &theme.contact_finder.contact_button }; - let style = theme.picker.item.style_for(mouse_state, selected); + let style = theme + .contact_finder + .picker + .item + .style_for(mouse_state, selected); Flex::row() .with_children(user.avatar.clone().map(|avatar| { Image::new(avatar) @@ -145,12 +142,21 @@ impl PickerDelegate for ContactFinder { .left() .boxed(), ) - .with_child( - render_icon_button(button_style, icon_path) + .with_children(icon_path.map(|icon_path| { + Svg::new(icon_path) + .with_color(button_style.color) + .constrained() + .with_width(button_style.icon_width) + .aligned() + .contained() + .with_style(button_style.container) + .constrained() + .with_width(button_style.button_width) + .with_height(button_style.button_width) .aligned() .flex_float() - .boxed(), - ) + .boxed() + })) .contained() .with_style(style.container) .constrained() @@ -160,34 +166,16 @@ impl PickerDelegate for ContactFinder { } impl ContactFinder { - fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext) { - workspace.toggle_modal(cx, |workspace, cx| { - let finder = cx.add_view(|cx| Self::new(workspace.user_store().clone(), cx)); - cx.subscribe(&finder, Self::on_event).detach(); - finder - }); - } - pub fn new(user_store: ModelHandle, cx: &mut ViewContext) -> Self { let this = cx.weak_handle(); Self { - picker: cx.add_view(|cx| Picker::new(this, cx)), + picker: cx.add_view(|cx| { + Picker::new(this, cx) + .with_theme(|cx| &cx.global::().theme.contact_finder.picker) + }), potential_contacts: Arc::from([]), user_store, selected_index: 0, } } - - fn on_event( - workspace: &mut Workspace, - _: ViewHandle, - event: &Event, - cx: &mut ViewContext, - ) { - match event { - Event::Dismissed => { - workspace.dismiss_modal(cx); - } - } - } } diff --git a/crates/collab_ui/src/contact_list.rs b/crates/collab_ui/src/contact_list.rs new file mode 100644 index 0000000000..cf8a8f8223 --- /dev/null +++ b/crates/collab_ui/src/contact_list.rs @@ -0,0 +1,1148 @@ +use std::sync::Arc; + +use crate::contacts_popover; +use call::ActiveCall; +use client::{Contact, PeerId, User, UserStore}; +use editor::{Cancel, Editor}; +use fuzzy::{match_strings, StringMatchCandidate}; +use gpui::{ + elements::*, + geometry::{rect::RectF, vector::vec2f}, + impl_actions, impl_internal_actions, keymap, AppContext, CursorStyle, Entity, ModelHandle, + MouseButton, MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle, +}; +use menu::{Confirm, SelectNext, SelectPrev}; +use project::Project; +use serde::Deserialize; +use settings::Settings; +use theme::IconButton; +use util::ResultExt; +use workspace::JoinProject; + +impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]); +impl_internal_actions!(contact_list, [ToggleExpanded, Call, LeaveCall]); + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(ContactList::remove_contact); + cx.add_action(ContactList::respond_to_contact_request); + cx.add_action(ContactList::clear_filter); + cx.add_action(ContactList::select_next); + cx.add_action(ContactList::select_prev); + cx.add_action(ContactList::confirm); + cx.add_action(ContactList::toggle_expanded); + cx.add_action(ContactList::call); + cx.add_action(ContactList::leave_call); +} + +#[derive(Clone, PartialEq)] +struct ToggleExpanded(Section); + +#[derive(Clone, PartialEq)] +struct Call { + recipient_user_id: u64, + initial_project: Option>, +} + +#[derive(Copy, Clone, PartialEq)] +struct LeaveCall; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)] +enum Section { + ActiveCall, + Requests, + Online, + Offline, +} + +#[derive(Clone)] +enum ContactEntry { + Header(Section), + CallParticipant { + user: Arc, + is_pending: bool, + }, + ParticipantProject { + project_id: u64, + worktree_root_names: Vec, + host_user_id: u64, + is_last: bool, + }, + IncomingRequest(Arc), + OutgoingRequest(Arc), + Contact(Arc), +} + +impl PartialEq for ContactEntry { + fn eq(&self, other: &Self) -> bool { + match self { + ContactEntry::Header(section_1) => { + if let ContactEntry::Header(section_2) = other { + return section_1 == section_2; + } + } + ContactEntry::CallParticipant { user: user_1, .. } => { + if let ContactEntry::CallParticipant { user: user_2, .. } = other { + return user_1.id == user_2.id; + } + } + ContactEntry::ParticipantProject { + project_id: project_id_1, + .. + } => { + if let ContactEntry::ParticipantProject { + project_id: project_id_2, + .. + } = other + { + return project_id_1 == project_id_2; + } + } + ContactEntry::IncomingRequest(user_1) => { + if let ContactEntry::IncomingRequest(user_2) = other { + return user_1.id == user_2.id; + } + } + ContactEntry::OutgoingRequest(user_1) => { + if let ContactEntry::OutgoingRequest(user_2) = other { + return user_1.id == user_2.id; + } + } + ContactEntry::Contact(contact_1) => { + if let ContactEntry::Contact(contact_2) = other { + return contact_1.user.id == contact_2.user.id; + } + } + } + false + } +} + +#[derive(Clone, Deserialize, PartialEq)] +pub struct RequestContact(pub u64); + +#[derive(Clone, Deserialize, PartialEq)] +pub struct RemoveContact(pub u64); + +#[derive(Clone, Deserialize, PartialEq)] +pub struct RespondToContactRequest { + pub user_id: u64, + pub accept: bool, +} + +pub enum Event { + Dismissed, +} + +pub struct ContactList { + entries: Vec, + match_candidates: Vec, + list_state: ListState, + project: ModelHandle, + user_store: ModelHandle, + filter_editor: ViewHandle, + collapsed_sections: Vec
, + selection: Option, + _subscriptions: Vec, +} + +impl ContactList { + pub fn new( + project: ModelHandle, + user_store: ModelHandle, + cx: &mut ViewContext, + ) -> Self { + let filter_editor = cx.add_view(|cx| { + let mut editor = Editor::single_line( + Some(|theme| theme.contact_list.user_query_editor.clone()), + cx, + ); + editor.set_placeholder_text("Filter contacts", cx); + editor + }); + + cx.subscribe(&filter_editor, |this, _, event, cx| { + if let editor::Event::BufferEdited = event { + let query = this.filter_editor.read(cx).text(cx); + if !query.is_empty() { + this.selection.take(); + } + this.update_entries(cx); + if !query.is_empty() { + this.selection = this + .entries + .iter() + .position(|entry| !matches!(entry, ContactEntry::Header(_))); + } + } + }) + .detach(); + + let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| { + let theme = cx.global::().theme.clone(); + let is_selected = this.selection == Some(ix); + let current_project_id = this.project.read(cx).remote_id(); + + match &this.entries[ix] { + ContactEntry::Header(section) => { + let is_collapsed = this.collapsed_sections.contains(section); + Self::render_header( + *section, + &theme.contact_list, + is_selected, + is_collapsed, + cx, + ) + } + ContactEntry::CallParticipant { user, is_pending } => { + Self::render_call_participant( + user, + *is_pending, + is_selected, + &theme.contact_list, + ) + } + ContactEntry::ParticipantProject { + project_id, + worktree_root_names, + host_user_id, + is_last, + } => Self::render_participant_project( + *project_id, + worktree_root_names, + *host_user_id, + Some(*project_id) == current_project_id, + *is_last, + is_selected, + &theme.contact_list, + cx, + ), + ContactEntry::IncomingRequest(user) => Self::render_contact_request( + user.clone(), + this.user_store.clone(), + &theme.contact_list, + true, + is_selected, + cx, + ), + ContactEntry::OutgoingRequest(user) => Self::render_contact_request( + user.clone(), + this.user_store.clone(), + &theme.contact_list, + false, + is_selected, + cx, + ), + ContactEntry::Contact(contact) => Self::render_contact( + contact, + &this.project, + &theme.contact_list, + is_selected, + cx, + ), + } + }); + + let active_call = ActiveCall::global(cx); + let mut subscriptions = Vec::new(); + subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx))); + subscriptions.push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx))); + + let mut this = Self { + list_state, + selection: None, + collapsed_sections: Default::default(), + entries: Default::default(), + match_candidates: Default::default(), + filter_editor, + _subscriptions: subscriptions, + project, + user_store, + }; + this.update_entries(cx); + this + } + + fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext) { + self.user_store + .update(cx, |store, cx| store.remove_contact(request.0, cx)) + .detach(); + } + + fn respond_to_contact_request( + &mut self, + action: &RespondToContactRequest, + cx: &mut ViewContext, + ) { + self.user_store + .update(cx, |store, cx| { + store.respond_to_contact_request(action.user_id, action.accept, cx) + }) + .detach(); + } + + fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext) { + let did_clear = self.filter_editor.update(cx, |editor, cx| { + if editor.buffer().read(cx).len(cx) > 0 { + editor.set_text("", cx); + true + } else { + false + } + }); + if !did_clear { + cx.emit(Event::Dismissed); + } + } + + fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { + if let Some(ix) = self.selection { + if self.entries.len() > ix + 1 { + self.selection = Some(ix + 1); + } + } else if !self.entries.is_empty() { + self.selection = Some(0); + } + cx.notify(); + self.list_state.reset(self.entries.len()); + } + + fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { + if let Some(ix) = self.selection { + if ix > 0 { + self.selection = Some(ix - 1); + } else { + self.selection = None; + } + } + cx.notify(); + self.list_state.reset(self.entries.len()); + } + + fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { + if let Some(selection) = self.selection { + if let Some(entry) = self.entries.get(selection) { + match entry { + ContactEntry::Header(section) => { + let section = *section; + self.toggle_expanded(&ToggleExpanded(section), cx); + } + ContactEntry::Contact(contact) => { + if contact.online && !contact.busy { + self.call( + &Call { + recipient_user_id: contact.user.id, + initial_project: Some(self.project.clone()), + }, + cx, + ); + } + } + ContactEntry::ParticipantProject { + project_id, + host_user_id, + .. + } => { + cx.dispatch_global_action(JoinProject { + project_id: *project_id, + follow_user_id: *host_user_id, + }); + } + _ => {} + } + } + } + } + + fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext) { + let section = action.0; + if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) { + self.collapsed_sections.remove(ix); + } else { + self.collapsed_sections.push(section); + } + self.update_entries(cx); + } + + fn update_entries(&mut self, cx: &mut ViewContext) { + let user_store = self.user_store.read(cx); + let query = self.filter_editor.read(cx).text(cx); + let executor = cx.background().clone(); + + let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned()); + self.entries.clear(); + + if let Some(room) = ActiveCall::global(cx).read(cx).room() { + let room = room.read(cx); + let mut participant_entries = Vec::new(); + + // Populate the active user. + if let Some(user) = user_store.current_user() { + self.match_candidates.clear(); + self.match_candidates.push(StringMatchCandidate { + id: 0, + string: user.github_login.clone(), + char_bag: user.github_login.chars().collect(), + }); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + if !matches.is_empty() { + let user_id = user.id; + participant_entries.push(ContactEntry::CallParticipant { + user, + is_pending: false, + }); + let mut projects = room.local_participant().projects.iter().peekable(); + while let Some(project) = projects.next() { + participant_entries.push(ContactEntry::ParticipantProject { + project_id: project.id, + worktree_root_names: project.worktree_root_names.clone(), + host_user_id: user_id, + is_last: projects.peek().is_none(), + }); + } + } + } + + // Populate remote participants. + self.match_candidates.clear(); + self.match_candidates + .extend( + room.remote_participants() + .iter() + .map(|(peer_id, participant)| StringMatchCandidate { + id: peer_id.0 as usize, + string: participant.user.github_login.clone(), + char_bag: participant.user.github_login.chars().collect(), + }), + ); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + for mat in matches { + let participant = &room.remote_participants()[&PeerId(mat.candidate_id as u32)]; + participant_entries.push(ContactEntry::CallParticipant { + user: room.remote_participants()[&PeerId(mat.candidate_id as u32)] + .user + .clone(), + is_pending: false, + }); + let mut projects = participant.projects.iter().peekable(); + while let Some(project) = projects.next() { + participant_entries.push(ContactEntry::ParticipantProject { + project_id: project.id, + worktree_root_names: project.worktree_root_names.clone(), + host_user_id: participant.user.id, + is_last: projects.peek().is_none(), + }); + } + } + + // Populate pending participants. + self.match_candidates.clear(); + self.match_candidates + .extend( + room.pending_participants() + .iter() + .enumerate() + .map(|(id, participant)| StringMatchCandidate { + id, + string: participant.github_login.clone(), + char_bag: participant.github_login.chars().collect(), + }), + ); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + participant_entries.extend(matches.iter().map(|mat| ContactEntry::CallParticipant { + user: room.pending_participants()[mat.candidate_id].clone(), + is_pending: true, + })); + + if !participant_entries.is_empty() { + self.entries.push(ContactEntry::Header(Section::ActiveCall)); + if !self.collapsed_sections.contains(&Section::ActiveCall) { + self.entries.extend(participant_entries); + } + } + } + + let mut request_entries = Vec::new(); + let incoming = user_store.incoming_contact_requests(); + if !incoming.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend( + incoming + .iter() + .enumerate() + .map(|(ix, user)| StringMatchCandidate { + id: ix, + string: user.github_login.clone(), + char_bag: user.github_login.chars().collect(), + }), + ); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + request_entries.extend( + matches + .iter() + .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())), + ); + } + + let outgoing = user_store.outgoing_contact_requests(); + if !outgoing.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend( + outgoing + .iter() + .enumerate() + .map(|(ix, user)| StringMatchCandidate { + id: ix, + string: user.github_login.clone(), + char_bag: user.github_login.chars().collect(), + }), + ); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + request_entries.extend( + matches + .iter() + .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())), + ); + } + + if !request_entries.is_empty() { + self.entries.push(ContactEntry::Header(Section::Requests)); + if !self.collapsed_sections.contains(&Section::Requests) { + self.entries.append(&mut request_entries); + } + } + + let contacts = user_store.contacts(); + if !contacts.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend( + contacts + .iter() + .enumerate() + .map(|(ix, contact)| StringMatchCandidate { + id: ix, + string: contact.user.github_login.clone(), + char_bag: contact.user.github_login.chars().collect(), + }), + ); + + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + + let (mut online_contacts, offline_contacts) = matches + .iter() + .partition::, _>(|mat| contacts[mat.candidate_id].online); + if let Some(room) = ActiveCall::global(cx).read(cx).room() { + let room = room.read(cx); + online_contacts.retain(|contact| { + let contact = &contacts[contact.candidate_id]; + !room.contains_participant(contact.user.id) + }); + } + + for (matches, section) in [ + (online_contacts, Section::Online), + (offline_contacts, Section::Offline), + ] { + if !matches.is_empty() { + self.entries.push(ContactEntry::Header(section)); + if !self.collapsed_sections.contains(§ion) { + for mat in matches { + let contact = &contacts[mat.candidate_id]; + self.entries.push(ContactEntry::Contact(contact.clone())); + } + } + } + } + } + + if let Some(prev_selected_entry) = prev_selected_entry { + self.selection.take(); + for (ix, entry) in self.entries.iter().enumerate() { + if *entry == prev_selected_entry { + self.selection = Some(ix); + break; + } + } + } + + self.list_state.reset(self.entries.len()); + cx.notify(); + } + + fn render_call_participant( + user: &User, + is_pending: bool, + is_selected: bool, + theme: &theme::ContactList, + ) -> ElementBox { + Flex::row() + .with_children(user.avatar.clone().map(|avatar| { + Image::new(avatar) + .with_style(theme.contact_avatar) + .aligned() + .left() + .boxed() + })) + .with_child( + Label::new( + user.github_login.clone(), + theme.contact_username.text.clone(), + ) + .contained() + .with_style(theme.contact_username.container) + .aligned() + .left() + .flex(1., true) + .boxed(), + ) + .with_children(if is_pending { + Some( + Label::new("Calling".to_string(), theme.calling_indicator.text.clone()) + .contained() + .with_style(theme.calling_indicator.container) + .aligned() + .boxed(), + ) + } else { + None + }) + .constrained() + .with_height(theme.row_height) + .contained() + .with_style( + *theme + .contact_row + .style_for(&mut Default::default(), is_selected), + ) + .boxed() + } + + fn render_participant_project( + project_id: u64, + worktree_root_names: &[String], + host_user_id: u64, + is_current: bool, + is_last: bool, + is_selected: bool, + theme: &theme::ContactList, + cx: &mut RenderContext, + ) -> ElementBox { + let font_cache = cx.font_cache(); + let host_avatar_height = theme + .contact_avatar + .width + .or(theme.contact_avatar.height) + .unwrap_or(0.); + let row = &theme.project_row.default; + let tree_branch = theme.tree_branch; + let line_height = row.name.text.line_height(font_cache); + let cap_height = row.name.text.cap_height(font_cache); + let baseline_offset = + row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.; + let project_name = if worktree_root_names.is_empty() { + "untitled".to_string() + } else { + worktree_root_names.join(", ") + }; + + MouseEventHandler::::new(project_id as usize, cx, |mouse_state, _| { + let tree_branch = *tree_branch.style_for(mouse_state, is_selected); + let row = theme.project_row.style_for(mouse_state, is_selected); + + Flex::row() + .with_child( + Stack::new() + .with_child( + Canvas::new(move |bounds, _, cx| { + let start_x = bounds.min_x() + (bounds.width() / 2.) + - (tree_branch.width / 2.); + let end_x = bounds.max_x(); + let start_y = bounds.min_y(); + let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.); + + cx.scene.push_quad(gpui::Quad { + bounds: RectF::from_points( + vec2f(start_x, start_y), + vec2f( + start_x + tree_branch.width, + if is_last { end_y } else { bounds.max_y() }, + ), + ), + background: Some(tree_branch.color), + border: gpui::Border::default(), + corner_radius: 0., + }); + cx.scene.push_quad(gpui::Quad { + bounds: RectF::from_points( + vec2f(start_x, end_y), + vec2f(end_x, end_y + tree_branch.width), + ), + background: Some(tree_branch.color), + border: gpui::Border::default(), + corner_radius: 0., + }); + }) + .boxed(), + ) + .constrained() + .with_width(host_avatar_height) + .boxed(), + ) + .with_child( + Label::new(project_name, row.name.text.clone()) + .aligned() + .left() + .contained() + .with_style(row.name.container) + .flex(1., false) + .boxed(), + ) + .constrained() + .with_height(theme.row_height) + .contained() + .with_style(row.container) + .boxed() + }) + .with_cursor_style(if !is_current { + CursorStyle::PointingHand + } else { + CursorStyle::Arrow + }) + .on_click(MouseButton::Left, move |_, cx| { + if !is_current { + cx.dispatch_global_action(JoinProject { + project_id, + follow_user_id: host_user_id, + }); + } + }) + .boxed() + } + + fn render_header( + section: Section, + theme: &theme::ContactList, + is_selected: bool, + is_collapsed: bool, + cx: &mut RenderContext, + ) -> ElementBox { + enum Header {} + + let header_style = theme + .header_row + .style_for(&mut Default::default(), is_selected); + let text = match section { + Section::ActiveCall => "Collaborators", + Section::Requests => "Contact Requests", + Section::Online => "Online", + Section::Offline => "Offline", + }; + let leave_call = if section == Section::ActiveCall { + Some( + MouseEventHandler::::new(0, cx, |state, _| { + let style = theme.leave_call.style_for(state, false); + Label::new("Leave Session".into(), style.text.clone()) + .contained() + .with_style(style.container) + .boxed() + }) + .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(LeaveCall)) + .aligned() + .boxed(), + ) + } else { + None + }; + + let icon_size = theme.section_icon_size; + MouseEventHandler::
::new(section as usize, cx, |_, _| { + Flex::row() + .with_child( + Svg::new(if is_collapsed { + "icons/chevron_right_8.svg" + } else { + "icons/chevron_down_8.svg" + }) + .with_color(header_style.text.color) + .constrained() + .with_max_width(icon_size) + .with_max_height(icon_size) + .aligned() + .constrained() + .with_width(icon_size) + .boxed(), + ) + .with_child( + Label::new(text.to_string(), header_style.text.clone()) + .aligned() + .left() + .contained() + .with_margin_left(theme.contact_username.container.margin.left) + .flex(1., true) + .boxed(), + ) + .with_children(leave_call) + .constrained() + .with_height(theme.row_height) + .contained() + .with_style(header_style.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(ToggleExpanded(section)) + }) + .boxed() + } + + fn render_contact( + contact: &Contact, + project: &ModelHandle, + theme: &theme::ContactList, + is_selected: bool, + cx: &mut RenderContext, + ) -> ElementBox { + let online = contact.online; + let busy = contact.busy; + let user_id = contact.user.id; + let initial_project = project.clone(); + let mut element = + MouseEventHandler::::new(contact.user.id as usize, cx, |_, _| { + Flex::row() + .with_children(contact.user.avatar.clone().map(|avatar| { + let status_badge = if contact.online { + Some( + Empty::new() + .collapsed() + .contained() + .with_style(if contact.busy { + theme.contact_status_busy + } else { + theme.contact_status_free + }) + .aligned() + .boxed(), + ) + } else { + None + }; + Stack::new() + .with_child( + Image::new(avatar) + .with_style(theme.contact_avatar) + .aligned() + .left() + .boxed(), + ) + .with_children(status_badge) + .boxed() + })) + .with_child( + Label::new( + contact.user.github_login.clone(), + theme.contact_username.text.clone(), + ) + .contained() + .with_style(theme.contact_username.container) + .aligned() + .left() + .flex(1., true) + .boxed(), + ) + .constrained() + .with_height(theme.row_height) + .contained() + .with_style( + *theme + .contact_row + .style_for(&mut Default::default(), is_selected), + ) + .boxed() + }) + .on_click(MouseButton::Left, move |_, cx| { + if online && !busy { + cx.dispatch_action(Call { + recipient_user_id: user_id, + initial_project: Some(initial_project.clone()), + }); + } + }); + + if online { + element = element.with_cursor_style(CursorStyle::PointingHand); + } + + element.boxed() + } + + fn render_contact_request( + user: Arc, + user_store: ModelHandle, + theme: &theme::ContactList, + is_incoming: bool, + is_selected: bool, + cx: &mut RenderContext, + ) -> ElementBox { + enum Decline {} + enum Accept {} + enum Cancel {} + + let mut row = Flex::row() + .with_children(user.avatar.clone().map(|avatar| { + Image::new(avatar) + .with_style(theme.contact_avatar) + .aligned() + .left() + .boxed() + })) + .with_child( + Label::new( + user.github_login.clone(), + theme.contact_username.text.clone(), + ) + .contained() + .with_style(theme.contact_username.container) + .aligned() + .left() + .flex(1., true) + .boxed(), + ); + + let user_id = user.id; + let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user); + let button_spacing = theme.contact_button_spacing; + + if is_incoming { + row.add_children([ + MouseEventHandler::::new(user.id as usize, cx, |mouse_state, _| { + let button_style = if is_contact_request_pending { + &theme.disabled_button + } else { + theme.contact_button.style_for(mouse_state, false) + }; + render_icon_button(button_style, "icons/x_mark_8.svg") + .aligned() + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(RespondToContactRequest { + user_id, + accept: false, + }) + }) + .contained() + .with_margin_right(button_spacing) + .boxed(), + MouseEventHandler::::new(user.id as usize, cx, |mouse_state, _| { + let button_style = if is_contact_request_pending { + &theme.disabled_button + } else { + theme.contact_button.style_for(mouse_state, false) + }; + render_icon_button(button_style, "icons/check_8.svg") + .aligned() + .flex_float() + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(RespondToContactRequest { + user_id, + accept: true, + }) + }) + .boxed(), + ]); + } else { + row.add_child( + MouseEventHandler::::new(user.id as usize, cx, |mouse_state, _| { + let button_style = if is_contact_request_pending { + &theme.disabled_button + } else { + theme.contact_button.style_for(mouse_state, false) + }; + render_icon_button(button_style, "icons/x_mark_8.svg") + .aligned() + .flex_float() + .boxed() + }) + .with_padding(Padding::uniform(2.)) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(RemoveContact(user_id)) + }) + .flex_float() + .boxed(), + ); + } + + row.constrained() + .with_height(theme.row_height) + .contained() + .with_style( + *theme + .contact_row + .style_for(&mut Default::default(), is_selected), + ) + .boxed() + } + + fn call(&mut self, action: &Call, cx: &mut ViewContext) { + let recipient_user_id = action.recipient_user_id; + let initial_project = action.initial_project.clone(); + let window_id = cx.window_id(); + + let active_call = ActiveCall::global(cx); + cx.spawn_weak(|_, mut cx| async move { + active_call + .update(&mut cx, |active_call, cx| { + active_call.invite(recipient_user_id, initial_project.clone(), cx) + }) + .await?; + if cx.update(|cx| cx.window_is_active(window_id)) { + active_call + .update(&mut cx, |call, cx| { + call.set_location(initial_project.as_ref(), cx) + }) + .await?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext) { + ActiveCall::global(cx) + .update(cx, |call, cx| call.hang_up(cx)) + .log_err(); + } +} + +impl Entity for ContactList { + type Event = Event; +} + +impl View for ContactList { + fn ui_name() -> &'static str { + "ContactList" + } + + fn keymap_context(&self, _: &AppContext) -> keymap::Context { + let mut cx = Self::default_keymap_context(); + cx.set.insert("menu".into()); + cx + } + + fn render(&mut self, cx: &mut RenderContext) -> ElementBox { + enum AddContact {} + let theme = cx.global::().theme.clone(); + + Flex::column() + .with_child( + Flex::row() + .with_child( + ChildView::new(self.filter_editor.clone(), cx) + .contained() + .with_style(theme.contact_list.user_query_editor.container) + .flex(1., true) + .boxed(), + ) + .with_child( + MouseEventHandler::::new(0, cx, |_, _| { + render_icon_button( + &theme.contact_list.add_contact_button, + "icons/user_plus_16.svg", + ) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, |_, cx| { + cx.dispatch_action(contacts_popover::ToggleContactFinder) + }) + .with_tooltip::( + 0, + "Add contact".into(), + None, + theme.tooltip.clone(), + cx, + ) + .boxed(), + ) + .constrained() + .with_height(theme.contact_list.user_query_editor_height) + .boxed(), + ) + .with_child(List::new(self.list_state.clone()).flex(1., false).boxed()) + .boxed() + } + + fn on_focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { + if !self.filter_editor.is_focused(cx) { + cx.focus(&self.filter_editor); + } + } + + fn on_focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { + if !self.filter_editor.is_focused(cx) { + cx.emit(Event::Dismissed); + } + } +} + +fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element { + Svg::new(svg_path) + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .contained() + .with_style(style.container) + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) +} diff --git a/crates/contacts_panel/src/contact_notification.rs b/crates/collab_ui/src/contact_notification.rs similarity index 96% rename from crates/contacts_panel/src/contact_notification.rs rename to crates/collab_ui/src/contact_notification.rs index c608346d79..f543a01446 100644 --- a/crates/contacts_panel/src/contact_notification.rs +++ b/crates/collab_ui/src/contact_notification.rs @@ -49,10 +49,7 @@ impl View for ContactNotification { self.user.clone(), "wants to add you as a contact", Some("They won't know if you decline."), - RespondToContactRequest { - user_id: self.user.id, - accept: false, - }, + Dismiss(self.user.id), vec![ ( "Decline", diff --git a/crates/collab_ui/src/contacts_popover.rs b/crates/collab_ui/src/contacts_popover.rs new file mode 100644 index 0000000000..075255d727 --- /dev/null +++ b/crates/collab_ui/src/contacts_popover.rs @@ -0,0 +1,171 @@ +use crate::{contact_finder::ContactFinder, contact_list::ContactList, ToggleCollaborationMenu}; +use client::UserStore; +use gpui::{ + actions, elements::*, ClipboardItem, CursorStyle, Entity, ModelHandle, MouseButton, + MutableAppContext, RenderContext, View, ViewContext, ViewHandle, +}; +use project::Project; +use settings::Settings; + +actions!(contacts_popover, [ToggleContactFinder]); + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(ContactsPopover::toggle_contact_finder); +} + +pub enum Event { + Dismissed, +} + +enum Child { + ContactList(ViewHandle), + ContactFinder(ViewHandle), +} + +pub struct ContactsPopover { + child: Child, + project: ModelHandle, + user_store: ModelHandle, + _subscription: Option, +} + +impl ContactsPopover { + pub fn new( + project: ModelHandle, + user_store: ModelHandle, + cx: &mut ViewContext, + ) -> Self { + let mut this = Self { + child: Child::ContactList( + cx.add_view(|cx| ContactList::new(project.clone(), user_store.clone(), cx)), + ), + project, + user_store, + _subscription: None, + }; + this.show_contact_list(cx); + this + } + + fn toggle_contact_finder(&mut self, _: &ToggleContactFinder, cx: &mut ViewContext) { + match &self.child { + Child::ContactList(_) => self.show_contact_finder(cx), + Child::ContactFinder(_) => self.show_contact_list(cx), + } + } + + fn show_contact_finder(&mut self, cx: &mut ViewContext) { + let child = cx.add_view(|cx| ContactFinder::new(self.user_store.clone(), cx)); + cx.focus(&child); + self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event { + crate::contact_finder::Event::Dismissed => cx.emit(Event::Dismissed), + })); + self.child = Child::ContactFinder(child); + cx.notify(); + } + + fn show_contact_list(&mut self, cx: &mut ViewContext) { + let child = + cx.add_view(|cx| ContactList::new(self.project.clone(), self.user_store.clone(), cx)); + cx.focus(&child); + self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event { + crate::contact_list::Event::Dismissed => cx.emit(Event::Dismissed), + })); + self.child = Child::ContactList(child); + cx.notify(); + } +} + +impl Entity for ContactsPopover { + type Event = Event; +} + +impl View for ContactsPopover { + fn ui_name() -> &'static str { + "ContactsPopover" + } + + fn render(&mut self, cx: &mut RenderContext) -> ElementBox { + let theme = cx.global::().theme.clone(); + let child = match &self.child { + Child::ContactList(child) => ChildView::new(child, cx), + Child::ContactFinder(child) => ChildView::new(child, cx), + }; + + MouseEventHandler::::new(0, cx, |_, cx| { + Flex::column() + .with_child(child.flex(1., true).boxed()) + .with_children( + self.user_store + .read(cx) + .invite_info() + .cloned() + .and_then(|info| { + enum InviteLink {} + + if info.count > 0 { + Some( + MouseEventHandler::::new(0, cx, |state, cx| { + let style = theme + .contacts_popover + .invite_row + .style_for(state, false) + .clone(); + + let copied = + cx.read_from_clipboard().map_or(false, |item| { + item.text().as_str() == info.url.as_ref() + }); + + Label::new( + format!( + "{} invite link ({} left)", + if copied { "Copied" } else { "Copy" }, + info.count + ), + style.label.clone(), + ) + .aligned() + .left() + .constrained() + .with_height(theme.contacts_popover.invite_row_height) + .contained() + .with_style(style.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new( + info.url.to_string(), + )); + cx.notify(); + }) + .boxed(), + ) + } else { + None + } + }), + ) + .contained() + .with_style(theme.contacts_popover.container) + .constrained() + .with_width(theme.contacts_popover.width) + .with_height(theme.contacts_popover.height) + .boxed() + }) + .on_down_out(MouseButton::Left, move |_, cx| { + cx.dispatch_action(ToggleCollaborationMenu); + }) + .boxed() + } + + fn on_focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { + if cx.is_self_focused() { + match &self.child { + Child::ContactList(child) => cx.focus(child), + Child::ContactFinder(child) => cx.focus(child), + } + } + } +} diff --git a/crates/collab_ui/src/incoming_call_notification.rs b/crates/collab_ui/src/incoming_call_notification.rs new file mode 100644 index 0000000000..ff359b9d9e --- /dev/null +++ b/crates/collab_ui/src/incoming_call_notification.rs @@ -0,0 +1,232 @@ +use call::{ActiveCall, IncomingCall}; +use client::proto; +use futures::StreamExt; +use gpui::{ + elements::*, + geometry::{rect::RectF, vector::vec2f}, + impl_internal_actions, CursorStyle, Entity, MouseButton, MutableAppContext, RenderContext, + View, ViewContext, WindowBounds, WindowKind, WindowOptions, +}; +use settings::Settings; +use util::ResultExt; +use workspace::JoinProject; + +impl_internal_actions!(incoming_call_notification, [RespondToCall]); + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(IncomingCallNotification::respond_to_call); + + let mut incoming_call = ActiveCall::global(cx).read(cx).incoming(); + cx.spawn(|mut cx| async move { + let mut notification_window = None; + while let Some(incoming_call) = incoming_call.next().await { + if let Some(window_id) = notification_window.take() { + cx.remove_window(window_id); + } + + if let Some(incoming_call) = incoming_call { + const PADDING: f32 = 16.; + let screen_size = cx.platform().screen_size(); + + let window_size = cx.read(|cx| { + let theme = &cx.global::().theme.incoming_call_notification; + vec2f(theme.window_width, theme.window_height) + }); + let (window_id, _) = cx.add_window( + WindowOptions { + bounds: WindowBounds::Fixed(RectF::new( + vec2f(screen_size.x() - window_size.x() - PADDING, PADDING), + window_size, + )), + titlebar: None, + center: false, + kind: WindowKind::PopUp, + is_movable: false, + }, + |_| IncomingCallNotification::new(incoming_call), + ); + notification_window = Some(window_id); + } + } + }) + .detach(); +} + +#[derive(Clone, PartialEq)] +struct RespondToCall { + accept: bool, +} + +pub struct IncomingCallNotification { + call: IncomingCall, +} + +impl IncomingCallNotification { + pub fn new(call: IncomingCall) -> Self { + Self { call } + } + + fn respond_to_call(&mut self, action: &RespondToCall, cx: &mut ViewContext) { + let active_call = ActiveCall::global(cx); + if action.accept { + let join = active_call.update(cx, |active_call, cx| active_call.accept_incoming(cx)); + let caller_user_id = self.call.caller.id; + let initial_project_id = self.call.initial_project.as_ref().map(|project| project.id); + cx.spawn_weak(|_, mut cx| async move { + join.await?; + if let Some(project_id) = initial_project_id { + cx.update(|cx| { + cx.dispatch_global_action(JoinProject { + project_id, + follow_user_id: caller_user_id, + }) + }); + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } else { + active_call.update(cx, |active_call, _| { + active_call.decline_incoming().log_err(); + }); + } + } + + fn render_caller(&self, cx: &mut RenderContext) -> ElementBox { + let theme = &cx.global::().theme.incoming_call_notification; + let default_project = proto::ParticipantProject::default(); + let initial_project = self + .call + .initial_project + .as_ref() + .unwrap_or(&default_project); + Flex::row() + .with_children(self.call.caller.avatar.clone().map(|avatar| { + Image::new(avatar) + .with_style(theme.caller_avatar) + .aligned() + .boxed() + })) + .with_child( + Flex::column() + .with_child( + Label::new( + self.call.caller.github_login.clone(), + theme.caller_username.text.clone(), + ) + .contained() + .with_style(theme.caller_username.container) + .boxed(), + ) + .with_child( + Label::new( + format!( + "is sharing a project in Zed{}", + if initial_project.worktree_root_names.is_empty() { + "" + } else { + ":" + } + ), + theme.caller_message.text.clone(), + ) + .contained() + .with_style(theme.caller_message.container) + .boxed(), + ) + .with_children(if initial_project.worktree_root_names.is_empty() { + None + } else { + Some( + Label::new( + initial_project.worktree_root_names.join(", "), + theme.worktree_roots.text.clone(), + ) + .contained() + .with_style(theme.worktree_roots.container) + .boxed(), + ) + }) + .contained() + .with_style(theme.caller_metadata) + .aligned() + .boxed(), + ) + .contained() + .with_style(theme.caller_container) + .flex(1., true) + .boxed() + } + + fn render_buttons(&self, cx: &mut RenderContext) -> ElementBox { + enum Accept {} + enum Decline {} + + Flex::column() + .with_child( + MouseEventHandler::::new(0, cx, |_, cx| { + let theme = &cx.global::().theme.incoming_call_notification; + Label::new("Accept".to_string(), theme.accept_button.text.clone()) + .aligned() + .contained() + .with_style(theme.accept_button.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, |_, cx| { + cx.dispatch_action(RespondToCall { accept: true }); + }) + .flex(1., true) + .boxed(), + ) + .with_child( + MouseEventHandler::::new(0, cx, |_, cx| { + let theme = &cx.global::().theme.incoming_call_notification; + Label::new("Decline".to_string(), theme.decline_button.text.clone()) + .aligned() + .contained() + .with_style(theme.decline_button.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, |_, cx| { + cx.dispatch_action(RespondToCall { accept: false }); + }) + .flex(1., true) + .boxed(), + ) + .constrained() + .with_width( + cx.global::() + .theme + .incoming_call_notification + .button_width, + ) + .boxed() + } +} + +impl Entity for IncomingCallNotification { + type Event = (); +} + +impl View for IncomingCallNotification { + fn ui_name() -> &'static str { + "IncomingCallNotification" + } + + fn render(&mut self, cx: &mut RenderContext) -> gpui::ElementBox { + let background = cx + .global::() + .theme + .incoming_call_notification + .background; + Flex::row() + .with_child(self.render_caller(cx)) + .with_child(self.render_buttons(cx)) + .contained() + .with_background_color(background) + .expanded() + .boxed() + } +} diff --git a/crates/contacts_panel/src/notifications.rs b/crates/collab_ui/src/notifications.rs similarity index 83% rename from crates/contacts_panel/src/notifications.rs rename to crates/collab_ui/src/notifications.rs index b9a6dba545..dcb9894006 100644 --- a/crates/contacts_panel/src/notifications.rs +++ b/crates/collab_ui/src/notifications.rs @@ -1,9 +1,7 @@ -use crate::render_icon_button; use client::User; use gpui::{ - elements::{Flex, Image, Label, MouseEventHandler, Padding, ParentElement, Text}, - platform::CursorStyle, - Action, Element, ElementBox, MouseButton, RenderContext, View, + elements::*, platform::CursorStyle, Action, Element, ElementBox, MouseButton, RenderContext, + View, }; use settings::Settings; use std::sync::Arc; @@ -53,11 +51,18 @@ pub fn render_user_notification( ) .with_child( MouseEventHandler::::new(user.id as usize, cx, |state, _| { - render_icon_button( - theme.dismiss_button.style_for(state, false), - "icons/x_mark_thin_8.svg", - ) - .boxed() + let style = theme.dismiss_button.style_for(state, false); + Svg::new("icons/x_mark_thin_8.svg") + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .contained() + .with_style(style.container) + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) + .boxed() }) .with_cursor_style(CursorStyle::PointingHand) .with_padding(Padding::uniform(5.)) diff --git a/crates/collab_ui/src/project_shared_notification.rs b/crates/collab_ui/src/project_shared_notification.rs new file mode 100644 index 0000000000..a17e11b079 --- /dev/null +++ b/crates/collab_ui/src/project_shared_notification.rs @@ -0,0 +1,232 @@ +use call::{room, ActiveCall}; +use client::User; +use collections::HashMap; +use gpui::{ + actions, + elements::*, + geometry::{rect::RectF, vector::vec2f}, + CursorStyle, Entity, MouseButton, MutableAppContext, RenderContext, View, ViewContext, + WindowBounds, WindowKind, WindowOptions, +}; +use settings::Settings; +use std::sync::Arc; +use workspace::JoinProject; + +actions!(project_shared_notification, [DismissProject]); + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(ProjectSharedNotification::join); + cx.add_action(ProjectSharedNotification::dismiss); + + let active_call = ActiveCall::global(cx); + let mut notification_windows = HashMap::default(); + cx.subscribe(&active_call, move |_, event, cx| match event { + room::Event::RemoteProjectShared { + owner, + project_id, + worktree_root_names, + } => { + const PADDING: f32 = 16.; + let screen_size = cx.platform().screen_size(); + + let theme = &cx.global::().theme.project_shared_notification; + let window_size = vec2f(theme.window_width, theme.window_height); + let (window_id, _) = cx.add_window( + WindowOptions { + bounds: WindowBounds::Fixed(RectF::new( + vec2f(screen_size.x() - window_size.x() - PADDING, PADDING), + window_size, + )), + titlebar: None, + center: false, + kind: WindowKind::PopUp, + is_movable: false, + }, + |_| { + ProjectSharedNotification::new( + owner.clone(), + *project_id, + worktree_root_names.clone(), + ) + }, + ); + notification_windows.insert(*project_id, window_id); + } + room::Event::RemoteProjectUnshared { project_id } => { + if let Some(window_id) = notification_windows.remove(&project_id) { + cx.remove_window(window_id); + } + } + room::Event::Left => { + for (_, window_id) in notification_windows.drain() { + cx.remove_window(window_id); + } + } + }) + .detach(); +} + +pub struct ProjectSharedNotification { + project_id: u64, + worktree_root_names: Vec, + owner: Arc, +} + +impl ProjectSharedNotification { + fn new(owner: Arc, project_id: u64, worktree_root_names: Vec) -> Self { + Self { + project_id, + worktree_root_names, + owner, + } + } + + fn join(&mut self, _: &JoinProject, cx: &mut ViewContext) { + let window_id = cx.window_id(); + cx.remove_window(window_id); + cx.propagate_action(); + } + + fn dismiss(&mut self, _: &DismissProject, cx: &mut ViewContext) { + let window_id = cx.window_id(); + cx.remove_window(window_id); + } + + fn render_owner(&self, cx: &mut RenderContext) -> ElementBox { + let theme = &cx.global::().theme.project_shared_notification; + Flex::row() + .with_children(self.owner.avatar.clone().map(|avatar| { + Image::new(avatar) + .with_style(theme.owner_avatar) + .aligned() + .boxed() + })) + .with_child( + Flex::column() + .with_child( + Label::new( + self.owner.github_login.clone(), + theme.owner_username.text.clone(), + ) + .contained() + .with_style(theme.owner_username.container) + .boxed(), + ) + .with_child( + Label::new( + format!( + "is sharing a project in Zed{}", + if self.worktree_root_names.is_empty() { + "" + } else { + ":" + } + ), + theme.message.text.clone(), + ) + .contained() + .with_style(theme.message.container) + .boxed(), + ) + .with_children(if self.worktree_root_names.is_empty() { + None + } else { + Some( + Label::new( + self.worktree_root_names.join(", "), + theme.worktree_roots.text.clone(), + ) + .contained() + .with_style(theme.worktree_roots.container) + .boxed(), + ) + }) + .contained() + .with_style(theme.owner_metadata) + .aligned() + .boxed(), + ) + .contained() + .with_style(theme.owner_container) + .flex(1., true) + .boxed() + } + + fn render_buttons(&self, cx: &mut RenderContext) -> ElementBox { + enum Open {} + enum Dismiss {} + + let project_id = self.project_id; + let owner_user_id = self.owner.id; + + Flex::column() + .with_child( + MouseEventHandler::::new(0, cx, |_, cx| { + let theme = &cx.global::().theme.project_shared_notification; + Label::new("Open".to_string(), theme.open_button.text.clone()) + .aligned() + .contained() + .with_style(theme.open_button.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(JoinProject { + project_id, + follow_user_id: owner_user_id, + }); + }) + .flex(1., true) + .boxed(), + ) + .with_child( + MouseEventHandler::::new(0, cx, |_, cx| { + let theme = &cx.global::().theme.project_shared_notification; + Label::new("Dismiss".to_string(), theme.dismiss_button.text.clone()) + .aligned() + .contained() + .with_style(theme.dismiss_button.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, |_, cx| { + cx.dispatch_action(DismissProject); + }) + .flex(1., true) + .boxed(), + ) + .constrained() + .with_width( + cx.global::() + .theme + .project_shared_notification + .button_width, + ) + .boxed() + } +} + +impl Entity for ProjectSharedNotification { + type Event = (); +} + +impl View for ProjectSharedNotification { + fn ui_name() -> &'static str { + "ProjectSharedNotification" + } + + fn render(&mut self, cx: &mut RenderContext) -> gpui::ElementBox { + let background = cx + .global::() + .theme + .project_shared_notification + .background; + Flex::row() + .with_child(self.render_owner(cx)) + .with_child(self.render_buttons(cx)) + .contained() + .with_background_color(background) + .expanded() + .boxed() + } +} diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index c12e68a854..7702aaaf2a 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -4,8 +4,8 @@ use gpui::{ actions, elements::{ChildView, Flex, Label, ParentElement}, keymap::Keystroke, - Action, AnyViewHandle, Element, Entity, MouseState, MutableAppContext, View, ViewContext, - ViewHandle, + Action, AnyViewHandle, Element, Entity, MouseState, MutableAppContext, RenderContext, View, + ViewContext, ViewHandle, }; use picker::{Picker, PickerDelegate}; use settings::Settings; @@ -131,8 +131,8 @@ impl View for CommandPalette { "CommandPalette" } - fn render(&mut self, _: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox { - ChildView::new(self.picker.clone()).boxed() + fn render(&mut self, cx: &mut RenderContext) -> gpui::ElementBox { + ChildView::new(self.picker.clone(), cx).boxed() } fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { @@ -224,7 +224,7 @@ impl PickerDelegate for CommandPalette { fn render_match( &self, ix: usize, - mouse_state: MouseState, + mouse_state: &mut MouseState, selected: bool, cx: &gpui::AppContext, ) -> gpui::ElementBox { diff --git a/crates/contacts_panel/Cargo.toml b/crates/contacts_panel/Cargo.toml deleted file mode 100644 index b68f48bb97..0000000000 --- a/crates/contacts_panel/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "contacts_panel" -version = "0.1.0" -edition = "2021" - -[lib] -path = "src/contacts_panel.rs" -doctest = false - -[dependencies] -client = { path = "../client" } -collections = { path = "../collections" } -editor = { path = "../editor" } -fuzzy = { path = "../fuzzy" } -gpui = { path = "../gpui" } -menu = { path = "../menu" } -picker = { path = "../picker" } -project = { path = "../project" } -settings = { path = "../settings" } -theme = { path = "../theme" } -util = { path = "../util" } -workspace = { path = "../workspace" } -anyhow = "1.0" -futures = "0.3" -log = "0.4" -postage = { version = "0.4.1", features = ["futures-traits"] } -serde = { version = "1.0", features = ["derive", "rc"] } - -[dev-dependencies] -language = { path = "../language", features = ["test-support"] } -project = { path = "../project", features = ["test-support"] } -workspace = { path = "../workspace", features = ["test-support"] } diff --git a/crates/contacts_panel/src/contacts_panel.rs b/crates/contacts_panel/src/contacts_panel.rs deleted file mode 100644 index b5460f4d06..0000000000 --- a/crates/contacts_panel/src/contacts_panel.rs +++ /dev/null @@ -1,1652 +0,0 @@ -mod contact_finder; -mod contact_notification; -mod join_project_notification; -mod notifications; - -use client::{Contact, ContactEventKind, User, UserStore}; -use contact_notification::ContactNotification; -use editor::{Cancel, Editor}; -use fuzzy::{match_strings, StringMatchCandidate}; -use gpui::{ - actions, - elements::*, - geometry::{rect::RectF, vector::vec2f}, - impl_actions, impl_internal_actions, - platform::CursorStyle, - AnyViewHandle, AppContext, ClipboardItem, Element, ElementBox, Entity, ModelHandle, - MouseButton, MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle, - WeakModelHandle, WeakViewHandle, -}; -use join_project_notification::JoinProjectNotification; -use menu::{Confirm, SelectNext, SelectPrev}; -use project::{Project, ProjectStore}; -use serde::Deserialize; -use settings::Settings; -use std::{ops::DerefMut, sync::Arc}; -use theme::IconButton; -use workspace::{sidebar::SidebarItem, JoinProject, ToggleProjectOnline, Workspace}; - -actions!(contacts_panel, [ToggleFocus]); - -impl_actions!( - contacts_panel, - [RequestContact, RemoveContact, RespondToContactRequest] -); - -impl_internal_actions!(contacts_panel, [ToggleExpanded]); - -#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)] -enum Section { - Requests, - Online, - Offline, -} - -#[derive(Clone)] -enum ContactEntry { - Header(Section), - IncomingRequest(Arc), - OutgoingRequest(Arc), - Contact(Arc), - ContactProject(Arc, usize, Option>), - OfflineProject(WeakModelHandle), -} - -#[derive(Clone, PartialEq)] -struct ToggleExpanded(Section); - -pub struct ContactsPanel { - entries: Vec, - match_candidates: Vec, - list_state: ListState, - user_store: ModelHandle, - project_store: ModelHandle, - filter_editor: ViewHandle, - collapsed_sections: Vec
, - selection: Option, - _maintain_contacts: Subscription, -} - -#[derive(Clone, Deserialize, PartialEq)] -pub struct RequestContact(pub u64); - -#[derive(Clone, Deserialize, PartialEq)] -pub struct RemoveContact(pub u64); - -#[derive(Clone, Deserialize, PartialEq)] -pub struct RespondToContactRequest { - pub user_id: u64, - pub accept: bool, -} - -pub fn init(cx: &mut MutableAppContext) { - contact_finder::init(cx); - contact_notification::init(cx); - join_project_notification::init(cx); - cx.add_action(ContactsPanel::request_contact); - cx.add_action(ContactsPanel::remove_contact); - cx.add_action(ContactsPanel::respond_to_contact_request); - cx.add_action(ContactsPanel::clear_filter); - cx.add_action(ContactsPanel::select_next); - cx.add_action(ContactsPanel::select_prev); - cx.add_action(ContactsPanel::confirm); - cx.add_action(ContactsPanel::toggle_expanded); -} - -impl ContactsPanel { - pub fn new( - user_store: ModelHandle, - project_store: ModelHandle, - workspace: WeakViewHandle, - cx: &mut ViewContext, - ) -> Self { - let filter_editor = cx.add_view(|cx| { - let mut editor = Editor::single_line( - Some(|theme| theme.contacts_panel.user_query_editor.clone()), - cx, - ); - editor.set_placeholder_text("Filter contacts", cx); - editor - }); - - cx.subscribe(&filter_editor, |this, _, event, cx| { - if let editor::Event::BufferEdited = event { - let query = this.filter_editor.read(cx).text(cx); - if !query.is_empty() { - this.selection.take(); - } - this.update_entries(cx); - if !query.is_empty() { - this.selection = this - .entries - .iter() - .position(|entry| !matches!(entry, ContactEntry::Header(_))); - } - } - }) - .detach(); - - cx.defer({ - let workspace = workspace.clone(); - move |_, cx| { - if let Some(workspace_handle) = workspace.upgrade(cx) { - cx.subscribe(&workspace_handle.read(cx).project().clone(), { - let workspace = workspace; - move |_, project, event, cx| { - if let project::Event::ContactRequestedJoin(user) = event { - if let Some(workspace) = workspace.upgrade(cx) { - workspace.update(cx, |workspace, cx| { - workspace.show_notification(user.id as usize, cx, |cx| { - cx.add_view(|cx| { - JoinProjectNotification::new( - project, - user.clone(), - cx, - ) - }) - }) - }); - } - } - } - }) - .detach(); - } - } - }); - - cx.observe(&project_store, |this, _, cx| this.update_entries(cx)) - .detach(); - - cx.subscribe(&user_store, move |_, user_store, event, cx| { - if let Some(workspace) = workspace.upgrade(cx) { - workspace.update(cx, |workspace, cx| { - if let client::Event::Contact { user, kind } = event { - if let ContactEventKind::Requested | ContactEventKind::Accepted = kind { - workspace.show_notification(user.id as usize, cx, |cx| { - cx.add_view(|cx| { - ContactNotification::new(user.clone(), *kind, user_store, cx) - }) - }) - } - } - }); - } - - if let client::Event::ShowContacts = event { - cx.emit(Event::Activate); - } - }) - .detach(); - - let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| { - let theme = cx.global::().theme.clone(); - let current_user_id = this.user_store.read(cx).current_user().map(|user| user.id); - let is_selected = this.selection == Some(ix); - - match &this.entries[ix] { - ContactEntry::Header(section) => { - let is_collapsed = this.collapsed_sections.contains(section); - Self::render_header( - *section, - &theme.contacts_panel, - is_selected, - is_collapsed, - cx, - ) - } - ContactEntry::IncomingRequest(user) => Self::render_contact_request( - user.clone(), - this.user_store.clone(), - &theme.contacts_panel, - true, - is_selected, - cx, - ), - ContactEntry::OutgoingRequest(user) => Self::render_contact_request( - user.clone(), - this.user_store.clone(), - &theme.contacts_panel, - false, - is_selected, - cx, - ), - ContactEntry::Contact(contact) => { - Self::render_contact(&contact.user, &theme.contacts_panel, is_selected) - } - ContactEntry::ContactProject(contact, project_ix, open_project) => { - let is_last_project_for_contact = - this.entries.get(ix + 1).map_or(true, |next| { - if let ContactEntry::ContactProject(next_contact, _, _) = next { - next_contact.user.id != contact.user.id - } else { - true - } - }); - Self::render_project( - contact.clone(), - current_user_id, - *project_ix, - *open_project, - &theme.contacts_panel, - &theme.tooltip, - is_last_project_for_contact, - is_selected, - cx, - ) - } - ContactEntry::OfflineProject(project) => Self::render_offline_project( - *project, - &theme.contacts_panel, - &theme.tooltip, - is_selected, - cx, - ), - } - }); - - let mut this = Self { - list_state, - selection: None, - collapsed_sections: Default::default(), - entries: Default::default(), - match_candidates: Default::default(), - filter_editor, - _maintain_contacts: cx.observe(&user_store, |this, _, cx| this.update_entries(cx)), - user_store, - project_store, - }; - this.update_entries(cx); - this - } - - fn render_header( - section: Section, - theme: &theme::ContactsPanel, - is_selected: bool, - is_collapsed: bool, - cx: &mut RenderContext, - ) -> ElementBox { - enum Header {} - - let header_style = theme.header_row.style_for(Default::default(), is_selected); - let text = match section { - Section::Requests => "Requests", - Section::Online => "Online", - Section::Offline => "Offline", - }; - let icon_size = theme.section_icon_size; - MouseEventHandler::
::new(section as usize, cx, |_, _| { - Flex::row() - .with_child( - Svg::new(if is_collapsed { - "icons/chevron_right_8.svg" - } else { - "icons/chevron_down_8.svg" - }) - .with_color(header_style.text.color) - .constrained() - .with_max_width(icon_size) - .with_max_height(icon_size) - .aligned() - .constrained() - .with_width(icon_size) - .boxed(), - ) - .with_child( - Label::new(text.to_string(), header_style.text.clone()) - .aligned() - .left() - .contained() - .with_margin_left(theme.contact_username.container.margin.left) - .flex(1., true) - .boxed(), - ) - .constrained() - .with_height(theme.row_height) - .contained() - .with_style(header_style.container) - .boxed() - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, cx| { - cx.dispatch_action(ToggleExpanded(section)) - }) - .boxed() - } - - fn render_contact(user: &User, theme: &theme::ContactsPanel, is_selected: bool) -> ElementBox { - Flex::row() - .with_children(user.avatar.clone().map(|avatar| { - Image::new(avatar) - .with_style(theme.contact_avatar) - .aligned() - .left() - .boxed() - })) - .with_child( - Label::new( - user.github_login.clone(), - theme.contact_username.text.clone(), - ) - .contained() - .with_style(theme.contact_username.container) - .aligned() - .left() - .flex(1., true) - .boxed(), - ) - .constrained() - .with_height(theme.row_height) - .contained() - .with_style(*theme.contact_row.style_for(Default::default(), is_selected)) - .boxed() - } - - #[allow(clippy::too_many_arguments)] - fn render_project( - contact: Arc, - current_user_id: Option, - project_index: usize, - open_project: Option>, - theme: &theme::ContactsPanel, - tooltip_style: &TooltipStyle, - is_last_project: bool, - is_selected: bool, - cx: &mut RenderContext, - ) -> ElementBox { - enum ToggleOnline {} - - let project = &contact.projects[project_index]; - let project_id = project.id; - let is_host = Some(contact.user.id) == current_user_id; - let open_project = open_project.and_then(|p| p.upgrade(cx.deref_mut())); - - let font_cache = cx.font_cache(); - let host_avatar_height = theme - .contact_avatar - .width - .or(theme.contact_avatar.height) - .unwrap_or(0.); - let row = &theme.project_row.default; - let tree_branch = theme.tree_branch; - let line_height = row.name.text.line_height(font_cache); - let cap_height = row.name.text.cap_height(font_cache); - let baseline_offset = - row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.; - - MouseEventHandler::::new(project_id as usize, cx, |mouse_state, cx| { - let tree_branch = *tree_branch.style_for(mouse_state, is_selected); - let row = theme.project_row.style_for(mouse_state, is_selected); - - Flex::row() - .with_child( - Stack::new() - .with_child( - Canvas::new(move |bounds, _, cx| { - let start_x = bounds.min_x() + (bounds.width() / 2.) - - (tree_branch.width / 2.); - let end_x = bounds.max_x(); - let start_y = bounds.min_y(); - let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.); - - cx.scene.push_quad(gpui::Quad { - bounds: RectF::from_points( - vec2f(start_x, start_y), - vec2f( - start_x + tree_branch.width, - if is_last_project { - end_y - } else { - bounds.max_y() - }, - ), - ), - background: Some(tree_branch.color), - border: gpui::Border::default(), - corner_radius: 0., - }); - cx.scene.push_quad(gpui::Quad { - bounds: RectF::from_points( - vec2f(start_x, end_y), - vec2f(end_x, end_y + tree_branch.width), - ), - background: Some(tree_branch.color), - border: gpui::Border::default(), - corner_radius: 0., - }); - }) - .boxed(), - ) - .with_children(open_project.and_then(|open_project| { - let is_going_offline = !open_project.read(cx).is_online(); - if !mouse_state.hovered && !is_going_offline { - return None; - } - - let button = MouseEventHandler::::new( - project_id as usize, - cx, - |state, _| { - let mut icon_style = - *theme.private_button.style_for(state, false); - icon_style.container.background_color = - row.container.background_color; - if is_going_offline { - icon_style.color = theme.disabled_button.color; - } - render_icon_button(&icon_style, "icons/lock_8.svg") - .aligned() - .boxed() - }, - ); - - if is_going_offline { - Some(button.boxed()) - } else { - Some( - button - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, cx| { - cx.dispatch_action(ToggleProjectOnline { - project: Some(open_project.clone()), - }) - }) - .with_tooltip::( - project_id as usize, - "Take project offline".to_string(), - None, - tooltip_style.clone(), - cx, - ) - .boxed(), - ) - } - })) - .constrained() - .with_width(host_avatar_height) - .boxed(), - ) - .with_child( - Label::new( - project.visible_worktree_root_names.join(", "), - row.name.text.clone(), - ) - .aligned() - .left() - .contained() - .with_style(row.name.container) - .flex(1., false) - .boxed(), - ) - .with_children(project.guests.iter().filter_map(|participant| { - participant.avatar.clone().map(|avatar| { - Image::new(avatar) - .with_style(row.guest_avatar) - .aligned() - .left() - .contained() - .with_margin_right(row.guest_avatar_spacing) - .boxed() - }) - })) - .constrained() - .with_height(theme.row_height) - .contained() - .with_style(row.container) - .boxed() - }) - .with_cursor_style(if !is_host { - CursorStyle::PointingHand - } else { - CursorStyle::Arrow - }) - .on_click(MouseButton::Left, move |_, cx| { - if !is_host { - cx.dispatch_global_action(JoinProject { - contact: contact.clone(), - project_index, - }); - } - }) - .boxed() - } - - fn render_offline_project( - project_handle: WeakModelHandle, - theme: &theme::ContactsPanel, - tooltip_style: &TooltipStyle, - is_selected: bool, - cx: &mut RenderContext, - ) -> ElementBox { - let host_avatar_height = theme - .contact_avatar - .width - .or(theme.contact_avatar.height) - .unwrap_or(0.); - - enum LocalProject {} - enum ToggleOnline {} - - let project_id = project_handle.id(); - MouseEventHandler::::new(project_id, cx, |state, cx| { - let row = theme.project_row.style_for(state, is_selected); - let mut worktree_root_names = String::new(); - let project = if let Some(project) = project_handle.upgrade(cx.deref_mut()) { - project.read(cx) - } else { - return Empty::new().boxed(); - }; - let is_going_online = project.is_online(); - for tree in project.visible_worktrees(cx) { - if !worktree_root_names.is_empty() { - worktree_root_names.push_str(", "); - } - worktree_root_names.push_str(tree.read(cx).root_name()); - } - - Flex::row() - .with_child({ - let button = - MouseEventHandler::::new(project_id, cx, |state, _| { - let mut style = *theme.private_button.style_for(state, false); - if is_going_online { - style.color = theme.disabled_button.color; - } - render_icon_button(&style, "icons/lock_8.svg") - .aligned() - .constrained() - .with_width(host_avatar_height) - .boxed() - }); - - if is_going_online { - button.boxed() - } else { - button - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, cx| { - let project = project_handle.upgrade(cx.app); - cx.dispatch_action(ToggleProjectOnline { project }) - }) - .with_tooltip::( - project_id, - "Take project online".to_string(), - None, - tooltip_style.clone(), - cx, - ) - .boxed() - } - }) - .with_child( - Label::new(worktree_root_names, row.name.text.clone()) - .aligned() - .left() - .contained() - .with_style(row.name.container) - .flex(1., false) - .boxed(), - ) - .constrained() - .with_height(theme.row_height) - .contained() - .with_style(row.container) - .boxed() - }) - .boxed() - } - - fn render_contact_request( - user: Arc, - user_store: ModelHandle, - theme: &theme::ContactsPanel, - is_incoming: bool, - is_selected: bool, - cx: &mut RenderContext, - ) -> ElementBox { - enum Decline {} - enum Accept {} - enum Cancel {} - - let mut row = Flex::row() - .with_children(user.avatar.clone().map(|avatar| { - Image::new(avatar) - .with_style(theme.contact_avatar) - .aligned() - .left() - .boxed() - })) - .with_child( - Label::new( - user.github_login.clone(), - theme.contact_username.text.clone(), - ) - .contained() - .with_style(theme.contact_username.container) - .aligned() - .left() - .flex(1., true) - .boxed(), - ); - - let user_id = user.id; - let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user); - let button_spacing = theme.contact_button_spacing; - - if is_incoming { - row.add_children([ - MouseEventHandler::::new(user.id as usize, cx, |mouse_state, _| { - let button_style = if is_contact_request_pending { - &theme.disabled_button - } else { - theme.contact_button.style_for(mouse_state, false) - }; - render_icon_button(button_style, "icons/x_mark_8.svg") - .aligned() - // .flex_float() - .boxed() - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, cx| { - cx.dispatch_action(RespondToContactRequest { - user_id, - accept: false, - }) - }) - // .flex_float() - .contained() - .with_margin_right(button_spacing) - .boxed(), - MouseEventHandler::::new(user.id as usize, cx, |mouse_state, _| { - let button_style = if is_contact_request_pending { - &theme.disabled_button - } else { - theme.contact_button.style_for(mouse_state, false) - }; - render_icon_button(button_style, "icons/check_8.svg") - .aligned() - .flex_float() - .boxed() - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, cx| { - cx.dispatch_action(RespondToContactRequest { - user_id, - accept: true, - }) - }) - .boxed(), - ]); - } else { - row.add_child( - MouseEventHandler::::new(user.id as usize, cx, |mouse_state, _| { - let button_style = if is_contact_request_pending { - &theme.disabled_button - } else { - theme.contact_button.style_for(mouse_state, false) - }; - render_icon_button(button_style, "icons/x_mark_8.svg") - .aligned() - .flex_float() - .boxed() - }) - .with_padding(Padding::uniform(2.)) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, cx| { - cx.dispatch_action(RemoveContact(user_id)) - }) - .flex_float() - .boxed(), - ); - } - - row.constrained() - .with_height(theme.row_height) - .contained() - .with_style(*theme.contact_row.style_for(Default::default(), is_selected)) - .boxed() - } - - fn update_entries(&mut self, cx: &mut ViewContext) { - let user_store = self.user_store.read(cx); - let project_store = self.project_store.read(cx); - let query = self.filter_editor.read(cx).text(cx); - let executor = cx.background().clone(); - - let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned()); - self.entries.clear(); - - let mut request_entries = Vec::new(); - let incoming = user_store.incoming_contact_requests(); - if !incoming.is_empty() { - self.match_candidates.clear(); - self.match_candidates - .extend( - incoming - .iter() - .enumerate() - .map(|(ix, user)| StringMatchCandidate { - id: ix, - string: user.github_login.clone(), - char_bag: user.github_login.chars().collect(), - }), - ); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - request_entries.extend( - matches - .iter() - .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())), - ); - } - - let outgoing = user_store.outgoing_contact_requests(); - if !outgoing.is_empty() { - self.match_candidates.clear(); - self.match_candidates - .extend( - outgoing - .iter() - .enumerate() - .map(|(ix, user)| StringMatchCandidate { - id: ix, - string: user.github_login.clone(), - char_bag: user.github_login.chars().collect(), - }), - ); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - request_entries.extend( - matches - .iter() - .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())), - ); - } - - if !request_entries.is_empty() { - self.entries.push(ContactEntry::Header(Section::Requests)); - if !self.collapsed_sections.contains(&Section::Requests) { - self.entries.append(&mut request_entries); - } - } - - let current_user = user_store.current_user(); - - let contacts = user_store.contacts(); - if !contacts.is_empty() { - // Always put the current user first. - self.match_candidates.clear(); - self.match_candidates.reserve(contacts.len()); - self.match_candidates.push(StringMatchCandidate { - id: 0, - string: Default::default(), - char_bag: Default::default(), - }); - for (ix, contact) in contacts.iter().enumerate() { - let candidate = StringMatchCandidate { - id: ix, - string: contact.user.github_login.clone(), - char_bag: contact.user.github_login.chars().collect(), - }; - if current_user - .as_ref() - .map_or(false, |current_user| current_user.id == contact.user.id) - { - self.match_candidates[0] = candidate; - } else { - self.match_candidates.push(candidate); - } - } - if self.match_candidates[0].string.is_empty() { - self.match_candidates.remove(0); - } - - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - - let (online_contacts, offline_contacts) = matches - .iter() - .partition::, _>(|mat| contacts[mat.candidate_id].online); - - for (matches, section) in [ - (online_contacts, Section::Online), - (offline_contacts, Section::Offline), - ] { - if !matches.is_empty() { - self.entries.push(ContactEntry::Header(section)); - if !self.collapsed_sections.contains(§ion) { - for mat in matches { - let contact = &contacts[mat.candidate_id]; - self.entries.push(ContactEntry::Contact(contact.clone())); - - let is_current_user = current_user - .as_ref() - .map_or(false, |user| user.id == contact.user.id); - if is_current_user { - let mut open_projects = - project_store.projects(cx).collect::>(); - self.entries.extend( - contact.projects.iter().enumerate().filter_map( - |(ix, project)| { - let open_project = open_projects - .iter() - .position(|p| { - p.read(cx).remote_id() == Some(project.id) - }) - .map(|ix| open_projects.remove(ix).downgrade()); - if project.visible_worktree_root_names.is_empty() { - None - } else { - Some(ContactEntry::ContactProject( - contact.clone(), - ix, - open_project, - )) - } - }, - ), - ); - self.entries.extend(open_projects.into_iter().filter_map( - |project| { - if project.read(cx).visible_worktrees(cx).next().is_none() { - None - } else { - Some(ContactEntry::OfflineProject(project.downgrade())) - } - }, - )); - } else { - self.entries.extend( - contact.projects.iter().enumerate().filter_map( - |(ix, project)| { - if project.visible_worktree_root_names.is_empty() { - None - } else { - Some(ContactEntry::ContactProject( - contact.clone(), - ix, - None, - )) - } - }, - ), - ); - } - } - } - } - } - } - - if let Some(prev_selected_entry) = prev_selected_entry { - self.selection.take(); - for (ix, entry) in self.entries.iter().enumerate() { - if *entry == prev_selected_entry { - self.selection = Some(ix); - break; - } - } - } - - self.list_state.reset(self.entries.len()); - cx.notify(); - } - - fn request_contact(&mut self, request: &RequestContact, cx: &mut ViewContext) { - self.user_store - .update(cx, |store, cx| store.request_contact(request.0, cx)) - .detach(); - } - - fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext) { - self.user_store - .update(cx, |store, cx| store.remove_contact(request.0, cx)) - .detach(); - } - - fn respond_to_contact_request( - &mut self, - action: &RespondToContactRequest, - cx: &mut ViewContext, - ) { - self.user_store - .update(cx, |store, cx| { - store.respond_to_contact_request(action.user_id, action.accept, cx) - }) - .detach(); - } - - fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext) { - let did_clear = self.filter_editor.update(cx, |editor, cx| { - if editor.buffer().read(cx).len(cx) > 0 { - editor.set_text("", cx); - true - } else { - false - } - }); - if !did_clear { - cx.propagate_action(); - } - } - - fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { - if let Some(ix) = self.selection { - if self.entries.len() > ix + 1 { - self.selection = Some(ix + 1); - } - } else if !self.entries.is_empty() { - self.selection = Some(0); - } - cx.notify(); - self.list_state.reset(self.entries.len()); - } - - fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { - if let Some(ix) = self.selection { - if ix > 0 { - self.selection = Some(ix - 1); - } else { - self.selection = None; - } - } - cx.notify(); - self.list_state.reset(self.entries.len()); - } - - fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { - if let Some(selection) = self.selection { - if let Some(entry) = self.entries.get(selection) { - match entry { - ContactEntry::Header(section) => { - let section = *section; - self.toggle_expanded(&ToggleExpanded(section), cx); - } - ContactEntry::ContactProject(contact, project_index, open_project) => { - if let Some(open_project) = open_project { - workspace::activate_workspace_for_project(cx, |_, cx| { - cx.model_id() == open_project.id() - }); - } else { - cx.dispatch_global_action(JoinProject { - contact: contact.clone(), - project_index: *project_index, - }) - } - } - _ => {} - } - } - } - } - - fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext) { - let section = action.0; - if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) { - self.collapsed_sections.remove(ix); - } else { - self.collapsed_sections.push(section); - } - self.update_entries(cx); - } -} - -impl SidebarItem for ContactsPanel { - fn should_show_badge(&self, cx: &AppContext) -> bool { - !self - .user_store - .read(cx) - .incoming_contact_requests() - .is_empty() - } - - fn contains_focused_view(&self, cx: &AppContext) -> bool { - self.filter_editor.is_focused(cx) - } - - fn should_activate_item_on_event(&self, event: &Event, _: &AppContext) -> bool { - matches!(event, Event::Activate) - } -} - -fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element { - Svg::new(svg_path) - .with_color(style.color) - .constrained() - .with_width(style.icon_width) - .aligned() - .contained() - .with_style(style.container) - .constrained() - .with_width(style.button_width) - .with_height(style.button_width) -} - -pub enum Event { - Activate, -} - -impl Entity for ContactsPanel { - type Event = Event; -} - -impl View for ContactsPanel { - fn ui_name() -> &'static str { - "ContactsPanel" - } - - fn render(&mut self, cx: &mut RenderContext) -> ElementBox { - enum AddContact {} - - let theme = cx.global::().theme.clone(); - let theme = &theme.contacts_panel; - Container::new( - Flex::column() - .with_child( - Flex::row() - .with_child( - ChildView::new(self.filter_editor.clone()) - .contained() - .with_style(theme.user_query_editor.container) - .flex(1., true) - .boxed(), - ) - .with_child( - MouseEventHandler::::new(0, cx, |_, _| { - Svg::new("icons/user_plus_16.svg") - .with_color(theme.add_contact_button.color) - .constrained() - .with_height(16.) - .contained() - .with_style(theme.add_contact_button.container) - .aligned() - .boxed() - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, |_, cx| { - cx.dispatch_action(contact_finder::Toggle) - }) - .boxed(), - ) - .constrained() - .with_height(theme.user_query_editor_height) - .boxed(), - ) - .with_child(List::new(self.list_state.clone()).flex(1., false).boxed()) - .with_children( - self.user_store - .read(cx) - .invite_info() - .cloned() - .and_then(|info| { - enum InviteLink {} - - if info.count > 0 { - Some( - MouseEventHandler::::new(0, cx, |state, cx| { - let style = - theme.invite_row.style_for(state, false).clone(); - - let copied = - cx.read_from_clipboard().map_or(false, |item| { - item.text().as_str() == info.url.as_ref() - }); - - Label::new( - format!( - "{} invite link ({} left)", - if copied { "Copied" } else { "Copy" }, - info.count - ), - style.label.clone(), - ) - .aligned() - .left() - .constrained() - .with_height(theme.row_height) - .contained() - .with_style(style.container) - .boxed() - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, cx| { - cx.write_to_clipboard(ClipboardItem::new( - info.url.to_string(), - )); - cx.notify(); - }) - .boxed(), - ) - } else { - None - } - }), - ) - .boxed(), - ) - .with_style(theme.container) - .boxed() - } - - fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { - cx.focus(&self.filter_editor); - } - - fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context { - let mut cx = Self::default_keymap_context(); - cx.set.insert("menu".into()); - cx - } -} - -impl PartialEq for ContactEntry { - fn eq(&self, other: &Self) -> bool { - match self { - ContactEntry::Header(section_1) => { - if let ContactEntry::Header(section_2) = other { - return section_1 == section_2; - } - } - ContactEntry::IncomingRequest(user_1) => { - if let ContactEntry::IncomingRequest(user_2) = other { - return user_1.id == user_2.id; - } - } - ContactEntry::OutgoingRequest(user_1) => { - if let ContactEntry::OutgoingRequest(user_2) = other { - return user_1.id == user_2.id; - } - } - ContactEntry::Contact(contact_1) => { - if let ContactEntry::Contact(contact_2) = other { - return contact_1.user.id == contact_2.user.id; - } - } - ContactEntry::ContactProject(contact_1, ix_1, _) => { - if let ContactEntry::ContactProject(contact_2, ix_2, _) = other { - return contact_1.user.id == contact_2.user.id && ix_1 == ix_2; - } - } - ContactEntry::OfflineProject(project_1) => { - if let ContactEntry::OfflineProject(project_2) = other { - return project_1.id() == project_2.id(); - } - } - } - false - } -} - -#[cfg(test)] -mod tests { - use super::*; - use client::{ - proto, - test::{FakeHttpClient, FakeServer}, - Client, - }; - use collections::HashSet; - use gpui::{serde_json::json, TestAppContext}; - use language::LanguageRegistry; - use project::{FakeFs, Project}; - - #[gpui::test] - async fn test_contact_panel(cx: &mut TestAppContext) { - Settings::test_async(cx); - let current_user_id = 100; - - let languages = Arc::new(LanguageRegistry::test()); - let http_client = FakeHttpClient::with_404_response(); - let client = Client::new(http_client.clone()); - let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx)); - let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake())); - let server = FakeServer::for_client(current_user_id, &client, cx).await; - let fs = FakeFs::new(cx.background()); - fs.insert_tree("/private_dir", json!({ "one.rs": "" })) - .await; - let project = cx.update(|cx| { - Project::local( - false, - client.clone(), - user_store.clone(), - project_store.clone(), - languages, - fs, - cx, - ) - }); - let worktree_id = project - .update(cx, |project, cx| { - project.find_or_create_local_worktree("/private_dir", true, cx) - }) - .await - .unwrap() - .0 - .read_with(cx, |worktree, _| worktree.id().to_proto()); - - let (_, workspace) = - cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx)); - let panel = cx.add_view(&workspace, |cx| { - ContactsPanel::new( - user_store.clone(), - project_store.clone(), - workspace.downgrade(), - cx, - ) - }); - - workspace.update(cx, |_, cx| { - cx.observe(&panel, |_, panel, cx| { - let entries = render_to_strings(&panel, cx); - assert!( - entries.iter().collect::>().len() == entries.len(), - "Duplicate contact panel entries {:?}", - entries - ) - }) - .detach(); - }); - - let get_users_request = server.receive::().await.unwrap(); - server - .respond( - get_users_request.receipt(), - proto::UsersResponse { - users: [ - "user_zero", - "user_one", - "user_two", - "user_three", - "user_four", - "user_five", - ] - .into_iter() - .enumerate() - .map(|(id, name)| proto::User { - id: id as u64, - github_login: name.to_string(), - ..Default::default() - }) - .chain([proto::User { - id: current_user_id, - github_login: "the_current_user".to_string(), - ..Default::default() - }]) - .collect(), - }, - ) - .await; - - let request = server.receive::().await.unwrap(); - server - .respond( - request.receipt(), - proto::RegisterProjectResponse { project_id: 200 }, - ) - .await; - - server.send(proto::UpdateContacts { - incoming_requests: vec![proto::IncomingContactRequest { - requester_id: 1, - should_notify: false, - }], - outgoing_requests: vec![2], - contacts: vec![ - proto::Contact { - user_id: 3, - online: true, - should_notify: false, - projects: vec![proto::ProjectMetadata { - id: 101, - visible_worktree_root_names: vec!["dir1".to_string()], - guests: vec![2], - }], - }, - proto::Contact { - user_id: 4, - online: true, - should_notify: false, - projects: vec![proto::ProjectMetadata { - id: 102, - visible_worktree_root_names: vec!["dir2".to_string()], - guests: vec![2], - }], - }, - proto::Contact { - user_id: 5, - online: false, - should_notify: false, - projects: vec![], - }, - proto::Contact { - user_id: current_user_id, - online: true, - should_notify: false, - projects: vec![proto::ProjectMetadata { - id: 103, - visible_worktree_root_names: vec!["dir3".to_string()], - guests: vec![3], - }], - }, - ], - ..Default::default() - }); - - assert_eq!( - server - .receive::() - .await - .unwrap() - .payload, - proto::UpdateProject { - project_id: 200, - online: false, - worktrees: vec![] - }, - ); - - cx.foreground().run_until_parked(); - assert_eq!( - cx.read(|cx| render_to_strings(&panel, cx)), - &[ - "v Requests", - " incoming user_one", - " outgoing user_two", - "v Online", - " the_current_user", - " dir3", - " 🔒 private_dir", - " user_four", - " dir2", - " user_three", - " dir1", - "v Offline", - " user_five", - ] - ); - - // Take a project online. It appears as loading, since the project - // isn't yet visible to other contacts. - project.update(cx, |project, cx| project.set_online(true, cx)); - cx.foreground().run_until_parked(); - assert_eq!( - cx.read(|cx| render_to_strings(&panel, cx)), - &[ - "v Requests", - " incoming user_one", - " outgoing user_two", - "v Online", - " the_current_user", - " dir3", - " 🔒 private_dir (going online...)", - " user_four", - " dir2", - " user_three", - " dir1", - "v Offline", - " user_five", - ] - ); - - // The server receives the project's metadata and updates the contact metadata - // for the current user. Now the project appears as online. - assert_eq!( - server - .receive::() - .await - .unwrap() - .payload, - proto::UpdateProject { - project_id: 200, - online: true, - worktrees: vec![proto::WorktreeMetadata { - id: worktree_id, - root_name: "private_dir".to_string(), - visible: true, - }] - }, - ); - server - .receive::() - .await - .unwrap(); - - server.send(proto::UpdateContacts { - contacts: vec![proto::Contact { - user_id: current_user_id, - online: true, - should_notify: false, - projects: vec![ - proto::ProjectMetadata { - id: 103, - visible_worktree_root_names: vec!["dir3".to_string()], - guests: vec![3], - }, - proto::ProjectMetadata { - id: 200, - visible_worktree_root_names: vec!["private_dir".to_string()], - guests: vec![3], - }, - ], - }], - ..Default::default() - }); - cx.foreground().run_until_parked(); - assert_eq!( - cx.read(|cx| render_to_strings(&panel, cx)), - &[ - "v Requests", - " incoming user_one", - " outgoing user_two", - "v Online", - " the_current_user", - " dir3", - " private_dir", - " user_four", - " dir2", - " user_three", - " dir1", - "v Offline", - " user_five", - ] - ); - - // Take the project offline. It appears as loading. - project.update(cx, |project, cx| project.set_online(false, cx)); - cx.foreground().run_until_parked(); - assert_eq!( - cx.read(|cx| render_to_strings(&panel, cx)), - &[ - "v Requests", - " incoming user_one", - " outgoing user_two", - "v Online", - " the_current_user", - " dir3", - " private_dir (going offline...)", - " user_four", - " dir2", - " user_three", - " dir1", - "v Offline", - " user_five", - ] - ); - - // The server receives the unregister request and updates the contact - // metadata for the current user. The project is now offline. - assert_eq!( - server - .receive::() - .await - .unwrap() - .payload, - proto::UpdateProject { - project_id: 200, - online: false, - worktrees: vec![] - }, - ); - - server.send(proto::UpdateContacts { - contacts: vec![proto::Contact { - user_id: current_user_id, - online: true, - should_notify: false, - projects: vec![proto::ProjectMetadata { - id: 103, - visible_worktree_root_names: vec!["dir3".to_string()], - guests: vec![3], - }], - }], - ..Default::default() - }); - cx.foreground().run_until_parked(); - assert_eq!( - cx.read(|cx| render_to_strings(&panel, cx)), - &[ - "v Requests", - " incoming user_one", - " outgoing user_two", - "v Online", - " the_current_user", - " dir3", - " 🔒 private_dir", - " user_four", - " dir2", - " user_three", - " dir1", - "v Offline", - " user_five", - ] - ); - - panel.update(cx, |panel, cx| { - panel - .filter_editor - .update(cx, |editor, cx| editor.set_text("f", cx)) - }); - cx.foreground().run_until_parked(); - assert_eq!( - cx.read(|cx| render_to_strings(&panel, cx)), - &[ - "v Online", - " user_four <=== selected", - " dir2", - "v Offline", - " user_five", - ] - ); - - panel.update(cx, |panel, cx| { - panel.select_next(&Default::default(), cx); - }); - assert_eq!( - cx.read(|cx| render_to_strings(&panel, cx)), - &[ - "v Online", - " user_four", - " dir2 <=== selected", - "v Offline", - " user_five", - ] - ); - - panel.update(cx, |panel, cx| { - panel.select_next(&Default::default(), cx); - }); - assert_eq!( - cx.read(|cx| render_to_strings(&panel, cx)), - &[ - "v Online", - " user_four", - " dir2", - "v Offline <=== selected", - " user_five", - ] - ); - } - - fn render_to_strings(panel: &ViewHandle, cx: &AppContext) -> Vec { - let panel = panel.read(cx); - let mut entries = Vec::new(); - entries.extend(panel.entries.iter().enumerate().map(|(ix, entry)| { - let mut string = match entry { - ContactEntry::Header(name) => { - let icon = if panel.collapsed_sections.contains(name) { - ">" - } else { - "v" - }; - format!("{} {:?}", icon, name) - } - ContactEntry::IncomingRequest(user) => { - format!(" incoming {}", user.github_login) - } - ContactEntry::OutgoingRequest(user) => { - format!(" outgoing {}", user.github_login) - } - ContactEntry::Contact(contact) => { - format!(" {}", contact.user.github_login) - } - ContactEntry::ContactProject(contact, project_ix, project) => { - let project = project - .and_then(|p| p.upgrade(cx)) - .map(|project| project.read(cx)); - format!( - " {}{}", - contact.projects[*project_ix] - .visible_worktree_root_names - .join(", "), - if project.map_or(true, |project| project.is_online()) { - "" - } else { - " (going offline...)" - }, - ) - } - ContactEntry::OfflineProject(project) => { - let project = project.upgrade(cx).unwrap().read(cx); - format!( - " 🔒 {}{}", - project - .worktree_root_names(cx) - .collect::>() - .join(", "), - if project.is_online() { - " (going online...)" - } else { - "" - }, - ) - } - }; - - if panel.selection == Some(ix) { - string.push_str(" <=== selected"); - } - - string - })); - entries - } -} diff --git a/crates/contacts_panel/src/join_project_notification.rs b/crates/contacts_panel/src/join_project_notification.rs deleted file mode 100644 index d8e8e670cf..0000000000 --- a/crates/contacts_panel/src/join_project_notification.rs +++ /dev/null @@ -1,80 +0,0 @@ -use client::User; -use gpui::{ - actions, ElementBox, Entity, ModelHandle, MutableAppContext, RenderContext, View, ViewContext, -}; -use project::Project; -use std::sync::Arc; -use workspace::Notification; - -use crate::notifications::render_user_notification; - -pub fn init(cx: &mut MutableAppContext) { - cx.add_action(JoinProjectNotification::decline); - cx.add_action(JoinProjectNotification::accept); -} - -pub enum Event { - Dismiss, -} - -actions!(contacts_panel, [Accept, Decline]); - -pub struct JoinProjectNotification { - project: ModelHandle, - user: Arc, -} - -impl JoinProjectNotification { - pub fn new(project: ModelHandle, user: Arc, cx: &mut ViewContext) -> Self { - cx.subscribe(&project, |this, _, event, cx| { - if let project::Event::ContactCancelledJoinRequest(user) = event { - if *user == this.user { - cx.emit(Event::Dismiss); - } - } - }) - .detach(); - Self { project, user } - } - - fn decline(&mut self, _: &Decline, cx: &mut ViewContext) { - self.project.update(cx, |project, cx| { - project.respond_to_join_request(self.user.id, false, cx) - }); - cx.emit(Event::Dismiss) - } - - fn accept(&mut self, _: &Accept, cx: &mut ViewContext) { - self.project.update(cx, |project, cx| { - project.respond_to_join_request(self.user.id, true, cx) - }); - cx.emit(Event::Dismiss) - } -} - -impl Entity for JoinProjectNotification { - type Event = Event; -} - -impl View for JoinProjectNotification { - fn ui_name() -> &'static str { - "JoinProjectNotification" - } - - fn render(&mut self, cx: &mut RenderContext) -> ElementBox { - render_user_notification( - self.user.clone(), - "wants to join your project", - None, - Decline, - vec![("Decline", Box::new(Decline)), ("Accept", Box::new(Accept))], - cx, - ) - } -} - -impl Notification for JoinProjectNotification { - fn should_dismiss_notification_on_event(&self, event: &::Event) -> bool { - matches!(event, Event::Dismiss) - } -} diff --git a/crates/contacts_status_item/Cargo.toml b/crates/contacts_status_item/Cargo.toml deleted file mode 100644 index df115a3842..0000000000 --- a/crates/contacts_status_item/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "contacts_status_item" -version = "0.1.0" -edition = "2021" - -[lib] -path = "src/contacts_status_item.rs" -doctest = false - -[dependencies] -client = { path = "../client" } -collections = { path = "../collections" } -editor = { path = "../editor" } -fuzzy = { path = "../fuzzy" } -gpui = { path = "../gpui" } -menu = { path = "../menu" } -picker = { path = "../picker" } -project = { path = "../project" } -settings = { path = "../settings" } -theme = { path = "../theme" } -util = { path = "../util" } -workspace = { path = "../workspace" } -anyhow = "1.0" -futures = "0.3" -log = "0.4" -postage = { version = "0.4.1", features = ["futures-traits"] } -serde = { version = "1.0", features = ["derive", "rc"] } - -[dev-dependencies] -language = { path = "../language", features = ["test-support"] } -project = { path = "../project", features = ["test-support"] } -workspace = { path = "../workspace", features = ["test-support"] } diff --git a/crates/contacts_status_item/src/contacts_popover.rs b/crates/contacts_status_item/src/contacts_popover.rs deleted file mode 100644 index 2998d74ed8..0000000000 --- a/crates/contacts_status_item/src/contacts_popover.rs +++ /dev/null @@ -1,94 +0,0 @@ -use editor::Editor; -use gpui::{elements::*, Entity, RenderContext, View, ViewContext, ViewHandle}; -use settings::Settings; - -pub enum Event { - Deactivated, -} - -pub struct ContactsPopover { - filter_editor: ViewHandle, -} - -impl Entity for ContactsPopover { - type Event = Event; -} - -impl View for ContactsPopover { - fn ui_name() -> &'static str { - "ContactsPopover" - } - - fn render(&mut self, cx: &mut RenderContext) -> ElementBox { - let theme = &cx.global::().theme.contacts_popover; - - Flex::row() - .with_child( - ChildView::new(self.filter_editor.clone()) - .contained() - .with_style( - cx.global::() - .theme - .contacts_panel - .user_query_editor - .container, - ) - .flex(1., true) - .boxed(), - ) - // .with_child( - // MouseEventHandler::::new(0, cx, |_, _| { - // Svg::new("icons/user_plus_16.svg") - // .with_color(theme.add_contact_button.color) - // .constrained() - // .with_height(16.) - // .contained() - // .with_style(theme.add_contact_button.container) - // .aligned() - // .boxed() - // }) - // .with_cursor_style(CursorStyle::PointingHand) - // .on_click(MouseButton::Left, |_, cx| { - // cx.dispatch_action(contact_finder::Toggle) - // }) - // .boxed(), - // ) - .constrained() - .with_height( - cx.global::() - .theme - .contacts_panel - .user_query_editor_height, - ) - .aligned() - .top() - .contained() - .with_background_color(theme.background) - .with_uniform_padding(4.) - .boxed() - } -} - -impl ContactsPopover { - pub fn new(cx: &mut ViewContext) -> Self { - cx.observe_window_activation(Self::window_activation_changed) - .detach(); - - let filter_editor = cx.add_view(|cx| { - let mut editor = Editor::single_line( - Some(|theme| theme.contacts_panel.user_query_editor.clone()), - cx, - ); - editor.set_placeholder_text("Filter contacts", cx); - editor - }); - - Self { filter_editor } - } - - fn window_activation_changed(&mut self, is_active: bool, cx: &mut ViewContext) { - if !is_active { - cx.emit(Event::Deactivated); - } - } -} diff --git a/crates/contacts_status_item/src/contacts_status_item.rs b/crates/contacts_status_item/src/contacts_status_item.rs deleted file mode 100644 index 5d471abcdf..0000000000 --- a/crates/contacts_status_item/src/contacts_status_item.rs +++ /dev/null @@ -1,94 +0,0 @@ -mod contacts_popover; - -use contacts_popover::ContactsPopover; -use gpui::{ - actions, - color::Color, - elements::*, - geometry::{rect::RectF, vector::vec2f}, - Appearance, Entity, MouseButton, MutableAppContext, RenderContext, View, ViewContext, - ViewHandle, WindowKind, -}; - -actions!(contacts_status_item, [ToggleContactsPopover]); - -pub fn init(cx: &mut MutableAppContext) { - cx.add_action(ContactsStatusItem::toggle_contacts_popover); -} - -pub struct ContactsStatusItem { - popover: Option>, -} - -impl Entity for ContactsStatusItem { - type Event = (); -} - -impl View for ContactsStatusItem { - fn ui_name() -> &'static str { - "ContactsStatusItem" - } - - fn render(&mut self, cx: &mut RenderContext) -> ElementBox { - let color = match cx.appearance { - Appearance::Light | Appearance::VibrantLight => Color::black(), - Appearance::Dark | Appearance::VibrantDark => Color::white(), - }; - MouseEventHandler::::new(0, cx, |_, _| { - Svg::new("icons/zed_22.svg") - .with_color(color) - .aligned() - .boxed() - }) - .on_click(MouseButton::Left, |_, cx| { - cx.dispatch_action(ToggleContactsPopover); - }) - .boxed() - } -} - -impl ContactsStatusItem { - pub fn new() -> Self { - Self { popover: None } - } - - fn toggle_contacts_popover(&mut self, _: &ToggleContactsPopover, cx: &mut ViewContext) { - match self.popover.take() { - Some(popover) => { - cx.remove_window(popover.window_id()); - } - None => { - let window_bounds = cx.window_bounds(); - let size = vec2f(360., 460.); - let origin = window_bounds.lower_left() - + vec2f(window_bounds.width() / 2. - size.x() / 2., 0.); - let (_, popover) = cx.add_window( - gpui::WindowOptions { - bounds: gpui::WindowBounds::Fixed(RectF::new(origin, size)), - titlebar: None, - center: false, - kind: WindowKind::PopUp, - is_movable: false, - }, - |cx| ContactsPopover::new(cx), - ); - cx.subscribe(&popover, Self::on_popover_event).detach(); - self.popover = Some(popover); - } - } - } - - fn on_popover_event( - &mut self, - popover: ViewHandle, - event: &contacts_popover::Event, - cx: &mut ViewContext, - ) { - match event { - contacts_popover::Event::Deactivated => { - self.popover.take(); - cx.remove_window(popover.window_id()); - } - } - } -} diff --git a/crates/context_menu/src/context_menu.rs b/crates/context_menu/src/context_menu.rs index b79e931257..c284375966 100644 --- a/crates/context_menu/src/context_menu.rs +++ b/crates/context_menu/src/context_menu.rs @@ -258,9 +258,10 @@ impl ContextMenu { .with_children(self.items.iter().enumerate().map(|(ix, item)| { match item { ContextMenuItem::Item { label, .. } => { - let style = style - .item - .style_for(Default::default(), Some(ix) == self.selected_index); + let style = style.item.style_for( + &mut Default::default(), + Some(ix) == self.selected_index, + ); Label::new(label.to_string(), style.label.clone()) .contained() @@ -283,9 +284,10 @@ impl ContextMenu { .with_children(self.items.iter().enumerate().map(|(ix, item)| { match item { ContextMenuItem::Item { action, .. } => { - let style = style - .item - .style_for(Default::default(), Some(ix) == self.selected_index); + let style = style.item.style_for( + &mut Default::default(), + Some(ix) == self.selected_index, + ); KeystrokeLabel::new( action.boxed_clone(), style.keystroke.container, diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml new file mode 100644 index 0000000000..f4ed283b6e --- /dev/null +++ b/crates/db/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "db" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/db.rs" +doctest = false + +[features] +test-support = [] + +[dependencies] +collections = { path = "../collections" } +anyhow = "1.0.57" +async-trait = "0.1" +parking_lot = "0.11.1" +rocksdb = "0.18" + +[dev-dependencies] +gpui = { path = "../gpui", features = ["test-support"] } +tempdir = { version = "0.3.7" } diff --git a/crates/project/src/db.rs b/crates/db/src/db.rs similarity index 100% rename from crates/project/src/db.rs rename to crates/db/src/db.rs diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 3111d7a9f1..8180a6c9f6 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -95,7 +95,7 @@ impl View for ProjectDiagnosticsEditor { .with_style(theme.container) .boxed() } else { - ChildView::new(&self.editor).boxed() + ChildView::new(&self.editor, cx).boxed() } } diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index dfd4938742..db634376d0 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -25,6 +25,7 @@ clock = { path = "../clock" } collections = { path = "../collections" } context_menu = { path = "../context_menu" } fuzzy = { path = "../fuzzy" } +git = { path = "../git" } gpui = { path = "../gpui" } language = { path = "../language" } lsp = { path = "../lsp" } @@ -47,10 +48,12 @@ ordered-float = "2.1.1" parking_lot = "0.11" postage = { version = "0.4", features = ["futures-traits"] } rand = { version = "0.8.3", optional = true } -serde = { version = "1.0", features = ["derive", "rc"] } +serde = { workspace = true } smallvec = { version = "1.6", features = ["union"] } smol = "1.2" tree-sitter-rust = { version = "*", optional = true } +tree-sitter-html = { version = "*", optional = true } +tree-sitter-javascript = { version = "*", optional = true } [dev-dependencies] text = { path = "../text", features = ["test-support"] } @@ -67,3 +70,5 @@ rand = "0.8" unindent = "0.1.7" tree-sitter = "0.20" tree-sitter-rust = "0.20" +tree-sitter-html = "0.19" +tree-sitter-javascript = "0.20" diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 58fc2e4fe7..e32276df41 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -330,34 +330,91 @@ impl DisplaySnapshot { DisplayPoint(self.blocks_snapshot.max_point()) } + /// Returns text chunks starting at the given display row until the end of the file pub fn text_chunks(&self, display_row: u32) -> impl Iterator { self.blocks_snapshot .chunks(display_row..self.max_point().row() + 1, false, None) .map(|h| h.text) } + // Returns text chunks starting at the end of the given display row in reverse until the start of the file + pub fn reverse_text_chunks(&self, display_row: u32) -> impl Iterator { + (0..=display_row).into_iter().rev().flat_map(|row| { + self.blocks_snapshot + .chunks(row..row + 1, false, None) + .map(|h| h.text) + .collect::>() + .into_iter() + .rev() + }) + } + pub fn chunks(&self, display_rows: Range, language_aware: bool) -> DisplayChunks<'_> { self.blocks_snapshot .chunks(display_rows, language_aware, Some(&self.text_highlights)) } - pub fn chars_at(&self, point: DisplayPoint) -> impl Iterator + '_ { - let mut column = 0; - let mut chars = self.text_chunks(point.row()).flat_map(str::chars); - while column < point.column() { - if let Some(c) = chars.next() { - column += c.len_utf8() as u32; - } else { - break; - } - } - chars + pub fn chars_at( + &self, + mut point: DisplayPoint, + ) -> impl Iterator + '_ { + point = DisplayPoint(self.blocks_snapshot.clip_point(point.0, Bias::Left)); + self.text_chunks(point.row()) + .flat_map(str::chars) + .skip_while({ + let mut column = 0; + move |char| { + let at_point = column >= point.column(); + column += char.len_utf8() as u32; + !at_point + } + }) + .map(move |ch| { + let result = (ch, point); + if ch == '\n' { + *point.row_mut() += 1; + *point.column_mut() = 0; + } else { + *point.column_mut() += ch.len_utf8() as u32; + } + result + }) + } + + pub fn reverse_chars_at( + &self, + mut point: DisplayPoint, + ) -> impl Iterator + '_ { + point = DisplayPoint(self.blocks_snapshot.clip_point(point.0, Bias::Left)); + self.reverse_text_chunks(point.row()) + .flat_map(|chunk| chunk.chars().rev()) + .skip_while({ + let mut column = self.line_len(point.row()); + if self.max_point().row() > point.row() { + column += 1; + } + + move |char| { + let at_point = column <= point.column(); + column = column.saturating_sub(char.len_utf8() as u32); + !at_point + } + }) + .map(move |ch| { + if ch == '\n' { + *point.row_mut() -= 1; + *point.column_mut() = self.line_len(point.row()); + } else { + *point.column_mut() = point.column().saturating_sub(ch.len_utf8() as u32); + } + (ch, point) + }) } pub fn column_to_chars(&self, display_row: u32, target: u32) -> u32 { let mut count = 0; let mut column = 0; - for c in self.chars_at(DisplayPoint::new(display_row, 0)) { + for (c, _) in self.chars_at(DisplayPoint::new(display_row, 0)) { if column >= target { break; } @@ -370,7 +427,7 @@ impl DisplaySnapshot { pub fn column_from_chars(&self, display_row: u32, char_count: u32) -> u32 { let mut column = 0; - for (count, c) in self.chars_at(DisplayPoint::new(display_row, 0)).enumerate() { + for (count, (c, _)) in self.chars_at(DisplayPoint::new(display_row, 0)).enumerate() { if c == '\n' || count >= char_count as usize { break; } @@ -454,7 +511,7 @@ impl DisplaySnapshot { pub fn line_indent(&self, display_row: u32) -> (u32, bool) { let mut indent = 0; let mut is_blank = true; - for c in self.chars_at(DisplayPoint::new(display_row, 0)) { + for (c, _) in self.chars_at(DisplayPoint::new(display_row, 0)) { if c == ' ' { indent += 1; } else { @@ -565,7 +622,7 @@ pub mod tests { use super::*; use crate::{movement, test::marked_display_snapshot}; use gpui::{color::Color, elements::*, test::observe, MutableAppContext}; - use language::{Buffer, Language, LanguageConfig, RandomCharIter, SelectionGoal}; + use language::{Buffer, Language, LanguageConfig, SelectionGoal}; use rand::{prelude::*, Rng}; use smol::stream::StreamExt; use std::{env, sync::Arc}; @@ -609,7 +666,9 @@ pub mod tests { let buffer = cx.update(|cx| { if rng.gen() { let len = rng.gen_range(0..10); - let text = RandomCharIter::new(&mut rng).take(len).collect::(); + let text = util::RandomCharIter::new(&mut rng) + .take(len) + .collect::(); MultiBuffer::build_simple(&text, cx) } else { MultiBuffer::build_random(&mut rng, cx) diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 210daccac2..ee07c77d01 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -5,7 +5,7 @@ use super::{ use crate::{Anchor, ExcerptRange, ToPoint as _}; use collections::{Bound, HashMap, HashSet}; use gpui::{ElementBox, RenderContext}; -use language::{BufferSnapshot, Chunk, Patch}; +use language::{BufferSnapshot, Chunk, Patch, Point}; use parking_lot::Mutex; use std::{ cell::RefCell, @@ -18,7 +18,7 @@ use std::{ }, }; use sum_tree::{Bias, SumTree}; -use text::{Edit, Point}; +use text::Edit; const NEWLINES: &[u8] = &[b'\n'; u8::MAX as usize]; @@ -42,7 +42,7 @@ pub struct BlockSnapshot { pub struct BlockId(usize); #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)] -pub struct BlockPoint(pub super::Point); +pub struct BlockPoint(pub Point); #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)] struct BlockRow(u32); @@ -157,6 +157,7 @@ pub struct BlockChunks<'a> { max_output_row: u32, } +#[derive(Clone)] pub struct BlockBufferRows<'a> { transforms: sum_tree::Cursor<'a, Transform, (BlockRow, WrapRow)>, input_buffer_rows: wrap_map::WrapBufferRows<'a>, @@ -994,7 +995,7 @@ mod tests { use rand::prelude::*; use settings::Settings; use std::env; - use text::RandomCharIter; + use util::RandomCharIter; #[gpui::test] fn test_offset_for_row() { diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index 970910f969..a2f7005e3d 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -18,11 +18,11 @@ use std::{ use sum_tree::{Bias, Cursor, FilterCursor, SumTree}; #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)] -pub struct FoldPoint(pub super::Point); +pub struct FoldPoint(pub Point); impl FoldPoint { pub fn new(row: u32, column: u32) -> Self { - Self(super::Point::new(row, column)) + Self(Point::new(row, column)) } pub fn row(self) -> u32 { @@ -274,6 +274,7 @@ impl FoldMap { if buffer.edit_count() != new_buffer.edit_count() || buffer.parse_count() != new_buffer.parse_count() || buffer.diagnostics_update_count() != new_buffer.diagnostics_update_count() + || buffer.git_diff_update_count() != new_buffer.git_diff_update_count() || buffer.trailing_excerpt_update_count() != new_buffer.trailing_excerpt_update_count() { @@ -986,6 +987,7 @@ impl<'a> sum_tree::Dimension<'a, FoldSummary> for usize { } } +#[derive(Clone)] pub struct FoldBufferRows<'a> { cursor: Cursor<'a, Transform, (FoldPoint, Point)>, input_buffer_rows: MultiBufferRows<'a>, @@ -1195,8 +1197,8 @@ mod tests { use settings::Settings; use std::{cmp::Reverse, env, mem, sync::Arc}; use sum_tree::TreeMap; - use text::RandomCharIter; use util::test::sample_text; + use util::RandomCharIter; use Bias::{Left, Right}; #[gpui::test] diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs index 4d89767a19..39bcdc4d9f 100644 --- a/crates/editor/src/display_map/tab_map.rs +++ b/crates/editor/src/display_map/tab_map.rs @@ -3,11 +3,10 @@ use super::{ TextHighlights, }; use crate::MultiBufferSnapshot; -use language::{rope, Chunk}; +use language::{Chunk, Point}; use parking_lot::Mutex; use std::{cmp, mem, num::NonZeroU32, ops::Range}; use sum_tree::Bias; -use text::Point; pub struct TabMap(Mutex); @@ -332,11 +331,11 @@ impl TabSnapshot { } #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)] -pub struct TabPoint(pub super::Point); +pub struct TabPoint(pub Point); impl TabPoint { pub fn new(row: u32, column: u32) -> Self { - Self(super::Point::new(row, column)) + Self(Point::new(row, column)) } pub fn zero() -> Self { @@ -352,8 +351,8 @@ impl TabPoint { } } -impl From for TabPoint { - fn from(point: super::Point) -> Self { +impl From for TabPoint { + fn from(point: Point) -> Self { Self(point) } } @@ -362,7 +361,7 @@ pub type TabEdit = text::Edit; #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct TextSummary { - pub lines: super::Point, + pub lines: Point, pub first_line_chars: u32, pub last_line_chars: u32, pub longest_row: u32, @@ -371,7 +370,7 @@ pub struct TextSummary { impl<'a> From<&'a str> for TextSummary { fn from(text: &'a str) -> Self { - let sum = rope::TextSummary::from(text); + let sum = text::TextSummary::from(text); TextSummary { lines: sum.lines, @@ -485,7 +484,6 @@ mod tests { use super::*; use crate::{display_map::fold_map::FoldMap, MultiBuffer}; use rand::{prelude::StdRng, Rng}; - use text::{RandomCharIter, Rope}; #[test] fn test_expand_tabs() { @@ -508,7 +506,9 @@ mod tests { let tab_size = NonZeroU32::new(rng.gen_range(1..=4)).unwrap(); let len = rng.gen_range(0..30); let buffer = if rng.gen() { - let text = RandomCharIter::new(&mut rng).take(len).collect::(); + let text = util::RandomCharIter::new(&mut rng) + .take(len) + .collect::(); MultiBuffer::build_simple(&text, cx) } else { MultiBuffer::build_random(&mut rng, cx) @@ -522,7 +522,7 @@ mod tests { log::info!("FoldMap text: {:?}", folds_snapshot.text()); let (_, tabs_snapshot) = TabMap::new(folds_snapshot.clone(), tab_size); - let text = Rope::from(tabs_snapshot.text().as_str()); + let text = text::Rope::from(tabs_snapshot.text().as_str()); log::info!( "TabMap text (tab size: {}): {:?}", tab_size, diff --git a/crates/editor/src/display_map/wrap_map.rs b/crates/editor/src/display_map/wrap_map.rs index ee6ce2860d..6c34fa4797 100644 --- a/crates/editor/src/display_map/wrap_map.rs +++ b/crates/editor/src/display_map/wrap_map.rs @@ -3,12 +3,12 @@ use super::{ tab_map::{self, TabEdit, TabPoint, TabSnapshot}, TextHighlights, }; -use crate::{MultiBufferSnapshot, Point}; +use crate::MultiBufferSnapshot; use gpui::{ fonts::FontId, text_layout::LineWrapper, Entity, ModelContext, ModelHandle, MutableAppContext, Task, }; -use language::Chunk; +use language::{Chunk, Point}; use lazy_static::lazy_static; use smol::future::yield_now; use std::{cmp, collections::VecDeque, mem, ops::Range, time::Duration}; @@ -52,7 +52,7 @@ struct TransformSummary { } #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)] -pub struct WrapPoint(pub super::Point); +pub struct WrapPoint(pub Point); pub struct WrapChunks<'a> { input_chunks: tab_map::TabChunks<'a>, @@ -62,6 +62,7 @@ pub struct WrapChunks<'a> { transforms: Cursor<'a, Transform, (WrapPoint, TabPoint)>, } +#[derive(Clone)] pub struct WrapBufferRows<'a> { input_buffer_rows: fold_map::FoldBufferRows<'a>, input_buffer_row: Option, @@ -959,7 +960,7 @@ impl SumTreeExt for SumTree { impl WrapPoint { pub fn new(row: u32, column: u32) -> Self { - Self(super::Point::new(row, column)) + Self(Point::new(row, column)) } pub fn row(self) -> u32 { @@ -1029,7 +1030,6 @@ mod tests { MultiBuffer, }; use gpui::test::observe; - use language::RandomCharIter; use rand::prelude::*; use settings::Settings; use smol::stream::StreamExt; @@ -1067,7 +1067,9 @@ mod tests { MultiBuffer::build_random(&mut rng, cx) } else { let len = rng.gen_range(0..10); - let text = RandomCharIter::new(&mut rng).take(len).collect::(); + let text = util::RandomCharIter::new(&mut rng) + .take(len) + .collect::(); MultiBuffer::build_simple(&text, cx) } }); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a7d46f7710..070fc69f7e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -9,6 +9,8 @@ pub mod movement; mod multi_buffer; pub mod selections_collection; +#[cfg(test)] +mod editor_tests; #[cfg(any(test, feature = "test-support"))] pub mod test; @@ -19,6 +21,7 @@ use collections::{BTreeMap, Bound, HashMap, HashSet, VecDeque}; pub use display_map::DisplayPoint; use display_map::*; pub use element::*; +use futures::FutureExt; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ actions, @@ -29,6 +32,7 @@ use gpui::{ geometry::vector::{vec2f, Vector2F}, impl_actions, impl_internal_actions, platform::CursorStyle, + serde_json::json, text_layout, AnyViewHandle, AppContext, AsyncAppContext, ClipboardItem, Element, ElementBox, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, @@ -49,7 +53,7 @@ pub use multi_buffer::{ }; use multi_buffer::{MultiBufferChunks, ToOffsetUtf16}; use ordered_float::OrderedFloat; -use project::{LocationLink, Project, ProjectPath, ProjectTransaction}; +use project::{FormatTrigger, LocationLink, Project, ProjectPath, ProjectTransaction}; use selections_collection::{resolve_multiple, MutableSelectionsCollection, SelectionsCollection}; use serde::{Deserialize, Serialize}; use settings::Settings; @@ -72,10 +76,13 @@ use util::{post_inc, ResultExt, TryFutureExt}; use workspace::{ItemNavHistory, Workspace}; const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); +const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); const MAX_LINE_LEN: usize = 1024; const MIN_NAVIGATION_HISTORY_ROW_DELTA: i64 = 10; const MAX_SELECTION_HISTORY_LEN: usize = 1024; +pub const FORMAT_TIMEOUT: Duration = Duration::from_secs(2); + #[derive(Clone, Deserialize, PartialEq, Default)] pub struct SelectNext { #[serde(default)] @@ -101,6 +108,18 @@ pub struct SelectToBeginningOfLine { stop_at_soft_wraps: bool, } +#[derive(Clone, Default, Deserialize, PartialEq)] +pub struct MovePageUp { + #[serde(default)] + center_cursor: bool, +} + +#[derive(Clone, Default, Deserialize, PartialEq)] +pub struct MovePageDown { + #[serde(default)] + center_cursor: bool, +} + #[derive(Clone, Deserialize, PartialEq)] pub struct SelectToEndOfLine { #[serde(default)] @@ -154,8 +173,11 @@ actions!( Paste, Undo, Redo, + CenterScreen, MoveUp, + PageUp, MoveDown, + PageDown, MoveLeft, MoveRight, MoveToPreviousWordStart, @@ -195,8 +217,6 @@ actions!( FindAllReferences, Rename, ConfirmRename, - PageUp, - PageDown, Fold, UnfoldLines, FoldSelectedRanges, @@ -204,6 +224,7 @@ actions!( OpenExcerpts, RestartLanguageServer, Hover, + Format, ] ); @@ -214,6 +235,8 @@ impl_actions!( SelectToBeginningOfLine, SelectToEndOfLine, ToggleCodeActions, + MovePageUp, + MovePageDown, ConfirmCompletion, ConfirmCodeAction, ] @@ -231,6 +254,9 @@ pub enum Direction { Next, } +#[derive(Default)] +struct ScrollbarAutoHide(bool); + pub fn init(cx: &mut MutableAppContext) { cx.add_action(Editor::new_file); cx.add_action(|this: &mut Editor, action: &Scroll, cx| this.set_scroll_position(action.0, cx)); @@ -262,7 +288,12 @@ pub fn init(cx: &mut MutableAppContext) { cx.add_action(Editor::undo); cx.add_action(Editor::redo); cx.add_action(Editor::move_up); + cx.add_action(Editor::move_page_up); + cx.add_action(Editor::page_up); cx.add_action(Editor::move_down); + cx.add_action(Editor::move_page_down); + cx.add_action(Editor::page_down); + cx.add_action(Editor::center_screen); cx.add_action(Editor::move_left); cx.add_action(Editor::move_right); cx.add_action(Editor::move_to_previous_word_start); @@ -301,8 +332,6 @@ pub fn init(cx: &mut MutableAppContext) { cx.add_action(Editor::go_to_prev_diagnostic); cx.add_action(Editor::go_to_definition); cx.add_action(Editor::go_to_type_definition); - cx.add_action(Editor::page_up); - cx.add_action(Editor::page_down); cx.add_action(Editor::fold); cx.add_action(Editor::unfold_lines); cx.add_action(Editor::fold_selected_ranges); @@ -310,6 +339,7 @@ pub fn init(cx: &mut MutableAppContext) { cx.add_action(Editor::toggle_code_actions); cx.add_action(Editor::open_excerpts); cx.add_action(Editor::jump); + cx.add_async_action(Editor::format); cx.add_action(Editor::restart_language_server); cx.add_action(Editor::show_character_palette); cx.add_async_action(Editor::confirm_completion); @@ -404,7 +434,7 @@ pub struct Editor { add_selections_state: Option, select_next_state: Option, selection_history: SelectionHistory, - autoclose_stack: InvalidationStack, + autoclose_regions: Vec, snippet_stack: InvalidationStack, select_larger_syntax_node_stack: Vec]>>, ime_transaction: Option, @@ -419,6 +449,8 @@ pub struct Editor { focused: bool, show_local_cursors: bool, show_local_selections: bool, + show_scrollbars: bool, + hide_scrollbar_task: Option>, blink_epoch: usize, blinking_paused: bool, mode: EditorMode, @@ -443,6 +475,7 @@ pub struct Editor { leader_replica_id: Option, hover_state: HoverState, link_go_to_definition_state: LinkGoToDefinitionState, + visible_line_count: Option, _subscriptions: Vec, } @@ -563,8 +596,10 @@ struct SelectNextState { done: bool, } -struct BracketPairState { - ranges: Vec>, +#[derive(Debug)] +struct AutocloseRegion { + selection_id: usize, + range: Range, pair: BracketPair, } @@ -589,6 +624,18 @@ enum ContextMenu { } impl ContextMenu { + fn select_first(&mut self, cx: &mut ViewContext) -> bool { + if self.visible() { + match self { + ContextMenu::Completions(menu) => menu.select_first(cx), + ContextMenu::CodeActions(menu) => menu.select_first(cx), + } + true + } else { + false + } + } + fn select_prev(&mut self, cx: &mut ViewContext) -> bool { if self.visible() { match self { @@ -613,6 +660,18 @@ impl ContextMenu { } } + fn select_last(&mut self, cx: &mut ViewContext) -> bool { + if self.visible() { + match self { + ContextMenu::Completions(menu) => menu.select_last(cx), + ContextMenu::CodeActions(menu) => menu.select_last(cx), + } + true + } else { + false + } + } + fn visible(&self) -> bool { match self { ContextMenu::Completions(menu) => menu.visible(), @@ -645,6 +704,12 @@ struct CompletionsMenu { } impl CompletionsMenu { + fn select_first(&mut self, cx: &mut ViewContext) { + self.selected_item = 0; + self.list.scroll_to(ScrollTarget::Show(self.selected_item)); + cx.notify(); + } + fn select_prev(&mut self, cx: &mut ViewContext) { if self.selected_item > 0 { self.selected_item -= 1; @@ -661,6 +726,12 @@ impl CompletionsMenu { cx.notify(); } + fn select_last(&mut self, cx: &mut ViewContext) { + self.selected_item = self.matches.len() - 1; + self.list.scroll_to(ScrollTarget::Show(self.selected_item)); + cx.notify(); + } + fn visible(&self) -> bool { !self.matches.is_empty() } @@ -688,7 +759,7 @@ impl CompletionsMenu { |state, _| { let item_style = if item_ix == selected_item { style.autocomplete.selected_item - } else if state.hovered { + } else if state.hovered() { style.autocomplete.hovered_item } else { style.autocomplete.item @@ -792,6 +863,11 @@ struct CodeActionsMenu { } impl CodeActionsMenu { + fn select_first(&mut self, cx: &mut ViewContext) { + self.selected_item = 0; + cx.notify() + } + fn select_prev(&mut self, cx: &mut ViewContext) { if self.selected_item > 0 { self.selected_item -= 1; @@ -806,6 +882,11 @@ impl CodeActionsMenu { } } + fn select_last(&mut self, cx: &mut ViewContext) { + self.selected_item = self.actions.len() - 1; + cx.notify() + } + fn visible(&self) -> bool { !self.actions.is_empty() } @@ -833,7 +914,7 @@ impl CodeActionsMenu { MouseEventHandler::::new(item_ix, cx, |state, _| { let item_style = if item_ix == selected_item { style.autocomplete.selected_item - } else if state.hovered { + } else if state.hovered() { style.autocomplete.hovered_item } else { style.autocomplete.item @@ -1004,7 +1085,7 @@ impl Editor { add_selections_state: None, select_next_state: None, selection_history: Default::default(), - autoclose_stack: Default::default(), + autoclose_regions: Default::default(), snippet_stack: Default::default(), select_larger_syntax_node_stack: Vec::new(), ime_transaction: Default::default(), @@ -1018,6 +1099,8 @@ impl Editor { focused: false, show_local_cursors: false, show_local_selections: true, + show_scrollbars: true, + hide_scrollbar_task: None, blink_epoch: 0, blinking_paused: false, mode, @@ -1042,6 +1125,7 @@ impl Editor { leader_replica_id: None, hover_state: Default::default(), link_go_to_definition_state: Default::default(), + visible_line_count: None, _subscriptions: vec![ cx.observe(&buffer, Self::on_buffer_changed), cx.subscribe(&buffer, Self::on_buffer_event), @@ -1049,10 +1133,17 @@ impl Editor { ], }; this.end_selection(cx); + this.make_scrollbar_visible(cx); let editor_created_event = EditorCreated(cx.handle()); cx.emit_global(editor_created_event); + if mode == EditorMode::Full { + let should_auto_hide_scrollbars = cx.platform().should_auto_hide_scrollbars(); + cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars)); + } + + this.report_event("open editor", cx); this } @@ -1109,7 +1200,7 @@ impl Editor { &self, point: T, cx: &'a AppContext, - ) -> Option<&'a Arc> { + ) -> Option> { self.buffer.read(cx).language_at(point, cx) } @@ -1152,9 +1243,9 @@ impl Editor { ) { let map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - if scroll_position.y() == 0. { + if scroll_position.y() <= 0. { self.scroll_top_anchor = Anchor::min(); - self.scroll_position = scroll_position; + self.scroll_position = scroll_position.max(vec2f(0., 0.)); } else { let scroll_top_buffer_offset = DisplayPoint::new(scroll_position.y() as u32, 0).to_offset(&map, Bias::Right); @@ -1168,6 +1259,7 @@ impl Editor { self.scroll_top_anchor = anchor; } + self.make_scrollbar_visible(cx); self.autoscroll_request.take(); hide_hover(self, cx); @@ -1175,6 +1267,10 @@ impl Editor { cx.notify(); } + fn set_visible_line_count(&mut self, lines: f32) { + self.visible_line_count = Some(lines) + } + fn set_scroll_top_anchor( &mut self, anchor: Anchor, @@ -1239,7 +1335,7 @@ impl Editor { let max_scroll_top = if matches!(self.mode, EditorMode::AutoHeight { .. }) { (display_map.max_point().row() as f32 - visible_lines + 1.).max(0.) } else { - display_map.max_point().row().saturating_sub(1) as f32 + display_map.max_point().row() as f32 }; if scroll_position.y() > max_scroll_top { scroll_position.set_y(max_scroll_top); @@ -1394,8 +1490,7 @@ impl Editor { self.add_selections_state = None; self.select_next_state = None; self.select_larger_syntax_node_stack.clear(); - self.autoclose_stack - .invalidate(&self.selections.disjoint_anchors(), buffer); + self.invalidate_autoclose_regions(&self.selections.disjoint_anchors(), buffer); self.snippet_stack .invalidate(&self.selections.disjoint_anchors(), buffer); self.take_rename(false, cx); @@ -1842,15 +1937,160 @@ impl Editor { return; } - if !self.skip_autoclose_end(text, cx) { - self.transact(cx, |this, cx| { - if !this.surround_with_bracket_pair(text, cx) { - this.insert(text, cx); - this.autoclose_bracket_pairs(cx); + let text: Arc = text.into(); + let selections = self.selections.all_adjusted(cx); + let mut edits = Vec::new(); + let mut new_selections = Vec::with_capacity(selections.len()); + let mut new_autoclose_regions = Vec::new(); + let snapshot = self.buffer.read(cx).read(cx); + + for (selection, autoclose_region) in + self.selections_with_autoclose_regions(selections, &snapshot) + { + if let Some(language) = snapshot.language_at(selection.head()) { + // Determine if the inserted text matches the opening or closing + // bracket of any of this language's bracket pairs. + let mut bracket_pair = None; + let mut is_bracket_pair_start = false; + for pair in language.brackets() { + if pair.close && pair.start.ends_with(text.as_ref()) { + bracket_pair = Some(pair.clone()); + is_bracket_pair_start = true; + break; + } else if pair.end.as_str() == text.as_ref() { + bracket_pair = Some(pair.clone()); + break; + } } - }); - self.trigger_completion_on_input(text, cx); + + if let Some(bracket_pair) = bracket_pair { + if selection.is_empty() { + if is_bracket_pair_start { + let prefix_len = bracket_pair.start.len() - text.len(); + + // If the inserted text is a suffix of an opening bracket and the + // selection is preceded by the rest of the opening bracket, then + // insert the closing bracket. + let following_text_allows_autoclose = snapshot + .chars_at(selection.start) + .next() + .map_or(true, |c| language.should_autoclose_before(c)); + let preceding_text_matches_prefix = prefix_len == 0 + || (selection.start.column >= (prefix_len as u32) + && snapshot.contains_str_at( + Point::new( + selection.start.row, + selection.start.column - (prefix_len as u32), + ), + &bracket_pair.start[..prefix_len], + )); + if following_text_allows_autoclose && preceding_text_matches_prefix { + let anchor = snapshot.anchor_before(selection.end); + new_selections + .push((selection.map(|_| anchor.clone()), text.len())); + new_autoclose_regions.push(( + anchor.clone(), + text.len(), + selection.id, + bracket_pair.clone(), + )); + edits.push(( + selection.range(), + format!("{}{}", text, bracket_pair.end).into(), + )); + continue; + } + } else if let Some(region) = autoclose_region { + // If the selection is followed by an auto-inserted closing bracket, + // then don't insert that closing bracket again; just move the selection + // past the closing bracket. + let should_skip = selection.end == region.range.end.to_point(&snapshot) + && text.as_ref() == region.pair.end.as_str(); + if should_skip { + let anchor = snapshot.anchor_after(selection.end); + new_selections.push(( + selection.map(|_| anchor.clone()), + region.pair.end.len(), + )); + continue; + } + } + } + // If an opening bracket is typed while text is selected, then + // surround that text with the bracket pair. + else if is_bracket_pair_start { + edits.push((selection.start..selection.start, text.clone())); + edits.push(( + selection.end..selection.end, + bracket_pair.end.as_str().into(), + )); + new_selections.push(( + Selection { + id: selection.id, + start: snapshot.anchor_after(selection.start), + end: snapshot.anchor_before(selection.end), + reversed: selection.reversed, + goal: selection.goal, + }, + 0, + )); + continue; + } + } + } + + // If not handling any auto-close operation, then just replace the selected + // text with the given input and move the selection to the end of the + // newly inserted text. + let anchor = snapshot.anchor_after(selection.end); + new_selections.push((selection.map(|_| anchor.clone()), 0)); + edits.push((selection.start..selection.end, text.clone())); } + + drop(snapshot); + self.transact(cx, |this, cx| { + this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, Some(AutoindentMode::EachLine), cx); + }); + + let new_anchor_selections = new_selections.iter().map(|e| &e.0); + let new_selection_deltas = new_selections.iter().map(|e| e.1); + let snapshot = this.buffer.read(cx).read(cx); + let new_selections = resolve_multiple::(new_anchor_selections, &snapshot) + .zip(new_selection_deltas) + .map(|(selection, delta)| selection.map(|e| e + delta)) + .collect::>(); + + let mut i = 0; + for (position, delta, selection_id, pair) in new_autoclose_regions { + let position = position.to_offset(&snapshot) + delta; + let start = snapshot.anchor_before(position); + let end = snapshot.anchor_after(position); + while let Some(existing_state) = this.autoclose_regions.get(i) { + match existing_state.range.start.cmp(&start, &snapshot) { + Ordering::Less => i += 1, + Ordering::Greater => break, + Ordering::Equal => match end.cmp(&existing_state.range.end, &snapshot) { + Ordering::Less => i += 1, + Ordering::Equal => break, + Ordering::Greater => break, + }, + } + } + this.autoclose_regions.insert( + i, + AutocloseRegion { + selection_id, + range: start..end, + pair, + }, + ); + } + + drop(snapshot); + this.change_selections(Some(Autoscroll::Fit), cx, |s| s.select(new_selections)); + this.trigger_completion_on_input(&text, cx); + }); } pub fn newline(&mut self, _: &Newline, cx: &mut ViewContext) { @@ -1869,7 +2109,7 @@ impl Editor { let end = selection.end; let mut insert_extra_newline = false; - if let Some(language) = buffer.language() { + if let Some(language) = buffer.language_at(start) { let leading_whitespace_len = buffer .reversed_chars_at(start) .take_while(|c| c.is_whitespace() && *c != '\n') @@ -2022,232 +2262,89 @@ impl Editor { } } - fn surround_with_bracket_pair(&mut self, text: &str, cx: &mut ViewContext) -> bool { - let snapshot = self.buffer.read(cx).snapshot(cx); - if let Some(pair) = snapshot - .language() - .and_then(|language| language.brackets().iter().find(|b| b.start == text)) - .cloned() - { - if self - .selections - .all::(cx) - .iter() - .any(|selection| selection.is_empty()) - { - return false; - } - - let mut selections = self.selections.disjoint_anchors().to_vec(); - for selection in &mut selections { - selection.end = selection.end.bias_left(&snapshot); - } - drop(snapshot); - - self.buffer.update(cx, |buffer, cx| { - let pair_start: Arc = pair.start.clone().into(); - let pair_end: Arc = pair.end.clone().into(); - buffer.edit( - selections.iter().flat_map(|s| { - [ - (s.start.clone()..s.start.clone(), pair_start.clone()), - (s.end.clone()..s.end.clone(), pair_end.clone()), - ] - }), - None, - cx, - ); - }); - - let snapshot = self.buffer.read(cx).read(cx); - for selection in &mut selections { - selection.end = selection.end.bias_right(&snapshot); - } - drop(snapshot); - - self.change_selections(None, cx, |s| s.select_anchors(selections)); - true - } else { - false - } - } - - fn autoclose_bracket_pairs(&mut self, cx: &mut ViewContext) { + /// If any empty selections is touching the start of its innermost containing autoclose + /// region, expand it to select the brackets. + fn select_autoclose_pair(&mut self, cx: &mut ViewContext) { let selections = self.selections.all::(cx); - let mut bracket_pair_state = None; - let mut new_selections = None; - self.buffer.update(cx, |buffer, cx| { - let mut snapshot = buffer.snapshot(cx); - let left_biased_selections = selections - .iter() - .map(|selection| selection.map(|p| snapshot.anchor_before(p))) - .collect::>(); - - let autoclose_pair = snapshot.language().and_then(|language| { - let first_selection_start = selections.first().unwrap().start; - let pair = language.brackets().iter().find(|pair| { - pair.close - && snapshot.contains_str_at( - first_selection_start.saturating_sub(pair.start.len()), - &pair.start, - ) - }); - pair.and_then(|pair| { - let should_autoclose = selections.iter().all(|selection| { - // Ensure all selections are parked at the end of a pair start. - if snapshot.contains_str_at( - selection.start.saturating_sub(pair.start.len()), - &pair.start, - ) { - snapshot - .chars_at(selection.start) - .next() - .map_or(true, |c| language.should_autoclose_before(c)) - } else { - false + let buffer = self.buffer.read(cx).read(cx); + let mut new_selections = Vec::new(); + for (mut selection, region) in self.selections_with_autoclose_regions(selections, &buffer) { + if let (Some(region), true) = (region, selection.is_empty()) { + let mut range = region.range.to_offset(&buffer); + if selection.start == range.start { + if range.start >= region.pair.start.len() { + range.start -= region.pair.start.len(); + if buffer.contains_str_at(range.start, ®ion.pair.start) { + if buffer.contains_str_at(range.end, ®ion.pair.end) { + range.end += region.pair.end.len(); + selection.start = range.start; + selection.end = range.end; + } } - }); - - if should_autoclose { - Some(pair.clone()) - } else { - None } - }) - }); - - if let Some(pair) = autoclose_pair { - let selection_ranges = selections - .iter() - .map(|selection| { - let start = selection.start.to_offset(&snapshot); - start..start - }) - .collect::>(); - - let pair_end: Arc = pair.end.clone().into(); - buffer.edit( - selection_ranges - .iter() - .map(|range| (range.clone(), pair_end.clone())), - None, - cx, - ); - snapshot = buffer.snapshot(cx); - - new_selections = Some( - resolve_multiple::(left_biased_selections.iter(), &snapshot) - .collect::>(), - ); - - if pair.end.len() == 1 { - let mut delta = 0; - bracket_pair_state = Some(BracketPairState { - ranges: selections - .iter() - .map(move |selection| { - let offset = selection.start + delta; - delta += 1; - snapshot.anchor_before(offset)..snapshot.anchor_after(offset) - }) - .collect(), - pair, - }); } } - }); + new_selections.push(selection); + } - if let Some(new_selections) = new_selections { - self.change_selections(None, cx, |s| { - s.select(new_selections); - }); - } - if let Some(bracket_pair_state) = bracket_pair_state { - self.autoclose_stack.push(bracket_pair_state); - } + drop(buffer); + self.change_selections(None, cx, |selections| selections.select(new_selections)); } - fn skip_autoclose_end(&mut self, text: &str, cx: &mut ViewContext) -> bool { - let buffer = self.buffer.read(cx).snapshot(cx); - let old_selections = self.selections.all::(cx); - let autoclose_pair = if let Some(autoclose_pair) = self.autoclose_stack.last() { - autoclose_pair - } else { - return false; - }; - if text != autoclose_pair.pair.end { - return false; - } + /// Iterate the given selections, and for each one, find the smallest surrounding + /// autoclose region. This uses the ordering of the selections and the autoclose + /// regions to avoid repeated comparisons. + fn selections_with_autoclose_regions<'a, D: ToOffset + Clone>( + &'a self, + selections: impl IntoIterator>, + buffer: &'a MultiBufferSnapshot, + ) -> impl Iterator, Option<&'a AutocloseRegion>)> { + let mut i = 0; + let mut regions = self.autoclose_regions.as_slice(); + selections.into_iter().map(move |selection| { + let range = selection.start.to_offset(buffer)..selection.end.to_offset(buffer); - debug_assert_eq!(old_selections.len(), autoclose_pair.ranges.len()); - - if old_selections - .iter() - .zip(autoclose_pair.ranges.iter().map(|r| r.to_offset(&buffer))) - .all(|(selection, autoclose_range)| { - let autoclose_range_end = autoclose_range.end.to_offset(&buffer); - selection.is_empty() && selection.start == autoclose_range_end - }) - { - let new_selections = old_selections - .into_iter() - .map(|selection| { - let cursor = selection.start + 1; - Selection { - id: selection.id, - start: cursor, - end: cursor, - reversed: false, - goal: SelectionGoal::None, - } - }) - .collect(); - self.autoclose_stack.pop(); - self.change_selections(Some(Autoscroll::Fit), cx, |s| { - s.select(new_selections); - }); - true - } else { - false - } - } - - fn select_autoclose_pair(&mut self, cx: &mut ViewContext) -> bool { - let buffer = self.buffer.read(cx).snapshot(cx); - let old_selections = self.selections.all::(cx); - let autoclose_pair = if let Some(autoclose_pair) = self.autoclose_stack.last() { - autoclose_pair - } else { - return false; - }; - - debug_assert_eq!(old_selections.len(), autoclose_pair.ranges.len()); - - let mut new_selections = Vec::new(); - for (selection, autoclose_range) in old_selections - .iter() - .zip(autoclose_pair.ranges.iter().map(|r| r.to_offset(&buffer))) - { - if selection.is_empty() - && autoclose_range.is_empty() - && selection.start == autoclose_range.start - { - new_selections.push(Selection { - id: selection.id, - start: selection.start - autoclose_pair.pair.start.len(), - end: selection.end + autoclose_pair.pair.end.len(), - reversed: true, - goal: selection.goal, - }); - } else { - return false; + let mut enclosing = None; + while let Some(pair_state) = regions.get(i) { + if pair_state.range.end.to_offset(buffer) < range.start { + regions = ®ions[i + 1..]; + i = 0; + } else if pair_state.range.start.to_offset(buffer) > range.end { + break; + } else if pair_state.selection_id == selection.id { + enclosing = Some(pair_state); + i += 1; + } } - } - self.change_selections(Some(Autoscroll::Fit), cx, |selections| { - selections.select(new_selections) + (selection.clone(), enclosing) + }) + } + + /// Remove any autoclose regions that no longer contain their selection. + fn invalidate_autoclose_regions( + &mut self, + mut selections: &[Selection], + buffer: &MultiBufferSnapshot, + ) { + self.autoclose_regions.retain(|state| { + let mut i = 0; + while let Some(selection) = selections.get(i) { + if selection.end.cmp(&state.range.start, buffer).is_lt() { + selections = &selections[1..]; + continue; + } + if selection.start.cmp(&state.range.end, buffer).is_gt() { + break; + } + if selection.id == state.selection_id { + return true; + } else { + i += 1; + } + } + false }); - true } fn completion_query(buffer: &MultiBufferSnapshot, position: impl ToOffset) -> Option { @@ -2902,51 +2999,49 @@ impl Editor { pub fn backspace(&mut self, _: &Backspace, cx: &mut ViewContext) { self.transact(cx, |this, cx| { - if !this.select_autoclose_pair(cx) { - let mut selections = this.selections.all::(cx); - if !this.selections.line_mode { - let display_map = this.display_map.update(cx, |map, cx| map.snapshot(cx)); - for selection in &mut selections { - if selection.is_empty() { - let old_head = selection.head(); - let mut new_head = movement::left( - &display_map, - old_head.to_display_point(&display_map), - ) - .to_point(&display_map); - if let Some((buffer, line_buffer_range)) = display_map - .buffer_snapshot - .buffer_line_for_row(old_head.row) - { - let indent_size = - buffer.indent_size_for_line(line_buffer_range.start.row); - let language_name = - buffer.language().map(|language| language.name()); - let indent_len = match indent_size.kind { - IndentKind::Space => { - cx.global::().tab_size(language_name.as_deref()) - } - IndentKind::Tab => NonZeroU32::new(1).unwrap(), - }; - if old_head.column <= indent_size.len && old_head.column > 0 { - let indent_len = indent_len.get(); - new_head = cmp::min( - new_head, - Point::new( - old_head.row, - ((old_head.column - 1) / indent_len) * indent_len, - ), - ); + this.select_autoclose_pair(cx); + let mut selections = this.selections.all::(cx); + if !this.selections.line_mode { + let display_map = this.display_map.update(cx, |map, cx| map.snapshot(cx)); + for selection in &mut selections { + if selection.is_empty() { + let old_head = selection.head(); + let mut new_head = + movement::left(&display_map, old_head.to_display_point(&display_map)) + .to_point(&display_map); + if let Some((buffer, line_buffer_range)) = display_map + .buffer_snapshot + .buffer_line_for_row(old_head.row) + { + let indent_size = + buffer.indent_size_for_line(line_buffer_range.start.row); + let language_name = buffer + .language_at(line_buffer_range.start) + .map(|language| language.name()); + let indent_len = match indent_size.kind { + IndentKind::Space => { + cx.global::().tab_size(language_name.as_deref()) } + IndentKind::Tab => NonZeroU32::new(1).unwrap(), + }; + if old_head.column <= indent_size.len && old_head.column > 0 { + let indent_len = indent_len.get(); + new_head = cmp::min( + new_head, + Point::new( + old_head.row, + ((old_head.column - 1) / indent_len) * indent_len, + ), + ); } - - selection.set_head(new_head, SelectionGoal::None); } + + selection.set_head(new_head, SelectionGoal::None); } } - - this.change_selections(Some(Autoscroll::Fit), cx, |s| s.select(selections)); } + + this.change_selections(Some(Autoscroll::Fit), cx, |s| s.select(selections)); this.insert("", cx); }); } @@ -3818,6 +3913,23 @@ impl Editor { }) } + pub fn center_screen(&mut self, _: &CenterScreen, cx: &mut ViewContext) { + if self.take_rename(true, cx).is_some() { + return; + } + + if let Some(_) = self.context_menu.as_mut() { + return; + } + + if matches!(self.mode, EditorMode::SingleLine) { + cx.propagate_action(); + return; + } + + self.request_autoscroll(Autoscroll::Center, cx); + } + pub fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext) { if self.take_rename(true, cx).is_some() { return; @@ -3846,6 +3958,72 @@ impl Editor { }) } + pub fn move_page_up(&mut self, action: &MovePageUp, cx: &mut ViewContext) { + if self.take_rename(true, cx).is_some() { + return; + } + + if let Some(context_menu) = self.context_menu.as_mut() { + if context_menu.select_first(cx) { + return; + } + } + + if matches!(self.mode, EditorMode::SingleLine) { + cx.propagate_action(); + return; + } + + let row_count = match self.visible_line_count { + Some(row_count) => row_count as u32 - 1, + None => return, + }; + + let autoscroll = if action.center_cursor { + Autoscroll::Center + } else { + Autoscroll::Fit + }; + + self.change_selections(Some(autoscroll), cx, |s| { + let line_mode = s.line_mode; + s.move_with(|map, selection| { + if !selection.is_empty() && !line_mode { + selection.goal = SelectionGoal::None; + } + let (cursor, goal) = + movement::up_by_rows(map, selection.end, row_count, selection.goal, false); + selection.collapse_to(cursor, goal); + }); + }); + } + + pub fn page_up(&mut self, _: &PageUp, cx: &mut ViewContext) { + if self.take_rename(true, cx).is_some() { + return; + } + + if let Some(context_menu) = self.context_menu.as_mut() { + if context_menu.select_first(cx) { + return; + } + } + + if matches!(self.mode, EditorMode::SingleLine) { + cx.propagate_action(); + return; + } + + let lines = match self.visible_line_count { + Some(lines) => lines, + None => return, + }; + + let cur_position = self.scroll_position(cx); + let new_pos = cur_position - vec2f(0., lines + 1.); + self.set_scroll_position(new_pos, cx); + } + pub fn select_up(&mut self, _: &SelectUp, cx: &mut ViewContext) { self.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_heads_with(|map, head, goal| movement::up(map, head, goal, false)) @@ -3878,6 +4056,72 @@ impl Editor { }); } + pub fn move_page_down(&mut self, action: &MovePageDown, cx: &mut ViewContext) { + if self.take_rename(true, cx).is_some() { + return; + } + + if let Some(context_menu) = self.context_menu.as_mut() { + if context_menu.select_last(cx) { + return; + } + } + + if matches!(self.mode, EditorMode::SingleLine) { + cx.propagate_action(); + return; + } + + let row_count = match self.visible_line_count { + Some(row_count) => row_count as u32 - 1, + None => return, + }; + + let autoscroll = if action.center_cursor { + Autoscroll::Center + } else { + Autoscroll::Fit + }; + + self.change_selections(Some(autoscroll), cx, |s| { + let line_mode = s.line_mode; + s.move_with(|map, selection| { + if !selection.is_empty() && !line_mode { + selection.goal = SelectionGoal::None; + } + let (cursor, goal) = + movement::down_by_rows(map, selection.end, row_count, selection.goal, false); + selection.collapse_to(cursor, goal); + }); + }); + } + + pub fn page_down(&mut self, _: &PageDown, cx: &mut ViewContext) { + if self.take_rename(true, cx).is_some() { + return; + } + + if let Some(context_menu) = self.context_menu.as_mut() { + if context_menu.select_last(cx) { + return; + } + } + + if matches!(self.mode, EditorMode::SingleLine) { + cx.propagate_action(); + return; + } + + let lines = match self.visible_line_count { + Some(lines) => lines, + None => return, + }; + + let cur_position = self.scroll_position(cx); + let new_pos = cur_position + vec2f(0., lines - 1.); + self.set_scroll_position(new_pos, cx); + } + pub fn select_down(&mut self, _: &SelectDown, cx: &mut ViewContext) { self.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_heads_with(|map, head, goal| movement::down(map, head, goal, false)) @@ -3950,17 +4194,16 @@ impl Editor { cx: &mut ViewContext, ) { self.transact(cx, |this, cx| { - if !this.select_autoclose_pair(cx) { - this.change_selections(Some(Autoscroll::Fit), cx, |s| { - let line_mode = s.line_mode; - s.move_with(|map, selection| { - if selection.is_empty() && !line_mode { - let cursor = movement::previous_word_start(map, selection.head()); - selection.set_head(cursor, SelectionGoal::None); - } - }); + this.select_autoclose_pair(cx); + this.change_selections(Some(Autoscroll::Fit), cx, |s| { + let line_mode = s.line_mode; + s.move_with(|map, selection| { + if selection.is_empty() && !line_mode { + let cursor = movement::previous_word_start(map, selection.head()); + selection.set_head(cursor, SelectionGoal::None); + } }); - } + }); this.insert("", cx); }); } @@ -3971,17 +4214,16 @@ impl Editor { cx: &mut ViewContext, ) { self.transact(cx, |this, cx| { - if !this.select_autoclose_pair(cx) { - this.change_selections(Some(Autoscroll::Fit), cx, |s| { - let line_mode = s.line_mode; - s.move_with(|map, selection| { - if selection.is_empty() && !line_mode { - let cursor = movement::previous_subword_start(map, selection.head()); - selection.set_head(cursor, SelectionGoal::None); - } - }); + this.select_autoclose_pair(cx); + this.change_selections(Some(Autoscroll::Fit), cx, |s| { + let line_mode = s.line_mode; + s.move_with(|map, selection| { + if selection.is_empty() && !line_mode { + let cursor = movement::previous_subword_start(map, selection.head()); + selection.set_head(cursor, SelectionGoal::None); + } }); - } + }); this.insert("", cx); }); } @@ -4067,7 +4309,7 @@ impl Editor { self.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_cursors_with(|map, head, _| { ( - movement::line_beginning(map, head, true), + movement::indented_line_beginning(map, head, true), SelectionGoal::None, ) }); @@ -4082,7 +4324,7 @@ impl Editor { self.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_heads_with(|map, head, _| { ( - movement::line_beginning(map, head, action.stop_at_soft_wraps), + movement::indented_line_beginning(map, head, action.stop_at_soft_wraps), SelectionGoal::None, ) }); @@ -4484,108 +4726,218 @@ impl Editor { pub fn toggle_comments(&mut self, _: &ToggleComments, cx: &mut ViewContext) { self.transact(cx, |this, cx| { let mut selections = this.selections.all::(cx); - let mut all_selection_lines_are_comments = true; - let mut edit_ranges = Vec::new(); + let mut edits = Vec::new(); + let mut selection_edit_ranges = Vec::new(); let mut last_toggled_row = None; - this.buffer.update(cx, |buffer, cx| { - // TODO: Handle selections that cross excerpts - for selection in &mut selections { - // Get the line comment prefix. Split its trailing whitespace into a separate string, - // as that portion won't be used for detecting if a line is a comment. - let full_comment_prefix: Arc = if let Some(prefix) = buffer - .language_at(selection.start, cx) - .and_then(|l| l.line_comment_prefix()) - { - prefix.into() + let snapshot = this.buffer.read(cx).read(cx); + let empty_str: Arc = "".into(); + let mut suffixes_inserted = Vec::new(); + + fn comment_prefix_range( + snapshot: &MultiBufferSnapshot, + row: u32, + comment_prefix: &str, + comment_prefix_whitespace: &str, + ) -> Range { + let start = Point::new(row, snapshot.indent_size_for_line(row).len); + + let mut line_bytes = snapshot + .bytes_in_range(start..snapshot.max_point()) + .flatten() + .copied(); + + // If this line currently begins with the line comment prefix, then record + // the range containing the prefix. + if line_bytes + .by_ref() + .take(comment_prefix.len()) + .eq(comment_prefix.bytes()) + { + // Include any whitespace that matches the comment prefix. + let matching_whitespace_len = line_bytes + .zip(comment_prefix_whitespace.bytes()) + .take_while(|(a, b)| a == b) + .count() as u32; + let end = Point::new( + start.row, + start.column + comment_prefix.len() as u32 + matching_whitespace_len, + ); + start..end + } else { + start..start + } + } + + fn comment_suffix_range( + snapshot: &MultiBufferSnapshot, + row: u32, + comment_suffix: &str, + comment_suffix_has_leading_space: bool, + ) -> Range { + let end = Point::new(row, snapshot.line_len(row)); + let suffix_start_column = end.column.saturating_sub(comment_suffix.len() as u32); + + let mut line_end_bytes = snapshot + .bytes_in_range(Point::new(end.row, suffix_start_column.saturating_sub(1))..end) + .flatten() + .copied(); + + let leading_space_len = if suffix_start_column > 0 + && line_end_bytes.next() == Some(b' ') + && comment_suffix_has_leading_space + { + 1 + } else { + 0 + }; + + // If this line currently begins with the line comment prefix, then record + // the range containing the prefix. + if line_end_bytes.by_ref().eq(comment_suffix.bytes()) { + let start = Point::new(end.row, suffix_start_column - leading_space_len); + start..end + } else { + end..end + } + } + + // TODO: Handle selections that cross excerpts + for selection in &mut selections { + let language = if let Some(language) = snapshot.language_at(selection.start) { + language + } else { + continue; + }; + + selection_edit_ranges.clear(); + + // If multiple selections contain a given row, avoid processing that + // row more than once. + let mut start_row = selection.start.row; + if last_toggled_row == Some(start_row) { + start_row += 1; + } + let end_row = + if selection.end.row > selection.start.row && selection.end.column == 0 { + selection.end.row - 1 } else { - return; + selection.end.row }; + last_toggled_row = Some(end_row); + + if start_row > end_row { + continue; + } + + // If the language has line comments, toggle those. + if let Some(full_comment_prefix) = language.line_comment_prefix() { + // Split the comment prefix's trailing whitespace into a separate string, + // as that portion won't be used for detecting if a line is a comment. let comment_prefix = full_comment_prefix.trim_end_matches(' '); let comment_prefix_whitespace = &full_comment_prefix[comment_prefix.len()..]; - edit_ranges.clear(); - let snapshot = buffer.snapshot(cx); - - let end_row = - if selection.end.row > selection.start.row && selection.end.column == 0 { - selection.end.row - } else { - selection.end.row + 1 - }; - - for row in selection.start.row..end_row { - // If multiple selections contain a given row, avoid processing that - // row more than once. - if last_toggled_row == Some(row) { - continue; - } else { - last_toggled_row = Some(row); - } + let mut all_selection_lines_are_comments = true; + for row in start_row..=end_row { if snapshot.is_line_blank(row) { continue; } - let start = Point::new(row, snapshot.indent_size_for_line(row).len); - let mut line_bytes = snapshot - .bytes_in_range(start..snapshot.max_point()) - .flatten() - .copied(); - - // If this line currently begins with the line comment prefix, then record - // the range containing the prefix. - if all_selection_lines_are_comments - && line_bytes - .by_ref() - .take(comment_prefix.len()) - .eq(comment_prefix.bytes()) - { - // Include any whitespace that matches the comment prefix. - let matching_whitespace_len = line_bytes - .zip(comment_prefix_whitespace.bytes()) - .take_while(|(a, b)| a == b) - .count() - as u32; - let end = Point::new( - row, - start.column - + comment_prefix.len() as u32 - + matching_whitespace_len, - ); - edit_ranges.push(start..end); - } - // If this line does not begin with the line comment prefix, then record - // the position where the prefix should be inserted. - else { + let prefix_range = comment_prefix_range( + snapshot.deref(), + row, + comment_prefix, + comment_prefix_whitespace, + ); + if prefix_range.is_empty() { all_selection_lines_are_comments = false; - edit_ranges.push(start..start); } + selection_edit_ranges.push(prefix_range); } - if !edit_ranges.is_empty() { - if all_selection_lines_are_comments { - let empty_str: Arc = "".into(); - buffer.edit( - edit_ranges - .iter() - .cloned() - .map(|range| (range, empty_str.clone())), - None, - cx, - ); - } else { - let min_column = - edit_ranges.iter().map(|r| r.start.column).min().unwrap(); - let edits = edit_ranges.iter().map(|range| { - let position = Point::new(range.start.row, min_column); - (position..position, full_comment_prefix.clone()) - }); - buffer.edit(edits, None, cx); + if all_selection_lines_are_comments { + edits.extend( + selection_edit_ranges + .iter() + .cloned() + .map(|range| (range, empty_str.clone())), + ); + } else { + let min_column = selection_edit_ranges + .iter() + .map(|r| r.start.column) + .min() + .unwrap_or(0); + edits.extend(selection_edit_ranges.iter().map(|range| { + let position = Point::new(range.start.row, min_column); + (position..position, full_comment_prefix.clone()) + })); + } + } else if let Some((full_comment_prefix, comment_suffix)) = + language.block_comment_delimiters() + { + let comment_prefix = full_comment_prefix.trim_end_matches(' '); + let comment_prefix_whitespace = &full_comment_prefix[comment_prefix.len()..]; + let prefix_range = comment_prefix_range( + snapshot.deref(), + start_row, + comment_prefix, + comment_prefix_whitespace, + ); + let suffix_range = comment_suffix_range( + snapshot.deref(), + end_row, + comment_suffix.trim_start_matches(' '), + comment_suffix.starts_with(' '), + ); + + if prefix_range.is_empty() || suffix_range.is_empty() { + edits.push(( + prefix_range.start..prefix_range.start, + full_comment_prefix.clone(), + )); + edits.push((suffix_range.end..suffix_range.end, comment_suffix.clone())); + suffixes_inserted.push((end_row, comment_suffix.len())); + } else { + edits.push((prefix_range, empty_str.clone())); + edits.push((suffix_range, empty_str.clone())); + } + } else { + continue; + } + } + + drop(snapshot); + this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + + // Adjust selections so that they end before any comment suffixes that + // were inserted. + let mut suffixes_inserted = suffixes_inserted.into_iter().peekable(); + let mut selections = this.selections.all::(cx); + let snapshot = this.buffer.read(cx).read(cx); + for selection in &mut selections { + while let Some((row, suffix_len)) = suffixes_inserted.peek().copied() { + match row.cmp(&selection.end.row) { + Ordering::Less => { + suffixes_inserted.next(); + continue; + } + Ordering::Greater => break, + Ordering::Equal => { + if selection.end.column == snapshot.line_len(row) { + if selection.is_empty() { + selection.start.column -= suffix_len as u32; + } + selection.end.column -= suffix_len as u32; + } + break; } } } - }); + } - let selections = this.selections.all::(cx); + drop(snapshot); this.change_selections(Some(Autoscroll::Fit), cx, |s| s.select(selections)); }); } @@ -5068,7 +5420,7 @@ impl Editor { render: Arc::new({ let editor = rename_editor.clone(); move |cx: &mut BlockContext| { - ChildView::new(editor.clone()) + ChildView::new(editor.clone(), cx) .contained() .with_padding_left(cx.anchor_x) .boxed() @@ -5173,6 +5525,51 @@ impl Editor { self.pending_rename.as_ref() } + fn format(&mut self, _: &Format, cx: &mut ViewContext<'_, Self>) -> Option>> { + let project = match &self.project { + Some(project) => project.clone(), + None => return None, + }; + + Some(self.perform_format(project, cx)) + } + + fn perform_format( + &mut self, + project: ModelHandle, + cx: &mut ViewContext<'_, Self>, + ) -> Task> { + let buffer = self.buffer().clone(); + let buffers = buffer.read(cx).all_buffers(); + + let mut timeout = cx.background().timer(FORMAT_TIMEOUT).fuse(); + let format = project.update(cx, |project, cx| { + project.format(buffers, true, FormatTrigger::Manual, cx) + }); + + cx.spawn(|_, mut cx| async move { + let transaction = futures::select_biased! { + _ = timeout => { + log::warn!("timed out waiting for formatting"); + None + } + transaction = format.log_err().fuse() => transaction, + }; + + buffer.update(&mut cx, |buffer, cx| { + if let Some(transaction) = transaction { + if !buffer.is_singleton() { + buffer.push_transaction(&transaction.0); + } + } + + cx.notify(); + }); + + Ok(()) + }) + } + fn restart_language_server(&mut self, _: &RestartLanguageServer, cx: &mut ViewContext) { if let Some(project) = self.project.clone() { self.buffer.update(cx, |multi_buffer, cx| { @@ -5352,14 +5749,6 @@ impl Editor { } } - pub fn page_up(&mut self, _: &PageUp, _: &mut ViewContext) { - log::info!("Editor::page_up"); - } - - pub fn page_down(&mut self, _: &PageDown, _: &mut ViewContext) { - log::info!("Editor::page_down"); - } - pub fn fold(&mut self, _: &Fold, cx: &mut ViewContext) { let mut fold_ranges = Vec::new(); @@ -5594,8 +5983,11 @@ impl Editor { &mut self, cx: &mut ViewContext, ) -> Option<(fn(&Theme) -> Color, Vec>)> { - cx.notify(); - self.background_highlights.remove(&TypeId::of::()) + let highlights = self.background_highlights.remove(&TypeId::of::()); + if highlights.is_some() { + cx.notify(); + } + highlights } #[cfg(feature = "test-support")] @@ -5709,9 +6101,13 @@ impl Editor { &mut self, cx: &mut ViewContext, ) -> Option>)>> { - cx.notify(); - self.display_map - .update(cx, |map, _| map.clear_text_highlights(TypeId::of::())) + let highlights = self + .display_map + .update(cx, |map, _| map.clear_text_highlights(TypeId::of::())); + if highlights.is_some() { + cx.notify(); + } + highlights } fn next_blink_epoch(&mut self) -> usize { @@ -5770,6 +6166,31 @@ impl Editor { self.show_local_cursors && self.focused } + pub fn show_scrollbars(&self) -> bool { + self.show_scrollbars + } + + fn make_scrollbar_visible(&mut self, cx: &mut ViewContext) { + if !self.show_scrollbars { + self.show_scrollbars = true; + cx.notify(); + } + + if cx.default_global::().0 { + self.hide_scrollbar_task = Some(cx.spawn_weak(|this, mut cx| async move { + Timer::after(SCROLLBAR_SHOW_INTERVAL).await; + if let Some(this) = this.upgrade(&cx) { + this.update(&mut cx, |this, cx| { + this.show_scrollbars = false; + cx.notify(); + }); + } + })); + } else { + self.hide_scrollbar_task = None; + } + } + fn on_buffer_changed(&mut self, _: ModelHandle, cx: &mut ViewContext) { cx.notify(); } @@ -5933,9 +6354,32 @@ impl Editor { }) .collect() } + + fn report_event(&self, name: &str, cx: &AppContext) { + if let Some((project, file)) = self.project.as_ref().zip( + self.buffer + .read(cx) + .as_singleton() + .and_then(|b| b.read(cx).file()), + ) { + project.read(cx).client().report_event( + name, + json!({ + "file_extension": file + .path() + .extension() + .and_then(|e| e.to_str()) + }), + ); + } + } } impl EditorSnapshot { + pub fn language_at(&self, position: T) -> Option<&Arc> { + self.display_snapshot.buffer_snapshot.language_at(position) + } + pub fn is_focused(&self) -> bool { self.is_focused } @@ -6025,7 +6469,7 @@ impl View for Editor { .with_child( EditorElement::new(self.handle.clone(), style.clone(), self.cursor_shape).boxed(), ) - .with_child(ChildView::new(&self.mouse_context_menu).boxed()) + .with_child(ChildView::new(&self.mouse_context_menu, cx).boxed()) .boxed() } @@ -6424,12 +6868,6 @@ impl DerefMut for InvalidationStack { } } -impl InvalidationRegion for BracketPairState { - fn ranges(&self) -> &[Range] { - &self.ranges - } -} - impl InvalidationRegion for SnippetState { fn ranges(&self) -> &[Range] { &self.ranges[self.active_index] @@ -6628,4531 +7066,6 @@ pub fn styled_runs_for_code_label<'a>( }) } -#[cfg(test)] -mod tests { - use crate::test::{ - assert_text_with_selections, build_editor, select_ranges, EditorLspTestContext, - EditorTestContext, - }; - - use super::*; - use futures::StreamExt; - use gpui::{ - geometry::rect::RectF, - platform::{WindowBounds, WindowOptions}, - }; - use indoc::indoc; - use language::{FakeLspAdapter, LanguageConfig}; - use project::FakeFs; - use settings::EditorSettings; - use std::{cell::RefCell, rc::Rc, time::Instant}; - use text::Point; - use unindent::Unindent; - use util::{ - assert_set_eq, - test::{marked_text_ranges, marked_text_ranges_by, sample_text, TextRangeMarker}, - }; - use workspace::{FollowableItem, ItemHandle, NavigationEntry, Pane}; - - #[gpui::test] - fn test_edit_events(cx: &mut MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = cx.add_model(|cx| language::Buffer::new(0, "123456", cx)); - - let events = Rc::new(RefCell::new(Vec::new())); - let (_, editor1) = cx.add_window(Default::default(), { - let events = events.clone(); - |cx| { - cx.subscribe(&cx.handle(), move |_, _, event, _| { - if matches!( - event, - Event::Edited | Event::BufferEdited | Event::DirtyChanged - ) { - events.borrow_mut().push(("editor1", *event)); - } - }) - .detach(); - Editor::for_buffer(buffer.clone(), None, cx) - } - }); - let (_, editor2) = cx.add_window(Default::default(), { - let events = events.clone(); - |cx| { - cx.subscribe(&cx.handle(), move |_, _, event, _| { - if matches!( - event, - Event::Edited | Event::BufferEdited | Event::DirtyChanged - ) { - events.borrow_mut().push(("editor2", *event)); - } - }) - .detach(); - Editor::for_buffer(buffer.clone(), None, cx) - } - }); - assert_eq!(mem::take(&mut *events.borrow_mut()), []); - - // Mutating editor 1 will emit an `Edited` event only for that editor. - editor1.update(cx, |editor, cx| editor.insert("X", cx)); - assert_eq!( - mem::take(&mut *events.borrow_mut()), - [ - ("editor1", Event::Edited), - ("editor1", Event::BufferEdited), - ("editor2", Event::BufferEdited), - ("editor1", Event::DirtyChanged), - ("editor2", Event::DirtyChanged) - ] - ); - - // Mutating editor 2 will emit an `Edited` event only for that editor. - editor2.update(cx, |editor, cx| editor.delete(&Delete, cx)); - assert_eq!( - mem::take(&mut *events.borrow_mut()), - [ - ("editor2", Event::Edited), - ("editor1", Event::BufferEdited), - ("editor2", Event::BufferEdited), - ] - ); - - // Undoing on editor 1 will emit an `Edited` event only for that editor. - editor1.update(cx, |editor, cx| editor.undo(&Undo, cx)); - assert_eq!( - mem::take(&mut *events.borrow_mut()), - [ - ("editor1", Event::Edited), - ("editor1", Event::BufferEdited), - ("editor2", Event::BufferEdited), - ("editor1", Event::DirtyChanged), - ("editor2", Event::DirtyChanged), - ] - ); - - // Redoing on editor 1 will emit an `Edited` event only for that editor. - editor1.update(cx, |editor, cx| editor.redo(&Redo, cx)); - assert_eq!( - mem::take(&mut *events.borrow_mut()), - [ - ("editor1", Event::Edited), - ("editor1", Event::BufferEdited), - ("editor2", Event::BufferEdited), - ("editor1", Event::DirtyChanged), - ("editor2", Event::DirtyChanged), - ] - ); - - // Undoing on editor 2 will emit an `Edited` event only for that editor. - editor2.update(cx, |editor, cx| editor.undo(&Undo, cx)); - assert_eq!( - mem::take(&mut *events.borrow_mut()), - [ - ("editor2", Event::Edited), - ("editor1", Event::BufferEdited), - ("editor2", Event::BufferEdited), - ("editor1", Event::DirtyChanged), - ("editor2", Event::DirtyChanged), - ] - ); - - // Redoing on editor 2 will emit an `Edited` event only for that editor. - editor2.update(cx, |editor, cx| editor.redo(&Redo, cx)); - assert_eq!( - mem::take(&mut *events.borrow_mut()), - [ - ("editor2", Event::Edited), - ("editor1", Event::BufferEdited), - ("editor2", Event::BufferEdited), - ("editor1", Event::DirtyChanged), - ("editor2", Event::DirtyChanged), - ] - ); - - // No event is emitted when the mutation is a no-op. - editor2.update(cx, |editor, cx| { - editor.change_selections(None, cx, |s| s.select_ranges([0..0])); - - editor.backspace(&Backspace, cx); - }); - assert_eq!(mem::take(&mut *events.borrow_mut()), []); - } - - #[gpui::test] - fn test_undo_redo_with_selection_restoration(cx: &mut MutableAppContext) { - cx.set_global(Settings::test(cx)); - let mut now = Instant::now(); - let buffer = cx.add_model(|cx| language::Buffer::new(0, "123456", cx)); - let group_interval = buffer.read(cx).transaction_group_interval(); - let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); - let (_, editor) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); - - editor.update(cx, |editor, cx| { - editor.start_transaction_at(now, cx); - editor.change_selections(None, cx, |s| s.select_ranges([2..4])); - - editor.insert("cd", cx); - editor.end_transaction_at(now, cx); - assert_eq!(editor.text(cx), "12cd56"); - assert_eq!(editor.selections.ranges(cx), vec![4..4]); - - editor.start_transaction_at(now, cx); - editor.change_selections(None, cx, |s| s.select_ranges([4..5])); - editor.insert("e", cx); - editor.end_transaction_at(now, cx); - assert_eq!(editor.text(cx), "12cde6"); - assert_eq!(editor.selections.ranges(cx), vec![5..5]); - - now += group_interval + Duration::from_millis(1); - editor.change_selections(None, cx, |s| s.select_ranges([2..2])); - - // Simulate an edit in another editor - buffer.update(cx, |buffer, cx| { - buffer.start_transaction_at(now, cx); - buffer.edit([(0..1, "a")], None, cx); - buffer.edit([(1..1, "b")], None, cx); - buffer.end_transaction_at(now, cx); - }); - - assert_eq!(editor.text(cx), "ab2cde6"); - assert_eq!(editor.selections.ranges(cx), vec![3..3]); - - // Last transaction happened past the group interval in a different editor. - // Undo it individually and don't restore selections. - editor.undo(&Undo, cx); - assert_eq!(editor.text(cx), "12cde6"); - assert_eq!(editor.selections.ranges(cx), vec![2..2]); - - // First two transactions happened within the group interval in this editor. - // Undo them together and restore selections. - editor.undo(&Undo, cx); - editor.undo(&Undo, cx); // Undo stack is empty here, so this is a no-op. - assert_eq!(editor.text(cx), "123456"); - assert_eq!(editor.selections.ranges(cx), vec![0..0]); - - // Redo the first two transactions together. - editor.redo(&Redo, cx); - assert_eq!(editor.text(cx), "12cde6"); - assert_eq!(editor.selections.ranges(cx), vec![5..5]); - - // Redo the last transaction on its own. - editor.redo(&Redo, cx); - assert_eq!(editor.text(cx), "ab2cde6"); - assert_eq!(editor.selections.ranges(cx), vec![6..6]); - - // Test empty transactions. - editor.start_transaction_at(now, cx); - editor.end_transaction_at(now, cx); - editor.undo(&Undo, cx); - assert_eq!(editor.text(cx), "12cde6"); - }); - } - - #[gpui::test] - fn test_ime_composition(cx: &mut MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = cx.add_model(|cx| { - let mut buffer = language::Buffer::new(0, "abcde", cx); - // Ensure automatic grouping doesn't occur. - buffer.set_group_interval(Duration::ZERO); - buffer - }); - - let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); - cx.add_window(Default::default(), |cx| { - let mut editor = build_editor(buffer.clone(), cx); - - // Start a new IME composition. - editor.replace_and_mark_text_in_range(Some(0..1), "à", None, cx); - editor.replace_and_mark_text_in_range(Some(0..1), "á", None, cx); - editor.replace_and_mark_text_in_range(Some(0..1), "ä", None, cx); - assert_eq!(editor.text(cx), "äbcde"); - assert_eq!( - editor.marked_text_ranges(cx), - Some(vec![OffsetUtf16(0)..OffsetUtf16(1)]) - ); - - // Finalize IME composition. - editor.replace_text_in_range(None, "ā", cx); - assert_eq!(editor.text(cx), "ābcde"); - assert_eq!(editor.marked_text_ranges(cx), None); - - // IME composition edits are grouped and are undone/redone at once. - editor.undo(&Default::default(), cx); - assert_eq!(editor.text(cx), "abcde"); - assert_eq!(editor.marked_text_ranges(cx), None); - editor.redo(&Default::default(), cx); - assert_eq!(editor.text(cx), "ābcde"); - assert_eq!(editor.marked_text_ranges(cx), None); - - // Start a new IME composition. - editor.replace_and_mark_text_in_range(Some(0..1), "à", None, cx); - assert_eq!( - editor.marked_text_ranges(cx), - Some(vec![OffsetUtf16(0)..OffsetUtf16(1)]) - ); - - // Undoing during an IME composition cancels it. - editor.undo(&Default::default(), cx); - assert_eq!(editor.text(cx), "ābcde"); - assert_eq!(editor.marked_text_ranges(cx), None); - - // Start a new IME composition with an invalid marked range, ensuring it gets clipped. - editor.replace_and_mark_text_in_range(Some(4..999), "è", None, cx); - assert_eq!(editor.text(cx), "ābcdè"); - assert_eq!( - editor.marked_text_ranges(cx), - Some(vec![OffsetUtf16(4)..OffsetUtf16(5)]) - ); - - // Finalize IME composition with an invalid replacement range, ensuring it gets clipped. - editor.replace_text_in_range(Some(4..999), "ę", cx); - assert_eq!(editor.text(cx), "ābcdę"); - assert_eq!(editor.marked_text_ranges(cx), None); - - // Start a new IME composition with multiple cursors. - editor.change_selections(None, cx, |s| { - s.select_ranges([ - OffsetUtf16(1)..OffsetUtf16(1), - OffsetUtf16(3)..OffsetUtf16(3), - OffsetUtf16(5)..OffsetUtf16(5), - ]) - }); - editor.replace_and_mark_text_in_range(Some(4..5), "XYZ", None, cx); - assert_eq!(editor.text(cx), "XYZbXYZdXYZ"); - assert_eq!( - editor.marked_text_ranges(cx), - Some(vec![ - OffsetUtf16(0)..OffsetUtf16(3), - OffsetUtf16(4)..OffsetUtf16(7), - OffsetUtf16(8)..OffsetUtf16(11) - ]) - ); - - // Ensure the newly-marked range gets treated as relative to the previously-marked ranges. - editor.replace_and_mark_text_in_range(Some(1..2), "1", None, cx); - assert_eq!(editor.text(cx), "X1ZbX1ZdX1Z"); - assert_eq!( - editor.marked_text_ranges(cx), - Some(vec![ - OffsetUtf16(1)..OffsetUtf16(2), - OffsetUtf16(5)..OffsetUtf16(6), - OffsetUtf16(9)..OffsetUtf16(10) - ]) - ); - - // Finalize IME composition with multiple cursors. - editor.replace_text_in_range(Some(9..10), "2", cx); - assert_eq!(editor.text(cx), "X2ZbX2ZdX2Z"); - assert_eq!(editor.marked_text_ranges(cx), None); - - editor - }); - } - - #[gpui::test] - fn test_selection_with_mouse(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - - let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\nddddddd\n", cx); - let (_, editor) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - editor.update(cx, |view, cx| { - view.begin_selection(DisplayPoint::new(2, 2), false, 1, cx); - }); - assert_eq!( - editor.update(cx, |view, cx| view.selections.display_ranges(cx)), - [DisplayPoint::new(2, 2)..DisplayPoint::new(2, 2)] - ); - - editor.update(cx, |view, cx| { - view.update_selection(DisplayPoint::new(3, 3), 0, Vector2F::zero(), cx); - }); - - assert_eq!( - editor.update(cx, |view, cx| view.selections.display_ranges(cx)), - [DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3)] - ); - - editor.update(cx, |view, cx| { - view.update_selection(DisplayPoint::new(1, 1), 0, Vector2F::zero(), cx); - }); - - assert_eq!( - editor.update(cx, |view, cx| view.selections.display_ranges(cx)), - [DisplayPoint::new(2, 2)..DisplayPoint::new(1, 1)] - ); - - editor.update(cx, |view, cx| { - view.end_selection(cx); - view.update_selection(DisplayPoint::new(3, 3), 0, Vector2F::zero(), cx); - }); - - assert_eq!( - editor.update(cx, |view, cx| view.selections.display_ranges(cx)), - [DisplayPoint::new(2, 2)..DisplayPoint::new(1, 1)] - ); - - editor.update(cx, |view, cx| { - view.begin_selection(DisplayPoint::new(3, 3), true, 1, cx); - view.update_selection(DisplayPoint::new(0, 0), 0, Vector2F::zero(), cx); - }); - - assert_eq!( - editor.update(cx, |view, cx| view.selections.display_ranges(cx)), - [ - DisplayPoint::new(2, 2)..DisplayPoint::new(1, 1), - DisplayPoint::new(3, 3)..DisplayPoint::new(0, 0) - ] - ); - - editor.update(cx, |view, cx| { - view.end_selection(cx); - }); - - assert_eq!( - editor.update(cx, |view, cx| view.selections.display_ranges(cx)), - [DisplayPoint::new(3, 3)..DisplayPoint::new(0, 0)] - ); - } - - #[gpui::test] - fn test_canceling_pending_selection(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - - view.update(cx, |view, cx| { - view.begin_selection(DisplayPoint::new(2, 2), false, 1, cx); - assert_eq!( - view.selections.display_ranges(cx), - [DisplayPoint::new(2, 2)..DisplayPoint::new(2, 2)] - ); - }); - - view.update(cx, |view, cx| { - view.update_selection(DisplayPoint::new(3, 3), 0, Vector2F::zero(), cx); - assert_eq!( - view.selections.display_ranges(cx), - [DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3)] - ); - }); - - view.update(cx, |view, cx| { - view.cancel(&Cancel, cx); - view.update_selection(DisplayPoint::new(1, 1), 0, Vector2F::zero(), cx); - assert_eq!( - view.selections.display_ranges(cx), - [DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3)] - ); - }); - } - - #[gpui::test] - fn test_clone(cx: &mut gpui::MutableAppContext) { - let (text, selection_ranges) = marked_text_ranges( - indoc! {" - one - two - threeˇ - four - fiveˇ - "}, - true, - ); - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple(&text, cx); - - let (_, editor) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - - editor.update(cx, |editor, cx| { - editor.change_selections(None, cx, |s| s.select_ranges(selection_ranges.clone())); - editor.fold_ranges( - [ - Point::new(1, 0)..Point::new(2, 0), - Point::new(3, 0)..Point::new(4, 0), - ], - cx, - ); - }); - - let (_, cloned_editor) = editor.update(cx, |editor, cx| { - cx.add_window(Default::default(), |cx| editor.clone(cx)) - }); - - let snapshot = editor.update(cx, |e, cx| e.snapshot(cx)); - let cloned_snapshot = cloned_editor.update(cx, |e, cx| e.snapshot(cx)); - - assert_eq!( - cloned_editor.update(cx, |e, cx| e.display_text(cx)), - editor.update(cx, |e, cx| e.display_text(cx)) - ); - assert_eq!( - cloned_snapshot - .folds_in_range(0..text.len()) - .collect::>(), - snapshot.folds_in_range(0..text.len()).collect::>(), - ); - assert_set_eq!( - cloned_editor.read(cx).selections.ranges::(cx), - editor.read(cx).selections.ranges(cx) - ); - assert_set_eq!( - cloned_editor.update(cx, |e, cx| e.selections.display_ranges(cx)), - editor.update(cx, |e, cx| e.selections.display_ranges(cx)) - ); - } - - #[gpui::test] - fn test_navigation_history(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - use workspace::Item; - let (_, pane) = cx.add_window(Default::default(), |cx| Pane::new(None, cx)); - let buffer = MultiBuffer::build_simple(&sample_text(300, 5, 'a'), cx); - - cx.add_view(&pane, |cx| { - let mut editor = build_editor(buffer.clone(), cx); - let handle = cx.handle(); - editor.set_nav_history(Some(pane.read(cx).nav_history_for_item(&handle))); - - fn pop_history( - editor: &mut Editor, - cx: &mut MutableAppContext, - ) -> Option { - editor.nav_history.as_mut().unwrap().pop_backward(cx) - } - - // Move the cursor a small distance. - // Nothing is added to the navigation history. - editor.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)]) - }); - editor.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)]) - }); - assert!(pop_history(&mut editor, cx).is_none()); - - // Move the cursor a large distance. - // The history can jump back to the previous position. - editor.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(13, 0)..DisplayPoint::new(13, 3)]) - }); - let nav_entry = pop_history(&mut editor, cx).unwrap(); - editor.navigate(nav_entry.data.unwrap(), cx); - assert_eq!(nav_entry.item.id(), cx.view_id()); - assert_eq!( - editor.selections.display_ranges(cx), - &[DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)] - ); - assert!(pop_history(&mut editor, cx).is_none()); - - // Move the cursor a small distance via the mouse. - // Nothing is added to the navigation history. - editor.begin_selection(DisplayPoint::new(5, 0), false, 1, cx); - editor.end_selection(cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[DisplayPoint::new(5, 0)..DisplayPoint::new(5, 0)] - ); - assert!(pop_history(&mut editor, cx).is_none()); - - // Move the cursor a large distance via the mouse. - // The history can jump back to the previous position. - editor.begin_selection(DisplayPoint::new(15, 0), false, 1, cx); - editor.end_selection(cx); - assert_eq!( - editor.selections.display_ranges(cx), - &[DisplayPoint::new(15, 0)..DisplayPoint::new(15, 0)] - ); - let nav_entry = pop_history(&mut editor, cx).unwrap(); - editor.navigate(nav_entry.data.unwrap(), cx); - assert_eq!(nav_entry.item.id(), cx.view_id()); - assert_eq!( - editor.selections.display_ranges(cx), - &[DisplayPoint::new(5, 0)..DisplayPoint::new(5, 0)] - ); - assert!(pop_history(&mut editor, cx).is_none()); - - // Set scroll position to check later - editor.set_scroll_position(Vector2F::new(5.5, 5.5), cx); - let original_scroll_position = editor.scroll_position; - let original_scroll_top_anchor = editor.scroll_top_anchor.clone(); - - // Jump to the end of the document and adjust scroll - editor.move_to_end(&MoveToEnd, cx); - editor.set_scroll_position(Vector2F::new(-2.5, -0.5), cx); - assert_ne!(editor.scroll_position, original_scroll_position); - assert_ne!(editor.scroll_top_anchor, original_scroll_top_anchor); - - let nav_entry = pop_history(&mut editor, cx).unwrap(); - editor.navigate(nav_entry.data.unwrap(), cx); - assert_eq!(editor.scroll_position, original_scroll_position); - assert_eq!(editor.scroll_top_anchor, original_scroll_top_anchor); - - // Ensure we don't panic when navigation data contains invalid anchors *and* points. - let mut invalid_anchor = editor.scroll_top_anchor.clone(); - invalid_anchor.text_anchor.buffer_id = Some(999); - let invalid_point = Point::new(9999, 0); - editor.navigate( - Box::new(NavigationData { - cursor_anchor: invalid_anchor.clone(), - cursor_position: invalid_point, - scroll_top_anchor: invalid_anchor, - scroll_top_row: invalid_point.row, - scroll_position: Default::default(), - }), - cx, - ); - assert_eq!( - editor.selections.display_ranges(cx), - &[editor.max_point(cx)..editor.max_point(cx)] - ); - assert_eq!( - editor.scroll_position(cx), - vec2f(0., editor.max_point(cx).row() as f32) - ); - - editor - }); - } - - #[gpui::test] - fn test_cancel(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - - view.update(cx, |view, cx| { - view.begin_selection(DisplayPoint::new(3, 4), false, 1, cx); - view.update_selection(DisplayPoint::new(1, 1), 0, Vector2F::zero(), cx); - view.end_selection(cx); - - view.begin_selection(DisplayPoint::new(0, 1), true, 1, cx); - view.update_selection(DisplayPoint::new(0, 3), 0, Vector2F::zero(), cx); - view.end_selection(cx); - assert_eq!( - view.selections.display_ranges(cx), - [ - DisplayPoint::new(0, 1)..DisplayPoint::new(0, 3), - DisplayPoint::new(3, 4)..DisplayPoint::new(1, 1), - ] - ); - }); - - view.update(cx, |view, cx| { - view.cancel(&Cancel, cx); - assert_eq!( - view.selections.display_ranges(cx), - [DisplayPoint::new(3, 4)..DisplayPoint::new(1, 1)] - ); - }); - - view.update(cx, |view, cx| { - view.cancel(&Cancel, cx); - assert_eq!( - view.selections.display_ranges(cx), - [DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1)] - ); - }); - } - - #[gpui::test] - fn test_fold(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple( - &" - impl Foo { - // Hello! - - fn a() { - 1 - } - - fn b() { - 2 - } - - fn c() { - 3 - } - } - " - .unindent(), - cx, - ); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); - - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(8, 0)..DisplayPoint::new(12, 0)]); - }); - view.fold(&Fold, cx); - assert_eq!( - view.display_text(cx), - " - impl Foo { - // Hello! - - fn a() { - 1 - } - - fn b() {… - } - - fn c() {… - } - } - " - .unindent(), - ); - - view.fold(&Fold, cx); - assert_eq!( - view.display_text(cx), - " - impl Foo {… - } - " - .unindent(), - ); - - view.unfold_lines(&UnfoldLines, cx); - assert_eq!( - view.display_text(cx), - " - impl Foo { - // Hello! - - fn a() { - 1 - } - - fn b() {… - } - - fn c() {… - } - } - " - .unindent(), - ); - - view.unfold_lines(&UnfoldLines, cx); - assert_eq!(view.display_text(cx), buffer.read(cx).read(cx).text()); - }); - } - - #[gpui::test] - fn test_move_cursor(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); - - buffer.update(cx, |buffer, cx| { - buffer.edit( - vec![ - (Point::new(1, 0)..Point::new(1, 0), "\t"), - (Point::new(1, 1)..Point::new(1, 1), "\t"), - ], - None, - cx, - ); - }); - - view.update(cx, |view, cx| { - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)] - ); - - view.move_down(&MoveDown, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)] - ); - - view.move_right(&MoveRight, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(1, 4)..DisplayPoint::new(1, 4)] - ); - - view.move_left(&MoveLeft, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)] - ); - - view.move_up(&MoveUp, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)] - ); - - view.move_to_end(&MoveToEnd, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(5, 6)..DisplayPoint::new(5, 6)] - ); - - view.move_to_beginning(&MoveToBeginning, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)] - ); - - view.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(0, 1)..DisplayPoint::new(0, 2)]); - }); - view.select_to_beginning(&SelectToBeginning, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(0, 1)..DisplayPoint::new(0, 0)] - ); - - view.select_to_end(&SelectToEnd, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(0, 1)..DisplayPoint::new(5, 6)] - ); - }); - } - - #[gpui::test] - fn test_move_cursor_multibyte(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcde\nαβγδε\n", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); - - assert_eq!('ⓐ'.len_utf8(), 3); - assert_eq!('α'.len_utf8(), 2); - - view.update(cx, |view, cx| { - view.fold_ranges( - vec![ - Point::new(0, 6)..Point::new(0, 12), - Point::new(1, 2)..Point::new(1, 4), - Point::new(2, 4)..Point::new(2, 8), - ], - cx, - ); - assert_eq!(view.display_text(cx), "ⓐⓑ…ⓔ\nab…e\nαβ…ε\n"); - - view.move_right(&MoveRight, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(0, "ⓐ".len())] - ); - view.move_right(&MoveRight, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(0, "ⓐⓑ".len())] - ); - view.move_right(&MoveRight, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(0, "ⓐⓑ…".len())] - ); - - view.move_down(&MoveDown, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(1, "ab…".len())] - ); - view.move_left(&MoveLeft, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(1, "ab".len())] - ); - view.move_left(&MoveLeft, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(1, "a".len())] - ); - - view.move_down(&MoveDown, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(2, "α".len())] - ); - view.move_right(&MoveRight, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(2, "αβ".len())] - ); - view.move_right(&MoveRight, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(2, "αβ…".len())] - ); - view.move_right(&MoveRight, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(2, "αβ…ε".len())] - ); - - view.move_up(&MoveUp, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(1, "ab…e".len())] - ); - view.move_up(&MoveUp, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(0, "ⓐⓑ…ⓔ".len())] - ); - view.move_left(&MoveLeft, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(0, "ⓐⓑ…".len())] - ); - view.move_left(&MoveLeft, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(0, "ⓐⓑ".len())] - ); - view.move_left(&MoveLeft, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(0, "ⓐ".len())] - ); - }); - } - - #[gpui::test] - fn test_move_cursor_different_line_lengths(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcd\nαβγ\nabcd\nⓐⓑⓒⓓⓔ\n", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([empty_range(0, "ⓐⓑⓒⓓⓔ".len())]); - }); - view.move_down(&MoveDown, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(1, "abcd".len())] - ); - - view.move_down(&MoveDown, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(2, "αβγ".len())] - ); - - view.move_down(&MoveDown, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(3, "abcd".len())] - ); - - view.move_down(&MoveDown, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(4, "ⓐⓑⓒⓓⓔ".len())] - ); - - view.move_up(&MoveUp, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(3, "abcd".len())] - ); - - view.move_up(&MoveUp, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[empty_range(2, "αβγ".len())] - ); - }); - } - - #[gpui::test] - fn test_beginning_end_of_line(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("abc\n def", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1), - DisplayPoint::new(1, 4)..DisplayPoint::new(1, 4), - ]); - }); - }); - - view.update(cx, |view, cx| { - view.move_to_beginning_of_line(&MoveToBeginningOfLine, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[ - DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0), - DisplayPoint::new(1, 2)..DisplayPoint::new(1, 2), - ] - ); - }); - - view.update(cx, |view, cx| { - view.move_to_beginning_of_line(&MoveToBeginningOfLine, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[ - DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0), - DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0), - ] - ); - }); - - view.update(cx, |view, cx| { - view.move_to_beginning_of_line(&MoveToBeginningOfLine, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[ - DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0), - DisplayPoint::new(1, 2)..DisplayPoint::new(1, 2), - ] - ); - }); - - view.update(cx, |view, cx| { - view.move_to_end_of_line(&MoveToEndOfLine, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[ - DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3), - DisplayPoint::new(1, 5)..DisplayPoint::new(1, 5), - ] - ); - }); - - // Moving to the end of line again is a no-op. - view.update(cx, |view, cx| { - view.move_to_end_of_line(&MoveToEndOfLine, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[ - DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3), - DisplayPoint::new(1, 5)..DisplayPoint::new(1, 5), - ] - ); - }); - - view.update(cx, |view, cx| { - view.move_left(&MoveLeft, cx); - view.select_to_beginning_of_line( - &SelectToBeginningOfLine { - stop_at_soft_wraps: true, - }, - cx, - ); - assert_eq!( - view.selections.display_ranges(cx), - &[ - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 0), - DisplayPoint::new(1, 4)..DisplayPoint::new(1, 2), - ] - ); - }); - - view.update(cx, |view, cx| { - view.select_to_beginning_of_line( - &SelectToBeginningOfLine { - stop_at_soft_wraps: true, - }, - cx, - ); - assert_eq!( - view.selections.display_ranges(cx), - &[ - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 0), - DisplayPoint::new(1, 4)..DisplayPoint::new(1, 0), - ] - ); - }); - - view.update(cx, |view, cx| { - view.select_to_beginning_of_line( - &SelectToBeginningOfLine { - stop_at_soft_wraps: true, - }, - cx, - ); - assert_eq!( - view.selections.display_ranges(cx), - &[ - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 0), - DisplayPoint::new(1, 4)..DisplayPoint::new(1, 2), - ] - ); - }); - - view.update(cx, |view, cx| { - view.select_to_end_of_line( - &SelectToEndOfLine { - stop_at_soft_wraps: true, - }, - cx, - ); - assert_eq!( - view.selections.display_ranges(cx), - &[ - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 3), - DisplayPoint::new(1, 4)..DisplayPoint::new(1, 5), - ] - ); - }); - - view.update(cx, |view, cx| { - view.delete_to_end_of_line(&DeleteToEndOfLine, cx); - assert_eq!(view.display_text(cx), "ab\n de"); - assert_eq!( - view.selections.display_ranges(cx), - &[ - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), - DisplayPoint::new(1, 4)..DisplayPoint::new(1, 4), - ] - ); - }); - - view.update(cx, |view, cx| { - view.delete_to_beginning_of_line(&DeleteToBeginningOfLine, cx); - assert_eq!(view.display_text(cx), "\n"); - assert_eq!( - view.selections.display_ranges(cx), - &[ - DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0), - DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0), - ] - ); - }); - } - - #[gpui::test] - fn test_prev_next_word_boundary(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("use std::str::{foo, bar}\n\n {baz.qux()}", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(0, 11)..DisplayPoint::new(0, 11), - DisplayPoint::new(2, 4)..DisplayPoint::new(2, 4), - ]) - }); - - view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); - assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n {ˇbaz.qux()}", view, cx); - - view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); - assert_selection_ranges("use stdˇ::str::{foo, bar}\n\n ˇ{baz.qux()}", view, cx); - - view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); - assert_selection_ranges("use ˇstd::str::{foo, bar}\n\nˇ {baz.qux()}", view, cx); - - view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); - assert_selection_ranges("ˇuse std::str::{foo, bar}\nˇ\n {baz.qux()}", view, cx); - - view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); - assert_selection_ranges("ˇuse std::str::{foo, barˇ}\n\n {baz.qux()}", view, cx); - - view.move_to_next_word_end(&MoveToNextWordEnd, cx); - assert_selection_ranges("useˇ std::str::{foo, bar}ˇ\n\n {baz.qux()}", view, cx); - - view.move_to_next_word_end(&MoveToNextWordEnd, cx); - assert_selection_ranges("use stdˇ::str::{foo, bar}\nˇ\n {baz.qux()}", view, cx); - - view.move_to_next_word_end(&MoveToNextWordEnd, cx); - assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n {ˇbaz.qux()}", view, cx); - - view.move_right(&MoveRight, cx); - view.select_to_previous_word_start(&SelectToPreviousWordStart, cx); - assert_selection_ranges("use std::«ˇs»tr::{foo, bar}\n\n {«ˇb»az.qux()}", view, cx); - - view.select_to_previous_word_start(&SelectToPreviousWordStart, cx); - assert_selection_ranges("use std«ˇ::s»tr::{foo, bar}\n\n «ˇ{b»az.qux()}", view, cx); - - view.select_to_next_word_end(&SelectToNextWordEnd, cx); - assert_selection_ranges("use std::«ˇs»tr::{foo, bar}\n\n {«ˇb»az.qux()}", view, cx); - }); - } - - #[gpui::test] - fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("use one::{\n two::three::four::five\n};", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - - view.update(cx, |view, cx| { - view.set_wrap_width(Some(140.), cx); - assert_eq!( - view.display_text(cx), - "use one::{\n two::three::\n four::five\n};" - ); - - view.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(1, 7)..DisplayPoint::new(1, 7)]); - }); - - view.move_to_next_word_end(&MoveToNextWordEnd, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(1, 9)..DisplayPoint::new(1, 9)] - ); - - view.move_to_next_word_end(&MoveToNextWordEnd, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(1, 14)..DisplayPoint::new(1, 14)] - ); - - view.move_to_next_word_end(&MoveToNextWordEnd, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(2, 4)..DisplayPoint::new(2, 4)] - ); - - view.move_to_next_word_end(&MoveToNextWordEnd, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(2, 8)..DisplayPoint::new(2, 8)] - ); - - view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(2, 4)..DisplayPoint::new(2, 4)] - ); - - view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(1, 14)..DisplayPoint::new(1, 14)] - ); - }); - } - - #[gpui::test] - async fn test_delete_to_beginning_of_line(cx: &mut gpui::TestAppContext) { - let mut cx = EditorTestContext::new(cx); - cx.set_state("one «two threeˇ» four"); - cx.update_editor(|editor, cx| { - editor.delete_to_beginning_of_line(&DeleteToBeginningOfLine, cx); - assert_eq!(editor.text(cx), " four"); - }); - } - - #[gpui::test] - fn test_delete_to_word_boundary(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("one two three four", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); - - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - // an empty selection - the preceding word fragment is deleted - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), - // characters selected - they are deleted - DisplayPoint::new(0, 9)..DisplayPoint::new(0, 12), - ]) - }); - view.delete_to_previous_word_start(&DeleteToPreviousWordStart, cx); - }); - - assert_eq!(buffer.read(cx).read(cx).text(), "e two te four"); - - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - // an empty selection - the following word fragment is deleted - DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3), - // characters selected - they are deleted - DisplayPoint::new(0, 9)..DisplayPoint::new(0, 10), - ]) - }); - view.delete_to_next_word_end(&DeleteToNextWordEnd, cx); - }); - - assert_eq!(buffer.read(cx).read(cx).text(), "e t te our"); - } - - #[gpui::test] - fn test_newline(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("aaaa\n bbbb\n", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); - - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), - DisplayPoint::new(1, 2)..DisplayPoint::new(1, 2), - DisplayPoint::new(1, 6)..DisplayPoint::new(1, 6), - ]) - }); - - view.newline(&Newline, cx); - assert_eq!(view.text(cx), "aa\naa\n \n bb\n bb\n"); - }); - } - - #[gpui::test] - fn test_newline_with_old_selections(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple( - " - a - b( - X - ) - c( - X - ) - " - .unindent() - .as_str(), - cx, - ); - - let (_, editor) = cx.add_window(Default::default(), |cx| { - let mut editor = build_editor(buffer.clone(), cx); - editor.change_selections(None, cx, |s| { - s.select_ranges([ - Point::new(2, 4)..Point::new(2, 5), - Point::new(5, 4)..Point::new(5, 5), - ]) - }); - editor - }); - - // Edit the buffer directly, deleting ranges surrounding the editor's selections - buffer.update(cx, |buffer, cx| { - buffer.edit( - [ - (Point::new(1, 2)..Point::new(3, 0), ""), - (Point::new(4, 2)..Point::new(6, 0), ""), - ], - None, - cx, - ); - assert_eq!( - buffer.read(cx).text(), - " - a - b() - c() - " - .unindent() - ); - }); - - editor.update(cx, |editor, cx| { - assert_eq!( - editor.selections.ranges(cx), - &[ - Point::new(1, 2)..Point::new(1, 2), - Point::new(2, 2)..Point::new(2, 2), - ], - ); - - editor.newline(&Newline, cx); - assert_eq!( - editor.text(cx), - " - a - b( - ) - c( - ) - " - .unindent() - ); - - // The selections are moved after the inserted newlines - assert_eq!( - editor.selections.ranges(cx), - &[ - Point::new(2, 0)..Point::new(2, 0), - Point::new(4, 0)..Point::new(4, 0), - ], - ); - }); - } - - #[gpui::test] - async fn test_newline_below(cx: &mut gpui::TestAppContext) { - let mut cx = EditorTestContext::new(cx); - cx.update(|cx| { - cx.update_global::(|settings, _| { - settings.editor_overrides.tab_size = Some(NonZeroU32::new(4).unwrap()); - }); - }); - - let language = Arc::new( - Language::new( - LanguageConfig::default(), - Some(tree_sitter_rust::language()), - ) - .with_indents_query(r#"(_ "(" ")" @end) @indent"#) - .unwrap(), - ); - cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); - - cx.set_state(indoc! {" - const a: ˇA = ( - (ˇ - «const_functionˇ»(ˇ), - so«mˇ»et«hˇ»ing_ˇelse,ˇ - )ˇ - ˇ);ˇ - "}); - cx.update_editor(|e, cx| e.newline_below(&NewlineBelow, cx)); - cx.assert_editor_state(indoc! {" - const a: A = ( - ˇ - ( - ˇ - const_function(), - ˇ - ˇ - something_else, - ˇ - ˇ - ˇ - ˇ - ) - ˇ - ); - ˇ - ˇ - "}); - } - - #[gpui::test] - fn test_insert_with_old_selections(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("a( X ), b( Y ), c( Z )", cx); - let (_, editor) = cx.add_window(Default::default(), |cx| { - let mut editor = build_editor(buffer.clone(), cx); - editor.change_selections(None, cx, |s| s.select_ranges([3..4, 11..12, 19..20])); - editor - }); - - // Edit the buffer directly, deleting ranges surrounding the editor's selections - buffer.update(cx, |buffer, cx| { - buffer.edit([(2..5, ""), (10..13, ""), (18..21, "")], None, cx); - assert_eq!(buffer.read(cx).text(), "a(), b(), c()".unindent()); - }); - - editor.update(cx, |editor, cx| { - assert_eq!(editor.selections.ranges(cx), &[2..2, 7..7, 12..12],); - - editor.insert("Z", cx); - assert_eq!(editor.text(cx), "a(Z), b(Z), c(Z)"); - - // The selections are moved after the inserted characters - assert_eq!(editor.selections.ranges(cx), &[3..3, 9..9, 15..15],); - }); - } - - #[gpui::test] - async fn test_tab(cx: &mut gpui::TestAppContext) { - let mut cx = EditorTestContext::new(cx); - cx.update(|cx| { - cx.update_global::(|settings, _| { - settings.editor_overrides.tab_size = Some(NonZeroU32::new(3).unwrap()); - }); - }); - cx.set_state(indoc! {" - ˇabˇc - ˇ🏀ˇ🏀ˇefg - dˇ - "}); - cx.update_editor(|e, cx| e.tab(&Tab, cx)); - cx.assert_editor_state(indoc! {" - ˇab ˇc - ˇ🏀 ˇ🏀 ˇefg - d ˇ - "}); - - cx.set_state(indoc! {" - a - «🏀ˇ»🏀«🏀ˇ»🏀«🏀ˇ» - "}); - cx.update_editor(|e, cx| e.tab(&Tab, cx)); - cx.assert_editor_state(indoc! {" - a - «🏀ˇ»🏀«🏀ˇ»🏀«🏀ˇ» - "}); - } - - #[gpui::test] - async fn test_tab_on_blank_line_auto_indents(cx: &mut gpui::TestAppContext) { - let mut cx = EditorTestContext::new(cx); - let language = Arc::new( - Language::new( - LanguageConfig::default(), - Some(tree_sitter_rust::language()), - ) - .with_indents_query(r#"(_ "(" ")" @end) @indent"#) - .unwrap(), - ); - cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); - - // cursors that are already at the suggested indent level insert - // a soft tab. cursors that are to the left of the suggested indent - // auto-indent their line. - cx.set_state(indoc! {" - ˇ - const a: B = ( - c( - d( - ˇ - ) - ˇ - ˇ ) - ); - "}); - cx.update_editor(|e, cx| e.tab(&Tab, cx)); - cx.assert_editor_state(indoc! {" - ˇ - const a: B = ( - c( - d( - ˇ - ) - ˇ - ˇ) - ); - "}); - - // handle auto-indent when there are multiple cursors on the same line - cx.set_state(indoc! {" - const a: B = ( - c( - ˇ ˇ - ˇ ) - ); - "}); - cx.update_editor(|e, cx| e.tab(&Tab, cx)); - cx.assert_editor_state(indoc! {" - const a: B = ( - c( - ˇ - ˇ) - ); - "}); - } - - #[gpui::test] - async fn test_indent_outdent(cx: &mut gpui::TestAppContext) { - let mut cx = EditorTestContext::new(cx); - - cx.set_state(indoc! {" - «oneˇ» «twoˇ» - three - four - "}); - cx.update_editor(|e, cx| e.tab(&Tab, cx)); - cx.assert_editor_state(indoc! {" - «oneˇ» «twoˇ» - three - four - "}); - - cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); - cx.assert_editor_state(indoc! {" - «oneˇ» «twoˇ» - three - four - "}); - - // select across line ending - cx.set_state(indoc! {" - one two - t«hree - ˇ» four - "}); - cx.update_editor(|e, cx| e.tab(&Tab, cx)); - cx.assert_editor_state(indoc! {" - one two - t«hree - ˇ» four - "}); - - cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); - cx.assert_editor_state(indoc! {" - one two - t«hree - ˇ» four - "}); - - // Ensure that indenting/outdenting works when the cursor is at column 0. - cx.set_state(indoc! {" - one two - ˇthree - four - "}); - cx.update_editor(|e, cx| e.tab(&Tab, cx)); - cx.assert_editor_state(indoc! {" - one two - ˇthree - four - "}); - - cx.set_state(indoc! {" - one two - ˇ three - four - "}); - cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); - cx.assert_editor_state(indoc! {" - one two - ˇthree - four - "}); - } - - #[gpui::test] - async fn test_indent_outdent_with_hard_tabs(cx: &mut gpui::TestAppContext) { - let mut cx = EditorTestContext::new(cx); - cx.update(|cx| { - cx.update_global::(|settings, _| { - settings.editor_overrides.hard_tabs = Some(true); - }); - }); - - // select two ranges on one line - cx.set_state(indoc! {" - «oneˇ» «twoˇ» - three - four - "}); - cx.update_editor(|e, cx| e.tab(&Tab, cx)); - cx.assert_editor_state(indoc! {" - \t«oneˇ» «twoˇ» - three - four - "}); - cx.update_editor(|e, cx| e.tab(&Tab, cx)); - cx.assert_editor_state(indoc! {" - \t\t«oneˇ» «twoˇ» - three - four - "}); - cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); - cx.assert_editor_state(indoc! {" - \t«oneˇ» «twoˇ» - three - four - "}); - cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); - cx.assert_editor_state(indoc! {" - «oneˇ» «twoˇ» - three - four - "}); - - // select across a line ending - cx.set_state(indoc! {" - one two - t«hree - ˇ»four - "}); - cx.update_editor(|e, cx| e.tab(&Tab, cx)); - cx.assert_editor_state(indoc! {" - one two - \tt«hree - ˇ»four - "}); - cx.update_editor(|e, cx| e.tab(&Tab, cx)); - cx.assert_editor_state(indoc! {" - one two - \t\tt«hree - ˇ»four - "}); - cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); - cx.assert_editor_state(indoc! {" - one two - \tt«hree - ˇ»four - "}); - cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); - cx.assert_editor_state(indoc! {" - one two - t«hree - ˇ»four - "}); - - // Ensure that indenting/outdenting works when the cursor is at column 0. - cx.set_state(indoc! {" - one two - ˇthree - four - "}); - cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); - cx.assert_editor_state(indoc! {" - one two - ˇthree - four - "}); - cx.update_editor(|e, cx| e.tab(&Tab, cx)); - cx.assert_editor_state(indoc! {" - one two - \tˇthree - four - "}); - cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); - cx.assert_editor_state(indoc! {" - one two - ˇthree - four - "}); - } - - #[gpui::test] - fn test_indent_outdent_with_excerpts(cx: &mut gpui::MutableAppContext) { - cx.set_global( - Settings::test(cx) - .with_language_defaults( - "TOML", - EditorSettings { - tab_size: Some(2.try_into().unwrap()), - ..Default::default() - }, - ) - .with_language_defaults( - "Rust", - EditorSettings { - tab_size: Some(4.try_into().unwrap()), - ..Default::default() - }, - ), - ); - let toml_language = Arc::new(Language::new( - LanguageConfig { - name: "TOML".into(), - ..Default::default() - }, - None, - )); - let rust_language = Arc::new(Language::new( - LanguageConfig { - name: "Rust".into(), - ..Default::default() - }, - None, - )); - - let toml_buffer = cx - .add_model(|cx| Buffer::new(0, "a = 1\nb = 2\n", cx).with_language(toml_language, cx)); - let rust_buffer = cx.add_model(|cx| { - Buffer::new(0, "const c: usize = 3;\n", cx).with_language(rust_language, cx) - }); - let multibuffer = cx.add_model(|cx| { - let mut multibuffer = MultiBuffer::new(0); - multibuffer.push_excerpts( - toml_buffer.clone(), - [ExcerptRange { - context: Point::new(0, 0)..Point::new(2, 0), - primary: None, - }], - cx, - ); - multibuffer.push_excerpts( - rust_buffer.clone(), - [ExcerptRange { - context: Point::new(0, 0)..Point::new(1, 0), - primary: None, - }], - cx, - ); - multibuffer - }); - - cx.add_window(Default::default(), |cx| { - let mut editor = build_editor(multibuffer, cx); - - assert_eq!( - editor.text(cx), - indoc! {" - a = 1 - b = 2 - - const c: usize = 3; - "} - ); - - select_ranges( - &mut editor, - indoc! {" - «aˇ» = 1 - b = 2 - - «const c:ˇ» usize = 3; - "}, - cx, - ); - - editor.tab(&Tab, cx); - assert_text_with_selections( - &mut editor, - indoc! {" - «aˇ» = 1 - b = 2 - - «const c:ˇ» usize = 3; - "}, - cx, - ); - editor.tab_prev(&TabPrev, cx); - assert_text_with_selections( - &mut editor, - indoc! {" - «aˇ» = 1 - b = 2 - - «const c:ˇ» usize = 3; - "}, - cx, - ); - - editor - }); - } - - #[gpui::test] - async fn test_backspace(cx: &mut gpui::TestAppContext) { - let mut cx = EditorTestContext::new(cx); - - // Basic backspace - cx.set_state(indoc! {" - onˇe two three - fou«rˇ» five six - seven «ˇeight nine - »ten - "}); - cx.update_editor(|e, cx| e.backspace(&Backspace, cx)); - cx.assert_editor_state(indoc! {" - oˇe two three - fouˇ five six - seven ˇten - "}); - - // Test backspace inside and around indents - cx.set_state(indoc! {" - zero - ˇone - ˇtwo - ˇ ˇ ˇ three - ˇ ˇ four - "}); - cx.update_editor(|e, cx| e.backspace(&Backspace, cx)); - cx.assert_editor_state(indoc! {" - zero - ˇone - ˇtwo - ˇ threeˇ four - "}); - - // Test backspace with line_mode set to true - cx.update_editor(|e, _| e.selections.line_mode = true); - cx.set_state(indoc! {" - The ˇquick ˇbrown - fox jumps over - the lazy dog - ˇThe qu«ick bˇ»rown"}); - cx.update_editor(|e, cx| e.backspace(&Backspace, cx)); - cx.assert_editor_state(indoc! {" - ˇfox jumps over - the lazy dogˇ"}); - } - - #[gpui::test] - async fn test_delete(cx: &mut gpui::TestAppContext) { - let mut cx = EditorTestContext::new(cx); - - cx.set_state(indoc! {" - onˇe two three - fou«rˇ» five six - seven «ˇeight nine - »ten - "}); - cx.update_editor(|e, cx| e.delete(&Delete, cx)); - cx.assert_editor_state(indoc! {" - onˇ two three - fouˇ five six - seven ˇten - "}); - - // Test backspace with line_mode set to true - cx.update_editor(|e, _| e.selections.line_mode = true); - cx.set_state(indoc! {" - The ˇquick ˇbrown - fox «ˇjum»ps over - the lazy dog - ˇThe qu«ick bˇ»rown"}); - cx.update_editor(|e, cx| e.backspace(&Backspace, cx)); - cx.assert_editor_state("ˇthe lazy dogˇ"); - } - - #[gpui::test] - fn test_delete_line(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1), - DisplayPoint::new(1, 0)..DisplayPoint::new(1, 1), - DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0), - ]) - }); - view.delete_line(&DeleteLine, cx); - assert_eq!(view.display_text(cx), "ghi"); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0), - DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1) - ] - ); - }); - - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(2, 0)..DisplayPoint::new(0, 1)]) - }); - view.delete_line(&DeleteLine, cx); - assert_eq!(view.display_text(cx), "ghi\n"); - assert_eq!( - view.selections.display_ranges(cx), - vec![DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1)] - ); - }); - } - - #[gpui::test] - fn test_duplicate_line(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1), - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), - DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0), - DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0), - ]) - }); - view.duplicate_line(&DuplicateLine, cx); - assert_eq!(view.display_text(cx), "abc\nabc\ndef\ndef\nghi\n\n"); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(1, 0)..DisplayPoint::new(1, 1), - DisplayPoint::new(1, 2)..DisplayPoint::new(1, 2), - DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0), - DisplayPoint::new(6, 0)..DisplayPoint::new(6, 0), - ] - ); - }); - - let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(0, 1)..DisplayPoint::new(1, 1), - DisplayPoint::new(1, 2)..DisplayPoint::new(2, 1), - ]) - }); - view.duplicate_line(&DuplicateLine, cx); - assert_eq!(view.display_text(cx), "abc\ndef\nghi\nabc\ndef\nghi\n"); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(3, 1)..DisplayPoint::new(4, 1), - DisplayPoint::new(4, 2)..DisplayPoint::new(5, 1), - ] - ); - }); - } - - #[gpui::test] - fn test_move_line_up_down(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple(&sample_text(10, 5, 'a'), cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - view.update(cx, |view, cx| { - view.fold_ranges( - vec![ - Point::new(0, 2)..Point::new(1, 2), - Point::new(2, 3)..Point::new(4, 1), - Point::new(7, 0)..Point::new(8, 4), - ], - cx, - ); - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1), - DisplayPoint::new(3, 1)..DisplayPoint::new(3, 1), - DisplayPoint::new(3, 2)..DisplayPoint::new(4, 3), - DisplayPoint::new(5, 0)..DisplayPoint::new(5, 2), - ]) - }); - assert_eq!( - view.display_text(cx), - "aa…bbb\nccc…eeee\nfffff\nggggg\n…i\njjjjj" - ); - - view.move_line_up(&MoveLineUp, cx); - assert_eq!( - view.display_text(cx), - "aa…bbb\nccc…eeee\nggggg\n…i\njjjjj\nfffff" - ); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1), - DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1), - DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3), - DisplayPoint::new(4, 0)..DisplayPoint::new(4, 2) - ] - ); - }); - - view.update(cx, |view, cx| { - view.move_line_down(&MoveLineDown, cx); - assert_eq!( - view.display_text(cx), - "ccc…eeee\naa…bbb\nfffff\nggggg\n…i\njjjjj" - ); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1), - DisplayPoint::new(3, 1)..DisplayPoint::new(3, 1), - DisplayPoint::new(3, 2)..DisplayPoint::new(4, 3), - DisplayPoint::new(5, 0)..DisplayPoint::new(5, 2) - ] - ); - }); - - view.update(cx, |view, cx| { - view.move_line_down(&MoveLineDown, cx); - assert_eq!( - view.display_text(cx), - "ccc…eeee\nfffff\naa…bbb\nggggg\n…i\njjjjj" - ); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1), - DisplayPoint::new(3, 1)..DisplayPoint::new(3, 1), - DisplayPoint::new(3, 2)..DisplayPoint::new(4, 3), - DisplayPoint::new(5, 0)..DisplayPoint::new(5, 2) - ] - ); - }); - - view.update(cx, |view, cx| { - view.move_line_up(&MoveLineUp, cx); - assert_eq!( - view.display_text(cx), - "ccc…eeee\naa…bbb\nggggg\n…i\njjjjj\nfffff" - ); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1), - DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1), - DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3), - DisplayPoint::new(4, 0)..DisplayPoint::new(4, 2) - ] - ); - }); - } - - #[gpui::test] - fn test_move_line_up_down_with_blocks(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple(&sample_text(10, 5, 'a'), cx); - let snapshot = buffer.read(cx).snapshot(cx); - let (_, editor) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - editor.update(cx, |editor, cx| { - editor.insert_blocks( - [BlockProperties { - style: BlockStyle::Fixed, - position: snapshot.anchor_after(Point::new(2, 0)), - disposition: BlockDisposition::Below, - height: 1, - render: Arc::new(|_| Empty::new().boxed()), - }], - cx, - ); - editor.change_selections(None, cx, |s| { - s.select_ranges([Point::new(2, 0)..Point::new(2, 0)]) - }); - editor.move_line_down(&MoveLineDown, cx); - }); - } - - #[gpui::test] - fn test_transpose(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - - _ = cx - .add_window(Default::default(), |cx| { - let mut editor = build_editor(MultiBuffer::build_simple("abc", cx), cx); - - editor.change_selections(None, cx, |s| s.select_ranges([1..1])); - editor.transpose(&Default::default(), cx); - assert_eq!(editor.text(cx), "bac"); - assert_eq!(editor.selections.ranges(cx), [2..2]); - - editor.transpose(&Default::default(), cx); - assert_eq!(editor.text(cx), "bca"); - assert_eq!(editor.selections.ranges(cx), [3..3]); - - editor.transpose(&Default::default(), cx); - assert_eq!(editor.text(cx), "bac"); - assert_eq!(editor.selections.ranges(cx), [3..3]); - - editor - }) - .1; - - _ = cx - .add_window(Default::default(), |cx| { - let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), cx); - - editor.change_selections(None, cx, |s| s.select_ranges([3..3])); - editor.transpose(&Default::default(), cx); - assert_eq!(editor.text(cx), "acb\nde"); - assert_eq!(editor.selections.ranges(cx), [3..3]); - - editor.change_selections(None, cx, |s| s.select_ranges([4..4])); - editor.transpose(&Default::default(), cx); - assert_eq!(editor.text(cx), "acbd\ne"); - assert_eq!(editor.selections.ranges(cx), [5..5]); - - editor.transpose(&Default::default(), cx); - assert_eq!(editor.text(cx), "acbde\n"); - assert_eq!(editor.selections.ranges(cx), [6..6]); - - editor.transpose(&Default::default(), cx); - assert_eq!(editor.text(cx), "acbd\ne"); - assert_eq!(editor.selections.ranges(cx), [6..6]); - - editor - }) - .1; - - _ = cx - .add_window(Default::default(), |cx| { - let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), cx); - - editor.change_selections(None, cx, |s| s.select_ranges([1..1, 2..2, 4..4])); - editor.transpose(&Default::default(), cx); - assert_eq!(editor.text(cx), "bacd\ne"); - assert_eq!(editor.selections.ranges(cx), [2..2, 3..3, 5..5]); - - editor.transpose(&Default::default(), cx); - assert_eq!(editor.text(cx), "bcade\n"); - assert_eq!(editor.selections.ranges(cx), [3..3, 4..4, 6..6]); - - editor.transpose(&Default::default(), cx); - assert_eq!(editor.text(cx), "bcda\ne"); - assert_eq!(editor.selections.ranges(cx), [4..4, 6..6]); - - editor.transpose(&Default::default(), cx); - assert_eq!(editor.text(cx), "bcade\n"); - assert_eq!(editor.selections.ranges(cx), [4..4, 6..6]); - - editor.transpose(&Default::default(), cx); - assert_eq!(editor.text(cx), "bcaed\n"); - assert_eq!(editor.selections.ranges(cx), [5..5, 6..6]); - - editor - }) - .1; - - _ = cx - .add_window(Default::default(), |cx| { - let mut editor = build_editor(MultiBuffer::build_simple("🍐🏀✋", cx), cx); - - editor.change_selections(None, cx, |s| s.select_ranges([4..4])); - editor.transpose(&Default::default(), cx); - assert_eq!(editor.text(cx), "🏀🍐✋"); - assert_eq!(editor.selections.ranges(cx), [8..8]); - - editor.transpose(&Default::default(), cx); - assert_eq!(editor.text(cx), "🏀✋🍐"); - assert_eq!(editor.selections.ranges(cx), [11..11]); - - editor.transpose(&Default::default(), cx); - assert_eq!(editor.text(cx), "🏀🍐✋"); - assert_eq!(editor.selections.ranges(cx), [11..11]); - - editor - }) - .1; - } - - #[gpui::test] - async fn test_clipboard(cx: &mut gpui::TestAppContext) { - let mut cx = EditorTestContext::new(cx); - - cx.set_state("«one✅ ˇ»two «three ˇ»four «five ˇ»six "); - cx.update_editor(|e, cx| e.cut(&Cut, cx)); - cx.assert_editor_state("ˇtwo ˇfour ˇsix "); - - // Paste with three cursors. Each cursor pastes one slice of the clipboard text. - cx.set_state("two ˇfour ˇsix ˇ"); - cx.update_editor(|e, cx| e.paste(&Paste, cx)); - cx.assert_editor_state("two one✅ ˇfour three ˇsix five ˇ"); - - // Paste again but with only two cursors. Since the number of cursors doesn't - // match the number of slices in the clipboard, the entire clipboard text - // is pasted at each cursor. - cx.set_state("ˇtwo one✅ four three six five ˇ"); - cx.update_editor(|e, cx| { - e.handle_input("( ", cx); - e.paste(&Paste, cx); - e.handle_input(") ", cx); - }); - cx.assert_editor_state(indoc! {" - ( one✅ - three - five ) ˇtwo one✅ four three six five ( one✅ - three - five ) ˇ"}); - - // Cut with three selections, one of which is full-line. - cx.set_state(indoc! {" - 1«2ˇ»3 - 4ˇ567 - «8ˇ»9"}); - cx.update_editor(|e, cx| e.cut(&Cut, cx)); - cx.assert_editor_state(indoc! {" - 1ˇ3 - ˇ9"}); - - // Paste with three selections, noticing how the copied selection that was full-line - // gets inserted before the second cursor. - cx.set_state(indoc! {" - 1ˇ3 - 9ˇ - «oˇ»ne"}); - cx.update_editor(|e, cx| e.paste(&Paste, cx)); - cx.assert_editor_state(indoc! {" - 12ˇ3 - 4567 - 9ˇ - 8ˇne"}); - - // Copy with a single cursor only, which writes the whole line into the clipboard. - cx.set_state(indoc! {" - The quick brown - fox juˇmps over - the lazy dog"}); - cx.update_editor(|e, cx| e.copy(&Copy, cx)); - cx.cx.assert_clipboard_content(Some("fox jumps over\n")); - - // Paste with three selections, noticing how the copied full-line selection is inserted - // before the empty selections but replaces the selection that is non-empty. - cx.set_state(indoc! {" - Tˇhe quick brown - «foˇ»x jumps over - tˇhe lazy dog"}); - cx.update_editor(|e, cx| e.paste(&Paste, cx)); - cx.assert_editor_state(indoc! {" - fox jumps over - Tˇhe quick brown - fox jumps over - ˇx jumps over - fox jumps over - tˇhe lazy dog"}); - } - - #[gpui::test] - async fn test_paste_multiline(cx: &mut gpui::TestAppContext) { - let mut cx = EditorTestContext::new(cx); - let language = Arc::new(Language::new( - LanguageConfig::default(), - Some(tree_sitter_rust::language()), - )); - cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); - - // Cut an indented block, without the leading whitespace. - cx.set_state(indoc! {" - const a: B = ( - c(), - «d( - e, - f - )ˇ» - ); - "}); - cx.update_editor(|e, cx| e.cut(&Cut, cx)); - cx.assert_editor_state(indoc! {" - const a: B = ( - c(), - ˇ - ); - "}); - - // Paste it at the same position. - cx.update_editor(|e, cx| e.paste(&Paste, cx)); - cx.assert_editor_state(indoc! {" - const a: B = ( - c(), - d( - e, - f - )ˇ - ); - "}); - - // Paste it at a line with a lower indent level. - cx.set_state(indoc! {" - ˇ - const a: B = ( - c(), - ); - "}); - cx.update_editor(|e, cx| e.paste(&Paste, cx)); - cx.assert_editor_state(indoc! {" - d( - e, - f - )ˇ - const a: B = ( - c(), - ); - "}); - - // Cut an indented block, with the leading whitespace. - cx.set_state(indoc! {" - const a: B = ( - c(), - « d( - e, - f - ) - ˇ»); - "}); - cx.update_editor(|e, cx| e.cut(&Cut, cx)); - cx.assert_editor_state(indoc! {" - const a: B = ( - c(), - ˇ); - "}); - - // Paste it at the same position. - cx.update_editor(|e, cx| e.paste(&Paste, cx)); - cx.assert_editor_state(indoc! {" - const a: B = ( - c(), - d( - e, - f - ) - ˇ); - "}); - - // Paste it at a line with a higher indent level. - cx.set_state(indoc! {" - const a: B = ( - c(), - d( - e, - fˇ - ) - ); - "}); - cx.update_editor(|e, cx| e.paste(&Paste, cx)); - cx.assert_editor_state(indoc! {" - const a: B = ( - c(), - d( - e, - f d( - e, - f - ) - ˇ - ) - ); - "}); - } - - #[gpui::test] - fn test_select_all(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("abc\nde\nfgh", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - view.update(cx, |view, cx| { - view.select_all(&SelectAll, cx); - assert_eq!( - view.selections.display_ranges(cx), - &[DisplayPoint::new(0, 0)..DisplayPoint::new(2, 3)] - ); - }); - } - - #[gpui::test] - fn test_select_line(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple(&sample_text(6, 5, 'a'), cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1), - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), - DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0), - DisplayPoint::new(4, 2)..DisplayPoint::new(4, 2), - ]) - }); - view.select_line(&SelectLine, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(0, 0)..DisplayPoint::new(2, 0), - DisplayPoint::new(4, 0)..DisplayPoint::new(5, 0), - ] - ); - }); - - view.update(cx, |view, cx| { - view.select_line(&SelectLine, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(0, 0)..DisplayPoint::new(3, 0), - DisplayPoint::new(4, 0)..DisplayPoint::new(5, 5), - ] - ); - }); - - view.update(cx, |view, cx| { - view.select_line(&SelectLine, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![DisplayPoint::new(0, 0)..DisplayPoint::new(5, 5)] - ); - }); - } - - #[gpui::test] - fn test_split_selection_into_lines(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple(&sample_text(9, 5, 'a'), cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - view.update(cx, |view, cx| { - view.fold_ranges( - vec![ - Point::new(0, 2)..Point::new(1, 2), - Point::new(2, 3)..Point::new(4, 1), - Point::new(7, 0)..Point::new(8, 4), - ], - cx, - ); - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1), - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), - DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0), - DisplayPoint::new(4, 4)..DisplayPoint::new(4, 4), - ]) - }); - assert_eq!(view.display_text(cx), "aa…bbb\nccc…eeee\nfffff\nggggg\n…i"); - }); - - view.update(cx, |view, cx| { - view.split_selection_into_lines(&SplitSelectionIntoLines, cx); - assert_eq!( - view.display_text(cx), - "aaaaa\nbbbbb\nccc…eeee\nfffff\nggggg\n…i" - ); - assert_eq!( - view.selections.display_ranges(cx), - [ - DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1), - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), - DisplayPoint::new(2, 0)..DisplayPoint::new(2, 0), - DisplayPoint::new(5, 4)..DisplayPoint::new(5, 4) - ] - ); - }); - - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(5, 0)..DisplayPoint::new(0, 1)]) - }); - view.split_selection_into_lines(&SplitSelectionIntoLines, cx); - assert_eq!( - view.display_text(cx), - "aaaaa\nbbbbb\nccccc\nddddd\neeeee\nfffff\nggggg\nhhhhh\niiiii" - ); - assert_eq!( - view.selections.display_ranges(cx), - [ - DisplayPoint::new(0, 5)..DisplayPoint::new(0, 5), - DisplayPoint::new(1, 5)..DisplayPoint::new(1, 5), - DisplayPoint::new(2, 5)..DisplayPoint::new(2, 5), - DisplayPoint::new(3, 5)..DisplayPoint::new(3, 5), - DisplayPoint::new(4, 5)..DisplayPoint::new(4, 5), - DisplayPoint::new(5, 5)..DisplayPoint::new(5, 5), - DisplayPoint::new(6, 5)..DisplayPoint::new(6, 5), - DisplayPoint::new(7, 0)..DisplayPoint::new(7, 0) - ] - ); - }); - } - - #[gpui::test] - fn test_add_selection_above_below(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("abc\ndefghi\n\njk\nlmno\n", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); - - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3)]) - }); - }); - view.update(cx, |view, cx| { - view.add_selection_above(&AddSelectionAbove, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3), - DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3) - ] - ); - }); - - view.update(cx, |view, cx| { - view.add_selection_above(&AddSelectionAbove, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3), - DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3) - ] - ); - }); - - view.update(cx, |view, cx| { - view.add_selection_below(&AddSelectionBelow, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3)] - ); - - view.undo_selection(&UndoSelection, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3), - DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3) - ] - ); - - view.redo_selection(&RedoSelection, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3)] - ); - }); - - view.update(cx, |view, cx| { - view.add_selection_below(&AddSelectionBelow, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3), - DisplayPoint::new(4, 3)..DisplayPoint::new(4, 3) - ] - ); - }); - - view.update(cx, |view, cx| { - view.add_selection_below(&AddSelectionBelow, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3), - DisplayPoint::new(4, 3)..DisplayPoint::new(4, 3) - ] - ); - }); - - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3)]) - }); - }); - view.update(cx, |view, cx| { - view.add_selection_below(&AddSelectionBelow, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3), - DisplayPoint::new(4, 4)..DisplayPoint::new(4, 3) - ] - ); - }); - - view.update(cx, |view, cx| { - view.add_selection_below(&AddSelectionBelow, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3), - DisplayPoint::new(4, 4)..DisplayPoint::new(4, 3) - ] - ); - }); - - view.update(cx, |view, cx| { - view.add_selection_above(&AddSelectionAbove, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3)] - ); - }); - - view.update(cx, |view, cx| { - view.add_selection_above(&AddSelectionAbove, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3)] - ); - }); - - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(0, 1)..DisplayPoint::new(1, 4)]) - }); - view.add_selection_below(&AddSelectionBelow, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(0, 1)..DisplayPoint::new(0, 3), - DisplayPoint::new(1, 1)..DisplayPoint::new(1, 4), - DisplayPoint::new(3, 1)..DisplayPoint::new(3, 2), - ] - ); - }); - - view.update(cx, |view, cx| { - view.add_selection_below(&AddSelectionBelow, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(0, 1)..DisplayPoint::new(0, 3), - DisplayPoint::new(1, 1)..DisplayPoint::new(1, 4), - DisplayPoint::new(3, 1)..DisplayPoint::new(3, 2), - DisplayPoint::new(4, 1)..DisplayPoint::new(4, 4), - ] - ); - }); - - view.update(cx, |view, cx| { - view.add_selection_above(&AddSelectionAbove, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(0, 1)..DisplayPoint::new(0, 3), - DisplayPoint::new(1, 1)..DisplayPoint::new(1, 4), - DisplayPoint::new(3, 1)..DisplayPoint::new(3, 2), - ] - ); - }); - - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(4, 3)..DisplayPoint::new(1, 1)]) - }); - }); - view.update(cx, |view, cx| { - view.add_selection_above(&AddSelectionAbove, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(0, 3)..DisplayPoint::new(0, 1), - DisplayPoint::new(1, 3)..DisplayPoint::new(1, 1), - DisplayPoint::new(3, 2)..DisplayPoint::new(3, 1), - DisplayPoint::new(4, 3)..DisplayPoint::new(4, 1), - ] - ); - }); - - view.update(cx, |view, cx| { - view.add_selection_below(&AddSelectionBelow, cx); - assert_eq!( - view.selections.display_ranges(cx), - vec![ - DisplayPoint::new(1, 3)..DisplayPoint::new(1, 1), - DisplayPoint::new(3, 2)..DisplayPoint::new(3, 1), - DisplayPoint::new(4, 3)..DisplayPoint::new(4, 1), - ] - ); - }); - } - - #[gpui::test] - async fn test_select_next(cx: &mut gpui::TestAppContext) { - let mut cx = EditorTestContext::new(cx); - cx.set_state("abc\nˇabc abc\ndefabc\nabc"); - - cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx)); - cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); - - cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx)); - cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\nabc"); - - cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx)); - cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); - - cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx)); - cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\nabc"); - - cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx)); - cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»"); - - cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx)); - cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»"); - } - - #[gpui::test] - async fn test_select_larger_smaller_syntax_node(cx: &mut gpui::TestAppContext) { - cx.update(|cx| cx.set_global(Settings::test(cx))); - let language = Arc::new(Language::new( - LanguageConfig::default(), - Some(tree_sitter_rust::language()), - )); - - let text = r#" - use mod1::mod2::{mod3, mod4}; - - fn fn_1(param1: bool, param2: &str) { - let var1 = "text"; - } - "# - .unindent(); - - let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx)); - let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); - let (_, view) = cx.add_window(|cx| build_editor(buffer, cx)); - view.condition(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) - .await; - - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(0, 25)..DisplayPoint::new(0, 25), - DisplayPoint::new(2, 24)..DisplayPoint::new(2, 12), - DisplayPoint::new(3, 18)..DisplayPoint::new(3, 18), - ]); - }); - view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx); - }); - assert_eq!( - view.update(cx, |view, cx| { view.selections.display_ranges(cx) }), - &[ - DisplayPoint::new(0, 23)..DisplayPoint::new(0, 27), - DisplayPoint::new(2, 35)..DisplayPoint::new(2, 7), - DisplayPoint::new(3, 15)..DisplayPoint::new(3, 21), - ] - ); - - view.update(cx, |view, cx| { - view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx); - }); - assert_eq!( - view.update(cx, |view, cx| view.selections.display_ranges(cx)), - &[ - DisplayPoint::new(0, 16)..DisplayPoint::new(0, 28), - DisplayPoint::new(4, 1)..DisplayPoint::new(2, 0), - ] - ); - - view.update(cx, |view, cx| { - view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx); - }); - assert_eq!( - view.update(cx, |view, cx| view.selections.display_ranges(cx)), - &[DisplayPoint::new(5, 0)..DisplayPoint::new(0, 0)] - ); - - // Trying to expand the selected syntax node one more time has no effect. - view.update(cx, |view, cx| { - view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx); - }); - assert_eq!( - view.update(cx, |view, cx| view.selections.display_ranges(cx)), - &[DisplayPoint::new(5, 0)..DisplayPoint::new(0, 0)] - ); - - view.update(cx, |view, cx| { - view.select_smaller_syntax_node(&SelectSmallerSyntaxNode, cx); - }); - assert_eq!( - view.update(cx, |view, cx| view.selections.display_ranges(cx)), - &[ - DisplayPoint::new(0, 16)..DisplayPoint::new(0, 28), - DisplayPoint::new(4, 1)..DisplayPoint::new(2, 0), - ] - ); - - view.update(cx, |view, cx| { - view.select_smaller_syntax_node(&SelectSmallerSyntaxNode, cx); - }); - assert_eq!( - view.update(cx, |view, cx| view.selections.display_ranges(cx)), - &[ - DisplayPoint::new(0, 23)..DisplayPoint::new(0, 27), - DisplayPoint::new(2, 35)..DisplayPoint::new(2, 7), - DisplayPoint::new(3, 15)..DisplayPoint::new(3, 21), - ] - ); - - view.update(cx, |view, cx| { - view.select_smaller_syntax_node(&SelectSmallerSyntaxNode, cx); - }); - assert_eq!( - view.update(cx, |view, cx| view.selections.display_ranges(cx)), - &[ - DisplayPoint::new(0, 25)..DisplayPoint::new(0, 25), - DisplayPoint::new(2, 24)..DisplayPoint::new(2, 12), - DisplayPoint::new(3, 18)..DisplayPoint::new(3, 18), - ] - ); - - // Trying to shrink the selected syntax node one more time has no effect. - view.update(cx, |view, cx| { - view.select_smaller_syntax_node(&SelectSmallerSyntaxNode, cx); - }); - assert_eq!( - view.update(cx, |view, cx| view.selections.display_ranges(cx)), - &[ - DisplayPoint::new(0, 25)..DisplayPoint::new(0, 25), - DisplayPoint::new(2, 24)..DisplayPoint::new(2, 12), - DisplayPoint::new(3, 18)..DisplayPoint::new(3, 18), - ] - ); - - // Ensure that we keep expanding the selection if the larger selection starts or ends within - // a fold. - view.update(cx, |view, cx| { - view.fold_ranges( - vec![ - Point::new(0, 21)..Point::new(0, 24), - Point::new(3, 20)..Point::new(3, 22), - ], - cx, - ); - view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx); - }); - assert_eq!( - view.update(cx, |view, cx| view.selections.display_ranges(cx)), - &[ - DisplayPoint::new(0, 16)..DisplayPoint::new(0, 28), - DisplayPoint::new(2, 35)..DisplayPoint::new(2, 7), - DisplayPoint::new(3, 4)..DisplayPoint::new(3, 23), - ] - ); - } - - #[gpui::test] - async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) { - cx.update(|cx| cx.set_global(Settings::test(cx))); - let language = Arc::new( - Language::new( - LanguageConfig { - brackets: vec![ - BracketPair { - start: "{".to_string(), - end: "}".to_string(), - close: false, - newline: true, - }, - BracketPair { - start: "(".to_string(), - end: ")".to_string(), - close: false, - newline: true, - }, - ], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ) - .with_indents_query( - r#" - (_ "(" ")" @end) @indent - (_ "{" "}" @end) @indent - "#, - ) - .unwrap(), - ); - - let text = "fn a() {}"; - - let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx)); - let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); - let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx)); - editor - .condition(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) - .await; - - editor.update(cx, |editor, cx| { - editor.change_selections(None, cx, |s| s.select_ranges([5..5, 8..8, 9..9])); - editor.newline(&Newline, cx); - assert_eq!(editor.text(cx), "fn a(\n \n) {\n \n}\n"); - assert_eq!( - editor.selections.ranges(cx), - &[ - Point::new(1, 4)..Point::new(1, 4), - Point::new(3, 4)..Point::new(3, 4), - Point::new(5, 0)..Point::new(5, 0) - ] - ); - }); - } - - #[gpui::test] - async fn test_autoclose_pairs(cx: &mut gpui::TestAppContext) { - cx.update(|cx| cx.set_global(Settings::test(cx))); - let language = Arc::new(Language::new( - LanguageConfig { - brackets: vec![ - BracketPair { - start: "{".to_string(), - end: "}".to_string(), - close: true, - newline: true, - }, - BracketPair { - start: "/*".to_string(), - end: " */".to_string(), - close: true, - newline: true, - }, - BracketPair { - start: "[".to_string(), - end: "]".to_string(), - close: false, - newline: true, - }, - ], - autoclose_before: "})]".to_string(), - ..Default::default() - }, - Some(tree_sitter_rust::language()), - )); - - let text = r#" - a - - / - - "# - .unindent(); - - let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx)); - let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); - let (_, view) = cx.add_window(|cx| build_editor(buffer, cx)); - view.condition(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) - .await; - - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1), - DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0), - ]) - }); - - view.handle_input("{", cx); - view.handle_input("{", cx); - view.handle_input("{", cx); - assert_eq!( - view.text(cx), - " - {{{}}} - {{{}}} - / - - " - .unindent() - ); - - view.move_right(&MoveRight, cx); - view.handle_input("}", cx); - view.handle_input("}", cx); - view.handle_input("}", cx); - assert_eq!( - view.text(cx), - " - {{{}}}} - {{{}}}} - / - - " - .unindent() - ); - - view.undo(&Undo, cx); - view.handle_input("/", cx); - view.handle_input("*", cx); - assert_eq!( - view.text(cx), - " - /* */ - /* */ - / - - " - .unindent() - ); - - view.undo(&Undo, cx); - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1), - DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0), - ]) - }); - view.handle_input("*", cx); - assert_eq!( - view.text(cx), - " - a - - /* - * - " - .unindent() - ); - - // Don't autoclose if the next character isn't whitespace and isn't - // listed in the language's "autoclose_before" section. - view.finalize_last_transaction(cx); - view.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)]) - }); - view.handle_input("{", cx); - assert_eq!( - view.text(cx), - " - {a - - /* - * - " - .unindent() - ); - - view.undo(&Undo, cx); - view.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1)]) - }); - view.handle_input("{", cx); - assert_eq!( - view.text(cx), - " - {a} - - /* - * - " - .unindent() - ); - assert_eq!( - view.selections.display_ranges(cx), - [DisplayPoint::new(0, 1)..DisplayPoint::new(0, 2)] - ); - - view.undo(&Undo, cx); - view.handle_input("[", cx); - assert_eq!( - view.text(cx), - " - [a] - - /* - * - " - .unindent() - ); - assert_eq!( - view.selections.display_ranges(cx), - [DisplayPoint::new(0, 1)..DisplayPoint::new(0, 2)] - ); - - view.undo(&Undo, cx); - view.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1)]) - }); - view.handle_input("[", cx); - assert_eq!( - view.text(cx), - " - a[ - - /* - * - " - .unindent() - ); - assert_eq!( - view.selections.display_ranges(cx), - [DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2)] - ); - }); - } - - #[gpui::test] - async fn test_surround_with_pair(cx: &mut gpui::TestAppContext) { - cx.update(|cx| cx.set_global(Settings::test(cx))); - let language = Arc::new(Language::new( - LanguageConfig { - brackets: vec![BracketPair { - start: "{".to_string(), - end: "}".to_string(), - close: true, - newline: true, - }], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - )); - - let text = r#" - a - b - c - "# - .unindent(); - - let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx)); - let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); - let (_, view) = cx.add_window(|cx| build_editor(buffer, cx)); - view.condition(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) - .await; - - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1), - DisplayPoint::new(1, 0)..DisplayPoint::new(1, 1), - DisplayPoint::new(2, 0)..DisplayPoint::new(2, 1), - ]) - }); - - view.handle_input("{", cx); - view.handle_input("{", cx); - view.handle_input("{", cx); - assert_eq!( - view.text(cx), - " - {{{a}}} - {{{b}}} - {{{c}}} - " - .unindent() - ); - assert_eq!( - view.selections.display_ranges(cx), - [ - DisplayPoint::new(0, 3)..DisplayPoint::new(0, 4), - DisplayPoint::new(1, 3)..DisplayPoint::new(1, 4), - DisplayPoint::new(2, 3)..DisplayPoint::new(2, 4) - ] - ); - - view.undo(&Undo, cx); - assert_eq!( - view.text(cx), - " - a - b - c - " - .unindent() - ); - assert_eq!( - view.selections.display_ranges(cx), - [ - DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1), - DisplayPoint::new(1, 0)..DisplayPoint::new(1, 1), - DisplayPoint::new(2, 0)..DisplayPoint::new(2, 1) - ] - ); - }); - } - - #[gpui::test] - async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) { - cx.update(|cx| cx.set_global(Settings::test(cx))); - let language = Arc::new(Language::new( - LanguageConfig { - brackets: vec![BracketPair { - start: "{".to_string(), - end: "}".to_string(), - close: true, - newline: true, - }], - autoclose_before: "}".to_string(), - ..Default::default() - }, - Some(tree_sitter_rust::language()), - )); - - let text = r#" - a - b - c - "# - .unindent(); - - let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx)); - let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); - let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx)); - editor - .condition(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) - .await; - - editor.update(cx, |editor, cx| { - editor.change_selections(None, cx, |s| { - s.select_ranges([ - Point::new(0, 1)..Point::new(0, 1), - Point::new(1, 1)..Point::new(1, 1), - Point::new(2, 1)..Point::new(2, 1), - ]) - }); - - editor.handle_input("{", cx); - editor.handle_input("{", cx); - editor.handle_input("_", cx); - assert_eq!( - editor.text(cx), - " - a{{_}} - b{{_}} - c{{_}} - " - .unindent() - ); - assert_eq!( - editor.selections.ranges::(cx), - [ - Point::new(0, 4)..Point::new(0, 4), - Point::new(1, 4)..Point::new(1, 4), - Point::new(2, 4)..Point::new(2, 4) - ] - ); - - editor.backspace(&Default::default(), cx); - editor.backspace(&Default::default(), cx); - assert_eq!( - editor.text(cx), - " - a{} - b{} - c{} - " - .unindent() - ); - assert_eq!( - editor.selections.ranges::(cx), - [ - Point::new(0, 2)..Point::new(0, 2), - Point::new(1, 2)..Point::new(1, 2), - Point::new(2, 2)..Point::new(2, 2) - ] - ); - - editor.delete_to_previous_word_start(&Default::default(), cx); - assert_eq!( - editor.text(cx), - " - a - b - c - " - .unindent() - ); - assert_eq!( - editor.selections.ranges::(cx), - [ - Point::new(0, 1)..Point::new(0, 1), - Point::new(1, 1)..Point::new(1, 1), - Point::new(2, 1)..Point::new(2, 1) - ] - ); - }); - } - - #[gpui::test] - async fn test_snippets(cx: &mut gpui::TestAppContext) { - cx.update(|cx| cx.set_global(Settings::test(cx))); - - let (text, insertion_ranges) = marked_text_ranges( - indoc! {" - a.ˇ b - a.ˇ b - a.ˇ b - "}, - false, - ); - - let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); - let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx)); - - editor.update(cx, |editor, cx| { - let snippet = Snippet::parse("f(${1:one}, ${2:two}, ${1:three})$0").unwrap(); - - editor - .insert_snippet(&insertion_ranges, snippet, cx) - .unwrap(); - - fn assert(editor: &mut Editor, cx: &mut ViewContext, marked_text: &str) { - let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false); - assert_eq!(editor.text(cx), expected_text); - assert_eq!(editor.selections.ranges::(cx), selection_ranges); - } - - assert( - editor, - cx, - indoc! {" - a.f(«one», two, «three») b - a.f(«one», two, «three») b - a.f(«one», two, «three») b - "}, - ); - - // Can't move earlier than the first tab stop - assert!(!editor.move_to_prev_snippet_tabstop(cx)); - assert( - editor, - cx, - indoc! {" - a.f(«one», two, «three») b - a.f(«one», two, «three») b - a.f(«one», two, «three») b - "}, - ); - - assert!(editor.move_to_next_snippet_tabstop(cx)); - assert( - editor, - cx, - indoc! {" - a.f(one, «two», three) b - a.f(one, «two», three) b - a.f(one, «two», three) b - "}, - ); - - editor.move_to_prev_snippet_tabstop(cx); - assert( - editor, - cx, - indoc! {" - a.f(«one», two, «three») b - a.f(«one», two, «three») b - a.f(«one», two, «three») b - "}, - ); - - assert!(editor.move_to_next_snippet_tabstop(cx)); - assert( - editor, - cx, - indoc! {" - a.f(one, «two», three) b - a.f(one, «two», three) b - a.f(one, «two», three) b - "}, - ); - assert!(editor.move_to_next_snippet_tabstop(cx)); - assert( - editor, - cx, - indoc! {" - a.f(one, two, three)ˇ b - a.f(one, two, three)ˇ b - a.f(one, two, three)ˇ b - "}, - ); - - // As soon as the last tab stop is reached, snippet state is gone - editor.move_to_prev_snippet_tabstop(cx); - assert( - editor, - cx, - indoc! {" - a.f(one, two, three)ˇ b - a.f(one, two, three)ˇ b - a.f(one, two, three)ˇ b - "}, - ); - }); - } - - #[gpui::test] - async fn test_document_format_during_save(cx: &mut gpui::TestAppContext) { - cx.foreground().forbid_parking(); - - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_servers = language - .set_fake_lsp_adapter(Arc::new(FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - document_formatting_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - ..Default::default() - })) - .await; - - let fs = FakeFs::new(cx.background()); - fs.insert_file("/file.rs", Default::default()).await; - - let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; - project.update(cx, |project, _| project.languages().add(Arc::new(language))); - let buffer = project - .update(cx, |project, cx| project.open_local_buffer("/file.rs", cx)) - .await - .unwrap(); - - cx.foreground().start_waiting(); - let fake_server = fake_servers.next().await.unwrap(); - - let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); - let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx)); - editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx)); - assert!(cx.read(|cx| editor.is_dirty(cx))); - - let save = cx.update(|cx| editor.save(project.clone(), cx)); - fake_server - .handle_request::(move |params, _| async move { - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path("/file.rs").unwrap() - ); - assert_eq!(params.options.tab_size, 4); - Ok(Some(vec![lsp::TextEdit::new( - lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), - ", ".to_string(), - )])) - }) - .next() - .await; - cx.foreground().start_waiting(); - save.await.unwrap(); - assert_eq!( - editor.read_with(cx, |editor, cx| editor.text(cx)), - "one, two\nthree\n" - ); - assert!(!cx.read(|cx| editor.is_dirty(cx))); - - editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx)); - assert!(cx.read(|cx| editor.is_dirty(cx))); - - // Ensure we can still save even if formatting hangs. - fake_server.handle_request::(move |params, _| async move { - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path("/file.rs").unwrap() - ); - futures::future::pending::<()>().await; - unreachable!() - }); - let save = cx.update(|cx| editor.save(project.clone(), cx)); - cx.foreground().advance_clock(items::FORMAT_TIMEOUT); - cx.foreground().start_waiting(); - save.await.unwrap(); - assert_eq!( - editor.read_with(cx, |editor, cx| editor.text(cx)), - "one\ntwo\nthree\n" - ); - assert!(!cx.read(|cx| editor.is_dirty(cx))); - - // Set rust language override and assert overriden tabsize is sent to language server - cx.update(|cx| { - cx.update_global::(|settings, _| { - settings.language_overrides.insert( - "Rust".into(), - EditorSettings { - tab_size: Some(8.try_into().unwrap()), - ..Default::default() - }, - ); - }) - }); - - let save = cx.update(|cx| editor.save(project.clone(), cx)); - fake_server - .handle_request::(move |params, _| async move { - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path("/file.rs").unwrap() - ); - assert_eq!(params.options.tab_size, 8); - Ok(Some(vec![])) - }) - .next() - .await; - cx.foreground().start_waiting(); - save.await.unwrap(); - } - - #[gpui::test] - async fn test_range_format_during_save(cx: &mut gpui::TestAppContext) { - cx.foreground().forbid_parking(); - - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_servers = language - .set_fake_lsp_adapter(Arc::new(FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - document_range_formatting_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - ..Default::default() - })) - .await; - - let fs = FakeFs::new(cx.background()); - fs.insert_file("/file.rs", Default::default()).await; - - let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; - project.update(cx, |project, _| project.languages().add(Arc::new(language))); - let buffer = project - .update(cx, |project, cx| project.open_local_buffer("/file.rs", cx)) - .await - .unwrap(); - - cx.foreground().start_waiting(); - let fake_server = fake_servers.next().await.unwrap(); - - let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); - let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx)); - editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx)); - assert!(cx.read(|cx| editor.is_dirty(cx))); - - let save = cx.update(|cx| editor.save(project.clone(), cx)); - fake_server - .handle_request::(move |params, _| async move { - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path("/file.rs").unwrap() - ); - assert_eq!(params.options.tab_size, 4); - Ok(Some(vec![lsp::TextEdit::new( - lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), - ", ".to_string(), - )])) - }) - .next() - .await; - cx.foreground().start_waiting(); - save.await.unwrap(); - assert_eq!( - editor.read_with(cx, |editor, cx| editor.text(cx)), - "one, two\nthree\n" - ); - assert!(!cx.read(|cx| editor.is_dirty(cx))); - - editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx)); - assert!(cx.read(|cx| editor.is_dirty(cx))); - - // Ensure we can still save even if formatting hangs. - fake_server.handle_request::( - move |params, _| async move { - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path("/file.rs").unwrap() - ); - futures::future::pending::<()>().await; - unreachable!() - }, - ); - let save = cx.update(|cx| editor.save(project.clone(), cx)); - cx.foreground().advance_clock(items::FORMAT_TIMEOUT); - cx.foreground().start_waiting(); - save.await.unwrap(); - assert_eq!( - editor.read_with(cx, |editor, cx| editor.text(cx)), - "one\ntwo\nthree\n" - ); - assert!(!cx.read(|cx| editor.is_dirty(cx))); - - // Set rust language override and assert overriden tabsize is sent to language server - cx.update(|cx| { - cx.update_global::(|settings, _| { - settings.language_overrides.insert( - "Rust".into(), - EditorSettings { - tab_size: Some(8.try_into().unwrap()), - ..Default::default() - }, - ); - }) - }); - - let save = cx.update(|cx| editor.save(project.clone(), cx)); - fake_server - .handle_request::(move |params, _| async move { - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path("/file.rs").unwrap() - ); - assert_eq!(params.options.tab_size, 8); - Ok(Some(vec![])) - }) - .next() - .await; - cx.foreground().start_waiting(); - save.await.unwrap(); - } - - #[gpui::test] - async fn test_completion(cx: &mut gpui::TestAppContext) { - let mut cx = EditorLspTestContext::new_rust( - lsp::ServerCapabilities { - completion_provider: Some(lsp::CompletionOptions { - trigger_characters: Some(vec![".".to_string(), ":".to_string()]), - ..Default::default() - }), - ..Default::default() - }, - cx, - ) - .await; - - cx.set_state(indoc! {" - oneˇ - two - three - "}); - cx.simulate_keystroke("."); - handle_completion_request( - &mut cx, - indoc! {" - one.|<> - two - three - "}, - vec!["first_completion", "second_completion"], - ) - .await; - cx.condition(|editor, _| editor.context_menu_visible()) - .await; - let apply_additional_edits = cx.update_editor(|editor, cx| { - editor.move_down(&MoveDown, cx); - editor - .confirm_completion(&ConfirmCompletion::default(), cx) - .unwrap() - }); - cx.assert_editor_state(indoc! {" - one.second_completionˇ - two - three - "}); - - handle_resolve_completion_request( - &mut cx, - Some(( - indoc! {" - one.second_completion - two - threeˇ - "}, - "\nadditional edit", - )), - ) - .await; - apply_additional_edits.await.unwrap(); - cx.assert_editor_state(indoc! {" - one.second_completionˇ - two - three - additional edit - "}); - - cx.set_state(indoc! {" - one.second_completion - twoˇ - threeˇ - additional edit - "}); - cx.simulate_keystroke(" "); - assert!(cx.editor(|e, _| e.context_menu.is_none())); - cx.simulate_keystroke("s"); - assert!(cx.editor(|e, _| e.context_menu.is_none())); - - cx.assert_editor_state(indoc! {" - one.second_completion - two sˇ - three sˇ - additional edit - "}); - // - handle_completion_request( - &mut cx, - indoc! {" - one.second_completion - two s - three - additional edit - "}, - vec!["fourth_completion", "fifth_completion", "sixth_completion"], - ) - .await; - cx.condition(|editor, _| editor.context_menu_visible()) - .await; - - cx.simulate_keystroke("i"); - - handle_completion_request( - &mut cx, - indoc! {" - one.second_completion - two si - three - additional edit - "}, - vec!["fourth_completion", "fifth_completion", "sixth_completion"], - ) - .await; - cx.condition(|editor, _| editor.context_menu_visible()) - .await; - - let apply_additional_edits = cx.update_editor(|editor, cx| { - editor - .confirm_completion(&ConfirmCompletion::default(), cx) - .unwrap() - }); - cx.assert_editor_state(indoc! {" - one.second_completion - two sixth_completionˇ - three sixth_completionˇ - additional edit - "}); - - handle_resolve_completion_request(&mut cx, None).await; - apply_additional_edits.await.unwrap(); - - cx.update(|cx| { - cx.update_global::(|settings, _| { - settings.show_completions_on_input = false; - }) - }); - cx.set_state("editorˇ"); - cx.simulate_keystroke("."); - assert!(cx.editor(|e, _| e.context_menu.is_none())); - cx.simulate_keystroke("c"); - cx.simulate_keystroke("l"); - cx.simulate_keystroke("o"); - cx.assert_editor_state("editor.cloˇ"); - assert!(cx.editor(|e, _| e.context_menu.is_none())); - cx.update_editor(|editor, cx| { - editor.show_completions(&ShowCompletions, cx); - }); - handle_completion_request(&mut cx, "editor.", vec!["close", "clobber"]).await; - cx.condition(|editor, _| editor.context_menu_visible()) - .await; - let apply_additional_edits = cx.update_editor(|editor, cx| { - editor - .confirm_completion(&ConfirmCompletion::default(), cx) - .unwrap() - }); - cx.assert_editor_state("editor.closeˇ"); - handle_resolve_completion_request(&mut cx, None).await; - apply_additional_edits.await.unwrap(); - - // Handle completion request passing a marked string specifying where the completion - // should be triggered from using '|' character, what range should be replaced, and what completions - // should be returned using '<' and '>' to delimit the range - async fn handle_completion_request<'a>( - cx: &mut EditorLspTestContext<'a>, - marked_string: &str, - completions: Vec<&'static str>, - ) { - let complete_from_marker: TextRangeMarker = '|'.into(); - let replace_range_marker: TextRangeMarker = ('<', '>').into(); - let (_, mut marked_ranges) = marked_text_ranges_by( - marked_string, - vec![complete_from_marker.clone(), replace_range_marker.clone()], - ); - - let complete_from_position = - cx.to_lsp(marked_ranges.remove(&complete_from_marker).unwrap()[0].start); - let replace_range = - cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone()); - - cx.handle_request::(move |url, params, _| { - let completions = completions.clone(); - async move { - assert_eq!(params.text_document_position.text_document.uri, url.clone()); - assert_eq!( - params.text_document_position.position, - complete_from_position - ); - Ok(Some(lsp::CompletionResponse::Array( - completions - .iter() - .map(|completion_text| lsp::CompletionItem { - label: completion_text.to_string(), - text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - range: replace_range, - new_text: completion_text.to_string(), - })), - ..Default::default() - }) - .collect(), - ))) - } - }) - .next() - .await; - } - - async fn handle_resolve_completion_request<'a>( - cx: &mut EditorLspTestContext<'a>, - edit: Option<(&'static str, &'static str)>, - ) { - let edit = edit.map(|(marked_string, new_text)| { - let (_, marked_ranges) = marked_text_ranges(marked_string, false); - let replace_range = cx.to_lsp_range(marked_ranges[0].clone()); - vec![lsp::TextEdit::new(replace_range, new_text.to_string())] - }); - - cx.handle_request::(move |_, _, _| { - let edit = edit.clone(); - async move { - Ok(lsp::CompletionItem { - additional_text_edits: edit, - ..Default::default() - }) - } - }) - .next() - .await; - } - } - - #[gpui::test] - async fn test_toggle_comment(cx: &mut gpui::TestAppContext) { - cx.update(|cx| cx.set_global(Settings::test(cx))); - let language = Arc::new(Language::new( - LanguageConfig { - line_comment: Some("// ".to_string()), - ..Default::default() - }, - Some(tree_sitter_rust::language()), - )); - - let text = " - fn a() { - //b(); - // c(); - // d(); - } - " - .unindent(); - - let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx)); - let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); - let (_, view) = cx.add_window(|cx| build_editor(buffer, cx)); - - view.update(cx, |editor, cx| { - // If multiple selections intersect a line, the line is only - // toggled once. - editor.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(1, 3)..DisplayPoint::new(2, 3), - DisplayPoint::new(3, 5)..DisplayPoint::new(3, 6), - ]) - }); - editor.toggle_comments(&ToggleComments, cx); - assert_eq!( - editor.text(cx), - " - fn a() { - b(); - c(); - d(); - } - " - .unindent() - ); - - // The comment prefix is inserted at the same column for every line - // in a selection. - editor.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(1, 3)..DisplayPoint::new(3, 6)]) - }); - editor.toggle_comments(&ToggleComments, cx); - assert_eq!( - editor.text(cx), - " - fn a() { - // b(); - // c(); - // d(); - } - " - .unindent() - ); - - // If a selection ends at the beginning of a line, that line is not toggled. - editor.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(2, 0)..DisplayPoint::new(3, 0)]) - }); - editor.toggle_comments(&ToggleComments, cx); - assert_eq!( - editor.text(cx), - " - fn a() { - // b(); - c(); - // d(); - } - " - .unindent() - ); - }); - } - - #[gpui::test] - fn test_editing_disjoint_excerpts(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(3, 4, 'a'), cx)); - let multibuffer = cx.add_model(|cx| { - let mut multibuffer = MultiBuffer::new(0); - multibuffer.push_excerpts( - buffer.clone(), - [ - ExcerptRange { - context: Point::new(0, 0)..Point::new(0, 4), - primary: None, - }, - ExcerptRange { - context: Point::new(1, 0)..Point::new(1, 4), - primary: None, - }, - ], - cx, - ); - multibuffer - }); - - assert_eq!(multibuffer.read(cx).read(cx).text(), "aaaa\nbbbb"); - - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(multibuffer, cx)); - view.update(cx, |view, cx| { - assert_eq!(view.text(cx), "aaaa\nbbbb"); - view.change_selections(None, cx, |s| { - s.select_ranges([ - Point::new(0, 0)..Point::new(0, 0), - Point::new(1, 0)..Point::new(1, 0), - ]) - }); - - view.handle_input("X", cx); - assert_eq!(view.text(cx), "Xaaaa\nXbbbb"); - assert_eq!( - view.selections.ranges(cx), - [ - Point::new(0, 1)..Point::new(0, 1), - Point::new(1, 1)..Point::new(1, 1), - ] - ) - }); - } - - #[gpui::test] - fn test_editing_overlapping_excerpts(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let markers = vec![('[', ']').into(), ('(', ')').into()]; - let (initial_text, mut excerpt_ranges) = marked_text_ranges_by( - indoc! {" - [aaaa - (bbbb] - cccc)", - }, - markers.clone(), - ); - let excerpt_ranges = markers.into_iter().map(|marker| { - let context = excerpt_ranges.remove(&marker).unwrap()[0].clone(); - ExcerptRange { - context, - primary: None, - } - }); - let buffer = cx.add_model(|cx| Buffer::new(0, initial_text, cx)); - let multibuffer = cx.add_model(|cx| { - let mut multibuffer = MultiBuffer::new(0); - multibuffer.push_excerpts(buffer, excerpt_ranges, cx); - multibuffer - }); - - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(multibuffer, cx)); - view.update(cx, |view, cx| { - let (expected_text, selection_ranges) = marked_text_ranges( - indoc! {" - aaaa - bˇbbb - bˇbbˇb - cccc" - }, - true, - ); - assert_eq!(view.text(cx), expected_text); - view.change_selections(None, cx, |s| s.select_ranges(selection_ranges)); - - view.handle_input("X", cx); - - let (expected_text, expected_selections) = marked_text_ranges( - indoc! {" - aaaa - bXˇbbXb - bXˇbbXˇb - cccc" - }, - false, - ); - assert_eq!(view.text(cx), expected_text); - assert_eq!(view.selections.ranges(cx), expected_selections); - - view.newline(&Newline, cx); - let (expected_text, expected_selections) = marked_text_ranges( - indoc! {" - aaaa - bX - ˇbbX - b - bX - ˇbbX - ˇb - cccc" - }, - false, - ); - assert_eq!(view.text(cx), expected_text); - assert_eq!(view.selections.ranges(cx), expected_selections); - }); - } - - #[gpui::test] - fn test_refresh_selections(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(3, 4, 'a'), cx)); - let mut excerpt1_id = None; - let multibuffer = cx.add_model(|cx| { - let mut multibuffer = MultiBuffer::new(0); - excerpt1_id = multibuffer - .push_excerpts( - buffer.clone(), - [ - ExcerptRange { - context: Point::new(0, 0)..Point::new(1, 4), - primary: None, - }, - ExcerptRange { - context: Point::new(1, 0)..Point::new(2, 4), - primary: None, - }, - ], - cx, - ) - .into_iter() - .next(); - multibuffer - }); - assert_eq!( - multibuffer.read(cx).read(cx).text(), - "aaaa\nbbbb\nbbbb\ncccc" - ); - let (_, editor) = cx.add_window(Default::default(), |cx| { - let mut editor = build_editor(multibuffer.clone(), cx); - let snapshot = editor.snapshot(cx); - editor.change_selections(None, cx, |s| { - s.select_ranges([Point::new(1, 3)..Point::new(1, 3)]) - }); - editor.begin_selection(Point::new(2, 1).to_display_point(&snapshot), true, 1, cx); - assert_eq!( - editor.selections.ranges(cx), - [ - Point::new(1, 3)..Point::new(1, 3), - Point::new(2, 1)..Point::new(2, 1), - ] - ); - editor - }); - - // Refreshing selections is a no-op when excerpts haven't changed. - editor.update(cx, |editor, cx| { - editor.change_selections(None, cx, |s| { - s.refresh(); - }); - assert_eq!( - editor.selections.ranges(cx), - [ - Point::new(1, 3)..Point::new(1, 3), - Point::new(2, 1)..Point::new(2, 1), - ] - ); - }); - - multibuffer.update(cx, |multibuffer, cx| { - multibuffer.remove_excerpts([&excerpt1_id.unwrap()], cx); - }); - editor.update(cx, |editor, cx| { - // Removing an excerpt causes the first selection to become degenerate. - assert_eq!( - editor.selections.ranges(cx), - [ - Point::new(0, 0)..Point::new(0, 0), - Point::new(0, 1)..Point::new(0, 1) - ] - ); - - // Refreshing selections will relocate the first selection to the original buffer - // location. - editor.change_selections(None, cx, |s| { - s.refresh(); - }); - assert_eq!( - editor.selections.ranges(cx), - [ - Point::new(0, 1)..Point::new(0, 1), - Point::new(0, 3)..Point::new(0, 3) - ] - ); - assert!(editor.selections.pending_anchor().is_some()); - }); - } - - #[gpui::test] - fn test_refresh_selections_while_selecting_with_mouse(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(3, 4, 'a'), cx)); - let mut excerpt1_id = None; - let multibuffer = cx.add_model(|cx| { - let mut multibuffer = MultiBuffer::new(0); - excerpt1_id = multibuffer - .push_excerpts( - buffer.clone(), - [ - ExcerptRange { - context: Point::new(0, 0)..Point::new(1, 4), - primary: None, - }, - ExcerptRange { - context: Point::new(1, 0)..Point::new(2, 4), - primary: None, - }, - ], - cx, - ) - .into_iter() - .next(); - multibuffer - }); - assert_eq!( - multibuffer.read(cx).read(cx).text(), - "aaaa\nbbbb\nbbbb\ncccc" - ); - let (_, editor) = cx.add_window(Default::default(), |cx| { - let mut editor = build_editor(multibuffer.clone(), cx); - let snapshot = editor.snapshot(cx); - editor.begin_selection(Point::new(1, 3).to_display_point(&snapshot), false, 1, cx); - assert_eq!( - editor.selections.ranges(cx), - [Point::new(1, 3)..Point::new(1, 3)] - ); - editor - }); - - multibuffer.update(cx, |multibuffer, cx| { - multibuffer.remove_excerpts([&excerpt1_id.unwrap()], cx); - }); - editor.update(cx, |editor, cx| { - assert_eq!( - editor.selections.ranges(cx), - [Point::new(0, 0)..Point::new(0, 0)] - ); - - // Ensure we don't panic when selections are refreshed and that the pending selection is finalized. - editor.change_selections(None, cx, |s| { - s.refresh(); - }); - assert_eq!( - editor.selections.ranges(cx), - [Point::new(0, 3)..Point::new(0, 3)] - ); - assert!(editor.selections.pending_anchor().is_some()); - }); - } - - #[gpui::test] - async fn test_extra_newline_insertion(cx: &mut gpui::TestAppContext) { - cx.update(|cx| cx.set_global(Settings::test(cx))); - let language = Arc::new( - Language::new( - LanguageConfig { - brackets: vec![ - BracketPair { - start: "{".to_string(), - end: "}".to_string(), - close: true, - newline: true, - }, - BracketPair { - start: "/* ".to_string(), - end: " */".to_string(), - close: true, - newline: true, - }, - ], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ) - .with_indents_query("") - .unwrap(), - ); - - let text = concat!( - "{ }\n", // Suppress rustfmt - " x\n", // - " /* */\n", // - "x\n", // - "{{} }\n", // - ); - - let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx)); - let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); - let (_, view) = cx.add_window(|cx| build_editor(buffer, cx)); - view.condition(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) - .await; - - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 3), - DisplayPoint::new(2, 5)..DisplayPoint::new(2, 5), - DisplayPoint::new(4, 4)..DisplayPoint::new(4, 4), - ]) - }); - view.newline(&Newline, cx); - - assert_eq!( - view.buffer().read(cx).read(cx).text(), - concat!( - "{ \n", // Suppress rustfmt - "\n", // - "}\n", // - " x\n", // - " /* \n", // - " \n", // - " */\n", // - "x\n", // - "{{} \n", // - "}\n", // - ) - ); - }); - } - - #[gpui::test] - fn test_highlighted_ranges(cx: &mut gpui::MutableAppContext) { - let buffer = MultiBuffer::build_simple(&sample_text(16, 8, 'a'), cx); - - cx.set_global(Settings::test(cx)); - let (_, editor) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); - - editor.update(cx, |editor, cx| { - struct Type1; - struct Type2; - - let buffer = buffer.read(cx).snapshot(cx); - - let anchor_range = |range: Range| { - buffer.anchor_after(range.start)..buffer.anchor_after(range.end) - }; - - editor.highlight_background::( - vec![ - anchor_range(Point::new(2, 1)..Point::new(2, 3)), - anchor_range(Point::new(4, 2)..Point::new(4, 4)), - anchor_range(Point::new(6, 3)..Point::new(6, 5)), - anchor_range(Point::new(8, 4)..Point::new(8, 6)), - ], - |_| Color::red(), - cx, - ); - editor.highlight_background::( - vec![ - anchor_range(Point::new(3, 2)..Point::new(3, 5)), - anchor_range(Point::new(5, 3)..Point::new(5, 6)), - anchor_range(Point::new(7, 4)..Point::new(7, 7)), - anchor_range(Point::new(9, 5)..Point::new(9, 8)), - ], - |_| Color::green(), - cx, - ); - - let snapshot = editor.snapshot(cx); - let mut highlighted_ranges = editor.background_highlights_in_range( - anchor_range(Point::new(3, 4)..Point::new(7, 4)), - &snapshot, - cx.global::().theme.as_ref(), - ); - // Enforce a consistent ordering based on color without relying on the ordering of the - // highlight's `TypeId` which is non-deterministic. - highlighted_ranges.sort_unstable_by_key(|(_, color)| *color); - assert_eq!( - highlighted_ranges, - &[ - ( - DisplayPoint::new(3, 2)..DisplayPoint::new(3, 5), - Color::green(), - ), - ( - DisplayPoint::new(5, 3)..DisplayPoint::new(5, 6), - Color::green(), - ), - ( - DisplayPoint::new(4, 2)..DisplayPoint::new(4, 4), - Color::red(), - ), - ( - DisplayPoint::new(6, 3)..DisplayPoint::new(6, 5), - Color::red(), - ), - ] - ); - assert_eq!( - editor.background_highlights_in_range( - anchor_range(Point::new(5, 6)..Point::new(6, 4)), - &snapshot, - cx.global::().theme.as_ref(), - ), - &[( - DisplayPoint::new(6, 3)..DisplayPoint::new(6, 5), - Color::red(), - )] - ); - }); - } - - #[gpui::test] - fn test_following(cx: &mut gpui::MutableAppContext) { - let buffer = MultiBuffer::build_simple(&sample_text(16, 8, 'a'), cx); - - cx.set_global(Settings::test(cx)); - - let (_, leader) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); - let (_, follower) = cx.add_window( - WindowOptions { - bounds: WindowBounds::Fixed(RectF::from_points(vec2f(0., 0.), vec2f(10., 80.))), - ..Default::default() - }, - |cx| build_editor(buffer.clone(), cx), - ); - - let pending_update = Rc::new(RefCell::new(None)); - follower.update(cx, { - let update = pending_update.clone(); - |_, cx| { - cx.subscribe(&leader, move |_, leader, event, cx| { - leader - .read(cx) - .add_event_to_update_proto(event, &mut *update.borrow_mut(), cx); - }) - .detach(); - } - }); - - // Update the selections only - leader.update(cx, |leader, cx| { - leader.change_selections(None, cx, |s| s.select_ranges([1..1])); - }); - follower.update(cx, |follower, cx| { - follower - .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx) - .unwrap(); - }); - assert_eq!(follower.read(cx).selections.ranges(cx), vec![1..1]); - - // Update the scroll position only - leader.update(cx, |leader, cx| { - leader.set_scroll_position(vec2f(1.5, 3.5), cx); - }); - follower.update(cx, |follower, cx| { - follower - .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx) - .unwrap(); - }); - assert_eq!( - follower.update(cx, |follower, cx| follower.scroll_position(cx)), - vec2f(1.5, 3.5) - ); - - // Update the selections and scroll position - leader.update(cx, |leader, cx| { - leader.change_selections(None, cx, |s| s.select_ranges([0..0])); - leader.request_autoscroll(Autoscroll::Newest, cx); - leader.set_scroll_position(vec2f(1.5, 3.5), cx); - }); - follower.update(cx, |follower, cx| { - let initial_scroll_position = follower.scroll_position(cx); - follower - .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx) - .unwrap(); - assert_eq!(follower.scroll_position(cx), initial_scroll_position); - assert!(follower.autoscroll_request.is_some()); - }); - assert_eq!(follower.read(cx).selections.ranges(cx), vec![0..0]); - - // Creating a pending selection that precedes another selection - leader.update(cx, |leader, cx| { - leader.change_selections(None, cx, |s| s.select_ranges([1..1])); - leader.begin_selection(DisplayPoint::new(0, 0), true, 1, cx); - }); - follower.update(cx, |follower, cx| { - follower - .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx) - .unwrap(); - }); - assert_eq!(follower.read(cx).selections.ranges(cx), vec![0..0, 1..1]); - - // Extend the pending selection so that it surrounds another selection - leader.update(cx, |leader, cx| { - leader.extend_selection(DisplayPoint::new(0, 2), 1, cx); - }); - follower.update(cx, |follower, cx| { - follower - .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx) - .unwrap(); - }); - assert_eq!(follower.read(cx).selections.ranges(cx), vec![0..2]); - } - - #[test] - fn test_combine_syntax_and_fuzzy_match_highlights() { - let string = "abcdefghijklmnop"; - let syntax_ranges = [ - ( - 0..3, - HighlightStyle { - color: Some(Color::red()), - ..Default::default() - }, - ), - ( - 4..8, - HighlightStyle { - color: Some(Color::green()), - ..Default::default() - }, - ), - ]; - let match_indices = [4, 6, 7, 8]; - assert_eq!( - combine_syntax_and_fuzzy_match_highlights( - string, - Default::default(), - syntax_ranges.into_iter(), - &match_indices, - ), - &[ - ( - 0..3, - HighlightStyle { - color: Some(Color::red()), - ..Default::default() - }, - ), - ( - 4..5, - HighlightStyle { - color: Some(Color::green()), - weight: Some(fonts::Weight::BOLD), - ..Default::default() - }, - ), - ( - 5..6, - HighlightStyle { - color: Some(Color::green()), - ..Default::default() - }, - ), - ( - 6..8, - HighlightStyle { - color: Some(Color::green()), - weight: Some(fonts::Weight::BOLD), - ..Default::default() - }, - ), - ( - 8..9, - HighlightStyle { - weight: Some(fonts::Weight::BOLD), - ..Default::default() - }, - ), - ] - ); - } - - fn empty_range(row: usize, column: usize) -> Range { - let point = DisplayPoint::new(row as u32, column as u32); - point..point - } - - fn assert_selection_ranges(marked_text: &str, view: &mut Editor, cx: &mut ViewContext) { - let (text, ranges) = marked_text_ranges(marked_text, true); - assert_eq!(view.text(cx), text); - assert_eq!( - view.selections.ranges(cx), - ranges, - "Assert selections are {}", - marked_text - ); - } -} - trait RangeExt { fn sorted(&self) -> Range; fn to_inclusive(&self) -> RangeInclusive; diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs new file mode 100644 index 0000000000..58978c51f0 --- /dev/null +++ b/crates/editor/src/editor_tests.rs @@ -0,0 +1,5081 @@ +use std::{cell::RefCell, rc::Rc, time::Instant}; + +use futures::StreamExt; +use indoc::indoc; +use unindent::Unindent; + +use super::*; +use crate::test::{ + assert_text_with_selections, build_editor, editor_lsp_test_context::EditorLspTestContext, + editor_test_context::EditorTestContext, select_ranges, +}; +use gpui::{ + geometry::rect::RectF, + platform::{WindowBounds, WindowOptions}, +}; +use language::{FakeLspAdapter, LanguageConfig, LanguageRegistry, Point}; +use project::FakeFs; +use settings::EditorSettings; +use util::{ + assert_set_eq, + test::{marked_text_ranges, marked_text_ranges_by, sample_text, TextRangeMarker}, +}; +use workspace::{FollowableItem, ItemHandle, NavigationEntry, Pane}; + +#[gpui::test] +fn test_edit_events(cx: &mut MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = cx.add_model(|cx| language::Buffer::new(0, "123456", cx)); + + let events = Rc::new(RefCell::new(Vec::new())); + let (_, editor1) = cx.add_window(Default::default(), { + let events = events.clone(); + |cx| { + cx.subscribe(&cx.handle(), move |_, _, event, _| { + if matches!( + event, + Event::Edited | Event::BufferEdited | Event::DirtyChanged + ) { + events.borrow_mut().push(("editor1", *event)); + } + }) + .detach(); + Editor::for_buffer(buffer.clone(), None, cx) + } + }); + let (_, editor2) = cx.add_window(Default::default(), { + let events = events.clone(); + |cx| { + cx.subscribe(&cx.handle(), move |_, _, event, _| { + if matches!( + event, + Event::Edited | Event::BufferEdited | Event::DirtyChanged + ) { + events.borrow_mut().push(("editor2", *event)); + } + }) + .detach(); + Editor::for_buffer(buffer.clone(), None, cx) + } + }); + assert_eq!(mem::take(&mut *events.borrow_mut()), []); + + // Mutating editor 1 will emit an `Edited` event only for that editor. + editor1.update(cx, |editor, cx| editor.insert("X", cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor1", Event::Edited), + ("editor1", Event::BufferEdited), + ("editor2", Event::BufferEdited), + ("editor1", Event::DirtyChanged), + ("editor2", Event::DirtyChanged) + ] + ); + + // Mutating editor 2 will emit an `Edited` event only for that editor. + editor2.update(cx, |editor, cx| editor.delete(&Delete, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor2", Event::Edited), + ("editor1", Event::BufferEdited), + ("editor2", Event::BufferEdited), + ] + ); + + // Undoing on editor 1 will emit an `Edited` event only for that editor. + editor1.update(cx, |editor, cx| editor.undo(&Undo, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor1", Event::Edited), + ("editor1", Event::BufferEdited), + ("editor2", Event::BufferEdited), + ("editor1", Event::DirtyChanged), + ("editor2", Event::DirtyChanged), + ] + ); + + // Redoing on editor 1 will emit an `Edited` event only for that editor. + editor1.update(cx, |editor, cx| editor.redo(&Redo, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor1", Event::Edited), + ("editor1", Event::BufferEdited), + ("editor2", Event::BufferEdited), + ("editor1", Event::DirtyChanged), + ("editor2", Event::DirtyChanged), + ] + ); + + // Undoing on editor 2 will emit an `Edited` event only for that editor. + editor2.update(cx, |editor, cx| editor.undo(&Undo, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor2", Event::Edited), + ("editor1", Event::BufferEdited), + ("editor2", Event::BufferEdited), + ("editor1", Event::DirtyChanged), + ("editor2", Event::DirtyChanged), + ] + ); + + // Redoing on editor 2 will emit an `Edited` event only for that editor. + editor2.update(cx, |editor, cx| editor.redo(&Redo, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor2", Event::Edited), + ("editor1", Event::BufferEdited), + ("editor2", Event::BufferEdited), + ("editor1", Event::DirtyChanged), + ("editor2", Event::DirtyChanged), + ] + ); + + // No event is emitted when the mutation is a no-op. + editor2.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| s.select_ranges([0..0])); + + editor.backspace(&Backspace, cx); + }); + assert_eq!(mem::take(&mut *events.borrow_mut()), []); +} + +#[gpui::test] +fn test_undo_redo_with_selection_restoration(cx: &mut MutableAppContext) { + cx.set_global(Settings::test(cx)); + let mut now = Instant::now(); + let buffer = cx.add_model(|cx| language::Buffer::new(0, "123456", cx)); + let group_interval = buffer.read(cx).transaction_group_interval(); + let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); + let (_, editor) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); + + editor.update(cx, |editor, cx| { + editor.start_transaction_at(now, cx); + editor.change_selections(None, cx, |s| s.select_ranges([2..4])); + + editor.insert("cd", cx); + editor.end_transaction_at(now, cx); + assert_eq!(editor.text(cx), "12cd56"); + assert_eq!(editor.selections.ranges(cx), vec![4..4]); + + editor.start_transaction_at(now, cx); + editor.change_selections(None, cx, |s| s.select_ranges([4..5])); + editor.insert("e", cx); + editor.end_transaction_at(now, cx); + assert_eq!(editor.text(cx), "12cde6"); + assert_eq!(editor.selections.ranges(cx), vec![5..5]); + + now += group_interval + Duration::from_millis(1); + editor.change_selections(None, cx, |s| s.select_ranges([2..2])); + + // Simulate an edit in another editor + buffer.update(cx, |buffer, cx| { + buffer.start_transaction_at(now, cx); + buffer.edit([(0..1, "a")], None, cx); + buffer.edit([(1..1, "b")], None, cx); + buffer.end_transaction_at(now, cx); + }); + + assert_eq!(editor.text(cx), "ab2cde6"); + assert_eq!(editor.selections.ranges(cx), vec![3..3]); + + // Last transaction happened past the group interval in a different editor. + // Undo it individually and don't restore selections. + editor.undo(&Undo, cx); + assert_eq!(editor.text(cx), "12cde6"); + assert_eq!(editor.selections.ranges(cx), vec![2..2]); + + // First two transactions happened within the group interval in this editor. + // Undo them together and restore selections. + editor.undo(&Undo, cx); + editor.undo(&Undo, cx); // Undo stack is empty here, so this is a no-op. + assert_eq!(editor.text(cx), "123456"); + assert_eq!(editor.selections.ranges(cx), vec![0..0]); + + // Redo the first two transactions together. + editor.redo(&Redo, cx); + assert_eq!(editor.text(cx), "12cde6"); + assert_eq!(editor.selections.ranges(cx), vec![5..5]); + + // Redo the last transaction on its own. + editor.redo(&Redo, cx); + assert_eq!(editor.text(cx), "ab2cde6"); + assert_eq!(editor.selections.ranges(cx), vec![6..6]); + + // Test empty transactions. + editor.start_transaction_at(now, cx); + editor.end_transaction_at(now, cx); + editor.undo(&Undo, cx); + assert_eq!(editor.text(cx), "12cde6"); + }); +} + +#[gpui::test] +fn test_ime_composition(cx: &mut MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = cx.add_model(|cx| { + let mut buffer = language::Buffer::new(0, "abcde", cx); + // Ensure automatic grouping doesn't occur. + buffer.set_group_interval(Duration::ZERO); + buffer + }); + + let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); + cx.add_window(Default::default(), |cx| { + let mut editor = build_editor(buffer.clone(), cx); + + // Start a new IME composition. + editor.replace_and_mark_text_in_range(Some(0..1), "à", None, cx); + editor.replace_and_mark_text_in_range(Some(0..1), "á", None, cx); + editor.replace_and_mark_text_in_range(Some(0..1), "ä", None, cx); + assert_eq!(editor.text(cx), "äbcde"); + assert_eq!( + editor.marked_text_ranges(cx), + Some(vec![OffsetUtf16(0)..OffsetUtf16(1)]) + ); + + // Finalize IME composition. + editor.replace_text_in_range(None, "ā", cx); + assert_eq!(editor.text(cx), "ābcde"); + assert_eq!(editor.marked_text_ranges(cx), None); + + // IME composition edits are grouped and are undone/redone at once. + editor.undo(&Default::default(), cx); + assert_eq!(editor.text(cx), "abcde"); + assert_eq!(editor.marked_text_ranges(cx), None); + editor.redo(&Default::default(), cx); + assert_eq!(editor.text(cx), "ābcde"); + assert_eq!(editor.marked_text_ranges(cx), None); + + // Start a new IME composition. + editor.replace_and_mark_text_in_range(Some(0..1), "à", None, cx); + assert_eq!( + editor.marked_text_ranges(cx), + Some(vec![OffsetUtf16(0)..OffsetUtf16(1)]) + ); + + // Undoing during an IME composition cancels it. + editor.undo(&Default::default(), cx); + assert_eq!(editor.text(cx), "ābcde"); + assert_eq!(editor.marked_text_ranges(cx), None); + + // Start a new IME composition with an invalid marked range, ensuring it gets clipped. + editor.replace_and_mark_text_in_range(Some(4..999), "è", None, cx); + assert_eq!(editor.text(cx), "ābcdè"); + assert_eq!( + editor.marked_text_ranges(cx), + Some(vec![OffsetUtf16(4)..OffsetUtf16(5)]) + ); + + // Finalize IME composition with an invalid replacement range, ensuring it gets clipped. + editor.replace_text_in_range(Some(4..999), "ę", cx); + assert_eq!(editor.text(cx), "ābcdę"); + assert_eq!(editor.marked_text_ranges(cx), None); + + // Start a new IME composition with multiple cursors. + editor.change_selections(None, cx, |s| { + s.select_ranges([ + OffsetUtf16(1)..OffsetUtf16(1), + OffsetUtf16(3)..OffsetUtf16(3), + OffsetUtf16(5)..OffsetUtf16(5), + ]) + }); + editor.replace_and_mark_text_in_range(Some(4..5), "XYZ", None, cx); + assert_eq!(editor.text(cx), "XYZbXYZdXYZ"); + assert_eq!( + editor.marked_text_ranges(cx), + Some(vec![ + OffsetUtf16(0)..OffsetUtf16(3), + OffsetUtf16(4)..OffsetUtf16(7), + OffsetUtf16(8)..OffsetUtf16(11) + ]) + ); + + // Ensure the newly-marked range gets treated as relative to the previously-marked ranges. + editor.replace_and_mark_text_in_range(Some(1..2), "1", None, cx); + assert_eq!(editor.text(cx), "X1ZbX1ZdX1Z"); + assert_eq!( + editor.marked_text_ranges(cx), + Some(vec![ + OffsetUtf16(1)..OffsetUtf16(2), + OffsetUtf16(5)..OffsetUtf16(6), + OffsetUtf16(9)..OffsetUtf16(10) + ]) + ); + + // Finalize IME composition with multiple cursors. + editor.replace_text_in_range(Some(9..10), "2", cx); + assert_eq!(editor.text(cx), "X2ZbX2ZdX2Z"); + assert_eq!(editor.marked_text_ranges(cx), None); + + editor + }); +} + +#[gpui::test] +fn test_selection_with_mouse(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + + let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\nddddddd\n", cx); + let (_, editor) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + editor.update(cx, |view, cx| { + view.begin_selection(DisplayPoint::new(2, 2), false, 1, cx); + }); + assert_eq!( + editor.update(cx, |view, cx| view.selections.display_ranges(cx)), + [DisplayPoint::new(2, 2)..DisplayPoint::new(2, 2)] + ); + + editor.update(cx, |view, cx| { + view.update_selection(DisplayPoint::new(3, 3), 0, Vector2F::zero(), cx); + }); + + assert_eq!( + editor.update(cx, |view, cx| view.selections.display_ranges(cx)), + [DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3)] + ); + + editor.update(cx, |view, cx| { + view.update_selection(DisplayPoint::new(1, 1), 0, Vector2F::zero(), cx); + }); + + assert_eq!( + editor.update(cx, |view, cx| view.selections.display_ranges(cx)), + [DisplayPoint::new(2, 2)..DisplayPoint::new(1, 1)] + ); + + editor.update(cx, |view, cx| { + view.end_selection(cx); + view.update_selection(DisplayPoint::new(3, 3), 0, Vector2F::zero(), cx); + }); + + assert_eq!( + editor.update(cx, |view, cx| view.selections.display_ranges(cx)), + [DisplayPoint::new(2, 2)..DisplayPoint::new(1, 1)] + ); + + editor.update(cx, |view, cx| { + view.begin_selection(DisplayPoint::new(3, 3), true, 1, cx); + view.update_selection(DisplayPoint::new(0, 0), 0, Vector2F::zero(), cx); + }); + + assert_eq!( + editor.update(cx, |view, cx| view.selections.display_ranges(cx)), + [ + DisplayPoint::new(2, 2)..DisplayPoint::new(1, 1), + DisplayPoint::new(3, 3)..DisplayPoint::new(0, 0) + ] + ); + + editor.update(cx, |view, cx| { + view.end_selection(cx); + }); + + assert_eq!( + editor.update(cx, |view, cx| view.selections.display_ranges(cx)), + [DisplayPoint::new(3, 3)..DisplayPoint::new(0, 0)] + ); +} + +#[gpui::test] +fn test_canceling_pending_selection(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + + view.update(cx, |view, cx| { + view.begin_selection(DisplayPoint::new(2, 2), false, 1, cx); + assert_eq!( + view.selections.display_ranges(cx), + [DisplayPoint::new(2, 2)..DisplayPoint::new(2, 2)] + ); + }); + + view.update(cx, |view, cx| { + view.update_selection(DisplayPoint::new(3, 3), 0, Vector2F::zero(), cx); + assert_eq!( + view.selections.display_ranges(cx), + [DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3)] + ); + }); + + view.update(cx, |view, cx| { + view.cancel(&Cancel, cx); + view.update_selection(DisplayPoint::new(1, 1), 0, Vector2F::zero(), cx); + assert_eq!( + view.selections.display_ranges(cx), + [DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3)] + ); + }); +} + +#[gpui::test] +fn test_clone(cx: &mut gpui::MutableAppContext) { + let (text, selection_ranges) = marked_text_ranges( + indoc! {" + one + two + threeˇ + four + fiveˇ + "}, + true, + ); + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple(&text, cx); + + let (_, editor) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + + editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| s.select_ranges(selection_ranges.clone())); + editor.fold_ranges( + [ + Point::new(1, 0)..Point::new(2, 0), + Point::new(3, 0)..Point::new(4, 0), + ], + cx, + ); + }); + + let (_, cloned_editor) = editor.update(cx, |editor, cx| { + cx.add_window(Default::default(), |cx| editor.clone(cx)) + }); + + let snapshot = editor.update(cx, |e, cx| e.snapshot(cx)); + let cloned_snapshot = cloned_editor.update(cx, |e, cx| e.snapshot(cx)); + + assert_eq!( + cloned_editor.update(cx, |e, cx| e.display_text(cx)), + editor.update(cx, |e, cx| e.display_text(cx)) + ); + assert_eq!( + cloned_snapshot + .folds_in_range(0..text.len()) + .collect::>(), + snapshot.folds_in_range(0..text.len()).collect::>(), + ); + assert_set_eq!( + cloned_editor.read(cx).selections.ranges::(cx), + editor.read(cx).selections.ranges(cx) + ); + assert_set_eq!( + cloned_editor.update(cx, |e, cx| e.selections.display_ranges(cx)), + editor.update(cx, |e, cx| e.selections.display_ranges(cx)) + ); +} + +#[gpui::test] +fn test_navigation_history(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + use workspace::Item; + let (_, pane) = cx.add_window(Default::default(), |cx| Pane::new(None, cx)); + let buffer = MultiBuffer::build_simple(&sample_text(300, 5, 'a'), cx); + + cx.add_view(&pane, |cx| { + let mut editor = build_editor(buffer.clone(), cx); + let handle = cx.handle(); + editor.set_nav_history(Some(pane.read(cx).nav_history_for_item(&handle))); + + fn pop_history(editor: &mut Editor, cx: &mut MutableAppContext) -> Option { + editor.nav_history.as_mut().unwrap().pop_backward(cx) + } + + // Move the cursor a small distance. + // Nothing is added to the navigation history. + editor.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)]) + }); + editor.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)]) + }); + assert!(pop_history(&mut editor, cx).is_none()); + + // Move the cursor a large distance. + // The history can jump back to the previous position. + editor.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(13, 0)..DisplayPoint::new(13, 3)]) + }); + let nav_entry = pop_history(&mut editor, cx).unwrap(); + editor.navigate(nav_entry.data.unwrap(), cx); + assert_eq!(nav_entry.item.id(), cx.view_id()); + assert_eq!( + editor.selections.display_ranges(cx), + &[DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)] + ); + assert!(pop_history(&mut editor, cx).is_none()); + + // Move the cursor a small distance via the mouse. + // Nothing is added to the navigation history. + editor.begin_selection(DisplayPoint::new(5, 0), false, 1, cx); + editor.end_selection(cx); + assert_eq!( + editor.selections.display_ranges(cx), + &[DisplayPoint::new(5, 0)..DisplayPoint::new(5, 0)] + ); + assert!(pop_history(&mut editor, cx).is_none()); + + // Move the cursor a large distance via the mouse. + // The history can jump back to the previous position. + editor.begin_selection(DisplayPoint::new(15, 0), false, 1, cx); + editor.end_selection(cx); + assert_eq!( + editor.selections.display_ranges(cx), + &[DisplayPoint::new(15, 0)..DisplayPoint::new(15, 0)] + ); + let nav_entry = pop_history(&mut editor, cx).unwrap(); + editor.navigate(nav_entry.data.unwrap(), cx); + assert_eq!(nav_entry.item.id(), cx.view_id()); + assert_eq!( + editor.selections.display_ranges(cx), + &[DisplayPoint::new(5, 0)..DisplayPoint::new(5, 0)] + ); + assert!(pop_history(&mut editor, cx).is_none()); + + // Set scroll position to check later + editor.set_scroll_position(Vector2F::new(5.5, 5.5), cx); + let original_scroll_position = editor.scroll_position; + let original_scroll_top_anchor = editor.scroll_top_anchor.clone(); + + // Jump to the end of the document and adjust scroll + editor.move_to_end(&MoveToEnd, cx); + editor.set_scroll_position(Vector2F::new(-2.5, -0.5), cx); + assert_ne!(editor.scroll_position, original_scroll_position); + assert_ne!(editor.scroll_top_anchor, original_scroll_top_anchor); + + let nav_entry = pop_history(&mut editor, cx).unwrap(); + editor.navigate(nav_entry.data.unwrap(), cx); + assert_eq!(editor.scroll_position, original_scroll_position); + assert_eq!(editor.scroll_top_anchor, original_scroll_top_anchor); + + // Ensure we don't panic when navigation data contains invalid anchors *and* points. + let mut invalid_anchor = editor.scroll_top_anchor.clone(); + invalid_anchor.text_anchor.buffer_id = Some(999); + let invalid_point = Point::new(9999, 0); + editor.navigate( + Box::new(NavigationData { + cursor_anchor: invalid_anchor.clone(), + cursor_position: invalid_point, + scroll_top_anchor: invalid_anchor, + scroll_top_row: invalid_point.row, + scroll_position: Default::default(), + }), + cx, + ); + assert_eq!( + editor.selections.display_ranges(cx), + &[editor.max_point(cx)..editor.max_point(cx)] + ); + assert_eq!( + editor.scroll_position(cx), + vec2f(0., editor.max_point(cx).row() as f32) + ); + + editor + }); +} + +#[gpui::test] +fn test_cancel(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + + view.update(cx, |view, cx| { + view.begin_selection(DisplayPoint::new(3, 4), false, 1, cx); + view.update_selection(DisplayPoint::new(1, 1), 0, Vector2F::zero(), cx); + view.end_selection(cx); + + view.begin_selection(DisplayPoint::new(0, 1), true, 1, cx); + view.update_selection(DisplayPoint::new(0, 3), 0, Vector2F::zero(), cx); + view.end_selection(cx); + assert_eq!( + view.selections.display_ranges(cx), + [ + DisplayPoint::new(0, 1)..DisplayPoint::new(0, 3), + DisplayPoint::new(3, 4)..DisplayPoint::new(1, 1), + ] + ); + }); + + view.update(cx, |view, cx| { + view.cancel(&Cancel, cx); + assert_eq!( + view.selections.display_ranges(cx), + [DisplayPoint::new(3, 4)..DisplayPoint::new(1, 1)] + ); + }); + + view.update(cx, |view, cx| { + view.cancel(&Cancel, cx); + assert_eq!( + view.selections.display_ranges(cx), + [DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1)] + ); + }); +} + +#[gpui::test] +fn test_fold(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple( + &" + impl Foo { + // Hello! + + fn a() { + 1 + } + + fn b() { + 2 + } + + fn c() { + 3 + } + } + " + .unindent(), + cx, + ); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); + + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(8, 0)..DisplayPoint::new(12, 0)]); + }); + view.fold(&Fold, cx); + assert_eq!( + view.display_text(cx), + " + impl Foo { + // Hello! + + fn a() { + 1 + } + + fn b() {… + } + + fn c() {… + } + } + " + .unindent(), + ); + + view.fold(&Fold, cx); + assert_eq!( + view.display_text(cx), + " + impl Foo {… + } + " + .unindent(), + ); + + view.unfold_lines(&UnfoldLines, cx); + assert_eq!( + view.display_text(cx), + " + impl Foo { + // Hello! + + fn a() { + 1 + } + + fn b() {… + } + + fn c() {… + } + } + " + .unindent(), + ); + + view.unfold_lines(&UnfoldLines, cx); + assert_eq!(view.display_text(cx), buffer.read(cx).read(cx).text()); + }); +} + +#[gpui::test] +fn test_move_cursor(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); + + buffer.update(cx, |buffer, cx| { + buffer.edit( + vec![ + (Point::new(1, 0)..Point::new(1, 0), "\t"), + (Point::new(1, 1)..Point::new(1, 1), "\t"), + ], + None, + cx, + ); + }); + + view.update(cx, |view, cx| { + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)] + ); + + view.move_down(&MoveDown, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)] + ); + + view.move_right(&MoveRight, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(1, 4)..DisplayPoint::new(1, 4)] + ); + + view.move_left(&MoveLeft, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)] + ); + + view.move_up(&MoveUp, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)] + ); + + view.move_to_end(&MoveToEnd, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(5, 6)..DisplayPoint::new(5, 6)] + ); + + view.move_to_beginning(&MoveToBeginning, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)] + ); + + view.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(0, 1)..DisplayPoint::new(0, 2)]); + }); + view.select_to_beginning(&SelectToBeginning, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(0, 1)..DisplayPoint::new(0, 0)] + ); + + view.select_to_end(&SelectToEnd, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(0, 1)..DisplayPoint::new(5, 6)] + ); + }); +} + +#[gpui::test] +fn test_move_cursor_multibyte(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcde\nαβγδε\n", cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); + + assert_eq!('ⓐ'.len_utf8(), 3); + assert_eq!('α'.len_utf8(), 2); + + view.update(cx, |view, cx| { + view.fold_ranges( + vec![ + Point::new(0, 6)..Point::new(0, 12), + Point::new(1, 2)..Point::new(1, 4), + Point::new(2, 4)..Point::new(2, 8), + ], + cx, + ); + assert_eq!(view.display_text(cx), "ⓐⓑ…ⓔ\nab…e\nαβ…ε\n"); + + view.move_right(&MoveRight, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(0, "ⓐ".len())] + ); + view.move_right(&MoveRight, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(0, "ⓐⓑ".len())] + ); + view.move_right(&MoveRight, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(0, "ⓐⓑ…".len())] + ); + + view.move_down(&MoveDown, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(1, "ab…".len())] + ); + view.move_left(&MoveLeft, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(1, "ab".len())] + ); + view.move_left(&MoveLeft, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(1, "a".len())] + ); + + view.move_down(&MoveDown, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(2, "α".len())] + ); + view.move_right(&MoveRight, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(2, "αβ".len())] + ); + view.move_right(&MoveRight, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(2, "αβ…".len())] + ); + view.move_right(&MoveRight, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(2, "αβ…ε".len())] + ); + + view.move_up(&MoveUp, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(1, "ab…e".len())] + ); + view.move_up(&MoveUp, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(0, "ⓐⓑ…ⓔ".len())] + ); + view.move_left(&MoveLeft, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(0, "ⓐⓑ…".len())] + ); + view.move_left(&MoveLeft, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(0, "ⓐⓑ".len())] + ); + view.move_left(&MoveLeft, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(0, "ⓐ".len())] + ); + }); +} + +#[gpui::test] +fn test_move_cursor_different_line_lengths(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcd\nαβγ\nabcd\nⓐⓑⓒⓓⓔ\n", cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([empty_range(0, "ⓐⓑⓒⓓⓔ".len())]); + }); + view.move_down(&MoveDown, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(1, "abcd".len())] + ); + + view.move_down(&MoveDown, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(2, "αβγ".len())] + ); + + view.move_down(&MoveDown, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(3, "abcd".len())] + ); + + view.move_down(&MoveDown, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(4, "ⓐⓑⓒⓓⓔ".len())] + ); + + view.move_up(&MoveUp, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(3, "abcd".len())] + ); + + view.move_up(&MoveUp, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(2, "αβγ".len())] + ); + }); +} + +#[gpui::test] +fn test_beginning_end_of_line(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple("abc\n def", cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1), + DisplayPoint::new(1, 4)..DisplayPoint::new(1, 4), + ]); + }); + }); + + view.update(cx, |view, cx| { + view.move_to_beginning_of_line(&MoveToBeginningOfLine, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[ + DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0), + DisplayPoint::new(1, 2)..DisplayPoint::new(1, 2), + ] + ); + }); + + view.update(cx, |view, cx| { + view.move_to_beginning_of_line(&MoveToBeginningOfLine, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[ + DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0), + DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0), + ] + ); + }); + + view.update(cx, |view, cx| { + view.move_to_beginning_of_line(&MoveToBeginningOfLine, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[ + DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0), + DisplayPoint::new(1, 2)..DisplayPoint::new(1, 2), + ] + ); + }); + + view.update(cx, |view, cx| { + view.move_to_end_of_line(&MoveToEndOfLine, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[ + DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3), + DisplayPoint::new(1, 5)..DisplayPoint::new(1, 5), + ] + ); + }); + + // Moving to the end of line again is a no-op. + view.update(cx, |view, cx| { + view.move_to_end_of_line(&MoveToEndOfLine, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[ + DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3), + DisplayPoint::new(1, 5)..DisplayPoint::new(1, 5), + ] + ); + }); + + view.update(cx, |view, cx| { + view.move_left(&MoveLeft, cx); + view.select_to_beginning_of_line( + &SelectToBeginningOfLine { + stop_at_soft_wraps: true, + }, + cx, + ); + assert_eq!( + view.selections.display_ranges(cx), + &[ + DisplayPoint::new(0, 2)..DisplayPoint::new(0, 0), + DisplayPoint::new(1, 4)..DisplayPoint::new(1, 2), + ] + ); + }); + + view.update(cx, |view, cx| { + view.select_to_beginning_of_line( + &SelectToBeginningOfLine { + stop_at_soft_wraps: true, + }, + cx, + ); + assert_eq!( + view.selections.display_ranges(cx), + &[ + DisplayPoint::new(0, 2)..DisplayPoint::new(0, 0), + DisplayPoint::new(1, 4)..DisplayPoint::new(1, 0), + ] + ); + }); + + view.update(cx, |view, cx| { + view.select_to_beginning_of_line( + &SelectToBeginningOfLine { + stop_at_soft_wraps: true, + }, + cx, + ); + assert_eq!( + view.selections.display_ranges(cx), + &[ + DisplayPoint::new(0, 2)..DisplayPoint::new(0, 0), + DisplayPoint::new(1, 4)..DisplayPoint::new(1, 2), + ] + ); + }); + + view.update(cx, |view, cx| { + view.select_to_end_of_line( + &SelectToEndOfLine { + stop_at_soft_wraps: true, + }, + cx, + ); + assert_eq!( + view.selections.display_ranges(cx), + &[ + DisplayPoint::new(0, 2)..DisplayPoint::new(0, 3), + DisplayPoint::new(1, 4)..DisplayPoint::new(1, 5), + ] + ); + }); + + view.update(cx, |view, cx| { + view.delete_to_end_of_line(&DeleteToEndOfLine, cx); + assert_eq!(view.display_text(cx), "ab\n de"); + assert_eq!( + view.selections.display_ranges(cx), + &[ + DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), + DisplayPoint::new(1, 4)..DisplayPoint::new(1, 4), + ] + ); + }); + + view.update(cx, |view, cx| { + view.delete_to_beginning_of_line(&DeleteToBeginningOfLine, cx); + assert_eq!(view.display_text(cx), "\n"); + assert_eq!( + view.selections.display_ranges(cx), + &[ + DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0), + DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0), + ] + ); + }); +} + +#[gpui::test] +fn test_prev_next_word_boundary(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple("use std::str::{foo, bar}\n\n {baz.qux()}", cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(0, 11)..DisplayPoint::new(0, 11), + DisplayPoint::new(2, 4)..DisplayPoint::new(2, 4), + ]) + }); + + view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); + assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n {ˇbaz.qux()}", view, cx); + + view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); + assert_selection_ranges("use stdˇ::str::{foo, bar}\n\n ˇ{baz.qux()}", view, cx); + + view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); + assert_selection_ranges("use ˇstd::str::{foo, bar}\n\nˇ {baz.qux()}", view, cx); + + view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); + assert_selection_ranges("ˇuse std::str::{foo, bar}\nˇ\n {baz.qux()}", view, cx); + + view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); + assert_selection_ranges("ˇuse std::str::{foo, barˇ}\n\n {baz.qux()}", view, cx); + + view.move_to_next_word_end(&MoveToNextWordEnd, cx); + assert_selection_ranges("useˇ std::str::{foo, bar}ˇ\n\n {baz.qux()}", view, cx); + + view.move_to_next_word_end(&MoveToNextWordEnd, cx); + assert_selection_ranges("use stdˇ::str::{foo, bar}\nˇ\n {baz.qux()}", view, cx); + + view.move_to_next_word_end(&MoveToNextWordEnd, cx); + assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n {ˇbaz.qux()}", view, cx); + + view.move_right(&MoveRight, cx); + view.select_to_previous_word_start(&SelectToPreviousWordStart, cx); + assert_selection_ranges("use std::«ˇs»tr::{foo, bar}\n\n {«ˇb»az.qux()}", view, cx); + + view.select_to_previous_word_start(&SelectToPreviousWordStart, cx); + assert_selection_ranges("use std«ˇ::s»tr::{foo, bar}\n\n «ˇ{b»az.qux()}", view, cx); + + view.select_to_next_word_end(&SelectToNextWordEnd, cx); + assert_selection_ranges("use std::«ˇs»tr::{foo, bar}\n\n {«ˇb»az.qux()}", view, cx); + }); +} + +#[gpui::test] +fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple("use one::{\n two::three::four::five\n};", cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + + view.update(cx, |view, cx| { + view.set_wrap_width(Some(140.), cx); + assert_eq!( + view.display_text(cx), + "use one::{\n two::three::\n four::five\n};" + ); + + view.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(1, 7)..DisplayPoint::new(1, 7)]); + }); + + view.move_to_next_word_end(&MoveToNextWordEnd, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(1, 9)..DisplayPoint::new(1, 9)] + ); + + view.move_to_next_word_end(&MoveToNextWordEnd, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(1, 14)..DisplayPoint::new(1, 14)] + ); + + view.move_to_next_word_end(&MoveToNextWordEnd, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(2, 4)..DisplayPoint::new(2, 4)] + ); + + view.move_to_next_word_end(&MoveToNextWordEnd, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(2, 8)..DisplayPoint::new(2, 8)] + ); + + view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(2, 4)..DisplayPoint::new(2, 4)] + ); + + view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(1, 14)..DisplayPoint::new(1, 14)] + ); + }); +} + +#[gpui::test] +async fn test_move_page_up_page_down(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + let line_height = cx.editor(|editor, cx| editor.style(cx).text.line_height(cx.font_cache())); + cx.simulate_window_resize(cx.window_id, vec2f(100., 4. * line_height)); + + cx.set_state( + &r#" + ˇone + two + threeˇ + four + five + six + seven + eight + nine + ten + "# + .unindent(), + ); + + cx.update_editor(|editor, cx| editor.move_page_down(&MovePageDown::default(), cx)); + cx.assert_editor_state( + &r#" + one + two + three + ˇfour + five + sixˇ + seven + eight + nine + ten + "# + .unindent(), + ); + + cx.update_editor(|editor, cx| editor.move_page_down(&MovePageDown::default(), cx)); + cx.assert_editor_state( + &r#" + one + two + three + four + five + six + ˇseven + eight + nineˇ + ten + "# + .unindent(), + ); + + cx.update_editor(|editor, cx| editor.move_page_up(&MovePageUp::default(), cx)); + cx.assert_editor_state( + &r#" + one + two + three + ˇfour + five + sixˇ + seven + eight + nine + ten + "# + .unindent(), + ); + + cx.update_editor(|editor, cx| editor.move_page_up(&MovePageUp::default(), cx)); + cx.assert_editor_state( + &r#" + ˇone + two + threeˇ + four + five + six + seven + eight + nine + ten + "# + .unindent(), + ); + + // Test select collapsing + cx.update_editor(|editor, cx| { + editor.move_page_down(&MovePageDown::default(), cx); + editor.move_page_down(&MovePageDown::default(), cx); + editor.move_page_down(&MovePageDown::default(), cx); + }); + cx.assert_editor_state( + &r#" + one + two + three + four + five + six + seven + eight + nine + ˇten + ˇ"# + .unindent(), + ); +} + +#[gpui::test] +async fn test_delete_to_beginning_of_line(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx); + cx.set_state("one «two threeˇ» four"); + cx.update_editor(|editor, cx| { + editor.delete_to_beginning_of_line(&DeleteToBeginningOfLine, cx); + assert_eq!(editor.text(cx), " four"); + }); +} + +#[gpui::test] +fn test_delete_to_word_boundary(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple("one two three four", cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); + + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + // an empty selection - the preceding word fragment is deleted + DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), + // characters selected - they are deleted + DisplayPoint::new(0, 9)..DisplayPoint::new(0, 12), + ]) + }); + view.delete_to_previous_word_start(&DeleteToPreviousWordStart, cx); + }); + + assert_eq!(buffer.read(cx).read(cx).text(), "e two te four"); + + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + // an empty selection - the following word fragment is deleted + DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3), + // characters selected - they are deleted + DisplayPoint::new(0, 9)..DisplayPoint::new(0, 10), + ]) + }); + view.delete_to_next_word_end(&DeleteToNextWordEnd, cx); + }); + + assert_eq!(buffer.read(cx).read(cx).text(), "e t te our"); +} + +#[gpui::test] +fn test_newline(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple("aaaa\n bbbb\n", cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); + + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), + DisplayPoint::new(1, 2)..DisplayPoint::new(1, 2), + DisplayPoint::new(1, 6)..DisplayPoint::new(1, 6), + ]) + }); + + view.newline(&Newline, cx); + assert_eq!(view.text(cx), "aa\naa\n \n bb\n bb\n"); + }); +} + +#[gpui::test] +fn test_newline_with_old_selections(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple( + " + a + b( + X + ) + c( + X + ) + " + .unindent() + .as_str(), + cx, + ); + + let (_, editor) = cx.add_window(Default::default(), |cx| { + let mut editor = build_editor(buffer.clone(), cx); + editor.change_selections(None, cx, |s| { + s.select_ranges([ + Point::new(2, 4)..Point::new(2, 5), + Point::new(5, 4)..Point::new(5, 5), + ]) + }); + editor + }); + + // Edit the buffer directly, deleting ranges surrounding the editor's selections + buffer.update(cx, |buffer, cx| { + buffer.edit( + [ + (Point::new(1, 2)..Point::new(3, 0), ""), + (Point::new(4, 2)..Point::new(6, 0), ""), + ], + None, + cx, + ); + assert_eq!( + buffer.read(cx).text(), + " + a + b() + c() + " + .unindent() + ); + }); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.selections.ranges(cx), + &[ + Point::new(1, 2)..Point::new(1, 2), + Point::new(2, 2)..Point::new(2, 2), + ], + ); + + editor.newline(&Newline, cx); + assert_eq!( + editor.text(cx), + " + a + b( + ) + c( + ) + " + .unindent() + ); + + // The selections are moved after the inserted newlines + assert_eq!( + editor.selections.ranges(cx), + &[ + Point::new(2, 0)..Point::new(2, 0), + Point::new(4, 0)..Point::new(4, 0), + ], + ); + }); +} + +#[gpui::test] +async fn test_newline_below(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx); + cx.update(|cx| { + cx.update_global::(|settings, _| { + settings.editor_overrides.tab_size = Some(NonZeroU32::new(4).unwrap()); + }); + }); + + let language = Arc::new( + Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::language()), + ) + .with_indents_query(r#"(_ "(" ")" @end) @indent"#) + .unwrap(), + ); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + cx.set_state(indoc! {" + const a: ˇA = ( + (ˇ + «const_functionˇ»(ˇ), + so«mˇ»et«hˇ»ing_ˇelse,ˇ + )ˇ + ˇ);ˇ + "}); + cx.update_editor(|e, cx| e.newline_below(&NewlineBelow, cx)); + cx.assert_editor_state(indoc! {" + const a: A = ( + ˇ + ( + ˇ + const_function(), + ˇ + ˇ + something_else, + ˇ + ˇ + ˇ + ˇ + ) + ˇ + ); + ˇ + ˇ + "}); +} + +#[gpui::test] +fn test_insert_with_old_selections(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple("a( X ), b( Y ), c( Z )", cx); + let (_, editor) = cx.add_window(Default::default(), |cx| { + let mut editor = build_editor(buffer.clone(), cx); + editor.change_selections(None, cx, |s| s.select_ranges([3..4, 11..12, 19..20])); + editor + }); + + // Edit the buffer directly, deleting ranges surrounding the editor's selections + buffer.update(cx, |buffer, cx| { + buffer.edit([(2..5, ""), (10..13, ""), (18..21, "")], None, cx); + assert_eq!(buffer.read(cx).text(), "a(), b(), c()".unindent()); + }); + + editor.update(cx, |editor, cx| { + assert_eq!(editor.selections.ranges(cx), &[2..2, 7..7, 12..12],); + + editor.insert("Z", cx); + assert_eq!(editor.text(cx), "a(Z), b(Z), c(Z)"); + + // The selections are moved after the inserted characters + assert_eq!(editor.selections.ranges(cx), &[3..3, 9..9, 15..15],); + }); +} + +#[gpui::test] +async fn test_tab(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx); + cx.update(|cx| { + cx.update_global::(|settings, _| { + settings.editor_overrides.tab_size = Some(NonZeroU32::new(3).unwrap()); + }); + }); + cx.set_state(indoc! {" + ˇabˇc + ˇ🏀ˇ🏀ˇefg + dˇ + "}); + cx.update_editor(|e, cx| e.tab(&Tab, cx)); + cx.assert_editor_state(indoc! {" + ˇab ˇc + ˇ🏀 ˇ🏀 ˇefg + d ˇ + "}); + + cx.set_state(indoc! {" + a + «🏀ˇ»🏀«🏀ˇ»🏀«🏀ˇ» + "}); + cx.update_editor(|e, cx| e.tab(&Tab, cx)); + cx.assert_editor_state(indoc! {" + a + «🏀ˇ»🏀«🏀ˇ»🏀«🏀ˇ» + "}); +} + +#[gpui::test] +async fn test_tab_on_blank_line_auto_indents(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx); + let language = Arc::new( + Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::language()), + ) + .with_indents_query(r#"(_ "(" ")" @end) @indent"#) + .unwrap(), + ); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // cursors that are already at the suggested indent level insert + // a soft tab. cursors that are to the left of the suggested indent + // auto-indent their line. + cx.set_state(indoc! {" + ˇ + const a: B = ( + c( + d( + ˇ + ) + ˇ + ˇ ) + ); + "}); + cx.update_editor(|e, cx| e.tab(&Tab, cx)); + cx.assert_editor_state(indoc! {" + ˇ + const a: B = ( + c( + d( + ˇ + ) + ˇ + ˇ) + ); + "}); + + // handle auto-indent when there are multiple cursors on the same line + cx.set_state(indoc! {" + const a: B = ( + c( + ˇ ˇ + ˇ ) + ); + "}); + cx.update_editor(|e, cx| e.tab(&Tab, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c( + ˇ + ˇ) + ); + "}); +} + +#[gpui::test] +async fn test_indent_outdent(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.set_state(indoc! {" + «oneˇ» «twoˇ» + three + four + "}); + cx.update_editor(|e, cx| e.tab(&Tab, cx)); + cx.assert_editor_state(indoc! {" + «oneˇ» «twoˇ» + three + four + "}); + + cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); + cx.assert_editor_state(indoc! {" + «oneˇ» «twoˇ» + three + four + "}); + + // select across line ending + cx.set_state(indoc! {" + one two + t«hree + ˇ» four + "}); + cx.update_editor(|e, cx| e.tab(&Tab, cx)); + cx.assert_editor_state(indoc! {" + one two + t«hree + ˇ» four + "}); + + cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); + cx.assert_editor_state(indoc! {" + one two + t«hree + ˇ» four + "}); + + // Ensure that indenting/outdenting works when the cursor is at column 0. + cx.set_state(indoc! {" + one two + ˇthree + four + "}); + cx.update_editor(|e, cx| e.tab(&Tab, cx)); + cx.assert_editor_state(indoc! {" + one two + ˇthree + four + "}); + + cx.set_state(indoc! {" + one two + ˇ three + four + "}); + cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); + cx.assert_editor_state(indoc! {" + one two + ˇthree + four + "}); +} + +#[gpui::test] +async fn test_indent_outdent_with_hard_tabs(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx); + cx.update(|cx| { + cx.update_global::(|settings, _| { + settings.editor_overrides.hard_tabs = Some(true); + }); + }); + + // select two ranges on one line + cx.set_state(indoc! {" + «oneˇ» «twoˇ» + three + four + "}); + cx.update_editor(|e, cx| e.tab(&Tab, cx)); + cx.assert_editor_state(indoc! {" + \t«oneˇ» «twoˇ» + three + four + "}); + cx.update_editor(|e, cx| e.tab(&Tab, cx)); + cx.assert_editor_state(indoc! {" + \t\t«oneˇ» «twoˇ» + three + four + "}); + cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); + cx.assert_editor_state(indoc! {" + \t«oneˇ» «twoˇ» + three + four + "}); + cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); + cx.assert_editor_state(indoc! {" + «oneˇ» «twoˇ» + three + four + "}); + + // select across a line ending + cx.set_state(indoc! {" + one two + t«hree + ˇ»four + "}); + cx.update_editor(|e, cx| e.tab(&Tab, cx)); + cx.assert_editor_state(indoc! {" + one two + \tt«hree + ˇ»four + "}); + cx.update_editor(|e, cx| e.tab(&Tab, cx)); + cx.assert_editor_state(indoc! {" + one two + \t\tt«hree + ˇ»four + "}); + cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); + cx.assert_editor_state(indoc! {" + one two + \tt«hree + ˇ»four + "}); + cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); + cx.assert_editor_state(indoc! {" + one two + t«hree + ˇ»four + "}); + + // Ensure that indenting/outdenting works when the cursor is at column 0. + cx.set_state(indoc! {" + one two + ˇthree + four + "}); + cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); + cx.assert_editor_state(indoc! {" + one two + ˇthree + four + "}); + cx.update_editor(|e, cx| e.tab(&Tab, cx)); + cx.assert_editor_state(indoc! {" + one two + \tˇthree + four + "}); + cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); + cx.assert_editor_state(indoc! {" + one two + ˇthree + four + "}); +} + +#[gpui::test] +fn test_indent_outdent_with_excerpts(cx: &mut gpui::MutableAppContext) { + cx.set_global( + Settings::test(cx) + .with_language_defaults( + "TOML", + EditorSettings { + tab_size: Some(2.try_into().unwrap()), + ..Default::default() + }, + ) + .with_language_defaults( + "Rust", + EditorSettings { + tab_size: Some(4.try_into().unwrap()), + ..Default::default() + }, + ), + ); + let toml_language = Arc::new(Language::new( + LanguageConfig { + name: "TOML".into(), + ..Default::default() + }, + None, + )); + let rust_language = Arc::new(Language::new( + LanguageConfig { + name: "Rust".into(), + ..Default::default() + }, + None, + )); + + let toml_buffer = + cx.add_model(|cx| Buffer::new(0, "a = 1\nb = 2\n", cx).with_language(toml_language, cx)); + let rust_buffer = cx.add_model(|cx| { + Buffer::new(0, "const c: usize = 3;\n", cx).with_language(rust_language, cx) + }); + let multibuffer = cx.add_model(|cx| { + let mut multibuffer = MultiBuffer::new(0); + multibuffer.push_excerpts( + toml_buffer.clone(), + [ExcerptRange { + context: Point::new(0, 0)..Point::new(2, 0), + primary: None, + }], + cx, + ); + multibuffer.push_excerpts( + rust_buffer.clone(), + [ExcerptRange { + context: Point::new(0, 0)..Point::new(1, 0), + primary: None, + }], + cx, + ); + multibuffer + }); + + cx.add_window(Default::default(), |cx| { + let mut editor = build_editor(multibuffer, cx); + + assert_eq!( + editor.text(cx), + indoc! {" + a = 1 + b = 2 + + const c: usize = 3; + "} + ); + + select_ranges( + &mut editor, + indoc! {" + «aˇ» = 1 + b = 2 + + «const c:ˇ» usize = 3; + "}, + cx, + ); + + editor.tab(&Tab, cx); + assert_text_with_selections( + &mut editor, + indoc! {" + «aˇ» = 1 + b = 2 + + «const c:ˇ» usize = 3; + "}, + cx, + ); + editor.tab_prev(&TabPrev, cx); + assert_text_with_selections( + &mut editor, + indoc! {" + «aˇ» = 1 + b = 2 + + «const c:ˇ» usize = 3; + "}, + cx, + ); + + editor + }); +} + +#[gpui::test] +async fn test_backspace(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + // Basic backspace + cx.set_state(indoc! {" + onˇe two three + fou«rˇ» five six + seven «ˇeight nine + »ten + "}); + cx.update_editor(|e, cx| e.backspace(&Backspace, cx)); + cx.assert_editor_state(indoc! {" + oˇe two three + fouˇ five six + seven ˇten + "}); + + // Test backspace inside and around indents + cx.set_state(indoc! {" + zero + ˇone + ˇtwo + ˇ ˇ ˇ three + ˇ ˇ four + "}); + cx.update_editor(|e, cx| e.backspace(&Backspace, cx)); + cx.assert_editor_state(indoc! {" + zero + ˇone + ˇtwo + ˇ threeˇ four + "}); + + // Test backspace with line_mode set to true + cx.update_editor(|e, _| e.selections.line_mode = true); + cx.set_state(indoc! {" + The ˇquick ˇbrown + fox jumps over + the lazy dog + ˇThe qu«ick bˇ»rown"}); + cx.update_editor(|e, cx| e.backspace(&Backspace, cx)); + cx.assert_editor_state(indoc! {" + ˇfox jumps over + the lazy dogˇ"}); +} + +#[gpui::test] +async fn test_delete(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.set_state(indoc! {" + onˇe two three + fou«rˇ» five six + seven «ˇeight nine + »ten + "}); + cx.update_editor(|e, cx| e.delete(&Delete, cx)); + cx.assert_editor_state(indoc! {" + onˇ two three + fouˇ five six + seven ˇten + "}); + + // Test backspace with line_mode set to true + cx.update_editor(|e, _| e.selections.line_mode = true); + cx.set_state(indoc! {" + The ˇquick ˇbrown + fox «ˇjum»ps over + the lazy dog + ˇThe qu«ick bˇ»rown"}); + cx.update_editor(|e, cx| e.backspace(&Backspace, cx)); + cx.assert_editor_state("ˇthe lazy dogˇ"); +} + +#[gpui::test] +fn test_delete_line(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1), + DisplayPoint::new(1, 0)..DisplayPoint::new(1, 1), + DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0), + ]) + }); + view.delete_line(&DeleteLine, cx); + assert_eq!(view.display_text(cx), "ghi"); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0), + DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1) + ] + ); + }); + + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(2, 0)..DisplayPoint::new(0, 1)]) + }); + view.delete_line(&DeleteLine, cx); + assert_eq!(view.display_text(cx), "ghi\n"); + assert_eq!( + view.selections.display_ranges(cx), + vec![DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1)] + ); + }); +} + +#[gpui::test] +fn test_duplicate_line(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1), + DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), + DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0), + DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0), + ]) + }); + view.duplicate_line(&DuplicateLine, cx); + assert_eq!(view.display_text(cx), "abc\nabc\ndef\ndef\nghi\n\n"); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(1, 0)..DisplayPoint::new(1, 1), + DisplayPoint::new(1, 2)..DisplayPoint::new(1, 2), + DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0), + DisplayPoint::new(6, 0)..DisplayPoint::new(6, 0), + ] + ); + }); + + let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(0, 1)..DisplayPoint::new(1, 1), + DisplayPoint::new(1, 2)..DisplayPoint::new(2, 1), + ]) + }); + view.duplicate_line(&DuplicateLine, cx); + assert_eq!(view.display_text(cx), "abc\ndef\nghi\nabc\ndef\nghi\n"); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(3, 1)..DisplayPoint::new(4, 1), + DisplayPoint::new(4, 2)..DisplayPoint::new(5, 1), + ] + ); + }); +} + +#[gpui::test] +fn test_move_line_up_down(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple(&sample_text(10, 5, 'a'), cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + view.update(cx, |view, cx| { + view.fold_ranges( + vec![ + Point::new(0, 2)..Point::new(1, 2), + Point::new(2, 3)..Point::new(4, 1), + Point::new(7, 0)..Point::new(8, 4), + ], + cx, + ); + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1), + DisplayPoint::new(3, 1)..DisplayPoint::new(3, 1), + DisplayPoint::new(3, 2)..DisplayPoint::new(4, 3), + DisplayPoint::new(5, 0)..DisplayPoint::new(5, 2), + ]) + }); + assert_eq!( + view.display_text(cx), + "aa…bbb\nccc…eeee\nfffff\nggggg\n…i\njjjjj" + ); + + view.move_line_up(&MoveLineUp, cx); + assert_eq!( + view.display_text(cx), + "aa…bbb\nccc…eeee\nggggg\n…i\njjjjj\nfffff" + ); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1), + DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1), + DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3), + DisplayPoint::new(4, 0)..DisplayPoint::new(4, 2) + ] + ); + }); + + view.update(cx, |view, cx| { + view.move_line_down(&MoveLineDown, cx); + assert_eq!( + view.display_text(cx), + "ccc…eeee\naa…bbb\nfffff\nggggg\n…i\njjjjj" + ); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1), + DisplayPoint::new(3, 1)..DisplayPoint::new(3, 1), + DisplayPoint::new(3, 2)..DisplayPoint::new(4, 3), + DisplayPoint::new(5, 0)..DisplayPoint::new(5, 2) + ] + ); + }); + + view.update(cx, |view, cx| { + view.move_line_down(&MoveLineDown, cx); + assert_eq!( + view.display_text(cx), + "ccc…eeee\nfffff\naa…bbb\nggggg\n…i\njjjjj" + ); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1), + DisplayPoint::new(3, 1)..DisplayPoint::new(3, 1), + DisplayPoint::new(3, 2)..DisplayPoint::new(4, 3), + DisplayPoint::new(5, 0)..DisplayPoint::new(5, 2) + ] + ); + }); + + view.update(cx, |view, cx| { + view.move_line_up(&MoveLineUp, cx); + assert_eq!( + view.display_text(cx), + "ccc…eeee\naa…bbb\nggggg\n…i\njjjjj\nfffff" + ); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1), + DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1), + DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3), + DisplayPoint::new(4, 0)..DisplayPoint::new(4, 2) + ] + ); + }); +} + +#[gpui::test] +fn test_move_line_up_down_with_blocks(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple(&sample_text(10, 5, 'a'), cx); + let snapshot = buffer.read(cx).snapshot(cx); + let (_, editor) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + editor.update(cx, |editor, cx| { + editor.insert_blocks( + [BlockProperties { + style: BlockStyle::Fixed, + position: snapshot.anchor_after(Point::new(2, 0)), + disposition: BlockDisposition::Below, + height: 1, + render: Arc::new(|_| Empty::new().boxed()), + }], + cx, + ); + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(2, 0)..Point::new(2, 0)]) + }); + editor.move_line_down(&MoveLineDown, cx); + }); +} + +#[gpui::test] +fn test_transpose(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + + _ = cx + .add_window(Default::default(), |cx| { + let mut editor = build_editor(MultiBuffer::build_simple("abc", cx), cx); + + editor.change_selections(None, cx, |s| s.select_ranges([1..1])); + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bac"); + assert_eq!(editor.selections.ranges(cx), [2..2]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bca"); + assert_eq!(editor.selections.ranges(cx), [3..3]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bac"); + assert_eq!(editor.selections.ranges(cx), [3..3]); + + editor + }) + .1; + + _ = cx + .add_window(Default::default(), |cx| { + let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), cx); + + editor.change_selections(None, cx, |s| s.select_ranges([3..3])); + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "acb\nde"); + assert_eq!(editor.selections.ranges(cx), [3..3]); + + editor.change_selections(None, cx, |s| s.select_ranges([4..4])); + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "acbd\ne"); + assert_eq!(editor.selections.ranges(cx), [5..5]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "acbde\n"); + assert_eq!(editor.selections.ranges(cx), [6..6]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "acbd\ne"); + assert_eq!(editor.selections.ranges(cx), [6..6]); + + editor + }) + .1; + + _ = cx + .add_window(Default::default(), |cx| { + let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), cx); + + editor.change_selections(None, cx, |s| s.select_ranges([1..1, 2..2, 4..4])); + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bacd\ne"); + assert_eq!(editor.selections.ranges(cx), [2..2, 3..3, 5..5]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bcade\n"); + assert_eq!(editor.selections.ranges(cx), [3..3, 4..4, 6..6]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bcda\ne"); + assert_eq!(editor.selections.ranges(cx), [4..4, 6..6]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bcade\n"); + assert_eq!(editor.selections.ranges(cx), [4..4, 6..6]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bcaed\n"); + assert_eq!(editor.selections.ranges(cx), [5..5, 6..6]); + + editor + }) + .1; + + _ = cx + .add_window(Default::default(), |cx| { + let mut editor = build_editor(MultiBuffer::build_simple("🍐🏀✋", cx), cx); + + editor.change_selections(None, cx, |s| s.select_ranges([4..4])); + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "🏀🍐✋"); + assert_eq!(editor.selections.ranges(cx), [8..8]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "🏀✋🍐"); + assert_eq!(editor.selections.ranges(cx), [11..11]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "🏀🍐✋"); + assert_eq!(editor.selections.ranges(cx), [11..11]); + + editor + }) + .1; +} + +#[gpui::test] +async fn test_clipboard(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + cx.set_state("«one✅ ˇ»two «three ˇ»four «five ˇ»six "); + cx.update_editor(|e, cx| e.cut(&Cut, cx)); + cx.assert_editor_state("ˇtwo ˇfour ˇsix "); + + // Paste with three cursors. Each cursor pastes one slice of the clipboard text. + cx.set_state("two ˇfour ˇsix ˇ"); + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state("two one✅ ˇfour three ˇsix five ˇ"); + + // Paste again but with only two cursors. Since the number of cursors doesn't + // match the number of slices in the clipboard, the entire clipboard text + // is pasted at each cursor. + cx.set_state("ˇtwo one✅ four three six five ˇ"); + cx.update_editor(|e, cx| { + e.handle_input("( ", cx); + e.paste(&Paste, cx); + e.handle_input(") ", cx); + }); + cx.assert_editor_state(indoc! {" + ( one✅ + three + five ) ˇtwo one✅ four three six five ( one✅ + three + five ) ˇ"}); + + // Cut with three selections, one of which is full-line. + cx.set_state(indoc! {" + 1«2ˇ»3 + 4ˇ567 + «8ˇ»9"}); + cx.update_editor(|e, cx| e.cut(&Cut, cx)); + cx.assert_editor_state(indoc! {" + 1ˇ3 + ˇ9"}); + + // Paste with three selections, noticing how the copied selection that was full-line + // gets inserted before the second cursor. + cx.set_state(indoc! {" + 1ˇ3 + 9ˇ + «oˇ»ne"}); + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state(indoc! {" + 12ˇ3 + 4567 + 9ˇ + 8ˇne"}); + + // Copy with a single cursor only, which writes the whole line into the clipboard. + cx.set_state(indoc! {" + The quick brown + fox juˇmps over + the lazy dog"}); + cx.update_editor(|e, cx| e.copy(&Copy, cx)); + cx.cx.assert_clipboard_content(Some("fox jumps over\n")); + + // Paste with three selections, noticing how the copied full-line selection is inserted + // before the empty selections but replaces the selection that is non-empty. + cx.set_state(indoc! {" + Tˇhe quick brown + «foˇ»x jumps over + tˇhe lazy dog"}); + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state(indoc! {" + fox jumps over + Tˇhe quick brown + fox jumps over + ˇx jumps over + fox jumps over + tˇhe lazy dog"}); +} + +#[gpui::test] +async fn test_paste_multiline(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx); + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::language()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // Cut an indented block, without the leading whitespace. + cx.set_state(indoc! {" + const a: B = ( + c(), + «d( + e, + f + )ˇ» + ); + "}); + cx.update_editor(|e, cx| e.cut(&Cut, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + ˇ + ); + "}); + + // Paste it at the same position. + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + d( + e, + f + )ˇ + ); + "}); + + // Paste it at a line with a lower indent level. + cx.set_state(indoc! {" + ˇ + const a: B = ( + c(), + ); + "}); + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state(indoc! {" + d( + e, + f + )ˇ + const a: B = ( + c(), + ); + "}); + + // Cut an indented block, with the leading whitespace. + cx.set_state(indoc! {" + const a: B = ( + c(), + « d( + e, + f + ) + ˇ»); + "}); + cx.update_editor(|e, cx| e.cut(&Cut, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + ˇ); + "}); + + // Paste it at the same position. + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + d( + e, + f + ) + ˇ); + "}); + + // Paste it at a line with a higher indent level. + cx.set_state(indoc! {" + const a: B = ( + c(), + d( + e, + fˇ + ) + ); + "}); + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + d( + e, + f d( + e, + f + ) + ˇ + ) + ); + "}); +} + +#[gpui::test] +fn test_select_all(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple("abc\nde\nfgh", cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + view.update(cx, |view, cx| { + view.select_all(&SelectAll, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(0, 0)..DisplayPoint::new(2, 3)] + ); + }); +} + +#[gpui::test] +fn test_select_line(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple(&sample_text(6, 5, 'a'), cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1), + DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), + DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0), + DisplayPoint::new(4, 2)..DisplayPoint::new(4, 2), + ]) + }); + view.select_line(&SelectLine, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(0, 0)..DisplayPoint::new(2, 0), + DisplayPoint::new(4, 0)..DisplayPoint::new(5, 0), + ] + ); + }); + + view.update(cx, |view, cx| { + view.select_line(&SelectLine, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(0, 0)..DisplayPoint::new(3, 0), + DisplayPoint::new(4, 0)..DisplayPoint::new(5, 5), + ] + ); + }); + + view.update(cx, |view, cx| { + view.select_line(&SelectLine, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![DisplayPoint::new(0, 0)..DisplayPoint::new(5, 5)] + ); + }); +} + +#[gpui::test] +fn test_split_selection_into_lines(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple(&sample_text(9, 5, 'a'), cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + view.update(cx, |view, cx| { + view.fold_ranges( + vec![ + Point::new(0, 2)..Point::new(1, 2), + Point::new(2, 3)..Point::new(4, 1), + Point::new(7, 0)..Point::new(8, 4), + ], + cx, + ); + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1), + DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), + DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0), + DisplayPoint::new(4, 4)..DisplayPoint::new(4, 4), + ]) + }); + assert_eq!(view.display_text(cx), "aa…bbb\nccc…eeee\nfffff\nggggg\n…i"); + }); + + view.update(cx, |view, cx| { + view.split_selection_into_lines(&SplitSelectionIntoLines, cx); + assert_eq!( + view.display_text(cx), + "aaaaa\nbbbbb\nccc…eeee\nfffff\nggggg\n…i" + ); + assert_eq!( + view.selections.display_ranges(cx), + [ + DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1), + DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), + DisplayPoint::new(2, 0)..DisplayPoint::new(2, 0), + DisplayPoint::new(5, 4)..DisplayPoint::new(5, 4) + ] + ); + }); + + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(5, 0)..DisplayPoint::new(0, 1)]) + }); + view.split_selection_into_lines(&SplitSelectionIntoLines, cx); + assert_eq!( + view.display_text(cx), + "aaaaa\nbbbbb\nccccc\nddddd\neeeee\nfffff\nggggg\nhhhhh\niiiii" + ); + assert_eq!( + view.selections.display_ranges(cx), + [ + DisplayPoint::new(0, 5)..DisplayPoint::new(0, 5), + DisplayPoint::new(1, 5)..DisplayPoint::new(1, 5), + DisplayPoint::new(2, 5)..DisplayPoint::new(2, 5), + DisplayPoint::new(3, 5)..DisplayPoint::new(3, 5), + DisplayPoint::new(4, 5)..DisplayPoint::new(4, 5), + DisplayPoint::new(5, 5)..DisplayPoint::new(5, 5), + DisplayPoint::new(6, 5)..DisplayPoint::new(6, 5), + DisplayPoint::new(7, 0)..DisplayPoint::new(7, 0) + ] + ); + }); +} + +#[gpui::test] +fn test_add_selection_above_below(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = MultiBuffer::build_simple("abc\ndefghi\n\njk\nlmno\n", cx); + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx)); + + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3)]) + }); + }); + view.update(cx, |view, cx| { + view.add_selection_above(&AddSelectionAbove, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3), + DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3) + ] + ); + }); + + view.update(cx, |view, cx| { + view.add_selection_above(&AddSelectionAbove, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3), + DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3) + ] + ); + }); + + view.update(cx, |view, cx| { + view.add_selection_below(&AddSelectionBelow, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3)] + ); + + view.undo_selection(&UndoSelection, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3), + DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3) + ] + ); + + view.redo_selection(&RedoSelection, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3)] + ); + }); + + view.update(cx, |view, cx| { + view.add_selection_below(&AddSelectionBelow, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3), + DisplayPoint::new(4, 3)..DisplayPoint::new(4, 3) + ] + ); + }); + + view.update(cx, |view, cx| { + view.add_selection_below(&AddSelectionBelow, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3), + DisplayPoint::new(4, 3)..DisplayPoint::new(4, 3) + ] + ); + }); + + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3)]) + }); + }); + view.update(cx, |view, cx| { + view.add_selection_below(&AddSelectionBelow, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3), + DisplayPoint::new(4, 4)..DisplayPoint::new(4, 3) + ] + ); + }); + + view.update(cx, |view, cx| { + view.add_selection_below(&AddSelectionBelow, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3), + DisplayPoint::new(4, 4)..DisplayPoint::new(4, 3) + ] + ); + }); + + view.update(cx, |view, cx| { + view.add_selection_above(&AddSelectionAbove, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3)] + ); + }); + + view.update(cx, |view, cx| { + view.add_selection_above(&AddSelectionAbove, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3)] + ); + }); + + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(0, 1)..DisplayPoint::new(1, 4)]) + }); + view.add_selection_below(&AddSelectionBelow, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(0, 1)..DisplayPoint::new(0, 3), + DisplayPoint::new(1, 1)..DisplayPoint::new(1, 4), + DisplayPoint::new(3, 1)..DisplayPoint::new(3, 2), + ] + ); + }); + + view.update(cx, |view, cx| { + view.add_selection_below(&AddSelectionBelow, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(0, 1)..DisplayPoint::new(0, 3), + DisplayPoint::new(1, 1)..DisplayPoint::new(1, 4), + DisplayPoint::new(3, 1)..DisplayPoint::new(3, 2), + DisplayPoint::new(4, 1)..DisplayPoint::new(4, 4), + ] + ); + }); + + view.update(cx, |view, cx| { + view.add_selection_above(&AddSelectionAbove, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(0, 1)..DisplayPoint::new(0, 3), + DisplayPoint::new(1, 1)..DisplayPoint::new(1, 4), + DisplayPoint::new(3, 1)..DisplayPoint::new(3, 2), + ] + ); + }); + + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(4, 3)..DisplayPoint::new(1, 1)]) + }); + }); + view.update(cx, |view, cx| { + view.add_selection_above(&AddSelectionAbove, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(0, 3)..DisplayPoint::new(0, 1), + DisplayPoint::new(1, 3)..DisplayPoint::new(1, 1), + DisplayPoint::new(3, 2)..DisplayPoint::new(3, 1), + DisplayPoint::new(4, 3)..DisplayPoint::new(4, 1), + ] + ); + }); + + view.update(cx, |view, cx| { + view.add_selection_below(&AddSelectionBelow, cx); + assert_eq!( + view.selections.display_ranges(cx), + vec![ + DisplayPoint::new(1, 3)..DisplayPoint::new(1, 1), + DisplayPoint::new(3, 2)..DisplayPoint::new(3, 1), + DisplayPoint::new(4, 3)..DisplayPoint::new(4, 1), + ] + ); + }); +} + +#[gpui::test] +async fn test_select_next(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx); + cx.set_state("abc\nˇabc abc\ndefabc\nabc"); + + cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx)); + cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); + + cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx)); + cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\nabc"); + + cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx)); + cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); + + cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx)); + cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\nabc"); + + cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx)); + cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»"); + + cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx)); + cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»"); +} + +#[gpui::test] +async fn test_select_larger_smaller_syntax_node(cx: &mut gpui::TestAppContext) { + cx.update(|cx| cx.set_global(Settings::test(cx))); + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::language()), + )); + + let text = r#" + use mod1::mod2::{mod3, mod4}; + + fn fn_1(param1: bool, param2: &str) { + let var1 = "text"; + } + "# + .unindent(); + + let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx)); + let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); + let (_, view) = cx.add_window(|cx| build_editor(buffer, cx)); + view.condition(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) + .await; + + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(0, 25)..DisplayPoint::new(0, 25), + DisplayPoint::new(2, 24)..DisplayPoint::new(2, 12), + DisplayPoint::new(3, 18)..DisplayPoint::new(3, 18), + ]); + }); + view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx); + }); + assert_eq!( + view.update(cx, |view, cx| { view.selections.display_ranges(cx) }), + &[ + DisplayPoint::new(0, 23)..DisplayPoint::new(0, 27), + DisplayPoint::new(2, 35)..DisplayPoint::new(2, 7), + DisplayPoint::new(3, 15)..DisplayPoint::new(3, 21), + ] + ); + + view.update(cx, |view, cx| { + view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx); + }); + assert_eq!( + view.update(cx, |view, cx| view.selections.display_ranges(cx)), + &[ + DisplayPoint::new(0, 16)..DisplayPoint::new(0, 28), + DisplayPoint::new(4, 1)..DisplayPoint::new(2, 0), + ] + ); + + view.update(cx, |view, cx| { + view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx); + }); + assert_eq!( + view.update(cx, |view, cx| view.selections.display_ranges(cx)), + &[DisplayPoint::new(5, 0)..DisplayPoint::new(0, 0)] + ); + + // Trying to expand the selected syntax node one more time has no effect. + view.update(cx, |view, cx| { + view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx); + }); + assert_eq!( + view.update(cx, |view, cx| view.selections.display_ranges(cx)), + &[DisplayPoint::new(5, 0)..DisplayPoint::new(0, 0)] + ); + + view.update(cx, |view, cx| { + view.select_smaller_syntax_node(&SelectSmallerSyntaxNode, cx); + }); + assert_eq!( + view.update(cx, |view, cx| view.selections.display_ranges(cx)), + &[ + DisplayPoint::new(0, 16)..DisplayPoint::new(0, 28), + DisplayPoint::new(4, 1)..DisplayPoint::new(2, 0), + ] + ); + + view.update(cx, |view, cx| { + view.select_smaller_syntax_node(&SelectSmallerSyntaxNode, cx); + }); + assert_eq!( + view.update(cx, |view, cx| view.selections.display_ranges(cx)), + &[ + DisplayPoint::new(0, 23)..DisplayPoint::new(0, 27), + DisplayPoint::new(2, 35)..DisplayPoint::new(2, 7), + DisplayPoint::new(3, 15)..DisplayPoint::new(3, 21), + ] + ); + + view.update(cx, |view, cx| { + view.select_smaller_syntax_node(&SelectSmallerSyntaxNode, cx); + }); + assert_eq!( + view.update(cx, |view, cx| view.selections.display_ranges(cx)), + &[ + DisplayPoint::new(0, 25)..DisplayPoint::new(0, 25), + DisplayPoint::new(2, 24)..DisplayPoint::new(2, 12), + DisplayPoint::new(3, 18)..DisplayPoint::new(3, 18), + ] + ); + + // Trying to shrink the selected syntax node one more time has no effect. + view.update(cx, |view, cx| { + view.select_smaller_syntax_node(&SelectSmallerSyntaxNode, cx); + }); + assert_eq!( + view.update(cx, |view, cx| view.selections.display_ranges(cx)), + &[ + DisplayPoint::new(0, 25)..DisplayPoint::new(0, 25), + DisplayPoint::new(2, 24)..DisplayPoint::new(2, 12), + DisplayPoint::new(3, 18)..DisplayPoint::new(3, 18), + ] + ); + + // Ensure that we keep expanding the selection if the larger selection starts or ends within + // a fold. + view.update(cx, |view, cx| { + view.fold_ranges( + vec![ + Point::new(0, 21)..Point::new(0, 24), + Point::new(3, 20)..Point::new(3, 22), + ], + cx, + ); + view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx); + }); + assert_eq!( + view.update(cx, |view, cx| view.selections.display_ranges(cx)), + &[ + DisplayPoint::new(0, 16)..DisplayPoint::new(0, 28), + DisplayPoint::new(2, 35)..DisplayPoint::new(2, 7), + DisplayPoint::new(3, 4)..DisplayPoint::new(3, 23), + ] + ); +} + +#[gpui::test] +async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) { + cx.update(|cx| cx.set_global(Settings::test(cx))); + let language = Arc::new( + Language::new( + LanguageConfig { + brackets: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: false, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: false, + newline: true, + }, + ], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ) + .with_indents_query( + r#" + (_ "(" ")" @end) @indent + (_ "{" "}" @end) @indent + "#, + ) + .unwrap(), + ); + + let text = "fn a() {}"; + + let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx)); + let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); + let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx)); + editor + .condition(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| s.select_ranges([5..5, 8..8, 9..9])); + editor.newline(&Newline, cx); + assert_eq!(editor.text(cx), "fn a(\n \n) {\n \n}\n"); + assert_eq!( + editor.selections.ranges(cx), + &[ + Point::new(1, 4)..Point::new(1, 4), + Point::new(3, 4)..Point::new(3, 4), + Point::new(5, 0)..Point::new(5, 0) + ] + ); + }); +} + +#[gpui::test] +async fn test_autoclose_pairs(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + let language = Arc::new(Language::new( + LanguageConfig { + brackets: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + newline: true, + }, + BracketPair { + start: "/*".to_string(), + end: " */".to_string(), + close: true, + newline: true, + }, + BracketPair { + start: "[".to_string(), + end: "]".to_string(), + close: false, + newline: true, + }, + ], + autoclose_before: "})]".to_string(), + ..Default::default() + }, + Some(tree_sitter_rust::language()), + )); + + let registry = Arc::new(LanguageRegistry::test()); + registry.add(language.clone()); + cx.update_buffer(|buffer, cx| { + buffer.set_language_registry(registry); + buffer.set_language(Some(language), cx); + }); + + cx.set_state( + &r#" + 🏀ˇ + εˇ + ❤️ˇ + "# + .unindent(), + ); + + // autoclose multiple nested brackets at multiple cursors + cx.update_editor(|view, cx| { + view.handle_input("{", cx); + view.handle_input("{", cx); + view.handle_input("{", cx); + }); + cx.assert_editor_state( + &" + 🏀{{{ˇ}}} + ε{{{ˇ}}} + ❤️{{{ˇ}}} + " + .unindent(), + ); + + // insert a different closing bracket + cx.update_editor(|view, cx| { + view.handle_input(")", cx); + }); + cx.assert_editor_state( + &" + 🏀{{{)ˇ}}} + ε{{{)ˇ}}} + ❤️{{{)ˇ}}} + " + .unindent(), + ); + + // skip over the auto-closed brackets when typing a closing bracket + cx.update_editor(|view, cx| { + view.move_right(&MoveRight, cx); + view.handle_input("}", cx); + view.handle_input("}", cx); + view.handle_input("}", cx); + }); + cx.assert_editor_state( + &" + 🏀{{{)}}}}ˇ + ε{{{)}}}}ˇ + ❤️{{{)}}}}ˇ + " + .unindent(), + ); + + // autoclose multi-character pairs + cx.set_state( + &" + ˇ + ˇ + " + .unindent(), + ); + cx.update_editor(|view, cx| { + view.handle_input("/", cx); + view.handle_input("*", cx); + }); + cx.assert_editor_state( + &" + /*ˇ */ + /*ˇ */ + " + .unindent(), + ); + + // one cursor autocloses a multi-character pair, one cursor + // does not autoclose. + cx.set_state( + &" + /ˇ + ˇ + " + .unindent(), + ); + cx.update_editor(|view, cx| view.handle_input("*", cx)); + cx.assert_editor_state( + &" + /*ˇ */ + *ˇ + " + .unindent(), + ); + + // Don't autoclose if the next character isn't whitespace and isn't + // listed in the language's "autoclose_before" section. + cx.set_state("ˇa b"); + cx.update_editor(|view, cx| view.handle_input("{", cx)); + cx.assert_editor_state("{ˇa b"); + + // Don't autoclose if `close` is false for the bracket pair + cx.set_state("ˇ"); + cx.update_editor(|view, cx| view.handle_input("[", cx)); + cx.assert_editor_state("[ˇ"); + + // Surround with brackets if text is selected + cx.set_state("«aˇ» b"); + cx.update_editor(|view, cx| view.handle_input("{", cx)); + cx.assert_editor_state("{«aˇ»} b"); +} + +#[gpui::test] +async fn test_autoclose_with_embedded_language(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + let html_language = Arc::new( + Language::new( + LanguageConfig { + name: "HTML".into(), + brackets: vec![ + BracketPair { + start: "<".into(), + end: ">".into(), + close: true, + ..Default::default() + }, + BracketPair { + start: "{".into(), + end: "}".into(), + close: true, + ..Default::default() + }, + BracketPair { + start: "(".into(), + end: ")".into(), + close: true, + ..Default::default() + }, + ], + autoclose_before: "})]>".into(), + ..Default::default() + }, + Some(tree_sitter_html::language()), + ) + .with_injection_query( + r#" + (script_element + (raw_text) @content + (#set! "language" "javascript")) + "#, + ) + .unwrap(), + ); + + let javascript_language = Arc::new(Language::new( + LanguageConfig { + name: "JavaScript".into(), + brackets: vec![ + BracketPair { + start: "/*".into(), + end: " */".into(), + close: true, + ..Default::default() + }, + BracketPair { + start: "{".into(), + end: "}".into(), + close: true, + ..Default::default() + }, + BracketPair { + start: "(".into(), + end: ")".into(), + close: true, + ..Default::default() + }, + ], + autoclose_before: "})]>".into(), + ..Default::default() + }, + Some(tree_sitter_javascript::language()), + )); + + let registry = Arc::new(LanguageRegistry::test()); + registry.add(html_language.clone()); + registry.add(javascript_language.clone()); + + cx.update_buffer(|buffer, cx| { + buffer.set_language_registry(registry); + buffer.set_language(Some(html_language), cx); + }); + + cx.set_state( + &r#" + ˇ + + ˇ + "# + .unindent(), + ); + + // Precondition: different languages are active at different locations. + cx.update_editor(|editor, cx| { + let snapshot = editor.snapshot(cx); + let cursors = editor.selections.ranges::(cx); + let languages = cursors + .iter() + .map(|c| snapshot.language_at(c.start).unwrap().name()) + .collect::>(); + assert_eq!( + languages, + &["HTML".into(), "JavaScript".into(), "HTML".into()] + ); + }); + + // Angle brackets autoclose in HTML, but not JavaScript. + cx.update_editor(|editor, cx| { + editor.handle_input("<", cx); + editor.handle_input("a", cx); + }); + cx.assert_editor_state( + &r#" + + + + "# + .unindent(), + ); + + // Curly braces and parens autoclose in both HTML and JavaScript. + cx.update_editor(|editor, cx| { + editor.handle_input(" b=", cx); + editor.handle_input("{", cx); + editor.handle_input("c", cx); + editor.handle_input("(", cx); + }); + cx.assert_editor_state( + &r#" + + + + "# + .unindent(), + ); + + // Brackets that were already autoclosed are skipped. + cx.update_editor(|editor, cx| { + editor.handle_input(")", cx); + editor.handle_input("d", cx); + editor.handle_input("}", cx); + }); + cx.assert_editor_state( + &r#" + + + + "# + .unindent(), + ); + cx.update_editor(|editor, cx| { + editor.handle_input(">", cx); + }); + cx.assert_editor_state( + &r#" + ˇ + + ˇ + "# + .unindent(), + ); + + // Reset + cx.set_state( + &r#" + ˇ + + ˇ + "# + .unindent(), + ); + + cx.update_editor(|editor, cx| { + editor.handle_input("<", cx); + }); + cx.assert_editor_state( + &r#" + <ˇ> + + <ˇ> + "# + .unindent(), + ); + + // When backspacing, the closing angle brackets are removed. + cx.update_editor(|editor, cx| { + editor.backspace(&Backspace, cx); + }); + cx.assert_editor_state( + &r#" + ˇ + + ˇ + "# + .unindent(), + ); + + // Block comments autoclose in JavaScript, but not HTML. + cx.update_editor(|editor, cx| { + editor.handle_input("/", cx); + editor.handle_input("*", cx); + }); + cx.assert_editor_state( + &r#" + /*ˇ + + /*ˇ + "# + .unindent(), + ); +} + +#[gpui::test] +async fn test_surround_with_pair(cx: &mut gpui::TestAppContext) { + cx.update(|cx| cx.set_global(Settings::test(cx))); + let language = Arc::new(Language::new( + LanguageConfig { + brackets: vec![BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + newline: true, + }], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + )); + + let text = r#" + a + b + c + "# + .unindent(); + + let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx)); + let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); + let (_, view) = cx.add_window(|cx| build_editor(buffer, cx)); + view.condition(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) + .await; + + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1), + DisplayPoint::new(1, 0)..DisplayPoint::new(1, 1), + DisplayPoint::new(2, 0)..DisplayPoint::new(2, 1), + ]) + }); + + view.handle_input("{", cx); + view.handle_input("{", cx); + view.handle_input("{", cx); + assert_eq!( + view.text(cx), + " + {{{a}}} + {{{b}}} + {{{c}}} + " + .unindent() + ); + assert_eq!( + view.selections.display_ranges(cx), + [ + DisplayPoint::new(0, 3)..DisplayPoint::new(0, 4), + DisplayPoint::new(1, 3)..DisplayPoint::new(1, 4), + DisplayPoint::new(2, 3)..DisplayPoint::new(2, 4) + ] + ); + + view.undo(&Undo, cx); + assert_eq!( + view.text(cx), + " + a + b + c + " + .unindent() + ); + assert_eq!( + view.selections.display_ranges(cx), + [ + DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1), + DisplayPoint::new(1, 0)..DisplayPoint::new(1, 1), + DisplayPoint::new(2, 0)..DisplayPoint::new(2, 1) + ] + ); + }); +} + +#[gpui::test] +async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) { + cx.update(|cx| cx.set_global(Settings::test(cx))); + let language = Arc::new(Language::new( + LanguageConfig { + brackets: vec![BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + newline: true, + }], + autoclose_before: "}".to_string(), + ..Default::default() + }, + Some(tree_sitter_rust::language()), + )); + + let text = r#" + a + b + c + "# + .unindent(); + + let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx)); + let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); + let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx)); + editor + .condition(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_ranges([ + Point::new(0, 1)..Point::new(0, 1), + Point::new(1, 1)..Point::new(1, 1), + Point::new(2, 1)..Point::new(2, 1), + ]) + }); + + editor.handle_input("{", cx); + editor.handle_input("{", cx); + editor.handle_input("_", cx); + assert_eq!( + editor.text(cx), + " + a{{_}} + b{{_}} + c{{_}} + " + .unindent() + ); + assert_eq!( + editor.selections.ranges::(cx), + [ + Point::new(0, 4)..Point::new(0, 4), + Point::new(1, 4)..Point::new(1, 4), + Point::new(2, 4)..Point::new(2, 4) + ] + ); + + editor.backspace(&Default::default(), cx); + editor.backspace(&Default::default(), cx); + assert_eq!( + editor.text(cx), + " + a{} + b{} + c{} + " + .unindent() + ); + assert_eq!( + editor.selections.ranges::(cx), + [ + Point::new(0, 2)..Point::new(0, 2), + Point::new(1, 2)..Point::new(1, 2), + Point::new(2, 2)..Point::new(2, 2) + ] + ); + + editor.delete_to_previous_word_start(&Default::default(), cx); + assert_eq!( + editor.text(cx), + " + a + b + c + " + .unindent() + ); + assert_eq!( + editor.selections.ranges::(cx), + [ + Point::new(0, 1)..Point::new(0, 1), + Point::new(1, 1)..Point::new(1, 1), + Point::new(2, 1)..Point::new(2, 1) + ] + ); + }); +} + +#[gpui::test] +async fn test_snippets(cx: &mut gpui::TestAppContext) { + cx.update(|cx| cx.set_global(Settings::test(cx))); + + let (text, insertion_ranges) = marked_text_ranges( + indoc! {" + a.ˇ b + a.ˇ b + a.ˇ b + "}, + false, + ); + + let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); + let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx)); + + editor.update(cx, |editor, cx| { + let snippet = Snippet::parse("f(${1:one}, ${2:two}, ${1:three})$0").unwrap(); + + editor + .insert_snippet(&insertion_ranges, snippet, cx) + .unwrap(); + + fn assert(editor: &mut Editor, cx: &mut ViewContext, marked_text: &str) { + let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false); + assert_eq!(editor.text(cx), expected_text); + assert_eq!(editor.selections.ranges::(cx), selection_ranges); + } + + assert( + editor, + cx, + indoc! {" + a.f(«one», two, «three») b + a.f(«one», two, «three») b + a.f(«one», two, «three») b + "}, + ); + + // Can't move earlier than the first tab stop + assert!(!editor.move_to_prev_snippet_tabstop(cx)); + assert( + editor, + cx, + indoc! {" + a.f(«one», two, «three») b + a.f(«one», two, «three») b + a.f(«one», two, «three») b + "}, + ); + + assert!(editor.move_to_next_snippet_tabstop(cx)); + assert( + editor, + cx, + indoc! {" + a.f(one, «two», three) b + a.f(one, «two», three) b + a.f(one, «two», three) b + "}, + ); + + editor.move_to_prev_snippet_tabstop(cx); + assert( + editor, + cx, + indoc! {" + a.f(«one», two, «three») b + a.f(«one», two, «three») b + a.f(«one», two, «three») b + "}, + ); + + assert!(editor.move_to_next_snippet_tabstop(cx)); + assert( + editor, + cx, + indoc! {" + a.f(one, «two», three) b + a.f(one, «two», three) b + a.f(one, «two», three) b + "}, + ); + assert!(editor.move_to_next_snippet_tabstop(cx)); + assert( + editor, + cx, + indoc! {" + a.f(one, two, three)ˇ b + a.f(one, two, three)ˇ b + a.f(one, two, three)ˇ b + "}, + ); + + // As soon as the last tab stop is reached, snippet state is gone + editor.move_to_prev_snippet_tabstop(cx); + assert( + editor, + cx, + indoc! {" + a.f(one, two, three)ˇ b + a.f(one, two, three)ˇ b + a.f(one, two, three)ˇ b + "}, + ); + }); +} + +#[gpui::test] +async fn test_document_format_during_save(cx: &mut gpui::TestAppContext) { + cx.foreground().forbid_parking(); + + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_servers = language + .set_fake_lsp_adapter(Arc::new(FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + ..Default::default() + })) + .await; + + let fs = FakeFs::new(cx.background()); + fs.insert_file("/file.rs", Default::default()).await; + + let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; + project.update(cx, |project, _| project.languages().add(Arc::new(language))); + let buffer = project + .update(cx, |project, cx| project.open_local_buffer("/file.rs", cx)) + .await + .unwrap(); + + cx.foreground().start_waiting(); + let fake_server = fake_servers.next().await.unwrap(); + + let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); + let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx)); + editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx)); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let save = cx.update(|cx| editor.save(project.clone(), cx)); + fake_server + .handle_request::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Url::from_file_path("/file.rs").unwrap() + ); + assert_eq!(params.options.tab_size, 4); + Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), + ", ".to_string(), + )])) + }) + .next() + .await; + cx.foreground().start_waiting(); + save.await.unwrap(); + assert_eq!( + editor.read_with(cx, |editor, cx| editor.text(cx)), + "one, two\nthree\n" + ); + assert!(!cx.read(|cx| editor.is_dirty(cx))); + + editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx)); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + // Ensure we can still save even if formatting hangs. + fake_server.handle_request::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Url::from_file_path("/file.rs").unwrap() + ); + futures::future::pending::<()>().await; + unreachable!() + }); + let save = cx.update(|cx| editor.save(project.clone(), cx)); + cx.foreground().advance_clock(super::FORMAT_TIMEOUT); + cx.foreground().start_waiting(); + save.await.unwrap(); + assert_eq!( + editor.read_with(cx, |editor, cx| editor.text(cx)), + "one\ntwo\nthree\n" + ); + assert!(!cx.read(|cx| editor.is_dirty(cx))); + + // Set rust language override and assert overriden tabsize is sent to language server + cx.update(|cx| { + cx.update_global::(|settings, _| { + settings.language_overrides.insert( + "Rust".into(), + EditorSettings { + tab_size: Some(8.try_into().unwrap()), + ..Default::default() + }, + ); + }) + }); + + let save = cx.update(|cx| editor.save(project.clone(), cx)); + fake_server + .handle_request::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Url::from_file_path("/file.rs").unwrap() + ); + assert_eq!(params.options.tab_size, 8); + Ok(Some(vec![])) + }) + .next() + .await; + cx.foreground().start_waiting(); + save.await.unwrap(); +} + +#[gpui::test] +async fn test_range_format_during_save(cx: &mut gpui::TestAppContext) { + cx.foreground().forbid_parking(); + + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_servers = language + .set_fake_lsp_adapter(Arc::new(FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_range_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + ..Default::default() + })) + .await; + + let fs = FakeFs::new(cx.background()); + fs.insert_file("/file.rs", Default::default()).await; + + let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; + project.update(cx, |project, _| project.languages().add(Arc::new(language))); + let buffer = project + .update(cx, |project, cx| project.open_local_buffer("/file.rs", cx)) + .await + .unwrap(); + + cx.foreground().start_waiting(); + let fake_server = fake_servers.next().await.unwrap(); + + let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); + let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx)); + editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx)); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let save = cx.update(|cx| editor.save(project.clone(), cx)); + fake_server + .handle_request::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Url::from_file_path("/file.rs").unwrap() + ); + assert_eq!(params.options.tab_size, 4); + Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), + ", ".to_string(), + )])) + }) + .next() + .await; + cx.foreground().start_waiting(); + save.await.unwrap(); + assert_eq!( + editor.read_with(cx, |editor, cx| editor.text(cx)), + "one, two\nthree\n" + ); + assert!(!cx.read(|cx| editor.is_dirty(cx))); + + editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx)); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + // Ensure we can still save even if formatting hangs. + fake_server.handle_request::( + move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Url::from_file_path("/file.rs").unwrap() + ); + futures::future::pending::<()>().await; + unreachable!() + }, + ); + let save = cx.update(|cx| editor.save(project.clone(), cx)); + cx.foreground().advance_clock(super::FORMAT_TIMEOUT); + cx.foreground().start_waiting(); + save.await.unwrap(); + assert_eq!( + editor.read_with(cx, |editor, cx| editor.text(cx)), + "one\ntwo\nthree\n" + ); + assert!(!cx.read(|cx| editor.is_dirty(cx))); + + // Set rust language override and assert overriden tabsize is sent to language server + cx.update(|cx| { + cx.update_global::(|settings, _| { + settings.language_overrides.insert( + "Rust".into(), + EditorSettings { + tab_size: Some(8.try_into().unwrap()), + ..Default::default() + }, + ); + }) + }); + + let save = cx.update(|cx| editor.save(project.clone(), cx)); + fake_server + .handle_request::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Url::from_file_path("/file.rs").unwrap() + ); + assert_eq!(params.options.tab_size, 8); + Ok(Some(vec![])) + }) + .next() + .await; + cx.foreground().start_waiting(); + save.await.unwrap(); +} + +#[gpui::test] +async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) { + cx.foreground().forbid_parking(); + + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_servers = language + .set_fake_lsp_adapter(Arc::new(FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + ..Default::default() + })) + .await; + + let fs = FakeFs::new(cx.background()); + fs.insert_file("/file.rs", Default::default()).await; + + let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; + project.update(cx, |project, _| project.languages().add(Arc::new(language))); + let buffer = project + .update(cx, |project, cx| project.open_local_buffer("/file.rs", cx)) + .await + .unwrap(); + + cx.foreground().start_waiting(); + let fake_server = fake_servers.next().await.unwrap(); + + let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); + let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx)); + editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx)); + + let format = editor.update(cx, |editor, cx| editor.perform_format(project.clone(), cx)); + fake_server + .handle_request::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Url::from_file_path("/file.rs").unwrap() + ); + assert_eq!(params.options.tab_size, 4); + Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), + ", ".to_string(), + )])) + }) + .next() + .await; + cx.foreground().start_waiting(); + format.await.unwrap(); + assert_eq!( + editor.read_with(cx, |editor, cx| editor.text(cx)), + "one, two\nthree\n" + ); + + editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx)); + // Ensure we don't lock if formatting hangs. + fake_server.handle_request::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Url::from_file_path("/file.rs").unwrap() + ); + futures::future::pending::<()>().await; + unreachable!() + }); + let format = editor.update(cx, |editor, cx| editor.perform_format(project, cx)); + cx.foreground().advance_clock(super::FORMAT_TIMEOUT); + cx.foreground().start_waiting(); + format.await.unwrap(); + assert_eq!( + editor.read_with(cx, |editor, cx| editor.text(cx)), + "one\ntwo\nthree\n" + ); +} + +#[gpui::test] +async fn test_concurrent_format_requests(cx: &mut gpui::TestAppContext) { + cx.foreground().forbid_parking(); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {" + one.twoˇ + "}); + + // The format request takes a long time. When it completes, it inserts + // a newline and an indent before the `.` + cx.lsp + .handle_request::(move |_, cx| { + let executor = cx.background(); + async move { + executor.timer(Duration::from_millis(100)).await; + Ok(Some(vec![lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 3)), + new_text: "\n ".into(), + }])) + } + }); + + // Submit a format request. + let format_1 = cx + .update_editor(|editor, cx| editor.format(&Format, cx)) + .unwrap(); + cx.foreground().run_until_parked(); + + // Submit a second format request. + let format_2 = cx + .update_editor(|editor, cx| editor.format(&Format, cx)) + .unwrap(); + cx.foreground().run_until_parked(); + + // Wait for both format requests to complete + cx.foreground().advance_clock(Duration::from_millis(200)); + cx.foreground().start_waiting(); + format_1.await.unwrap(); + cx.foreground().start_waiting(); + format_2.await.unwrap(); + + // The formatting edits only happens once. + cx.assert_editor_state(indoc! {" + one + .twoˇ + "}); +} + +#[gpui::test] +async fn test_completion(cx: &mut gpui::TestAppContext) { + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {" + oneˇ + two + three + "}); + cx.simulate_keystroke("."); + handle_completion_request( + &mut cx, + indoc! {" + one.|<> + two + three + "}, + vec!["first_completion", "second_completion"], + ) + .await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + let apply_additional_edits = cx.update_editor(|editor, cx| { + editor.move_down(&MoveDown, cx); + editor + .confirm_completion(&ConfirmCompletion::default(), cx) + .unwrap() + }); + cx.assert_editor_state(indoc! {" + one.second_completionˇ + two + three + "}); + + handle_resolve_completion_request( + &mut cx, + Some(( + indoc! {" + one.second_completion + two + threeˇ + "}, + "\nadditional edit", + )), + ) + .await; + apply_additional_edits.await.unwrap(); + cx.assert_editor_state(indoc! {" + one.second_completionˇ + two + three + additional edit + "}); + + cx.set_state(indoc! {" + one.second_completion + twoˇ + threeˇ + additional edit + "}); + cx.simulate_keystroke(" "); + assert!(cx.editor(|e, _| e.context_menu.is_none())); + cx.simulate_keystroke("s"); + assert!(cx.editor(|e, _| e.context_menu.is_none())); + + cx.assert_editor_state(indoc! {" + one.second_completion + two sˇ + three sˇ + additional edit + "}); + handle_completion_request( + &mut cx, + indoc! {" + one.second_completion + two s + three + additional edit + "}, + vec!["fourth_completion", "fifth_completion", "sixth_completion"], + ) + .await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + + cx.simulate_keystroke("i"); + + handle_completion_request( + &mut cx, + indoc! {" + one.second_completion + two si + three + additional edit + "}, + vec!["fourth_completion", "fifth_completion", "sixth_completion"], + ) + .await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + + let apply_additional_edits = cx.update_editor(|editor, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), cx) + .unwrap() + }); + cx.assert_editor_state(indoc! {" + one.second_completion + two sixth_completionˇ + three sixth_completionˇ + additional edit + "}); + + handle_resolve_completion_request(&mut cx, None).await; + apply_additional_edits.await.unwrap(); + + cx.update(|cx| { + cx.update_global::(|settings, _| { + settings.show_completions_on_input = false; + }) + }); + cx.set_state("editorˇ"); + cx.simulate_keystroke("."); + assert!(cx.editor(|e, _| e.context_menu.is_none())); + cx.simulate_keystroke("c"); + cx.simulate_keystroke("l"); + cx.simulate_keystroke("o"); + cx.assert_editor_state("editor.cloˇ"); + assert!(cx.editor(|e, _| e.context_menu.is_none())); + cx.update_editor(|editor, cx| { + editor.show_completions(&ShowCompletions, cx); + }); + handle_completion_request(&mut cx, "editor.", vec!["close", "clobber"]).await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + let apply_additional_edits = cx.update_editor(|editor, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), cx) + .unwrap() + }); + cx.assert_editor_state("editor.closeˇ"); + handle_resolve_completion_request(&mut cx, None).await; + apply_additional_edits.await.unwrap(); + + // Handle completion request passing a marked string specifying where the completion + // should be triggered from using '|' character, what range should be replaced, and what completions + // should be returned using '<' and '>' to delimit the range + async fn handle_completion_request<'a>( + cx: &mut EditorLspTestContext<'a>, + marked_string: &str, + completions: Vec<&'static str>, + ) { + let complete_from_marker: TextRangeMarker = '|'.into(); + let replace_range_marker: TextRangeMarker = ('<', '>').into(); + let (_, mut marked_ranges) = marked_text_ranges_by( + marked_string, + vec![complete_from_marker.clone(), replace_range_marker.clone()], + ); + + let complete_from_position = + cx.to_lsp(marked_ranges.remove(&complete_from_marker).unwrap()[0].start); + let replace_range = + cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone()); + + cx.handle_request::(move |url, params, _| { + let completions = completions.clone(); + async move { + assert_eq!(params.text_document_position.text_document.uri, url.clone()); + assert_eq!( + params.text_document_position.position, + complete_from_position + ); + Ok(Some(lsp::CompletionResponse::Array( + completions + .iter() + .map(|completion_text| lsp::CompletionItem { + label: completion_text.to_string(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: replace_range, + new_text: completion_text.to_string(), + })), + ..Default::default() + }) + .collect(), + ))) + } + }) + .next() + .await; + } + + async fn handle_resolve_completion_request<'a>( + cx: &mut EditorLspTestContext<'a>, + edit: Option<(&'static str, &'static str)>, + ) { + let edit = edit.map(|(marked_string, new_text)| { + let (_, marked_ranges) = marked_text_ranges(marked_string, false); + let replace_range = cx.to_lsp_range(marked_ranges[0].clone()); + vec![lsp::TextEdit::new(replace_range, new_text.to_string())] + }); + + cx.handle_request::(move |_, _, _| { + let edit = edit.clone(); + async move { + Ok(lsp::CompletionItem { + additional_text_edits: edit, + ..Default::default() + }) + } + }) + .next() + .await; + } +} + +#[gpui::test] +async fn test_toggle_comment(cx: &mut gpui::TestAppContext) { + cx.update(|cx| cx.set_global(Settings::test(cx))); + let language = Arc::new(Language::new( + LanguageConfig { + line_comment: Some("// ".into()), + ..Default::default() + }, + Some(tree_sitter_rust::language()), + )); + + let text = " + fn a() { + //b(); + // c(); + // d(); + } + " + .unindent(); + + let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx)); + let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); + let (_, view) = cx.add_window(|cx| build_editor(buffer, cx)); + + view.update(cx, |editor, cx| { + // If multiple selections intersect a line, the line is only + // toggled once. + editor.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(1, 3)..DisplayPoint::new(2, 3), + DisplayPoint::new(3, 5)..DisplayPoint::new(3, 6), + ]) + }); + editor.toggle_comments(&ToggleComments, cx); + assert_eq!( + editor.text(cx), + " + fn a() { + b(); + c(); + d(); + } + " + .unindent() + ); + + // The comment prefix is inserted at the same column for every line + // in a selection. + editor.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(1, 3)..DisplayPoint::new(3, 6)]) + }); + editor.toggle_comments(&ToggleComments, cx); + assert_eq!( + editor.text(cx), + " + fn a() { + // b(); + // c(); + // d(); + } + " + .unindent() + ); + + // If a selection ends at the beginning of a line, that line is not toggled. + editor.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(2, 0)..DisplayPoint::new(3, 0)]) + }); + editor.toggle_comments(&ToggleComments, cx); + assert_eq!( + editor.text(cx), + " + fn a() { + // b(); + c(); + // d(); + } + " + .unindent() + ); + }); +} + +#[gpui::test] +async fn test_toggle_block_comment(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx); + + let html_language = Arc::new( + Language::new( + LanguageConfig { + name: "HTML".into(), + block_comment: Some(("".into())), + ..Default::default() + }, + Some(tree_sitter_html::language()), + ) + .with_injection_query( + r#" + (script_element + (raw_text) @content + (#set! "language" "javascript")) + "#, + ) + .unwrap(), + ); + + let javascript_language = Arc::new(Language::new( + LanguageConfig { + name: "JavaScript".into(), + line_comment: Some("// ".into()), + ..Default::default() + }, + Some(tree_sitter_javascript::language()), + )); + + let registry = Arc::new(LanguageRegistry::test()); + registry.add(html_language.clone()); + registry.add(javascript_language.clone()); + + cx.update_buffer(|buffer, cx| { + buffer.set_language_registry(registry); + buffer.set_language(Some(html_language), cx); + }); + + // Toggle comments for empty selections + cx.set_state( + &r#" +

A

ˇ +

B

ˇ +

C

ˇ + "# + .unindent(), + ); + cx.update_editor(|editor, cx| editor.toggle_comments(&ToggleComments, cx)); + cx.assert_editor_state( + &r#" + + + + "# + .unindent(), + ); + cx.update_editor(|editor, cx| editor.toggle_comments(&ToggleComments, cx)); + cx.assert_editor_state( + &r#" +

A

ˇ +

B

ˇ +

C

ˇ + "# + .unindent(), + ); + + // Toggle comments for mixture of empty and non-empty selections, where + // multiple selections occupy a given line. + cx.set_state( + &r#" +

+

ˇ»B

ˇ +

+

ˇ»D

ˇ + "# + .unindent(), + ); + + cx.update_editor(|editor, cx| editor.toggle_comments(&ToggleComments, cx)); + cx.assert_editor_state( + &r#" + + + "# + .unindent(), + ); + cx.update_editor(|editor, cx| editor.toggle_comments(&ToggleComments, cx)); + cx.assert_editor_state( + &r#" +

+

ˇ»B

ˇ +

+

ˇ»D

ˇ + "# + .unindent(), + ); + + // Toggle comments when different languages are active for different + // selections. + cx.set_state( + &r#" + ˇ + "# + .unindent(), + ); + cx.foreground().run_until_parked(); + cx.update_editor(|editor, cx| editor.toggle_comments(&ToggleComments, cx)); + cx.assert_editor_state( + &r#" + + // ˇvar x = new Y(); + + "# + .unindent(), + ); +} + +#[gpui::test] +fn test_editing_disjoint_excerpts(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(3, 4, 'a'), cx)); + let multibuffer = cx.add_model(|cx| { + let mut multibuffer = MultiBuffer::new(0); + multibuffer.push_excerpts( + buffer.clone(), + [ + ExcerptRange { + context: Point::new(0, 0)..Point::new(0, 4), + primary: None, + }, + ExcerptRange { + context: Point::new(1, 0)..Point::new(1, 4), + primary: None, + }, + ], + cx, + ); + multibuffer + }); + + assert_eq!(multibuffer.read(cx).read(cx).text(), "aaaa\nbbbb"); + + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(multibuffer, cx)); + view.update(cx, |view, cx| { + assert_eq!(view.text(cx), "aaaa\nbbbb"); + view.change_selections(None, cx, |s| { + s.select_ranges([ + Point::new(0, 0)..Point::new(0, 0), + Point::new(1, 0)..Point::new(1, 0), + ]) + }); + + view.handle_input("X", cx); + assert_eq!(view.text(cx), "Xaaaa\nXbbbb"); + assert_eq!( + view.selections.ranges(cx), + [ + Point::new(0, 1)..Point::new(0, 1), + Point::new(1, 1)..Point::new(1, 1), + ] + ) + }); +} + +#[gpui::test] +fn test_editing_overlapping_excerpts(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let markers = vec![('[', ']').into(), ('(', ')').into()]; + let (initial_text, mut excerpt_ranges) = marked_text_ranges_by( + indoc! {" + [aaaa + (bbbb] + cccc)", + }, + markers.clone(), + ); + let excerpt_ranges = markers.into_iter().map(|marker| { + let context = excerpt_ranges.remove(&marker).unwrap()[0].clone(); + ExcerptRange { + context, + primary: None, + } + }); + let buffer = cx.add_model(|cx| Buffer::new(0, initial_text, cx)); + let multibuffer = cx.add_model(|cx| { + let mut multibuffer = MultiBuffer::new(0); + multibuffer.push_excerpts(buffer, excerpt_ranges, cx); + multibuffer + }); + + let (_, view) = cx.add_window(Default::default(), |cx| build_editor(multibuffer, cx)); + view.update(cx, |view, cx| { + let (expected_text, selection_ranges) = marked_text_ranges( + indoc! {" + aaaa + bˇbbb + bˇbbˇb + cccc" + }, + true, + ); + assert_eq!(view.text(cx), expected_text); + view.change_selections(None, cx, |s| s.select_ranges(selection_ranges)); + + view.handle_input("X", cx); + + let (expected_text, expected_selections) = marked_text_ranges( + indoc! {" + aaaa + bXˇbbXb + bXˇbbXˇb + cccc" + }, + false, + ); + assert_eq!(view.text(cx), expected_text); + assert_eq!(view.selections.ranges(cx), expected_selections); + + view.newline(&Newline, cx); + let (expected_text, expected_selections) = marked_text_ranges( + indoc! {" + aaaa + bX + ˇbbX + b + bX + ˇbbX + ˇb + cccc" + }, + false, + ); + assert_eq!(view.text(cx), expected_text); + assert_eq!(view.selections.ranges(cx), expected_selections); + }); +} + +#[gpui::test] +fn test_refresh_selections(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(3, 4, 'a'), cx)); + let mut excerpt1_id = None; + let multibuffer = cx.add_model(|cx| { + let mut multibuffer = MultiBuffer::new(0); + excerpt1_id = multibuffer + .push_excerpts( + buffer.clone(), + [ + ExcerptRange { + context: Point::new(0, 0)..Point::new(1, 4), + primary: None, + }, + ExcerptRange { + context: Point::new(1, 0)..Point::new(2, 4), + primary: None, + }, + ], + cx, + ) + .into_iter() + .next(); + multibuffer + }); + assert_eq!( + multibuffer.read(cx).read(cx).text(), + "aaaa\nbbbb\nbbbb\ncccc" + ); + let (_, editor) = cx.add_window(Default::default(), |cx| { + let mut editor = build_editor(multibuffer.clone(), cx); + let snapshot = editor.snapshot(cx); + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(1, 3)..Point::new(1, 3)]) + }); + editor.begin_selection(Point::new(2, 1).to_display_point(&snapshot), true, 1, cx); + assert_eq!( + editor.selections.ranges(cx), + [ + Point::new(1, 3)..Point::new(1, 3), + Point::new(2, 1)..Point::new(2, 1), + ] + ); + editor + }); + + // Refreshing selections is a no-op when excerpts haven't changed. + editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.refresh(); + }); + assert_eq!( + editor.selections.ranges(cx), + [ + Point::new(1, 3)..Point::new(1, 3), + Point::new(2, 1)..Point::new(2, 1), + ] + ); + }); + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.remove_excerpts([&excerpt1_id.unwrap()], cx); + }); + editor.update(cx, |editor, cx| { + // Removing an excerpt causes the first selection to become degenerate. + assert_eq!( + editor.selections.ranges(cx), + [ + Point::new(0, 0)..Point::new(0, 0), + Point::new(0, 1)..Point::new(0, 1) + ] + ); + + // Refreshing selections will relocate the first selection to the original buffer + // location. + editor.change_selections(None, cx, |s| { + s.refresh(); + }); + assert_eq!( + editor.selections.ranges(cx), + [ + Point::new(0, 1)..Point::new(0, 1), + Point::new(0, 3)..Point::new(0, 3) + ] + ); + assert!(editor.selections.pending_anchor().is_some()); + }); +} + +#[gpui::test] +fn test_refresh_selections_while_selecting_with_mouse(cx: &mut gpui::MutableAppContext) { + cx.set_global(Settings::test(cx)); + let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(3, 4, 'a'), cx)); + let mut excerpt1_id = None; + let multibuffer = cx.add_model(|cx| { + let mut multibuffer = MultiBuffer::new(0); + excerpt1_id = multibuffer + .push_excerpts( + buffer.clone(), + [ + ExcerptRange { + context: Point::new(0, 0)..Point::new(1, 4), + primary: None, + }, + ExcerptRange { + context: Point::new(1, 0)..Point::new(2, 4), + primary: None, + }, + ], + cx, + ) + .into_iter() + .next(); + multibuffer + }); + assert_eq!( + multibuffer.read(cx).read(cx).text(), + "aaaa\nbbbb\nbbbb\ncccc" + ); + let (_, editor) = cx.add_window(Default::default(), |cx| { + let mut editor = build_editor(multibuffer.clone(), cx); + let snapshot = editor.snapshot(cx); + editor.begin_selection(Point::new(1, 3).to_display_point(&snapshot), false, 1, cx); + assert_eq!( + editor.selections.ranges(cx), + [Point::new(1, 3)..Point::new(1, 3)] + ); + editor + }); + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.remove_excerpts([&excerpt1_id.unwrap()], cx); + }); + editor.update(cx, |editor, cx| { + assert_eq!( + editor.selections.ranges(cx), + [Point::new(0, 0)..Point::new(0, 0)] + ); + + // Ensure we don't panic when selections are refreshed and that the pending selection is finalized. + editor.change_selections(None, cx, |s| { + s.refresh(); + }); + assert_eq!( + editor.selections.ranges(cx), + [Point::new(0, 3)..Point::new(0, 3)] + ); + assert!(editor.selections.pending_anchor().is_some()); + }); +} + +#[gpui::test] +async fn test_extra_newline_insertion(cx: &mut gpui::TestAppContext) { + cx.update(|cx| cx.set_global(Settings::test(cx))); + let language = Arc::new( + Language::new( + LanguageConfig { + brackets: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + newline: true, + }, + BracketPair { + start: "/* ".to_string(), + end: " */".to_string(), + close: true, + newline: true, + }, + ], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ) + .with_indents_query("") + .unwrap(), + ); + + let text = concat!( + "{ }\n", // + " x\n", // + " /* */\n", // + "x\n", // + "{{} }\n", // + ); + + let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx)); + let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); + let (_, view) = cx.add_window(|cx| build_editor(buffer, cx)); + view.condition(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) + .await; + + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(0, 2)..DisplayPoint::new(0, 3), + DisplayPoint::new(2, 5)..DisplayPoint::new(2, 5), + DisplayPoint::new(4, 4)..DisplayPoint::new(4, 4), + ]) + }); + view.newline(&Newline, cx); + + assert_eq!( + view.buffer().read(cx).read(cx).text(), + concat!( + "{ \n", // Suppress rustfmt + "\n", // + "}\n", // + " x\n", // + " /* \n", // + " \n", // + " */\n", // + "x\n", // + "{{} \n", // + "}\n", // + ) + ); + }); +} + +#[gpui::test] +fn test_highlighted_ranges(cx: &mut gpui::MutableAppContext) { + let buffer = MultiBuffer::build_simple(&sample_text(16, 8, 'a'), cx); + + cx.set_global(Settings::test(cx)); + let (_, editor) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); + + editor.update(cx, |editor, cx| { + struct Type1; + struct Type2; + + let buffer = buffer.read(cx).snapshot(cx); + + let anchor_range = + |range: Range| buffer.anchor_after(range.start)..buffer.anchor_after(range.end); + + editor.highlight_background::( + vec![ + anchor_range(Point::new(2, 1)..Point::new(2, 3)), + anchor_range(Point::new(4, 2)..Point::new(4, 4)), + anchor_range(Point::new(6, 3)..Point::new(6, 5)), + anchor_range(Point::new(8, 4)..Point::new(8, 6)), + ], + |_| Color::red(), + cx, + ); + editor.highlight_background::( + vec![ + anchor_range(Point::new(3, 2)..Point::new(3, 5)), + anchor_range(Point::new(5, 3)..Point::new(5, 6)), + anchor_range(Point::new(7, 4)..Point::new(7, 7)), + anchor_range(Point::new(9, 5)..Point::new(9, 8)), + ], + |_| Color::green(), + cx, + ); + + let snapshot = editor.snapshot(cx); + let mut highlighted_ranges = editor.background_highlights_in_range( + anchor_range(Point::new(3, 4)..Point::new(7, 4)), + &snapshot, + cx.global::().theme.as_ref(), + ); + // Enforce a consistent ordering based on color without relying on the ordering of the + // highlight's `TypeId` which is non-deterministic. + highlighted_ranges.sort_unstable_by_key(|(_, color)| *color); + assert_eq!( + highlighted_ranges, + &[ + ( + DisplayPoint::new(3, 2)..DisplayPoint::new(3, 5), + Color::green(), + ), + ( + DisplayPoint::new(5, 3)..DisplayPoint::new(5, 6), + Color::green(), + ), + ( + DisplayPoint::new(4, 2)..DisplayPoint::new(4, 4), + Color::red(), + ), + ( + DisplayPoint::new(6, 3)..DisplayPoint::new(6, 5), + Color::red(), + ), + ] + ); + assert_eq!( + editor.background_highlights_in_range( + anchor_range(Point::new(5, 6)..Point::new(6, 4)), + &snapshot, + cx.global::().theme.as_ref(), + ), + &[( + DisplayPoint::new(6, 3)..DisplayPoint::new(6, 5), + Color::red(), + )] + ); + }); +} + +#[gpui::test] +fn test_following(cx: &mut gpui::MutableAppContext) { + let buffer = MultiBuffer::build_simple(&sample_text(16, 8, 'a'), cx); + + cx.set_global(Settings::test(cx)); + + let (_, leader) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); + let (_, follower) = cx.add_window( + WindowOptions { + bounds: WindowBounds::Fixed(RectF::from_points(vec2f(0., 0.), vec2f(10., 80.))), + ..Default::default() + }, + |cx| build_editor(buffer.clone(), cx), + ); + + let pending_update = Rc::new(RefCell::new(None)); + follower.update(cx, { + let update = pending_update.clone(); + |_, cx| { + cx.subscribe(&leader, move |_, leader, event, cx| { + leader + .read(cx) + .add_event_to_update_proto(event, &mut *update.borrow_mut(), cx); + }) + .detach(); + } + }); + + // Update the selections only + leader.update(cx, |leader, cx| { + leader.change_selections(None, cx, |s| s.select_ranges([1..1])); + }); + follower.update(cx, |follower, cx| { + follower + .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx) + .unwrap(); + }); + assert_eq!(follower.read(cx).selections.ranges(cx), vec![1..1]); + + // Update the scroll position only + leader.update(cx, |leader, cx| { + leader.set_scroll_position(vec2f(1.5, 3.5), cx); + }); + follower.update(cx, |follower, cx| { + follower + .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx) + .unwrap(); + }); + assert_eq!( + follower.update(cx, |follower, cx| follower.scroll_position(cx)), + vec2f(1.5, 3.5) + ); + + // Update the selections and scroll position + leader.update(cx, |leader, cx| { + leader.change_selections(None, cx, |s| s.select_ranges([0..0])); + leader.request_autoscroll(Autoscroll::Newest, cx); + leader.set_scroll_position(vec2f(1.5, 3.5), cx); + }); + follower.update(cx, |follower, cx| { + let initial_scroll_position = follower.scroll_position(cx); + follower + .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx) + .unwrap(); + assert_eq!(follower.scroll_position(cx), initial_scroll_position); + assert!(follower.autoscroll_request.is_some()); + }); + assert_eq!(follower.read(cx).selections.ranges(cx), vec![0..0]); + + // Creating a pending selection that precedes another selection + leader.update(cx, |leader, cx| { + leader.change_selections(None, cx, |s| s.select_ranges([1..1])); + leader.begin_selection(DisplayPoint::new(0, 0), true, 1, cx); + }); + follower.update(cx, |follower, cx| { + follower + .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx) + .unwrap(); + }); + assert_eq!(follower.read(cx).selections.ranges(cx), vec![0..0, 1..1]); + + // Extend the pending selection so that it surrounds another selection + leader.update(cx, |leader, cx| { + leader.extend_selection(DisplayPoint::new(0, 2), 1, cx); + }); + follower.update(cx, |follower, cx| { + follower + .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx) + .unwrap(); + }); + assert_eq!(follower.read(cx).selections.ranges(cx), vec![0..2]); +} + +#[test] +fn test_combine_syntax_and_fuzzy_match_highlights() { + let string = "abcdefghijklmnop"; + let syntax_ranges = [ + ( + 0..3, + HighlightStyle { + color: Some(Color::red()), + ..Default::default() + }, + ), + ( + 4..8, + HighlightStyle { + color: Some(Color::green()), + ..Default::default() + }, + ), + ]; + let match_indices = [4, 6, 7, 8]; + assert_eq!( + combine_syntax_and_fuzzy_match_highlights( + string, + Default::default(), + syntax_ranges.into_iter(), + &match_indices, + ), + &[ + ( + 0..3, + HighlightStyle { + color: Some(Color::red()), + ..Default::default() + }, + ), + ( + 4..5, + HighlightStyle { + color: Some(Color::green()), + weight: Some(fonts::Weight::BOLD), + ..Default::default() + }, + ), + ( + 5..6, + HighlightStyle { + color: Some(Color::green()), + ..Default::default() + }, + ), + ( + 6..8, + HighlightStyle { + color: Some(Color::green()), + weight: Some(fonts::Weight::BOLD), + ..Default::default() + }, + ), + ( + 8..9, + HighlightStyle { + weight: Some(fonts::Weight::BOLD), + ..Default::default() + }, + ), + ] + ); +} + +fn empty_range(row: usize, column: usize) -> Range { + let point = DisplayPoint::new(row as u32, column as u32); + point..point +} + +fn assert_selection_ranges(marked_text: &str, view: &mut Editor, cx: &mut ViewContext) { + let (text, ranges) = marked_text_ranges(marked_text, true); + assert_eq!(view.text(cx), text); + assert_eq!( + view.selections.ranges(cx), + ranges, + "Assert selections are {}", + marked_text + ); +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 1e1ab83063..3e68c6766f 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -12,10 +12,11 @@ use crate::{ CmdShiftChanged, GoToFetchedDefinition, GoToFetchedTypeDefinition, UpdateGoToDefinitionLink, }, mouse_context_menu::DeployMouseContextMenu, - EditorStyle, + AnchorRangeExt, EditorStyle, }; use clock::ReplicaId; use collections::{BTreeMap, HashMap}; +use git::diff::DiffHunkStatus; use gpui::{ color::Color, elements::*, @@ -34,18 +35,25 @@ use gpui::{ WeakViewHandle, }; use json::json; -use language::{Bias, DiagnosticSeverity, OffsetUtf16, Selection}; +use language::{Bias, DiagnosticSeverity, OffsetUtf16, Point, Selection}; use project::ProjectPath; -use settings::Settings; +use settings::{GitGutter, Settings}; use smallvec::SmallVec; use std::{ cmp::{self, Ordering}, fmt::Write, iter, - ops::Range, + ops::{DerefMut, Range}, sync::Arc, }; +#[derive(Debug)] +struct DiffHunkLayout { + visual_range: Range, + status: DiffHunkStatus, + is_folded: bool, +} + struct SelectionLayout { head: DisplayPoint, range: Range, @@ -452,7 +460,6 @@ impl EditorElement { let bounds = gutter_bounds.union_rect(text_bounds); let scroll_top = layout.position_map.snapshot.scroll_position().y() * layout.position_map.line_height; - let editor = self.view(cx.app); cx.scene.push_quad(Quad { bounds: gutter_bounds, background: Some(self.style.gutter_background), @@ -466,7 +473,7 @@ impl EditorElement { corner_radius: 0., }); - if let EditorMode::Full = editor.mode { + if let EditorMode::Full = layout.mode { let mut active_rows = layout.active_rows.iter().peekable(); while let Some((start_row, contains_non_empty_selection)) = active_rows.next() { let mut end_row = *start_row; @@ -524,34 +531,120 @@ impl EditorElement { layout: &mut LayoutState, cx: &mut PaintContext, ) { - let scroll_top = - layout.position_map.snapshot.scroll_position().y() * layout.position_map.line_height; + let line_height = layout.position_map.line_height; + + let scroll_position = layout.position_map.snapshot.scroll_position(); + let scroll_top = scroll_position.y() * line_height; + + let show_gutter = matches!( + &cx.global::() + .git_overrides + .git_gutter + .unwrap_or_default(), + GitGutter::TrackedFiles + ); + + if show_gutter { + Self::paint_diff_hunks(bounds, layout, cx); + } + for (ix, line) in layout.line_number_layouts.iter().enumerate() { if let Some(line) = line { let line_origin = bounds.origin() + vec2f( bounds.width() - line.width() - layout.gutter_padding, - ix as f32 * layout.position_map.line_height - - (scroll_top % layout.position_map.line_height), + ix as f32 * line_height - (scroll_top % line_height), ); - line.paint( - line_origin, - visible_bounds, - layout.position_map.line_height, - cx, - ); + + line.paint(line_origin, visible_bounds, line_height, cx); } } if let Some((row, indicator)) = layout.code_actions_indicator.as_mut() { let mut x = bounds.width() - layout.gutter_padding; - let mut y = *row as f32 * layout.position_map.line_height - scroll_top; + let mut y = *row as f32 * line_height - scroll_top; x += ((layout.gutter_padding + layout.gutter_margin) - indicator.size().x()) / 2.; - y += (layout.position_map.line_height - indicator.size().y()) / 2.; + y += (line_height - indicator.size().y()) / 2.; indicator.paint(bounds.origin() + vec2f(x, y), visible_bounds, cx); } } + fn paint_diff_hunks(bounds: RectF, layout: &mut LayoutState, cx: &mut PaintContext) { + let diff_style = &cx.global::().theme.editor.diff.clone(); + let line_height = layout.position_map.line_height; + + let scroll_position = layout.position_map.snapshot.scroll_position(); + let scroll_top = scroll_position.y() * line_height; + + for hunk in &layout.hunk_layouts { + let color = match (hunk.status, hunk.is_folded) { + (DiffHunkStatus::Added, false) => diff_style.inserted, + (DiffHunkStatus::Modified, false) => diff_style.modified, + + //TODO: This rendering is entirely a horrible hack + (DiffHunkStatus::Removed, false) => { + let row = hunk.visual_range.start; + + let offset = line_height / 2.; + let start_y = row as f32 * line_height - offset - scroll_top; + let end_y = start_y + line_height; + + let width = diff_style.removed_width_em * line_height; + let highlight_origin = bounds.origin() + vec2f(-width, start_y); + let highlight_size = vec2f(width * 2., end_y - start_y); + let highlight_bounds = RectF::new(highlight_origin, highlight_size); + + cx.scene.push_quad(Quad { + bounds: highlight_bounds, + background: Some(diff_style.deleted), + border: Border::new(0., Color::transparent_black()), + corner_radius: 1. * line_height, + }); + + continue; + } + + (_, true) => { + let row = hunk.visual_range.start; + let start_y = row as f32 * line_height - scroll_top; + let end_y = start_y + line_height; + + let width = diff_style.removed_width_em * line_height; + let highlight_origin = bounds.origin() + vec2f(-width, start_y); + let highlight_size = vec2f(width * 2., end_y - start_y); + let highlight_bounds = RectF::new(highlight_origin, highlight_size); + + cx.scene.push_quad(Quad { + bounds: highlight_bounds, + background: Some(diff_style.modified), + border: Border::new(0., Color::transparent_black()), + corner_radius: 1. * line_height, + }); + + continue; + } + }; + + let start_row = hunk.visual_range.start; + let end_row = hunk.visual_range.end; + + let start_y = start_row as f32 * line_height - scroll_top; + let end_y = end_row as f32 * line_height - scroll_top; + + let width = diff_style.width_em * line_height; + let highlight_origin = bounds.origin() + vec2f(-width, start_y); + let highlight_size = vec2f(width * 2., end_y - start_y); + let highlight_bounds = RectF::new(highlight_origin, highlight_size); + + cx.scene.push_quad(Quad { + bounds: highlight_bounds, + background: Some(color), + border: Border::new(0., Color::transparent_black()), + corner_radius: diff_style.corner_radius * line_height, + }); + } + } + fn paint_text( &mut self, bounds: RectF, @@ -563,10 +656,8 @@ impl EditorElement { let style = &self.style; let local_replica_id = view.replica_id(cx); let scroll_position = layout.position_map.snapshot.scroll_position(); - let start_row = scroll_position.y() as u32; + let start_row = layout.visible_display_row_range.start; let scroll_top = scroll_position.y() * layout.position_map.line_height; - let end_row = - ((scroll_top + bounds.height()) / layout.position_map.line_height).ceil() as u32 + 1; // Add 1 to ensure selections bleed off screen let max_glyph_width = layout.position_map.em_width; let scroll_left = scroll_position.x() * max_glyph_width; let content_origin = bounds.origin() + vec2f(layout.gutter_margin, 0.); @@ -585,8 +676,6 @@ impl EditorElement { for (range, color) in &layout.highlighted_ranges { self.paint_highlighted_range( range.clone(), - start_row, - end_row, *color, 0., 0.15 * layout.position_map.line_height, @@ -607,8 +696,6 @@ impl EditorElement { for selection in selections { self.paint_highlighted_range( selection.range.clone(), - start_row, - end_row, selection_style.selection, corner_radius, corner_radius * 2., @@ -622,7 +709,10 @@ impl EditorElement { if view.show_local_cursors() || *replica_id != local_replica_id { let cursor_position = selection.head; - if (start_row..end_row).contains(&cursor_position.row()) { + if layout + .visible_display_row_range + .contains(&cursor_position.row()) + { let cursor_row_layout = &layout.position_map.line_layouts [(cursor_position.row() - start_row) as usize]; let cursor_column = cursor_position.column() as usize; @@ -639,7 +729,7 @@ impl EditorElement { .snapshot .chars_at(cursor_position) .next() - .and_then(|character| { + .and_then(|(character, _)| { let font_id = cursor_row_layout.font_for_index(cursor_column)?; let text = character.to_string(); @@ -796,12 +886,123 @@ impl EditorElement { cx.scene.pop_layer(); } + fn paint_scrollbar(&mut self, bounds: RectF, layout: &mut LayoutState, cx: &mut PaintContext) { + enum ScrollbarMouseHandlers {} + if layout.mode != EditorMode::Full { + return; + } + + let view = self.view.clone(); + let style = &self.style.theme.scrollbar; + + let top = bounds.min_y(); + let bottom = bounds.max_y(); + let right = bounds.max_x(); + let left = right - style.width; + let row_range = &layout.scrollbar_row_range; + let max_row = layout.max_row as f32 + (row_range.end - row_range.start); + + let mut height = bounds.height(); + let mut first_row_y_offset = 0.0; + + // Impose a minimum height on the scrollbar thumb + let min_thumb_height = + style.min_height_factor * cx.font_cache.line_height(self.style.text.font_size); + let thumb_height = (row_range.end - row_range.start) * height / max_row; + if thumb_height < min_thumb_height { + first_row_y_offset = (min_thumb_height - thumb_height) / 2.0; + height -= min_thumb_height - thumb_height; + } + + let y_for_row = |row: f32| -> f32 { top + first_row_y_offset + row * height / max_row }; + + let thumb_top = y_for_row(row_range.start) - first_row_y_offset; + let thumb_bottom = y_for_row(row_range.end) + first_row_y_offset; + let track_bounds = RectF::from_points(vec2f(left, top), vec2f(right, bottom)); + let thumb_bounds = RectF::from_points(vec2f(left, thumb_top), vec2f(right, thumb_bottom)); + + if layout.show_scrollbars { + cx.scene.push_quad(Quad { + bounds: track_bounds, + border: style.track.border, + background: style.track.background_color, + ..Default::default() + }); + cx.scene.push_quad(Quad { + bounds: thumb_bounds, + border: style.thumb.border, + background: style.thumb.background_color, + corner_radius: style.thumb.corner_radius, + }); + } + + cx.scene.push_cursor_region(CursorRegion { + bounds: track_bounds, + style: CursorStyle::Arrow, + }); + cx.scene.push_mouse_region( + MouseRegion::new::(view.id(), view.id(), track_bounds) + .on_move({ + let view = view.clone(); + move |_, cx| { + if let Some(view) = view.upgrade(cx.deref_mut()) { + view.update(cx.deref_mut(), |view, cx| { + view.make_scrollbar_visible(cx); + }); + } + } + }) + .on_down(MouseButton::Left, { + let view = view.clone(); + let row_range = row_range.clone(); + move |e, cx| { + let y = e.position.y(); + if let Some(view) = view.upgrade(cx.deref_mut()) { + view.update(cx.deref_mut(), |view, cx| { + if y < thumb_top || thumb_bottom < y { + let center_row = + ((y - top) * max_row as f32 / height).round() as u32; + let top_row = center_row.saturating_sub( + (row_range.end - row_range.start) as u32 / 2, + ); + let mut position = view.scroll_position(cx); + position.set_y(top_row as f32); + view.set_scroll_position(position, cx); + } else { + view.make_scrollbar_visible(cx); + } + }); + } + } + }) + .on_drag(MouseButton::Left, { + let view = view.clone(); + move |e, cx| { + let y = e.prev_mouse_position.y(); + let new_y = e.position.y(); + if thumb_top < y && y < thumb_bottom { + if let Some(view) = view.upgrade(cx.deref_mut()) { + view.update(cx.deref_mut(), |view, cx| { + let mut position = view.scroll_position(cx); + position.set_y( + position.y() + (new_y - y) * (max_row as f32) / height, + ); + if position.y() < 0.0 { + position.set_y(0.); + } + view.set_scroll_position(position, cx); + }); + } + } + } + }), + ); + } + #[allow(clippy::too_many_arguments)] fn paint_highlighted_range( &self, range: Range, - start_row: u32, - end_row: u32, color: Color, corner_radius: f32, line_end_overshoot: f32, @@ -812,6 +1013,8 @@ impl EditorElement { bounds: RectF, cx: &mut PaintContext, ) { + let start_row = layout.visible_display_row_range.start; + let end_row = layout.visible_display_row_range.end; if range.start != range.end { let row_range = if range.end.column() == 0 { cmp::max(range.start.row(), start_row)..cmp::min(range.end.row(), end_row) @@ -900,6 +1103,75 @@ impl EditorElement { .width() } + //Folds contained in a hunk are ignored apart from shrinking visual size + //If a fold contains any hunks then that fold line is marked as modified + fn layout_git_gutters( + &self, + rows: Range, + snapshot: &EditorSnapshot, + ) -> Vec { + let buffer_snapshot = &snapshot.buffer_snapshot; + let visual_start = DisplayPoint::new(rows.start, 0).to_point(snapshot).row; + let visual_end = DisplayPoint::new(rows.end, 0).to_point(snapshot).row; + let hunks = buffer_snapshot.git_diff_hunks_in_range(visual_start..visual_end); + + let mut layouts = Vec::::new(); + + for hunk in hunks { + let hunk_start_point = Point::new(hunk.buffer_range.start, 0); + let hunk_end_point = Point::new(hunk.buffer_range.end, 0); + let hunk_start_point_sub = Point::new(hunk.buffer_range.start.saturating_sub(1), 0); + let hunk_end_point_sub = Point::new( + hunk.buffer_range + .end + .saturating_sub(1) + .max(hunk.buffer_range.start), + 0, + ); + + let is_removal = hunk.status() == DiffHunkStatus::Removed; + + let folds_start = Point::new(hunk.buffer_range.start.saturating_sub(1), 0); + let folds_end = Point::new(hunk.buffer_range.end + 1, 0); + let folds_range = folds_start..folds_end; + + let containing_fold = snapshot.folds_in_range(folds_range).find(|fold_range| { + let fold_point_range = fold_range.to_point(buffer_snapshot); + let fold_point_range = fold_point_range.start..=fold_point_range.end; + + let folded_start = fold_point_range.contains(&hunk_start_point); + let folded_end = fold_point_range.contains(&hunk_end_point_sub); + let folded_start_sub = fold_point_range.contains(&hunk_start_point_sub); + + (folded_start && folded_end) || (is_removal && folded_start_sub) + }); + + let visual_range = if let Some(fold) = containing_fold { + let row = fold.start.to_display_point(snapshot).row(); + row..row + } else { + let start = hunk_start_point.to_display_point(snapshot).row(); + let end = hunk_end_point.to_display_point(snapshot).row(); + start..end + }; + + let has_existing_layout = match layouts.last() { + Some(e) => visual_range == e.visual_range && e.status == hunk.status(), + None => false, + }; + + if !has_existing_layout { + layouts.push(DiffHunkLayout { + visual_range, + status: hunk.status(), + is_folded: containing_fold.is_some(), + }); + } + } + + layouts + } + fn layout_line_numbers( &self, rows: Range, @@ -1288,6 +1560,8 @@ impl Element for EditorElement { let em_advance = style.text.em_advance(cx.font_cache); let overscroll = vec2f(em_width, 0.); let snapshot = self.update_view(cx.app, |view, cx| { + view.set_visible_line_count(size.y() / line_height); + let wrap_width = match view.soft_wrap_mode(cx) { SoftWrap::None => Some((MAX_LINE_LEN / 2) as f32 * em_advance), SoftWrap::EditorWidth => { @@ -1333,12 +1607,13 @@ impl Element for EditorElement { // The scroll position is a fractional point, the whole number of which represents // the top of the window in terms of display rows. let start_row = scroll_position.y() as u32; - let scroll_top = scroll_position.y() * line_height; + let height_in_lines = size.y() / line_height; + let max_row = snapshot.max_point().row(); // Add 1 to ensure selections bleed off screen let end_row = 1 + cmp::min( - ((scroll_top + size.y()) / line_height).ceil() as u32, - snapshot.max_point().row(), + (scroll_position.y() + height_in_lines).ceil() as u32, + max_row, ); let start_anchor = if start_row == 0 { @@ -1348,7 +1623,7 @@ impl Element for EditorElement { .buffer_snapshot .anchor_before(DisplayPoint::new(start_row, 0).to_offset(&snapshot, Bias::Left)) }; - let end_anchor = if end_row > snapshot.max_point().row() { + let end_anchor = if end_row > max_row { Anchor::max() } else { snapshot @@ -1360,6 +1635,7 @@ impl Element for EditorElement { let mut active_rows = BTreeMap::new(); let mut highlighted_rows = None; let mut highlighted_ranges = Vec::new(); + let mut show_scrollbars = false; self.update_view(cx.app, |view, cx| { let display_map = view.display_map.update(cx, |map, cx| map.snapshot(cx)); @@ -1420,11 +1696,17 @@ impl Element for EditorElement { .collect(), )); } + + show_scrollbars = view.show_scrollbars(); }); let line_number_layouts = self.layout_line_numbers(start_row..end_row, &active_rows, &snapshot, cx); + let hunk_layouts = self.layout_git_gutters(start_row..end_row, &snapshot); + + let scrollbar_row_range = scroll_position.y()..(scroll_position.y() + height_in_lines); + let mut max_visible_line_width = 0.0; let line_layouts = self.layout_lines(start_row..end_row, &snapshot, cx); for line in &line_layouts { @@ -1458,10 +1740,9 @@ impl Element for EditorElement { cx, ); - let max_row = snapshot.max_point().row(); let scroll_max = vec2f( ((scroll_width - text_size.x()) / em_width).max(0.0), - max_row.saturating_sub(1) as f32, + max_row as f32, ); self.update_view(cx.app, |view, cx| { @@ -1488,6 +1769,7 @@ impl Element for EditorElement { let mut context_menu = None; let mut code_actions_indicator = None; let mut hover = None; + let mut mode = EditorMode::Full; cx.render(&self.view.upgrade(cx).unwrap(), |view, cx| { let newest_selection_head = view .selections @@ -1509,6 +1791,7 @@ impl Element for EditorElement { let visible_rows = start_row..start_row + line_layouts.len() as u32; hover = view.hover_state.render(&snapshot, &style, visible_rows, cx); + mode = view.mode; }); if let Some((_, context_menu)) = context_menu.as_mut() { @@ -1556,6 +1839,7 @@ impl Element for EditorElement { ( size, LayoutState { + mode, position_map: Arc::new(PositionMap { size, scroll_max, @@ -1565,14 +1849,19 @@ impl Element for EditorElement { em_advance, snapshot, }), + visible_display_row_range: start_row..end_row, gutter_size, gutter_padding, text_size, + scrollbar_row_range, + show_scrollbars, + max_row, gutter_margin, active_rows, highlighted_rows, highlighted_ranges, line_number_layouts, + hunk_layouts, blocks, selections, context_menu, @@ -1589,7 +1878,8 @@ impl Element for EditorElement { layout: &mut Self::LayoutState, cx: &mut PaintContext, ) -> Self::PaintState { - cx.scene.push_layer(Some(bounds)); + let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default(); + cx.scene.push_layer(Some(visible_bounds)); let gutter_bounds = RectF::new(bounds.origin(), layout.gutter_size); let text_bounds = RectF::new( @@ -1613,11 +1903,12 @@ impl Element for EditorElement { } self.paint_text(text_bounds, visible_bounds, layout, cx); + cx.scene.push_layer(Some(bounds)); if !layout.blocks.is_empty() { - cx.scene.push_layer(Some(bounds)); self.paint_blocks(bounds, visible_bounds, layout, cx); - cx.scene.pop_layer(); } + self.paint_scrollbar(bounds, layout, cx); + cx.scene.pop_layer(); cx.scene.pop_layer(); } @@ -1703,12 +1994,18 @@ pub struct LayoutState { gutter_padding: f32, gutter_margin: f32, text_size: Vector2F, + mode: EditorMode, + visible_display_row_range: Range, active_rows: BTreeMap, highlighted_rows: Option>, line_number_layouts: Vec>, + hunk_layouts: Vec, blocks: Vec, highlighted_ranges: Vec<(Range, Color)>, selections: Vec<(ReplicaId, Vec)>, + scrollbar_row_range: Range, + show_scrollbars: bool, + max_row: u32, context_menu: Option<(DisplayPoint, ElementBox)>, code_actions_indicator: Option<(u32, ElementBox)>, hover_popovers: Option<(DisplayPoint, Vec)>, diff --git a/crates/editor/src/highlight_matching_bracket.rs b/crates/editor/src/highlight_matching_bracket.rs index 789393d70b..043b21db21 100644 --- a/crates/editor/src/highlight_matching_bracket.rs +++ b/crates/editor/src/highlight_matching_bracket.rs @@ -32,8 +32,9 @@ pub fn refresh_matching_bracket_highlights(editor: &mut Editor, cx: &mut ViewCon #[cfg(test)] mod tests { + use crate::test::editor_lsp_test_context::EditorLspTestContext; + use super::*; - use crate::test::EditorLspTestContext; use indoc::indoc; use language::{BracketPair, Language, LanguageConfig}; diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 250f8427a5..e4b4da68d3 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -354,7 +354,7 @@ impl InfoPopover { .with_style(style.hover_popover.container) .boxed() }) - .on_move(|_, _| {}) + .on_move(|_, _| {}) // Consume move events so they don't reach regions underneath. .with_cursor_style(CursorStyle::Arrow) .with_padding(Padding { bottom: HOVER_POPOVER_GAP, @@ -400,7 +400,7 @@ impl DiagnosticPopover { bottom: HOVER_POPOVER_GAP, ..Default::default() }) - .on_move(|_, _| {}) + .on_move(|_, _| {}) // Consume move events so they don't reach regions underneath. .on_click(MouseButton::Left, |_, cx| { cx.dispatch_action(GoToDiagnostic) }) @@ -427,13 +427,13 @@ impl DiagnosticPopover { #[cfg(test)] mod tests { - use futures::StreamExt; use indoc::indoc; use language::{Diagnostic, DiagnosticSet}; use project::HoverBlock; + use smol::stream::StreamExt; - use crate::test::EditorLspTestContext; + use crate::test::editor_lsp_test_context::EditorLspTestContext; use super::*; diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index fb6f12a16f..e6a4eebffb 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1,7 +1,7 @@ use crate::{ display_map::ToDisplayPoint, link_go_to_definition::hide_link_definition, movement::surrounding_word, Anchor, Autoscroll, Editor, Event, ExcerptId, MultiBuffer, - MultiBufferSnapshot, NavigationData, ToPoint as _, + MultiBufferSnapshot, NavigationData, ToPoint as _, FORMAT_TIMEOUT, }; use anyhow::{anyhow, Result}; use futures::FutureExt; @@ -9,8 +9,8 @@ use gpui::{ elements::*, geometry::vector::vec2f, AppContext, Entity, ModelHandle, MutableAppContext, RenderContext, Subscription, Task, View, ViewContext, ViewHandle, }; -use language::{Bias, Buffer, File as _, OffsetRangeExt, SelectionGoal}; -use project::{File, Project, ProjectEntryId, ProjectPath}; +use language::{Bias, Buffer, File as _, OffsetRangeExt, Point, SelectionGoal}; +use project::{File, FormatTrigger, Project, ProjectEntryId, ProjectPath}; use rpc::proto::{self, update_view}; use settings::Settings; use smallvec::SmallVec; @@ -20,9 +20,8 @@ use std::{ fmt::Write, ops::Range, path::{Path, PathBuf}, - time::Duration, }; -use text::{Point, Selection}; +use text::Selection; use util::TryFutureExt; use workspace::{ searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle}, @@ -30,7 +29,6 @@ use workspace::{ ToolbarItemLocation, }; -pub const FORMAT_TIMEOUT: Duration = Duration::from_secs(2); pub const MAX_TAB_TITLE_LEN: usize = 24; impl FollowableItem for Editor { @@ -406,10 +404,14 @@ impl Item for Editor { project: ModelHandle, cx: &mut ViewContext, ) -> Task> { + self.report_event("save editor", cx); + let buffer = self.buffer().clone(); let buffers = buffer.read(cx).all_buffers(); let mut timeout = cx.background().timer(FORMAT_TIMEOUT).fuse(); - let format = project.update(cx, |project, cx| project.format(buffers, true, cx)); + let format = project.update(cx, |project, cx| { + project.format(buffers, true, FormatTrigger::Save, cx) + }); cx.spawn(|_, mut cx| async move { let transaction = futures::select_biased! { _ = timeout => { @@ -476,6 +478,17 @@ impl Item for Editor { }) } + fn git_diff_recalc( + &mut self, + _project: ModelHandle, + cx: &mut ViewContext, + ) -> Task> { + self.buffer().update(cx, |multibuffer, cx| { + multibuffer.git_diff_recalc(cx); + }); + Task::ready(Ok(())) + } + fn to_item_events(event: &Self::Event) -> Vec { let mut result = Vec::new(); match event { diff --git a/crates/editor/src/link_go_to_definition.rs b/crates/editor/src/link_go_to_definition.rs index 6b23a04b67..c8294ddb43 100644 --- a/crates/editor/src/link_go_to_definition.rs +++ b/crates/editor/src/link_go_to_definition.rs @@ -400,7 +400,7 @@ mod tests { use indoc::indoc; use lsp::request::{GotoDefinition, GotoTypeDefinition}; - use crate::test::EditorLspTestContext; + use crate::test::editor_lsp_test_context::EditorLspTestContext; use super::*; diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 4adc030d99..d9840fd3fa 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -70,8 +70,9 @@ pub fn deploy_context_menu( #[cfg(test)] mod tests { + use crate::test::editor_lsp_test_context::EditorLspTestContext; + use super::*; - use crate::test::EditorLspTestContext; use indoc::indoc; #[gpui::test] diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 0db5cc0812..96b2065823 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -29,6 +29,25 @@ pub fn up( start: DisplayPoint, goal: SelectionGoal, preserve_column_at_start: bool, +) -> (DisplayPoint, SelectionGoal) { + up_by_rows(map, start, 1, goal, preserve_column_at_start) +} + +pub fn down( + map: &DisplaySnapshot, + start: DisplayPoint, + goal: SelectionGoal, + preserve_column_at_end: bool, +) -> (DisplayPoint, SelectionGoal) { + down_by_rows(map, start, 1, goal, preserve_column_at_end) +} + +pub fn up_by_rows( + map: &DisplaySnapshot, + start: DisplayPoint, + row_count: u32, + goal: SelectionGoal, + preserve_column_at_start: bool, ) -> (DisplayPoint, SelectionGoal) { let mut goal_column = if let SelectionGoal::Column(column) = goal { column @@ -36,7 +55,7 @@ pub fn up( map.column_to_chars(start.row(), start.column()) }; - let prev_row = start.row().saturating_sub(1); + let prev_row = start.row().saturating_sub(row_count); let mut point = map.clip_point( DisplayPoint::new(prev_row, map.line_len(prev_row)), Bias::Left, @@ -62,9 +81,10 @@ pub fn up( ) } -pub fn down( +pub fn down_by_rows( map: &DisplaySnapshot, start: DisplayPoint, + row_count: u32, goal: SelectionGoal, preserve_column_at_end: bool, ) -> (DisplayPoint, SelectionGoal) { @@ -74,8 +94,8 @@ pub fn down( map.column_to_chars(start.row(), start.column()) }; - let next_row = start.row() + 1; - let mut point = map.clip_point(DisplayPoint::new(next_row, 0), Bias::Right); + let new_row = start.row() + row_count; + let mut point = map.clip_point(DisplayPoint::new(new_row, 0), Bias::Right); if point.row() > start.row() { *point.column_mut() = map.column_from_chars(point.row(), goal_column); } else if preserve_column_at_end { @@ -101,6 +121,22 @@ pub fn line_beginning( map: &DisplaySnapshot, display_point: DisplayPoint, stop_at_soft_boundaries: bool, +) -> DisplayPoint { + let point = display_point.to_point(map); + let soft_line_start = map.clip_point(DisplayPoint::new(display_point.row(), 0), Bias::Right); + let line_start = map.prev_line_boundary(point).1; + + if stop_at_soft_boundaries && display_point != soft_line_start { + soft_line_start + } else { + line_start + } +} + +pub fn indented_line_beginning( + map: &DisplaySnapshot, + display_point: DisplayPoint, + stop_at_soft_boundaries: bool, ) -> DisplayPoint { let point = display_point.to_point(map); let soft_line_start = map.clip_point(DisplayPoint::new(display_point.row(), 0), Bias::Right); @@ -167,54 +203,79 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo }) } -/// Scans for a boundary from the start of each line preceding the given end point until a boundary -/// is found, indicated by the given predicate returning true. The predicate is called with the -/// character to the left and right of the candidate boundary location, and will be called with `\n` -/// characters indicating the start or end of a line. If the predicate returns true multiple times -/// on a line, the *rightmost* boundary is returned. +/// Scans for a boundary preceding the given start point `from` until a boundary is found, indicated by the +/// given predicate returning true. The predicate is called with the character to the left and right +/// of the candidate boundary location, and will be called with `\n` characters indicating the start +/// or end of a line. pub fn find_preceding_boundary( map: &DisplaySnapshot, - end: DisplayPoint, + from: DisplayPoint, mut is_boundary: impl FnMut(char, char) -> bool, ) -> DisplayPoint { - let mut point = end; - loop { - *point.column_mut() = 0; - if point.row() > 0 { - if let Some(indent) = map.soft_wrap_indent(point.row() - 1) { - *point.column_mut() = indent; + let mut start_column = 0; + let mut soft_wrap_row = from.row() + 1; + + let mut prev = None; + for (ch, point) in map.reverse_chars_at(from) { + // Recompute soft_wrap_indent if the row has changed + if point.row() != soft_wrap_row { + soft_wrap_row = point.row(); + + if point.row() == 0 { + start_column = 0; + } else if let Some(indent) = map.soft_wrap_indent(point.row() - 1) { + start_column = indent; } } - let mut boundary = None; - let mut prev_ch = if point.is_zero() { None } else { Some('\n') }; - for ch in map.chars_at(point) { - if point >= end { - break; - } - - if let Some(prev_ch) = prev_ch { - if is_boundary(prev_ch, ch) { - boundary = Some(point); - } - } - - if ch == '\n' { - break; - } - - prev_ch = Some(ch); - *point.column_mut() += ch.len_utf8() as u32; + // If the current point is in the soft_wrap, skip comparing it + if point.column() < start_column { + continue; } - if let Some(boundary) = boundary { - return boundary; - } else if point.row() == 0 { - return DisplayPoint::zero(); - } else { - *point.row_mut() -= 1; + if let Some((prev_ch, prev_point)) = prev { + if is_boundary(ch, prev_ch) { + return prev_point; + } + } + + prev = Some((ch, point)); + } + DisplayPoint::zero() +} + +/// Scans for a boundary preceding the given start point `from` until a boundary is found, indicated by the +/// given predicate returning true. The predicate is called with the character to the left and right +/// of the candidate boundary location, and will be called with `\n` characters indicating the start +/// or end of a line. If no boundary is found, the start of the line is returned. +pub fn find_preceding_boundary_in_line( + map: &DisplaySnapshot, + from: DisplayPoint, + mut is_boundary: impl FnMut(char, char) -> bool, +) -> DisplayPoint { + let mut start_column = 0; + if from.row() > 0 { + if let Some(indent) = map.soft_wrap_indent(from.row() - 1) { + start_column = indent; } } + + let mut prev = None; + for (ch, point) in map.reverse_chars_at(from) { + if let Some((prev_ch, prev_point)) = prev { + if is_boundary(ch, prev_ch) { + return prev_point; + } + } + + if ch == '\n' || point.column() < start_column { + break; + } + + prev = Some((ch, point)); + } + + prev.map(|(_, point)| point).unwrap_or(from) } /// Scans for a boundary following the given start point until a boundary is found, indicated by the @@ -223,26 +284,48 @@ pub fn find_preceding_boundary( /// or end of a line. pub fn find_boundary( map: &DisplaySnapshot, - mut point: DisplayPoint, + from: DisplayPoint, mut is_boundary: impl FnMut(char, char) -> bool, ) -> DisplayPoint { let mut prev_ch = None; - for ch in map.chars_at(point) { + for (ch, point) in map.chars_at(from) { if let Some(prev_ch) = prev_ch { if is_boundary(prev_ch, ch) { - break; + return map.clip_point(point, Bias::Right); } } - if ch == '\n' { - *point.row_mut() += 1; - *point.column_mut() = 0; - } else { - *point.column_mut() += ch.len_utf8() as u32; - } prev_ch = Some(ch); } - map.clip_point(point, Bias::Right) + map.clip_point(map.max_point(), Bias::Right) +} + +/// Scans for a boundary following the given start point until a boundary is found, indicated by the +/// given predicate returning true. The predicate is called with the character to the left and right +/// of the candidate boundary location, and will be called with `\n` characters indicating the start +/// or end of a line. If no boundary is found, the end of the line is returned +pub fn find_boundary_in_line( + map: &DisplaySnapshot, + from: DisplayPoint, + mut is_boundary: impl FnMut(char, char) -> bool, +) -> DisplayPoint { + let mut prev = None; + for (ch, point) in map.chars_at(from) { + if let Some((prev_ch, _)) = prev { + if is_boundary(prev_ch, ch) { + return map.clip_point(point, Bias::Right); + } + } + + prev = Some((ch, point)); + + if ch == '\n' { + break; + } + } + + // Return the last position checked so that we give a point right before the newline or eof. + map.clip_point(prev.map(|(_, point)| point).unwrap_or(from), Bias::Right) } pub fn is_inside_word(map: &DisplaySnapshot, point: DisplayPoint) -> bool { @@ -273,7 +356,6 @@ pub fn surrounding_word(map: &DisplaySnapshot, position: DisplayPoint) -> Range< mod tests { use super::*; use crate::{test::marked_display_snapshot, Buffer, DisplayMap, ExcerptRange, MultiBuffer}; - use language::Point; use settings::Settings; #[gpui::test] diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index 4ee9526a67..448564ed98 100644 --- a/crates/editor/src/multi_buffer.rs +++ b/crates/editor/src/multi_buffer.rs @@ -4,12 +4,14 @@ pub use anchor::{Anchor, AnchorRangeExt}; use anyhow::Result; use clock::ReplicaId; use collections::{BTreeMap, Bound, HashMap, HashSet}; +use git::diff::DiffHunk; use gpui::{AppContext, Entity, ModelContext, ModelHandle, Task}; pub use language::Completion; use language::{ char_kind, AutoindentMode, Buffer, BufferChunks, BufferSnapshot, CharKind, Chunk, - DiagnosticEntry, Event, File, IndentSize, Language, OffsetRangeExt, Outline, OutlineItem, - Selection, ToOffset as _, ToOffsetUtf16 as _, ToPoint as _, ToPointUtf16 as _, TransactionId, + DiagnosticEntry, Event, File, IndentSize, Language, OffsetRangeExt, OffsetUtf16, Outline, + OutlineItem, Point, PointUtf16, Selection, TextDimension, ToOffset as _, ToOffsetUtf16 as _, + ToPoint as _, ToPointUtf16 as _, TransactionId, }; use smallvec::SmallVec; use std::{ @@ -26,9 +28,8 @@ use std::{ use sum_tree::{Bias, Cursor, SumTree}; use text::{ locator::Locator, - rope::TextDimension, subscription::{Subscription, Topic}, - Edit, OffsetUtf16, Point, PointUtf16, TextSummary, + Edit, TextSummary, }; use theme::SyntaxTheme; use util::post_inc; @@ -90,6 +91,7 @@ struct BufferState { last_selections_update_count: usize, last_diagnostics_update_count: usize, last_file_update_count: usize, + last_git_diff_update_count: usize, excerpts: Vec, _subscriptions: [gpui::Subscription; 2], } @@ -101,6 +103,7 @@ pub struct MultiBufferSnapshot { parse_count: usize, diagnostics_update_count: usize, trailing_excerpt_update_count: usize, + git_diff_update_count: usize, edit_count: usize, is_dirty: bool, has_conflict: bool, @@ -140,6 +143,7 @@ struct ExcerptSummary { text: TextSummary, } +#[derive(Clone)] pub struct MultiBufferRows<'a> { buffer_row_range: Range, excerpts: Cursor<'a, Excerpt, Point>, @@ -165,7 +169,7 @@ struct ExcerptChunks<'a> { } struct ExcerptBytes<'a> { - content_bytes: language::rope::Bytes<'a>, + content_bytes: text::Bytes<'a>, footer_height: usize, } @@ -202,6 +206,7 @@ impl MultiBuffer { last_selections_update_count: buffer_state.last_selections_update_count, last_diagnostics_update_count: buffer_state.last_diagnostics_update_count, last_file_update_count: buffer_state.last_file_update_count, + last_git_diff_update_count: buffer_state.last_git_diff_update_count, excerpts: buffer_state.excerpts.clone(), _subscriptions: [ new_cx.observe(&buffer_state.buffer, |_, _, cx| cx.notify()), @@ -308,6 +313,17 @@ impl MultiBuffer { self.read(cx).symbols_containing(offset, theme) } + pub fn git_diff_recalc(&mut self, cx: &mut ModelContext) { + let buffers = self.buffers.borrow(); + for buffer_state in buffers.values() { + if buffer_state.buffer.read(cx).needs_git_diff_recalc() { + buffer_state + .buffer + .update(cx, |buffer, cx| buffer.git_diff_recalc(cx)) + } + } + } + pub fn edit( &mut self, edits: I, @@ -827,6 +843,7 @@ impl MultiBuffer { last_selections_update_count: buffer_snapshot.selections_update_count(), last_diagnostics_update_count: buffer_snapshot.diagnostics_update_count(), last_file_update_count: buffer_snapshot.file_update_count(), + last_git_diff_update_count: buffer_snapshot.git_diff_update_count(), excerpts: Default::default(), _subscriptions: [ cx.observe(&buffer, |_, _, cx| cx.notify()), @@ -1212,9 +1229,9 @@ impl MultiBuffer { &self, point: T, cx: &'a AppContext, - ) -> Option<&'a Arc> { + ) -> Option> { self.point_to_buffer_offset(point, cx) - .and_then(|(buffer, _)| buffer.read(cx).language()) + .and_then(|(buffer, offset)| buffer.read(cx).language_at(offset)) } pub fn files<'a>(&'a self, cx: &'a AppContext) -> SmallVec<[&'a dyn File; 2]> { @@ -1249,6 +1266,7 @@ impl MultiBuffer { let mut excerpts_to_edit = Vec::new(); let mut reparsed = false; let mut diagnostics_updated = false; + let mut git_diff_updated = false; let mut is_dirty = false; let mut has_conflict = false; let mut edited = false; @@ -1260,6 +1278,7 @@ impl MultiBuffer { let selections_update_count = buffer.selections_update_count(); let diagnostics_update_count = buffer.diagnostics_update_count(); let file_update_count = buffer.file_update_count(); + let git_diff_update_count = buffer.git_diff_update_count(); let buffer_edited = version.changed_since(&buffer_state.last_version); let buffer_reparsed = parse_count > buffer_state.last_parse_count; @@ -1268,17 +1287,21 @@ impl MultiBuffer { let buffer_diagnostics_updated = diagnostics_update_count > buffer_state.last_diagnostics_update_count; let buffer_file_updated = file_update_count > buffer_state.last_file_update_count; + let buffer_git_diff_updated = + git_diff_update_count > buffer_state.last_git_diff_update_count; if buffer_edited || buffer_reparsed || buffer_selections_updated || buffer_diagnostics_updated || buffer_file_updated + || buffer_git_diff_updated { buffer_state.last_version = version; buffer_state.last_parse_count = parse_count; buffer_state.last_selections_update_count = selections_update_count; buffer_state.last_diagnostics_update_count = diagnostics_update_count; buffer_state.last_file_update_count = file_update_count; + buffer_state.last_git_diff_update_count = git_diff_update_count; excerpts_to_edit.extend( buffer_state .excerpts @@ -1290,6 +1313,7 @@ impl MultiBuffer { edited |= buffer_edited; reparsed |= buffer_reparsed; diagnostics_updated |= buffer_diagnostics_updated; + git_diff_updated |= buffer_git_diff_updated; is_dirty |= buffer.is_dirty(); has_conflict |= buffer.has_conflict(); } @@ -1302,6 +1326,9 @@ impl MultiBuffer { if diagnostics_updated { snapshot.diagnostics_update_count += 1; } + if git_diff_updated { + snapshot.git_diff_update_count += 1; + } snapshot.is_dirty = is_dirty; snapshot.has_conflict = has_conflict; @@ -1386,7 +1413,7 @@ impl MultiBuffer { edit_count: usize, cx: &mut ModelContext, ) { - use text::RandomCharIter; + use util::RandomCharIter; let snapshot = self.read(cx); let mut edits: Vec<(Range, Arc)> = Vec::new(); @@ -1425,7 +1452,7 @@ impl MultiBuffer { ) { use rand::prelude::*; use std::env; - use text::RandomCharIter; + use util::RandomCharIter; let max_excerpts = env::var("MAX_EXCERPTS") .map(|i| i.parse().expect("invalid `MAX_EXCERPTS` variable")) @@ -1940,6 +1967,24 @@ impl MultiBufferSnapshot { } } + pub fn point_to_buffer_offset( + &self, + point: T, + ) -> Option<(&BufferSnapshot, usize)> { + let offset = point.to_offset(&self); + let mut cursor = self.excerpts.cursor::(); + cursor.seek(&offset, Bias::Right, &()); + if cursor.item().is_none() { + cursor.prev(&()); + } + + cursor.item().map(|excerpt| { + let excerpt_start = excerpt.range.context.start.to_offset(&excerpt.buffer); + let buffer_point = excerpt_start + offset - *cursor.start(); + (&excerpt.buffer, buffer_point) + }) + } + pub fn suggested_indents( &self, rows: impl IntoIterator, @@ -1949,8 +1994,10 @@ impl MultiBufferSnapshot { let mut rows_for_excerpt = Vec::new(); let mut cursor = self.excerpts.cursor::(); - let mut rows = rows.into_iter().peekable(); + let mut prev_row = u32::MAX; + let mut prev_language_indent_size = IndentSize::default(); + while let Some(row) = rows.next() { cursor.seek(&Point::new(row, 0), Bias::Right, &()); let excerpt = match cursor.item() { @@ -1958,7 +2005,17 @@ impl MultiBufferSnapshot { _ => continue, }; - let single_indent_size = excerpt.buffer.single_indent_size(cx); + // Retrieve the language and indent size once for each disjoint region being indented. + let single_indent_size = if row.saturating_sub(1) == prev_row { + prev_language_indent_size + } else { + excerpt + .buffer + .language_indent_size_at(Point::new(row, 0), cx) + }; + prev_language_indent_size = single_indent_size; + prev_row = row; + let start_buffer_row = excerpt.range.context.start.to_point(&excerpt.buffer).row; let start_multibuffer_row = cursor.start().row; @@ -2479,15 +2536,17 @@ impl MultiBufferSnapshot { self.diagnostics_update_count } + pub fn git_diff_update_count(&self) -> usize { + self.git_diff_update_count + } + pub fn trailing_excerpt_update_count(&self) -> usize { self.trailing_excerpt_update_count } - pub fn language(&self) -> Option<&Arc> { - self.excerpts - .iter() - .next() - .and_then(|excerpt| excerpt.buffer.language()) + pub fn language_at<'a, T: ToOffset>(&'a self, point: T) -> Option<&'a Arc> { + self.point_to_buffer_offset(point) + .and_then(|(buffer, offset)| buffer.language_at(offset)) } pub fn is_dirty(&self) -> bool { @@ -2529,6 +2588,15 @@ impl MultiBufferSnapshot { }) } + pub fn git_diff_hunks_in_range<'a>( + &'a self, + row_range: Range, + ) -> impl 'a + Iterator> { + self.as_singleton() + .into_iter() + .flat_map(move |(_, _, buffer)| buffer.git_diff_hunks_in_range(row_range.clone())) + } + pub fn range_for_syntax_ancestor(&self, range: Range) -> Option> { let range = range.start.to_offset(self)..range.end.to_offset(self); @@ -3270,7 +3338,7 @@ mod tests { use rand::prelude::*; use settings::Settings; use std::{env, rc::Rc}; - use text::{Point, RandomCharIter}; + use util::test::sample_text; #[gpui::test] @@ -3888,7 +3956,9 @@ mod tests { } _ => { let buffer_handle = if buffers.is_empty() || rng.gen_bool(0.4) { - let base_text = RandomCharIter::new(&mut rng).take(10).collect::(); + let base_text = util::RandomCharIter::new(&mut rng) + .take(10) + .collect::(); buffers.push(cx.add_model(|cx| Buffer::new(0, base_text, cx))); buffers.last().unwrap() } else { diff --git a/crates/editor/src/multi_buffer/anchor.rs b/crates/editor/src/multi_buffer/anchor.rs index cb8a1692b9..43723b95fc 100644 --- a/crates/editor/src/multi_buffer/anchor.rs +++ b/crates/editor/src/multi_buffer/anchor.rs @@ -1,10 +1,10 @@ use super::{ExcerptId, MultiBufferSnapshot, ToOffset, ToOffsetUtf16, ToPoint}; +use language::{OffsetUtf16, Point, TextDimension}; use std::{ cmp::Ordering, ops::{Range, Sub}, }; use sum_tree::Bias; -use text::{rope::TextDimension, OffsetUtf16, Point}; #[derive(Clone, Eq, PartialEq, Debug, Hash)] pub struct Anchor { diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 9d6450f8ec..999f410db5 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -8,7 +8,7 @@ use std::{ use collections::HashMap; use gpui::{AppContext, ModelHandle, MutableAppContext}; use itertools::Itertools; -use language::{rope::TextDimension, Bias, Point, Selection, SelectionGoal, ToPoint}; +use language::{Bias, Point, Selection, SelectionGoal, TextDimension, ToPoint}; use util::post_inc; use crate::{ diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs index 75bc5fe76a..48652c44b7 100644 --- a/crates/editor/src/test.rs +++ b/crates/editor/src/test.rs @@ -1,28 +1,14 @@ +pub mod editor_lsp_test_context; +pub mod editor_test_context; + use crate::{ display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint}, - multi_buffer::ToPointUtf16, - AnchorRangeExt, Autoscroll, DisplayPoint, Editor, EditorMode, MultiBuffer, ToPoint, + DisplayPoint, Editor, EditorMode, MultiBuffer, }; -use anyhow::Result; -use futures::{Future, StreamExt}; -use gpui::{ - json, keymap::Keystroke, AppContext, ModelContext, ModelHandle, ViewContext, ViewHandle, -}; -use indoc::indoc; -use language::{point_to_lsp, Buffer, BufferSnapshot, FakeLspAdapter, Language, LanguageConfig}; -use lsp::{notification, request}; -use project::Project; -use settings::Settings; -use std::{ - any::TypeId, - ops::{Deref, DerefMut, Range}, - sync::Arc, -}; -use util::{ - assert_set_eq, set_eq, - test::{generate_marked_text, marked_text_offsets, marked_text_ranges}, -}; -use workspace::{pane, AppState, Workspace, WorkspaceHandle}; + +use gpui::{ModelHandle, ViewContext}; + +use util::test::{marked_text_offsets, marked_text_ranges}; #[cfg(test)] #[ctor::ctor] @@ -80,430 +66,3 @@ pub(crate) fn build_editor( ) -> Editor { Editor::new(EditorMode::Full, buffer, None, None, cx) } - -pub struct EditorTestContext<'a> { - pub cx: &'a mut gpui::TestAppContext, - pub window_id: usize, - pub editor: ViewHandle, -} - -impl<'a> EditorTestContext<'a> { - pub fn new(cx: &'a mut gpui::TestAppContext) -> EditorTestContext<'a> { - let (window_id, editor) = cx.update(|cx| { - cx.set_global(Settings::test(cx)); - crate::init(cx); - - let (window_id, editor) = cx.add_window(Default::default(), |cx| { - build_editor(MultiBuffer::build_simple("", cx), cx) - }); - - editor.update(cx, |_, cx| cx.focus_self()); - - (window_id, editor) - }); - - Self { - cx, - window_id, - editor, - } - } - - pub fn condition( - &self, - predicate: impl FnMut(&Editor, &AppContext) -> bool, - ) -> impl Future { - self.editor.condition(self.cx, predicate) - } - - pub fn editor(&self, read: F) -> T - where - F: FnOnce(&Editor, &AppContext) -> T, - { - self.editor.read_with(self.cx, read) - } - - pub fn update_editor(&mut self, update: F) -> T - where - F: FnOnce(&mut Editor, &mut ViewContext) -> T, - { - self.editor.update(self.cx, update) - } - - pub fn multibuffer(&self, read: F) -> T - where - F: FnOnce(&MultiBuffer, &AppContext) -> T, - { - self.editor(|editor, cx| read(editor.buffer().read(cx), cx)) - } - - pub fn update_multibuffer(&mut self, update: F) -> T - where - F: FnOnce(&mut MultiBuffer, &mut ModelContext) -> T, - { - self.update_editor(|editor, cx| editor.buffer().update(cx, update)) - } - - pub fn buffer_text(&self) -> String { - self.multibuffer(|buffer, cx| buffer.snapshot(cx).text()) - } - - pub fn buffer(&self, read: F) -> T - where - F: FnOnce(&Buffer, &AppContext) -> T, - { - self.multibuffer(|multibuffer, cx| { - let buffer = multibuffer.as_singleton().unwrap().read(cx); - read(buffer, cx) - }) - } - - pub fn update_buffer(&mut self, update: F) -> T - where - F: FnOnce(&mut Buffer, &mut ModelContext) -> T, - { - self.update_multibuffer(|multibuffer, cx| { - let buffer = multibuffer.as_singleton().unwrap(); - buffer.update(cx, update) - }) - } - - pub fn buffer_snapshot(&self) -> BufferSnapshot { - self.buffer(|buffer, _| buffer.snapshot()) - } - - pub fn simulate_keystroke(&mut self, keystroke_text: &str) { - let keystroke = Keystroke::parse(keystroke_text).unwrap(); - self.cx.dispatch_keystroke(self.window_id, keystroke, false); - } - - pub fn simulate_keystrokes(&mut self, keystroke_texts: [&str; COUNT]) { - for keystroke_text in keystroke_texts.into_iter() { - self.simulate_keystroke(keystroke_text); - } - } - - pub fn ranges(&self, marked_text: &str) -> Vec> { - let (unmarked_text, ranges) = marked_text_ranges(marked_text, false); - assert_eq!(self.buffer_text(), unmarked_text); - ranges - } - - pub fn display_point(&mut self, marked_text: &str) -> DisplayPoint { - let ranges = self.ranges(marked_text); - let snapshot = self - .editor - .update(self.cx, |editor, cx| editor.snapshot(cx)); - ranges[0].start.to_display_point(&snapshot) - } - - // Returns anchors for the current buffer using `«` and `»` - pub fn text_anchor_range(&self, marked_text: &str) -> Range { - let ranges = self.ranges(marked_text); - let snapshot = self.buffer_snapshot(); - snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end) - } - - /// Change the editor's text and selections using a string containing - /// embedded range markers that represent the ranges and directions of - /// each selection. - /// - /// See the `util::test::marked_text_ranges` function for more information. - pub fn set_state(&mut self, marked_text: &str) { - let (unmarked_text, selection_ranges) = marked_text_ranges(marked_text, true); - self.editor.update(self.cx, |editor, cx| { - editor.set_text(unmarked_text, cx); - editor.change_selections(Some(Autoscroll::Fit), cx, |s| { - s.select_ranges(selection_ranges) - }) - }) - } - - /// Make an assertion about the editor's text and the ranges and directions - /// of its selections using a string containing embedded range markers. - /// - /// See the `util::test::marked_text_ranges` function for more information. - pub fn assert_editor_state(&mut self, marked_text: &str) { - let (unmarked_text, expected_selections) = marked_text_ranges(marked_text, true); - let buffer_text = self.buffer_text(); - assert_eq!( - buffer_text, unmarked_text, - "Unmarked text doesn't match buffer text" - ); - self.assert_selections(expected_selections, marked_text.to_string()) - } - - pub fn assert_editor_background_highlights(&mut self, marked_text: &str) { - let expected_ranges = self.ranges(marked_text); - let actual_ranges: Vec> = self.update_editor(|editor, cx| { - let snapshot = editor.snapshot(cx); - editor - .background_highlights - .get(&TypeId::of::()) - .map(|h| h.1.clone()) - .unwrap_or_default() - .into_iter() - .map(|range| range.to_offset(&snapshot.buffer_snapshot)) - .collect() - }); - assert_set_eq!(actual_ranges, expected_ranges); - } - - pub fn assert_editor_text_highlights(&mut self, marked_text: &str) { - let expected_ranges = self.ranges(marked_text); - let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx)); - let actual_ranges: Vec> = snapshot - .highlight_ranges::() - .map(|ranges| ranges.as_ref().clone().1) - .unwrap_or_default() - .into_iter() - .map(|range| range.to_offset(&snapshot.buffer_snapshot)) - .collect(); - assert_set_eq!(actual_ranges, expected_ranges); - } - - pub fn assert_editor_selections(&mut self, expected_selections: Vec>) { - let expected_marked_text = - generate_marked_text(&self.buffer_text(), &expected_selections, true); - self.assert_selections(expected_selections, expected_marked_text) - } - - fn assert_selections( - &mut self, - expected_selections: Vec>, - expected_marked_text: String, - ) { - let actual_selections = self - .editor - .read_with(self.cx, |editor, cx| editor.selections.all::(cx)) - .into_iter() - .map(|s| { - if s.reversed { - s.end..s.start - } else { - s.start..s.end - } - }) - .collect::>(); - let actual_marked_text = - generate_marked_text(&self.buffer_text(), &actual_selections, true); - if expected_selections != actual_selections { - panic!( - indoc! {" - Editor has unexpected selections. - - Expected selections: - {} - - Actual selections: - {} - "}, - expected_marked_text, actual_marked_text, - ); - } - } -} - -impl<'a> Deref for EditorTestContext<'a> { - type Target = gpui::TestAppContext; - - fn deref(&self) -> &Self::Target { - self.cx - } -} - -impl<'a> DerefMut for EditorTestContext<'a> { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.cx - } -} - -pub struct EditorLspTestContext<'a> { - pub cx: EditorTestContext<'a>, - pub lsp: lsp::FakeLanguageServer, - pub workspace: ViewHandle, - pub buffer_lsp_url: lsp::Url, -} - -impl<'a> EditorLspTestContext<'a> { - pub async fn new( - mut language: Language, - capabilities: lsp::ServerCapabilities, - cx: &'a mut gpui::TestAppContext, - ) -> EditorLspTestContext<'a> { - use json::json; - - cx.update(|cx| { - crate::init(cx); - pane::init(cx); - }); - - let params = cx.update(AppState::test); - - let file_name = format!( - "file.{}", - language - .path_suffixes() - .first() - .unwrap_or(&"txt".to_string()) - ); - - let mut fake_servers = language - .set_fake_lsp_adapter(Arc::new(FakeLspAdapter { - capabilities, - ..Default::default() - })) - .await; - - let project = Project::test(params.fs.clone(), [], cx).await; - project.update(cx, |project, _| project.languages().add(Arc::new(language))); - - params - .fs - .as_fake() - .insert_tree("/root", json!({ "dir": { file_name: "" }})) - .await; - - let (window_id, workspace) = - cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx)); - project - .update(cx, |project, cx| { - project.find_or_create_local_worktree("/root", true, cx) - }) - .await - .unwrap(); - cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx)) - .await; - - let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone()); - let item = workspace - .update(cx, |workspace, cx| workspace.open_path(file, true, cx)) - .await - .expect("Could not open test file"); - - let editor = cx.update(|cx| { - item.act_as::(cx) - .expect("Opened test file wasn't an editor") - }); - editor.update(cx, |_, cx| cx.focus_self()); - - let lsp = fake_servers.next().await.unwrap(); - - Self { - cx: EditorTestContext { - cx, - window_id, - editor, - }, - lsp, - workspace, - buffer_lsp_url: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(), - } - } - - pub async fn new_rust( - capabilities: lsp::ServerCapabilities, - cx: &'a mut gpui::TestAppContext, - ) -> EditorLspTestContext<'a> { - let language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - - Self::new(language, capabilities, cx).await - } - - // Constructs lsp range using a marked string with '[', ']' range delimiters - pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range { - let ranges = self.ranges(marked_text); - self.to_lsp_range(ranges[0].clone()) - } - - pub fn to_lsp_range(&mut self, range: Range) -> lsp::Range { - let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx)); - let start_point = range.start.to_point(&snapshot.buffer_snapshot); - let end_point = range.end.to_point(&snapshot.buffer_snapshot); - - self.editor(|editor, cx| { - let buffer = editor.buffer().read(cx); - let start = point_to_lsp( - buffer - .point_to_buffer_offset(start_point, cx) - .unwrap() - .1 - .to_point_utf16(&buffer.read(cx)), - ); - let end = point_to_lsp( - buffer - .point_to_buffer_offset(end_point, cx) - .unwrap() - .1 - .to_point_utf16(&buffer.read(cx)), - ); - - lsp::Range { start, end } - }) - } - - pub fn to_lsp(&mut self, offset: usize) -> lsp::Position { - let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx)); - let point = offset.to_point(&snapshot.buffer_snapshot); - - self.editor(|editor, cx| { - let buffer = editor.buffer().read(cx); - point_to_lsp( - buffer - .point_to_buffer_offset(point, cx) - .unwrap() - .1 - .to_point_utf16(&buffer.read(cx)), - ) - }) - } - - pub fn update_workspace(&mut self, update: F) -> T - where - F: FnOnce(&mut Workspace, &mut ViewContext) -> T, - { - self.workspace.update(self.cx.cx, update) - } - - pub fn handle_request( - &self, - mut handler: F, - ) -> futures::channel::mpsc::UnboundedReceiver<()> - where - T: 'static + request::Request, - T::Params: 'static + Send, - F: 'static + Send + FnMut(lsp::Url, T::Params, gpui::AsyncAppContext) -> Fut, - Fut: 'static + Send + Future>, - { - let url = self.buffer_lsp_url.clone(); - self.lsp.handle_request::(move |params, cx| { - let url = url.clone(); - handler(url, params, cx) - }) - } - - pub fn notify(&self, params: T::Params) { - self.lsp.notify::(params); - } -} - -impl<'a> Deref for EditorLspTestContext<'a> { - type Target = EditorTestContext<'a>; - - fn deref(&self) -> &Self::Target { - &self.cx - } -} - -impl<'a> DerefMut for EditorLspTestContext<'a> { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.cx - } -} diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs new file mode 100644 index 0000000000..b4a4cd7ab8 --- /dev/null +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -0,0 +1,208 @@ +use std::{ + ops::{Deref, DerefMut, Range}, + sync::Arc, +}; + +use anyhow::Result; + +use futures::Future; +use gpui::{json, ViewContext, ViewHandle}; +use language::{point_to_lsp, FakeLspAdapter, Language, LanguageConfig}; +use lsp::{notification, request}; +use project::Project; +use smol::stream::StreamExt; +use workspace::{pane, AppState, Workspace, WorkspaceHandle}; + +use crate::{multi_buffer::ToPointUtf16, Editor, ToPoint}; + +use super::editor_test_context::EditorTestContext; + +pub struct EditorLspTestContext<'a> { + pub cx: EditorTestContext<'a>, + pub lsp: lsp::FakeLanguageServer, + pub workspace: ViewHandle, + pub buffer_lsp_url: lsp::Url, +} + +impl<'a> EditorLspTestContext<'a> { + pub async fn new( + mut language: Language, + capabilities: lsp::ServerCapabilities, + cx: &'a mut gpui::TestAppContext, + ) -> EditorLspTestContext<'a> { + use json::json; + + cx.update(|cx| { + crate::init(cx); + pane::init(cx); + }); + + let params = cx.update(AppState::test); + + let file_name = format!( + "file.{}", + language + .path_suffixes() + .first() + .unwrap_or(&"txt".to_string()) + ); + + let mut fake_servers = language + .set_fake_lsp_adapter(Arc::new(FakeLspAdapter { + capabilities, + ..Default::default() + })) + .await; + + let project = Project::test(params.fs.clone(), [], cx).await; + project.update(cx, |project, _| project.languages().add(Arc::new(language))); + + params + .fs + .as_fake() + .insert_tree("/root", json!({ "dir": { file_name: "" }})) + .await; + + let (window_id, workspace) = + cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx)); + project + .update(cx, |project, cx| { + project.find_or_create_local_worktree("/root", true, cx) + }) + .await + .unwrap(); + cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx)) + .await; + + let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone()); + let item = workspace + .update(cx, |workspace, cx| workspace.open_path(file, true, cx)) + .await + .expect("Could not open test file"); + + let editor = cx.update(|cx| { + item.act_as::(cx) + .expect("Opened test file wasn't an editor") + }); + editor.update(cx, |_, cx| cx.focus_self()); + + let lsp = fake_servers.next().await.unwrap(); + + Self { + cx: EditorTestContext { + cx, + window_id, + editor, + }, + lsp, + workspace, + buffer_lsp_url: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(), + } + } + + pub async fn new_rust( + capabilities: lsp::ServerCapabilities, + cx: &'a mut gpui::TestAppContext, + ) -> EditorLspTestContext<'a> { + let language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + + Self::new(language, capabilities, cx).await + } + + // Constructs lsp range using a marked string with '[', ']' range delimiters + pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range { + let ranges = self.ranges(marked_text); + self.to_lsp_range(ranges[0].clone()) + } + + pub fn to_lsp_range(&mut self, range: Range) -> lsp::Range { + let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx)); + let start_point = range.start.to_point(&snapshot.buffer_snapshot); + let end_point = range.end.to_point(&snapshot.buffer_snapshot); + + self.editor(|editor, cx| { + let buffer = editor.buffer().read(cx); + let start = point_to_lsp( + buffer + .point_to_buffer_offset(start_point, cx) + .unwrap() + .1 + .to_point_utf16(&buffer.read(cx)), + ); + let end = point_to_lsp( + buffer + .point_to_buffer_offset(end_point, cx) + .unwrap() + .1 + .to_point_utf16(&buffer.read(cx)), + ); + + lsp::Range { start, end } + }) + } + + pub fn to_lsp(&mut self, offset: usize) -> lsp::Position { + let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx)); + let point = offset.to_point(&snapshot.buffer_snapshot); + + self.editor(|editor, cx| { + let buffer = editor.buffer().read(cx); + point_to_lsp( + buffer + .point_to_buffer_offset(point, cx) + .unwrap() + .1 + .to_point_utf16(&buffer.read(cx)), + ) + }) + } + + pub fn update_workspace(&mut self, update: F) -> T + where + F: FnOnce(&mut Workspace, &mut ViewContext) -> T, + { + self.workspace.update(self.cx.cx, update) + } + + pub fn handle_request( + &self, + mut handler: F, + ) -> futures::channel::mpsc::UnboundedReceiver<()> + where + T: 'static + request::Request, + T::Params: 'static + Send, + F: 'static + Send + FnMut(lsp::Url, T::Params, gpui::AsyncAppContext) -> Fut, + Fut: 'static + Send + Future>, + { + let url = self.buffer_lsp_url.clone(); + self.lsp.handle_request::(move |params, cx| { + let url = url.clone(); + handler(url, params, cx) + }) + } + + pub fn notify(&self, params: T::Params) { + self.lsp.notify::(params); + } +} + +impl<'a> Deref for EditorLspTestContext<'a> { + type Target = EditorTestContext<'a>; + + fn deref(&self) -> &Self::Target { + &self.cx + } +} + +impl<'a> DerefMut for EditorLspTestContext<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.cx + } +} diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs new file mode 100644 index 0000000000..73dc6bfd6e --- /dev/null +++ b/crates/editor/src/test/editor_test_context.rs @@ -0,0 +1,273 @@ +use std::{ + any::TypeId, + ops::{Deref, DerefMut, Range}, +}; + +use futures::Future; +use indoc::indoc; + +use crate::{ + display_map::ToDisplayPoint, AnchorRangeExt, Autoscroll, DisplayPoint, Editor, MultiBuffer, +}; +use gpui::{keymap::Keystroke, AppContext, ContextHandle, ModelContext, ViewContext, ViewHandle}; +use language::{Buffer, BufferSnapshot}; +use settings::Settings; +use util::{ + assert_set_eq, + test::{generate_marked_text, marked_text_ranges}, +}; + +use super::build_editor; + +pub struct EditorTestContext<'a> { + pub cx: &'a mut gpui::TestAppContext, + pub window_id: usize, + pub editor: ViewHandle, +} + +impl<'a> EditorTestContext<'a> { + pub fn new(cx: &'a mut gpui::TestAppContext) -> EditorTestContext<'a> { + let (window_id, editor) = cx.update(|cx| { + cx.set_global(Settings::test(cx)); + crate::init(cx); + + let (window_id, editor) = cx.add_window(Default::default(), |cx| { + build_editor(MultiBuffer::build_simple("", cx), cx) + }); + + editor.update(cx, |_, cx| cx.focus_self()); + + (window_id, editor) + }); + + Self { + cx, + window_id, + editor, + } + } + + pub fn condition( + &self, + predicate: impl FnMut(&Editor, &AppContext) -> bool, + ) -> impl Future { + self.editor.condition(self.cx, predicate) + } + + pub fn editor(&self, read: F) -> T + where + F: FnOnce(&Editor, &AppContext) -> T, + { + self.editor.read_with(self.cx, read) + } + + pub fn update_editor(&mut self, update: F) -> T + where + F: FnOnce(&mut Editor, &mut ViewContext) -> T, + { + self.editor.update(self.cx, update) + } + + pub fn multibuffer(&self, read: F) -> T + where + F: FnOnce(&MultiBuffer, &AppContext) -> T, + { + self.editor(|editor, cx| read(editor.buffer().read(cx), cx)) + } + + pub fn update_multibuffer(&mut self, update: F) -> T + where + F: FnOnce(&mut MultiBuffer, &mut ModelContext) -> T, + { + self.update_editor(|editor, cx| editor.buffer().update(cx, update)) + } + + pub fn buffer_text(&self) -> String { + self.multibuffer(|buffer, cx| buffer.snapshot(cx).text()) + } + + pub fn buffer(&self, read: F) -> T + where + F: FnOnce(&Buffer, &AppContext) -> T, + { + self.multibuffer(|multibuffer, cx| { + let buffer = multibuffer.as_singleton().unwrap().read(cx); + read(buffer, cx) + }) + } + + pub fn update_buffer(&mut self, update: F) -> T + where + F: FnOnce(&mut Buffer, &mut ModelContext) -> T, + { + self.update_multibuffer(|multibuffer, cx| { + let buffer = multibuffer.as_singleton().unwrap(); + buffer.update(cx, update) + }) + } + + pub fn buffer_snapshot(&self) -> BufferSnapshot { + self.buffer(|buffer, _| buffer.snapshot()) + } + + pub fn simulate_keystroke(&mut self, keystroke_text: &str) -> ContextHandle { + let keystroke_under_test_handle = + self.add_assertion_context(format!("Simulated Keystroke: {:?}", keystroke_text)); + let keystroke = Keystroke::parse(keystroke_text).unwrap(); + self.cx.dispatch_keystroke(self.window_id, keystroke, false); + keystroke_under_test_handle + } + + pub fn simulate_keystrokes( + &mut self, + keystroke_texts: [&str; COUNT], + ) -> ContextHandle { + let keystrokes_under_test_handle = + self.add_assertion_context(format!("Simulated Keystrokes: {:?}", keystroke_texts)); + for keystroke_text in keystroke_texts.into_iter() { + self.simulate_keystroke(keystroke_text); + } + keystrokes_under_test_handle + } + + pub fn ranges(&self, marked_text: &str) -> Vec> { + let (unmarked_text, ranges) = marked_text_ranges(marked_text, false); + assert_eq!(self.buffer_text(), unmarked_text); + ranges + } + + pub fn display_point(&mut self, marked_text: &str) -> DisplayPoint { + let ranges = self.ranges(marked_text); + let snapshot = self + .editor + .update(self.cx, |editor, cx| editor.snapshot(cx)); + ranges[0].start.to_display_point(&snapshot) + } + + // Returns anchors for the current buffer using `«` and `»` + pub fn text_anchor_range(&self, marked_text: &str) -> Range { + let ranges = self.ranges(marked_text); + let snapshot = self.buffer_snapshot(); + snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end) + } + + /// Change the editor's text and selections using a string containing + /// embedded range markers that represent the ranges and directions of + /// each selection. + /// + /// See the `util::test::marked_text_ranges` function for more information. + pub fn set_state(&mut self, marked_text: &str) -> ContextHandle { + let _state_context = self.add_assertion_context(format!( + "Editor State: \"{}\"", + marked_text.escape_debug().to_string() + )); + let (unmarked_text, selection_ranges) = marked_text_ranges(marked_text, true); + self.editor.update(self.cx, |editor, cx| { + editor.set_text(unmarked_text, cx); + editor.change_selections(Some(Autoscroll::Fit), cx, |s| { + s.select_ranges(selection_ranges) + }) + }); + _state_context + } + + /// Make an assertion about the editor's text and the ranges and directions + /// of its selections using a string containing embedded range markers. + /// + /// See the `util::test::marked_text_ranges` function for more information. + pub fn assert_editor_state(&mut self, marked_text: &str) { + let (unmarked_text, expected_selections) = marked_text_ranges(marked_text, true); + let buffer_text = self.buffer_text(); + assert_eq!( + buffer_text, unmarked_text, + "Unmarked text doesn't match buffer text" + ); + self.assert_selections(expected_selections, marked_text.to_string()) + } + + pub fn assert_editor_background_highlights(&mut self, marked_text: &str) { + let expected_ranges = self.ranges(marked_text); + let actual_ranges: Vec> = self.update_editor(|editor, cx| { + let snapshot = editor.snapshot(cx); + editor + .background_highlights + .get(&TypeId::of::()) + .map(|h| h.1.clone()) + .unwrap_or_default() + .into_iter() + .map(|range| range.to_offset(&snapshot.buffer_snapshot)) + .collect() + }); + assert_set_eq!(actual_ranges, expected_ranges); + } + + pub fn assert_editor_text_highlights(&mut self, marked_text: &str) { + let expected_ranges = self.ranges(marked_text); + let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx)); + let actual_ranges: Vec> = snapshot + .highlight_ranges::() + .map(|ranges| ranges.as_ref().clone().1) + .unwrap_or_default() + .into_iter() + .map(|range| range.to_offset(&snapshot.buffer_snapshot)) + .collect(); + assert_set_eq!(actual_ranges, expected_ranges); + } + + pub fn assert_editor_selections(&mut self, expected_selections: Vec>) { + let expected_marked_text = + generate_marked_text(&self.buffer_text(), &expected_selections, true); + self.assert_selections(expected_selections, expected_marked_text) + } + + fn assert_selections( + &mut self, + expected_selections: Vec>, + expected_marked_text: String, + ) { + let actual_selections = self + .editor + .read_with(self.cx, |editor, cx| editor.selections.all::(cx)) + .into_iter() + .map(|s| { + if s.reversed { + s.end..s.start + } else { + s.start..s.end + } + }) + .collect::>(); + let actual_marked_text = + generate_marked_text(&self.buffer_text(), &actual_selections, true); + if expected_selections != actual_selections { + panic!( + indoc! {" + {}Editor has unexpected selections. + + Expected selections: + {} + + Actual selections: + {} + "}, + self.assertion_context(), + expected_marked_text, + actual_marked_text, + ); + } + } +} + +impl<'a> Deref for EditorTestContext<'a> { + type Target = gpui::TestAppContext; + + fn deref(&self) -> &Self::Target { + self.cx + } +} + +impl<'a> DerefMut for EditorTestContext<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.cx + } +} diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index aa2174b959..e787e3c1a6 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -49,8 +49,8 @@ impl View for FileFinder { "FileFinder" } - fn render(&mut self, _: &mut RenderContext) -> ElementBox { - ChildView::new(self.picker.clone()).boxed() + fn render(&mut self, cx: &mut RenderContext) -> ElementBox { + ChildView::new(self.picker.clone(), cx).boxed() } fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { @@ -251,7 +251,7 @@ impl PickerDelegate for FileFinder { fn render_match( &self, ix: usize, - mouse_state: MouseState, + mouse_state: &mut MouseState, selected: bool, cx: &AppContext, ) -> ElementBox { diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml new file mode 100644 index 0000000000..5b9082d114 --- /dev/null +++ b/crates/fs/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "fs" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/fs.rs" + +[dependencies] +collections = { path = "../collections" } +gpui = { path = "../gpui" } +lsp = { path = "../lsp" } +rope = { path = "../rope" } +util = { path = "../util" } +anyhow = "1.0.57" +async-trait = "0.1" +futures = "0.3" +tempfile = "3" +fsevent = { path = "../fsevent" } +lazy_static = "1.4.0" +parking_lot = "0.11.1" +smol = "1.2.5" +regex = "1.5" +git2 = { version = "0.15", default-features = false } +serde = { workspace = true } +serde_json = { workspace = true } +log = { version = "0.4.16", features = ["kv_unstable_serde"] } +libc = "0.2" + +[features] +test-support = [] diff --git a/crates/project/src/fs.rs b/crates/fs/src/fs.rs similarity index 85% rename from crates/project/src/fs.rs rename to crates/fs/src/fs.rs index f2d62fae87..2061d3734b 100644 --- a/crates/project/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -1,8 +1,19 @@ +pub mod repository; + use anyhow::{anyhow, Result}; use fsevent::EventStream; use futures::{future::BoxFuture, Stream, StreamExt}; -use language::LineEnding; +use git2::Repository as LibGitRepository; +use lazy_static::lazy_static; +use parking_lot::Mutex as SyncMutex; +use regex::Regex; +use repository::GitRepository; +use rope::Rope; use smol::io::{AsyncReadExt, AsyncWriteExt}; +use std::borrow::Cow; +use std::cmp; +use std::io::Write; +use std::sync::Arc; use std::{ io, os::unix::fs::MetadataExt, @@ -10,15 +21,77 @@ use std::{ pin::Pin, time::{Duration, SystemTime}, }; -use text::Rope; +use tempfile::NamedTempFile; +use util::ResultExt; #[cfg(any(test, feature = "test-support"))] use collections::{btree_map, BTreeMap}; #[cfg(any(test, feature = "test-support"))] use futures::lock::Mutex; #[cfg(any(test, feature = "test-support"))] -use std::sync::{Arc, Weak}; +use repository::FakeGitRepositoryState; +#[cfg(any(test, feature = "test-support"))] +use std::sync::Weak; +lazy_static! { + static ref CARRIAGE_RETURNS_REGEX: Regex = Regex::new("\r\n|\r").unwrap(); +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum LineEnding { + Unix, + Windows, +} + +impl Default for LineEnding { + fn default() -> Self { + #[cfg(unix)] + return Self::Unix; + + #[cfg(not(unix))] + return Self::CRLF; + } +} + +impl LineEnding { + pub fn as_str(&self) -> &'static str { + match self { + LineEnding::Unix => "\n", + LineEnding::Windows => "\r\n", + } + } + + pub fn detect(text: &str) -> Self { + let mut max_ix = cmp::min(text.len(), 1000); + while !text.is_char_boundary(max_ix) { + max_ix -= 1; + } + + if let Some(ix) = text[..max_ix].find(&['\n']) { + if ix > 0 && text.as_bytes()[ix - 1] == b'\r' { + Self::Windows + } else { + Self::Unix + } + } else { + Self::default() + } + } + + pub fn normalize(text: &mut String) { + if let Cow::Owned(replaced) = CARRIAGE_RETURNS_REGEX.replace_all(text, "\n") { + *text = replaced; + } + } + + pub fn normalize_arc(text: Arc) -> Arc { + if let Cow::Owned(replaced) = CARRIAGE_RETURNS_REGEX.replace_all(&text, "\n") { + replaced.into() + } else { + text + } + } +} #[async_trait::async_trait] pub trait Fs: Send + Sync { async fn create_dir(&self, path: &Path) -> Result<()>; @@ -29,6 +102,7 @@ pub trait Fs: Send + Sync { async fn remove_file(&self, path: &Path, options: RemoveOptions) -> Result<()>; async fn open_sync(&self, path: &Path) -> Result>; async fn load(&self, path: &Path) -> Result; + async fn atomic_write(&self, path: PathBuf, text: String) -> Result<()>; async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()>; async fn canonicalize(&self, path: &Path) -> Result; async fn is_file(&self, path: &Path) -> bool; @@ -42,6 +116,7 @@ pub trait Fs: Send + Sync { path: &Path, latency: Duration, ) -> Pin>>>; + fn open_repo(&self, abs_dot_git: &Path) -> Option>>; fn is_fake(&self) -> bool; #[cfg(any(test, feature = "test-support"))] fn as_fake(&self) -> &FakeFs; @@ -79,6 +154,33 @@ pub struct Metadata { pub is_dir: bool, } +impl From for CreateOptions { + fn from(options: lsp::CreateFileOptions) -> Self { + Self { + overwrite: options.overwrite.unwrap_or(false), + ignore_if_exists: options.ignore_if_exists.unwrap_or(false), + } + } +} + +impl From for RenameOptions { + fn from(options: lsp::RenameFileOptions) -> Self { + Self { + overwrite: options.overwrite.unwrap_or(false), + ignore_if_exists: options.ignore_if_exists.unwrap_or(false), + } + } +} + +impl From for RemoveOptions { + fn from(options: lsp::DeleteFileOptions) -> Self { + Self { + recursive: options.recursive.unwrap_or(false), + ignore_if_not_exists: options.ignore_if_not_exists.unwrap_or(false), + } + } +} + pub struct RealFs; #[async_trait::async_trait] @@ -161,6 +263,18 @@ impl Fs for RealFs { Ok(text) } + async fn atomic_write(&self, path: PathBuf, data: String) -> Result<()> { + smol::unblock(move || { + let mut tmp_file = NamedTempFile::new()?; + tmp_file.write_all(data.as_bytes())?; + tmp_file.persist(path)?; + Ok::<(), anyhow::Error>(()) + }) + .await?; + + Ok(()) + } + async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()> { let buffer_size = text.summary().len.min(10 * 1024); let file = smol::fs::File::create(path).await?; @@ -235,6 +349,14 @@ impl Fs for RealFs { }))) } + fn open_repo(&self, dotgit_path: &Path) -> Option>> { + LibGitRepository::open(&dotgit_path) + .log_err() + .and_then::>, _>(|libgit_repository| { + Some(Arc::new(SyncMutex::new(libgit_repository))) + }) + } + fn is_fake(&self) -> bool { false } @@ -270,6 +392,7 @@ enum FakeFsEntry { inode: u64, mtime: SystemTime, entries: BTreeMap>>, + git_repo_state: Option>>, }, Symlink { target: PathBuf, @@ -384,6 +507,7 @@ impl FakeFs { inode: 0, mtime: SystemTime::now(), entries: Default::default(), + git_repo_state: None, })), next_inode: 1, event_txs: Default::default(), @@ -473,6 +597,28 @@ impl FakeFs { .boxed() } + pub async fn set_index_for_repo(&self, dot_git: &Path, head_state: &[(&Path, String)]) { + let mut state = self.state.lock().await; + let entry = state.read_path(dot_git).await.unwrap(); + let mut entry = entry.lock().await; + + if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry { + let repo_state = git_repo_state.get_or_insert_with(Default::default); + let mut repo_state = repo_state.lock(); + + repo_state.index_contents.clear(); + repo_state.index_contents.extend( + head_state + .iter() + .map(|(path, content)| (path.to_path_buf(), content.clone())), + ); + + state.emit_event([dot_git]); + } else { + panic!("not a directory"); + } + } + pub async fn files(&self) -> Vec { let mut result = Vec::new(); let mut queue = collections::VecDeque::new(); @@ -562,6 +708,7 @@ impl Fs for FakeFs { inode, mtime: SystemTime::now(), entries: Default::default(), + git_repo_state: None, })) }); Ok(()) @@ -748,6 +895,14 @@ impl Fs for FakeFs { entry.file_content(&path).cloned() } + async fn atomic_write(&self, path: PathBuf, data: String) -> Result<()> { + self.simulate_random_delay().await; + let path = normalize_path(path.as_path()); + self.insert_file(path, data.to_string()).await; + + Ok(()) + } + async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()> { self.simulate_random_delay().await; let path = normalize_path(path); @@ -846,6 +1001,24 @@ impl Fs for FakeFs { })) } + fn open_repo(&self, abs_dot_git: &Path) -> Option>> { + smol::block_on(async move { + let state = self.state.lock().await; + let entry = state.read_path(abs_dot_git).await.unwrap(); + let mut entry = entry.lock().await; + if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry { + let state = git_repo_state + .get_or_insert_with(|| { + Arc::new(SyncMutex::new(FakeGitRepositoryState::default())) + }) + .clone(); + Some(repository::FakeGitRepository::open(state)) + } else { + None + } + }) + } + fn is_fake(&self) -> bool { true } diff --git a/crates/fs/src/repository.rs b/crates/fs/src/repository.rs new file mode 100644 index 0000000000..ce881d2b0f --- /dev/null +++ b/crates/fs/src/repository.rs @@ -0,0 +1,71 @@ +use anyhow::Result; +use collections::HashMap; +use parking_lot::Mutex; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +pub use git2::Repository as LibGitRepository; + +#[async_trait::async_trait] +pub trait GitRepository: Send { + fn reload_index(&self); + + fn load_index_text(&self, relative_file_path: &Path) -> Option; +} + +#[async_trait::async_trait] +impl GitRepository for LibGitRepository { + fn reload_index(&self) { + if let Ok(mut index) = self.index() { + _ = index.read(false); + } + } + + fn load_index_text(&self, relative_file_path: &Path) -> Option { + fn logic(repo: &LibGitRepository, relative_file_path: &Path) -> Result> { + const STAGE_NORMAL: i32 = 0; + let index = repo.index()?; + let oid = match index.get_path(relative_file_path, STAGE_NORMAL) { + Some(entry) => entry.id, + None => return Ok(None), + }; + + let content = repo.find_blob(oid)?.content().to_owned(); + Ok(Some(String::from_utf8(content)?)) + } + + match logic(&self, relative_file_path) { + Ok(value) => return value, + Err(err) => log::error!("Error loading head text: {:?}", err), + } + None + } +} + +#[derive(Debug, Clone, Default)] +pub struct FakeGitRepository { + state: Arc>, +} + +#[derive(Debug, Clone, Default)] +pub struct FakeGitRepositoryState { + pub index_contents: HashMap, +} + +impl FakeGitRepository { + pub fn open(state: Arc>) -> Arc> { + Arc::new(Mutex::new(FakeGitRepository { state })) + } +} + +#[async_trait::async_trait] +impl GitRepository for FakeGitRepository { + fn reload_index(&self) {} + + fn load_index_text(&self, path: &Path) -> Option { + let state = self.state.lock(); + state.index_contents.get(path).cloned() + } +} diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml new file mode 100644 index 0000000000..66202a489a --- /dev/null +++ b/crates/git/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "git" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/git.rs" + +[dependencies] +anyhow = "1.0.38" +clock = { path = "../clock" } +lazy_static = "1.4.0" +sum_tree = { path = "../sum_tree" } +text = { path = "../text" } +collections = { path = "../collections" } +util = { path = "../util" } +log = { version = "0.4.16", features = ["kv_unstable_serde"] } +smol = "1.2" +parking_lot = "0.11.1" +async-trait = "0.1" +futures = "0.3" +git2 = { version = "0.15", default-features = false } + +[dev-dependencies] +unindent = "0.1.7" + +[features] +test-support = [] diff --git a/crates/git/src/diff.rs b/crates/git/src/diff.rs new file mode 100644 index 0000000000..900f8967d7 --- /dev/null +++ b/crates/git/src/diff.rs @@ -0,0 +1,362 @@ +use std::ops::Range; +use sum_tree::SumTree; +use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point}; + +pub use git2 as libgit; +use libgit::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffHunkStatus { + Added, + Modified, + Removed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiffHunk { + pub buffer_range: Range, + pub head_byte_range: Range, +} + +impl DiffHunk { + pub fn status(&self) -> DiffHunkStatus { + if self.head_byte_range.is_empty() { + DiffHunkStatus::Added + } else if self.buffer_range.is_empty() { + DiffHunkStatus::Removed + } else { + DiffHunkStatus::Modified + } + } +} + +impl sum_tree::Item for DiffHunk { + type Summary = DiffHunkSummary; + + fn summary(&self) -> Self::Summary { + DiffHunkSummary { + buffer_range: self.buffer_range.clone(), + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct DiffHunkSummary { + buffer_range: Range, +} + +impl sum_tree::Summary for DiffHunkSummary { + type Context = text::BufferSnapshot; + + fn add_summary(&mut self, other: &Self, buffer: &Self::Context) { + self.buffer_range.start = self + .buffer_range + .start + .min(&other.buffer_range.start, buffer); + self.buffer_range.end = self.buffer_range.end.max(&other.buffer_range.end, buffer); + } +} + +#[derive(Clone)] +pub struct BufferDiff { + last_buffer_version: Option, + tree: SumTree>, +} + +impl BufferDiff { + pub fn new() -> BufferDiff { + BufferDiff { + last_buffer_version: None, + tree: SumTree::new(), + } + } + + pub fn hunks_in_range<'a>( + &'a self, + query_row_range: Range, + buffer: &'a BufferSnapshot, + ) -> impl 'a + Iterator> { + let start = buffer.anchor_before(Point::new(query_row_range.start, 0)); + let end = buffer.anchor_after(Point::new(query_row_range.end, 0)); + + let mut cursor = self.tree.filter::<_, DiffHunkSummary>(move |summary| { + let before_start = summary.buffer_range.end.cmp(&start, buffer).is_lt(); + let after_end = summary.buffer_range.start.cmp(&end, buffer).is_gt(); + !before_start && !after_end + }); + + std::iter::from_fn(move || { + cursor.next(buffer); + let hunk = cursor.item()?; + + let range = hunk.buffer_range.to_point(buffer); + let end_row = if range.end.column > 0 { + range.end.row + 1 + } else { + range.end.row + }; + + Some(DiffHunk { + buffer_range: range.start.row..end_row, + head_byte_range: hunk.head_byte_range.clone(), + }) + }) + } + + pub fn clear(&mut self, buffer: &text::BufferSnapshot) { + self.last_buffer_version = Some(buffer.version().clone()); + self.tree = SumTree::new(); + } + + pub fn needs_update(&self, buffer: &text::BufferSnapshot) -> bool { + match &self.last_buffer_version { + Some(last) => buffer.version().changed_since(last), + None => true, + } + } + + pub async fn update(&mut self, diff_base: &str, buffer: &text::BufferSnapshot) { + let mut tree = SumTree::new(); + + let buffer_text = buffer.as_rope().to_string(); + let patch = Self::diff(&diff_base, &buffer_text); + + if let Some(patch) = patch { + let mut divergence = 0; + for hunk_index in 0..patch.num_hunks() { + let hunk = Self::process_patch_hunk(&patch, hunk_index, buffer, &mut divergence); + tree.push(hunk, buffer); + } + } + + self.tree = tree; + self.last_buffer_version = Some(buffer.version().clone()); + } + + #[cfg(test)] + fn hunks<'a>(&'a self, text: &'a BufferSnapshot) -> impl 'a + Iterator> { + self.hunks_in_range(0..u32::MAX, text) + } + + fn diff<'a>(head: &'a str, current: &'a str) -> Option> { + let mut options = GitOptions::default(); + options.context_lines(0); + + let patch = GitPatch::from_buffers( + head.as_bytes(), + None, + current.as_bytes(), + None, + Some(&mut options), + ); + + match patch { + Ok(patch) => Some(patch), + + Err(err) => { + log::error!("`GitPatch::from_buffers` failed: {}", err); + None + } + } + } + + fn process_patch_hunk<'a>( + patch: &GitPatch<'a>, + hunk_index: usize, + buffer: &text::BufferSnapshot, + buffer_row_divergence: &mut i64, + ) -> DiffHunk { + let line_item_count = patch.num_lines_in_hunk(hunk_index).unwrap(); + assert!(line_item_count > 0); + + let mut first_deletion_buffer_row: Option = None; + let mut buffer_row_range: Option> = None; + let mut head_byte_range: Option> = None; + + for line_index in 0..line_item_count { + let line = patch.line_in_hunk(hunk_index, line_index).unwrap(); + let kind = line.origin_value(); + let content_offset = line.content_offset() as isize; + let content_len = line.content().len() as isize; + + if kind == GitDiffLineType::Addition { + *buffer_row_divergence += 1; + let row = line.new_lineno().unwrap().saturating_sub(1); + + match &mut buffer_row_range { + Some(buffer_row_range) => buffer_row_range.end = row + 1, + None => buffer_row_range = Some(row..row + 1), + } + } + + if kind == GitDiffLineType::Deletion { + let end = content_offset + content_len; + + match &mut head_byte_range { + Some(head_byte_range) => head_byte_range.end = end as usize, + None => head_byte_range = Some(content_offset as usize..end as usize), + } + + if first_deletion_buffer_row.is_none() { + let old_row = line.old_lineno().unwrap().saturating_sub(1); + let row = old_row as i64 + *buffer_row_divergence; + first_deletion_buffer_row = Some(row as u32); + } + + *buffer_row_divergence -= 1; + } + } + + //unwrap_or deletion without addition + let buffer_row_range = buffer_row_range.unwrap_or_else(|| { + //we cannot have an addition-less hunk without deletion(s) or else there would be no hunk + let row = first_deletion_buffer_row.unwrap(); + row..row + }); + + //unwrap_or addition without deletion + let head_byte_range = head_byte_range.unwrap_or(0..0); + + let start = Point::new(buffer_row_range.start, 0); + let end = Point::new(buffer_row_range.end, 0); + let buffer_range = buffer.anchor_before(start)..buffer.anchor_before(end); + DiffHunk { + buffer_range, + head_byte_range, + } + } +} + +/// Range (crossing new lines), old, new +#[cfg(any(test, feature = "test-support"))] +#[track_caller] +pub fn assert_hunks( + diff_hunks: Iter, + buffer: &BufferSnapshot, + diff_base: &str, + expected_hunks: &[(Range, &str, &str)], +) where + Iter: Iterator>, +{ + let actual_hunks = diff_hunks + .map(|hunk| { + ( + hunk.buffer_range.clone(), + &diff_base[hunk.head_byte_range], + buffer + .text_for_range( + Point::new(hunk.buffer_range.start, 0) + ..Point::new(hunk.buffer_range.end, 0), + ) + .collect::(), + ) + }) + .collect::>(); + + let expected_hunks: Vec<_> = expected_hunks + .iter() + .map(|(r, s, h)| (r.clone(), *s, h.to_string())) + .collect(); + + assert_eq!(actual_hunks, expected_hunks); +} + +#[cfg(test)] +mod tests { + use super::*; + use text::Buffer; + use unindent::Unindent as _; + + #[test] + fn test_buffer_diff_simple() { + let diff_base = " + one + two + three + " + .unindent(); + + let buffer_text = " + one + HELLO + three + " + .unindent(); + + let mut buffer = Buffer::new(0, 0, buffer_text); + let mut diff = BufferDiff::new(); + smol::block_on(diff.update(&diff_base, &buffer)); + assert_hunks( + diff.hunks(&buffer), + &buffer, + &diff_base, + &[(1..2, "two\n", "HELLO\n")], + ); + + buffer.edit([(0..0, "point five\n")]); + smol::block_on(diff.update(&diff_base, &buffer)); + assert_hunks( + diff.hunks(&buffer), + &buffer, + &diff_base, + &[(0..1, "", "point five\n"), (2..3, "two\n", "HELLO\n")], + ); + + diff.clear(&buffer); + assert_hunks(diff.hunks(&buffer), &buffer, &diff_base, &[]); + } + + #[test] + fn test_buffer_diff_range() { + let diff_base = " + one + two + three + four + five + six + seven + eight + nine + ten + " + .unindent(); + + let buffer_text = " + A + one + B + two + C + three + HELLO + four + five + SIXTEEN + seven + eight + WORLD + nine + + ten + + " + .unindent(); + + let buffer = Buffer::new(0, 0, buffer_text); + let mut diff = BufferDiff::new(); + smol::block_on(diff.update(&diff_base, &buffer)); + assert_eq!(diff.hunks(&buffer).count(), 8); + + assert_hunks( + diff.hunks_in_range(7..12, &buffer), + &buffer, + &diff_base, + &[ + (6..7, "", "HELLO\n"), + (9..10, "six\n", "SIXTEEN\n"), + (12..13, "", "WORLD\n"), + ], + ); + } +} diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs new file mode 100644 index 0000000000..b1b885eca2 --- /dev/null +++ b/crates/git/src/git.rs @@ -0,0 +1,11 @@ +use std::ffi::OsStr; + +pub use git2 as libgit; +pub use lazy_static::lazy_static; + +pub mod diff; + +lazy_static! { + pub static ref DOT_GIT: &'static OsStr = OsStr::new(".git"); + pub static ref GITIGNORE: &'static OsStr = OsStr::new(".gitignore"); +} diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index 3ca50cee42..eddb014b63 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/crates/go_to_line/src/go_to_line.rs @@ -165,7 +165,7 @@ impl View for GoToLine { Container::new( Flex::new(Axis::Vertical) .with_child( - Container::new(ChildView::new(&self.line_editor).boxed()) + Container::new(ChildView::new(&self.line_editor, cx).boxed()) .with_style(theme.input_editor.container) .boxed(), ) diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 51bc416e19..54fe5e46a2 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -25,6 +25,7 @@ env_logger = { version = "0.9", optional = true } etagere = "0.2" futures = "0.3" image = "0.23" +itertools = "0.10" lazy_static = "1.4.0" log = { version = "0.4.16", features = ["kv_unstable_serde"] } num_cpus = "1.13" diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 308ea6c831..a9529f3f9f 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1,28 +1,8 @@ pub mod action; mod callback_collection; +#[cfg(any(test, feature = "test-support"))] +pub mod test_app_context; -use crate::{ - elements::ElementBox, - executor::{self, Task}, - geometry::rect::RectF, - keymap::{self, Binding, Keystroke}, - platform::{self, KeyDownEvent, Platform, PromptLevel, WindowOptions}, - presenter::Presenter, - util::post_inc, - Appearance, AssetCache, AssetSource, ClipboardItem, FontCache, InputHandler, MouseButton, - MouseRegionId, PathPromptOptions, TextLayoutCache, -}; -pub use action::*; -use anyhow::{anyhow, Context, Result}; -use callback_collection::CallbackCollection; -use collections::{btree_map, hash_map::Entry, BTreeMap, HashMap, HashSet, VecDeque}; -use keymap::MatchResult; -use lazy_static::lazy_static; -use parking_lot::Mutex; -use platform::Event; -use postage::oneshot; -use smallvec::SmallVec; -use smol::prelude::*; use std::{ any::{type_name, Any, TypeId}, cell::RefCell, @@ -38,7 +18,32 @@ use std::{ time::Duration, }; -use self::callback_collection::Mapping; +use anyhow::{anyhow, Context, Result}; +use lazy_static::lazy_static; +use parking_lot::Mutex; +use postage::oneshot; +use smallvec::SmallVec; +use smol::prelude::*; + +pub use action::*; +use callback_collection::{CallbackCollection, Mapping}; +use collections::{btree_map, hash_map::Entry, BTreeMap, HashMap, HashSet, VecDeque}; +use keymap::MatchResult; +use platform::Event; +#[cfg(any(test, feature = "test-support"))] +pub use test_app_context::{ContextHandle, TestAppContext}; + +use crate::{ + elements::ElementBox, + executor::{self, Task}, + geometry::rect::RectF, + keymap::{self, Binding, Keystroke}, + platform::{self, KeyDownEvent, Platform, PromptLevel, WindowOptions}, + presenter::Presenter, + util::post_inc, + Appearance, AssetCache, AssetSource, ClipboardItem, FontCache, InputHandler, MouseButton, + MouseRegionId, PathPromptOptions, TextLayoutCache, +}; pub trait Entity: 'static { type Event; @@ -177,13 +182,6 @@ pub struct App(Rc>); #[derive(Clone)] pub struct AsyncAppContext(Rc>); -#[cfg(any(test, feature = "test-support"))] -pub struct TestAppContext { - cx: Rc>, - foreground_platform: Rc, - condition_duration: Option, -} - pub struct WindowInputHandler { app: Rc>, window_id: usize, @@ -427,327 +425,6 @@ impl InputHandler for WindowInputHandler { } } -#[cfg(any(test, feature = "test-support"))] -impl TestAppContext { - pub fn new( - foreground_platform: Rc, - platform: Arc, - foreground: Rc, - background: Arc, - font_cache: Arc, - leak_detector: Arc>, - first_entity_id: usize, - ) -> Self { - let mut cx = MutableAppContext::new( - foreground, - background, - platform, - foreground_platform.clone(), - font_cache, - RefCounts { - #[cfg(any(test, feature = "test-support"))] - leak_detector, - ..Default::default() - }, - (), - ); - cx.next_entity_id = first_entity_id; - let cx = TestAppContext { - cx: Rc::new(RefCell::new(cx)), - foreground_platform, - condition_duration: None, - }; - cx.cx.borrow_mut().weak_self = Some(Rc::downgrade(&cx.cx)); - cx - } - - pub fn dispatch_action(&self, window_id: usize, action: A) { - let mut cx = self.cx.borrow_mut(); - if let Some(view_id) = cx.focused_view_id(window_id) { - cx.handle_dispatch_action_from_effect(window_id, Some(view_id), &action); - } - } - - pub fn dispatch_global_action(&self, action: A) { - self.cx.borrow_mut().dispatch_global_action(action); - } - - pub fn dispatch_keystroke(&mut self, window_id: usize, keystroke: Keystroke, is_held: bool) { - let handled = self.cx.borrow_mut().update(|cx| { - let presenter = cx - .presenters_and_platform_windows - .get(&window_id) - .unwrap() - .0 - .clone(); - - if cx.dispatch_keystroke(window_id, &keystroke) { - return true; - } - - if presenter.borrow_mut().dispatch_event( - Event::KeyDown(KeyDownEvent { - keystroke: keystroke.clone(), - is_held, - }), - false, - cx, - ) { - return true; - } - - false - }); - - if !handled && !keystroke.cmd && !keystroke.ctrl { - WindowInputHandler { - app: self.cx.clone(), - window_id, - } - .replace_text_in_range(None, &keystroke.key) - } - } - - pub fn add_model(&mut self, build_model: F) -> ModelHandle - where - T: Entity, - F: FnOnce(&mut ModelContext) -> T, - { - self.cx.borrow_mut().add_model(build_model) - } - - pub fn add_window(&mut self, build_root_view: F) -> (usize, ViewHandle) - where - T: View, - F: FnOnce(&mut ViewContext) -> T, - { - let (window_id, view) = self - .cx - .borrow_mut() - .add_window(Default::default(), build_root_view); - self.simulate_window_activation(Some(window_id)); - (window_id, view) - } - - pub fn add_view( - &mut self, - parent_handle: impl Into, - build_view: F, - ) -> ViewHandle - where - T: View, - F: FnOnce(&mut ViewContext) -> T, - { - self.cx.borrow_mut().add_view(parent_handle, build_view) - } - - pub fn window_ids(&self) -> Vec { - self.cx.borrow().window_ids().collect() - } - - pub fn root_view(&self, window_id: usize) -> Option> { - self.cx.borrow().root_view(window_id) - } - - pub fn read T>(&self, callback: F) -> T { - callback(self.cx.borrow().as_ref()) - } - - pub fn update T>(&mut self, callback: F) -> T { - let mut state = self.cx.borrow_mut(); - // Don't increment pending flushes in order for effects to be flushed before the callback - // completes, which is helpful in tests. - let result = callback(&mut *state); - // Flush effects after the callback just in case there are any. This can happen in edge - // cases such as the closure dropping handles. - state.flush_effects(); - result - } - - pub fn render(&mut self, handle: &ViewHandle, f: F) -> T - where - F: FnOnce(&mut V, &mut RenderContext) -> T, - V: View, - { - handle.update(&mut *self.cx.borrow_mut(), |view, cx| { - let mut render_cx = RenderContext { - app: cx, - window_id: handle.window_id(), - view_id: handle.id(), - view_type: PhantomData, - titlebar_height: 0., - hovered_region_ids: Default::default(), - clicked_region_ids: None, - refreshing: false, - appearance: Appearance::Light, - }; - f(view, &mut render_cx) - }) - } - - pub fn to_async(&self) -> AsyncAppContext { - AsyncAppContext(self.cx.clone()) - } - - pub fn font_cache(&self) -> Arc { - self.cx.borrow().cx.font_cache.clone() - } - - pub fn foreground_platform(&self) -> Rc { - self.foreground_platform.clone() - } - - pub fn platform(&self) -> Arc { - self.cx.borrow().cx.platform.clone() - } - - pub fn foreground(&self) -> Rc { - self.cx.borrow().foreground().clone() - } - - pub fn background(&self) -> Arc { - self.cx.borrow().background().clone() - } - - pub fn spawn(&self, f: F) -> Task - where - F: FnOnce(AsyncAppContext) -> Fut, - Fut: 'static + Future, - T: 'static, - { - let foreground = self.foreground(); - let future = f(self.to_async()); - let cx = self.to_async(); - foreground.spawn(async move { - let result = future.await; - cx.0.borrow_mut().flush_effects(); - result - }) - } - - pub fn simulate_new_path_selection(&self, result: impl FnOnce(PathBuf) -> Option) { - self.foreground_platform.simulate_new_path_selection(result); - } - - pub fn did_prompt_for_new_path(&self) -> bool { - self.foreground_platform.as_ref().did_prompt_for_new_path() - } - - pub fn simulate_prompt_answer(&self, window_id: usize, answer: usize) { - use postage::prelude::Sink as _; - - let mut done_tx = self - .window_mut(window_id) - .pending_prompts - .borrow_mut() - .pop_front() - .expect("prompt was not called"); - let _ = done_tx.try_send(answer); - } - - pub fn has_pending_prompt(&self, window_id: usize) -> bool { - let window = self.window_mut(window_id); - let prompts = window.pending_prompts.borrow_mut(); - !prompts.is_empty() - } - - pub fn current_window_title(&self, window_id: usize) -> Option { - self.window_mut(window_id).title.clone() - } - - pub fn simulate_window_close(&self, window_id: usize) -> bool { - let handler = self.window_mut(window_id).should_close_handler.take(); - if let Some(mut handler) = handler { - let should_close = handler(); - self.window_mut(window_id).should_close_handler = Some(handler); - should_close - } else { - false - } - } - - pub fn simulate_window_activation(&self, to_activate: Option) { - let mut handlers = BTreeMap::new(); - { - let mut cx = self.cx.borrow_mut(); - for (window_id, (_, window)) in &mut cx.presenters_and_platform_windows { - let window = window - .as_any_mut() - .downcast_mut::() - .unwrap(); - handlers.insert( - *window_id, - mem::take(&mut window.active_status_change_handlers), - ); - } - }; - let mut handlers = handlers.into_iter().collect::>(); - handlers.sort_unstable_by_key(|(window_id, _)| Some(*window_id) == to_activate); - - for (window_id, mut window_handlers) in handlers { - for window_handler in &mut window_handlers { - window_handler(Some(window_id) == to_activate); - } - - self.window_mut(window_id) - .active_status_change_handlers - .extend(window_handlers); - } - } - - pub fn is_window_edited(&self, window_id: usize) -> bool { - self.window_mut(window_id).edited - } - - pub fn leak_detector(&self) -> Arc> { - self.cx.borrow().leak_detector() - } - - pub fn assert_dropped(&self, handle: impl WeakHandle) { - self.cx - .borrow() - .leak_detector() - .lock() - .assert_dropped(handle.id()) - } - - fn window_mut(&self, window_id: usize) -> std::cell::RefMut { - std::cell::RefMut::map(self.cx.borrow_mut(), |state| { - let (_, window) = state - .presenters_and_platform_windows - .get_mut(&window_id) - .unwrap(); - let test_window = window - .as_any_mut() - .downcast_mut::() - .unwrap(); - test_window - }) - } - - pub fn set_condition_duration(&mut self, duration: Option) { - self.condition_duration = duration; - } - - pub fn condition_duration(&self) -> Duration { - self.condition_duration.unwrap_or_else(|| { - if std::env::var("CI").is_ok() { - Duration::from_secs(2) - } else { - Duration::from_millis(500) - } - }) - } - - pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) { - self.update(|cx| { - let actual_content = cx.read_from_clipboard().map(|item| item.text().to_owned()); - let expected_content = expected_content.map(|content| content.to_owned()); - assert_eq!(actual_content, expected_content); - }) - } -} - impl AsyncAppContext { pub fn spawn(&self, f: F) -> Task where @@ -786,6 +463,24 @@ impl AsyncAppContext { self.update(|cx| cx.add_window(window_options, build_root_view)) } + pub fn remove_window(&mut self, window_id: usize) { + self.update(|cx| cx.remove_window(window_id)) + } + + pub fn activate_window(&mut self, window_id: usize) { + self.update(|cx| cx.activate_window(window_id)) + } + + pub fn prompt( + &mut self, + window_id: usize, + level: PromptLevel, + msg: &str, + answers: &[&str], + ) -> oneshot::Receiver { + self.update(|cx| cx.prompt(window_id, level, msg, answers)) + } + pub fn platform(&self) -> Arc { self.0.borrow().platform() } @@ -876,60 +571,6 @@ impl ReadViewWith for AsyncAppContext { } } -#[cfg(any(test, feature = "test-support"))] -impl UpdateModel for TestAppContext { - fn update_model( - &mut self, - handle: &ModelHandle, - update: &mut dyn FnMut(&mut T, &mut ModelContext) -> O, - ) -> O { - self.cx.borrow_mut().update_model(handle, update) - } -} - -#[cfg(any(test, feature = "test-support"))] -impl ReadModelWith for TestAppContext { - fn read_model_with( - &self, - handle: &ModelHandle, - read: &mut dyn FnMut(&E, &AppContext) -> T, - ) -> T { - let cx = self.cx.borrow(); - let cx = cx.as_ref(); - read(handle.read(cx), cx) - } -} - -#[cfg(any(test, feature = "test-support"))] -impl UpdateView for TestAppContext { - fn update_view( - &mut self, - handle: &ViewHandle, - update: &mut dyn FnMut(&mut T, &mut ViewContext) -> S, - ) -> S - where - T: View, - { - self.cx.borrow_mut().update_view(handle, update) - } -} - -#[cfg(any(test, feature = "test-support"))] -impl ReadViewWith for TestAppContext { - fn read_view_with( - &self, - handle: &ViewHandle, - read: &mut dyn FnMut(&V, &AppContext) -> T, - ) -> T - where - V: View, - { - let cx = self.cx.borrow(); - let cx = cx.as_ref(); - read(handle.read(cx), cx) - } -} - type ActionCallback = dyn FnMut(&mut dyn AnyView, &dyn Action, &mut MutableAppContext, usize, usize); type GlobalActionCallback = dyn FnMut(&dyn Action, &mut MutableAppContext); @@ -977,7 +618,6 @@ pub struct MutableAppContext { HashMap>, Box)>, foreground: Rc, pending_effects: VecDeque, - pending_focus_index: Option, pending_notifications: HashSet, pending_global_notifications: HashSet, pending_flushes: usize, @@ -1032,7 +672,6 @@ impl MutableAppContext { presenters_and_platform_windows: Default::default(), foreground, pending_effects: VecDeque::new(), - pending_focus_index: None, pending_notifications: Default::default(), pending_global_notifications: Default::default(), pending_flushes: 0, @@ -1519,6 +1158,17 @@ impl MutableAppContext { } } + pub fn observe_default_global(&mut self, observe: F) -> Subscription + where + G: Any + Default, + F: 'static + FnMut(&mut MutableAppContext), + { + if !self.has_global::() { + self.set_global(G::default()); + } + self.observe_global::(observe) + } + pub fn observe_release(&mut self, handle: &H, callback: F) -> Subscription where E: Entity, @@ -1887,6 +1537,10 @@ impl MutableAppContext { }) } + pub fn clear_globals(&mut self) { + self.cx.globals.clear(); + } + pub fn add_model(&mut self, build_model: F) -> ModelHandle where T: Entity, @@ -1967,6 +1621,10 @@ impl MutableAppContext { }) } + pub fn remove_status_bar_item(&mut self, id: usize) { + self.remove_window(id); + } + fn register_platform_window( &mut self, window_id: usize, @@ -2216,9 +1874,6 @@ impl MutableAppContext { let mut refreshing = false; loop { if let Some(effect) = self.pending_effects.pop_front() { - if let Some(pending_focus_index) = self.pending_focus_index.as_mut() { - *pending_focus_index = pending_focus_index.saturating_sub(1); - } match effect { Effect::Subscription { entity_id, @@ -2599,8 +2254,6 @@ impl MutableAppContext { } fn handle_focus_effect(&mut self, window_id: usize, focused_id: Option) { - self.pending_focus_index.take(); - if self .cx .windows @@ -2723,10 +2376,6 @@ impl MutableAppContext { } pub fn focus(&mut self, window_id: usize, view_id: Option) { - if let Some(pending_focus_index) = self.pending_focus_index { - self.pending_effects.remove(pending_focus_index); - } - self.pending_focus_index = Some(self.pending_effects.len()); self.pending_effects .push_back(Effect::Focus { window_id, view_id }); } @@ -2922,6 +2571,10 @@ impl AppContext { .and_then(|window| window.focused_view_id) } + pub fn view_ui_name(&self, window_id: usize, view_id: usize) -> Option<&'static str> { + Some(self.views.get(&(window_id, view_id))?.ui_name()) + } + pub fn background(&self) -> &Arc { &self.background } @@ -3805,6 +3458,15 @@ impl<'a, T: View> ViewContext<'a, T> { self.app.focused_view_id(self.window_id) == Some(self.view_id) } + pub fn is_child(&self, view: impl Into) -> bool { + let view = view.into(); + if self.window_id != view.window_id { + return false; + } + self.parents(view.window_id, view.view_id) + .any(|parent| parent == self.view_id) + } + pub fn blur(&mut self) { self.app.focus(self.window_id, None); } @@ -4112,10 +3774,32 @@ pub struct RenderContext<'a, T: View> { pub refreshing: bool, } -#[derive(Clone, Copy, Default)] +#[derive(Clone, Default)] pub struct MouseState { - pub hovered: bool, - pub clicked: Option, + hovered: bool, + clicked: Option, + accessed_hovered: bool, + accessed_clicked: bool, +} + +impl MouseState { + pub fn hovered(&mut self) -> bool { + self.accessed_hovered = true; + self.hovered + } + + pub fn clicked(&mut self) -> Option { + self.accessed_clicked = true; + self.clicked + } + + pub fn accessed_hovered(&self) -> bool { + self.accessed_hovered + } + + pub fn accessed_clicked(&self) -> bool { + self.accessed_clicked + } } impl<'a, V: View> RenderContext<'a, V> { @@ -4156,6 +3840,8 @@ impl<'a, V: View> RenderContext<'a, V> { None } }), + accessed_hovered: false, + accessed_clicked: false, } } @@ -4409,117 +4095,6 @@ impl ModelHandle { update(model, cx) }) } - - #[cfg(any(test, feature = "test-support"))] - pub fn next_notification(&self, cx: &TestAppContext) -> impl Future { - let (tx, mut rx) = futures::channel::mpsc::unbounded(); - let mut cx = cx.cx.borrow_mut(); - let subscription = cx.observe(self, move |_, _| { - tx.unbounded_send(()).ok(); - }); - - let duration = if std::env::var("CI").is_ok() { - Duration::from_secs(5) - } else { - Duration::from_secs(1) - }; - - async move { - let notification = crate::util::timeout(duration, rx.next()) - .await - .expect("next notification timed out"); - drop(subscription); - notification.expect("model dropped while test was waiting for its next notification") - } - } - - #[cfg(any(test, feature = "test-support"))] - pub fn next_event(&self, cx: &TestAppContext) -> impl Future - where - T::Event: Clone, - { - let (tx, mut rx) = futures::channel::mpsc::unbounded(); - let mut cx = cx.cx.borrow_mut(); - let subscription = cx.subscribe(self, move |_, event, _| { - tx.unbounded_send(event.clone()).ok(); - }); - - let duration = if std::env::var("CI").is_ok() { - Duration::from_secs(5) - } else { - Duration::from_secs(1) - }; - - cx.foreground.start_waiting(); - async move { - let event = crate::util::timeout(duration, rx.next()) - .await - .expect("next event timed out"); - drop(subscription); - event.expect("model dropped while test was waiting for its next event") - } - } - - #[cfg(any(test, feature = "test-support"))] - pub fn condition( - &self, - cx: &TestAppContext, - mut predicate: impl FnMut(&T, &AppContext) -> bool, - ) -> impl Future { - let (tx, mut rx) = futures::channel::mpsc::unbounded(); - - let mut cx = cx.cx.borrow_mut(); - let subscriptions = ( - cx.observe(self, { - let tx = tx.clone(); - move |_, _| { - tx.unbounded_send(()).ok(); - } - }), - cx.subscribe(self, { - move |_, _, _| { - tx.unbounded_send(()).ok(); - } - }), - ); - - let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap(); - let handle = self.downgrade(); - let duration = if std::env::var("CI").is_ok() { - Duration::from_secs(5) - } else { - Duration::from_secs(1) - }; - - async move { - crate::util::timeout(duration, async move { - loop { - { - let cx = cx.borrow(); - let cx = cx.as_ref(); - if predicate( - handle - .upgrade(cx) - .expect("model dropped with pending condition") - .read(cx), - cx, - ) { - break; - } - } - - cx.borrow().foreground().start_waiting(); - rx.next() - .await - .expect("model dropped with pending condition"); - cx.borrow().foreground().finish_waiting(); - } - }) - .await - .expect("condition timed out"); - drop(subscriptions); - } - } } impl Clone for ModelHandle { @@ -4650,6 +4225,12 @@ impl PartialEq for WeakModelHandle { impl Eq for WeakModelHandle {} +impl PartialEq> for WeakModelHandle { + fn eq(&self, other: &ModelHandle) -> bool { + self.model_id == other.model_id + } +} + impl Clone for WeakModelHandle { fn clone(&self) -> Self { Self { @@ -4746,93 +4327,6 @@ impl ViewHandle { cx.focused_view_id(self.window_id) .map_or(false, |focused_id| focused_id == self.view_id) } - - #[cfg(any(test, feature = "test-support"))] - pub fn next_notification(&self, cx: &TestAppContext) -> impl Future { - use postage::prelude::{Sink as _, Stream as _}; - - let (mut tx, mut rx) = postage::mpsc::channel(1); - let mut cx = cx.cx.borrow_mut(); - let subscription = cx.observe(self, move |_, _| { - tx.try_send(()).ok(); - }); - - let duration = if std::env::var("CI").is_ok() { - Duration::from_secs(5) - } else { - Duration::from_secs(1) - }; - - async move { - let notification = crate::util::timeout(duration, rx.recv()) - .await - .expect("next notification timed out"); - drop(subscription); - notification.expect("model dropped while test was waiting for its next notification") - } - } - - #[cfg(any(test, feature = "test-support"))] - pub fn condition( - &self, - cx: &TestAppContext, - mut predicate: impl FnMut(&T, &AppContext) -> bool, - ) -> impl Future { - use postage::prelude::{Sink as _, Stream as _}; - - let (tx, mut rx) = postage::mpsc::channel(1024); - let timeout_duration = cx.condition_duration(); - - let mut cx = cx.cx.borrow_mut(); - let subscriptions = self.update(&mut *cx, |_, cx| { - ( - cx.observe(self, { - let mut tx = tx.clone(); - move |_, _, _| { - tx.blocking_send(()).ok(); - } - }), - cx.subscribe(self, { - let mut tx = tx.clone(); - move |_, _, _, _| { - tx.blocking_send(()).ok(); - } - }), - ) - }); - - let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap(); - let handle = self.downgrade(); - - async move { - crate::util::timeout(timeout_duration, async move { - loop { - { - let cx = cx.borrow(); - let cx = cx.as_ref(); - if predicate( - handle - .upgrade(cx) - .expect("view dropped with pending condition") - .read(cx), - cx, - ) { - break; - } - } - - cx.borrow().foreground().start_waiting(); - rx.recv() - .await - .expect("view dropped with pending condition"); - cx.borrow().foreground().finish_waiting(); - } - }) - .await - .expect("condition timed out"); - drop(subscriptions); - } - } } impl Clone for ViewHandle { @@ -4950,6 +4444,10 @@ impl AnyViewHandle { } } + pub fn window_id(&self) -> usize { + self.window_id + } + pub fn id(&self) -> usize { self.view_id } @@ -5266,6 +4764,10 @@ pub struct AnyWeakViewHandle { } impl AnyWeakViewHandle { + pub fn id(&self) -> usize { + self.view_id + } + pub fn upgrade(&self, cx: &impl UpgradeViewHandle) -> Option { cx.upgrade_any_view_handle(self) } @@ -6910,18 +6412,29 @@ mod tests { assert_eq!(mem::take(&mut *observed_events.lock()), Vec::<&str>::new()); view_1.update(cx, |_, cx| { - // Ensure only the latest focus is honored. + // Ensure focus events are sent for all intermediate focuses cx.focus(&view_2); cx.focus(&view_1); cx.focus(&view_2); }); assert_eq!( mem::take(&mut *view_events.lock()), - ["view 1 blurred", "view 2 focused"], + [ + "view 1 blurred", + "view 2 focused", + "view 2 blurred", + "view 1 focused", + "view 1 blurred", + "view 2 focused" + ], ); assert_eq!( mem::take(&mut *observed_events.lock()), [ + "view 2 observed view 1's blur", + "view 1 observed view 2's focus", + "view 1 observed view 2's blur", + "view 2 observed view 1's focus", "view 2 observed view 1's blur", "view 1 observed view 2's focus" ] @@ -7555,4 +7068,73 @@ mod tests { cx.simulate_window_activation(Some(window_3)); assert_eq!(mem::take(&mut *events.borrow_mut()), []); } + + #[crate::test(self)] + fn test_child_view(cx: &mut MutableAppContext) { + struct Child { + rendered: Rc>, + dropped: Rc>, + } + + impl super::Entity for Child { + type Event = (); + } + + impl super::View for Child { + fn ui_name() -> &'static str { + "child view" + } + + fn render(&mut self, _: &mut RenderContext) -> ElementBox { + self.rendered.set(true); + Empty::new().boxed() + } + } + + impl Drop for Child { + fn drop(&mut self) { + self.dropped.set(true); + } + } + + struct Parent { + child: Option>, + } + + impl super::Entity for Parent { + type Event = (); + } + + impl super::View for Parent { + fn ui_name() -> &'static str { + "parent view" + } + + fn render(&mut self, cx: &mut RenderContext) -> ElementBox { + if let Some(child) = self.child.as_ref() { + ChildView::new(child, cx).boxed() + } else { + Empty::new().boxed() + } + } + } + + let child_rendered = Rc::new(Cell::new(false)); + let child_dropped = Rc::new(Cell::new(false)); + let (_, root_view) = cx.add_window(Default::default(), |cx| Parent { + child: Some(cx.add_view(|_| Child { + rendered: child_rendered.clone(), + dropped: child_dropped.clone(), + })), + }); + assert!(child_rendered.take()); + assert!(!child_dropped.take()); + + root_view.update(cx, |view, cx| { + view.child.take(); + cx.notify(); + }); + assert!(!child_rendered.take()); + assert!(child_dropped.take()); + } } diff --git a/crates/gpui/src/app/test_app_context.rs b/crates/gpui/src/app/test_app_context.rs new file mode 100644 index 0000000000..72f1f546fb --- /dev/null +++ b/crates/gpui/src/app/test_app_context.rs @@ -0,0 +1,667 @@ +use std::{ + cell::RefCell, + marker::PhantomData, + mem, + path::PathBuf, + rc::Rc, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; + +use futures::Future; +use itertools::Itertools; +use parking_lot::{Mutex, RwLock}; +use smol::stream::StreamExt; + +use crate::{ + executor, geometry::vector::Vector2F, keymap::Keystroke, platform, Action, AnyViewHandle, + AppContext, Appearance, Entity, Event, FontCache, InputHandler, KeyDownEvent, LeakDetector, + ModelContext, ModelHandle, MutableAppContext, Platform, ReadModelWith, ReadViewWith, + RenderContext, Task, UpdateModel, UpdateView, View, ViewContext, ViewHandle, WeakHandle, + WindowInputHandler, +}; +use collections::BTreeMap; + +use super::{AsyncAppContext, RefCounts}; + +pub struct TestAppContext { + cx: Rc>, + foreground_platform: Rc, + condition_duration: Option, + pub function_name: String, + assertion_context: AssertionContextManager, +} + +impl TestAppContext { + pub fn new( + foreground_platform: Rc, + platform: Arc, + foreground: Rc, + background: Arc, + font_cache: Arc, + leak_detector: Arc>, + first_entity_id: usize, + function_name: String, + ) -> Self { + let mut cx = MutableAppContext::new( + foreground, + background, + platform, + foreground_platform.clone(), + font_cache, + RefCounts { + #[cfg(any(test, feature = "test-support"))] + leak_detector, + ..Default::default() + }, + (), + ); + cx.next_entity_id = first_entity_id; + let cx = TestAppContext { + cx: Rc::new(RefCell::new(cx)), + foreground_platform, + condition_duration: None, + function_name, + assertion_context: AssertionContextManager::new(), + }; + cx.cx.borrow_mut().weak_self = Some(Rc::downgrade(&cx.cx)); + cx + } + + pub fn dispatch_action(&self, window_id: usize, action: A) { + let mut cx = self.cx.borrow_mut(); + if let Some(view_id) = cx.focused_view_id(window_id) { + cx.handle_dispatch_action_from_effect(window_id, Some(view_id), &action); + } + } + + pub fn dispatch_global_action(&self, action: A) { + self.cx.borrow_mut().dispatch_global_action(action); + } + + pub fn dispatch_keystroke(&mut self, window_id: usize, keystroke: Keystroke, is_held: bool) { + let handled = self.cx.borrow_mut().update(|cx| { + let presenter = cx + .presenters_and_platform_windows + .get(&window_id) + .unwrap() + .0 + .clone(); + + if cx.dispatch_keystroke(window_id, &keystroke) { + return true; + } + + if presenter.borrow_mut().dispatch_event( + Event::KeyDown(KeyDownEvent { + keystroke: keystroke.clone(), + is_held, + }), + false, + cx, + ) { + return true; + } + + false + }); + + if !handled && !keystroke.cmd && !keystroke.ctrl { + WindowInputHandler { + app: self.cx.clone(), + window_id, + } + .replace_text_in_range(None, &keystroke.key) + } + } + + pub fn add_model(&mut self, build_model: F) -> ModelHandle + where + T: Entity, + F: FnOnce(&mut ModelContext) -> T, + { + self.cx.borrow_mut().add_model(build_model) + } + + pub fn add_window(&mut self, build_root_view: F) -> (usize, ViewHandle) + where + T: View, + F: FnOnce(&mut ViewContext) -> T, + { + let (window_id, view) = self + .cx + .borrow_mut() + .add_window(Default::default(), build_root_view); + self.simulate_window_activation(Some(window_id)); + (window_id, view) + } + + pub fn add_view( + &mut self, + parent_handle: impl Into, + build_view: F, + ) -> ViewHandle + where + T: View, + F: FnOnce(&mut ViewContext) -> T, + { + self.cx.borrow_mut().add_view(parent_handle, build_view) + } + + pub fn window_ids(&self) -> Vec { + self.cx.borrow().window_ids().collect() + } + + pub fn root_view(&self, window_id: usize) -> Option> { + self.cx.borrow().root_view(window_id) + } + + pub fn read T>(&self, callback: F) -> T { + callback(self.cx.borrow().as_ref()) + } + + pub fn update T>(&mut self, callback: F) -> T { + let mut state = self.cx.borrow_mut(); + // Don't increment pending flushes in order for effects to be flushed before the callback + // completes, which is helpful in tests. + let result = callback(&mut *state); + // Flush effects after the callback just in case there are any. This can happen in edge + // cases such as the closure dropping handles. + state.flush_effects(); + result + } + + pub fn render(&mut self, handle: &ViewHandle, f: F) -> T + where + F: FnOnce(&mut V, &mut RenderContext) -> T, + V: View, + { + handle.update(&mut *self.cx.borrow_mut(), |view, cx| { + let mut render_cx = RenderContext { + app: cx, + window_id: handle.window_id(), + view_id: handle.id(), + view_type: PhantomData, + titlebar_height: 0., + hovered_region_ids: Default::default(), + clicked_region_ids: None, + refreshing: false, + appearance: Appearance::Light, + }; + f(view, &mut render_cx) + }) + } + + pub fn to_async(&self) -> AsyncAppContext { + AsyncAppContext(self.cx.clone()) + } + + pub fn font_cache(&self) -> Arc { + self.cx.borrow().cx.font_cache.clone() + } + + pub fn foreground_platform(&self) -> Rc { + self.foreground_platform.clone() + } + + pub fn platform(&self) -> Arc { + self.cx.borrow().cx.platform.clone() + } + + pub fn foreground(&self) -> Rc { + self.cx.borrow().foreground().clone() + } + + pub fn background(&self) -> Arc { + self.cx.borrow().background().clone() + } + + pub fn spawn(&self, f: F) -> Task + where + F: FnOnce(AsyncAppContext) -> Fut, + Fut: 'static + Future, + T: 'static, + { + let foreground = self.foreground(); + let future = f(self.to_async()); + let cx = self.to_async(); + foreground.spawn(async move { + let result = future.await; + cx.0.borrow_mut().flush_effects(); + result + }) + } + + pub fn simulate_new_path_selection(&self, result: impl FnOnce(PathBuf) -> Option) { + self.foreground_platform.simulate_new_path_selection(result); + } + + pub fn did_prompt_for_new_path(&self) -> bool { + self.foreground_platform.as_ref().did_prompt_for_new_path() + } + + pub fn simulate_prompt_answer(&self, window_id: usize, answer: usize) { + use postage::prelude::Sink as _; + + let mut done_tx = self + .window_mut(window_id) + .pending_prompts + .borrow_mut() + .pop_front() + .expect("prompt was not called"); + let _ = done_tx.try_send(answer); + } + + pub fn has_pending_prompt(&self, window_id: usize) -> bool { + let window = self.window_mut(window_id); + let prompts = window.pending_prompts.borrow_mut(); + !prompts.is_empty() + } + + pub fn current_window_title(&self, window_id: usize) -> Option { + self.window_mut(window_id).title.clone() + } + + pub fn simulate_window_close(&self, window_id: usize) -> bool { + let handler = self.window_mut(window_id).should_close_handler.take(); + if let Some(mut handler) = handler { + let should_close = handler(); + self.window_mut(window_id).should_close_handler = Some(handler); + should_close + } else { + false + } + } + + pub fn simulate_window_resize(&self, window_id: usize, size: Vector2F) { + let mut window = self.window_mut(window_id); + window.size = size; + let mut handlers = mem::take(&mut window.resize_handlers); + drop(window); + for handler in &mut handlers { + handler(); + } + self.window_mut(window_id).resize_handlers = handlers; + } + + pub fn simulate_window_activation(&self, to_activate: Option) { + let mut handlers = BTreeMap::new(); + { + let mut cx = self.cx.borrow_mut(); + for (window_id, (_, window)) in &mut cx.presenters_and_platform_windows { + let window = window + .as_any_mut() + .downcast_mut::() + .unwrap(); + handlers.insert( + *window_id, + mem::take(&mut window.active_status_change_handlers), + ); + } + }; + let mut handlers = handlers.into_iter().collect::>(); + handlers.sort_unstable_by_key(|(window_id, _)| Some(*window_id) == to_activate); + + for (window_id, mut window_handlers) in handlers { + for window_handler in &mut window_handlers { + window_handler(Some(window_id) == to_activate); + } + + self.window_mut(window_id) + .active_status_change_handlers + .extend(window_handlers); + } + } + + pub fn is_window_edited(&self, window_id: usize) -> bool { + self.window_mut(window_id).edited + } + + pub fn leak_detector(&self) -> Arc> { + self.cx.borrow().leak_detector() + } + + pub fn assert_dropped(&self, handle: impl WeakHandle) { + self.cx + .borrow() + .leak_detector() + .lock() + .assert_dropped(handle.id()) + } + + fn window_mut(&self, window_id: usize) -> std::cell::RefMut { + std::cell::RefMut::map(self.cx.borrow_mut(), |state| { + let (_, window) = state + .presenters_and_platform_windows + .get_mut(&window_id) + .unwrap(); + let test_window = window + .as_any_mut() + .downcast_mut::() + .unwrap(); + test_window + }) + } + + pub fn set_condition_duration(&mut self, duration: Option) { + self.condition_duration = duration; + } + + pub fn condition_duration(&self) -> Duration { + self.condition_duration.unwrap_or_else(|| { + if std::env::var("CI").is_ok() { + Duration::from_secs(2) + } else { + Duration::from_millis(500) + } + }) + } + + pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) { + self.update(|cx| { + let actual_content = cx.read_from_clipboard().map(|item| item.text().to_owned()); + let expected_content = expected_content.map(|content| content.to_owned()); + assert_eq!(actual_content, expected_content); + }) + } + + pub fn add_assertion_context(&self, context: String) -> ContextHandle { + self.assertion_context.add_context(context) + } + + pub fn assertion_context(&self) -> String { + self.assertion_context.context() + } +} + +impl UpdateModel for TestAppContext { + fn update_model( + &mut self, + handle: &ModelHandle, + update: &mut dyn FnMut(&mut T, &mut ModelContext) -> O, + ) -> O { + self.cx.borrow_mut().update_model(handle, update) + } +} + +impl ReadModelWith for TestAppContext { + fn read_model_with( + &self, + handle: &ModelHandle, + read: &mut dyn FnMut(&E, &AppContext) -> T, + ) -> T { + let cx = self.cx.borrow(); + let cx = cx.as_ref(); + read(handle.read(cx), cx) + } +} + +impl UpdateView for TestAppContext { + fn update_view( + &mut self, + handle: &ViewHandle, + update: &mut dyn FnMut(&mut T, &mut ViewContext) -> S, + ) -> S + where + T: View, + { + self.cx.borrow_mut().update_view(handle, update) + } +} + +impl ReadViewWith for TestAppContext { + fn read_view_with( + &self, + handle: &ViewHandle, + read: &mut dyn FnMut(&V, &AppContext) -> T, + ) -> T + where + V: View, + { + let cx = self.cx.borrow(); + let cx = cx.as_ref(); + read(handle.read(cx), cx) + } +} + +impl ModelHandle { + pub fn next_notification(&self, cx: &TestAppContext) -> impl Future { + let (tx, mut rx) = futures::channel::mpsc::unbounded(); + let mut cx = cx.cx.borrow_mut(); + let subscription = cx.observe(self, move |_, _| { + tx.unbounded_send(()).ok(); + }); + + let duration = if std::env::var("CI").is_ok() { + Duration::from_secs(5) + } else { + Duration::from_secs(1) + }; + + async move { + let notification = crate::util::timeout(duration, rx.next()) + .await + .expect("next notification timed out"); + drop(subscription); + notification.expect("model dropped while test was waiting for its next notification") + } + } + + pub fn next_event(&self, cx: &TestAppContext) -> impl Future + where + T::Event: Clone, + { + let (tx, mut rx) = futures::channel::mpsc::unbounded(); + let mut cx = cx.cx.borrow_mut(); + let subscription = cx.subscribe(self, move |_, event, _| { + tx.unbounded_send(event.clone()).ok(); + }); + + let duration = if std::env::var("CI").is_ok() { + Duration::from_secs(5) + } else { + Duration::from_secs(1) + }; + + cx.foreground.start_waiting(); + async move { + let event = crate::util::timeout(duration, rx.next()) + .await + .expect("next event timed out"); + drop(subscription); + event.expect("model dropped while test was waiting for its next event") + } + } + + pub fn condition( + &self, + cx: &TestAppContext, + mut predicate: impl FnMut(&T, &AppContext) -> bool, + ) -> impl Future { + let (tx, mut rx) = futures::channel::mpsc::unbounded(); + + let mut cx = cx.cx.borrow_mut(); + let subscriptions = ( + cx.observe(self, { + let tx = tx.clone(); + move |_, _| { + tx.unbounded_send(()).ok(); + } + }), + cx.subscribe(self, { + move |_, _, _| { + tx.unbounded_send(()).ok(); + } + }), + ); + + let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap(); + let handle = self.downgrade(); + let duration = if std::env::var("CI").is_ok() { + Duration::from_secs(5) + } else { + Duration::from_secs(1) + }; + + async move { + crate::util::timeout(duration, async move { + loop { + { + let cx = cx.borrow(); + let cx = cx.as_ref(); + if predicate( + handle + .upgrade(cx) + .expect("model dropped with pending condition") + .read(cx), + cx, + ) { + break; + } + } + + cx.borrow().foreground().start_waiting(); + rx.next() + .await + .expect("model dropped with pending condition"); + cx.borrow().foreground().finish_waiting(); + } + }) + .await + .expect("condition timed out"); + drop(subscriptions); + } + } +} + +impl ViewHandle { + pub fn next_notification(&self, cx: &TestAppContext) -> impl Future { + use postage::prelude::{Sink as _, Stream as _}; + + let (mut tx, mut rx) = postage::mpsc::channel(1); + let mut cx = cx.cx.borrow_mut(); + let subscription = cx.observe(self, move |_, _| { + tx.try_send(()).ok(); + }); + + let duration = if std::env::var("CI").is_ok() { + Duration::from_secs(5) + } else { + Duration::from_secs(1) + }; + + async move { + let notification = crate::util::timeout(duration, rx.recv()) + .await + .expect("next notification timed out"); + drop(subscription); + notification.expect("model dropped while test was waiting for its next notification") + } + } + + pub fn condition( + &self, + cx: &TestAppContext, + mut predicate: impl FnMut(&T, &AppContext) -> bool, + ) -> impl Future { + use postage::prelude::{Sink as _, Stream as _}; + + let (tx, mut rx) = postage::mpsc::channel(1024); + let timeout_duration = cx.condition_duration(); + + let mut cx = cx.cx.borrow_mut(); + let subscriptions = self.update(&mut *cx, |_, cx| { + ( + cx.observe(self, { + let mut tx = tx.clone(); + move |_, _, _| { + tx.blocking_send(()).ok(); + } + }), + cx.subscribe(self, { + let mut tx = tx.clone(); + move |_, _, _, _| { + tx.blocking_send(()).ok(); + } + }), + ) + }); + + let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap(); + let handle = self.downgrade(); + + async move { + crate::util::timeout(timeout_duration, async move { + loop { + { + let cx = cx.borrow(); + let cx = cx.as_ref(); + if predicate( + handle + .upgrade(cx) + .expect("view dropped with pending condition") + .read(cx), + cx, + ) { + break; + } + } + + cx.borrow().foreground().start_waiting(); + rx.recv() + .await + .expect("view dropped with pending condition"); + cx.borrow().foreground().finish_waiting(); + } + }) + .await + .expect("condition timed out"); + drop(subscriptions); + } + } +} + +#[derive(Clone)] +pub struct AssertionContextManager { + id: Arc, + contexts: Arc>>, +} + +impl AssertionContextManager { + pub fn new() -> Self { + Self { + id: Arc::new(AtomicUsize::new(0)), + contexts: Arc::new(RwLock::new(BTreeMap::new())), + } + } + + pub fn add_context(&self, context: String) -> ContextHandle { + let id = self.id.fetch_add(1, Ordering::Relaxed); + let mut contexts = self.contexts.write(); + contexts.insert(id, context); + ContextHandle { + id, + manager: self.clone(), + } + } + + pub fn context(&self) -> String { + let contexts = self.contexts.read(); + format!("\n{}\n", contexts.values().join("\n")) + } +} + +pub struct ContextHandle { + id: usize, + manager: AssertionContextManager, +} + +impl Drop for ContextHandle { + fn drop(&mut self) { + let mut contexts = self.manager.contexts.write(); + contexts.remove(&self.id); + } +} diff --git a/crates/gpui/src/elements.rs b/crates/gpui/src/elements.rs index b22f0b3250..59269f8af6 100644 --- a/crates/gpui/src/elements.rs +++ b/crates/gpui/src/elements.rs @@ -271,9 +271,6 @@ impl AnyElement for Lifecycle { mut layout, } => { let bounds = RectF::new(origin, size); - let visible_bounds = visible_bounds - .intersection(bounds) - .unwrap_or_else(|| RectF::new(bounds.origin(), Vector2F::default())); let paint = element.paint(bounds, visible_bounds, &mut layout, cx); Lifecycle::PostPaint { element, @@ -292,9 +289,6 @@ impl AnyElement for Lifecycle { .. } => { let bounds = RectF::new(origin, bounds.size()); - let visible_bounds = visible_bounds - .intersection(bounds) - .unwrap_or_else(|| RectF::new(bounds.origin(), Vector2F::default())); let paint = element.paint(bounds, visible_bounds, &mut layout, cx); Lifecycle::PostPaint { element, diff --git a/crates/gpui/src/elements/flex.rs b/crates/gpui/src/elements/flex.rs index e68810fb36..fd37b001fe 100644 --- a/crates/gpui/src/elements/flex.rs +++ b/crates/gpui/src/elements/flex.rs @@ -1,11 +1,10 @@ -use std::{any::Any, f32::INFINITY, ops::Range}; +use std::{any::Any, cell::Cell, f32::INFINITY, ops::Range, rc::Rc}; use crate::{ json::{self, ToJson, Value}, presenter::MeasurementContext, Axis, DebugContext, Element, ElementBox, ElementStateHandle, Event, EventContext, - LayoutContext, MouseMovedEvent, PaintContext, RenderContext, ScrollWheelEvent, SizeConstraint, - Vector2FExt, View, + LayoutContext, PaintContext, RenderContext, SizeConstraint, Vector2FExt, View, }; use pathfinder_geometry::{ rect::RectF, @@ -15,14 +14,14 @@ use serde_json::json; #[derive(Default)] struct ScrollState { - scroll_to: Option, - scroll_position: f32, + scroll_to: Cell>, + scroll_position: Cell, } pub struct Flex { axis: Axis, children: Vec, - scroll_state: Option>, + scroll_state: Option<(ElementStateHandle>, usize)>, } impl Flex { @@ -52,9 +51,9 @@ impl Flex { Tag: 'static, V: View, { - let scroll_state = cx.default_element_state::(element_id); - scroll_state.update(cx, |scroll_state, _| scroll_state.scroll_to = scroll_to); - self.scroll_state = Some(scroll_state); + let scroll_state = cx.default_element_state::>(element_id); + scroll_state.read(cx).scroll_to.set(scroll_to); + self.scroll_state = Some((scroll_state, cx.handle().id())); self } @@ -202,9 +201,9 @@ impl Element for Flex { } if let Some(scroll_state) = self.scroll_state.as_ref() { - scroll_state.update(cx, |scroll_state, _| { + scroll_state.0.update(cx, |scroll_state, _| { if let Some(scroll_to) = scroll_state.scroll_to.take() { - let visible_start = scroll_state.scroll_position; + let visible_start = scroll_state.scroll_position.get(); let visible_end = visible_start + size.along(self.axis); if let Some(child) = self.children.get(scroll_to) { let child_start: f32 = self.children[..scroll_to] @@ -213,15 +212,22 @@ impl Element for Flex { .sum(); let child_end = child_start + child.size().along(self.axis); if child_start < visible_start { - scroll_state.scroll_position = child_start; + scroll_state.scroll_position.set(child_start); } else if child_end > visible_end { - scroll_state.scroll_position = child_end - size.along(self.axis); + scroll_state + .scroll_position + .set(child_end - size.along(self.axis)); } } } - scroll_state.scroll_position = - scroll_state.scroll_position.min(-remaining_space).max(0.); + scroll_state.scroll_position.set( + scroll_state + .scroll_position + .get() + .min(-remaining_space) + .max(0.), + ); }); } @@ -235,16 +241,53 @@ impl Element for Flex { remaining_space: &mut Self::LayoutState, cx: &mut PaintContext, ) -> Self::PaintState { - let mut remaining_space = *remaining_space; + let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default(); + let mut remaining_space = *remaining_space; let overflowing = remaining_space < 0.; if overflowing { - cx.scene.push_layer(Some(bounds)); + cx.scene.push_layer(Some(visible_bounds)); + } + + if let Some(scroll_state) = &self.scroll_state { + cx.scene.push_mouse_region( + crate::MouseRegion::new::(scroll_state.1, 0, bounds) + .on_scroll({ + let scroll_state = scroll_state.0.read(cx).clone(); + let axis = self.axis; + move |e, cx| { + if remaining_space < 0. { + let mut delta = match axis { + Axis::Horizontal => { + if e.delta.x() != 0. { + e.delta.x() + } else { + e.delta.y() + } + } + Axis::Vertical => e.delta.y(), + }; + if !e.precise { + delta *= 20.; + } + + scroll_state + .scroll_position + .set(scroll_state.scroll_position.get() - delta); + + cx.notify(); + } else { + cx.propogate_event(); + } + } + }) + .on_move(|_, _| { /* Capture move events */ }), + ) } let mut child_origin = bounds.origin(); if let Some(scroll_state) = self.scroll_state.as_ref() { - let scroll_position = scroll_state.read(cx).scroll_position; + let scroll_position = scroll_state.0.read(cx).scroll_position.get(); match self.axis { Axis::Horizontal => child_origin.set_x(child_origin.x() - scroll_position), Axis::Vertical => child_origin.set_y(child_origin.y() - scroll_position), @@ -278,9 +321,9 @@ impl Element for Flex { fn dispatch_event( &mut self, event: &Event, - bounds: RectF, _: RectF, - remaining_space: &mut Self::LayoutState, + _: RectF, + _: &mut Self::LayoutState, _: &mut Self::PaintState, cx: &mut EventContext, ) -> bool { @@ -288,50 +331,6 @@ impl Element for Flex { for child in &mut self.children { handled = child.dispatch_event(event, cx) || handled; } - if !handled { - if let &Event::ScrollWheel(ScrollWheelEvent { - position, - delta, - precise, - .. - }) = event - { - if *remaining_space < 0. && bounds.contains_point(position) { - if let Some(scroll_state) = self.scroll_state.as_ref() { - scroll_state.update(cx, |scroll_state, cx| { - let mut delta = match self.axis { - Axis::Horizontal => { - if delta.x() != 0. { - delta.x() - } else { - delta.y() - } - } - Axis::Vertical => delta.y(), - }; - if !precise { - delta *= 20.; - } - - scroll_state.scroll_position -= delta; - - handled = true; - cx.notify(); - }); - } - } - } - } - - if !handled { - if let &Event::MouseMoved(MouseMovedEvent { position, .. }) = event { - // If this is a scrollable flex, and the mouse is over it, eat the scroll event to prevent - // propogating it to the element below. - if self.scroll_state.is_some() && bounds.contains_point(position) { - handled = true; - } - } - } handled } diff --git a/crates/gpui/src/elements/image.rs b/crates/gpui/src/elements/image.rs index e0387e39bb..7e231e5e29 100644 --- a/crates/gpui/src/elements/image.rs +++ b/crates/gpui/src/elements/image.rs @@ -27,6 +27,8 @@ pub struct ImageStyle { pub height: Option, #[serde(default)] pub width: Option, + #[serde(default)] + pub grayscale: bool, } impl Image { @@ -74,6 +76,7 @@ impl Element for Image { bounds, border: self.style.border, corner_radius: self.style.corner_radius, + grayscale: self.style.grayscale, data: self.data.clone(), }); } diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 57436c256b..d0f87590fc 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -5,8 +5,8 @@ use crate::{ }, json::json, presenter::MeasurementContext, - DebugContext, Element, ElementBox, ElementRc, Event, EventContext, LayoutContext, PaintContext, - RenderContext, ScrollWheelEvent, SizeConstraint, View, ViewContext, + DebugContext, Element, ElementBox, ElementRc, Event, EventContext, LayoutContext, MouseRegion, + PaintContext, RenderContext, SizeConstraint, View, ViewContext, }; use std::{cell::RefCell, collections::VecDeque, ops::Range, rc::Rc}; use sum_tree::{Bias, SumTree}; @@ -261,7 +261,25 @@ impl Element for List { scroll_top: &mut ListOffset, cx: &mut PaintContext, ) { - cx.scene.push_layer(Some(bounds)); + let visible_bounds = visible_bounds.intersection(bounds).unwrap_or_default(); + cx.scene.push_layer(Some(visible_bounds)); + + cx.scene.push_mouse_region( + MouseRegion::new::(cx.current_view_id(), 0, bounds).on_scroll({ + let state = self.state.clone(); + let height = bounds.height(); + let scroll_top = scroll_top.clone(); + move |e, cx| { + state.0.borrow_mut().scroll( + &scroll_top, + height, + e.platform_event.delta, + e.platform_event.precise, + cx, + ) + } + }), + ); let state = &mut *self.state.0.borrow_mut(); for (mut element, origin) in state.visible_elements(bounds, scroll_top) { @@ -312,20 +330,6 @@ impl Element for List { drop(cursor); state.items = new_items; - if let Event::ScrollWheel(ScrollWheelEvent { - position, - delta, - precise, - .. - }) = event - { - if bounds.contains_point(*position) - && state.scroll(scroll_top, bounds.height(), *delta, *precise, cx) - { - handled = true; - } - } - handled } @@ -527,7 +531,7 @@ impl StateInner { mut delta: Vector2F, precise: bool, cx: &mut EventContext, - ) -> bool { + ) { if !precise { delta *= 20.; } @@ -554,9 +558,8 @@ impl StateInner { let visible_range = self.visible_range(height, scroll_top); self.scroll_handler.as_mut().unwrap()(visible_range, cx); } - cx.notify(); - true + cx.notify(); } fn scroll_top(&self, logical_scroll_top: &ListOffset) -> f32 { diff --git a/crates/gpui/src/elements/mouse_event_handler.rs b/crates/gpui/src/elements/mouse_event_handler.rs index bb958ce99c..ab5aeb562b 100644 --- a/crates/gpui/src/elements/mouse_event_handler.rs +++ b/crates/gpui/src/elements/mouse_event_handler.rs @@ -7,7 +7,8 @@ use crate::{ platform::CursorStyle, scene::{ ClickRegionEvent, CursorRegion, DownOutRegionEvent, DownRegionEvent, DragRegionEvent, - HandlerSet, HoverRegionEvent, MoveRegionEvent, UpOutRegionEvent, UpRegionEvent, + HandlerSet, HoverRegionEvent, MoveRegionEvent, ScrollWheelRegionEvent, UpOutRegionEvent, + UpRegionEvent, }, DebugContext, Element, ElementBox, Event, EventContext, LayoutContext, MeasurementContext, MouseButton, MouseRegion, MouseState, PaintContext, RenderContext, SizeConstraint, View, @@ -21,6 +22,8 @@ pub struct MouseEventHandler { cursor_style: Option, handlers: HandlerSet, hoverable: bool, + notify_on_hover: bool, + notify_on_click: bool, padding: Padding, _tag: PhantomData, } @@ -29,13 +32,19 @@ impl MouseEventHandler { pub fn new(region_id: usize, cx: &mut RenderContext, render_child: F) -> Self where V: View, - F: FnOnce(MouseState, &mut RenderContext) -> ElementBox, + F: FnOnce(&mut MouseState, &mut RenderContext) -> ElementBox, { + let mut mouse_state = cx.mouse_state::(region_id); + let child = render_child(&mut mouse_state, cx); + let notify_on_hover = mouse_state.accessed_hovered(); + let notify_on_click = mouse_state.accessed_clicked(); Self { - child: render_child(cx.mouse_state::(region_id), cx), + child, region_id, cursor_style: None, handlers: Default::default(), + notify_on_hover, + notify_on_click, hoverable: true, padding: Default::default(), _tag: PhantomData, @@ -122,6 +131,14 @@ impl MouseEventHandler { self } + pub fn on_scroll( + mut self, + handler: impl Fn(ScrollWheelRegionEvent, &mut EventContext) + 'static, + ) -> Self { + self.handlers = self.handlers.on_scroll(handler); + self + } + pub fn with_hoverable(mut self, is_hoverable: bool) -> Self { self.hoverable = is_hoverable; self @@ -160,6 +177,7 @@ impl Element for MouseEventHandler { _: &mut Self::LayoutState, cx: &mut PaintContext, ) -> Self::PaintState { + let visible_bounds = visible_bounds.intersection(bounds).unwrap_or_default(); let hit_bounds = self.hit_bounds(visible_bounds); if let Some(style) = self.cursor_style { cx.scene.push_cursor_region(CursorRegion { @@ -175,7 +193,9 @@ impl Element for MouseEventHandler { hit_bounds, self.handlers.clone(), ) - .with_hoverable(self.hoverable), + .with_hoverable(self.hoverable) + .with_notify_on_hover(self.notify_on_hover) + .with_notify_on_click(self.notify_on_click), ); self.child.paint(bounds.origin(), visible_bounds, cx); diff --git a/crates/gpui/src/elements/overlay.rs b/crates/gpui/src/elements/overlay.rs index 20b6c75c8f..07442d1140 100644 --- a/crates/gpui/src/elements/overlay.rs +++ b/crates/gpui/src/elements/overlay.rs @@ -14,6 +14,7 @@ pub struct Overlay { anchor_position: Option, anchor_corner: AnchorCorner, fit_mode: OverlayFitMode, + position_mode: OverlayPositionMode, hoverable: bool, } @@ -24,6 +25,12 @@ pub enum OverlayFitMode { None, } +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum OverlayPositionMode { + Window, + Local, +} + #[derive(Clone, Copy, PartialEq, Eq)] pub enum AnchorCorner { TopLeft, @@ -73,6 +80,7 @@ impl Overlay { anchor_position: None, anchor_corner: AnchorCorner::TopLeft, fit_mode: OverlayFitMode::None, + position_mode: OverlayPositionMode::Window, hoverable: false, } } @@ -92,6 +100,11 @@ impl Overlay { self } + pub fn with_position_mode(mut self, position_mode: OverlayPositionMode) -> Self { + self.position_mode = position_mode; + self + } + pub fn with_hoverable(mut self, hoverable: bool) -> Self { self.hoverable = hoverable; self @@ -123,8 +136,20 @@ impl Element for Overlay { size: &mut Self::LayoutState, cx: &mut PaintContext, ) { - let anchor_position = self.anchor_position.unwrap_or_else(|| bounds.origin()); - let mut bounds = self.anchor_corner.get_bounds(anchor_position, *size); + let (anchor_position, mut bounds) = match self.position_mode { + OverlayPositionMode::Window => { + let anchor_position = self.anchor_position.unwrap_or_else(|| bounds.origin()); + let bounds = self.anchor_corner.get_bounds(anchor_position, *size); + (anchor_position, bounds) + } + OverlayPositionMode::Local => { + let anchor_position = self.anchor_position.unwrap_or_default(); + let bounds = self + .anchor_corner + .get_bounds(bounds.origin() + anchor_position, *size); + (anchor_position, bounds) + } + }; match self.fit_mode { OverlayFitMode::SnapToWindow => { @@ -192,7 +217,11 @@ impl Element for Overlay { )); } - self.child.paint(bounds.origin(), bounds, cx); + self.child.paint( + bounds.origin(), + RectF::new(Vector2F::zero(), cx.window_size), + cx, + ); cx.scene.pop_stacking_context(); } diff --git a/crates/gpui/src/elements/tooltip.rs b/crates/gpui/src/elements/tooltip.rs index 55ab7e44d2..c86230a5e1 100644 --- a/crates/gpui/src/elements/tooltip.rs +++ b/crates/gpui/src/elements/tooltip.rs @@ -36,10 +36,10 @@ struct TooltipState { #[derive(Clone, Deserialize, Default)] pub struct TooltipStyle { #[serde(flatten)] - container: ContainerStyle, - text: TextStyle, + pub container: ContainerStyle, + pub text: TextStyle, keystroke: KeystrokeStyle, - max_text_width: f32, + pub max_text_width: f32, } #[derive(Clone, Deserialize, Default)] @@ -126,7 +126,7 @@ impl Tooltip { } } - fn render_tooltip( + pub fn render_tooltip( text: String, style: TooltipStyle, action: Option>, diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index 103cb00d8c..c9cdbc1b2c 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -6,7 +6,8 @@ use crate::{ }, json::{self, json}, presenter::MeasurementContext, - ElementBox, RenderContext, ScrollWheelEvent, View, + scene::ScrollWheelRegionEvent, + ElementBox, MouseRegion, RenderContext, ScrollWheelEvent, View, }; use json::ToJson; use std::{cell::RefCell, cmp, ops::Range, rc::Rc}; @@ -50,6 +51,7 @@ pub struct UniformList { padding_top: f32, padding_bottom: f32, get_width_from_item: Option, + view_id: usize, } impl UniformList { @@ -77,6 +79,7 @@ impl UniformList { padding_top: 0., padding_bottom: 0., get_width_from_item: None, + view_id: cx.handle().id(), } } @@ -96,7 +99,7 @@ impl UniformList { } fn scroll( - &self, + state: UniformListState, _: Vector2F, mut delta: Vector2F, precise: bool, @@ -107,7 +110,7 @@ impl UniformList { delta *= 20.; } - let mut state = self.state.0.borrow_mut(); + let mut state = state.0.borrow_mut(); state.scroll_top = (state.scroll_top - delta.y()).max(0.0).min(scroll_max); cx.notify(); @@ -281,7 +284,31 @@ impl Element for UniformList { layout: &mut Self::LayoutState, cx: &mut PaintContext, ) -> Self::PaintState { - cx.scene.push_layer(Some(bounds)); + let visible_bounds = visible_bounds.intersection(bounds).unwrap_or_default(); + + cx.scene.push_layer(Some(visible_bounds)); + + cx.scene.push_mouse_region( + MouseRegion::new::(self.view_id, 0, visible_bounds).on_scroll({ + let scroll_max = layout.scroll_max; + let state = self.state.clone(); + move |ScrollWheelRegionEvent { + platform_event: + ScrollWheelEvent { + position, + delta, + precise, + .. + }, + .. + }, + cx| { + if !Self::scroll(state.clone(), position, delta, precise, scroll_max, cx) { + cx.propogate_event(); + } + } + }), + ); let mut item_origin = bounds.origin() - vec2f( @@ -300,7 +327,7 @@ impl Element for UniformList { fn dispatch_event( &mut self, event: &Event, - bounds: RectF, + _: RectF, _: RectF, layout: &mut Self::LayoutState, _: &mut Self::PaintState, @@ -311,20 +338,6 @@ impl Element for UniformList { handled = item.dispatch_event(event, cx) || handled; } - if let Event::ScrollWheel(ScrollWheelEvent { - position, - delta, - precise, - .. - }) = event - { - if bounds.contains_point(*position) - && self.scroll(*position, *delta, *precise, layout.scroll_max, cx) - { - handled = true; - } - } - handled } diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 980da91167..0639445b0d 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -325,7 +325,12 @@ impl Deterministic { let mut state = self.state.lock(); let wakeup_at = state.now + duration; let id = util::post_inc(&mut state.next_timer_id); - state.pending_timers.push((id, wakeup_at, tx)); + match state + .pending_timers + .binary_search_by_key(&wakeup_at, |e| e.1) + { + Ok(ix) | Err(ix) => state.pending_timers.insert(ix, (id, wakeup_at, tx)), + } let state = self.state.clone(); Timer::Deterministic(DeterministicTimer { rx, id, state }) } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index a50698070c..9fc2c16497 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -44,6 +44,8 @@ pub trait Platform: Send + Sync { fn unhide_other_apps(&self); fn quit(&self); + fn screen_size(&self) -> Vector2F; + fn open_window( &self, id: usize, @@ -63,12 +65,15 @@ pub trait Platform: Send + Sync { fn delete_credentials(&self, url: &str) -> Result<()>; fn set_cursor_style(&self, style: CursorStyle); + fn should_auto_hide_scrollbars(&self) -> bool; fn local_timezone(&self) -> UtcOffset; fn path_for_auxiliary_executable(&self, name: &str) -> Result; fn app_path(&self) -> Result; fn app_version(&self) -> Result; + fn os_name(&self) -> &'static str; + fn os_version(&self) -> Result; } pub(crate) trait ForegroundPlatform { diff --git a/crates/gpui/src/platform/mac/event.rs b/crates/gpui/src/platform/mac/event.rs index 51524f4b15..ea2b492b27 100644 --- a/crates/gpui/src/platform/mac/event.rs +++ b/crates/gpui/src/platform/mac/event.rs @@ -14,8 +14,10 @@ use core_graphics::{ event::{CGEvent, CGEventFlags, CGKeyCode}, event_source::{CGEventSource, CGEventSourceStateID}, }; +use ctor::ctor; +use foreign_types::ForeignType; use objc::{class, msg_send, sel, sel_impl}; -use std::{borrow::Cow, ffi::CStr, os::raw::c_char}; +use std::{borrow::Cow, ffi::CStr, mem, os::raw::c_char, ptr}; const BACKSPACE_KEY: u16 = 0x7f; const SPACE_KEY: u16 = b' ' as u16; @@ -25,6 +27,15 @@ const ESCAPE_KEY: u16 = 0x1b; const TAB_KEY: u16 = 0x09; const SHIFT_TAB_KEY: u16 = 0x19; +static mut EVENT_SOURCE: core_graphics::sys::CGEventSourceRef = ptr::null_mut(); + +#[ctor] +unsafe fn build_event_source() { + let source = CGEventSource::new(CGEventSourceStateID::Private).unwrap(); + EVENT_SOURCE = source.as_ptr(); + mem::forget(source); +} + pub fn key_to_native(key: &str) -> Cow { use cocoa::appkit::*; let code = match key { @@ -228,7 +239,8 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke { let mut chars_ignoring_modifiers = CStr::from_ptr(native_event.charactersIgnoringModifiers().UTF8String() as *mut c_char) .to_str() - .unwrap(); + .unwrap() + .to_string(); let first_char = chars_ignoring_modifiers.chars().next().map(|ch| ch as u16); let modifiers = native_event.modifierFlags(); @@ -243,31 +255,31 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke { #[allow(non_upper_case_globals)] let key = match first_char { - Some(SPACE_KEY) => "space", - Some(BACKSPACE_KEY) => "backspace", - Some(ENTER_KEY) | Some(NUMPAD_ENTER_KEY) => "enter", - Some(ESCAPE_KEY) => "escape", - Some(TAB_KEY) => "tab", - Some(SHIFT_TAB_KEY) => "tab", - Some(NSUpArrowFunctionKey) => "up", - Some(NSDownArrowFunctionKey) => "down", - Some(NSLeftArrowFunctionKey) => "left", - Some(NSRightArrowFunctionKey) => "right", - Some(NSPageUpFunctionKey) => "pageup", - Some(NSPageDownFunctionKey) => "pagedown", - Some(NSDeleteFunctionKey) => "delete", - Some(NSF1FunctionKey) => "f1", - Some(NSF2FunctionKey) => "f2", - Some(NSF3FunctionKey) => "f3", - Some(NSF4FunctionKey) => "f4", - Some(NSF5FunctionKey) => "f5", - Some(NSF6FunctionKey) => "f6", - Some(NSF7FunctionKey) => "f7", - Some(NSF8FunctionKey) => "f8", - Some(NSF9FunctionKey) => "f9", - Some(NSF10FunctionKey) => "f10", - Some(NSF11FunctionKey) => "f11", - Some(NSF12FunctionKey) => "f12", + Some(SPACE_KEY) => "space".to_string(), + Some(BACKSPACE_KEY) => "backspace".to_string(), + Some(ENTER_KEY) | Some(NUMPAD_ENTER_KEY) => "enter".to_string(), + Some(ESCAPE_KEY) => "escape".to_string(), + Some(TAB_KEY) => "tab".to_string(), + Some(SHIFT_TAB_KEY) => "tab".to_string(), + Some(NSUpArrowFunctionKey) => "up".to_string(), + Some(NSDownArrowFunctionKey) => "down".to_string(), + Some(NSLeftArrowFunctionKey) => "left".to_string(), + Some(NSRightArrowFunctionKey) => "right".to_string(), + Some(NSPageUpFunctionKey) => "pageup".to_string(), + Some(NSPageDownFunctionKey) => "pagedown".to_string(), + Some(NSDeleteFunctionKey) => "delete".to_string(), + Some(NSF1FunctionKey) => "f1".to_string(), + Some(NSF2FunctionKey) => "f2".to_string(), + Some(NSF3FunctionKey) => "f3".to_string(), + Some(NSF4FunctionKey) => "f4".to_string(), + Some(NSF5FunctionKey) => "f5".to_string(), + Some(NSF6FunctionKey) => "f6".to_string(), + Some(NSF7FunctionKey) => "f7".to_string(), + Some(NSF8FunctionKey) => "f8".to_string(), + Some(NSF9FunctionKey) => "f9".to_string(), + Some(NSF10FunctionKey) => "f10".to_string(), + Some(NSF11FunctionKey) => "f11".to_string(), + Some(NSF12FunctionKey) => "f12".to_string(), _ => { let mut chars_ignoring_modifiers_and_shift = chars_for_modified_key(native_event.keyCode(), false, false); @@ -303,21 +315,19 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke { shift, cmd, function, - key: key.into(), + key, } } -fn chars_for_modified_key<'a>(code: CGKeyCode, cmd: bool, shift: bool) -> &'a str { +fn chars_for_modified_key(code: CGKeyCode, cmd: bool, shift: bool) -> String { // Ideally, we would use `[NSEvent charactersByApplyingModifiers]` but that // always returns an empty string with certain keyboards, e.g. Japanese. Synthesizing // an event with the given flags instead lets us access `characters`, which always // returns a valid string. - let event = CGEvent::new_keyboard_event( - CGEventSource::new(CGEventSourceStateID::Private).unwrap(), - code, - true, - ) - .unwrap(); + let source = unsafe { core_graphics::event_source::CGEventSource::from_ptr(EVENT_SOURCE) }; + let event = CGEvent::new_keyboard_event(source.clone(), code, true).unwrap(); + mem::forget(source); + let mut flags = CGEventFlags::empty(); if cmd { flags |= CGEventFlags::CGEventFlagCommand; @@ -327,10 +337,11 @@ fn chars_for_modified_key<'a>(code: CGKeyCode, cmd: bool, shift: bool) -> &'a st } event.set_flags(flags); - let event: id = unsafe { msg_send![class!(NSEvent), eventWithCGEvent: event] }; unsafe { + let event: id = msg_send![class!(NSEvent), eventWithCGEvent: &*event]; CStr::from_ptr(event.characters().UTF8String()) .to_str() .unwrap() + .to_string() } } diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 7732da2b3e..a27220cf2e 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -2,9 +2,11 @@ use super::{ event::key_to_native, status_item::StatusItem, BoolExt as _, Dispatcher, FontSystem, Window, }; use crate::{ - executor, keymap, + executor, + geometry::vector::{vec2f, Vector2F}, + keymap, platform::{self, CursorStyle}, - Action, ClipboardItem, Event, Menu, MenuItem, + Action, AppVersion, ClipboardItem, Event, Menu, MenuItem, }; use anyhow::{anyhow, Result}; use block::ConcreteBlock; @@ -12,11 +14,12 @@ use cocoa::{ appkit::{ NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSPasteboard, - NSPasteboardTypeString, NSSavePanel, NSWindow, + NSPasteboardTypeString, NSSavePanel, NSScreen, NSWindow, }, base::{id, nil, selector, YES}, foundation::{ - NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSString, NSUInteger, NSURL, + NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSString, + NSUInteger, NSURL, }, }; use core_foundation::{ @@ -485,6 +488,14 @@ impl platform::Platform for MacPlatform { } } + fn screen_size(&self) -> Vector2F { + unsafe { + let screen = NSScreen::mainScreen(nil); + let frame = NSScreen::frame(screen); + vec2f(frame.size.width as f32, frame.size.height as f32) + } + } + fn open_window( &self, id: usize, @@ -698,6 +709,16 @@ impl platform::Platform for MacPlatform { } } + fn should_auto_hide_scrollbars(&self) -> bool { + #[allow(non_upper_case_globals)] + const NSScrollerStyleOverlay: NSInteger = 1; + + unsafe { + let style: NSInteger = msg_send![class!(NSScroller), preferredScrollerStyle]; + style == NSScrollerStyleOverlay + } + } + fn local_timezone(&self) -> UtcOffset { unsafe { let local_timezone: id = msg_send![class!(NSTimeZone), localTimeZone]; @@ -748,6 +769,22 @@ impl platform::Platform for MacPlatform { } } } + + fn os_name(&self) -> &'static str { + "macOS" + } + + fn os_version(&self) -> Result { + unsafe { + let process_info = NSProcessInfo::processInfo(nil); + let version = process_info.operatingSystemVersion(); + Ok(AppVersion { + major: version.majorVersion as usize, + minor: version.minorVersion as usize, + patch: version.patchVersion as usize, + }) + } + } } unsafe fn path_from_objc(path: id) -> PathBuf { diff --git a/crates/gpui/src/platform/mac/renderer.rs b/crates/gpui/src/platform/mac/renderer.rs index ea094e998c..6a70ff41f0 100644 --- a/crates/gpui/src/platform/mac/renderer.rs +++ b/crates/gpui/src/platform/mac/renderer.rs @@ -747,6 +747,7 @@ impl Renderer { border_left: border_width * (image.border.left as usize as f32), border_color: image.border.color.to_uchar4(), corner_radius, + grayscale: image.grayscale as u8, }); } @@ -769,6 +770,7 @@ impl Renderer { border_left: 0., border_color: Default::default(), corner_radius: 0., + grayscale: false as u8, }); } else { log::warn!("could not render glyph with id {}", image_glyph.id); diff --git a/crates/gpui/src/platform/mac/shaders/shaders.h b/crates/gpui/src/platform/mac/shaders/shaders.h index 29be2c9e1e..6e0ed1a5f1 100644 --- a/crates/gpui/src/platform/mac/shaders/shaders.h +++ b/crates/gpui/src/platform/mac/shaders/shaders.h @@ -90,6 +90,7 @@ typedef struct { float border_left; vector_uchar4 border_color; float corner_radius; + uint8_t grayscale; } GPUIImage; typedef enum { diff --git a/crates/gpui/src/platform/mac/shaders/shaders.metal b/crates/gpui/src/platform/mac/shaders/shaders.metal index 795026e747..397e7647c4 100644 --- a/crates/gpui/src/platform/mac/shaders/shaders.metal +++ b/crates/gpui/src/platform/mac/shaders/shaders.metal @@ -44,6 +44,7 @@ struct QuadFragmentInput { float border_left; float4 border_color; float corner_radius; + uchar grayscale; // only used in image shader }; float4 quad_sdf(QuadFragmentInput input) { @@ -110,6 +111,7 @@ vertex QuadFragmentInput quad_vertex( quad.border_left, coloru_to_colorf(quad.border_color), quad.corner_radius, + 0, }; } @@ -251,6 +253,7 @@ vertex QuadFragmentInput image_vertex( image.border_left, coloru_to_colorf(image.border_color), image.corner_radius, + image.grayscale, }; } @@ -260,6 +263,13 @@ fragment float4 image_fragment( ) { constexpr sampler atlas_sampler(mag_filter::linear, min_filter::linear); input.background_color = atlas.sample(atlas_sampler, input.atlas_position); + if (input.grayscale) { + float grayscale = + 0.2126 * input.background_color.r + + 0.7152 * input.background_color.g + + 0.0722 * input.background_color.b; + input.background_color = float4(grayscale, grayscale, grayscale, input.background_color.a); + } return quad_sdf(input); } @@ -289,6 +299,7 @@ vertex QuadFragmentInput surface_vertex( 0., float4(0.), 0., + 0, }; } diff --git a/crates/gpui/src/platform/test.rs b/crates/gpui/src/platform/test.rs index 9a458a1dd9..2a44616cdd 100644 --- a/crates/gpui/src/platform/test.rs +++ b/crates/gpui/src/platform/test.rs @@ -34,11 +34,11 @@ pub struct ForegroundPlatform { struct Dispatcher; pub struct Window { - size: Vector2F, + pub(crate) size: Vector2F, scale_factor: f32, current_scene: Option, event_handlers: Vec bool>>, - resize_handlers: Vec>, + pub(crate) resize_handlers: Vec>, close_handlers: Vec>, fullscreen_handlers: Vec>, pub(crate) active_status_change_handlers: Vec>, @@ -131,6 +131,10 @@ impl super::Platform for Platform { fn quit(&self) {} + fn screen_size(&self) -> Vector2F { + vec2f(1024., 768.) + } + fn open_window( &self, _: usize, @@ -177,6 +181,10 @@ impl super::Platform for Platform { *self.cursor.lock() = style; } + fn should_auto_hide_scrollbars(&self) -> bool { + false + } + fn local_timezone(&self) -> UtcOffset { UtcOffset::UTC } @@ -196,6 +204,18 @@ impl super::Platform for Platform { patch: 0, }) } + + fn os_name(&self) -> &'static str { + "test" + } + + fn os_version(&self) -> Result { + Ok(AppVersion { + major: 1, + minor: 0, + patch: 0, + }) + } } impl Window { diff --git a/crates/gpui/src/presenter.rs b/crates/gpui/src/presenter.rs index eaccd8f410..d082ebd095 100644 --- a/crates/gpui/src/presenter.rs +++ b/crates/gpui/src/presenter.rs @@ -12,10 +12,10 @@ use crate::{ UpOutRegionEvent, UpRegionEvent, }, text_layout::TextLayoutCache, - Action, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, Appearance, AssetCache, ElementBox, - Entity, FontSystem, ModelHandle, MouseButton, MouseMovedEvent, MouseRegion, MouseRegionId, - ParentId, ReadModel, ReadView, RenderContext, RenderParams, Scene, UpgradeModelHandle, - UpgradeViewHandle, View, ViewHandle, WeakModelHandle, WeakViewHandle, + Action, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AnyWeakViewHandle, Appearance, + AssetCache, ElementBox, Entity, FontSystem, ModelHandle, MouseButton, MouseMovedEvent, + MouseRegion, MouseRegionId, ParentId, ReadModel, ReadView, RenderContext, RenderParams, Scene, + UpgradeModelHandle, UpgradeViewHandle, View, ViewHandle, WeakModelHandle, WeakViewHandle, }; use collections::{HashMap, HashSet}; use pathfinder_geometry::vector::{vec2f, Vector2F}; @@ -231,7 +231,7 @@ impl Presenter { ) -> bool { if let Some(root_view_id) = cx.root_view_id(self.window_id) { let mut events_to_send = Vec::new(); - let mut invalidated_views: HashSet = Default::default(); + let mut notified_views: HashSet = Default::default(); // 1. Allocate the correct set of GPUI events generated from the platform events // -> These are usually small: [Mouse Down] or [Mouse up, Click] or [Mouse Moved, Mouse Dragged?] @@ -257,11 +257,6 @@ impl Presenter { }) .collect(); - // Clicked status is used when rendering views via the RenderContext. - // So when it changes, these views need to be rerendered - for clicked_region_id in self.clicked_region_ids.iter() { - invalidated_views.insert(clicked_region_id.view_id()); - } self.clicked_button = Some(e.button); } @@ -392,17 +387,31 @@ impl Presenter { //Ensure that hover entrance events aren't sent twice if self.hovered_region_ids.insert(region.id()) { valid_regions.push(region.clone()); - invalidated_views.insert(region.id().view_id()); + if region.notify_on_hover { + notified_views.insert(region.id().view_id()); + } } } else { // Ensure that hover exit events aren't sent twice if self.hovered_region_ids.remove(®ion.id()) { valid_regions.push(region.clone()); - invalidated_views.insert(region.id().view_id()); + if region.notify_on_hover { + notified_views.insert(region.id().view_id()); + } } } } } + MouseRegionEvent::Down(_) | MouseRegionEvent::Up(_) => { + for (region, _) in self.mouse_regions.iter().rev() { + if region.bounds.contains_point(self.mouse_position) { + if region.notify_on_click { + notified_views.insert(region.id().view_id()); + } + valid_regions.push(region.clone()); + } + } + } MouseRegionEvent::Click(e) => { // Only raise click events if the released button is the same as the one stored if self @@ -413,11 +422,6 @@ impl Presenter { // Clear clicked regions and clicked button let clicked_region_ids = std::mem::replace(&mut self.clicked_region_ids, Default::default()); - // Clicked status is used when rendering views via the RenderContext. - // So when it changes, these views need to be rerendered - for clicked_region_id in clicked_region_ids.iter() { - invalidated_views.insert(clicked_region_id.view_id()); - } self.clicked_button = None; // Find regions which still overlap with the mouse since the last MouseDown happened @@ -459,7 +463,7 @@ impl Presenter { //3. Fire region events let hovered_region_ids = self.hovered_region_ids.clone(); for valid_region in valid_regions.into_iter() { - let mut event_cx = self.build_event_context(&mut invalidated_views, cx); + let mut event_cx = self.build_event_context(&mut notified_views, cx); region_event.set_region(valid_region.bounds); if let MouseRegionEvent::Hover(e) = &mut region_event { @@ -482,9 +486,6 @@ impl Presenter { if let Some(callback) = valid_region.handlers.get(®ion_event.handler_key()) { event_cx.handled = true; - event_cx - .invalidated_views - .insert(valid_region.id().view_id()); event_cx.with_current_view(valid_region.id().view_id(), { let region_event = region_event.clone(); |cx| { @@ -503,11 +504,11 @@ impl Presenter { } if !any_event_handled && !event_reused { - let mut event_cx = self.build_event_context(&mut invalidated_views, cx); + let mut event_cx = self.build_event_context(&mut notified_views, cx); any_event_handled = event_cx.dispatch_event(root_view_id, &event); } - for view_id in invalidated_views { + for view_id in notified_views { cx.notify_view(self.window_id, view_id); } @@ -519,7 +520,7 @@ impl Presenter { pub fn build_event_context<'a>( &'a mut self, - invalidated_views: &'a mut HashSet, + notified_views: &'a mut HashSet, cx: &'a mut MutableAppContext, ) -> EventContext<'a> { EventContext { @@ -527,7 +528,7 @@ impl Presenter { font_cache: &self.font_cache, text_layout_cache: &self.text_layout_cache, view_stack: Default::default(), - invalidated_views, + notified_views, notify_count: 0, handled: false, window_id: self.window_id, @@ -750,7 +751,7 @@ pub struct EventContext<'a> { pub notify_count: usize, view_stack: Vec, handled: bool, - invalidated_views: &'a mut HashSet, + notified_views: &'a mut HashSet, } impl<'a> EventContext<'a> { @@ -809,7 +810,7 @@ impl<'a> EventContext<'a> { pub fn notify(&mut self) { self.notify_count += 1; if let Some(view_id) = self.view_stack.last() { - self.invalidated_views.insert(*view_id); + self.notified_views.insert(*view_id); } } @@ -972,17 +973,23 @@ impl ToJson for SizeConstraint { } pub struct ChildView { - view: AnyViewHandle, + view: AnyWeakViewHandle, + view_name: &'static str, } impl ChildView { - pub fn new(view: impl Into) -> Self { - Self { view: view.into() } + pub fn new(view: impl Into, cx: &AppContext) -> Self { + let view = view.into(); + let view_name = cx.view_ui_name(view.window_id(), view.id()).unwrap(); + Self { + view: view.downgrade(), + view_name, + } } } impl Element for ChildView { - type LayoutState = (); + type LayoutState = bool; type PaintState = (); fn layout( @@ -990,18 +997,35 @@ impl Element for ChildView { constraint: SizeConstraint, cx: &mut LayoutContext, ) -> (Vector2F, Self::LayoutState) { - let size = cx.layout(self.view.id(), constraint); - (size, ()) + if cx.rendered_views.contains_key(&self.view.id()) { + let size = cx.layout(self.view.id(), constraint); + (size, true) + } else { + log::error!( + "layout called on a ChildView element whose underlying view was dropped (view_id: {}, name: {:?})", + self.view.id(), + self.view_name + ); + (Vector2F::zero(), false) + } } fn paint( &mut self, bounds: RectF, visible_bounds: RectF, - _: &mut Self::LayoutState, + view_is_valid: &mut Self::LayoutState, cx: &mut PaintContext, - ) -> Self::PaintState { - cx.paint(self.view.id(), bounds.origin(), visible_bounds); + ) { + if *view_is_valid { + cx.paint(self.view.id(), bounds.origin(), visible_bounds); + } else { + log::error!( + "paint called on a ChildView element whose underlying view was dropped (view_id: {}, name: {:?})", + self.view.id(), + self.view_name + ); + } } fn dispatch_event( @@ -1009,11 +1033,20 @@ impl Element for ChildView { event: &Event, _: RectF, _: RectF, - _: &mut Self::LayoutState, + view_is_valid: &mut Self::LayoutState, _: &mut Self::PaintState, cx: &mut EventContext, ) -> bool { - cx.dispatch_event(self.view.id(), event) + if *view_is_valid { + cx.dispatch_event(self.view.id(), event) + } else { + log::error!( + "dispatch_event called on a ChildView element whose underlying view was dropped (view_id: {}, name: {:?})", + self.view.id(), + self.view_name + ); + false + } } fn rect_for_text_range( @@ -1021,11 +1054,20 @@ impl Element for ChildView { range_utf16: Range, _: RectF, _: RectF, - _: &Self::LayoutState, + view_is_valid: &Self::LayoutState, _: &Self::PaintState, cx: &MeasurementContext, ) -> Option { - cx.rect_for_text_range(self.view.id(), range_utf16) + if *view_is_valid { + cx.rect_for_text_range(self.view.id(), range_utf16) + } else { + log::error!( + "rect_for_text_range called on a ChildView element whose underlying view was dropped (view_id: {}, name: {:?})", + self.view.id(), + self.view_name + ); + None + } } fn debug( @@ -1039,7 +1081,11 @@ impl Element for ChildView { "type": "ChildView", "view_id": self.view.id(), "bounds": bounds.to_json(), - "view": self.view.debug_json(cx.app), + "view": if let Some(view) = self.view.upgrade(cx.app) { + view.debug_json(cx.app) + } else { + json!(null) + }, "child": if let Some(view) = cx.rendered_views.get(&self.view.id()) { view.debug(cx) } else { diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index e676147fa9..4ef17a3f8f 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -172,6 +172,7 @@ pub struct Image { pub bounds: RectF, pub border: Border, pub corner_radius: f32, + pub grayscale: bool, pub data: Arc, } diff --git a/crates/gpui/src/scene/mouse_region.rs b/crates/gpui/src/scene/mouse_region.rs index 9bd1ab008b..e84508622b 100644 --- a/crates/gpui/src/scene/mouse_region.rs +++ b/crates/gpui/src/scene/mouse_region.rs @@ -20,6 +20,8 @@ pub struct MouseRegion { pub bounds: RectF, pub handlers: HandlerSet, pub hoverable: bool, + pub notify_on_hover: bool, + pub notify_on_click: bool, } impl MouseRegion { @@ -52,6 +54,8 @@ impl MouseRegion { bounds, handlers, hoverable: true, + notify_on_hover: false, + notify_on_click: false, } } @@ -137,6 +141,16 @@ impl MouseRegion { self.hoverable = is_hoverable; self } + + pub fn with_notify_on_hover(mut self, notify: bool) -> Self { + self.notify_on_hover = notify; + self + } + + pub fn with_notify_on_click(mut self, notify: bool) -> Self { + self.notify_on_click = notify; + self + } } #[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] diff --git a/crates/gpui/src/test.rs b/crates/gpui/src/test.rs index 4122ad09b7..e76b094c9a 100644 --- a/crates/gpui/src/test.rs +++ b/crates/gpui/src/test.rs @@ -37,6 +37,7 @@ pub fn run_test( u64, bool, )), + fn_name: String, ) { // let _profiler = dhat::Profiler::new_heap(); @@ -78,6 +79,7 @@ pub fn run_test( font_cache.clone(), leak_detector.clone(), 0, + fn_name.clone(), ); cx.update(|cx| { test_fn( @@ -91,7 +93,7 @@ pub fn run_test( cx.update(|cx| cx.remove_all_windows()); deterministic.run_until_parked(); - cx.update(|_| {}); // flush effects + cx.update(|cx| cx.clear_globals()); leak_detector.lock().detect(); if is_last_iteration { diff --git a/crates/gpui/src/views/select.rs b/crates/gpui/src/views/select.rs index 113fbf5828..216491db0f 100644 --- a/crates/gpui/src/views/select.rs +++ b/crates/gpui/src/views/select.rs @@ -113,7 +113,7 @@ impl View for Select { Container::new((self.render_item)( self.selected_item_ix, ItemType::Header, - mouse_state.hovered, + mouse_state.hovered(), cx, )) .with_style(style.header) @@ -145,7 +145,7 @@ impl View for Select { } else { ItemType::Unselected }, - mouse_state.hovered, + mouse_state.hovered(), cx, ) }) diff --git a/crates/gpui_macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs index a60d385e8f..b43bedc643 100644 --- a/crates/gpui_macros/src/gpui_macros.rs +++ b/crates/gpui_macros/src/gpui_macros.rs @@ -117,12 +117,13 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { cx.font_cache().clone(), cx.leak_detector(), #first_entity_id, + stringify!(#outer_fn_name).to_string(), ); )); cx_teardowns.extend(quote!( #cx_varname.update(|cx| cx.remove_all_windows()); deterministic.run_until_parked(); - #cx_varname.update(|_| {}); // flush effects + #cx_varname.update(|cx| cx.clear_globals()); )); inner_fn_args.extend(quote!(&mut #cx_varname,)); continue; @@ -149,7 +150,8 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { #cx_vars cx.foreground().run(#inner_fn_name(#inner_fn_args)); #cx_teardowns - } + }, + stringify!(#outer_fn_name).to_string(), ); } } @@ -187,7 +189,8 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { #num_iterations as u64, #starting_seed as u64, #max_retries, - &mut |cx, _, _, seed, is_last_iteration| #inner_fn_name(#inner_fn_args) + &mut |cx, _, _, seed, is_last_iteration| #inner_fn_name(#inner_fn_args), + stringify!(#outer_fn_name).to_string(), ); } } diff --git a/crates/journal/Cargo.toml b/crates/journal/Cargo.toml index 94dcf8e407..8c900d9f4a 100644 --- a/crates/journal/Cargo.toml +++ b/crates/journal/Cargo.toml @@ -15,3 +15,5 @@ workspace = { path = "../workspace" } chrono = "0.4" dirs = "4.0" log = { version = "0.4.16", features = ["kv_unstable_serde"] } +settings = { path = "../settings" } +shellexpand = "2.1.0" \ No newline at end of file diff --git a/crates/journal/src/journal.rs b/crates/journal/src/journal.rs index 8135950e50..4269556251 100644 --- a/crates/journal/src/journal.rs +++ b/crates/journal/src/journal.rs @@ -1,7 +1,12 @@ -use chrono::{Datelike, Local, Timelike}; +use chrono::{Datelike, Local, NaiveTime, Timelike}; use editor::{Autoscroll, Editor}; use gpui::{actions, MutableAppContext}; -use std::{fs::OpenOptions, sync::Arc}; +use settings::{HourFormat, Settings}; +use std::{ + fs::OpenOptions, + path::{Path, PathBuf}, + sync::Arc, +}; use util::TryFutureExt as _; use workspace::AppState; @@ -12,24 +17,23 @@ pub fn init(app_state: Arc, cx: &mut MutableAppContext) { } pub fn new_journal_entry(app_state: Arc, cx: &mut MutableAppContext) { - let now = Local::now(); - let home_dir = match dirs::home_dir() { - Some(home_dir) => home_dir, + let settings = cx.global::(); + let journal_dir = match journal_dir(&settings) { + Some(journal_dir) => journal_dir, None => { - log::error!("can't determine home directory"); + log::error!("Can't determine journal directory"); return; } }; - let journal_dir = home_dir.join("journal"); + let now = Local::now(); let month_dir = journal_dir .join(format!("{:02}", now.year())) .join(format!("{:02}", now.month())); let entry_path = month_dir.join(format!("{:02}.md", now.day())); let now = now.time(); - let (pm, hour) = now.hour12(); - let am_or_pm = if pm { "PM" } else { "AM" }; - let entry_heading = format!("# {}:{:02} {}\n\n", hour, now.minute(), am_or_pm); + let hour_format = &settings.journal_overrides.hour_format; + let entry_heading = heading_entry(now, &hour_format); let create_entry = cx.background().spawn(async move { std::fs::create_dir_all(month_dir)?; @@ -64,6 +68,7 @@ pub fn new_journal_entry(app_state: Arc, cx: &mut MutableAppContext) { editor.insert("\n\n", cx); } editor.insert(&entry_heading, cx); + editor.insert("\n\n", cx); }); } } @@ -74,3 +79,65 @@ pub fn new_journal_entry(app_state: Arc, cx: &mut MutableAppContext) { }) .detach(); } + +fn journal_dir(settings: &Settings) -> Option { + let journal_dir = settings + .journal_overrides + .path + .as_ref() + .unwrap_or(settings.journal_defaults.path.as_ref()?); + + let expanded_journal_dir = shellexpand::full(&journal_dir) //TODO handle this better + .ok() + .map(|dir| Path::new(&dir.to_string()).to_path_buf().join("journal")); + + return expanded_journal_dir; +} + +fn heading_entry(now: NaiveTime, hour_format: &Option) -> String { + match hour_format { + Some(HourFormat::Hour24) => { + let hour = now.hour(); + format!("# {}:{:02}", hour, now.minute()) + } + _ => { + let (pm, hour) = now.hour12(); + let am_or_pm = if pm { "PM" } else { "AM" }; + format!("# {}:{:02} {}", hour, now.minute(), am_or_pm) + } + } +} + +#[cfg(test)] +mod tests { + mod heading_entry_tests { + use super::super::*; + + #[test] + fn test_heading_entry_defaults_to_hour_12() { + let naive_time = NaiveTime::from_hms_milli(15, 0, 0, 0); + let actual_heading_entry = heading_entry(naive_time, &None); + let expected_heading_entry = "# 3:00 PM"; + + assert_eq!(actual_heading_entry, expected_heading_entry); + } + + #[test] + fn test_heading_entry_is_hour_12() { + let naive_time = NaiveTime::from_hms_milli(15, 0, 0, 0); + let actual_heading_entry = heading_entry(naive_time, &Some(HourFormat::Hour12)); + let expected_heading_entry = "# 3:00 PM"; + + assert_eq!(actual_heading_entry, expected_heading_entry); + } + + #[test] + fn test_heading_entry_is_hour_24() { + let naive_time = NaiveTime::from_hms_milli(15, 0, 0, 0); + let actual_heading_entry = heading_entry(naive_time, &Some(HourFormat::Hour24)); + let expected_heading_entry = "# 15:00"; + + assert_eq!(actual_heading_entry, expected_heading_entry); + } + } +} diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 6e9f368e77..96feadbfbc 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -25,6 +25,8 @@ client = { path = "../client" } clock = { path = "../clock" } collections = { path = "../collections" } fuzzy = { path = "../fuzzy" } +fs = { path = "../fs" } +git = { path = "../git" } gpui = { path = "../gpui" } lsp = { path = "../lsp" } rpc = { path = "../rpc" } @@ -63,6 +65,8 @@ util = { path = "../util", features = ["test-support"] } ctor = "0.1" env_logger = "0.9" rand = "0.8.3" +tree-sitter-html = "*" +tree-sitter-javascript = "*" tree-sitter-json = "*" tree-sitter-rust = "*" tree-sitter-python = "*" diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 08843aacfe..274777b81c 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -13,6 +13,7 @@ use crate::{ }; use anyhow::{anyhow, Result}; use clock::ReplicaId; +use fs::LineEnding; use futures::FutureExt as _; use gpui::{fonts::HighlightStyle, AppContext, Entity, ModelContext, MutableAppContext, Task}; use parking_lot::Mutex; @@ -38,6 +39,8 @@ use sum_tree::TreeMap; use text::operation_queue::OperationQueue; pub use text::{Buffer as TextBuffer, BufferSnapshot as TextBufferSnapshot, Operation as _, *}; use theme::SyntaxTheme; +#[cfg(any(test, feature = "test-support"))] +use util::RandomCharIter; use util::TryFutureExt as _; #[cfg(any(test, feature = "test-support"))] @@ -45,8 +48,16 @@ pub use {tree_sitter_rust, tree_sitter_typescript}; pub use lsp::DiagnosticSeverity; +struct GitDiffStatus { + diff: git::diff::BufferDiff, + update_in_progress: bool, + update_requested: bool, +} + pub struct Buffer { text: TextBuffer, + diff_base: Option, + git_diff_status: GitDiffStatus, file: Option>, saved_version: clock::Global, saved_version_fingerprint: String, @@ -66,6 +77,7 @@ pub struct Buffer { diagnostics_update_count: usize, diagnostics_timestamp: clock::Lamport, file_update_count: usize, + git_diff_update_count: usize, completion_triggers: Vec, completion_triggers_timestamp: clock::Lamport, deferred_ops: OperationQueue, @@ -73,25 +85,28 @@ pub struct Buffer { pub struct BufferSnapshot { text: text::BufferSnapshot, + pub git_diff: git::diff::BufferDiff, pub(crate) syntax: SyntaxSnapshot, file: Option>, diagnostics: DiagnosticSet, diagnostics_update_count: usize, file_update_count: usize, + git_diff_update_count: usize, remote_selections: TreeMap, selections_update_count: usize, language: Option>, parse_count: usize, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub struct IndentSize { pub len: u32, pub kind: IndentKind, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum IndentKind { + #[default] Space, Tab, } @@ -236,7 +251,6 @@ pub enum AutoindentMode { struct AutoindentRequest { before_edit: BufferSnapshot, entries: Vec, - indent_size: IndentSize, is_block_mode: bool, } @@ -249,6 +263,7 @@ struct AutoindentRequestEntry { /// only be adjusted if the suggested indentation level has *changed* /// since the edit was made. first_line_is_new: bool, + indent_size: IndentSize, original_indent_column: Option, } @@ -267,7 +282,7 @@ struct BufferChunkHighlights<'a> { pub struct BufferChunks<'a> { range: Range, - chunks: rope::Chunks<'a>, + chunks: text::Chunks<'a>, diagnostic_endpoints: Peekable>, error_depth: usize, warning_depth: usize, @@ -288,10 +303,8 @@ pub struct Chunk<'a> { pub struct Diff { base_version: clock::Global, - new_text: Arc, - changes: Vec<(ChangeTag, usize)>, line_ending: LineEnding, - start_offset: usize, + edits: Vec<(Range, Arc)>, } #[derive(Clone, Copy)] @@ -328,17 +341,20 @@ impl Buffer { Self::build( TextBuffer::new(replica_id, cx.model_id() as u64, base_text.into()), None, + None, ) } pub fn from_file>( replica_id: ReplicaId, base_text: T, + diff_base: Option, file: Arc, cx: &mut ModelContext, ) -> Self { Self::build( TextBuffer::new(replica_id, cx.model_id() as u64, base_text.into()), + diff_base.map(|h| h.into().into_boxed_str().into()), Some(file), ) } @@ -349,9 +365,13 @@ impl Buffer { file: Option>, ) -> Result { let buffer = TextBuffer::new(replica_id, message.id, message.base_text); - let mut this = Self::build(buffer, file); + let mut this = Self::build( + buffer, + message.diff_base.map(|text| text.into_boxed_str().into()), + file, + ); this.text.set_line_ending(proto::deserialize_line_ending( - proto::LineEnding::from_i32(message.line_ending) + rpc::proto::LineEnding::from_i32(message.line_ending) .ok_or_else(|| anyhow!("missing line_ending"))?, )); Ok(this) @@ -362,6 +382,7 @@ impl Buffer { id: self.remote_id(), file: self.file.as_ref().map(|f| f.to_proto()), base_text: self.base_text().to_string(), + diff_base: self.diff_base.as_ref().map(|h| h.to_string()), line_ending: proto::serialize_line_ending(self.line_ending()) as i32, } } @@ -404,7 +425,7 @@ impl Buffer { self } - fn build(buffer: TextBuffer, file: Option>) -> Self { + fn build(buffer: TextBuffer, diff_base: Option, file: Option>) -> Self { let saved_mtime = if let Some(file) = file.as_ref() { file.mtime() } else { @@ -418,6 +439,12 @@ impl Buffer { transaction_depth: 0, was_dirty_before_starting_transaction: None, text: buffer, + diff_base, + git_diff_status: GitDiffStatus { + diff: git::diff::BufferDiff::new(), + update_in_progress: false, + update_requested: false, + }, file, syntax_map: Mutex::new(SyntaxMap::new()), parsing_in_background: false, @@ -432,6 +459,7 @@ impl Buffer { diagnostics_update_count: 0, diagnostics_timestamp: Default::default(), file_update_count: 0, + git_diff_update_count: 0, completion_triggers: Default::default(), completion_triggers_timestamp: Default::default(), deferred_ops: OperationQueue::new(), @@ -447,11 +475,13 @@ impl Buffer { BufferSnapshot { text, syntax, + git_diff: self.git_diff_status.diff.clone(), file: self.file.clone(), remote_selections: self.remote_selections.clone(), diagnostics: self.diagnostics.clone(), diagnostics_update_count: self.diagnostics_update_count, file_update_count: self.file_update_count, + git_diff_update_count: self.git_diff_update_count, language: self.language.clone(), parse_count: self.parse_count, selections_update_count: self.selections_update_count, @@ -584,6 +614,7 @@ impl Buffer { cx, ); } + self.git_diff_recalc(cx); cx.emit(Event::Reloaded); cx.notify(); } @@ -633,6 +664,60 @@ impl Buffer { task } + #[cfg(any(test, feature = "test-support"))] + pub fn diff_base(&self) -> Option<&str> { + self.diff_base.as_deref() + } + + pub fn update_diff_base(&mut self, diff_base: Option, cx: &mut ModelContext) { + self.diff_base = diff_base; + self.git_diff_recalc(cx); + } + + pub fn needs_git_diff_recalc(&self) -> bool { + self.git_diff_status.diff.needs_update(self) + } + + pub fn git_diff_recalc(&mut self, cx: &mut ModelContext) { + if self.git_diff_status.update_in_progress { + self.git_diff_status.update_requested = true; + return; + } + + if let Some(diff_base) = &self.diff_base { + let snapshot = self.snapshot(); + let diff_base = diff_base.clone(); + + let mut diff = self.git_diff_status.diff.clone(); + let diff = cx.background().spawn(async move { + diff.update(&diff_base, &snapshot).await; + diff + }); + + cx.spawn_weak(|this, mut cx| async move { + let buffer_diff = diff.await; + if let Some(this) = this.upgrade(&cx) { + this.update(&mut cx, |this, cx| { + this.git_diff_status.diff = buffer_diff; + this.git_diff_update_count += 1; + cx.notify(); + + this.git_diff_status.update_in_progress = false; + if this.git_diff_status.update_requested { + this.git_diff_recalc(cx); + } + }) + } + }) + .detach() + } else { + let snapshot = self.snapshot(); + self.git_diff_status.diff.clear(&snapshot); + self.git_diff_update_count += 1; + cx.notify(); + } + } + pub fn close(&mut self, cx: &mut ModelContext) { cx.emit(Event::Closed); } @@ -641,6 +726,16 @@ impl Buffer { self.language.as_ref() } + pub fn language_at(&self, position: D) -> Option> { + let offset = position.to_offset(self); + self.syntax_map + .lock() + .layers_for_range(offset..offset, &self.text) + .last() + .map(|info| info.language.clone()) + .or_else(|| self.language.clone()) + } + pub fn parse_count(&self) -> usize { self.parse_count } @@ -657,6 +752,10 @@ impl Buffer { self.file_update_count } + pub fn git_diff_update_count(&self) -> usize { + self.git_diff_update_count + } + #[cfg(any(test, feature = "test-support"))] pub fn is_parsing(&self) -> bool { self.parsing_in_background @@ -766,6 +865,8 @@ impl Buffer { })); } } + } else { + self.autoindent_requests.clear(); } } @@ -784,10 +885,13 @@ impl Buffer { // buffer before this batch of edits. let mut row_ranges = Vec::new(); let mut old_to_new_rows = BTreeMap::new(); + let mut language_indent_sizes_by_new_row = Vec::new(); for entry in &request.entries { let position = entry.range.start; let new_row = position.to_point(&snapshot).row; let new_end_row = entry.range.end.to_point(&snapshot).row + 1; + language_indent_sizes_by_new_row.push((new_row, entry.indent_size)); + if !entry.first_line_is_new { let old_row = position.to_point(&request.before_edit).row; old_to_new_rows.insert(old_row, new_row); @@ -801,6 +905,8 @@ impl Buffer { let mut old_suggestions = BTreeMap::::default(); let old_edited_ranges = contiguous_ranges(old_to_new_rows.keys().copied(), max_rows_between_yields); + let mut language_indent_sizes = language_indent_sizes_by_new_row.iter().peekable(); + let mut language_indent_size = IndentSize::default(); for old_edited_range in old_edited_ranges { let suggestions = request .before_edit @@ -809,6 +915,17 @@ impl Buffer { .flatten(); for (old_row, suggestion) in old_edited_range.zip(suggestions) { if let Some(suggestion) = suggestion { + let new_row = *old_to_new_rows.get(&old_row).unwrap(); + + // Find the indent size based on the language for this row. + while let Some((row, size)) = language_indent_sizes.peek() { + if *row > new_row { + break; + } + language_indent_size = *size; + language_indent_sizes.next(); + } + let suggested_indent = old_to_new_rows .get(&suggestion.basis_row) .and_then(|from_row| old_suggestions.get(from_row).copied()) @@ -817,9 +934,8 @@ impl Buffer { .before_edit .indent_size_for_line(suggestion.basis_row) }) - .with_delta(suggestion.delta, request.indent_size); - old_suggestions - .insert(*old_to_new_rows.get(&old_row).unwrap(), suggested_indent); + .with_delta(suggestion.delta, language_indent_size); + old_suggestions.insert(new_row, suggested_indent); } } yield_now().await; @@ -840,6 +956,8 @@ impl Buffer { // Compute new suggestions for each line, but only include them in the result // if they differ from the old suggestion for that line. + let mut language_indent_sizes = language_indent_sizes_by_new_row.iter().peekable(); + let mut language_indent_size = IndentSize::default(); for new_edited_row_range in new_edited_row_ranges { let suggestions = snapshot .suggest_autoindents(new_edited_row_range.clone()) @@ -847,13 +965,22 @@ impl Buffer { .flatten(); for (new_row, suggestion) in new_edited_row_range.zip(suggestions) { if let Some(suggestion) = suggestion { + // Find the indent size based on the language for this row. + while let Some((row, size)) = language_indent_sizes.peek() { + if *row > new_row { + break; + } + language_indent_size = *size; + language_indent_sizes.next(); + } + let suggested_indent = indent_sizes .get(&suggestion.basis_row) .copied() .unwrap_or_else(|| { snapshot.indent_size_for_line(suggestion.basis_row) }) - .with_delta(suggestion.delta, request.indent_size); + .with_delta(suggestion.delta, language_indent_size); if old_suggestions .get(&new_row) .map_or(true, |old_indentation| { @@ -965,16 +1092,30 @@ impl Buffer { let old_text = old_text.to_string(); let line_ending = LineEnding::detect(&new_text); LineEnding::normalize(&mut new_text); - let changes = TextDiff::from_chars(old_text.as_str(), new_text.as_str()) - .iter_all_changes() - .map(|c| (c.tag(), c.value().len())) - .collect::>(); + let diff = TextDiff::from_chars(old_text.as_str(), new_text.as_str()); + let mut edits = Vec::new(); + let mut offset = 0; + let empty: Arc = "".into(); + for change in diff.iter_all_changes() { + let value = change.value(); + let end_offset = offset + value.len(); + match change.tag() { + ChangeTag::Equal => { + offset = end_offset; + } + ChangeTag::Delete => { + edits.push((offset..end_offset, empty.clone())); + offset = end_offset; + } + ChangeTag::Insert => { + edits.push((offset..offset, value.into())); + } + } + } Diff { base_version, - new_text: new_text.into(), - changes, line_ending, - start_offset: 0, + edits, } }) } @@ -984,28 +1125,7 @@ impl Buffer { self.finalize_last_transaction(); self.start_transaction(); self.text.set_line_ending(diff.line_ending); - let mut offset = diff.start_offset; - for (tag, len) in diff.changes { - let range = offset..(offset + len); - match tag { - ChangeTag::Equal => offset += len, - ChangeTag::Delete => { - self.edit([(range, "")], None, cx); - } - ChangeTag::Insert => { - self.edit( - [( - offset..offset, - &diff.new_text[range.start - diff.start_offset - ..range.end - diff.start_offset], - )], - None, - cx, - ); - offset += len; - } - } - } + self.edit(diff.edits, None, cx); if self.end_transaction(cx).is_some() { self.finalize_last_transaction() } else { @@ -1184,7 +1304,6 @@ impl Buffer { let edit_id = edit_operation.local_timestamp(); if let Some((before_edit, mode)) = autoindent_request { - let indent_size = before_edit.single_indent_size(cx); let (start_columns, is_block_mode) = match mode { AutoindentMode::Block { original_indent_columns: start_columns, @@ -1233,6 +1352,7 @@ impl Buffer { AutoindentRequestEntry { first_line_is_new, original_indent_column: start_column, + indent_size: before_edit.language_indent_size_at(range.start, cx), range: self.anchor_before(new_start + range_of_insertion_to_indent.start) ..self.anchor_after(new_start + range_of_insertion_to_indent.end), } @@ -1242,7 +1362,6 @@ impl Buffer { self.autoindent_requests.push(Arc::new(AutoindentRequest { before_edit, entries, - indent_size, is_block_mode, })); } @@ -1519,9 +1638,7 @@ impl Buffer { last_end = Some(range.end); let new_text_len = rng.gen_range(0..10); - let new_text: String = crate::random_char_iter::RandomCharIter::new(&mut *rng) - .take(new_text_len) - .collect(); + let new_text: String = RandomCharIter::new(&mut *rng).take(new_text_len).collect(); edits.push((range, new_text)); } @@ -1560,8 +1677,8 @@ impl BufferSnapshot { indent_size_for_line(self, row) } - pub fn single_indent_size(&self, cx: &AppContext) -> IndentSize { - let language_name = self.language().map(|language| language.name()); + pub fn language_indent_size_at(&self, position: T, cx: &AppContext) -> IndentSize { + let language_name = self.language_at(position).map(|language| language.name()); let settings = cx.global::(); if settings.hard_tabs(language_name.as_deref()) { IndentSize::tab() @@ -1631,6 +1748,8 @@ impl BufferSnapshot { if capture.index == config.indent_capture_ix { start.get_or_insert(Point::from_ts_point(capture.node.start_position())); end.get_or_insert(Point::from_ts_point(capture.node.end_position())); + } else if Some(capture.index) == config.start_capture_ix { + start = Some(Point::from_ts_point(capture.node.end_position())); } else if Some(capture.index) == config.end_capture_ix { end = Some(Point::from_ts_point(capture.node.start_position())); } @@ -1820,8 +1939,14 @@ impl BufferSnapshot { } } - pub fn language(&self) -> Option<&Arc> { - self.language.as_ref() + pub fn language_at(&self, position: D) -> Option<&Arc> { + let offset = position.to_offset(self); + self.syntax + .layers_for_range(offset..offset, &self.text) + .filter(|l| l.node.end_byte() > offset) + .last() + .map(|info| info.language) + .or(self.language.as_ref()) } pub fn surrounding_word(&self, start: T) -> (Range, Option) { @@ -1856,8 +1981,8 @@ impl BufferSnapshot { pub fn range_for_syntax_ancestor(&self, range: Range) -> Option> { let range = range.start.to_offset(self)..range.end.to_offset(self); let mut result: Option> = None; - 'outer: for (_, _, node) in self.syntax.layers_for_range(range.clone(), &self.text) { - let mut cursor = node.walk(); + 'outer: for layer in self.syntax.layers_for_range(range.clone(), &self.text) { + let mut cursor = layer.node.walk(); // Descend to the first leaf that touches the start of the range, // and if the range is non-empty, extends beyond the start. @@ -2139,6 +2264,13 @@ impl BufferSnapshot { }) } + pub fn git_diff_hunks_in_range<'a>( + &'a self, + query_row_range: Range, + ) -> impl 'a + Iterator> { + self.git_diff.hunks_in_range(query_row_range, self) + } + pub fn diagnostics_in_range<'a, T, O>( &'a self, search_range: Range, @@ -2186,6 +2318,10 @@ impl BufferSnapshot { pub fn file_update_count(&self) -> usize { self.file_update_count } + + pub fn git_diff_update_count(&self) -> usize { + self.git_diff_update_count + } } pub fn indent_size_for_line(text: &text::BufferSnapshot, row: u32) -> IndentSize { @@ -2212,6 +2348,7 @@ impl Clone for BufferSnapshot { fn clone(&self) -> Self { Self { text: self.text.clone(), + git_diff: self.git_diff.clone(), syntax: self.syntax.clone(), file: self.file.clone(), remote_selections: self.remote_selections.clone(), @@ -2219,6 +2356,7 @@ impl Clone for BufferSnapshot { selections_update_count: self.selections_update_count, diagnostics_update_count: self.diagnostics_update_count, file_update_count: self.file_update_count, + git_diff_update_count: self.git_diff_update_count, language: self.language.clone(), parse_count: self.parse_count, } diff --git a/crates/language/src/tests.rs b/crates/language/src/buffer_tests.rs similarity index 93% rename from crates/language/src/tests.rs rename to crates/language/src/buffer_tests.rs index 821bbc9968..0f3ab50f4a 100644 --- a/crates/language/src/tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -1,6 +1,7 @@ use super::*; use clock::ReplicaId; use collections::BTreeMap; +use fs::LineEnding; use gpui::{ModelHandle, MutableAppContext}; use proto::deserialize_operation; use rand::prelude::*; @@ -14,7 +15,7 @@ use std::{ }; use text::network::Network; use unindent::Unindent as _; -use util::post_inc; +use util::{post_inc, test::marked_text_ranges, RandomCharIter}; #[cfg(test)] #[ctor::ctor] @@ -1035,6 +1036,120 @@ fn test_autoindent_language_without_indents_query(cx: &mut MutableAppContext) { }); } +#[gpui::test] +fn test_autoindent_with_injected_languages(cx: &mut MutableAppContext) { + cx.set_global({ + let mut settings = Settings::test(cx); + settings.language_overrides.extend([ + ( + "HTML".into(), + settings::EditorSettings { + tab_size: Some(2.try_into().unwrap()), + ..Default::default() + }, + ), + ( + "JavaScript".into(), + settings::EditorSettings { + tab_size: Some(8.try_into().unwrap()), + ..Default::default() + }, + ), + ]); + settings + }); + + let html_language = Arc::new( + Language::new( + LanguageConfig { + name: "HTML".into(), + ..Default::default() + }, + Some(tree_sitter_html::language()), + ) + .with_indents_query( + " + (element + (start_tag) @start + (end_tag)? @end) @indent + ", + ) + .unwrap() + .with_injection_query( + r#" + (script_element + (raw_text) @content + (#set! "language" "javascript")) + "#, + ) + .unwrap(), + ); + + let javascript_language = Arc::new( + Language::new( + LanguageConfig { + name: "JavaScript".into(), + ..Default::default() + }, + Some(tree_sitter_javascript::language()), + ) + .with_indents_query( + r#" + (object "}" @end) @indent + "#, + ) + .unwrap(), + ); + + let language_registry = Arc::new(LanguageRegistry::test()); + language_registry.add(html_language.clone()); + language_registry.add(javascript_language.clone()); + + cx.add_model(|cx| { + let (text, ranges) = marked_text_ranges( + &" +
ˇ +
+ + ˇ + + " + .unindent(), + false, + ); + + let mut buffer = Buffer::new(0, text, cx); + buffer.set_language_registry(language_registry); + buffer.set_language(Some(html_language), cx); + buffer.edit( + ranges.into_iter().map(|range| (range, "\na")), + Some(AutoindentMode::EachLine), + cx, + ); + assert_eq!( + buffer.text(), + " +
+ a +
+ + + a + + " + .unindent() + ); + buffer + }); +} + #[gpui::test] fn test_serialization(cx: &mut gpui::MutableAppContext) { let mut now = Instant::now(); @@ -1449,7 +1564,7 @@ fn get_tree_sexp(buffer: &ModelHandle, cx: &gpui::TestAppContext) -> Str buffer.read_with(cx, |buffer, _| { let snapshot = buffer.snapshot(); let layers = snapshot.syntax.layers(buffer.as_text_snapshot()); - layers[0].2.to_sexp() + layers[0].node.to_sexp() }) } diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 780f6e75b5..bb75edbc32 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -4,8 +4,9 @@ mod highlight_map; mod outline; pub mod proto; mod syntax_map; + #[cfg(test)] -mod tests; +mod buffer_tests; use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; @@ -26,6 +27,7 @@ use serde_json::Value; use std::{ any::Any, cell::RefCell, + fmt::Debug, mem, ops::Range, path::{Path, PathBuf}, @@ -135,7 +137,7 @@ impl CachedLspAdapter { pub async fn label_for_completion( &self, completion_item: &lsp::CompletionItem, - language: &Language, + language: &Arc, ) -> Option { self.adapter .label_for_completion(completion_item, language) @@ -146,7 +148,7 @@ impl CachedLspAdapter { &self, name: &str, kind: lsp::SymbolKind, - language: &Language, + language: &Arc, ) -> Option { self.adapter.label_for_symbol(name, kind, language).await } @@ -175,7 +177,7 @@ pub trait LspAdapter: 'static + Send + Sync { async fn label_for_completion( &self, _: &lsp::CompletionItem, - _: &Language, + _: &Arc, ) -> Option { None } @@ -184,7 +186,7 @@ pub trait LspAdapter: 'static + Send + Sync { &self, _: &str, _: lsp::SymbolKind, - _: &Language, + _: &Arc, ) -> Option { None } @@ -230,7 +232,10 @@ pub struct LanguageConfig { pub decrease_indent_pattern: Option, #[serde(default)] pub autoclose_before: String, - pub line_comment: Option, + #[serde(default)] + pub line_comment: Option>, + #[serde(default)] + pub block_comment: Option<(Arc, Arc)>, } impl Default for LanguageConfig { @@ -244,6 +249,7 @@ impl Default for LanguageConfig { decrease_indent_pattern: Default::default(), autoclose_before: Default::default(), line_comment: Default::default(), + block_comment: Default::default(), } } } @@ -270,7 +276,7 @@ pub struct FakeLspAdapter { pub disk_based_diagnostics_sources: Vec, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Default, Deserialize)] pub struct BracketPair { pub start: String, pub end: String, @@ -304,6 +310,7 @@ pub struct Grammar { struct IndentConfig { query: Query, indent_capture_ix: u32, + start_capture_ix: Option, end_capture_ix: Option, } @@ -661,11 +668,13 @@ impl Language { let grammar = self.grammar_mut(); let query = Query::new(grammar.ts_language, source)?; let mut indent_capture_ix = None; + let mut start_capture_ix = None; let mut end_capture_ix = None; get_capture_indices( &query, &mut [ ("indent", &mut indent_capture_ix), + ("start", &mut start_capture_ix), ("end", &mut end_capture_ix), ], ); @@ -673,6 +682,7 @@ impl Language { grammar.indents_config = Some(IndentConfig { query, indent_capture_ix, + start_capture_ix, end_capture_ix, }); } @@ -763,8 +773,15 @@ impl Language { self.config.name.clone() } - pub fn line_comment_prefix(&self) -> Option<&str> { - self.config.line_comment.as_deref() + pub fn line_comment_prefix(&self) -> Option<&Arc> { + self.config.line_comment.as_ref() + } + + pub fn block_comment_delimiters(&self) -> Option<(&Arc, &Arc)> { + self.config + .block_comment + .as_ref() + .map(|(start, end)| (start, end)) } pub async fn disk_based_diagnostic_sources(&self) -> &[String] { @@ -789,7 +806,7 @@ impl Language { } pub async fn label_for_completion( - &self, + self: &Arc, completion: &lsp::CompletionItem, ) -> Option { self.adapter @@ -798,7 +815,11 @@ impl Language { .await } - pub async fn label_for_symbol(&self, name: &str, kind: lsp::SymbolKind) -> Option { + pub async fn label_for_symbol( + self: &Arc, + name: &str, + kind: lsp::SymbolKind, + ) -> Option { self.adapter .as_ref()? .label_for_symbol(name, kind, self) @@ -806,20 +827,17 @@ impl Language { } pub fn highlight_text<'a>( - &'a self, + self: &'a Arc, text: &'a Rope, range: Range, ) -> Vec<(Range, HighlightId)> { let mut result = Vec::new(); if let Some(grammar) = &self.grammar { let tree = grammar.parse_text(text, None); - let captures = SyntaxSnapshot::single_tree_captures( - range.clone(), - text, - &tree, - grammar, - |grammar| grammar.highlights_query.as_ref(), - ); + let captures = + SyntaxSnapshot::single_tree_captures(range.clone(), text, &tree, self, |grammar| { + grammar.highlights_query.as_ref() + }); let highlight_maps = vec![grammar.highlight_map()]; let mut offset = 0; for chunk in BufferChunks::new(text, range, Some((captures, highlight_maps)), vec![]) { @@ -861,6 +879,14 @@ impl Language { } } +impl Debug for Language { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Language") + .field("name", &self.config.name) + .finish() + } +} + impl Grammar { pub fn id(&self) -> usize { self.id diff --git a/crates/language/src/proto.rs b/crates/language/src/proto.rs index fddfb7961f..9e3ee7d46b 100644 --- a/crates/language/src/proto.rs +++ b/crates/language/src/proto.rs @@ -8,19 +8,19 @@ use rpc::proto; use std::{ops::Range, sync::Arc}; use text::*; -pub use proto::{BufferState, LineEnding, Operation, SelectionSet}; +pub use proto::{BufferState, Operation, SelectionSet}; -pub fn deserialize_line_ending(message: proto::LineEnding) -> text::LineEnding { +pub fn deserialize_line_ending(message: proto::LineEnding) -> fs::LineEnding { match message { - LineEnding::Unix => text::LineEnding::Unix, - LineEnding::Windows => text::LineEnding::Windows, + proto::LineEnding::Unix => fs::LineEnding::Unix, + proto::LineEnding::Windows => fs::LineEnding::Windows, } } -pub fn serialize_line_ending(message: text::LineEnding) -> proto::LineEnding { +pub fn serialize_line_ending(message: fs::LineEnding) -> proto::LineEnding { match message { - text::LineEnding::Unix => proto::LineEnding::Unix, - text::LineEnding::Windows => proto::LineEnding::Windows, + fs::LineEnding::Unix => proto::LineEnding::Unix, + fs::LineEnding::Windows => proto::LineEnding::Windows, } } diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index a8cac76ac7..5dd9c483af 100644 --- a/crates/language/src/syntax_map.rs +++ b/crates/language/src/syntax_map.rs @@ -10,7 +10,7 @@ use std::{ sync::Arc, }; use sum_tree::{Bias, SeekTarget, SumTree}; -use text::{rope, Anchor, BufferSnapshot, OffsetRangeExt, Point, Rope, ToOffset, ToPoint}; +use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point, Rope, ToOffset, ToPoint}; use tree_sitter::{ Node, Parser, Query, QueryCapture, QueryCaptures, QueryCursor, QueryMatches, Tree, }; @@ -92,6 +92,13 @@ struct SyntaxLayer { language: Arc, } +#[derive(Debug)] +pub struct SyntaxLayerInfo<'a> { + pub depth: usize, + pub node: Node<'a>, + pub language: &'a Arc, +} + #[derive(Debug, Clone)] struct SyntaxLayerSummary { min_depth: usize, @@ -127,7 +134,7 @@ struct ChangeRegionSet(Vec); struct TextProvider<'a>(&'a Rope); -struct ByteChunks<'a>(rope::Chunks<'a>); +struct ByteChunks<'a>(text::Chunks<'a>); struct QueryCursorHandle(Option); @@ -473,13 +480,18 @@ impl SyntaxSnapshot { range: Range, text: &'a Rope, tree: &'a Tree, - grammar: &'a Grammar, + language: &'a Arc, query: fn(&Grammar) -> Option<&Query>, ) -> SyntaxMapCaptures<'a> { SyntaxMapCaptures::new( range.clone(), text, - [(grammar, 0, tree.root_node())].into_iter(), + [SyntaxLayerInfo { + language, + depth: 0, + node: tree.root_node(), + }] + .into_iter(), query, ) } @@ -513,19 +525,19 @@ impl SyntaxSnapshot { } #[cfg(test)] - pub fn layers(&self, buffer: &BufferSnapshot) -> Vec<(&Grammar, usize, Node)> { - self.layers_for_range(0..buffer.len(), buffer) + pub fn layers<'a>(&'a self, buffer: &'a BufferSnapshot) -> Vec { + self.layers_for_range(0..buffer.len(), buffer).collect() } pub fn layers_for_range<'a, T: ToOffset>( - &self, + &'a self, range: Range, - buffer: &BufferSnapshot, - ) -> Vec<(&Grammar, usize, Node)> { + buffer: &'a BufferSnapshot, + ) -> impl 'a + Iterator { let start = buffer.anchor_before(range.start.to_offset(buffer)); let end = buffer.anchor_after(range.end.to_offset(buffer)); - let mut cursor = self.layers.filter::<_, ()>(|summary| { + let mut cursor = self.layers.filter::<_, ()>(move |summary| { if summary.max_depth > summary.min_depth { true } else { @@ -535,23 +547,26 @@ impl SyntaxSnapshot { } }); - let mut result = Vec::new(); + // let mut result = Vec::new(); cursor.next(buffer); - while let Some(layer) = cursor.item() { - if let Some(grammar) = &layer.language.grammar { - result.push(( - grammar.as_ref(), - layer.depth, - layer.tree.root_node_with_offset( + std::iter::from_fn(move || { + if let Some(layer) = cursor.item() { + let info = SyntaxLayerInfo { + language: &layer.language, + depth: layer.depth, + node: layer.tree.root_node_with_offset( layer.range.start.to_offset(buffer), layer.range.start.to_point(buffer).to_ts_point(), ), - )); + }; + cursor.next(buffer); + Some(info) + } else { + None } - cursor.next(buffer) - } + }) - result + // result } } @@ -559,7 +574,7 @@ impl<'a> SyntaxMapCaptures<'a> { fn new( range: Range, text: &'a Rope, - layers: impl Iterator)>, + layers: impl Iterator>, query: fn(&Grammar) -> Option<&Query>, ) -> Self { let mut result = Self { @@ -567,11 +582,19 @@ impl<'a> SyntaxMapCaptures<'a> { grammars: Vec::new(), active_layer_count: 0, }; - for (grammar, depth, node) in layers { - let query = if let Some(query) = query(grammar) { - query - } else { - continue; + for SyntaxLayerInfo { + language, + depth, + node, + } in layers + { + let grammar = match &language.grammar { + Some(grammer) => grammer, + None => continue, + }; + let query = match query(&grammar) { + Some(query) => query, + None => continue, }; let mut query_cursor = QueryCursorHandle::new(); @@ -678,15 +701,23 @@ impl<'a> SyntaxMapMatches<'a> { fn new( range: Range, text: &'a Rope, - layers: impl Iterator)>, + layers: impl Iterator>, query: fn(&Grammar) -> Option<&Query>, ) -> Self { let mut result = Self::default(); - for (grammar, depth, node) in layers { - let query = if let Some(query) = query(grammar) { - query - } else { - continue; + for SyntaxLayerInfo { + language, + depth, + node, + } in layers + { + let grammar = match &language.grammar { + Some(grammer) => grammer, + None => continue, + }; + let query = match query(&grammar) { + Some(query) => query, + None => continue, }; let mut query_cursor = QueryCursorHandle::new(); @@ -1211,7 +1242,7 @@ mod tests { use crate::LanguageConfig; use rand::rngs::StdRng; use std::env; - use text::{Buffer, Point}; + use text::Buffer; use unindent::Unindent as _; use util::test::marked_text_ranges; @@ -1624,8 +1655,8 @@ mod tests { let reference_layers = reference_syntax_map.layers(&buffer); for (edited_layer, reference_layer) in layers.into_iter().zip(reference_layers.into_iter()) { - assert_eq!(edited_layer.2.to_sexp(), reference_layer.2.to_sexp()); - assert_eq!(edited_layer.2.range(), reference_layer.2.range()); + assert_eq!(edited_layer.node.to_sexp(), reference_layer.node.to_sexp()); + assert_eq!(edited_layer.node.range(), reference_layer.node.range()); } } @@ -1770,13 +1801,13 @@ mod tests { mutated_layers.into_iter().zip(reference_layers.into_iter()) { assert_eq!( - edited_layer.2.to_sexp(), - reference_layer.2.to_sexp(), + edited_layer.node.to_sexp(), + reference_layer.node.to_sexp(), "different layer at step {i}" ); assert_eq!( - edited_layer.2.range(), - reference_layer.2.range(), + edited_layer.node.range(), + reference_layer.node.range(), "different layer at step {i}" ); } @@ -1822,13 +1853,15 @@ mod tests { range: Range, expected_layers: &[&str], ) { - let layers = syntax_map.layers_for_range(range, &buffer); + let layers = syntax_map + .layers_for_range(range, &buffer) + .collect::>(); assert_eq!( layers.len(), expected_layers.len(), "wrong number of layers" ); - for (i, ((_, _, node), expected_s_exp)) in + for (i, (SyntaxLayerInfo { node, .. }, expected_s_exp)) in layers.iter().zip(expected_layers.iter()).enumerate() { let actual_s_exp = node.to_sexp(); diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 95ef299c72..6e7def92e9 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -56,7 +56,7 @@ pub struct Subscription { #[derive(Serialize, Deserialize)] struct Request<'a, T> { - jsonrpc: &'a str, + jsonrpc: &'static str, id: usize, method: &'a str, params: T, @@ -73,6 +73,7 @@ struct AnyResponse<'a> { #[derive(Serialize)] struct Response { + jsonrpc: &'static str, id: usize, result: Option, error: Option, @@ -80,8 +81,7 @@ struct Response { #[derive(Serialize, Deserialize)] struct Notification<'a, T> { - #[serde(borrow)] - jsonrpc: &'a str, + jsonrpc: &'static str, #[serde(borrow)] method: &'a str, params: T, @@ -453,11 +453,13 @@ impl LanguageServer { async move { let response = match response.await { Ok(result) => Response { + jsonrpc: JSON_RPC_VERSION, id, result: Some(result), error: None, }, Err(error) => Response { + jsonrpc: JSON_RPC_VERSION, id, result: None, error: Some(Error { diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index f814276306..a677ab5b67 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -48,8 +48,8 @@ impl View for OutlineView { "OutlineView" } - fn render(&mut self, _: &mut RenderContext) -> ElementBox { - ChildView::new(self.picker.clone()).boxed() + fn render(&mut self, cx: &mut RenderContext) -> ElementBox { + ChildView::new(self.picker.clone(), cx).boxed() } fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { @@ -233,7 +233,7 @@ impl PickerDelegate for OutlineView { fn render_match( &self, ix: usize, - mouse_state: MouseState, + mouse_state: &mut MouseState, selected: bool, cx: &AppContext, ) -> ElementBox { diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index c2fac6371e..30ad7827ef 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -19,6 +19,7 @@ pub struct Picker { query_editor: ViewHandle, list_state: UniformListState, max_size: Vector2F, + theme: Box &theme::Picker>, confirmed: bool, } @@ -32,7 +33,7 @@ pub trait PickerDelegate: View { fn render_match( &self, ix: usize, - state: MouseState, + state: &mut MouseState, selected: bool, cx: &AppContext, ) -> ElementBox; @@ -51,8 +52,8 @@ impl View for Picker { } fn render(&mut self, cx: &mut RenderContext) -> gpui::ElementBox { - let settings = cx.global::(); - let container_style = settings.theme.picker.container; + let theme = (self.theme)(cx); + let container_style = theme.container; let delegate = self.delegate.clone(); let match_count = if let Some(delegate) = delegate.upgrade(cx.app) { delegate.read(cx).match_count() @@ -62,19 +63,16 @@ impl View for Picker { Flex::new(Axis::Vertical) .with_child( - ChildView::new(&self.query_editor) + ChildView::new(&self.query_editor, cx) .contained() - .with_style(settings.theme.picker.input_editor.container) + .with_style(theme.input_editor.container) .boxed(), ) .with_child( if match_count == 0 { - Label::new( - "No matches".into(), - settings.theme.picker.empty.label.clone(), - ) - .contained() - .with_style(settings.theme.picker.empty.container) + Label::new("No matches".into(), theme.empty.label.clone()) + .contained() + .with_style(theme.empty.container) } else { UniformList::new( self.list_state.clone(), @@ -147,6 +145,7 @@ impl Picker { list_state: Default::default(), delegate, max_size: vec2f(540., 420.), + theme: Box::new(|cx| &cx.global::().theme.picker), confirmed: false, }; cx.defer(|this, cx| { @@ -163,6 +162,14 @@ impl Picker { self } + pub fn with_theme(mut self, theme: F) -> Self + where + F: 'static + FnMut(&AppContext) -> &theme::Picker, + { + self.theme = Box::new(theme); + self + } + pub fn query(&self, cx: &AppContext) -> String { self.query_editor.read(cx).text(cx) } diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index eebfc08473..4e6dc09e4e 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -10,6 +10,7 @@ doctest = false [features] test-support = [ "client/test-support", + "db/test-support", "language/test-support", "settings/test-support", "text/test-support", @@ -20,8 +21,11 @@ text = { path = "../text" } client = { path = "../client" } clock = { path = "../clock" } collections = { path = "../collections" } +db = { path = "../db" } +fs = { path = "../fs" } fsevent = { path = "../fsevent" } fuzzy = { path = "../fuzzy" } +git = { path = "../git" } gpui = { path = "../gpui" } language = { path = "../language" } lsp = { path = "../lsp" } @@ -35,7 +39,6 @@ async-trait = "0.1" futures = "0.3" ignore = "0.4" lazy_static = "1.4.0" -libc = "0.2" log = { version = "0.4.16", features = ["kv_unstable_serde"] } parking_lot = "0.11.1" postage = { version = "0.4.1", features = ["futures-traits"] } @@ -54,6 +57,8 @@ rocksdb = "0.18" [dev-dependencies] client = { path = "../client", features = ["test-support"] } collections = { path = "../collections", features = ["test-support"] } +db = { path = "../db", features = ["test-support"] } +fs = { path = "../fs", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] } language = { path = "../language", features = ["test-support"] } lsp = { path = "../lsp", features = ["test-support"] } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 09c5a72315..f964726c4c 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1,5 +1,3 @@ -mod db; -pub mod fs; mod ignore; mod lsp_command; pub mod search; @@ -9,10 +7,11 @@ pub mod worktree; mod project_tests; use anyhow::{anyhow, Context, Result}; -use client::{proto, Client, PeerId, TypedEnvelope, User, UserStore}; +use client::{proto, Client, PeerId, TypedEnvelope, UserStore}; use clock::ReplicaId; use collections::{hash_map, BTreeMap, HashMap, HashSet}; use futures::{future::Shared, AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt}; + use gpui::{ AnyModelHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task, UpgradeModelHandle, WeakModelHandle, @@ -25,9 +24,8 @@ use language::{ }, range_from_lsp, range_to_lsp, Anchor, Bias, Buffer, CachedLspAdapter, CharKind, CodeAction, CodeLabel, Completion, Diagnostic, DiagnosticEntry, DiagnosticSet, Event as BufferEvent, - File as _, Language, LanguageRegistry, LanguageServerName, LineEnding, LocalFile, - OffsetRangeExt, Operation, Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, - Transaction, + File as _, Language, LanguageRegistry, LanguageServerName, LocalFile, OffsetRangeExt, + Operation, Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, }; use lsp::{ DiagnosticSeverity, DiagnosticTag, DocumentHighlightKind, LanguageServer, LanguageString, @@ -35,12 +33,11 @@ use lsp::{ }; use lsp_command::*; use parking_lot::Mutex; -use postage::stream::Stream; use postage::watch; use rand::prelude::*; use search::SearchQuery; use serde::Serialize; -use settings::Settings; +use settings::{FormatOnSave, Formatter, Settings}; use sha2::{Digest, Sha256}; use similar::{ChangeTag, TextDiff}; use std::{ @@ -63,7 +60,7 @@ use std::{ time::Instant, }; use thiserror::Error; -use util::{post_inc, ResultExt, TryFutureExt as _}; +use util::{defer, post_inc, ResultExt, TryFutureExt as _}; pub use db::Db; pub use fs::*; @@ -74,7 +71,6 @@ pub trait Item: Entity { } pub struct ProjectStore { - db: Arc, projects: Vec>, } @@ -108,7 +104,7 @@ pub struct Project { user_store: ModelHandle, project_store: ModelHandle, fs: Arc, - client_state: ProjectClientState, + client_state: Option, collaborators: HashMap, client_subscriptions: Vec, _subscriptions: Vec, @@ -125,8 +121,8 @@ pub struct Project { opened_buffers: HashMap, incomplete_buffers: HashMap>, buffer_snapshots: HashMap>, + buffers_being_formatted: HashSet, nonce: u128, - initialized_persistent_state: bool, _maintain_buffer_languages: Task<()>, } @@ -155,13 +151,8 @@ enum WorktreeHandle { enum ProjectClientState { Local { - is_shared: bool, - remote_id_tx: watch::Sender>, - remote_id_rx: watch::Receiver>, - online_tx: watch::Sender, - online_rx: watch::Receiver, - _maintain_remote_id: Task>, - _maintain_online_status: Task>, + remote_id: u64, + _detect_unshare: Task>, }, Remote { sharing_has_stopped: bool, @@ -173,7 +164,6 @@ enum ProjectClientState { #[derive(Clone, Debug)] pub struct Collaborator { - pub user: Arc, pub peer_id: PeerId, pub replica_id: ReplicaId, } @@ -196,8 +186,6 @@ pub enum Event { RemoteIdChanged(Option), DisconnectedFromHost, CollaboratorLeft(PeerId), - ContactRequestedJoin(Arc), - ContactCancelledJoinRequest(Arc), } pub enum LanguageServerState { @@ -364,19 +352,32 @@ impl ProjectEntryId { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FormatTrigger { + Save, + Manual, +} + +impl FormatTrigger { + fn from_proto(value: i32) -> FormatTrigger { + match value { + 0 => FormatTrigger::Save, + 1 => FormatTrigger::Manual, + _ => FormatTrigger::Save, + } + } +} + impl Project { pub fn init(client: &Arc) { - client.add_model_message_handler(Self::handle_request_join_project); client.add_model_message_handler(Self::handle_add_collaborator); client.add_model_message_handler(Self::handle_buffer_reloaded); client.add_model_message_handler(Self::handle_buffer_saved); client.add_model_message_handler(Self::handle_start_language_server); client.add_model_message_handler(Self::handle_update_language_server); client.add_model_message_handler(Self::handle_remove_collaborator); - client.add_model_message_handler(Self::handle_join_project_request_cancelled); client.add_model_message_handler(Self::handle_update_project); - client.add_model_message_handler(Self::handle_unregister_project); - client.add_model_message_handler(Self::handle_project_unshared); + client.add_model_message_handler(Self::handle_unshare_project); client.add_model_message_handler(Self::handle_create_buffer_for_peer); client.add_model_message_handler(Self::handle_update_buffer_file); client.add_model_message_handler(Self::handle_update_buffer); @@ -405,10 +406,10 @@ impl Project { client.add_model_request_handler(Self::handle_open_buffer_by_id); client.add_model_request_handler(Self::handle_open_buffer_by_path); client.add_model_request_handler(Self::handle_save_buffer); + client.add_model_message_handler(Self::handle_update_diff_base); } pub fn local( - online: bool, client: Arc, user_store: ModelHandle, project_store: ModelHandle, @@ -417,43 +418,6 @@ impl Project { cx: &mut MutableAppContext, ) -> ModelHandle { cx.add_model(|cx: &mut ModelContext| { - let (remote_id_tx, remote_id_rx) = watch::channel(); - let _maintain_remote_id = cx.spawn_weak({ - let mut status_rx = client.clone().status(); - move |this, mut cx| async move { - while let Some(status) = status_rx.recv().await { - let this = this.upgrade(&cx)?; - if status.is_connected() { - this.update(&mut cx, |this, cx| this.register(cx)) - .await - .log_err()?; - } else { - this.update(&mut cx, |this, cx| this.unregister(cx)) - .await - .log_err(); - } - } - None - } - }); - - let (online_tx, online_rx) = watch::channel_with(online); - let _maintain_online_status = cx.spawn_weak({ - let mut online_rx = online_rx.clone(); - move |this, mut cx| async move { - while let Some(online) = online_rx.recv().await { - let this = this.upgrade(&cx)?; - this.update(&mut cx, |this, cx| { - if !online { - this.unshared(cx); - } - this.metadata_changed(false, cx) - }); - } - None - } - }); - let handle = cx.weak_handle(); project_store.update(cx, |store, cx| store.add_project(handle, cx)); @@ -466,15 +430,7 @@ impl Project { loading_buffers: Default::default(), loading_local_worktrees: Default::default(), buffer_snapshots: Default::default(), - client_state: ProjectClientState::Local { - is_shared: false, - remote_id_tx, - remote_id_rx, - online_tx, - online_rx, - _maintain_remote_id, - _maintain_online_status, - }, + client_state: None, opened_buffer: watch::channel(), client_subscriptions: Vec::new(), _subscriptions: vec![cx.observe_global::(Self::on_settings_changed)], @@ -492,9 +448,9 @@ impl Project { language_server_statuses: Default::default(), last_workspace_edits_by_language_server: Default::default(), language_server_settings: Default::default(), + buffers_being_formatted: Default::default(), next_language_server_id: 0, nonce: StdRng::from_entropy().gen(), - initialized_persistent_state: false, } }) } @@ -516,24 +472,6 @@ impl Project { }) .await?; - let response = match response.variant.ok_or_else(|| anyhow!("missing variant"))? { - proto::join_project_response::Variant::Accept(response) => response, - proto::join_project_response::Variant::Decline(decline) => { - match proto::join_project_response::decline::Reason::from_i32(decline.reason) { - Some(proto::join_project_response::decline::Reason::Declined) => { - Err(JoinProjectError::HostDeclined)? - } - Some(proto::join_project_response::decline::Reason::Closed) => { - Err(JoinProjectError::HostClosedProject)? - } - Some(proto::join_project_response::decline::Reason::WentOffline) => { - Err(JoinProjectError::HostWentOffline)? - } - None => Err(anyhow!("missing decline reason"))?, - } - } - }; - let replica_id = response.replica_id as ReplicaId; let mut worktrees = Vec::new(); @@ -566,7 +504,7 @@ impl Project { client_subscriptions: vec![client.add_model_for_remote_entity(remote_id, cx)], _subscriptions: Default::default(), client: client.clone(), - client_state: ProjectClientState::Remote { + client_state: Some(ProjectClientState::Remote { sharing_has_stopped: false, remote_id, replica_id, @@ -585,7 +523,7 @@ impl Project { } .log_err() }), - }, + }), language_servers: Default::default(), language_server_ids: Default::default(), language_server_settings: Default::default(), @@ -607,9 +545,9 @@ impl Project { last_workspace_edits_by_language_server: Default::default(), next_language_server_id: 0, opened_buffers: Default::default(), + buffers_being_formatted: Default::default(), buffer_snapshots: Default::default(), nonce: StdRng::from_entropy().gen(), - initialized_persistent_state: false, }; for worktree in worktrees { this.add_worktree(&worktree, cx); @@ -627,7 +565,7 @@ impl Project { .await?; let mut collaborators = HashMap::default(); for message in response.collaborators { - let collaborator = Collaborator::from_proto(message, &user_store, &mut cx).await?; + let collaborator = Collaborator::from_proto(message); collaborators.insert(collaborator.peer_id, collaborator); } @@ -650,12 +588,11 @@ impl Project { let languages = Arc::new(LanguageRegistry::test()); let http_client = client::test::FakeHttpClient::with_404_response(); - let client = client::Client::new(http_client.clone()); + let client = cx.update(|cx| client::Client::new(http_client.clone(), cx)); let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx)); - let project_store = cx.add_model(|_| ProjectStore::new(Db::open_fake())); - let project = cx.update(|cx| { - Project::local(true, client, user_store, project_store, languages, fs, cx) - }); + let project_store = cx.add_model(|_| ProjectStore::new()); + let project = + cx.update(|cx| Project::local(client, user_store, project_store, languages, fs, cx)); for path in root_paths { let (tree, _) = project .update(cx, |project, cx| { @@ -669,53 +606,6 @@ impl Project { project } - pub fn restore_state(&mut self, cx: &mut ModelContext) -> Task> { - if self.is_remote() { - return Task::ready(Ok(())); - } - - let db = self.project_store.read(cx).db.clone(); - let keys = self.db_keys_for_online_state(cx); - let online_by_default = cx.global::().projects_online_by_default; - let read_online = cx.background().spawn(async move { - let values = db.read(keys)?; - anyhow::Ok( - values - .into_iter() - .all(|e| e.map_or(online_by_default, |e| e == [true as u8])), - ) - }); - cx.spawn(|this, mut cx| async move { - let online = read_online.await.log_err().unwrap_or(false); - this.update(&mut cx, |this, cx| { - this.initialized_persistent_state = true; - if let ProjectClientState::Local { online_tx, .. } = &mut this.client_state { - let mut online_tx = online_tx.borrow_mut(); - if *online_tx != online { - *online_tx = online; - drop(online_tx); - this.metadata_changed(false, cx); - } - } - }); - Ok(()) - }) - } - - fn persist_state(&mut self, cx: &mut ModelContext) -> Task> { - if self.is_remote() || !self.initialized_persistent_state { - return Task::ready(Ok(())); - } - - let db = self.project_store.read(cx).db.clone(); - let keys = self.db_keys_for_online_state(cx); - let is_online = self.is_online(); - cx.background().spawn(async move { - let value = &[is_online as u8]; - db.write(keys.into_iter().map(|key| (key, value))) - }) - } - fn on_settings_changed(&mut self, cx: &mut ModelContext) { let settings = cx.global::(); @@ -844,208 +734,67 @@ impl Project { &self.fs } - pub fn set_online(&mut self, online: bool, _: &mut ModelContext) { - if let ProjectClientState::Local { online_tx, .. } = &mut self.client_state { - let mut online_tx = online_tx.borrow_mut(); - if *online_tx != online { - *online_tx = online; - } - } - } - - pub fn is_online(&self) -> bool { - match &self.client_state { - ProjectClientState::Local { online_rx, .. } => *online_rx.borrow(), - ProjectClientState::Remote { .. } => true, - } - } - - fn unregister(&mut self, cx: &mut ModelContext) -> Task> { - self.unshared(cx); - if let ProjectClientState::Local { remote_id_rx, .. } = &mut self.client_state { - if let Some(remote_id) = *remote_id_rx.borrow() { - let request = self.client.request(proto::UnregisterProject { - project_id: remote_id, - }); - return cx.spawn(|this, mut cx| async move { - let response = request.await; - - // Unregistering the project causes the server to send out a - // contact update removing this project from the host's list - // of online projects. Wait until this contact update has been - // processed before clearing out this project's remote id, so - // that there is no moment where this project appears in the - // contact metadata and *also* has no remote id. - this.update(&mut cx, |this, cx| { - this.user_store() - .update(cx, |store, _| store.contact_updates_done()) - }) - .await; - - this.update(&mut cx, |this, cx| { - if let ProjectClientState::Local { remote_id_tx, .. } = - &mut this.client_state - { - *remote_id_tx.borrow_mut() = None; - } - this.client_subscriptions.clear(); - this.metadata_changed(false, cx); - }); - response.map(drop) - }); - } - } - Task::ready(Ok(())) - } - - fn register(&mut self, cx: &mut ModelContext) -> Task> { - if let ProjectClientState::Local { - remote_id_rx, - online_rx, - .. - } = &self.client_state - { - if remote_id_rx.borrow().is_some() { - return Task::ready(Ok(())); - } - - let response = self.client.request(proto::RegisterProject { - online: *online_rx.borrow(), - }); - cx.spawn(|this, mut cx| async move { - let remote_id = response.await?.project_id; - this.update(&mut cx, |this, cx| { - if let ProjectClientState::Local { remote_id_tx, .. } = &mut this.client_state { - *remote_id_tx.borrow_mut() = Some(remote_id); - } - - this.metadata_changed(false, cx); - cx.emit(Event::RemoteIdChanged(Some(remote_id))); - this.client_subscriptions - .push(this.client.add_model_for_remote_entity(remote_id, cx)); - Ok(()) - }) - }) - } else { - Task::ready(Err(anyhow!("can't register a remote project"))) - } - } - pub fn remote_id(&self) -> Option { - match &self.client_state { - ProjectClientState::Local { remote_id_rx, .. } => *remote_id_rx.borrow(), - ProjectClientState::Remote { remote_id, .. } => Some(*remote_id), - } - } - - pub fn next_remote_id(&self) -> impl Future { - let mut id = None; - let mut watch = None; - match &self.client_state { - ProjectClientState::Local { remote_id_rx, .. } => watch = Some(remote_id_rx.clone()), - ProjectClientState::Remote { remote_id, .. } => id = Some(*remote_id), - } - - async move { - if let Some(id) = id { - return id; - } - let mut watch = watch.unwrap(); - loop { - let id = *watch.borrow(); - if let Some(id) = id { - return id; - } - watch.next().await; - } - } - } - - pub fn shared_remote_id(&self) -> Option { - match &self.client_state { - ProjectClientState::Local { - remote_id_rx, - is_shared, - .. - } => { - if *is_shared { - *remote_id_rx.borrow() - } else { - None - } - } - ProjectClientState::Remote { remote_id, .. } => Some(*remote_id), + match self.client_state.as_ref()? { + ProjectClientState::Local { remote_id, .. } + | ProjectClientState::Remote { remote_id, .. } => Some(*remote_id), } } pub fn replica_id(&self) -> ReplicaId { match &self.client_state { - ProjectClientState::Local { .. } => 0, - ProjectClientState::Remote { replica_id, .. } => *replica_id, + Some(ProjectClientState::Remote { replica_id, .. }) => *replica_id, + _ => 0, } } - fn metadata_changed(&mut self, persist: bool, cx: &mut ModelContext) { - if let ProjectClientState::Local { - remote_id_rx, - online_rx, - .. - } = &self.client_state - { + fn metadata_changed(&mut self, cx: &mut ModelContext) { + if let Some(ProjectClientState::Local { remote_id, .. }) = &self.client_state { + let project_id = *remote_id; // Broadcast worktrees only if the project is online. - let worktrees = if *online_rx.borrow() { - self.worktrees + let worktrees = self + .worktrees + .iter() + .filter_map(|worktree| { + worktree + .upgrade(cx) + .map(|worktree| worktree.read(cx).as_local().unwrap().metadata_proto()) + }) + .collect(); + self.client + .send(proto::UpdateProject { + project_id, + worktrees, + }) + .log_err(); + + let worktrees = self.visible_worktrees(cx).collect::>(); + let scans_complete = futures::future::join_all( + worktrees .iter() - .filter_map(|worktree| { - worktree + .filter_map(|worktree| Some(worktree.read(cx).as_local()?.scan_complete())), + ); + + let worktrees = worktrees.into_iter().map(|handle| handle.downgrade()); + + cx.spawn_weak(move |_, cx| async move { + scans_complete.await; + cx.read(|cx| { + for worktree in worktrees { + if let Some(worktree) = worktree .upgrade(cx) - .map(|worktree| worktree.read(cx).as_local().unwrap().metadata_proto()) - }) - .collect() - } else { - Default::default() - }; - if let Some(project_id) = *remote_id_rx.borrow() { - let online = *online_rx.borrow(); - self.client - .send(proto::UpdateProject { - project_id, - worktrees, - online, - }) - .log_err(); - - if online { - let worktrees = self.visible_worktrees(cx).collect::>(); - let scans_complete = - futures::future::join_all(worktrees.iter().filter_map(|worktree| { - Some(worktree.read(cx).as_local()?.scan_complete()) - })); - - let worktrees = worktrees.into_iter().map(|handle| handle.downgrade()); - cx.spawn_weak(move |_, cx| async move { - scans_complete.await; - cx.read(|cx| { - for worktree in worktrees { - if let Some(worktree) = worktree - .upgrade(cx) - .and_then(|worktree| worktree.read(cx).as_local()) - { - worktree.send_extension_counts(project_id); - } - } - }) - }) - .detach(); - } - } - - self.project_store.update(cx, |_, cx| cx.notify()); - if persist { - self.persist_state(cx).detach_and_log_err(cx); - } - cx.notify(); + .and_then(|worktree| worktree.read(cx).as_local()) + { + worktree.send_extension_counts(project_id); + } + } + }) + }) + .detach(); } + + self.project_store.update(cx, |_, cx| cx.notify()); + cx.notify(); } pub fn collaborators(&self) -> &HashMap { @@ -1081,23 +830,6 @@ impl Project { .map(|tree| tree.read(cx).root_name()) } - fn db_keys_for_online_state(&self, cx: &AppContext) -> Vec { - self.worktrees - .iter() - .filter_map(|worktree| { - let worktree = worktree.upgrade(cx)?.read(cx); - if worktree.is_visible() { - Some(format!( - "project-path-online:{}", - worktree.as_local().unwrap().abs_path().to_string_lossy() - )) - } else { - None - } - }) - .collect::>() - } - pub fn worktree_for_id( &self, id: WorktreeId, @@ -1301,30 +1033,12 @@ impl Project { } } - fn share(&mut self, cx: &mut ModelContext) -> Task> { - if !self.is_online() { - return Task::ready(Err(anyhow!("can't share an offline project"))); + pub fn shared(&mut self, project_id: u64, cx: &mut ModelContext) -> Task> { + if self.client_state.is_some() { + return Task::ready(Err(anyhow!("project was already shared"))); } - let project_id; - if let ProjectClientState::Local { - remote_id_rx, - is_shared, - .. - } = &mut self.client_state - { - if *is_shared { - return Task::ready(Ok(())); - } - *is_shared = true; - if let Some(id) = *remote_id_rx.borrow() { - project_id = id; - } else { - return Task::ready(Err(anyhow!("project hasn't been registered"))); - } - } else { - return Task::ready(Err(anyhow!("can't share a remote project"))); - }; + let mut worktree_share_tasks = Vec::new(); for open_buffer in self.opened_buffers.values_mut() { match open_buffer { @@ -1349,14 +1063,6 @@ impl Project { } } - let mut tasks = Vec::new(); - for worktree in self.worktrees(cx).collect::>() { - worktree.update(cx, |worktree, cx| { - let worktree = worktree.as_local_mut().unwrap(); - tasks.push(worktree.share(project_id, cx)); - }); - } - for (server_id, status) in &self.language_server_statuses { self.client .send(proto::StartLanguageServer { @@ -1369,24 +1075,53 @@ impl Project { .log_err(); } - cx.spawn(|this, mut cx| async move { - for task in tasks { - task.await?; - } - this.update(&mut cx, |_, cx| cx.notify()); + for worktree in self.worktrees(cx).collect::>() { + worktree.update(cx, |worktree, cx| { + let worktree = worktree.as_local_mut().unwrap(); + worktree_share_tasks.push(worktree.share(project_id, cx)); + }); + } + + self.client_subscriptions + .push(self.client.add_model_for_remote_entity(project_id, cx)); + self.metadata_changed(cx); + cx.emit(Event::RemoteIdChanged(Some(project_id))); + cx.notify(); + + let mut status = self.client.status(); + self.client_state = Some(ProjectClientState::Local { + remote_id: project_id, + _detect_unshare: cx.spawn_weak(move |this, mut cx| { + async move { + let is_connected = status.next().await.map_or(false, |s| s.is_connected()); + // Even if we're initially connected, any future change of the status means we momentarily disconnected. + if !is_connected || status.next().await.is_some() { + if let Some(this) = this.upgrade(&cx) { + let _ = this.update(&mut cx, |this, cx| this.unshare(cx)); + } + } + Ok(()) + } + .log_err() + }), + }); + + cx.foreground().spawn(async move { + futures::future::try_join_all(worktree_share_tasks).await?; Ok(()) }) } - fn unshared(&mut self, cx: &mut ModelContext) { - if let ProjectClientState::Local { is_shared, .. } = &mut self.client_state { - if !*is_shared { - return; - } + pub fn unshare(&mut self, cx: &mut ModelContext) -> Result<()> { + if self.is_remote() { + return Err(anyhow!("attempted to unshare a remote project")); + } - *is_shared = false; + if let Some(ProjectClientState::Local { remote_id, .. }) = self.client_state.take() { self.collaborators.clear(); self.shared_buffers.clear(); + self.client_subscriptions.clear(); + for worktree_handle in self.worktrees.iter_mut() { if let WorktreeHandle::Strong(worktree) = worktree_handle { let is_visible = worktree.update(cx, |worktree, _| { @@ -1405,46 +1140,23 @@ impl Project { } } + self.metadata_changed(cx); cx.notify(); - } else { - log::error!("attempted to unshare a remote project"); - } - } + self.client.send(proto::UnshareProject { + project_id: remote_id, + })?; - pub fn respond_to_join_request( - &mut self, - requester_id: u64, - allow: bool, - cx: &mut ModelContext, - ) { - if let Some(project_id) = self.remote_id() { - let share = if self.is_online() && allow { - Some(self.share(cx)) - } else { - None - }; - let client = self.client.clone(); - cx.foreground() - .spawn(async move { - client.send(proto::RespondToJoinProjectRequest { - requester_id, - project_id, - allow, - })?; - if let Some(share) = share { - share.await?; - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); + Ok(()) + } else { + Err(anyhow!("attempted to unshare an unshared project")) } } fn disconnected_from_host(&mut self, cx: &mut ModelContext) { - if let ProjectClientState::Remote { + if let Some(ProjectClientState::Remote { sharing_has_stopped, .. - } = &mut self.client_state + }) = &mut self.client_state { *sharing_has_stopped = true; self.collaborators.clear(); @@ -1468,18 +1180,18 @@ impl Project { pub fn is_read_only(&self) -> bool { match &self.client_state { - ProjectClientState::Local { .. } => false, - ProjectClientState::Remote { + Some(ProjectClientState::Remote { sharing_has_stopped, .. - } => *sharing_has_stopped, + }) => *sharing_has_stopped, + _ => false, } } pub fn is_local(&self) -> bool { match &self.client_state { - ProjectClientState::Local { .. } => true, - ProjectClientState::Remote { .. } => false, + Some(ProjectClientState::Remote { .. }) => false, + _ => true, } } @@ -1910,7 +1622,7 @@ impl Project { ) -> Option<()> { match event { BufferEvent::Operation(operation) => { - if let Some(project_id) = self.shared_remote_id() { + if let Some(project_id) = self.remote_id() { let request = self.client.request(proto::UpdateBuffer { project_id, buffer_id: buffer.read(cx).remote_id(), @@ -2315,7 +2027,7 @@ impl Project { ) .ok(); - if let Some(project_id) = this.shared_remote_id() { + if let Some(project_id) = this.remote_id() { this.client .send(proto::StartLanguageServer { project_id, @@ -2722,7 +2434,7 @@ impl Project { language_server_id: usize, event: proto::update_language_server::Variant, ) { - if let Some(project_id) = self.shared_remote_id() { + if let Some(project_id) = self.remote_id() { self.client .send(proto::UpdateLanguageServer { project_id, @@ -3047,6 +2759,7 @@ impl Project { &self, buffers: HashSet>, push_to_history: bool, + trigger: FormatTrigger, cx: &mut ModelContext, ) -> Task> { let mut local_buffers = Vec::new(); @@ -3076,6 +2789,7 @@ impl Project { let response = client .request(proto::FormatBuffers { project_id, + trigger: trigger as i32, buffer_ids: remote_buffers .iter() .map(|buffer| buffer.read_with(&cx, |buffer, _| buffer.remote_id())) @@ -3091,19 +2805,41 @@ impl Project { .await?; } - for (buffer, buffer_abs_path, language_server) in local_buffers { - let (format_on_save, tab_size) = buffer.read_with(&cx, |buffer, cx| { + // Do not allow multiple concurrent formatting requests for the + // same buffer. + this.update(&mut cx, |this, _| { + local_buffers + .retain(|(buffer, _, _)| this.buffers_being_formatted.insert(buffer.id())); + }); + let _cleanup = defer({ + let this = this.clone(); + let mut cx = cx.clone(); + let local_buffers = &local_buffers; + move || { + this.update(&mut cx, |this, _| { + for (buffer, _, _) in local_buffers { + this.buffers_being_formatted.remove(&buffer.id()); + } + }); + } + }); + + for (buffer, buffer_abs_path, language_server) in &local_buffers { + let (format_on_save, formatter, tab_size) = buffer.read_with(&cx, |buffer, cx| { let settings = cx.global::(); let language_name = buffer.language().map(|language| language.name()); ( settings.format_on_save(language_name.as_deref()), + settings.formatter(language_name.as_deref()), settings.tab_size(language_name.as_deref()), ) }); - let transaction = match format_on_save { - settings::FormatOnSave::Off => continue, - settings::FormatOnSave::LanguageServer => Self::format_via_lsp( + let transaction = match (formatter, format_on_save) { + (_, FormatOnSave::Off) if trigger == FormatTrigger::Save => continue, + + (Formatter::LanguageServer, FormatOnSave::On | FormatOnSave::Off) + | (_, FormatOnSave::LanguageServer) => Self::format_via_lsp( &this, &buffer, &buffer_abs_path, @@ -3113,7 +2849,12 @@ impl Project { ) .await .context("failed to format via language server")?, - settings::FormatOnSave::External { command, arguments } => { + + ( + Formatter::External { command, arguments }, + FormatOnSave::On | FormatOnSave::Off, + ) + | (_, FormatOnSave::External { command, arguments }) => { Self::format_via_external_command( &buffer, &buffer_abs_path, @@ -3135,7 +2876,7 @@ impl Project { buffer.forget_transaction(transaction.id) }); } - project_transaction.0.insert(buffer, transaction); + project_transaction.0.insert(buffer.clone(), transaction); } } @@ -4423,8 +4164,8 @@ impl Project { pub fn is_shared(&self) -> bool { match &self.client_state { - ProjectClientState::Local { is_shared, .. } => *is_shared, - ProjectClientState::Remote { .. } => false, + Some(ProjectClientState::Local { .. }) => true, + _ => false, } } @@ -4460,7 +4201,7 @@ impl Project { let project_id = project.update(&mut cx, |project, cx| { project.add_worktree(&worktree, cx); - project.shared_remote_id() + project.remote_id() }); if let Some(project_id) = project_id { @@ -4501,15 +4242,18 @@ impl Project { false } }); - self.metadata_changed(true, cx); + self.metadata_changed(cx); cx.notify(); } fn add_worktree(&mut self, worktree: &ModelHandle, cx: &mut ModelContext) { cx.observe(worktree, |_, _, cx| cx.notify()).detach(); if worktree.read(cx).is_local() { - cx.subscribe(worktree, |this, worktree, _, cx| { - this.update_local_worktree_buffers(worktree, cx); + cx.subscribe(worktree, |this, worktree, event, cx| match event { + worktree::Event::UpdatedEntries => this.update_local_worktree_buffers(worktree, cx), + worktree::Event::UpdatedGitRepositories(updated_repos) => { + this.update_local_worktree_buffers_git_repos(worktree, updated_repos, cx) + } }) .detach(); } @@ -4526,7 +4270,7 @@ impl Project { .push(WorktreeHandle::Weak(worktree.downgrade())); } - self.metadata_changed(true, cx); + self.metadata_changed(cx); cx.observe_release(worktree, |this, worktree, cx| { this.remove_worktree(worktree.id(), cx); cx.notify(); @@ -4553,34 +4297,35 @@ impl Project { return; } - let new_file = if let Some(entry) = old_file - .entry_id - .and_then(|entry_id| snapshot.entry_for_id(entry_id)) + let new_file = if let Some(entry) = snapshot.entry_for_id(old_file.entry_id) { File { is_local: true, - entry_id: Some(entry.id), + entry_id: entry.id, mtime: entry.mtime, path: entry.path.clone(), worktree: worktree_handle.clone(), + is_deleted: false, } } else if let Some(entry) = snapshot.entry_for_path(old_file.path().as_ref()) { File { is_local: true, - entry_id: Some(entry.id), + entry_id: entry.id, mtime: entry.mtime, path: entry.path.clone(), worktree: worktree_handle.clone(), + is_deleted: false, } } else { File { is_local: true, - entry_id: None, + entry_id: old_file.entry_id, path: old_file.path().clone(), mtime: old_file.mtime(), worktree: worktree_handle.clone(), + is_deleted: true, } }; @@ -4589,7 +4334,7 @@ impl Project { renamed_buffers.push((cx.handle(), old_path)); } - if let Some(project_id) = self.shared_remote_id() { + if let Some(project_id) = self.remote_id() { self.client .send(proto::UpdateBufferFile { project_id, @@ -4617,6 +4362,63 @@ impl Project { } } + fn update_local_worktree_buffers_git_repos( + &mut self, + worktree: ModelHandle, + repos: &[GitRepositoryEntry], + cx: &mut ModelContext, + ) { + for (_, buffer) in &self.opened_buffers { + if let Some(buffer) = buffer.upgrade(cx) { + let file = match File::from_dyn(buffer.read(cx).file()) { + Some(file) => file, + None => continue, + }; + if file.worktree != worktree { + continue; + } + + let path = file.path().clone(); + + let repo = match repos.iter().find(|repo| repo.manages(&path)) { + Some(repo) => repo.clone(), + None => return, + }; + + let relative_repo = match path.strip_prefix(repo.content_path) { + Ok(relative_repo) => relative_repo.to_owned(), + Err(_) => return, + }; + + let remote_id = self.remote_id(); + let client = self.client.clone(); + + cx.spawn(|_, mut cx| async move { + let diff_base = cx + .background() + .spawn(async move { repo.repo.lock().load_index_text(&relative_repo) }) + .await; + + let buffer_id = buffer.update(&mut cx, |buffer, cx| { + buffer.update_diff_base(diff_base.clone(), cx); + buffer.remote_id() + }); + + if let Some(project_id) = remote_id { + client + .send(proto::UpdateDiffBase { + project_id, + buffer_id: buffer_id as u64, + diff_base, + }) + .log_err(); + } + }) + .detach(); + } + } + } + pub fn set_active_path(&mut self, entry: Option, cx: &mut ModelContext) { let new_active_entry = entry.and_then(|project_path| { let worktree = self.worktree_for_id(project_path.worktree_id, cx)?; @@ -4702,47 +4504,20 @@ impl Project { // RPC message handlers - async fn handle_request_join_project( + async fn handle_unshare_project( this: ModelHandle, - message: TypedEnvelope, + _: TypedEnvelope, _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { - let user_id = message.payload.requester_id; - if this.read_with(&cx, |project, _| { - project.collaborators.values().any(|c| c.user.id == user_id) - }) { - this.update(&mut cx, |this, cx| { - this.respond_to_join_request(user_id, true, cx) - }); - } else { - let user_store = this.read_with(&cx, |this, _| this.user_store.clone()); - let user = user_store - .update(&mut cx, |store, cx| store.fetch_user(user_id, cx)) - .await?; - this.update(&mut cx, |_, cx| cx.emit(Event::ContactRequestedJoin(user))); - } - Ok(()) - } - - async fn handle_unregister_project( - this: ModelHandle, - _: TypedEnvelope, - _: Arc, - mut cx: AsyncAppContext, - ) -> Result<()> { - this.update(&mut cx, |this, cx| this.disconnected_from_host(cx)); - Ok(()) - } - - async fn handle_project_unshared( - this: ModelHandle, - _: TypedEnvelope, - _: Arc, - mut cx: AsyncAppContext, - ) -> Result<()> { - this.update(&mut cx, |this, cx| this.unshared(cx)); - Ok(()) + this.update(&mut cx, |this, cx| { + if this.is_local() { + this.unshare(cx)?; + } else { + this.disconnected_from_host(cx); + } + Ok(()) + }) } async fn handle_add_collaborator( @@ -4751,14 +4526,13 @@ impl Project { _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { - let user_store = this.read_with(&cx, |this, _| this.user_store.clone()); let collaborator = envelope .payload .collaborator .take() .ok_or_else(|| anyhow!("empty collaborator"))?; - let collaborator = Collaborator::from_proto(collaborator, &user_store, &mut cx).await?; + let collaborator = Collaborator::from_proto(collaborator); this.update(&mut cx, |this, cx| { this.collaborators .insert(collaborator.peer_id, collaborator); @@ -4786,6 +4560,7 @@ impl Project { buffer.update(cx, |buffer, cx| buffer.remove_peer(replica_id, cx)); } } + this.shared_buffers.remove(&peer_id); cx.emit(Event::CollaboratorLeft(peer_id)); cx.notify(); @@ -4793,27 +4568,6 @@ impl Project { }) } - async fn handle_join_project_request_cancelled( - this: ModelHandle, - envelope: TypedEnvelope, - _: Arc, - mut cx: AsyncAppContext, - ) -> Result<()> { - let user = this - .update(&mut cx, |this, cx| { - this.user_store.update(cx, |user_store, cx| { - user_store.fetch_user(envelope.payload.requester_id, cx) - }) - }) - .await?; - - this.update(&mut cx, |_, cx| { - cx.emit(Event::ContactCancelledJoinRequest(user)); - }); - - Ok(()) - } - async fn handle_update_project( this: ModelHandle, envelope: TypedEnvelope, @@ -4845,7 +4599,7 @@ impl Project { } } - this.metadata_changed(true, cx); + this.metadata_changed(cx); for (id, _) in old_worktrees_by_id { cx.emit(Event::WorktreeRemoved(id)); } @@ -5189,6 +4943,27 @@ impl Project { }) } + async fn handle_update_diff_base( + this: ModelHandle, + envelope: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, cx| { + let buffer_id = envelope.payload.buffer_id; + let diff_base = envelope.payload.diff_base; + let buffer = this + .opened_buffers + .get_mut(&buffer_id) + .and_then(|b| b.upgrade(cx)) + .ok_or_else(|| anyhow!("No such buffer {}", buffer_id))?; + + buffer.update(cx, |buffer, cx| buffer.update_diff_base(diff_base, cx)); + + Ok(()) + }) + } + async fn handle_update_buffer_file( this: ModelHandle, envelope: TypedEnvelope, @@ -5296,7 +5071,8 @@ impl Project { .ok_or_else(|| anyhow!("unknown buffer id {}", buffer_id))?, ); } - Ok::<_, anyhow::Error>(this.format(buffers, false, cx)) + let trigger = FormatTrigger::from_proto(envelope.payload.trigger); + Ok::<_, anyhow::Error>(this.format(buffers, false, trigger, cx)) })?; let project_transaction = format.await?; @@ -5754,7 +5530,7 @@ impl Project { cx: &mut ModelContext, ) -> Task>> { let mut opened_buffer_rx = self.opened_buffer.1.clone(); - cx.spawn(|this, cx| async move { + cx.spawn(|this, mut cx| async move { let buffer = loop { let buffer = this.read_with(&cx, |this, cx| { this.opened_buffers @@ -5772,6 +5548,7 @@ impl Project { .await .ok_or_else(|| anyhow!("project dropped while waiting for buffer"))?; }; + buffer.update(&mut cx, |buffer, cx| buffer.git_diff_recalc(cx)); Ok(buffer) }) } @@ -6050,9 +5827,8 @@ impl Project { } impl ProjectStore { - pub fn new(db: Arc) -> Self { + pub fn new() -> Self { Self { - db, projects: Default::default(), } } @@ -6181,20 +5957,21 @@ impl Entity for Project { self.project_store.update(cx, ProjectStore::prune_projects); match &self.client_state { - ProjectClientState::Local { remote_id_rx, .. } => { - if let Some(project_id) = *remote_id_rx.borrow() { - self.client - .send(proto::UnregisterProject { project_id }) - .log_err(); - } + Some(ProjectClientState::Local { remote_id, .. }) => { + self.client + .send(proto::UnshareProject { + project_id: *remote_id, + }) + .log_err(); } - ProjectClientState::Remote { remote_id, .. } => { + Some(ProjectClientState::Remote { remote_id, .. }) => { self.client .send(proto::LeaveProject { project_id: *remote_id, }) .log_err(); } + _ => {} } } @@ -6225,21 +6002,10 @@ impl Entity for Project { } impl Collaborator { - fn from_proto( - message: proto::Collaborator, - user_store: &ModelHandle, - cx: &mut AsyncAppContext, - ) -> impl Future> { - let user = user_store.update(cx, |user_store, cx| { - user_store.fetch_user(message.user_id, cx) - }); - - async move { - Ok(Self { - peer_id: PeerId(message.peer_id), - user: user.await?, - replica_id: message.replica_id as ReplicaId, - }) + fn from_proto(message: proto::Collaborator) -> Self { + Self { + peer_id: PeerId(message.peer_id), + replica_id: message.replica_id as ReplicaId, } } } @@ -6253,33 +6019,6 @@ impl> From<(WorktreeId, P)> for ProjectPath { } } -impl From for fs::CreateOptions { - fn from(options: lsp::CreateFileOptions) -> Self { - Self { - overwrite: options.overwrite.unwrap_or(false), - ignore_if_exists: options.ignore_if_exists.unwrap_or(false), - } - } -} - -impl From for fs::RenameOptions { - fn from(options: lsp::RenameFileOptions) -> Self { - Self { - overwrite: options.overwrite.unwrap_or(false), - ignore_if_exists: options.ignore_if_exists.unwrap_or(false), - } - } -} - -impl From for fs::RemoveOptions { - fn from(options: lsp::DeleteFileOptions) -> Self { - Self { - recursive: options.recursive.unwrap_or(false), - ignore_if_not_exists: options.ignore_if_not_exists.unwrap_or(false), - } - } -} - fn serialize_symbol(symbol: &Symbol) -> proto::Symbol { proto::Symbol { language_server_name: symbol.language_server_name.0.to_string(), diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index ceb5d033a7..1b0294c4d1 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -1,10 +1,11 @@ use crate::{worktree::WorktreeHandle, Event, *}; -use fs::RealFs; +use fs::LineEnding; +use fs::{FakeFs, RealFs}; use futures::{future, StreamExt}; use gpui::{executor::Deterministic, test::subscribe}; use language::{ tree_sitter_rust, tree_sitter_typescript, Diagnostic, FakeLspAdapter, LanguageConfig, - LineEnding, OffsetRangeExt, Point, ToPoint, + OffsetRangeExt, Point, ToPoint, }; use lsp::Url; use serde_json::json; @@ -2259,6 +2260,57 @@ async fn test_rescan_and_remote_updates( }); } +#[gpui::test(iterations = 10)] +async fn test_buffer_identity_across_renames( + deterministic: Arc, + cx: &mut gpui::TestAppContext, +) { + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "a": { + "file1": "", + } + }), + ) + .await; + + let project = Project::test(fs, [Path::new("/dir")], cx).await; + let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); + let tree_id = tree.read_with(cx, |tree, _| tree.id()); + + let id_for_path = |path: &'static str, cx: &gpui::TestAppContext| { + project.read_with(cx, |project, cx| { + let tree = project.worktrees(cx).next().unwrap(); + tree.read(cx) + .entry_for_path(path) + .unwrap_or_else(|| panic!("no entry for path {}", path)) + .id + }) + }; + + let dir_id = id_for_path("a", cx); + let file_id = id_for_path("a/file1", cx); + let buffer = project + .update(cx, |p, cx| p.open_buffer((tree_id, "a/file1"), cx)) + .await + .unwrap(); + buffer.read_with(cx, |buffer, _| assert!(!buffer.is_dirty())); + + project + .update(cx, |project, cx| { + project.rename_entry(dir_id, Path::new("b"), cx) + }) + .unwrap() + .await + .unwrap(); + deterministic.run_until_parked(); + assert_eq!(id_for_path("b", cx), dir_id); + assert_eq!(id_for_path("b/file1", cx), file_id); + buffer.read_with(cx, |buffer, _| assert!(!buffer.is_dirty())); +} + #[gpui::test] async fn test_buffer_deduping(cx: &mut gpui::TestAppContext) { let fs = FakeFs::new(cx.background()); @@ -2413,6 +2465,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { .await .unwrap(); cx.foreground().run_until_parked(); + buffer2.read_with(cx, |buffer, _| assert!(buffer.is_dirty())); assert_eq!( *events.borrow(), &[ diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index 96ebb59de0..383c9ac35b 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -1,15 +1,12 @@ +use super::{ignore::IgnoreStack, DiagnosticSummary}; use crate::{copy_recursive, ProjectEntryId, RemoveOptions}; - -use super::{ - fs::{self, Fs}, - ignore::IgnoreStack, - DiagnosticSummary, -}; use ::ignore::gitignore::{Gitignore, GitignoreBuilder}; use anyhow::{anyhow, Context, Result}; use client::{proto, Client}; use clock::ReplicaId; use collections::{HashMap, VecDeque}; +use fs::LineEnding; +use fs::{repository::GitRepository, Fs}; use futures::{ channel::{ mpsc::{self, UnboundedSender}, @@ -18,20 +15,21 @@ use futures::{ Stream, StreamExt, }; use fuzzy::CharBag; +use git::{DOT_GIT, GITIGNORE}; use gpui::{ executor, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task, }; use language::{ proto::{deserialize_version, serialize_line_ending, serialize_version}, - Buffer, DiagnosticEntry, LineEnding, PointUtf16, Rope, + Buffer, DiagnosticEntry, PointUtf16, Rope, }; -use lazy_static::lazy_static; use parking_lot::Mutex; use postage::{ prelude::{Sink as _, Stream as _}, watch, }; + use smol::channel::{self, Sender}; use std::{ any::Any, @@ -40,6 +38,7 @@ use std::{ ffi::{OsStr, OsString}, fmt, future::Future, + mem, ops::{Deref, DerefMut}, os::unix::prelude::{OsStrExt, OsStringExt}, path::{Path, PathBuf}, @@ -50,10 +49,6 @@ use std::{ use sum_tree::{Bias, Edit, SeekTarget, SumTree, TreeMap, TreeSet}; use util::{ResultExt, TryFutureExt}; -lazy_static! { - static ref GITIGNORE: &'static OsStr = OsStr::new(".gitignore"); -} - #[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)] pub struct WorktreeId(usize); @@ -101,15 +96,51 @@ pub struct Snapshot { } #[derive(Clone)] +pub struct GitRepositoryEntry { + pub(crate) repo: Arc>, + + pub(crate) scan_id: usize, + // Path to folder containing the .git file or directory + pub(crate) content_path: Arc, + // Path to the actual .git folder. + // Note: if .git is a file, this points to the folder indicated by the .git file + pub(crate) git_dir_path: Arc, +} + +impl std::fmt::Debug for GitRepositoryEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GitRepositoryEntry") + .field("content_path", &self.content_path) + .field("git_dir_path", &self.git_dir_path) + .field("libgit_repository", &"LibGitRepository") + .finish() + } +} + pub struct LocalSnapshot { abs_path: Arc, ignores_by_parent_abs_path: HashMap, (Arc, usize)>, + git_repositories: Vec, removed_entry_ids: HashMap, next_entry_id: Arc, snapshot: Snapshot, extension_counts: HashMap, } +impl Clone for LocalSnapshot { + fn clone(&self) -> Self { + Self { + abs_path: self.abs_path.clone(), + ignores_by_parent_abs_path: self.ignores_by_parent_abs_path.clone(), + git_repositories: self.git_repositories.iter().cloned().collect(), + removed_entry_ids: self.removed_entry_ids.clone(), + next_entry_id: self.next_entry_id.clone(), + snapshot: self.snapshot.clone(), + extension_counts: self.extension_counts.clone(), + } + } +} + impl Deref for LocalSnapshot { type Target = Snapshot; @@ -142,6 +173,7 @@ struct ShareState { pub enum Event { UpdatedEntries, + UpdatedGitRepositories(Vec), } impl Entity for Worktree { @@ -372,6 +404,7 @@ impl LocalWorktree { let mut snapshot = LocalSnapshot { abs_path, ignores_by_parent_abs_path: Default::default(), + git_repositories: Default::default(), removed_entry_ids: Default::default(), next_entry_id, snapshot: Snapshot { @@ -446,10 +479,14 @@ impl LocalWorktree { ) -> Task>> { let path = Arc::from(path); cx.spawn(move |this, mut cx| async move { - let (file, contents) = this + let (file, contents, diff_base) = this .update(&mut cx, |t, cx| t.as_local().unwrap().load(&path, cx)) .await?; - Ok(cx.add_model(|cx| Buffer::from_file(0, contents, Arc::new(file), cx))) + Ok(cx.add_model(|cx| { + let mut buffer = Buffer::from_file(0, contents, diff_base, Arc::new(file), cx); + buffer.git_diff_recalc(cx); + buffer + })) }) } @@ -499,17 +536,37 @@ impl LocalWorktree { fn poll_snapshot(&mut self, force: bool, cx: &mut ModelContext) { self.poll_task.take(); + match self.scan_state() { ScanState::Idle => { - self.snapshot = self.background_snapshot.lock().clone(); + let new_snapshot = self.background_snapshot.lock().clone(); + let updated_repos = Self::changed_repos( + &self.snapshot.git_repositories, + &new_snapshot.git_repositories, + ); + self.snapshot = new_snapshot; + if let Some(share) = self.share.as_mut() { *share.snapshots_tx.borrow_mut() = self.snapshot.clone(); } + cx.emit(Event::UpdatedEntries); + + if !updated_repos.is_empty() { + cx.emit(Event::UpdatedGitRepositories(updated_repos)); + } } + ScanState::Initializing => { let is_fake_fs = self.fs.is_fake(); - self.snapshot = self.background_snapshot.lock().clone(); + + let new_snapshot = self.background_snapshot.lock().clone(); + let updated_repos = Self::changed_repos( + &self.snapshot.git_repositories, + &new_snapshot.git_repositories, + ); + self.snapshot = new_snapshot; + self.poll_task = Some(cx.spawn_weak(|this, mut cx| async move { if is_fake_fs { #[cfg(any(test, feature = "test-support"))] @@ -521,17 +578,52 @@ impl LocalWorktree { this.update(&mut cx, |this, cx| this.poll_snapshot(cx)); } })); + cx.emit(Event::UpdatedEntries); + + if !updated_repos.is_empty() { + cx.emit(Event::UpdatedGitRepositories(updated_repos)); + } } + _ => { if force { self.snapshot = self.background_snapshot.lock().clone(); } } } + cx.notify(); } + fn changed_repos( + old_repos: &[GitRepositoryEntry], + new_repos: &[GitRepositoryEntry], + ) -> Vec { + fn diff<'a>( + a: &'a [GitRepositoryEntry], + b: &'a [GitRepositoryEntry], + updated: &mut HashMap<&'a Path, GitRepositoryEntry>, + ) { + for a_repo in a { + let matched = b.iter().find(|b_repo| { + a_repo.git_dir_path == b_repo.git_dir_path && a_repo.scan_id == b_repo.scan_id + }); + + if matched.is_none() { + updated.insert(a_repo.git_dir_path.as_ref(), a_repo.clone()); + } + } + } + + let mut updated = HashMap::<&Path, GitRepositoryEntry>::default(); + + diff(old_repos, new_repos, &mut updated); + diff(new_repos, old_repos, &mut updated); + + updated.into_values().collect() + } + pub fn scan_complete(&self) -> impl Future { let mut scan_state_rx = self.last_scan_state_rx.clone(); async move { @@ -558,13 +650,33 @@ impl LocalWorktree { } } - fn load(&self, path: &Path, cx: &mut ModelContext) -> Task> { + fn load( + &self, + path: &Path, + cx: &mut ModelContext, + ) -> Task)>> { let handle = cx.handle(); let path = Arc::from(path); let abs_path = self.absolutize(&path); let fs = self.fs.clone(); + let snapshot = self.snapshot(); + cx.spawn(|this, mut cx| async move { let text = fs.load(&abs_path).await?; + + let diff_base = if let Some(repo) = snapshot.repo_for(&path) { + if let Ok(repo_relative) = path.strip_prefix(repo.content_path) { + let repo_relative = repo_relative.to_owned(); + cx.background() + .spawn(async move { repo.repo.lock().load_index_text(&repo_relative) }) + .await + } else { + None + } + } else { + None + }; + // Eagerly populate the snapshot with an updated entry for the loaded file let entry = this .update(&mut cx, |this, cx| { @@ -573,15 +685,18 @@ impl LocalWorktree { .refresh_entry(path, abs_path, None, cx) }) .await?; + Ok(( File { - entry_id: Some(entry.id), + entry_id: entry.id, worktree: handle, path: entry.path, mtime: entry.mtime, is_local: true, + is_deleted: false, }, text, + diff_base, )) }) } @@ -601,11 +716,12 @@ impl LocalWorktree { cx.as_mut().spawn(|mut cx| async move { let entry = save.await?; let file = File { - entry_id: Some(entry.id), + entry_id: entry.id, worktree: handle, path: entry.path, mtime: entry.mtime, is_local: true, + is_deleted: false, }; buffer_handle.update(&mut cx, |buffer, cx| { @@ -844,9 +960,20 @@ impl LocalWorktree { let (snapshots_tx, mut snapshots_rx) = watch::channel_with(self.snapshot()); let rpc = self.client.clone(); let worktree_id = cx.model_id() as u64; + + for (path, summary) in self.diagnostic_summaries.iter() { + if let Err(e) = rpc.send(proto::UpdateDiagnosticSummary { + project_id, + worktree_id, + summary: Some(summary.to_proto(&path.0)), + }) { + return Task::ready(Err(e)); + } + } + let maintain_remote_snapshot = cx.background().spawn({ let rpc = rpc; - let diagnostic_summaries = self.diagnostic_summaries.clone(); + async move { let mut prev_snapshot = match snapshots_rx.recv().await { Some(snapshot) => { @@ -879,14 +1006,6 @@ impl LocalWorktree { } }; - for (path, summary) in diagnostic_summaries.iter() { - rpc.send(proto::UpdateDiagnosticSummary { - project_id, - worktree_id, - summary: Some(summary.to_proto(&path.0)), - })?; - } - while let Some(snapshot) = snapshots_rx.recv().await { send_worktree_update( &rpc, @@ -1248,6 +1367,22 @@ impl LocalSnapshot { &self.extension_counts } + // Gives the most specific git repository for a given path + pub(crate) fn repo_for(&self, path: &Path) -> Option { + self.git_repositories + .iter() + .rev() //git_repository is ordered lexicographically + .find(|repo| repo.manages(path)) + .cloned() + } + + pub(crate) fn in_dot_git(&mut self, path: &Path) -> Option<&mut GitRepositoryEntry> { + // Git repositories cannot be nested, so we don't need to reverse the order + self.git_repositories + .iter_mut() + .find(|repo| repo.in_dot_git(path)) + } + #[cfg(test)] pub(crate) fn build_initial_update(&self, project_id: u64) -> proto::UpdateWorktree { let root_name = self.root_name.clone(); @@ -1330,7 +1465,7 @@ impl LocalSnapshot { } fn insert_entry(&mut self, mut entry: Entry, fs: &dyn Fs) -> Entry { - if !entry.is_dir() && entry.path.file_name() == Some(&GITIGNORE) { + if entry.is_file() && entry.path.file_name() == Some(&GITIGNORE) { let abs_path = self.abs_path.join(&entry.path); match smol::block_on(build_gitignore(&abs_path, fs)) { Ok(ignore) => { @@ -1384,6 +1519,7 @@ impl LocalSnapshot { parent_path: Arc, entries: impl IntoIterator, ignore: Option>, + fs: &dyn Fs, ) { let mut parent_entry = if let Some(parent_entry) = self.entries_by_path.get(&PathKey(parent_path.clone()), &()) @@ -1409,6 +1545,27 @@ impl LocalSnapshot { unreachable!(); } + if parent_path.file_name() == Some(&DOT_GIT) { + let abs_path = self.abs_path.join(&parent_path); + let content_path: Arc = parent_path.parent().unwrap().into(); + if let Err(ix) = self + .git_repositories + .binary_search_by_key(&&content_path, |repo| &repo.content_path) + { + if let Some(repo) = fs.open_repo(abs_path.as_path()) { + self.git_repositories.insert( + ix, + GitRepositoryEntry { + repo, + scan_id: 0, + content_path, + git_dir_path: parent_path, + }, + ); + } + } + } + let mut entries_by_path_edits = vec![Edit::Insert(parent_entry)]; let mut entries_by_id_edits = Vec::new(); @@ -1493,6 +1650,14 @@ impl LocalSnapshot { { *scan_id = self.snapshot.scan_id; } + } else if path.file_name() == Some(&DOT_GIT) { + let parent_path = path.parent().unwrap(); + if let Ok(ix) = self + .git_repositories + .binary_search_by_key(&parent_path, |repo| repo.git_dir_path.as_ref()) + { + self.git_repositories[ix].scan_id = self.snapshot.scan_id; + } } } @@ -1532,6 +1697,22 @@ impl LocalSnapshot { ignore_stack } + + pub fn git_repo_entries(&self) -> &[GitRepositoryEntry] { + &self.git_repositories + } +} + +impl GitRepositoryEntry { + // Note that these paths should be relative to the worktree root. + pub(crate) fn manages(&self, path: &Path) -> bool { + path.starts_with(self.content_path.as_ref()) + } + + // Note that theis path should be relative to the worktree root. + pub(crate) fn in_dot_git(&self, path: &Path) -> bool { + path.starts_with(self.git_dir_path.as_ref()) + } } async fn build_gitignore(abs_path: &Path, fs: &dyn Fs) -> Result { @@ -1634,8 +1815,9 @@ pub struct File { pub worktree: ModelHandle, pub path: Arc, pub mtime: SystemTime, - pub(crate) entry_id: Option, + pub(crate) entry_id: ProjectEntryId, pub(crate) is_local: bool, + pub(crate) is_deleted: bool, } impl language::File for File { @@ -1673,7 +1855,7 @@ impl language::File for File { } fn is_deleted(&self) -> bool { - self.entry_id.is_none() + self.is_deleted } fn save( @@ -1733,9 +1915,10 @@ impl language::File for File { fn to_proto(&self) -> rpc::proto::File { rpc::proto::File { worktree_id: self.worktree.id() as u64, - entry_id: self.entry_id.map(|entry_id| entry_id.to_proto()), + entry_id: self.entry_id.to_proto(), path: self.path.to_string_lossy().into(), mtime: Some(self.mtime.into()), + is_deleted: self.is_deleted, } } } @@ -1804,8 +1987,9 @@ impl File { worktree, path: Path::new(&proto.path).into(), mtime: proto.mtime.ok_or_else(|| anyhow!("no timestamp"))?.into(), - entry_id: proto.entry_id.map(ProjectEntryId::from_proto), + entry_id: ProjectEntryId::from_proto(proto.entry_id), is_local: false, + is_deleted: proto.is_deleted, }) } @@ -1818,7 +2002,11 @@ impl File { } pub fn project_entry_id(&self, _: &AppContext) -> Option { - self.entry_id + if self.is_deleted { + None + } else { + Some(self.entry_id) + } } } @@ -2244,9 +2432,12 @@ impl BackgroundScanner { new_entries.push(child_entry); } - self.snapshot - .lock() - .populate_dir(job.path.clone(), new_entries, new_ignore); + self.snapshot.lock().populate_dir( + job.path.clone(), + new_entries, + new_ignore, + self.fs.as_ref(), + ); for new_job in new_jobs { job.scan_queue.send(new_job).await.unwrap(); } @@ -2321,6 +2512,12 @@ impl BackgroundScanner { fs_entry.is_ignored = ignore_stack.is_all(); snapshot.insert_entry(fs_entry, self.fs.as_ref()); + let scan_id = snapshot.scan_id; + if let Some(repo) = snapshot.in_dot_git(&path) { + repo.repo.lock().reload_index(); + repo.scan_id = scan_id; + } + let mut ancestor_inodes = snapshot.ancestor_inodes_for_path(&path); if metadata.is_dir && !ancestor_inodes.contains(&metadata.inode) { ancestor_inodes.insert(metadata.inode); @@ -2367,6 +2564,7 @@ impl BackgroundScanner { self.snapshot.lock().removed_entry_ids.clear(); self.update_ignore_statuses().await; + self.update_git_repositories(); true } @@ -2432,6 +2630,13 @@ impl BackgroundScanner { .await; } + fn update_git_repositories(&self) { + let mut snapshot = self.snapshot.lock(); + let mut git_repositories = mem::take(&mut snapshot.git_repositories); + git_repositories.retain(|repo| snapshot.entry_for_path(&repo.git_dir_path).is_some()); + snapshot.git_repositories = git_repositories; + } + async fn update_ignore_status(&self, job: UpdateIgnoreStatusJob, snapshot: &LocalSnapshot) { let mut ignore_stack = job.ignore_stack; if let Some((ignore, _)) = snapshot.ignores_by_parent_abs_path.get(&job.abs_path) { @@ -2774,10 +2979,10 @@ async fn send_worktree_update(client: &Arc, update: proto::UpdateWorktre #[cfg(test)] mod tests { use super::*; - use crate::fs::FakeFs; use anyhow::Result; use client::test::FakeHttpClient; - use fs::RealFs; + use fs::repository::FakeGitRepository; + use fs::{FakeFs, RealFs}; use gpui::{executor::Deterministic, TestAppContext}; use rand::prelude::*; use serde_json::json; @@ -2786,6 +2991,7 @@ mod tests { fmt::Write, time::{SystemTime, UNIX_EPOCH}, }; + use util::test::temp_tree; #[gpui::test] @@ -2804,7 +3010,7 @@ mod tests { .await; let http_client = FakeHttpClient::with_404_response(); - let client = Client::new(http_client); + let client = cx.read(|cx| Client::new(http_client, cx)); let tree = Worktree::local( client, @@ -2866,8 +3072,7 @@ mod tests { fs.insert_symlink("/root/lib/a/lib", "..".into()).await; fs.insert_symlink("/root/lib/b/lib", "..".into()).await; - let http_client = FakeHttpClient::with_404_response(); - let client = Client::new(http_client); + let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let tree = Worktree::local( client, Arc::from(Path::new("/root")), @@ -2945,8 +3150,7 @@ mod tests { })); let dir = parent_dir.path().join("tree"); - let http_client = FakeHttpClient::with_404_response(); - let client = Client::new(http_client.clone()); + let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let tree = Worktree::local( client, @@ -3007,6 +3211,135 @@ mod tests { }); } + #[gpui::test] + async fn test_git_repository_for_path(cx: &mut TestAppContext) { + let root = temp_tree(json!({ + "dir1": { + ".git": {}, + "deps": { + "dep1": { + ".git": {}, + "src": { + "a.txt": "" + } + } + }, + "src": { + "b.txt": "" + } + }, + "c.txt": "", + + })); + + let http_client = FakeHttpClient::with_404_response(); + let client = cx.read(|cx| Client::new(http_client, cx)); + let tree = Worktree::local( + client, + root.path(), + true, + Arc::new(RealFs), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + tree.flush_fs_events(cx).await; + + tree.read_with(cx, |tree, _cx| { + let tree = tree.as_local().unwrap(); + + assert!(tree.repo_for("c.txt".as_ref()).is_none()); + + let repo = tree.repo_for("dir1/src/b.txt".as_ref()).unwrap(); + assert_eq!(repo.content_path.as_ref(), Path::new("dir1")); + assert_eq!(repo.git_dir_path.as_ref(), Path::new("dir1/.git")); + + let repo = tree.repo_for("dir1/deps/dep1/src/a.txt".as_ref()).unwrap(); + assert_eq!(repo.content_path.as_ref(), Path::new("dir1/deps/dep1")); + assert_eq!(repo.git_dir_path.as_ref(), Path::new("dir1/deps/dep1/.git"),); + }); + + let original_scan_id = tree.read_with(cx, |tree, _cx| { + let tree = tree.as_local().unwrap(); + tree.repo_for("dir1/src/b.txt".as_ref()).unwrap().scan_id + }); + + std::fs::write(root.path().join("dir1/.git/random_new_file"), "hello").unwrap(); + tree.flush_fs_events(cx).await; + + tree.read_with(cx, |tree, _cx| { + let tree = tree.as_local().unwrap(); + let new_scan_id = tree.repo_for("dir1/src/b.txt".as_ref()).unwrap().scan_id; + assert_ne!( + original_scan_id, new_scan_id, + "original {original_scan_id}, new {new_scan_id}" + ); + }); + + std::fs::remove_dir_all(root.path().join("dir1/.git")).unwrap(); + tree.flush_fs_events(cx).await; + + tree.read_with(cx, |tree, _cx| { + let tree = tree.as_local().unwrap(); + + assert!(tree.repo_for("dir1/src/b.txt".as_ref()).is_none()); + }); + } + + #[test] + fn test_changed_repos() { + fn fake_entry(git_dir_path: impl AsRef, scan_id: usize) -> GitRepositoryEntry { + GitRepositoryEntry { + repo: Arc::new(Mutex::new(FakeGitRepository::default())), + scan_id, + content_path: git_dir_path.as_ref().parent().unwrap().into(), + git_dir_path: git_dir_path.as_ref().into(), + } + } + + let prev_repos: Vec = vec![ + fake_entry("/.git", 0), + fake_entry("/a/.git", 0), + fake_entry("/a/b/.git", 0), + ]; + + let new_repos: Vec = vec![ + fake_entry("/a/.git", 1), + fake_entry("/a/b/.git", 0), + fake_entry("/a/c/.git", 0), + ]; + + let res = LocalWorktree::changed_repos(&prev_repos, &new_repos); + + // Deletion retained + assert!(res + .iter() + .find(|repo| repo.git_dir_path.as_ref() == Path::new("/.git") && repo.scan_id == 0) + .is_some()); + + // Update retained + assert!(res + .iter() + .find(|repo| repo.git_dir_path.as_ref() == Path::new("/a/.git") && repo.scan_id == 1) + .is_some()); + + // Addition retained + assert!(res + .iter() + .find(|repo| repo.git_dir_path.as_ref() == Path::new("/a/c/.git") && repo.scan_id == 0) + .is_some()); + + // Nochange, not retained + assert!(res + .iter() + .find(|repo| repo.git_dir_path.as_ref() == Path::new("/a/b/.git") && repo.scan_id == 0) + .is_none()); + } + #[gpui::test] async fn test_write_file(cx: &mut TestAppContext) { let dir = temp_tree(json!({ @@ -3016,8 +3349,7 @@ mod tests { "ignored-dir": {} })); - let http_client = FakeHttpClient::with_404_response(); - let client = Client::new(http_client.clone()); + let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let tree = Worktree::local( client, @@ -3064,8 +3396,7 @@ mod tests { #[gpui::test(iterations = 30)] async fn test_create_directory(cx: &mut TestAppContext) { - let http_client = FakeHttpClient::with_404_response(); - let client = Client::new(http_client.clone()); + let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let fs = FakeFs::new(cx.background()); fs.insert_tree( @@ -3127,6 +3458,7 @@ mod tests { abs_path: root_dir.path().into(), removed_entry_ids: Default::default(), ignores_by_parent_abs_path: Default::default(), + git_repositories: Default::default(), next_entry_id: next_entry_id.clone(), snapshot: Snapshot { id: WorktreeId::from_usize(0), diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 8ba70ee4eb..abd8f44603 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1012,7 +1012,7 @@ impl ProjectPanel { ) -> ElementBox { let kind = details.kind; let show_editor = details.is_editing && !details.is_processing; - MouseEventHandler::::new(entry_id.to_usize(), cx, |state, _| { + MouseEventHandler::::new(entry_id.to_usize(), cx, |state, cx| { let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width; let mut style = theme.entry.style_for(state, details.is_selected).clone(); if details.is_ignored { @@ -1051,7 +1051,7 @@ impl ProjectPanel { .boxed(), ) .with_child(if show_editor { - ChildView::new(editor.clone()) + ChildView::new(editor.clone(), cx) .contained() .with_margin_left(theme.entry.default.icon_spacing) .aligned() @@ -1147,7 +1147,7 @@ impl View for ProjectPanel { }) .boxed(), ) - .with_child(ChildView::new(&self.context_menu).boxed()) + .with_child(ChildView::new(&self.context_menu, cx).boxed()) .boxed() } diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index c310cfb043..a81231b98f 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -47,8 +47,8 @@ impl View for ProjectSymbolsView { "ProjectSymbolsView" } - fn render(&mut self, _: &mut RenderContext) -> ElementBox { - ChildView::new(self.picker.clone()).boxed() + fn render(&mut self, cx: &mut RenderContext) -> ElementBox { + ChildView::new(self.picker.clone(), cx).boxed() } fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { @@ -234,7 +234,7 @@ impl PickerDelegate for ProjectSymbolsView { fn render_match( &self, ix: usize, - mouse_state: MouseState, + mouse_state: &mut MouseState, selected: bool, cx: &AppContext, ) -> ElementBox { diff --git a/crates/rope/Cargo.toml b/crates/rope/Cargo.toml new file mode 100644 index 0000000000..0f754c1fb3 --- /dev/null +++ b/crates/rope/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "rope" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/rope.rs" + +[dependencies] +bromberg_sl2 = "0.6" +smallvec = { version = "1.6", features = ["union"] } +sum_tree = { path = "../sum_tree" } +arrayvec = "0.7.1" +log = { version = "0.4.16", features = ["kv_unstable_serde"] } + + +[dev-dependencies] +rand = "0.8.3" +util = { path = "../util", features = ["test-support"] } +gpui = { path = "../gpui", features = ["test-support"] } diff --git a/crates/text/src/offset_utf16.rs b/crates/rope/src/offset_utf16.rs similarity index 100% rename from crates/text/src/offset_utf16.rs rename to crates/rope/src/offset_utf16.rs diff --git a/crates/text/src/point.rs b/crates/rope/src/point.rs similarity index 100% rename from crates/text/src/point.rs rename to crates/rope/src/point.rs diff --git a/crates/text/src/point_utf16.rs b/crates/rope/src/point_utf16.rs similarity index 100% rename from crates/text/src/point_utf16.rs rename to crates/rope/src/point_utf16.rs diff --git a/crates/text/src/rope.rs b/crates/rope/src/rope.rs similarity index 98% rename from crates/text/src/rope.rs rename to crates/rope/src/rope.rs index d35ac46f45..8c357801e3 100644 --- a/crates/text/src/rope.rs +++ b/crates/rope/src/rope.rs @@ -1,11 +1,17 @@ -use super::Point; -use crate::{OffsetUtf16, PointUtf16}; +mod offset_utf16; +mod point; +mod point_utf16; + use arrayvec::ArrayString; use bromberg_sl2::{DigestString, HashMatrix}; use smallvec::SmallVec; use std::{cmp, fmt, io, mem, ops::Range, str}; use sum_tree::{Bias, Dimension, SumTree}; +pub use offset_utf16::OffsetUtf16; +pub use point::Point; +pub use point_utf16::PointUtf16; + #[cfg(test)] const CHUNK_BASE: usize = 6; @@ -54,6 +60,13 @@ impl Rope { cursor.slice(range.end) } + pub fn slice_rows(&self, range: Range) -> Rope { + //This would be more efficient with a forward advance after the first, but it's fine + let start = self.point_to_offset(Point::new(range.start, 0)); + let end = self.point_to_offset(Point::new(range.end, 0)); + self.slice(start..end) + } + pub fn push(&mut self, text: &str) { let mut new_chunks = SmallVec::<[_; 16]>::new(); let mut new_chunk = ArrayString::new(); @@ -1066,9 +1079,9 @@ fn find_split_ix(text: &str) -> usize { #[cfg(test)] mod tests { use super::*; - use crate::random_char_iter::RandomCharIter; use rand::prelude::*; use std::{cmp::Ordering, env, io::Read}; + use util::RandomCharIter; use Bias::{Left, Right}; #[test] diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 4cf7e38a13..1248bb0551 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -10,104 +10,116 @@ message Envelope { Error error = 5; Ping ping = 6; Test test = 7; + + CreateRoom create_room = 8; + CreateRoomResponse create_room_response = 9; + JoinRoom join_room = 10; + JoinRoomResponse join_room_response = 11; + LeaveRoom leave_room = 12; + Call call = 13; + IncomingCall incoming_call = 14; + CallCanceled call_canceled = 15; + CancelCall cancel_call = 16; + DeclineCall decline_call = 17; + UpdateParticipantLocation update_participant_location = 18; + RoomUpdated room_updated = 19; - RegisterProject register_project = 8; - RegisterProjectResponse register_project_response = 9; - UnregisterProject unregister_project = 10; - RequestJoinProject request_join_project = 11; - RespondToJoinProjectRequest respond_to_join_project_request = 12; - JoinProjectRequestCancelled join_project_request_cancelled = 13; - JoinProject join_project = 14; - JoinProjectResponse join_project_response = 15; - LeaveProject leave_project = 16; - AddProjectCollaborator add_project_collaborator = 17; - RemoveProjectCollaborator remove_project_collaborator = 18; - ProjectUnshared project_unshared = 19; + ShareProject share_project = 20; + ShareProjectResponse share_project_response = 21; + UnshareProject unshare_project = 22; + JoinProject join_project = 23; + JoinProjectResponse join_project_response = 24; + LeaveProject leave_project = 25; + AddProjectCollaborator add_project_collaborator = 26; + RemoveProjectCollaborator remove_project_collaborator = 27; - GetDefinition get_definition = 20; - GetDefinitionResponse get_definition_response = 21; - GetTypeDefinition get_type_definition = 22; - GetTypeDefinitionResponse get_type_definition_response = 23; - GetReferences get_references = 24; - GetReferencesResponse get_references_response = 25; - GetDocumentHighlights get_document_highlights = 26; - GetDocumentHighlightsResponse get_document_highlights_response = 27; - GetProjectSymbols get_project_symbols = 28; - GetProjectSymbolsResponse get_project_symbols_response = 29; - OpenBufferForSymbol open_buffer_for_symbol = 30; - OpenBufferForSymbolResponse open_buffer_for_symbol_response = 31; + GetDefinition get_definition = 28; + GetDefinitionResponse get_definition_response = 29; + GetTypeDefinition get_type_definition = 30; + GetTypeDefinitionResponse get_type_definition_response = 31; + GetReferences get_references = 32; + GetReferencesResponse get_references_response = 33; + GetDocumentHighlights get_document_highlights = 34; + GetDocumentHighlightsResponse get_document_highlights_response = 35; + GetProjectSymbols get_project_symbols = 36; + GetProjectSymbolsResponse get_project_symbols_response = 37; + OpenBufferForSymbol open_buffer_for_symbol = 38; + OpenBufferForSymbolResponse open_buffer_for_symbol_response = 39; - UpdateProject update_project = 32; - RegisterProjectActivity register_project_activity = 33; - UpdateWorktree update_worktree = 34; - UpdateWorktreeExtensions update_worktree_extensions = 35; + UpdateProject update_project = 40; + RegisterProjectActivity register_project_activity = 41; + UpdateWorktree update_worktree = 42; + UpdateWorktreeExtensions update_worktree_extensions = 43; - CreateProjectEntry create_project_entry = 36; - RenameProjectEntry rename_project_entry = 37; - CopyProjectEntry copy_project_entry = 38; - DeleteProjectEntry delete_project_entry = 39; - ProjectEntryResponse project_entry_response = 40; + CreateProjectEntry create_project_entry = 44; + RenameProjectEntry rename_project_entry = 45; + CopyProjectEntry copy_project_entry = 46; + DeleteProjectEntry delete_project_entry = 47; + ProjectEntryResponse project_entry_response = 48; - UpdateDiagnosticSummary update_diagnostic_summary = 41; - StartLanguageServer start_language_server = 42; - UpdateLanguageServer update_language_server = 43; + UpdateDiagnosticSummary update_diagnostic_summary = 49; + StartLanguageServer start_language_server = 50; + UpdateLanguageServer update_language_server = 51; - OpenBufferById open_buffer_by_id = 44; - OpenBufferByPath open_buffer_by_path = 45; - OpenBufferResponse open_buffer_response = 46; - CreateBufferForPeer create_buffer_for_peer = 47; - UpdateBuffer update_buffer = 48; - UpdateBufferFile update_buffer_file = 49; - SaveBuffer save_buffer = 50; - BufferSaved buffer_saved = 51; - BufferReloaded buffer_reloaded = 52; - ReloadBuffers reload_buffers = 53; - ReloadBuffersResponse reload_buffers_response = 54; - FormatBuffers format_buffers = 55; - FormatBuffersResponse format_buffers_response = 56; - GetCompletions get_completions = 57; - GetCompletionsResponse get_completions_response = 58; - ApplyCompletionAdditionalEdits apply_completion_additional_edits = 59; - ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 60; - GetCodeActions get_code_actions = 61; - GetCodeActionsResponse get_code_actions_response = 62; - GetHover get_hover = 63; - GetHoverResponse get_hover_response = 64; - ApplyCodeAction apply_code_action = 65; - ApplyCodeActionResponse apply_code_action_response = 66; - PrepareRename prepare_rename = 67; - PrepareRenameResponse prepare_rename_response = 68; - PerformRename perform_rename = 69; - PerformRenameResponse perform_rename_response = 70; - SearchProject search_project = 71; - SearchProjectResponse search_project_response = 72; + OpenBufferById open_buffer_by_id = 52; + OpenBufferByPath open_buffer_by_path = 53; + OpenBufferResponse open_buffer_response = 54; + CreateBufferForPeer create_buffer_for_peer = 55; + UpdateBuffer update_buffer = 56; + UpdateBufferFile update_buffer_file = 57; + SaveBuffer save_buffer = 58; + BufferSaved buffer_saved = 59; + BufferReloaded buffer_reloaded = 60; + ReloadBuffers reload_buffers = 61; + ReloadBuffersResponse reload_buffers_response = 62; + FormatBuffers format_buffers = 63; + FormatBuffersResponse format_buffers_response = 64; + GetCompletions get_completions = 65; + GetCompletionsResponse get_completions_response = 66; + ApplyCompletionAdditionalEdits apply_completion_additional_edits = 67; + ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 68; + GetCodeActions get_code_actions = 69; + GetCodeActionsResponse get_code_actions_response = 70; + GetHover get_hover = 71; + GetHoverResponse get_hover_response = 72; + ApplyCodeAction apply_code_action = 73; + ApplyCodeActionResponse apply_code_action_response = 74; + PrepareRename prepare_rename = 75; + PrepareRenameResponse prepare_rename_response = 76; + PerformRename perform_rename = 77; + PerformRenameResponse perform_rename_response = 78; + SearchProject search_project = 79; + SearchProjectResponse search_project_response = 80; - GetChannels get_channels = 73; - GetChannelsResponse get_channels_response = 74; - JoinChannel join_channel = 75; - JoinChannelResponse join_channel_response = 76; - LeaveChannel leave_channel = 77; - SendChannelMessage send_channel_message = 78; - SendChannelMessageResponse send_channel_message_response = 79; - ChannelMessageSent channel_message_sent = 80; - GetChannelMessages get_channel_messages = 81; - GetChannelMessagesResponse get_channel_messages_response = 82; + GetChannels get_channels = 81; + GetChannelsResponse get_channels_response = 82; + JoinChannel join_channel = 83; + JoinChannelResponse join_channel_response = 84; + LeaveChannel leave_channel = 85; + SendChannelMessage send_channel_message = 86; + SendChannelMessageResponse send_channel_message_response = 87; + ChannelMessageSent channel_message_sent = 88; + GetChannelMessages get_channel_messages = 89; + GetChannelMessagesResponse get_channel_messages_response = 90; - UpdateContacts update_contacts = 83; - UpdateInviteInfo update_invite_info = 84; - ShowContacts show_contacts = 85; + UpdateContacts update_contacts = 91; + UpdateInviteInfo update_invite_info = 92; + ShowContacts show_contacts = 93; - GetUsers get_users = 86; - FuzzySearchUsers fuzzy_search_users = 87; - UsersResponse users_response = 88; - RequestContact request_contact = 89; - RespondToContactRequest respond_to_contact_request = 90; - RemoveContact remove_contact = 91; + GetUsers get_users = 94; + FuzzySearchUsers fuzzy_search_users = 95; + UsersResponse users_response = 96; + RequestContact request_contact = 97; + RespondToContactRequest respond_to_contact_request = 98; + RemoveContact remove_contact = 99; - Follow follow = 92; - FollowResponse follow_response = 93; - UpdateFollowers update_followers = 94; - Unfollow unfollow = 95; + Follow follow = 100; + FollowResponse follow_response = 101; + UpdateFollowers update_followers = 102; + Unfollow unfollow = 103; + GetPrivateUserInfo get_private_user_info = 104; + GetPrivateUserInfoResponse get_private_user_info_response = 105; + UpdateDiffBase update_diff_base = 106; } } @@ -125,70 +137,121 @@ message Test { uint64 id = 1; } -message RegisterProject { - bool online = 1; +message CreateRoom {} + +message CreateRoomResponse { + uint64 id = 1; } -message RegisterProjectResponse { +message JoinRoom { + uint64 id = 1; +} + +message JoinRoomResponse { + Room room = 1; +} + +message LeaveRoom { + uint64 id = 1; +} + +message Room { + repeated Participant participants = 1; + repeated uint64 pending_participant_user_ids = 2; +} + +message Participant { + uint64 user_id = 1; + uint32 peer_id = 2; + repeated ParticipantProject projects = 3; + ParticipantLocation location = 4; +} + +message ParticipantProject { + uint64 id = 1; + repeated string worktree_root_names = 2; +} + +message ParticipantLocation { + oneof variant { + SharedProject shared_project = 1; + UnsharedProject unshared_project = 2; + External external = 3; + } + + message SharedProject { + uint64 id = 1; + } + + message UnsharedProject {} + + message External {} +} + +message Call { + uint64 room_id = 1; + uint64 recipient_user_id = 2; + optional uint64 initial_project_id = 3; +} + +message IncomingCall { + uint64 room_id = 1; + uint64 caller_user_id = 2; + repeated uint64 participant_user_ids = 3; + optional ParticipantProject initial_project = 4; +} + +message CallCanceled {} + +message CancelCall { + uint64 room_id = 1; + uint64 recipient_user_id = 2; +} + +message DeclineCall { + uint64 room_id = 1; +} + +message UpdateParticipantLocation { + uint64 room_id = 1; + ParticipantLocation location = 2; +} + +message RoomUpdated { + Room room = 1; +} + +message ShareProject { + uint64 room_id = 1; + repeated WorktreeMetadata worktrees = 2; +} + +message ShareProjectResponse { uint64 project_id = 1; } -message UnregisterProject { +message UnshareProject { uint64 project_id = 1; } message UpdateProject { uint64 project_id = 1; repeated WorktreeMetadata worktrees = 2; - bool online = 3; } message RegisterProjectActivity { uint64 project_id = 1; } -message RequestJoinProject { - uint64 requester_id = 1; - uint64 project_id = 2; -} - -message RespondToJoinProjectRequest { - uint64 requester_id = 1; - uint64 project_id = 2; - bool allow = 3; -} - -message JoinProjectRequestCancelled { - uint64 requester_id = 1; - uint64 project_id = 2; -} - message JoinProject { uint64 project_id = 1; } message JoinProjectResponse { - oneof variant { - Accept accept = 1; - Decline decline = 2; - } - - message Accept { - uint32 replica_id = 1; - repeated WorktreeMetadata worktrees = 2; - repeated Collaborator collaborators = 3; - repeated LanguageServer language_servers = 4; - } - - message Decline { - Reason reason = 1; - - enum Reason { - Declined = 0; - Closed = 1; - WentOffline = 2; - } - } + uint32 replica_id = 1; + repeated WorktreeMetadata worktrees = 2; + repeated Collaborator collaborators = 3; + repeated LanguageServer language_servers = 4; } message LeaveProject { @@ -251,10 +314,6 @@ message RemoveProjectCollaborator { uint32 peer_id = 2; } -message ProjectUnshared { - uint64 project_id = 1; -} - message GetDefinition { uint64 project_id = 1; uint64 buffer_id = 2; @@ -420,9 +479,15 @@ message ReloadBuffersResponse { ProjectTransaction transaction = 1; } +enum FormatTrigger { + Save = 0; + Manual = 1; +} + message FormatBuffers { uint64 project_id = 1; - repeated uint64 buffer_ids = 2; + FormatTrigger trigger = 2; + repeated uint64 buffer_ids = 3; } message FormatBuffersResponse { @@ -742,6 +807,13 @@ message Unfollow { uint32 leader_id = 2; } +message GetPrivateUserInfo {} + +message GetPrivateUserInfoResponse { + string metrics_id = 1; + bool staff = 2; +} + // Entities message UpdateActiveView { @@ -796,9 +868,10 @@ message User { message File { uint64 worktree_id = 1; - optional uint64 entry_id = 2; + uint64 entry_id = 2; string path = 3; Timestamp mtime = 4; + bool is_deleted = 5; } message Entry { @@ -815,7 +888,8 @@ message BufferState { uint64 id = 1; optional File file = 2; string base_text = 3; - LineEnding line_ending = 4; + optional string diff_base = 4; + LineEnding line_ending = 5; } message BufferChunk { @@ -969,19 +1043,19 @@ message ChannelMessage { message Contact { uint64 user_id = 1; - repeated ProjectMetadata projects = 2; - bool online = 3; + bool online = 2; + bool busy = 3; bool should_notify = 4; } -message ProjectMetadata { - uint64 id = 1; - repeated string visible_worktree_root_names = 3; - repeated uint64 guests = 4; -} - message WorktreeMetadata { uint64 id = 1; string root_name = 2; bool visible = 3; } + +message UpdateDiffBase { + uint64 project_id = 1; + uint64 buffer_id = 2; + optional string diff_base = 3; +} diff --git a/crates/rpc/src/peer.rs b/crates/rpc/src/peer.rs index 6c1c4f01da..4dbade4fec 100644 --- a/crates/rpc/src/peer.rs +++ b/crates/rpc/src/peer.rs @@ -33,7 +33,7 @@ impl fmt::Display for ConnectionId { } } -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] pub struct PeerId(pub u32); impl fmt::Display for PeerId { @@ -113,7 +113,7 @@ impl Peer { } #[instrument(skip_all)] - pub async fn add_connection( + pub fn add_connection( self: &Arc, connection: Connection, create_timer: F, @@ -326,7 +326,7 @@ impl Peer { } #[cfg(any(test, feature = "test-support"))] - pub async fn add_test_connection( + pub fn add_test_connection( self: &Arc, connection: Connection, executor: Arc, @@ -337,7 +337,6 @@ impl Peer { ) { let executor = executor.clone(); self.add_connection(connection, move |duration| executor.timer(duration)) - .await } pub fn disconnect(&self, connection_id: ConnectionId) { @@ -394,7 +393,11 @@ impl Peer { send?; let (response, _barrier) = rx.await.map_err(|_| anyhow!("connection was closed"))?; if let Some(proto::envelope::Payload::Error(error)) = &response.payload { - Err(anyhow!("RPC request failed - {}", error.message)) + Err(anyhow!( + "RPC request {} failed - {}", + T::NAME, + error.message + )) } else { T::Response::from_envelope(response) .ok_or_else(|| anyhow!("received response of the wrong type")) @@ -518,21 +521,17 @@ mod tests { let (client1_to_server_conn, server_to_client_1_conn, _kill) = Connection::in_memory(cx.background()); - let (client1_conn_id, io_task1, client1_incoming) = client1 - .add_test_connection(client1_to_server_conn, cx.background()) - .await; - let (_, io_task2, server_incoming1) = server - .add_test_connection(server_to_client_1_conn, cx.background()) - .await; + let (client1_conn_id, io_task1, client1_incoming) = + client1.add_test_connection(client1_to_server_conn, cx.background()); + let (_, io_task2, server_incoming1) = + server.add_test_connection(server_to_client_1_conn, cx.background()); let (client2_to_server_conn, server_to_client_2_conn, _kill) = Connection::in_memory(cx.background()); - let (client2_conn_id, io_task3, client2_incoming) = client2 - .add_test_connection(client2_to_server_conn, cx.background()) - .await; - let (_, io_task4, server_incoming2) = server - .add_test_connection(server_to_client_2_conn, cx.background()) - .await; + let (client2_conn_id, io_task3, client2_incoming) = + client2.add_test_connection(client2_to_server_conn, cx.background()); + let (_, io_task4, server_incoming2) = + server.add_test_connection(server_to_client_2_conn, cx.background()); executor.spawn(io_task1).detach(); executor.spawn(io_task2).detach(); @@ -615,12 +614,10 @@ mod tests { let (client_to_server_conn, server_to_client_conn, _kill) = Connection::in_memory(cx.background()); - let (client_to_server_conn_id, io_task1, mut client_incoming) = client - .add_test_connection(client_to_server_conn, cx.background()) - .await; - let (server_to_client_conn_id, io_task2, mut server_incoming) = server - .add_test_connection(server_to_client_conn, cx.background()) - .await; + let (client_to_server_conn_id, io_task1, mut client_incoming) = + client.add_test_connection(client_to_server_conn, cx.background()); + let (server_to_client_conn_id, io_task2, mut server_incoming) = + server.add_test_connection(server_to_client_conn, cx.background()); executor.spawn(io_task1).detach(); executor.spawn(io_task2).detach(); @@ -715,12 +712,10 @@ mod tests { let (client_to_server_conn, server_to_client_conn, _kill) = Connection::in_memory(cx.background()); - let (client_to_server_conn_id, io_task1, mut client_incoming) = client - .add_test_connection(client_to_server_conn, cx.background()) - .await; - let (server_to_client_conn_id, io_task2, mut server_incoming) = server - .add_test_connection(server_to_client_conn, cx.background()) - .await; + let (client_to_server_conn_id, io_task1, mut client_incoming) = + client.add_test_connection(client_to_server_conn, cx.background()); + let (server_to_client_conn_id, io_task2, mut server_incoming) = + server.add_test_connection(server_to_client_conn, cx.background()); executor.spawn(io_task1).detach(); executor.spawn(io_task2).detach(); @@ -828,9 +823,8 @@ mod tests { let (client_conn, mut server_conn, _kill) = Connection::in_memory(cx.background()); let client = Peer::new(); - let (connection_id, io_handler, mut incoming) = client - .add_test_connection(client_conn, cx.background()) - .await; + let (connection_id, io_handler, mut incoming) = + client.add_test_connection(client_conn, cx.background()); let (io_ended_tx, io_ended_rx) = oneshot::channel(); executor @@ -864,9 +858,8 @@ mod tests { let (client_conn, mut server_conn, _kill) = Connection::in_memory(cx.background()); let client = Peer::new(); - let (connection_id, io_handler, mut incoming) = client - .add_test_connection(client_conn, cx.background()) - .await; + let (connection_id, io_handler, mut incoming) = + client.add_test_connection(client_conn, cx.background()); executor.spawn(io_handler).detach(); executor .spawn(async move { incoming.next().await }) diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 2ba3fa18ba..069fde4e59 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -83,11 +83,16 @@ messages!( (ApplyCompletionAdditionalEditsResponse, Background), (BufferReloaded, Foreground), (BufferSaved, Foreground), - (RemoveContact, Foreground), + (Call, Foreground), + (CallCanceled, Foreground), + (CancelCall, Foreground), (ChannelMessageSent, Foreground), (CopyProjectEntry, Foreground), (CreateBufferForPeer, Foreground), (CreateProjectEntry, Foreground), + (CreateRoom, Foreground), + (CreateRoomResponse, Foreground), + (DeclineCall, Foreground), (DeleteProjectEntry, Foreground), (Error, Foreground), (Follow, Foreground), @@ -116,14 +121,17 @@ messages!( (GetProjectSymbols, Background), (GetProjectSymbolsResponse, Background), (GetUsers, Foreground), + (IncomingCall, Foreground), (UsersResponse, Foreground), (JoinChannel, Foreground), (JoinChannelResponse, Foreground), (JoinProject, Foreground), (JoinProjectResponse, Foreground), - (JoinProjectRequestCancelled, Foreground), + (JoinRoom, Foreground), + (JoinRoomResponse, Foreground), (LeaveChannel, Foreground), (LeaveProject, Foreground), + (LeaveRoom, Foreground), (OpenBufferById, Background), (OpenBufferByPath, Background), (OpenBufferForSymbol, Background), @@ -134,29 +142,28 @@ messages!( (PrepareRename, Background), (PrepareRenameResponse, Background), (ProjectEntryResponse, Foreground), - (ProjectUnshared, Foreground), - (RegisterProjectResponse, Foreground), + (RemoveContact, Foreground), (Ping, Foreground), - (RegisterProject, Foreground), (RegisterProjectActivity, Foreground), (ReloadBuffers, Foreground), (ReloadBuffersResponse, Foreground), (RemoveProjectCollaborator, Foreground), (RenameProjectEntry, Foreground), (RequestContact, Foreground), - (RequestJoinProject, Foreground), (RespondToContactRequest, Foreground), - (RespondToJoinProjectRequest, Foreground), + (RoomUpdated, Foreground), (SaveBuffer, Foreground), (SearchProject, Background), (SearchProjectResponse, Background), (SendChannelMessage, Foreground), (SendChannelMessageResponse, Foreground), + (ShareProject, Foreground), + (ShareProjectResponse, Foreground), (ShowContacts, Foreground), (StartLanguageServer, Foreground), (Test, Foreground), (Unfollow, Foreground), - (UnregisterProject, Foreground), + (UnshareProject, Foreground), (UpdateBuffer, Foreground), (UpdateBufferFile, Foreground), (UpdateContacts, Foreground), @@ -164,9 +171,13 @@ messages!( (UpdateFollowers, Foreground), (UpdateInviteInfo, Foreground), (UpdateLanguageServer, Foreground), + (UpdateParticipantLocation, Foreground), (UpdateProject, Foreground), (UpdateWorktree, Foreground), (UpdateWorktreeExtensions, Background), + (UpdateDiffBase, Background), + (GetPrivateUserInfo, Foreground), + (GetPrivateUserInfoResponse, Foreground), ); request_messages!( @@ -175,8 +186,12 @@ request_messages!( ApplyCompletionAdditionalEdits, ApplyCompletionAdditionalEditsResponse ), + (Call, Ack), + (CancelCall, Ack), (CopyProjectEntry, ProjectEntryResponse), (CreateProjectEntry, ProjectEntryResponse), + (CreateRoom, CreateRoomResponse), + (DeclineCall, Ack), (DeleteProjectEntry, ProjectEntryResponse), (Follow, FollowResponse), (FormatBuffers, FormatBuffersResponse), @@ -189,18 +204,20 @@ request_messages!( (GetTypeDefinition, GetTypeDefinitionResponse), (GetDocumentHighlights, GetDocumentHighlightsResponse), (GetReferences, GetReferencesResponse), + (GetPrivateUserInfo, GetPrivateUserInfoResponse), (GetProjectSymbols, GetProjectSymbolsResponse), (FuzzySearchUsers, UsersResponse), (GetUsers, UsersResponse), (JoinChannel, JoinChannelResponse), (JoinProject, JoinProjectResponse), + (JoinRoom, JoinRoomResponse), + (IncomingCall, Ack), (OpenBufferById, OpenBufferResponse), (OpenBufferByPath, OpenBufferResponse), (OpenBufferForSymbol, OpenBufferForSymbolResponse), (Ping, Ack), (PerformRename, PerformRenameResponse), (PrepareRename, PrepareRenameResponse), - (RegisterProject, RegisterProjectResponse), (ReloadBuffers, ReloadBuffersResponse), (RequestContact, Ack), (RemoveContact, Ack), @@ -209,9 +226,10 @@ request_messages!( (SaveBuffer, BufferSaved), (SearchProject, SearchProjectResponse), (SendChannelMessage, SendChannelMessageResponse), + (ShareProject, ShareProjectResponse), (Test, Test), - (UnregisterProject, Ack), (UpdateBuffer, Ack), + (UpdateParticipantLocation, Ack), (UpdateWorktree, Ack), ); @@ -237,24 +255,21 @@ entity_messages!( GetReferences, GetProjectSymbols, JoinProject, - JoinProjectRequestCancelled, LeaveProject, OpenBufferById, OpenBufferByPath, OpenBufferForSymbol, PerformRename, PrepareRename, - ProjectUnshared, RegisterProjectActivity, ReloadBuffers, RemoveProjectCollaborator, RenameProjectEntry, - RequestJoinProject, SaveBuffer, SearchProject, StartLanguageServer, Unfollow, - UnregisterProject, + UnshareProject, UpdateBuffer, UpdateBufferFile, UpdateDiagnosticSummary, @@ -263,6 +278,7 @@ entity_messages!( UpdateProject, UpdateWorktree, UpdateWorktreeExtensions, + UpdateDiffBase ); entity_messages!(channel_id, ChannelMessageSent); diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index 308e5b0f43..5fb9ca79a2 100644 --- a/crates/rpc/src/rpc.rs +++ b/crates/rpc/src/rpc.rs @@ -6,4 +6,4 @@ pub use conn::Connection; pub use peer::*; mod macros; -pub const PROTOCOL_VERSION: u32 = 31; +pub const PROTOCOL_VERSION: u32 = 35; diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 6f75888f48..a43f3eb486 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -105,7 +105,7 @@ impl View for BufferSearchBar { .with_child( Flex::row() .with_child( - ChildView::new(&self.query_editor) + ChildView::new(&self.query_editor, cx) .aligned() .left() .flex(1., true) diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 4992d03737..eb5bf7d699 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -189,7 +189,9 @@ impl View for ProjectSearchView { }) .boxed() } else { - ChildView::new(&self.results_editor).flex(1., true).boxed() + ChildView::new(&self.results_editor, cx) + .flex(1., true) + .boxed() } } @@ -200,6 +202,10 @@ impl View for ProjectSearchView { .0 .insert(self.model.read(cx).project.downgrade(), handle) }); + + if cx.is_self_focused() { + self.focus_query_editor(cx); + } } } @@ -820,7 +826,7 @@ impl View for ProjectSearchBar { .with_child( Flex::row() .with_child( - ChildView::new(&search.query_editor) + ChildView::new(&search.query_editor, cx) .aligned() .left() .flex(1., true) diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml index 78440a2418..ad184ad313 100644 --- a/crates/settings/Cargo.toml +++ b/crates/settings/Cargo.toml @@ -14,12 +14,22 @@ test-support = [] assets = { path = "../assets" } collections = { path = "../collections" } gpui = { path = "../gpui" } +fs = { path = "../fs" } +anyhow = "1.0.38" +futures = "0.3" theme = { path = "../theme" } util = { path = "../util" } -anyhow = "1.0.38" json_comments = "0.2" +postage = { version = "0.4.1", features = ["futures-traits"] } schemars = "0.8" -serde = { version = "1.0", features = ["derive", "rc"] } -serde_json = { version = "1.0", features = ["preserve_order"] } +serde = { workspace = true } +serde_json = { workspace = true } serde_path_to_error = "0.1.4" toml = "0.5" +tree-sitter = "*" +tree-sitter-json = "*" + +[dev-dependencies] +unindent = "0.1" +gpui = { path = "../gpui", features = ["test-support"] } +fs = { path = "../fs", features = ["test-support"] } diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index d2b076b012..63bc5962fa 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -1,4 +1,6 @@ mod keymap_file; +pub mod settings_file; +pub mod watched_json; use anyhow::Result; use gpui::{ @@ -10,10 +12,11 @@ use schemars::{ schema::{InstanceType, ObjectValidation, Schema, SchemaObject, SingleOrVec}, JsonSchema, }; -use serde::{de::DeserializeOwned, Deserialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; -use std::{collections::HashMap, num::NonZeroU32, str, sync::Arc}; +use std::{collections::HashMap, fmt::Write as _, num::NonZeroU32, str, sync::Arc}; use theme::{Theme, ThemeRegistry}; +use tree_sitter::Query; use util::ResultExt as _; pub use keymap_file::{keymap_file_json_schema, KeymapFileContent}; @@ -32,6 +35,10 @@ pub struct Settings { pub default_dock_anchor: DockAnchor, pub editor_defaults: EditorSettings, pub editor_overrides: EditorSettings, + pub git: GitSettings, + pub git_overrides: GitSettings, + pub journal_defaults: JournalSettings, + pub journal_overrides: JournalSettings, pub terminal_defaults: TerminalSettings, pub terminal_overrides: TerminalSettings, pub language_defaults: HashMap, EditorSettings>, @@ -41,7 +48,7 @@ pub struct Settings { pub staff_mode: bool, } -#[derive(Copy, Clone, Debug, Default, Deserialize, JsonSchema)] +#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct FeatureFlags { pub experimental_themes: bool, } @@ -52,27 +59,44 @@ impl FeatureFlags { } } -#[derive(Clone, Debug, Default, Deserialize, JsonSchema)] +#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] +pub struct GitSettings { + pub git_gutter: Option, + pub gutter_debounce: Option, +} + +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum GitGutter { + #[default] + TrackedFiles, + Hide, +} + +pub struct GitGutterConfig {} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct EditorSettings { pub tab_size: Option, pub hard_tabs: Option, pub soft_wrap: Option, pub preferred_line_length: Option, pub format_on_save: Option, + pub formatter: Option, pub enable_language_server: Option, } -#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum SoftWrap { None, EditorWidth, PreferredLineLength, } - -#[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum FormatOnSave { + On, Off, LanguageServer, External { @@ -81,7 +105,17 @@ pub enum FormatOnSave { }, } -#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum Formatter { + LanguageServer, + External { + command: String, + arguments: Vec, + }, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum Autosave { Off, @@ -90,7 +124,35 @@ pub enum Autosave { OnWindowChange, } -#[derive(Clone, Debug, Default, Deserialize, JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct JournalSettings { + pub path: Option, + pub hour_format: Option, +} + +impl Default for JournalSettings { + fn default() -> Self { + Self { + path: Some("~".into()), + hour_format: Some(Default::default()), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum HourFormat { + Hour12, + Hour24, +} + +impl Default for HourFormat { + fn default() -> Self { + Self::Hour12 + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct TerminalSettings { pub shell: Option, pub working_directory: Option, @@ -100,9 +162,10 @@ pub struct TerminalSettings { pub blinking: Option, pub alternate_scroll: Option, pub option_as_meta: Option, + pub copy_on_select: Option, } -#[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum TerminalBlink { Off, @@ -116,7 +179,7 @@ impl Default for TerminalBlink { } } -#[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum Shell { System, @@ -130,7 +193,7 @@ impl Default for Shell { } } -#[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum AlternateScroll { On, @@ -143,7 +206,7 @@ impl Default for AlternateScroll { } } -#[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum WorkingDirectory { CurrentProjectDirectory, @@ -152,7 +215,7 @@ pub enum WorkingDirectory { Always { directory: String }, } -#[derive(PartialEq, Eq, Debug, Default, Copy, Clone, Hash, Deserialize, JsonSchema)] +#[derive(PartialEq, Eq, Debug, Default, Copy, Clone, Hash, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum DockAnchor { #[default] @@ -161,7 +224,7 @@ pub enum DockAnchor { Expanded, } -#[derive(Clone, Debug, Default, Deserialize, JsonSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct SettingsFileContent { pub experiments: Option, #[serde(default)] @@ -183,8 +246,12 @@ pub struct SettingsFileContent { #[serde(flatten)] pub editor: EditorSettings, #[serde(default)] + pub journal: JournalSettings, + #[serde(default)] pub terminal: TerminalSettings, #[serde(default)] + pub git: Option, + #[serde(default)] #[serde(alias = "language_overrides")] pub languages: HashMap, EditorSettings>, #[serde(default)] @@ -195,7 +262,7 @@ pub struct SettingsFileContent { pub staff_mode: Option, } -#[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] pub struct LspSettings { pub initialization_options: Option, @@ -207,6 +274,7 @@ impl Settings { font_cache: &FontCache, themes: &ThemeRegistry, ) -> Self { + #[track_caller] fn required(value: Option) -> Option { assert!(value.is_some(), "missing default setting value"); value @@ -236,10 +304,15 @@ impl Settings { soft_wrap: required(defaults.editor.soft_wrap), preferred_line_length: required(defaults.editor.preferred_line_length), format_on_save: required(defaults.editor.format_on_save), + formatter: required(defaults.editor.formatter), enable_language_server: required(defaults.editor.enable_language_server), }, editor_overrides: Default::default(), - terminal_defaults: Default::default(), + git: defaults.git.unwrap(), + git_overrides: Default::default(), + journal_defaults: defaults.journal, + journal_overrides: Default::default(), + terminal_defaults: defaults.terminal, terminal_overrides: Default::default(), language_defaults: defaults.languages, language_overrides: Default::default(), @@ -290,7 +363,10 @@ impl Settings { } self.editor_overrides = data.editor; + self.git_overrides = data.git.unwrap_or_default(); + self.journal_overrides = data.journal; self.terminal_defaults.font_size = data.terminal.font_size; + self.terminal_overrides.copy_on_select = data.terminal.copy_on_select; self.terminal_overrides = data.terminal; self.language_overrides = data.languages; self.lsp = data.lsp; @@ -326,6 +402,10 @@ impl Settings { self.language_setting(language, |settings| settings.format_on_save.clone()) } + pub fn formatter(&self, language: Option<&str>) -> Formatter { + self.language_setting(language, |settings| settings.formatter.clone()) + } + pub fn enable_language_server(&self, language: Option<&str>) -> bool { self.language_setting(language, |settings| settings.enable_language_server) } @@ -341,6 +421,14 @@ impl Settings { .expect("missing default") } + pub fn git_gutter(&self) -> GitGutter { + self.git_overrides.git_gutter.unwrap_or_else(|| { + self.git + .git_gutter + .expect("git_gutter should be some by setting setup") + }) + } + #[cfg(any(test, feature = "test-support"))] pub fn test(cx: &gpui::AppContext) -> Settings { Settings { @@ -358,12 +446,17 @@ impl Settings { hard_tabs: Some(false), soft_wrap: Some(SoftWrap::None), preferred_line_length: Some(80), - format_on_save: Some(FormatOnSave::LanguageServer), + format_on_save: Some(FormatOnSave::On), + formatter: Some(Formatter::LanguageServer), enable_language_server: Some(true), }, editor_overrides: Default::default(), + journal_defaults: Default::default(), + journal_overrides: Default::default(), terminal_defaults: Default::default(), terminal_overrides: Default::default(), + git: Default::default(), + git_overrides: Default::default(), language_defaults: Default::default(), language_overrides: Default::default(), lsp: Default::default(), @@ -448,6 +541,103 @@ pub fn settings_file_json_schema( serde_json::to_value(root_schema).unwrap() } +/// Expects the key to be unquoted, and the value to be valid JSON +/// (e.g. values should be unquoted for numbers and bools, quoted for strings) +pub fn write_top_level_setting( + mut settings_content: String, + top_level_key: &str, + new_val: &str, +) -> String { + let mut parser = tree_sitter::Parser::new(); + parser.set_language(tree_sitter_json::language()).unwrap(); + let tree = parser.parse(&settings_content, None).unwrap(); + + let mut cursor = tree_sitter::QueryCursor::new(); + + let query = Query::new( + tree_sitter_json::language(), + " + (document + (object + (pair + key: (string) @key + value: (_) @value))) + ", + ) + .unwrap(); + + let mut first_key_start = None; + let mut existing_value_range = None; + let matches = cursor.matches(&query, tree.root_node(), settings_content.as_bytes()); + for mat in matches { + if mat.captures.len() != 2 { + continue; + } + + let key = mat.captures[0]; + let value = mat.captures[1]; + + first_key_start.get_or_insert_with(|| key.node.start_byte()); + + if let Some(key_text) = settings_content.get(key.node.byte_range()) { + if key_text == format!("\"{top_level_key}\"") { + existing_value_range = Some(value.node.byte_range()); + break; + } + } + } + + match (first_key_start, existing_value_range) { + (None, None) => { + // No document, create a new object and overwrite + settings_content.clear(); + write!( + settings_content, + "{{\n \"{}\": {new_val}\n}}\n", + top_level_key + ) + .unwrap(); + } + + (_, Some(existing_value_range)) => { + // Existing theme key, overwrite + settings_content.replace_range(existing_value_range, &new_val); + } + + (Some(first_key_start), None) => { + // No existing theme key, but other settings. Prepend new theme settings and + // match style of first key + let mut row = 0; + let mut column = 0; + for (ix, char) in settings_content.char_indices() { + if ix == first_key_start { + break; + } + if char == '\n' { + row += 1; + column = 0; + } else { + column += char.len_utf8(); + } + } + + let content = format!(r#""{top_level_key}": {new_val},"#); + settings_content.insert_str(first_key_start, &content); + + if row > 0 { + settings_content.insert_str( + first_key_start + content.len(), + &format!("\n{:width$}", ' ', width = column), + ) + } else { + settings_content.insert_str(first_key_start + content.len(), " ") + } + } + } + + settings_content +} + fn merge(target: &mut T, value: Option) { if let Some(value) = value { *target = value; @@ -459,3 +649,114 @@ pub fn parse_json_with_comments(content: &str) -> Result json_comments::CommentSettings::c_style().strip_comments(content.as_bytes()), )?) } + +#[cfg(test)] +mod tests { + use crate::write_top_level_setting; + use unindent::Unindent; + + #[test] + fn test_write_theme_into_settings_with_theme() { + let settings = r#" + { + "theme": "one-dark" + } + "# + .unindent(); + + let new_settings = r#" + { + "theme": "summerfruit-light" + } + "# + .unindent(); + + let settings_after_theme = + write_top_level_setting(settings, "theme", "\"summerfruit-light\""); + + assert_eq!(settings_after_theme, new_settings) + } + + #[test] + fn test_write_theme_into_empty_settings() { + let settings = r#" + { + } + "# + .unindent(); + + let new_settings = r#" + { + "theme": "summerfruit-light" + } + "# + .unindent(); + + let settings_after_theme = + write_top_level_setting(settings, "theme", "\"summerfruit-light\""); + + assert_eq!(settings_after_theme, new_settings) + } + + #[test] + fn test_write_theme_into_no_settings() { + let settings = "".to_string(); + + let new_settings = r#" + { + "theme": "summerfruit-light" + } + "# + .unindent(); + + let settings_after_theme = + write_top_level_setting(settings, "theme", "\"summerfruit-light\""); + + assert_eq!(settings_after_theme, new_settings) + } + + #[test] + fn test_write_theme_into_single_line_settings_without_theme() { + let settings = r#"{ "a": "", "ok": true }"#.to_string(); + let new_settings = r#"{ "theme": "summerfruit-light", "a": "", "ok": true }"#; + + let settings_after_theme = + write_top_level_setting(settings, "theme", "\"summerfruit-light\""); + + assert_eq!(settings_after_theme, new_settings) + } + + #[test] + fn test_write_theme_pre_object_whitespace() { + let settings = r#" { "a": "", "ok": true }"#.to_string(); + let new_settings = r#" { "theme": "summerfruit-light", "a": "", "ok": true }"#; + + let settings_after_theme = + write_top_level_setting(settings, "theme", "\"summerfruit-light\""); + + assert_eq!(settings_after_theme, new_settings) + } + + #[test] + fn test_write_theme_into_multi_line_settings_without_theme() { + let settings = r#" + { + "a": "b" + } + "# + .unindent(); + + let new_settings = r#" + { + "theme": "summerfruit-light", + "a": "b" + } + "# + .unindent(); + + let settings_after_theme = + write_top_level_setting(settings, "theme", "\"summerfruit-light\""); + + assert_eq!(settings_after_theme, new_settings) + } +} diff --git a/crates/zed/src/settings_file.rs b/crates/settings/src/settings_file.rs similarity index 56% rename from crates/zed/src/settings_file.rs rename to crates/settings/src/settings_file.rs index 14c9f63e95..506ebc8c3d 100644 --- a/crates/zed/src/settings_file.rs +++ b/crates/settings/src/settings_file.rs @@ -1,108 +1,96 @@ -use futures::StreamExt; -use gpui::{executor, MutableAppContext}; -use postage::sink::Sink as _; -use postage::{prelude::Stream, watch}; -use project::Fs; -use serde::Deserialize; -use settings::{parse_json_with_comments, KeymapFileContent, Settings, SettingsFileContent}; -use std::{path::Path, sync::Arc, time::Duration}; -use theme::ThemeRegistry; -use util::ResultExt; +use crate::{watched_json::WatchedJsonFile, write_top_level_setting, SettingsFileContent}; +use anyhow::Result; +use fs::Fs; +use gpui::MutableAppContext; +use serde_json::Value; +use std::{path::Path, sync::Arc}; +// TODO: Switch SettingsFile to open a worktree and buffer for synchronization +// And instant updates in the Zed editor #[derive(Clone)] -pub struct WatchedJsonFile(pub watch::Receiver); +pub struct SettingsFile { + path: &'static Path, + settings_file_content: WatchedJsonFile, + fs: Arc, +} -impl WatchedJsonFile -where - T: 'static + for<'de> Deserialize<'de> + Clone + Default + Send + Sync, -{ - pub async fn new( +impl SettingsFile { + pub fn new( + path: &'static Path, + settings_file_content: WatchedJsonFile, fs: Arc, - executor: &executor::Background, - path: impl Into>, ) -> Self { - let path = path.into(); - let settings = Self::load(fs.clone(), &path).await.unwrap_or_default(); - let mut events = fs.watch(&path, Duration::from_millis(500)).await; - let (mut tx, rx) = watch::channel_with(settings); - executor + SettingsFile { + path, + settings_file_content, + fs, + } + } + + pub fn update(cx: &mut MutableAppContext, update: impl FnOnce(&mut SettingsFileContent)) { + let this = cx.global::(); + + let current_file_content = this.settings_file_content.current(); + let mut new_file_content = current_file_content.clone(); + + update(&mut new_file_content); + + let fs = this.fs.clone(); + let path = this.path.clone(); + + cx.background() .spawn(async move { - while events.next().await.is_some() { - if let Some(settings) = Self::load(fs.clone(), &path).await { - if tx.send(settings).await.is_err() { - break; + // Unwrap safety: These values are all guarnteed to be well formed, and we know + // that they will deserialize to our settings object. All of the following unwraps + // are therefore safe. + let tmp = serde_json::to_value(current_file_content).unwrap(); + let old_json = tmp.as_object().unwrap(); + + let new_tmp = serde_json::to_value(new_file_content).unwrap(); + let new_json = new_tmp.as_object().unwrap(); + + // Find changed fields + let mut diffs = vec![]; + for (key, old_value) in old_json.iter() { + let new_value = new_json.get(key).unwrap(); + if old_value != new_value { + if matches!( + new_value, + &Value::Null | &Value::Object(_) | &Value::Array(_) + ) { + unimplemented!( + "We only support updating basic values at the top level" + ); } + + let new_json = serde_json::to_string_pretty(new_value) + .expect("Could not serialize new json field to string"); + + diffs.push((key, new_json)); } } + + // Have diffs, rewrite the settings file now. + let mut content = fs.load(path).await?; + + for (key, new_value) in diffs { + content = write_top_level_setting(content, key, &new_value) + } + + fs.atomic_write(path.to_path_buf(), content).await?; + + Ok(()) as Result<()> }) - .detach(); - Self(rx) + .detach_and_log_err(cx); } - - ///Loads the given watched JSON file. In the special case that the file is - ///empty (ignoring whitespace) or is not a file, this will return T::default() - async fn load(fs: Arc, path: &Path) -> Option { - if !fs.is_file(path).await { - return Some(T::default()); - } - - fs.load(path).await.log_err().and_then(|data| { - if data.trim().is_empty() { - Some(T::default()) - } else { - parse_json_with_comments(&data).log_err() - } - }) - } -} - -pub fn watch_settings_file( - defaults: Settings, - mut file: WatchedJsonFile, - theme_registry: Arc, - cx: &mut MutableAppContext, -) { - settings_updated(&defaults, file.0.borrow().clone(), &theme_registry, cx); - cx.spawn(|mut cx| async move { - while let Some(content) = file.0.recv().await { - cx.update(|cx| settings_updated(&defaults, content, &theme_registry, cx)); - } - }) - .detach(); -} - -pub fn keymap_updated(content: KeymapFileContent, cx: &mut MutableAppContext) { - cx.clear_bindings(); - settings::KeymapFileContent::load_defaults(cx); - content.add_to_cx(cx).log_err(); -} - -pub fn settings_updated( - defaults: &Settings, - content: SettingsFileContent, - theme_registry: &Arc, - cx: &mut MutableAppContext, -) { - let mut settings = defaults.clone(); - settings.set_user_settings(content, theme_registry, cx.font_cache()); - cx.set_global(settings); - cx.refresh_windows(); -} - -pub fn watch_keymap_file(mut file: WatchedJsonFile, cx: &mut MutableAppContext) { - cx.spawn(|mut cx| async move { - while let Some(content) = file.0.recv().await { - cx.update(|cx| keymap_updated(content, cx)); - } - }) - .detach(); } #[cfg(test)] mod tests { use super::*; - use project::FakeFs; - use settings::{EditorSettings, SoftWrap}; + use crate::{watched_json::watch_settings_file, EditorSettings, Settings, SoftWrap}; + use fs::FakeFs; + use theme::ThemeRegistry; #[gpui::test] async fn test_watch_settings_files(cx: &mut gpui::TestAppContext) { diff --git a/crates/settings/src/watched_json.rs b/crates/settings/src/watched_json.rs new file mode 100644 index 0000000000..e304842aa2 --- /dev/null +++ b/crates/settings/src/watched_json.rs @@ -0,0 +1,105 @@ +use fs::Fs; +use futures::StreamExt; +use gpui::{executor, MutableAppContext}; +use postage::sink::Sink as _; +use postage::{prelude::Stream, watch}; +use serde::Deserialize; + +use std::{path::Path, sync::Arc, time::Duration}; +use theme::ThemeRegistry; +use util::ResultExt; + +use crate::{parse_json_with_comments, KeymapFileContent, Settings, SettingsFileContent}; + +#[derive(Clone)] +pub struct WatchedJsonFile(pub watch::Receiver); + +impl WatchedJsonFile +where + T: 'static + for<'de> Deserialize<'de> + Clone + Default + Send + Sync, +{ + pub async fn new( + fs: Arc, + executor: &executor::Background, + path: impl Into>, + ) -> Self { + let path = path.into(); + let settings = Self::load(fs.clone(), &path).await.unwrap_or_default(); + let mut events = fs.watch(&path, Duration::from_millis(500)).await; + let (mut tx, rx) = watch::channel_with(settings); + executor + .spawn(async move { + while events.next().await.is_some() { + if let Some(settings) = Self::load(fs.clone(), &path).await { + if tx.send(settings).await.is_err() { + break; + } + } + } + }) + .detach(); + Self(rx) + } + + ///Loads the given watched JSON file. In the special case that the file is + ///empty (ignoring whitespace) or is not a file, this will return T::default() + async fn load(fs: Arc, path: &Path) -> Option { + if !fs.is_file(path).await { + return Some(T::default()); + } + + fs.load(path).await.log_err().and_then(|data| { + if data.trim().is_empty() { + Some(T::default()) + } else { + parse_json_with_comments(&data).log_err() + } + }) + } + + pub fn current(&self) -> T { + self.0.borrow().clone() + } +} + +pub fn watch_settings_file( + defaults: Settings, + mut file: WatchedJsonFile, + theme_registry: Arc, + cx: &mut MutableAppContext, +) { + settings_updated(&defaults, file.0.borrow().clone(), &theme_registry, cx); + cx.spawn(|mut cx| async move { + while let Some(content) = file.0.recv().await { + cx.update(|cx| settings_updated(&defaults, content, &theme_registry, cx)); + } + }) + .detach(); +} + +pub fn keymap_updated(content: KeymapFileContent, cx: &mut MutableAppContext) { + cx.clear_bindings(); + KeymapFileContent::load_defaults(cx); + content.add_to_cx(cx).log_err(); +} + +pub fn settings_updated( + defaults: &Settings, + content: SettingsFileContent, + theme_registry: &Arc, + cx: &mut MutableAppContext, +) { + let mut settings = defaults.clone(); + settings.set_user_settings(content, theme_registry, cx.font_cache()); + cx.set_global(settings); + cx.refresh_windows(); +} + +pub fn watch_keymap_file(mut file: WatchedJsonFile, cx: &mut MutableAppContext) { + cx.spawn(|mut cx| async move { + while let Some(content) = file.0.recv().await { + cx.update(|cx| keymap_updated(content, cx)); + } + }) + .detach(); +} diff --git a/crates/sum_tree/src/sum_tree.rs b/crates/sum_tree/src/sum_tree.rs index cb05dff967..7beab3b7c5 100644 --- a/crates/sum_tree/src/sum_tree.rs +++ b/crates/sum_tree/src/sum_tree.rs @@ -101,6 +101,12 @@ pub enum Bias { Right, } +impl Default for Bias { + fn default() -> Self { + Bias::Left + } +} + impl PartialOrd for Bias { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index e81ff413b1..61dcc60b60 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -29,9 +29,14 @@ shellexpand = "2.1.0" libc = "0.2" anyhow = "1" thiserror = "1.0" +lazy_static = "1.4.0" +serde = { version = "1.0", features = ["derive"] } + + [dev-dependencies] gpui = { path = "../gpui", features = ["test-support"] } client = { path = "../client", features = ["test-support"]} project = { path = "../project", features = ["test-support"]} workspace = { path = "../workspace", features = ["test-support"] } +rand = "0.8.5" diff --git a/crates/terminal/src/mappings/mouse.rs b/crates/terminal/src/mappings/mouse.rs index 7d92036b71..1616540cff 100644 --- a/crates/terminal/src/mappings/mouse.rs +++ b/crates/terminal/src/mappings/mouse.rs @@ -202,7 +202,7 @@ pub fn mouse_side(pos: Vector2F, cur_size: TerminalSize) -> alacritty_terminal:: } } -pub fn mouse_point(pos: Vector2F, cur_size: TerminalSize, display_offset: usize) -> Point { +pub fn grid_point(pos: Vector2F, cur_size: TerminalSize, display_offset: usize) -> Point { let col = pos.x() / cur_size.cell_width; let col = min(GridCol(col as usize), cur_size.last_column()); let line = pos.y() / cur_size.line_height; @@ -295,7 +295,7 @@ fn sgr_mouse_report(point: Point, button: u8, pressed: bool) -> String { #[cfg(test)] mod test { - use crate::mappings::mouse::mouse_point; + use crate::mappings::mouse::grid_point; #[test] fn test_mouse_to_selection() { @@ -317,7 +317,7 @@ mod test { let mouse_pos = gpui::geometry::vector::vec2f(mouse_pos_x, mouse_pos_y); let origin = gpui::geometry::vector::vec2f(origin_x, origin_y); //Position of terminal window, 1 'cell' in let mouse_pos = mouse_pos - origin; - let point = mouse_point(mouse_pos, cur_size, 0); + let point = grid_point(mouse_pos, cur_size, 0); assert_eq!( point, alacritty_terminal::index::Point::new( diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 3ee092896d..5715d4b152 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -29,18 +29,22 @@ use futures::{ }; use mappings::mouse::{ - alt_scroll, mouse_button_report, mouse_moved_report, mouse_point, mouse_side, scroll_report, + alt_scroll, grid_point, mouse_button_report, mouse_moved_report, mouse_side, scroll_report, }; use procinfo::LocalProcessInfo; use settings::{AlternateScroll, Settings, Shell, TerminalBlink}; +use util::ResultExt; use std::{ + cmp::min, collections::{HashMap, VecDeque}, fmt::Display, - ops::{Deref, RangeInclusive, Sub}, - os::unix::prelude::AsRawFd, + io, + ops::{Deref, Index, RangeInclusive, Sub}, + os::unix::{prelude::AsRawFd, process::CommandExt}, path::PathBuf, + process::Command, sync::Arc, time::{Duration, Instant}, }; @@ -49,9 +53,7 @@ use thiserror::Error; use gpui::{ geometry::vector::{vec2f, Vector2F}, keymap::Keystroke, - scene::{ - ClickRegionEvent, DownRegionEvent, DragRegionEvent, ScrollWheelRegionEvent, UpRegionEvent, - }, + scene::{DownRegionEvent, DragRegionEvent, ScrollWheelRegionEvent, UpRegionEvent}, ClipboardItem, Entity, ModelContext, MouseButton, MouseMovedEvent, MutableAppContext, Task, }; @@ -59,6 +61,7 @@ use crate::mappings::{ colors::{get_color_at_index, to_alac_rgb}, keys::to_esc_str, }; +use lazy_static::lazy_static; ///Initialize and register all of our action handlers pub fn init(cx: &mut MutableAppContext) { @@ -70,12 +73,18 @@ pub fn init(cx: &mut MutableAppContext) { ///Scroll multiplier that is set to 3 by default. This will be removed when I ///Implement scroll bars. const SCROLL_MULTIPLIER: f32 = 4.; -// const MAX_SEARCH_LINES: usize = 100; +const MAX_SEARCH_LINES: usize = 100; const DEBUG_TERMINAL_WIDTH: f32 = 500.; const DEBUG_TERMINAL_HEIGHT: f32 = 30.; const DEBUG_CELL_WIDTH: f32 = 5.; const DEBUG_LINE_HEIGHT: f32 = 5.; +// Regex Copied from alacritty's ui_config.rs + +lazy_static! { + static ref URL_REGEX: RegexSearch = RegexSearch::new("(ipfs:|ipns:|magnet:|mailto:|gemini:|gopher:|https:|http:|news:|file:|git:|ssh:|ftp:)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`]+").unwrap(); +} + ///Upward flowing events, for changing the title and such #[derive(Clone, Copy, Debug)] pub enum Event { @@ -98,6 +107,8 @@ enum InternalEvent { ScrollToPoint(Point), SetSelection(Option<(Selection, Point)>), UpdateSelection(Vector2F), + // Adjusted mouse position, should open + FindHyperlink(Vector2F, bool), Copy, } @@ -267,7 +278,6 @@ impl TerminalBuilder { working_directory: Option, shell: Option, env: Option>, - initial_size: TerminalSize, blink_settings: Option, alternate_scroll: &AlternateScroll, window_id: usize, @@ -307,7 +317,11 @@ impl TerminalBuilder { //TODO: Remove with a bounded sender which can be dispatched on &self let (events_tx, events_rx) = unbounded(); //Set up the terminal... - let mut term = Term::new(&config, &initial_size, ZedListener(events_tx.clone())); + let mut term = Term::new( + &config, + &TerminalSize::default(), + ZedListener(events_tx.clone()), + ); //Start off blinking if we need to if let Some(TerminalBlink::On) = blink_settings { @@ -322,7 +336,11 @@ impl TerminalBuilder { let term = Arc::new(FairMutex::new(term)); //Setup the pty... - let pty = match tty::new(&pty_config, initial_size.into(), window_id as u64) { + let pty = match tty::new( + &pty_config, + TerminalSize::default().into(), + window_id as u64, + ) { Ok(pty) => pty, Err(error) => { bail!(TerminalError { @@ -354,7 +372,6 @@ impl TerminalBuilder { term, events: VecDeque::with_capacity(10), //Should never get this high. last_content: Default::default(), - cur_size: initial_size, last_mouse: None, matches: Vec::new(), last_synced: Instant::now(), @@ -365,6 +382,9 @@ impl TerminalBuilder { foreground_process_info: None, breadcrumb_text: String::new(), scroll_px: 0., + last_mouse_position: None, + next_link_id: 0, + selection_phase: SelectionPhase::Ended, }; Ok(TerminalBuilder { @@ -450,6 +470,8 @@ pub struct TerminalContent { selection: Option, cursor: RenderableCursor, cursor_char: char, + size: TerminalSize, + last_hovered_hyperlink: Option<(String, RangeInclusive, usize)>, } impl Default for TerminalContent { @@ -465,17 +487,27 @@ impl Default for TerminalContent { point: Point::new(Line(0), Column(0)), }, cursor_char: Default::default(), + size: Default::default(), + last_hovered_hyperlink: None, } } } +#[derive(PartialEq, Eq)] +pub enum SelectionPhase { + Selecting, + Ended, +} + pub struct Terminal { pty_tx: Notifier, term: Arc>>, events: VecDeque, + /// This is only used for mouse mode cell change detection last_mouse: Option<(Point, AlacDirection)>, + /// This is only used for terminal hyperlink checking + last_mouse_position: Option, pub matches: Vec>, - cur_size: TerminalSize, last_content: TerminalContent, last_synced: Instant, sync_task: Option>, @@ -485,6 +517,8 @@ pub struct Terminal { shell_fd: u32, foreground_process_info: Option, scroll_px: f32, + next_link_id: usize, + selection_phase: SelectionPhase, } impl Terminal { @@ -508,7 +542,7 @@ impl Terminal { )), AlacTermEvent::PtyWrite(out) => self.write_to_pty(out.clone()), AlacTermEvent::TextAreaSizeRequest(format) => { - self.write_to_pty(format(self.cur_size.into())) + self.write_to_pty(format(self.last_content.size.into())) } AlacTermEvent::CursorBlinkingChange => { cx.emit(Event::BlinkChanged); @@ -577,24 +611,45 @@ impl Terminal { new_size.height = f32::max(new_size.line_height, new_size.height); new_size.width = f32::max(new_size.cell_width, new_size.width); - self.cur_size = new_size.clone(); + self.last_content.size = new_size.clone(); self.pty_tx.0.send(Msg::Resize((new_size).into())).ok(); - // When this resize happens - // We go from 737px -> 703px height - // This means there is 1 less line - // that means the delta is 1 - // That means the selection is rotated by -1 - term.resize(new_size); } InternalEvent::Clear => { - self.write_to_pty("\x0c".to_string()); + // Clear back buffer term.clear_screen(ClearMode::Saved); + + let cursor = term.grid().cursor.point; + + // Clear the lines above + term.grid_mut().reset_region(..cursor.line); + + // Copy the current line up + let line = term.grid()[cursor.line][..cursor.column] + .iter() + .cloned() + .enumerate() + .collect::>(); + + for (i, cell) in line { + term.grid_mut()[Line(0)][Column(i)] = cell; + } + + // Reset the cursor + term.grid_mut().cursor.point = + Point::new(Line(0), term.grid_mut().cursor.point.column); + let new_cursor = term.grid().cursor.point; + + // Clear the lines below the new cursor + if (new_cursor.line.0 as usize) < term.screen_lines() - 1 { + term.grid_mut().reset_region((new_cursor.line + 1)..); + } } InternalEvent::Scroll(scroll) => { term.scroll_display(*scroll); + self.refresh_hyperlink(); } InternalEvent::SetSelection(selection) => { term.selection = selection.as_ref().map(|(sel, _)| sel.clone()); @@ -606,8 +661,12 @@ impl Terminal { } InternalEvent::UpdateSelection(position) => { if let Some(mut selection) = term.selection.take() { - let point = mouse_point(*position, self.cur_size, term.grid().display_offset()); - let side = mouse_side(*position, self.cur_size); + let point = grid_point( + *position, + self.last_content.size, + term.grid().display_offset(), + ); + let side = mouse_side(*position, self.last_content.size); selection.update(point, side); term.selection = Some(selection); @@ -622,10 +681,95 @@ impl Terminal { cx.write_to_clipboard(ClipboardItem::new(txt)) } } - InternalEvent::ScrollToPoint(point) => term.scroll_to_point(*point), + InternalEvent::ScrollToPoint(point) => { + term.scroll_to_point(*point); + self.refresh_hyperlink(); + } + InternalEvent::FindHyperlink(position, open) => { + let prev_hyperlink = self.last_content.last_hovered_hyperlink.take(); + + let point = grid_point( + *position, + self.last_content.size, + term.grid().display_offset(), + ) + .grid_clamp(term, alacritty_terminal::index::Boundary::Cursor); + + let link = term.grid().index(point).hyperlink(); + let found_url = if link.is_some() { + let mut min_index = point; + loop { + let new_min_index = + min_index.sub(term, alacritty_terminal::index::Boundary::Cursor, 1); + if new_min_index == min_index { + break; + } else if term.grid().index(new_min_index).hyperlink() != link { + break; + } else { + min_index = new_min_index + } + } + + let mut max_index = point; + loop { + let new_max_index = + max_index.add(term, alacritty_terminal::index::Boundary::Cursor, 1); + if new_max_index == max_index { + break; + } else if term.grid().index(new_max_index).hyperlink() != link { + break; + } else { + max_index = new_max_index + } + } + + let url = link.unwrap().uri().to_owned(); + let url_match = min_index..=max_index; + + Some((url, url_match)) + } else if let Some(url_match) = regex_match_at(term, point, &URL_REGEX) { + let url = term.bounds_to_string(*url_match.start(), *url_match.end()); + + Some((url, url_match)) + } else { + None + }; + + if let Some((url, url_match)) = found_url { + if *open { + open_uri(&url).log_err(); + } else { + self.update_hyperlink(prev_hyperlink, url, url_match); + } + } + } } } + fn update_hyperlink( + &mut self, + prev_hyperlink: Option<(String, RangeInclusive, usize)>, + url: String, + url_match: RangeInclusive, + ) { + if let Some(prev_hyperlink) = prev_hyperlink { + if prev_hyperlink.0 == url && prev_hyperlink.1 == url_match { + self.last_content.last_hovered_hyperlink = Some((url, url_match, prev_hyperlink.2)); + } else { + self.last_content.last_hovered_hyperlink = + Some((url, url_match, self.next_link_id())); + } + } else { + self.last_content.last_hovered_hyperlink = Some((url, url_match, self.next_link_id())); + } + } + + fn next_link_id(&mut self) -> usize { + let res = self.next_link_id; + self.next_link_id = self.next_link_id.wrapping_add(1); + res + } + pub fn last_content(&self) -> &TerminalContent { &self.last_content } @@ -691,7 +835,8 @@ impl Terminal { } else { text.replace("\r\n", "\r").replace('\n', "\r") }; - self.input(paste_text) + + self.input(paste_text); } pub fn try_sync(&mut self, cx: &mut ModelContext) { @@ -730,11 +875,11 @@ impl Terminal { self.process_terminal_event(&e, &mut terminal, cx) } - self.last_content = Self::make_content(&terminal); + self.last_content = Self::make_content(&terminal, &self.last_content); self.last_synced = Instant::now(); } - fn make_content(term: &Term) -> TerminalContent { + fn make_content(term: &Term, last_content: &TerminalContent) -> TerminalContent { let content = term.renderable_content(); TerminalContent { cells: content @@ -757,6 +902,8 @@ impl Terminal { selection: content.selection, cursor: content.cursor, cursor_char: term.grid()[content.cursor.point].c, + size: last_content.size, + last_hovered_hyperlink: last_content.last_hovered_hyperlink.clone(), } } @@ -766,7 +913,8 @@ impl Terminal { } } - pub fn focus_out(&self) { + pub fn focus_out(&mut self) { + self.last_mouse_position = None; if self.last_content.mode.contains(TermMode::FOCUS_IN_OUT) { self.write_to_pty("\x1b[O".to_string()); } @@ -795,21 +943,40 @@ impl Terminal { pub fn mouse_move(&mut self, e: &MouseMovedEvent, origin: Vector2F) { let position = e.position.sub(origin); + self.last_mouse_position = Some(position); + if self.mouse_mode(e.shift) { + let point = grid_point( + position, + self.last_content.size, + self.last_content.display_offset, + ); + let side = mouse_side(position, self.last_content.size); - let point = mouse_point(position, self.cur_size, self.last_content.display_offset); - let side = mouse_side(position, self.cur_size); - - if self.mouse_changed(point, side) && self.mouse_mode(e.shift) { - if let Some(bytes) = mouse_moved_report(point, e, self.last_content.mode) { - self.pty_tx.notify(bytes); + if self.mouse_changed(point, side) { + if let Some(bytes) = mouse_moved_report(point, e, self.last_content.mode) { + self.pty_tx.notify(bytes); + } } + } else { + self.hyperlink_from_position(Some(position)); + } + } + + fn hyperlink_from_position(&mut self, position: Option) { + if self.selection_phase == SelectionPhase::Selecting { + self.last_content.last_hovered_hyperlink = None; + } else if let Some(position) = position { + self.events + .push_back(InternalEvent::FindHyperlink(position, false)); } } pub fn mouse_drag(&mut self, e: DragRegionEvent, origin: Vector2F) { let position = e.position.sub(origin); + self.last_mouse_position = Some(position); if !self.mouse_mode(e.shift) { + self.selection_phase = SelectionPhase::Selecting; // Alacritty has the same ordering, of first updating the selection // then scrolling 15ms later self.events @@ -822,20 +989,18 @@ impl Terminal { None => return, }; - let scroll_lines = (scroll_delta / self.cur_size.line_height) as i32; + let scroll_lines = (scroll_delta / self.last_content.size.line_height) as i32; self.events .push_back(InternalEvent::Scroll(AlacScroll::Delta(scroll_lines))); - self.events - .push_back(InternalEvent::UpdateSelection(position)) } } } fn drag_line_delta(&mut self, e: DragRegionEvent) -> Option { //TODO: Why do these need to be doubled? Probably the same problem that the IME has - let top = e.region.origin_y() + (self.cur_size.line_height * 2.); - let bottom = e.region.lower_left().y() - (self.cur_size.line_height * 2.); + let top = e.region.origin_y() + (self.last_content.size.line_height * 2.); + let bottom = e.region.lower_left().y() - (self.last_content.size.line_height * 2.); let scroll_delta = if e.position.y() < top { (top - e.position.y()).powf(1.1) } else if e.position.y() > bottom { @@ -848,27 +1013,24 @@ impl Terminal { pub fn mouse_down(&mut self, e: &DownRegionEvent, origin: Vector2F) { let position = e.position.sub(origin); - let point = mouse_point(position, self.cur_size, self.last_content.display_offset); - let side = mouse_side(position, self.cur_size); + let point = grid_point( + position, + self.last_content.size, + self.last_content.display_offset, + ); if self.mouse_mode(e.shift) { if let Some(bytes) = mouse_button_report(point, e, true, self.last_content.mode) { self.pty_tx.notify(bytes); } } else if e.button == MouseButton::Left { - self.events.push_back(InternalEvent::SetSelection(Some(( - Selection::new(SelectionType::Simple, point, side), - point, - )))); - } - } - - pub fn left_click(&mut self, e: &ClickRegionEvent, origin: Vector2F) { - let position = e.position.sub(origin); - - if !self.mouse_mode(e.shift) { - let point = mouse_point(position, self.cur_size, self.last_content.display_offset); - let side = mouse_side(position, self.cur_size); + let position = e.position.sub(origin); + let point = grid_point( + position, + self.last_content.size, + self.last_content.display_offset, + ); + let side = mouse_side(position, self.last_content.size); let selection_type = match e.click_count { 0 => return, //This is a release @@ -888,19 +1050,47 @@ impl Terminal { } } - pub fn mouse_up(&mut self, e: &UpRegionEvent, origin: Vector2F) { + pub fn mouse_up(&mut self, e: &UpRegionEvent, origin: Vector2F, cx: &mut ModelContext) { + let settings = cx.global::(); + let copy_on_select = settings + .terminal_overrides + .copy_on_select + .unwrap_or_else(|| { + settings + .terminal_defaults + .copy_on_select + .expect("Should be set in defaults") + }); + let position = e.position.sub(origin); if self.mouse_mode(e.shift) { - let point = mouse_point(position, self.cur_size, self.last_content.display_offset); + let point = grid_point( + position, + self.last_content.size, + self.last_content.display_offset, + ); if let Some(bytes) = mouse_button_report(point, e, false, self.last_content.mode) { self.pty_tx.notify(bytes); } - } else if e.button == MouseButton::Left { - // Seems pretty standard to automatically copy on mouse_up for terminals, - // so let's do that here - self.copy(); + } else { + if e.button == MouseButton::Left && copy_on_select { + self.copy(); + } + + //Hyperlinks + if self.selection_phase == SelectionPhase::Ended { + let mouse_cell_index = content_index_for_mouse(position, &self.last_content); + if let Some(link) = self.last_content.cells[mouse_cell_index].hyperlink() { + open_uri(link.uri()).log_err(); + } else { + self.events + .push_back(InternalEvent::FindHyperlink(position, true)); + } + } } + + self.selection_phase = SelectionPhase::Ended; self.last_mouse = None; } @@ -910,9 +1100,9 @@ impl Terminal { if let Some(scroll_lines) = self.determine_scroll_lines(&e, mouse_mode) { if mouse_mode { - let point = mouse_point( + let point = grid_point( e.position.sub(origin), - self.cur_size, + self.last_content.size, self.last_content.display_offset, ); @@ -940,6 +1130,10 @@ impl Terminal { } } + pub fn refresh_hyperlink(&mut self) { + self.hyperlink_from_position(self.last_mouse_position); + } + fn determine_scroll_lines( &mut self, e: &ScrollWheelRegionEvent, @@ -955,20 +1149,22 @@ impl Terminal { } /* Calculate the appropriate scroll lines */ Some(gpui::TouchPhase::Moved) => { - let old_offset = (self.scroll_px / self.cur_size.line_height) as i32; + let old_offset = (self.scroll_px / self.last_content.size.line_height) as i32; self.scroll_px += e.delta.y() * scroll_multiplier; - let new_offset = (self.scroll_px / self.cur_size.line_height) as i32; + let new_offset = (self.scroll_px / self.last_content.size.line_height) as i32; // Whenever we hit the edges, reset our stored scroll to 0 // so we can respond to changes in direction quickly - self.scroll_px %= self.cur_size.height; + self.scroll_px %= self.last_content.size.height; Some(new_offset - old_offset) } /* Fall back to delta / line_height */ - None => Some(((e.delta.y() * scroll_multiplier) / self.cur_size.line_height) as i32), + None => Some( + ((e.delta.y() * scroll_multiplier) / self.last_content.size.line_height) as i32, + ), _ => None, } } @@ -1011,30 +1207,36 @@ impl Entity for Terminal { type Event = Event; } +/// Based on alacritty/src/display/hint.rs > regex_match_at +/// Retrieve the match, if the specified point is inside the content matching the regex. +fn regex_match_at(term: &Term, point: Point, regex: &RegexSearch) -> Option { + visible_regex_match_iter(term, regex).find(|rm| rm.contains(&point)) +} + +/// Copied from alacritty/src/display/hint.rs: +/// Iterate over all visible regex matches. +pub fn visible_regex_match_iter<'a, T>( + term: &'a Term, + regex: &'a RegexSearch, +) -> impl Iterator + 'a { + let viewport_start = Line(-(term.grid().display_offset() as i32)); + let viewport_end = viewport_start + term.bottommost_line(); + let mut start = term.line_search_left(Point::new(viewport_start, Column(0))); + let mut end = term.line_search_right(Point::new(viewport_end, Column(0))); + start.line = start.line.max(viewport_start - MAX_SEARCH_LINES); + end.line = end.line.min(viewport_end + MAX_SEARCH_LINES); + + RegexIter::new(start, end, AlacDirection::Right, term, regex) + .skip_while(move |rm| rm.end().line < viewport_start) + .take_while(move |rm| rm.start().line <= viewport_end) +} + fn make_selection(range: &RangeInclusive) -> Selection { let mut selection = Selection::new(SelectionType::Simple, *range.start(), AlacDirection::Left); selection.update(*range.end(), AlacDirection::Right); selection } -/// Copied from alacritty/src/display/hint.rs HintMatches::visible_regex_matches() -/// Iterate over all visible regex matches. -// fn visible_search_matches<'a, T>( -// term: &'a Term, -// regex: &'a RegexSearch, -// ) -> impl Iterator + 'a { -// let viewport_start = Line(-(term.grid().display_offset() as i32)); -// let viewport_end = viewport_start + term.bottommost_line(); -// let mut start = term.line_search_left(Point::new(viewport_start, Column(0))); -// let mut end = term.line_search_right(Point::new(viewport_end, Column(0))); -// start.line = start.line.max(viewport_start - MAX_SEARCH_LINES); -// end.line = end.line.min(viewport_end + MAX_SEARCH_LINES); - -// RegexIter::new(start, end, AlacDirection::Right, term, regex) -// .skip_while(move |rm| rm.end().line < viewport_start) -// .take_while(move |rm| rm.start().line <= viewport_end) -// } - fn all_search_matches<'a, T>( term: &'a Term, regex: &'a RegexSearch, @@ -1044,7 +1246,115 @@ fn all_search_matches<'a, T>( RegexIter::new(start, end, AlacDirection::Right, term, regex) } +fn content_index_for_mouse<'a>(pos: Vector2F, content: &'a TerminalContent) -> usize { + let col = min( + (pos.x() / content.size.cell_width()) as usize, + content.size.columns() - 1, + ) as usize; + let line = min( + (pos.y() / content.size.line_height()) as usize, + content.size.screen_lines() - 1, + ) as usize; + + line * content.size.columns() + col +} + +fn open_uri(uri: &str) -> Result<(), std::io::Error> { + let mut command = Command::new("open"); + command.arg(uri); + + unsafe { + command + .pre_exec(|| { + match libc::fork() { + -1 => return Err(io::Error::last_os_error()), + 0 => (), + _ => libc::_exit(0), + } + + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + + Ok(()) + }) + .spawn()? + .wait() + .map(|_| ()) + } +} + #[cfg(test)] mod tests { + use gpui::geometry::vector::vec2f; + use rand::{thread_rng, Rng}; + + use crate::content_index_for_mouse; + + use self::terminal_test_context::TerminalTestContext; + pub mod terminal_test_context; + + #[test] + fn test_mouse_to_cell() { + let mut rng = thread_rng(); + + for _ in 0..10 { + let viewport_cells = rng.gen_range(5..50); + let cell_size = rng.gen_range(5.0..20.0); + + let size = crate::TerminalSize { + cell_width: cell_size, + line_height: cell_size, + height: cell_size * (viewport_cells as f32), + width: cell_size * (viewport_cells as f32), + }; + + let (content, cells) = TerminalTestContext::create_terminal_content(size, &mut rng); + + for i in 0..(viewport_cells - 1) { + let i = i as usize; + for j in 0..(viewport_cells - 1) { + let j = j as usize; + let min_row = i as f32 * cell_size; + let max_row = (i + 1) as f32 * cell_size; + let min_col = j as f32 * cell_size; + let max_col = (j + 1) as f32 * cell_size; + + let mouse_pos = vec2f( + rng.gen_range(min_row..max_row), + rng.gen_range(min_col..max_col), + ); + + assert_eq!( + content.cells[content_index_for_mouse(mouse_pos, &content)].c, + cells[j][i] + ); + } + } + } + } + + #[test] + fn test_mouse_to_cell_clamp() { + let mut rng = thread_rng(); + + let size = crate::TerminalSize { + cell_width: 10., + line_height: 10., + height: 100., + width: 100., + }; + + let (content, cells) = TerminalTestContext::create_terminal_content(size, &mut rng); + + assert_eq!( + content.cells[content_index_for_mouse(vec2f(-10., -10.), &content)].c, + cells[0][0] + ); + assert_eq!( + content.cells[content_index_for_mouse(vec2f(1000., 1000.), &content)].c, + cells[9][9] + ); + } } diff --git a/crates/terminal/src/terminal_container_view.rs b/crates/terminal/src/terminal_container_view.rs index 9cf26ac743..6621975285 100644 --- a/crates/terminal/src/terminal_container_view.rs +++ b/crates/terminal/src/terminal_container_view.rs @@ -11,7 +11,6 @@ use util::truncate_and_trailoff; use workspace::searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle}; use workspace::{Item, ItemEvent, ToolbarItemLocation, Workspace}; -use crate::TerminalSize; use project::{LocalWorktree, Project, ProjectPath}; use settings::{AlternateScroll, Settings, WorkingDirectory}; use smallvec::SmallVec; @@ -86,9 +85,6 @@ impl TerminalContainer { modal: bool, cx: &mut ViewContext, ) -> Self { - //The exact size here doesn't matter, the terminal will be resized on the first layout - let size_info = TerminalSize::default(); - let settings = cx.global::(); let shell = settings.terminal_overrides.shell.clone(); let envs = settings.terminal_overrides.env.clone(); //Should be short and cheap. @@ -110,7 +106,6 @@ impl TerminalContainer { working_directory.clone(), shell, envs, - size_info, settings.terminal_overrides.blinking.clone(), scroll, cx.window_id(), @@ -162,10 +157,10 @@ impl View for TerminalContainer { "Terminal" } - fn render(&mut self, _cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox { + fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox { match &self.content { - TerminalContainerContent::Connected(connected) => ChildView::new(connected), - TerminalContainerContent::Error(error) => ChildView::new(error), + TerminalContainerContent::Connected(connected) => ChildView::new(connected, cx), + TerminalContainerContent::Error(error) => ChildView::new(error, cx), } .boxed() } diff --git a/crates/terminal/src/terminal_element.rs b/crates/terminal/src/terminal_element.rs index 7cd123b19a..f14b49b6c4 100644 --- a/crates/terminal/src/terminal_element.rs +++ b/crates/terminal/src/terminal_element.rs @@ -7,15 +7,17 @@ use alacritty_terminal::{ use editor::{Cursor, CursorShape, HighlightedRange, HighlightedRangeLine}; use gpui::{ color::Color, - fonts::{Properties, Style::Italic, TextStyle, Underline, Weight}, + elements::{Empty, Overlay}, + fonts::{HighlightStyle, Properties, Style::Italic, TextStyle, Underline, Weight}, geometry::{ rect::RectF, vector::{vec2f, Vector2F}, }, serde_json::json, text_layout::{Line, RunStyle}, - Element, Event, EventContext, FontCache, KeyDownEvent, ModelContext, MouseButton, MouseRegion, - PaintContext, Quad, TextLayoutCache, WeakModelHandle, WeakViewHandle, + Element, ElementBox, Event, EventContext, FontCache, KeyDownEvent, ModelContext, MouseButton, + MouseRegion, PaintContext, Quad, SizeConstraint, TextLayoutCache, WeakModelHandle, + WeakViewHandle, }; use itertools::Itertools; use ordered_float::OrderedFloat; @@ -42,6 +44,7 @@ pub struct LayoutState { size: TerminalSize, mode: TermMode, display_offset: usize, + hyperlink_tooltip: Option, } ///Helper struct for converting data between alacritty's cursor points, and displayed cursor points @@ -176,6 +179,7 @@ impl TerminalElement { terminal_theme: &TerminalStyle, text_layout_cache: &TextLayoutCache, font_cache: &FontCache, + hyperlink: Option<(HighlightStyle, &RangeInclusive)>, ) -> (Vec, Vec) { let mut cells = vec![]; let mut rects = vec![]; @@ -233,13 +237,14 @@ impl TerminalElement { //Layout current cell text { let cell_text = &cell.c.to_string(); - if cell_text != " " { + if !is_blank(&cell) { let cell_style = TerminalElement::cell_style( &cell, fg, terminal_theme, text_style, font_cache, + hyperlink, ); let layout_cell = text_layout_cache.layout_str( @@ -252,8 +257,8 @@ impl TerminalElement { Point::new(line_index as i32, cell.point.column.0 as i32), layout_cell, )) - } - }; + }; + } } if cur_rect.is_some() { @@ -298,11 +303,12 @@ impl TerminalElement { style: &TerminalStyle, text_style: &TextStyle, font_cache: &FontCache, + hyperlink: Option<(HighlightStyle, &RangeInclusive)>, ) -> RunStyle { let flags = indexed.cell.flags; let fg = convert_color(&fg, &style); - let underline = flags + let mut underline = flags .intersects(Flags::ALL_UNDERLINES) .then(|| Underline { color: Some(fg), @@ -311,14 +317,17 @@ impl TerminalElement { }) .unwrap_or_default(); + if indexed.cell.hyperlink().is_some() { + if underline.thickness == OrderedFloat(0.) { + underline.thickness = OrderedFloat(1.); + } + } + let mut properties = Properties::new(); - if indexed - .flags - .intersects(Flags::BOLD | Flags::BOLD_ITALIC | Flags::DIM_BOLD) - { + if indexed.flags.intersects(Flags::BOLD | Flags::DIM_BOLD) { properties = *properties.weight(Weight::BOLD); } - if indexed.flags.intersects(Flags::ITALIC | Flags::BOLD_ITALIC) { + if indexed.flags.intersects(Flags::ITALIC) { properties = *properties.style(Italic); } @@ -326,11 +335,25 @@ impl TerminalElement { .select_font(text_style.font_family_id, &properties) .unwrap_or(text_style.font_id); - RunStyle { + let mut result = RunStyle { color: fg, font_id, underline, + }; + + if let Some((style, range)) = hyperlink { + if range.contains(&indexed.point) { + if let Some(underline) = style.underline { + result.underline = underline; + } + + if let Some(color) = style.color { + result.color = color; + } + } } + + result } fn generic_button_handler( @@ -360,7 +383,7 @@ impl TerminalElement { ) { let connection = self.terminal; - let mut region = MouseRegion::new::(view_id, view_id, visible_bounds); + let mut region = MouseRegion::new::(view_id, 0, visible_bounds); // Terminal Emulator controlled behavior: region = region @@ -392,19 +415,8 @@ impl TerminalElement { TerminalElement::generic_button_handler( connection, origin, - move |terminal, origin, e, _cx| { - terminal.mouse_up(&e, origin); - }, - ), - ) - // Handle click based selections - .on_click( - MouseButton::Left, - TerminalElement::generic_button_handler( - connection, - origin, - move |terminal, origin, e, _cx| { - terminal.left_click(&e, origin); + move |terminal, origin, e, cx| { + terminal.mouse_up(&e, origin, cx); }, ), ) @@ -422,13 +434,25 @@ impl TerminalElement { }); } }) - .on_scroll(TerminalElement::generic_button_handler( - connection, - origin, - move |terminal, origin, e, _cx| { - terminal.scroll_wheel(e, origin); - }, - )); + .on_move(move |event, cx| { + if cx.is_parent_view_focused() { + if let Some(conn_handle) = connection.upgrade(cx.app) { + conn_handle.update(cx.app, |terminal, cx| { + terminal.mouse_move(&event, origin); + cx.notify(); + }) + } + } + }) + .on_scroll(move |event, cx| { + // cx.focus_parent_view(); + if let Some(conn_handle) = connection.upgrade(cx.app) { + conn_handle.update(cx.app, |terminal, cx| { + terminal.scroll_wheel(event, origin); + cx.notify(); + }) + } + }); // Mouse mode handlers: // All mouse modes need the extra click handlers @@ -459,8 +483,8 @@ impl TerminalElement { TerminalElement::generic_button_handler( connection, origin, - move |terminal, origin, e, _cx| { - terminal.mouse_up(&e, origin); + move |terminal, origin, e, cx| { + terminal.mouse_up(&e, origin, cx); }, ), ) @@ -469,27 +493,12 @@ impl TerminalElement { TerminalElement::generic_button_handler( connection, origin, - move |terminal, origin, e, _cx| { - terminal.mouse_up(&e, origin); + move |terminal, origin, e, cx| { + terminal.mouse_up(&e, origin, cx); }, ), ) } - //Mouse move manages both dragging and motion events - if mode.intersects(TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION) { - region = region - //TODO: This does not fire on right-mouse-down-move events. - .on_move(move |event, cx| { - if cx.is_parent_view_focused() { - if let Some(conn_handle) = connection.upgrade(cx.app) { - conn_handle.update(cx.app, |terminal, cx| { - terminal.mouse_move(&event, origin); - cx.notify(); - }) - } - } - }) - } cx.scene.push_mouse_region(region); } @@ -541,6 +550,9 @@ impl Element for TerminalElement { //Setup layout information let terminal_theme = settings.theme.terminal.clone(); //TODO: Try to minimize this clone. + let link_style = settings.theme.editor.link_definition; + let tooltip_style = settings.theme.tooltip.clone(); + let text_style = TerminalElement::make_text_style(font_cache, settings); let selection_color = settings.theme.editor.selection.selection; let match_color = settings.theme.search.match_background; @@ -559,9 +571,34 @@ impl Element for TerminalElement { let background_color = terminal_theme.background; let terminal_handle = self.terminal.upgrade(cx).unwrap(); - terminal_handle.update(cx.app, |terminal, cx| { + let last_hovered_hyperlink = terminal_handle.update(cx.app, |terminal, cx| { terminal.set_size(dimensions); - terminal.try_sync(cx) + terminal.try_sync(cx); + terminal.last_content.last_hovered_hyperlink.clone() + }); + + let view_handle = self.view.clone(); + let hyperlink_tooltip = last_hovered_hyperlink.and_then(|(uri, _, id)| { + // last_mouse.and_then(|_last_mouse| { + view_handle.upgrade(cx).map(|handle| { + let mut tooltip = cx.render(&handle, |_, cx| { + Overlay::new( + Empty::new() + .contained() + .constrained() + .with_width(dimensions.width()) + .with_height(dimensions.height()) + .with_tooltip::(id, uri, None, tooltip_style, cx) + .boxed(), + ) + .with_position_mode(gpui::elements::OverlayPositionMode::Local) + .boxed() + }); + + tooltip.layout(SizeConstraint::new(Vector2F::zero(), cx.window_size), cx); + tooltip + }) + // }) }); let TerminalContent { @@ -571,8 +608,9 @@ impl Element for TerminalElement { cursor_char, selection, cursor, + last_hovered_hyperlink, .. - } = &terminal_handle.read(cx).last_content; + } = { &terminal_handle.read(cx).last_content }; // searches, highlights to a single range representations let mut relative_highlighted_ranges = Vec::new(); @@ -591,6 +629,9 @@ impl Element for TerminalElement { &terminal_theme, cx.text_layout_cache, cx.font_cache(), + last_hovered_hyperlink + .as_ref() + .map(|(_, range, _)| (link_style, range)), ); //Layout cursor. Rectangle is used for IME, so we should lay it out even @@ -622,14 +663,15 @@ impl Element for TerminalElement { ) }; + let focused = self.focused; TerminalElement::shape_cursor(cursor_point, dimensions, &cursor_text).map( move |(cursor_position, block_width)| { - let shape = match cursor.shape { - AlacCursorShape::Block if !self.focused => CursorShape::Hollow, - AlacCursorShape::Block => CursorShape::Block, - AlacCursorShape::Underline => CursorShape::Underscore, - AlacCursorShape::Beam => CursorShape::Bar, - AlacCursorShape::HollowBlock => CursorShape::Hollow, + let (shape, text) = match cursor.shape { + AlacCursorShape::Block if !focused => (CursorShape::Hollow, None), + AlacCursorShape::Block => (CursorShape::Block, Some(cursor_text)), + AlacCursorShape::Underline => (CursorShape::Underscore, None), + AlacCursorShape::Beam => (CursorShape::Bar, None), + AlacCursorShape::HollowBlock => (CursorShape::Hollow, None), //This case is handled in the if wrapping the whole cursor layout AlacCursorShape::Hidden => unreachable!(), }; @@ -640,7 +682,7 @@ impl Element for TerminalElement { dimensions.line_height, terminal_theme.cursor, shape, - Some(cursor_text), + text, ) }, ) @@ -658,6 +700,7 @@ impl Element for TerminalElement { relative_highlighted_ranges, mode: *mode, display_offset: *display_offset, + hyperlink_tooltip, }, ) } @@ -669,6 +712,8 @@ impl Element for TerminalElement { layout: &mut Self::LayoutState, cx: &mut gpui::PaintContext, ) -> Self::PaintState { + let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default(); + //Setup element stuff let clip_bounds = Some(visible_bounds); @@ -680,7 +725,11 @@ impl Element for TerminalElement { cx.scene.push_cursor_region(gpui::CursorRegion { bounds, - style: gpui::CursorStyle::IBeam, + style: if layout.hyperlink_tooltip.is_some() { + gpui::CursorStyle::PointingHand + } else { + gpui::CursorStyle::IBeam + }, }); cx.paint_layer(clip_bounds, |cx| { @@ -732,6 +781,10 @@ impl Element for TerminalElement { }) } } + + if let Some(element) = &mut layout.hyperlink_tooltip { + element.paint(origin, visible_bounds, cx) + } }); } @@ -813,6 +866,29 @@ impl Element for TerminalElement { } } +fn is_blank(cell: &IndexedCell) -> bool { + if cell.c != ' ' { + return false; + } + + if cell.bg != AnsiColor::Named(NamedColor::Background) { + return false; + } + + if cell.hyperlink().is_some() { + return false; + } + + if cell + .flags + .intersects(Flags::ALL_UNDERLINES | Flags::INVERSE | Flags::STRIKEOUT) + { + return false; + } + + return true; +} + fn to_highlighted_range_lines( range: &RangeInclusive, layout: &LayoutState, diff --git a/crates/terminal/src/terminal_view.rs b/crates/terminal/src/terminal_view.rs index 1a5f87e824..063d68d336 100644 --- a/crates/terminal/src/terminal_view.rs +++ b/crates/terminal/src/terminal_view.rs @@ -6,13 +6,15 @@ use gpui::{ actions, elements::{AnchorCorner, ChildView, ParentElement, Stack}, geometry::vector::Vector2F, - impl_internal_actions, + impl_actions, impl_internal_actions, keymap::Keystroke, AnyViewHandle, AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, Task, View, ViewContext, ViewHandle, }; +use serde::Deserialize; use settings::{Settings, TerminalBlink}; use smol::Timer; +use util::ResultExt; use workspace::pane; use crate::{terminal_element::TerminalElement, Event, Terminal}; @@ -28,6 +30,12 @@ pub struct DeployContextMenu { pub position: Vector2F, } +#[derive(Clone, Default, Deserialize, PartialEq)] +pub struct SendText(String); + +#[derive(Clone, Default, Deserialize, PartialEq)] +pub struct SendKeystroke(String); + actions!( terminal, [ @@ -43,16 +51,15 @@ actions!( SearchTest ] ); + +impl_actions!(terminal, [SendText, SendKeystroke]); + impl_internal_actions!(project_panel, [DeployContextMenu]); pub fn init(cx: &mut MutableAppContext) { - //Global binding overrrides - cx.add_action(TerminalView::ctrl_c); - cx.add_action(TerminalView::up); - cx.add_action(TerminalView::down); - cx.add_action(TerminalView::escape); - cx.add_action(TerminalView::enter); //Useful terminal views + cx.add_action(TerminalView::send_text); + cx.add_action(TerminalView::send_keystroke); cx.add_action(TerminalView::deploy_context_menu); cx.add_action(TerminalView::copy); cx.add_action(TerminalView::paste); @@ -135,8 +142,8 @@ impl TerminalView { pub fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext) { let menu_entries = vec![ - ContextMenuItem::item("Clear Buffer", Clear), - ContextMenuItem::item("Close Terminal", pane::CloseActiveItem), + ContextMenuItem::item("Clear", Clear), + ContextMenuItem::item("Close", pane::CloseActiveItem), ]; self.context_menu.update(cx, |menu, cx| { @@ -283,44 +290,26 @@ impl TerminalView { } } - ///Synthesize the keyboard event corresponding to 'up' - fn up(&mut self, _: &Up, cx: &mut ViewContext) { + fn send_text(&mut self, text: &SendText, cx: &mut ViewContext) { self.clear_bel(cx); self.terminal.update(cx, |term, _| { - term.try_keystroke(&Keystroke::parse("up").unwrap(), false) + term.input(text.0.to_string()); }); } - ///Synthesize the keyboard event corresponding to 'down' - fn down(&mut self, _: &Down, cx: &mut ViewContext) { - self.clear_bel(cx); - self.terminal.update(cx, |term, _| { - term.try_keystroke(&Keystroke::parse("down").unwrap(), false) - }); - } - - ///Synthesize the keyboard event corresponding to 'ctrl-c' - fn ctrl_c(&mut self, _: &CtrlC, cx: &mut ViewContext) { - self.clear_bel(cx); - self.terminal.update(cx, |term, _| { - term.try_keystroke(&Keystroke::parse("ctrl-c").unwrap(), false) - }); - } - - ///Synthesize the keyboard event corresponding to 'escape' - fn escape(&mut self, _: &Escape, cx: &mut ViewContext) { - self.clear_bel(cx); - self.terminal.update(cx, |term, _| { - term.try_keystroke(&Keystroke::parse("escape").unwrap(), false) - }); - } - - ///Synthesize the keyboard event corresponding to 'enter' - fn enter(&mut self, _: &Enter, cx: &mut ViewContext) { - self.clear_bel(cx); - self.terminal.update(cx, |term, _| { - term.try_keystroke(&Keystroke::parse("enter").unwrap(), false) - }); + fn send_keystroke(&mut self, text: &SendKeystroke, cx: &mut ViewContext) { + if let Some(keystroke) = Keystroke::parse(&text.0).log_err() { + self.clear_bel(cx); + self.terminal.update(cx, |term, cx| { + term.try_keystroke( + &keystroke, + cx.global::() + .terminal_overrides + .option_as_meta + .unwrap_or(false), + ); + }); + } } } @@ -349,7 +338,7 @@ impl View for TerminalView { .contained() .boxed(), ) - .with_child(ChildView::new(&self.context_menu).boxed()) + .with_child(ChildView::new(&self.context_menu, cx).boxed()) .boxed() } @@ -361,7 +350,9 @@ impl View for TerminalView { } fn on_focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { - self.terminal.read(cx).focus_out(); + self.terminal.update(cx, |terminal, _| { + terminal.focus_out(); + }); cx.notify(); } diff --git a/crates/terminal/src/tests/terminal_test_context.rs b/crates/terminal/src/tests/terminal_test_context.rs index f9ee6e8082..3e3d1243d5 100644 --- a/crates/terminal/src/tests/terminal_test_context.rs +++ b/crates/terminal/src/tests/terminal_test_context.rs @@ -1,10 +1,17 @@ use std::{path::Path, time::Duration}; +use alacritty_terminal::{ + index::{Column, Line, Point}, + term::cell::Cell, +}; use gpui::{ModelHandle, TestAppContext, ViewHandle}; use project::{Entry, Project, ProjectPath, Worktree}; +use rand::{rngs::ThreadRng, Rng}; use workspace::{AppState, Workspace}; +use crate::{IndexedCell, TerminalContent, TerminalSize}; + pub struct TerminalTestContext<'a> { pub cx: &'a mut TestAppContext, } @@ -88,6 +95,39 @@ impl<'a> TerminalTestContext<'a> { project.update(cx, |project, cx| project.set_active_path(Some(p), cx)); }); } + + pub fn create_terminal_content( + size: TerminalSize, + rng: &mut ThreadRng, + ) -> (TerminalContent, Vec>) { + let mut ic = Vec::new(); + let mut cells = Vec::new(); + + for row in 0..((size.height() / size.line_height()) as usize) { + let mut row_vec = Vec::new(); + for col in 0..((size.width() / size.cell_width()) as usize) { + let cell_char = rng.gen(); + ic.push(IndexedCell { + point: Point::new(Line(row as i32), Column(col)), + cell: Cell { + c: cell_char, + ..Default::default() + }, + }); + row_vec.push(cell_char) + } + cells.push(row_vec) + } + + ( + TerminalContent { + cells: ic, + size, + ..Default::default() + }, + cells, + ) + } } impl<'a> Drop for TerminalTestContext<'a> { diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 4fc09eff46..ad960ec93e 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -13,23 +13,24 @@ test-support = ["rand"] [dependencies] clock = { path = "../clock" } collections = { path = "../collections" } +fs = { path = "../fs" } +rope = { path = "../rope" } sum_tree = { path = "../sum_tree" } anyhow = "1.0.38" -arrayvec = "0.7.1" digest = { version = "0.9", features = ["std"] } -bromberg_sl2 = "0.6" lazy_static = "1.4" log = { version = "0.4.16", features = ["kv_unstable_serde"] } parking_lot = "0.11" postage = { version = "0.4.1", features = ["futures-traits"] } rand = { version = "0.8.3", optional = true } -regex = "1.5" smallvec = { version = "1.6", features = ["union"] } +util = { path = "../util" } +regex = "1.5" + [dev-dependencies] collections = { path = "../collections", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] } -util = { path = "../util", features = ["test-support"] } ctor = "0.1" env_logger = "0.9" rand = "0.8.3" diff --git a/crates/text/src/anchor.rs b/crates/text/src/anchor.rs index dca95ce5d5..68ec00056a 100644 --- a/crates/text/src/anchor.rs +++ b/crates/text/src/anchor.rs @@ -1,10 +1,9 @@ -use super::{Point, ToOffset}; -use crate::{rope::TextDimension, BufferSnapshot, PointUtf16, ToPoint, ToPointUtf16}; +use crate::{BufferSnapshot, Point, PointUtf16, TextDimension, ToOffset, ToPoint, ToPointUtf16}; use anyhow::Result; use std::{cmp::Ordering, fmt::Debug, ops::Range}; use sum_tree::Bias; -#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash)] +#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash, Default)] pub struct Anchor { pub timestamp: clock::Local, pub offset: usize, diff --git a/crates/text/src/random_char_iter.rs b/crates/text/src/random_char_iter.rs deleted file mode 100644 index 04cdcd3524..0000000000 --- a/crates/text/src/random_char_iter.rs +++ /dev/null @@ -1,36 +0,0 @@ -use rand::prelude::*; - -pub struct RandomCharIter(T); - -impl RandomCharIter { - pub fn new(rng: T) -> Self { - Self(rng) - } -} - -impl Iterator for RandomCharIter { - type Item = char; - - fn next(&mut self) -> Option { - if std::env::var("SIMPLE_TEXT").map_or(false, |v| !v.is_empty()) { - return if self.0.gen_range(0..100) < 5 { - Some('\n') - } else { - Some(self.0.gen_range(b'a'..b'z' + 1).into()) - }; - } - - match self.0.gen_range(0..100) { - // whitespace - 0..=19 => [' ', '\n', '\r', '\t'].choose(&mut self.0).copied(), - // two-byte greek letters - 20..=32 => char::from_u32(self.0.gen_range(('α' as u32)..('ω' as u32 + 1))), - // // three-byte characters - 33..=45 => ['✋', '✅', '❌', '❎', '⭐'].choose(&mut self.0).copied(), - // // four-byte characters - 46..=58 => ['🍐', '🏀', '🍗', '🎉'].choose(&mut self.0).copied(), - // ascii letters - _ => Some(self.0.gen_range(b'a'..b'z' + 1).into()), - } - } -} diff --git a/crates/text/src/selection.rs b/crates/text/src/selection.rs index e5acbd21bc..d4f55a043b 100644 --- a/crates/text/src/selection.rs +++ b/crates/text/src/selection.rs @@ -1,5 +1,4 @@ -use crate::Anchor; -use crate::{rope::TextDimension, BufferSnapshot}; +use crate::{Anchor, BufferSnapshot, TextDimension}; use std::cmp::Ordering; use std::ops::Range; diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index 0648e6341a..72ae018a16 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -2,39 +2,28 @@ mod anchor; pub mod locator; #[cfg(any(test, feature = "test-support"))] pub mod network; -mod offset_utf16; pub mod operation_queue; mod patch; -mod point; -mod point_utf16; -#[cfg(any(test, feature = "test-support"))] -pub mod random_char_iter; -pub mod rope; mod selection; pub mod subscription; #[cfg(test)] mod tests; +mod undo_map; pub use anchor::*; use anyhow::Result; use clock::ReplicaId; use collections::{HashMap, HashSet}; -use lazy_static::lazy_static; +use fs::LineEnding; use locator::Locator; -pub use offset_utf16::*; use operation_queue::OperationQueue; pub use patch::Patch; -pub use point::*; -pub use point_utf16::*; use postage::{barrier, oneshot, prelude::*}; -#[cfg(any(test, feature = "test-support"))] -pub use random_char_iter::*; -use regex::Regex; -use rope::TextDimension; -pub use rope::{Chunks, Rope, TextSummary}; + +pub use rope::*; pub use selection::*; + use std::{ - borrow::Cow, cmp::{self, Ordering, Reverse}, future::Future, iter::Iterator, @@ -46,10 +35,10 @@ use std::{ pub use subscription::*; pub use sum_tree::Bias; use sum_tree::{FilterCursor, SumTree, TreeMap}; +use undo_map::UndoMap; -lazy_static! { - static ref CARRIAGE_RETURNS_REGEX: Regex = Regex::new("\r\n|\r").unwrap(); -} +#[cfg(any(test, feature = "test-support"))] +use util::RandomCharIter; pub type TransactionId = clock::Local; @@ -66,7 +55,7 @@ pub struct Buffer { version_barriers: Vec<(clock::Global, barrier::Sender)>, } -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct BufferSnapshot { replica_id: ReplicaId, remote_id: u64, @@ -94,12 +83,6 @@ pub struct Transaction { pub start: clock::Global, } -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum LineEnding { - Unix, - Windows, -} - impl HistoryEntry { pub fn transaction_id(&self) -> TransactionId { self.transaction.id @@ -335,44 +318,6 @@ impl History { } } -#[derive(Clone, Default, Debug)] -struct UndoMap(HashMap>); - -impl UndoMap { - fn insert(&mut self, undo: &UndoOperation) { - for (edit_id, count) in &undo.counts { - self.0.entry(*edit_id).or_default().push((undo.id, *count)); - } - } - - fn is_undone(&self, edit_id: clock::Local) -> bool { - self.undo_count(edit_id) % 2 == 1 - } - - fn was_undone(&self, edit_id: clock::Local, version: &clock::Global) -> bool { - let undo_count = self - .0 - .get(&edit_id) - .unwrap_or(&Vec::new()) - .iter() - .filter(|(undo_id, _)| version.observed(*undo_id)) - .map(|(_, undo_count)| *undo_count) - .max() - .unwrap_or(0); - undo_count % 2 == 1 - } - - fn undo_count(&self, edit_id: clock::Local) -> u32 { - self.0 - .get(&edit_id) - .unwrap_or(&Vec::new()) - .iter() - .map(|(_, undo_count)| *undo_count) - .max() - .unwrap_or(0) - } -} - struct Edits<'a, D: TextDimension, F: FnMut(&FragmentSummary) -> bool> { visible_cursor: rope::Cursor<'a>, deleted_cursor: rope::Cursor<'a>, @@ -1218,13 +1163,6 @@ impl Buffer { &self.history.operations } - pub fn undo_history(&self) -> impl Iterator { - self.undo_map - .0 - .iter() - .map(|(edit_id, undo_counts)| (edit_id, undo_counts.as_slice())) - } - pub fn undo(&mut self) -> Option<(TransactionId, Operation)> { if let Some(entry) = self.history.pop_undo() { let transaction = entry.transaction.clone(); @@ -1507,9 +1445,7 @@ impl Buffer { last_end = Some(range.end); let new_text_len = rng.gen_range(0..10); - let new_text: String = crate::random_char_iter::RandomCharIter::new(&mut *rng) - .take(new_text_len) - .collect(); + let new_text: String = RandomCharIter::new(&mut *rng).take(new_text_len).collect(); edits.push((range, new_text.into())); } @@ -2413,56 +2349,6 @@ impl operation_queue::Operation for Operation { } } -impl Default for LineEnding { - fn default() -> Self { - #[cfg(unix)] - return Self::Unix; - - #[cfg(not(unix))] - return Self::CRLF; - } -} - -impl LineEnding { - pub fn as_str(&self) -> &'static str { - match self { - LineEnding::Unix => "\n", - LineEnding::Windows => "\r\n", - } - } - - pub fn detect(text: &str) -> Self { - let mut max_ix = cmp::min(text.len(), 1000); - while !text.is_char_boundary(max_ix) { - max_ix -= 1; - } - - if let Some(ix) = text[..max_ix].find(&['\n']) { - if ix > 0 && text.as_bytes()[ix - 1] == b'\r' { - Self::Windows - } else { - Self::Unix - } - } else { - Self::default() - } - } - - pub fn normalize(text: &mut String) { - if let Cow::Owned(replaced) = CARRIAGE_RETURNS_REGEX.replace_all(text, "\n") { - *text = replaced; - } - } - - fn normalize_arc(text: Arc) -> Arc { - if let Cow::Owned(replaced) = CARRIAGE_RETURNS_REGEX.replace_all(&text, "\n") { - replaced.into() - } else { - text - } - } -} - pub trait ToOffset { fn to_offset(&self, snapshot: &BufferSnapshot) -> usize; } diff --git a/crates/text/src/undo_map.rs b/crates/text/src/undo_map.rs new file mode 100644 index 0000000000..ff1b241e73 --- /dev/null +++ b/crates/text/src/undo_map.rs @@ -0,0 +1,112 @@ +use crate::UndoOperation; +use std::cmp; +use sum_tree::{Bias, SumTree}; + +#[derive(Copy, Clone, Debug)] +struct UndoMapEntry { + key: UndoMapKey, + undo_count: u32, +} + +impl sum_tree::Item for UndoMapEntry { + type Summary = UndoMapKey; + + fn summary(&self) -> Self::Summary { + self.key + } +} + +impl sum_tree::KeyedItem for UndoMapEntry { + type Key = UndoMapKey; + + fn key(&self) -> Self::Key { + self.key + } +} + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +struct UndoMapKey { + edit_id: clock::Local, + undo_id: clock::Local, +} + +impl sum_tree::Summary for UndoMapKey { + type Context = (); + + fn add_summary(&mut self, summary: &Self, _: &Self::Context) { + *self = cmp::max(*self, *summary); + } +} + +#[derive(Clone, Default)] +pub struct UndoMap(SumTree); + +impl UndoMap { + pub fn insert(&mut self, undo: &UndoOperation) { + let edits = undo + .counts + .iter() + .map(|(edit_id, count)| { + sum_tree::Edit::Insert(UndoMapEntry { + key: UndoMapKey { + edit_id: *edit_id, + undo_id: undo.id, + }, + undo_count: *count, + }) + }) + .collect::>(); + self.0.edit(edits, &()); + } + + pub fn is_undone(&self, edit_id: clock::Local) -> bool { + self.undo_count(edit_id) % 2 == 1 + } + + pub fn was_undone(&self, edit_id: clock::Local, version: &clock::Global) -> bool { + let mut cursor = self.0.cursor::(); + cursor.seek( + &UndoMapKey { + edit_id, + undo_id: Default::default(), + }, + Bias::Left, + &(), + ); + + let mut undo_count = 0; + for entry in cursor { + if entry.key.edit_id != edit_id { + break; + } + + if version.observed(entry.key.undo_id) { + undo_count = cmp::max(undo_count, entry.undo_count); + } + } + + undo_count % 2 == 1 + } + + pub fn undo_count(&self, edit_id: clock::Local) -> u32 { + let mut cursor = self.0.cursor::(); + cursor.seek( + &UndoMapKey { + edit_id, + undo_id: Default::default(), + }, + Bias::Left, + &(), + ); + + let mut undo_count = 0; + for entry in cursor { + if entry.key.edit_id != edit_id { + break; + } + + undo_count = cmp::max(undo_count, entry.undo_count); + } + undo_count + } +} diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index b27a98e804..11296af6ab 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -19,7 +19,7 @@ pub struct Theme { pub workspace: Workspace, pub context_menu: ContextMenu, pub contacts_popover: ContactsPopover, - pub contacts_panel: ContactsPanel, + pub contact_list: ContactList, pub contact_finder: ContactFinder, pub project_panel: ProjectPanel, pub command_palette: CommandPalette, @@ -30,6 +30,8 @@ pub struct Theme { pub breadcrumbs: ContainedText, pub contact_notification: ContactNotification, pub update_notification: UpdateNotification, + pub project_shared_notification: ProjectSharedNotification, + pub incoming_call_notification: IncomingCallNotification, pub tooltip: TooltipStyle, pub terminal: TerminalStyle, pub color_scheme: ColorScheme, @@ -58,6 +60,7 @@ pub struct Workspace { pub notifications: Notifications, pub joining_project_avatar: ImageStyle, pub joining_project_message: ContainedText, + pub external_location_message: ContainedText, pub dock: Dock, } @@ -72,8 +75,67 @@ pub struct Titlebar { pub avatar_ribbon: AvatarRibbon, pub offline_icon: OfflineIcon, pub avatar: ImageStyle, + pub inactive_avatar: ImageStyle, pub sign_in_prompt: Interactive, pub outdated_warning: ContainedText, + pub share_button: Interactive, + pub toggle_contacts_button: Interactive, + pub toggle_contacts_badge: ContainerStyle, +} + +#[derive(Deserialize, Default)] +pub struct ContactsPopover { + #[serde(flatten)] + pub container: ContainerStyle, + pub height: f32, + pub width: f32, + pub invite_row_height: f32, + pub invite_row: Interactive, +} + +#[derive(Deserialize, Default)] +pub struct ContactList { + pub user_query_editor: FieldEditor, + pub user_query_editor_height: f32, + pub add_contact_button: IconButton, + pub header_row: Interactive, + pub leave_call: Interactive, + pub contact_row: Interactive, + pub row_height: f32, + pub project_row: Interactive, + pub tree_branch: Interactive, + pub contact_avatar: ImageStyle, + pub contact_status_free: ContainerStyle, + pub contact_status_busy: ContainerStyle, + pub contact_username: ContainedText, + pub contact_button: Interactive, + pub contact_button_spacing: f32, + pub disabled_button: IconButton, + pub section_icon_size: f32, + pub calling_indicator: ContainedText, +} + +#[derive(Deserialize, Default)] +pub struct ProjectRow { + #[serde(flatten)] + pub container: ContainerStyle, + pub name: ContainedText, +} + +#[derive(Deserialize, Default, Clone, Copy)] +pub struct TreeBranch { + pub width: f32, + pub color: Color, +} + +#[derive(Deserialize, Default)] +pub struct ContactFinder { + pub picker: Picker, + pub row_height: f32, + pub contact_avatar: ImageStyle, + pub contact_username: ContainerStyle, + pub contact_button: IconButton, + pub disabled_contact_button: IconButton, } #[derive(Clone, Deserialize, Default)] @@ -303,33 +365,6 @@ pub struct CommandPalette { pub keystroke_spacing: f32, } -#[derive(Deserialize, Default)] -pub struct ContactsPopover { - pub background: Color, -} - -#[derive(Deserialize, Default)] -pub struct ContactsPanel { - #[serde(flatten)] - pub container: ContainerStyle, - pub user_query_editor: FieldEditor, - pub user_query_editor_height: f32, - pub add_contact_button: IconButton, - pub header_row: Interactive, - pub contact_row: Interactive, - pub project_row: Interactive, - pub row_height: f32, - pub contact_avatar: ImageStyle, - pub contact_username: ContainedText, - pub contact_button: Interactive, - pub contact_button_spacing: f32, - pub disabled_button: IconButton, - pub tree_branch: Interactive, - pub private_button: Interactive, - pub section_icon_size: f32, - pub invite_row: Interactive, -} - #[derive(Deserialize, Default)] pub struct InviteLink { #[serde(flatten)] @@ -339,21 +374,6 @@ pub struct InviteLink { pub icon: Icon, } -#[derive(Deserialize, Default, Clone, Copy)] -pub struct TreeBranch { - pub width: f32, - pub color: Color, -} - -#[derive(Deserialize, Default)] -pub struct ContactFinder { - pub row_height: f32, - pub contact_avatar: ImageStyle, - pub contact_username: ContainerStyle, - pub contact_button: IconButton, - pub disabled_contact_button: IconButton, -} - #[derive(Deserialize, Default)] pub struct Icon { #[serde(flatten)] @@ -372,16 +392,6 @@ pub struct IconButton { pub button_width: f32, } -#[derive(Deserialize, Default)] -pub struct ProjectRow { - #[serde(flatten)] - pub container: ContainerStyle, - pub name: ContainedText, - pub guests: ContainerStyle, - pub guest_avatar: ImageStyle, - pub guest_avatar_spacing: f32, -} - #[derive(Deserialize, Default)] pub struct ChatMessage { #[serde(flatten)] @@ -463,6 +473,40 @@ pub struct UpdateNotification { pub dismiss_button: Interactive, } +#[derive(Deserialize, Default)] +pub struct ProjectSharedNotification { + pub window_height: f32, + pub window_width: f32, + #[serde(default)] + pub background: Color, + pub owner_container: ContainerStyle, + pub owner_avatar: ImageStyle, + pub owner_metadata: ContainerStyle, + pub owner_username: ContainedText, + pub message: ContainedText, + pub worktree_roots: ContainedText, + pub button_width: f32, + pub open_button: ContainedText, + pub dismiss_button: ContainedText, +} + +#[derive(Deserialize, Default)] +pub struct IncomingCallNotification { + pub window_height: f32, + pub window_width: f32, + #[serde(default)] + pub background: Color, + pub caller_container: ContainerStyle, + pub caller_avatar: ImageStyle, + pub caller_metadata: ContainerStyle, + pub caller_username: ContainedText, + pub caller_message: ContainedText, + pub worktree_roots: ContainedText, + pub button_width: f32, + pub accept_button: ContainedText, + pub decline_button: ContainedText, +} + #[derive(Clone, Deserialize, Default)] pub struct Editor { pub text_color: Color, @@ -476,8 +520,7 @@ pub struct Editor { pub rename_fade: f32, pub document_highlight_read_background: Color, pub document_highlight_write_background: Color, - pub diff_background_deleted: Color, - pub diff_background_inserted: Color, + pub diff: DiffStyle, pub line_number: Color, pub line_number_active: Color, pub guest_selections: Vec, @@ -499,6 +542,15 @@ pub struct Editor { pub link_definition: HighlightStyle, pub composition_mark: HighlightStyle, pub jump_icon: Interactive, + pub scrollbar: Scrollbar, +} + +#[derive(Clone, Deserialize, Default)] +pub struct Scrollbar { + pub track: ContainerStyle, + pub thumb: ContainerStyle, + pub width: f32, + pub min_height_factor: f32, } #[derive(Clone, Deserialize, Default)] @@ -561,6 +613,16 @@ pub struct CodeActions { pub vertical_scale: f32, } +#[derive(Clone, Deserialize, Default)] +pub struct DiffStyle { + pub inserted: Color, + pub modified: Color, + pub deleted: Color, + pub removed_width_em: f32, + pub width_em: f32, + pub corner_radius: f32, +} + #[derive(Debug, Default, Clone, Copy)] pub struct Interactive { pub default: T, @@ -571,12 +633,12 @@ pub struct Interactive { } impl Interactive { - pub fn style_for(&self, state: MouseState, active: bool) -> &T { + pub fn style_for(&self, state: &mut MouseState, active: bool) -> &T { if active { self.active.as_ref().unwrap_or(&self.default) - } else if state.clicked == Some(gpui::MouseButton::Left) && self.clicked.is_some() { + } else if state.clicked() == Some(gpui::MouseButton::Left) && self.clicked.is_some() { self.clicked.as_ref().unwrap() - } else if state.hovered { + } else if state.hovered() { self.hover.as_ref().unwrap_or(&self.default) } else { &self.default diff --git a/crates/theme_selector/Cargo.toml b/crates/theme_selector/Cargo.toml index 804eff2c7a..59cb5fbc2c 100644 --- a/crates/theme_selector/Cargo.toml +++ b/crates/theme_selector/Cargo.toml @@ -19,3 +19,4 @@ log = { version = "0.4.16", features = ["kv_unstable_serde"] } parking_lot = "0.11.1" postage = { version = "0.4.1", features = ["futures-traits"] } smol = "1.2.5" + diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 59b0bc7e6a..3236120857 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -4,7 +4,7 @@ use gpui::{ MutableAppContext, RenderContext, View, ViewContext, ViewHandle, }; use picker::{Picker, PickerDelegate}; -use settings::Settings; +use settings::{settings_file::SettingsFile, Settings}; use std::sync::Arc; use theme::{Theme, ThemeMeta, ThemeRegistry}; use workspace::{AppState, Workspace}; @@ -107,7 +107,9 @@ impl ThemeSelector { fn show_selected_theme(&mut self, cx: &mut ViewContext) { if let Some(mat) = self.matches.get(self.selected_index) { match self.registry.get(&mat.string) { - Ok(theme) => Self::set_theme(theme, cx), + Ok(theme) => { + Self::set_theme(theme, cx); + } Err(error) => { log::error!("error loading theme {}: {}", mat.string, error) } @@ -151,6 +153,12 @@ impl PickerDelegate for ThemeSelector { fn confirm(&mut self, cx: &mut ViewContext) { self.selection_completed = true; + + let theme_name = cx.global::().theme.meta.name.clone(); + SettingsFile::update(cx, |settings_content| { + settings_content.theme = Some(theme_name); + }); + cx.emit(Event::Dismissed); } @@ -222,7 +230,7 @@ impl PickerDelegate for ThemeSelector { fn render_match( &self, ix: usize, - mouse_state: MouseState, + mouse_state: &mut MouseState, selected: bool, cx: &AppContext, ) -> ElementBox { @@ -254,8 +262,8 @@ impl View for ThemeSelector { "ThemeSelector" } - fn render(&mut self, _: &mut RenderContext) -> ElementBox { - ChildView::new(self.picker.clone()).boxed() + fn render(&mut self, cx: &mut RenderContext) -> ElementBox { + ChildView::new(self.picker.clone(), cx).boxed() } fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { diff --git a/crates/theme_testbench/src/theme_testbench.rs b/crates/theme_testbench/src/theme_testbench.rs index 2b5cadb398..f9f0d96f4e 100644 --- a/crates/theme_testbench/src/theme_testbench.rs +++ b/crates/theme_testbench/src/theme_testbench.rs @@ -209,9 +209,9 @@ impl ThemeTestbench { MouseEventHandler::::new(layer_index + button_index, cx, |state, cx| { let style = if let Some(style_override) = style_override { style_override(&style_set) - } else if state.clicked.is_some() { + } else if state.clicked().is_some() { &style_set.pressed - } else if state.hovered { + } else if state.hovered() { &style_set.hovered } else { &style_set.default diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index 4ec214fef1..c083137156 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -7,17 +7,20 @@ edition = "2021" doctest = false [features] -test-support = ["rand", "serde_json", "tempdir"] +test-support = ["serde_json", "tempdir", "git2"] [dependencies] anyhow = "1.0.38" futures = "0.3" log = { version = "0.4.16", features = ["kv_unstable_serde"] } -rand = { version = "0.8", optional = true } +lazy_static = "1.4.0" +rand = { workspace = true } tempdir = { version = "0.3.7", optional = true } serde_json = { version = "1.0", features = ["preserve_order"], optional = true } +git2 = { version = "0.15", default-features = false, optional = true } + [dev-dependencies] -rand = { version = "0.8" } tempdir = { version = "0.3.7" } serde_json = { version = "1.0", features = ["preserve_order"] } +git2 = { version = "0.15", default-features = false } diff --git a/crates/util/src/lib.rs b/crates/util/src/lib.rs index 97f409f410..e35f2df7d4 100644 --- a/crates/util/src/lib.rs +++ b/crates/util/src/lib.rs @@ -2,6 +2,7 @@ pub mod test; use futures::Future; +use rand::{seq::SliceRandom, Rng}; use std::{ cmp::Ordering, ops::AddAssign, @@ -155,6 +156,41 @@ pub fn defer(f: F) -> impl Drop { Defer(Some(f)) } +pub struct RandomCharIter(T); + +impl RandomCharIter { + pub fn new(rng: T) -> Self { + Self(rng) + } +} + +impl Iterator for RandomCharIter { + type Item = char; + + fn next(&mut self) -> Option { + if std::env::var("SIMPLE_TEXT").map_or(false, |v| !v.is_empty()) { + return if self.0.gen_range(0..100) < 5 { + Some('\n') + } else { + Some(self.0.gen_range(b'a'..b'z' + 1).into()) + }; + } + + match self.0.gen_range(0..100) { + // whitespace + 0..=19 => [' ', '\n', '\r', '\t'].choose(&mut self.0).copied(), + // two-byte greek letters + 20..=32 => char::from_u32(self.0.gen_range(('α' as u32)..('ω' as u32 + 1))), + // // three-byte characters + 33..=45 => ['✋', '✅', '❌', '❎', '⭐'].choose(&mut self.0).copied(), + // // four-byte characters + 46..=58 => ['🍐', '🏀', '🍗', '🎉'].choose(&mut self.0).copied(), + // ascii letters + _ => Some(self.0.gen_range(b'a'..b'z' + 1).into()), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/util/src/test.rs b/crates/util/src/test.rs index 7b2e00d57b..96d13f4c81 100644 --- a/crates/util/src/test.rs +++ b/crates/util/src/test.rs @@ -1,7 +1,11 @@ mod assertions; mod marked_text; -use std::path::{Path, PathBuf}; +use git2; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; use tempdir::TempDir; pub use assertions::*; @@ -24,6 +28,11 @@ fn write_tree(path: &Path, tree: serde_json::Value) { match contents { Value::Object(_) => { fs::create_dir(&path).unwrap(); + + if path.file_name() == Some(&OsStr::new(".git")) { + git2::Repository::init(&path.parent().unwrap()).unwrap(); + } + write_tree(&path, contents); } Value::Null => { diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 5bc32cd5bd..44f2a8cb16 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -7,7 +7,20 @@ edition = "2021" path = "src/vim.rs" doctest = false +[features] +neovim = ["nvim-rs", "async-compat", "async-trait", "tokio"] + [dependencies] +serde = { version = "1.0", features = ["derive", "rc"] } +itertools = "0.10" +log = { version = "0.4.16", features = ["kv_unstable_serde"] } + +async-compat = { version = "0.2.1", "optional" = true } +async-trait = { version = "0.1", "optional" = true } +nvim-rs = { git = "https://github.com/KillTheMule/nvim-rs", branch = "master", features = ["use_tokio"], optional = true } +tokio = { version = "1.15", "optional" = true } +serde_json = { version = "1.0", features = ["preserve_order"] } + assets = { path = "../assets" } collections = { path = "../collections" } command_palette = { path = "../command_palette" } @@ -15,14 +28,14 @@ editor = { path = "../editor" } gpui = { path = "../gpui" } language = { path = "../language" } search = { path = "../search" } -serde = { version = "1.0", features = ["derive", "rc"] } settings = { path = "../settings" } workspace = { path = "../workspace" } -itertools = "0.10" -log = { version = "0.4.16", features = ["kv_unstable_serde"] } [dev-dependencies] indoc = "1.0.4" +parking_lot = "0.11.1" +lazy_static = "1.4" + editor = { path = "../editor", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] } language = { path = "../language", features = ["test-support"] } diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index a1d1c7b404..05cd2af1d9 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -26,7 +26,7 @@ fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext normal_motion(motion, cx), - Mode::Visual { .. } => visual_motion(motion, cx), + Mode::Normal => normal_motion(motion, operator, times, cx), + Mode::Visual { .. } => visual_motion(motion, times, cx), Mode::Insert => { // Shouldn't execute a motion in insert mode. Ignoring } } + Vim::update(cx, |vim, cx| vim.clear_operator(cx)); } // Motion handling is specified here: @@ -150,30 +155,32 @@ impl Motion { map: &DisplaySnapshot, point: DisplayPoint, goal: SelectionGoal, + times: usize, ) -> (DisplayPoint, SelectionGoal) { use Motion::*; match self { - Left => (left(map, point), SelectionGoal::None), - Down => movement::down(map, point, goal, true), - Up => movement::up(map, point, goal, true), - Right => (right(map, point), SelectionGoal::None), + Left => (left(map, point, times), SelectionGoal::None), + Backspace => (backspace(map, point, times), SelectionGoal::None), + Down => down(map, point, goal, times), + Up => up(map, point, goal, times), + Right => (right(map, point, times), SelectionGoal::None), NextWordStart { ignore_punctuation } => ( - next_word_start(map, point, ignore_punctuation), + next_word_start(map, point, ignore_punctuation, times), SelectionGoal::None, ), NextWordEnd { ignore_punctuation } => ( - next_word_end(map, point, ignore_punctuation), + next_word_end(map, point, ignore_punctuation, times), SelectionGoal::None, ), PreviousWordStart { ignore_punctuation } => ( - previous_word_start(map, point, ignore_punctuation), + previous_word_start(map, point, ignore_punctuation, times), SelectionGoal::None, ), FirstNonWhitespace => (first_non_whitespace(map, point), SelectionGoal::None), StartOfLine => (start_of_line(map, point), SelectionGoal::None), EndOfLine => (end_of_line(map, point), SelectionGoal::None), CurrentLine => (end_of_line(map, point), SelectionGoal::None), - StartOfDocument => (start_of_document(map, point), SelectionGoal::None), + StartOfDocument => (start_of_document(map, point, times), SelectionGoal::None), EndOfDocument => (end_of_document(map, point), SelectionGoal::None), Matching => (matching(map, point), SelectionGoal::None), } @@ -184,9 +191,10 @@ impl Motion { self, map: &DisplaySnapshot, selection: &mut Selection, + times: usize, expand_to_surrounding_newline: bool, ) { - let (head, goal) = self.move_point(map, selection.head(), selection.goal); + let (head, goal) = self.move_point(map, selection.head(), selection.goal, times); selection.set_head(head, goal); if self.linewise() { @@ -206,7 +214,7 @@ impl Motion { } } - selection.end = map.next_line_boundary(selection.end.to_point(map)).1; + (_, selection.end) = map.next_line_boundary(selection.end.to_point(map)); } else { // If the motion is exclusive and the end of the motion is in column 1, the // end of the motion is moved to the end of the previous line and the motion @@ -234,95 +242,151 @@ impl Motion { } } -fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { - *point.column_mut() = point.column().saturating_sub(1); - map.clip_point(point, Bias::Left) -} - -fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { - *point.column_mut() += 1; - map.clip_point(point, Bias::Right) -} - -fn next_word_start( - map: &DisplaySnapshot, - point: DisplayPoint, - ignore_punctuation: bool, -) -> DisplayPoint { - let mut crossed_newline = false; - movement::find_boundary(map, point, |left, right| { - let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); - let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); - let at_newline = right == '\n'; - - let found = (left_kind != right_kind && !right.is_whitespace()) - || at_newline && crossed_newline - || at_newline && left == '\n'; // Prevents skipping repeated empty lines - - if at_newline { - crossed_newline = true; +fn left(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint { + for _ in 0..times { + *point.column_mut() = point.column().saturating_sub(1); + point = map.clip_point(point, Bias::Right); + if point.column() == 0 { + break; } - found - }) + } + point +} + +fn backspace(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint { + for _ in 0..times { + point = movement::left(map, point); + } + point +} + +fn down( + map: &DisplaySnapshot, + mut point: DisplayPoint, + mut goal: SelectionGoal, + times: usize, +) -> (DisplayPoint, SelectionGoal) { + for _ in 0..times { + (point, goal) = movement::down(map, point, goal, true); + } + (point, goal) +} + +fn up( + map: &DisplaySnapshot, + mut point: DisplayPoint, + mut goal: SelectionGoal, + times: usize, +) -> (DisplayPoint, SelectionGoal) { + for _ in 0..times { + (point, goal) = movement::up(map, point, goal, true); + } + (point, goal) +} + +pub(crate) fn right(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint { + for _ in 0..times { + let mut new_point = point; + *new_point.column_mut() += 1; + let new_point = map.clip_point(new_point, Bias::Right); + if point == new_point { + break; + } + point = new_point; + } + point +} + +pub(crate) fn next_word_start( + map: &DisplaySnapshot, + mut point: DisplayPoint, + ignore_punctuation: bool, + times: usize, +) -> DisplayPoint { + for _ in 0..times { + let mut crossed_newline = false; + point = movement::find_boundary(map, point, |left, right| { + let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); + let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); + let at_newline = right == '\n'; + + let found = (left_kind != right_kind && right_kind != CharKind::Whitespace) + || at_newline && crossed_newline + || at_newline && left == '\n'; // Prevents skipping repeated empty lines + + if at_newline { + crossed_newline = true; + } + found + }) + } + point } fn next_word_end( map: &DisplaySnapshot, mut point: DisplayPoint, ignore_punctuation: bool, + times: usize, ) -> DisplayPoint { - *point.column_mut() += 1; - point = movement::find_boundary(map, point, |left, right| { - let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); - let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); + for _ in 0..times { + *point.column_mut() += 1; + point = movement::find_boundary(map, point, |left, right| { + let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); + let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); - left_kind != right_kind && !left.is_whitespace() - }); - // find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know - // we have backtraced already - if !map - .chars_at(point) - .nth(1) - .map(|c| c == '\n') - .unwrap_or(true) - { - *point.column_mut() = point.column().saturating_sub(1); + left_kind != right_kind && left_kind != CharKind::Whitespace + }); + + // find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know + // we have backtraced already + if !map + .chars_at(point) + .nth(1) + .map(|(c, _)| c == '\n') + .unwrap_or(true) + { + *point.column_mut() = point.column().saturating_sub(1); + } + point = map.clip_point(point, Bias::Left); } - map.clip_point(point, Bias::Left) + point } fn previous_word_start( map: &DisplaySnapshot, mut point: DisplayPoint, ignore_punctuation: bool, + times: usize, ) -> DisplayPoint { - // This works even though find_preceding_boundary is called for every character in the line containing - // cursor because the newline is checked only once. - point = movement::find_preceding_boundary(map, point, |left, right| { - let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); - let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); + for _ in 0..times { + // This works even though find_preceding_boundary is called for every character in the line containing + // cursor because the newline is checked only once. + point = movement::find_preceding_boundary(map, point, |left, right| { + let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); + let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); - (left_kind != right_kind && !right.is_whitespace()) || left == '\n' - }); + (left_kind != right_kind && !right.is_whitespace()) || left == '\n' + }); + } point } -fn first_non_whitespace(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { - let mut column = 0; - for ch in map.chars_at(DisplayPoint::new(point.row(), 0)) { +fn first_non_whitespace(map: &DisplaySnapshot, from: DisplayPoint) -> DisplayPoint { + let mut last_point = DisplayPoint::new(from.row(), 0); + for (ch, point) in map.chars_at(last_point) { if ch == '\n' { - return point; + return from; } + last_point = point; + if char_kind(ch) != CharKind::Whitespace { break; } - - column += ch.len_utf8() as u32; } - *point.column_mut() = column; - map.clip_point(point, Bias::Left) + map.clip_point(last_point, Bias::Left) } fn start_of_line(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { @@ -333,8 +397,8 @@ fn end_of_line(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { map.clip_point(map.next_line_boundary(point.to_point(map)).1, Bias::Left) } -fn start_of_document(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { - let mut new_point = 0usize.to_display_point(map); +fn start_of_document(map: &DisplaySnapshot, point: DisplayPoint, line: usize) -> DisplayPoint { + let mut new_point = (line - 1).to_display_point(map); *new_point.column_mut() = point.column(); map.clip_point(new_point, Bias::Left) } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index db5583b4ce..894b77e6e8 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -6,17 +6,23 @@ use std::borrow::Cow; use crate::{ motion::Motion, + object::Object, state::{Mode, Operator}, Vim, }; -use change::init as change_init; -use collections::HashSet; -use editor::{Autoscroll, Bias, ClipboardSelection, DisplayPoint}; +use collections::{HashMap, HashSet}; +use editor::{ + display_map::ToDisplayPoint, Anchor, Autoscroll, Bias, ClipboardSelection, DisplayPoint, +}; use gpui::{actions, MutableAppContext, ViewContext}; use language::{AutoindentMode, Point, SelectionGoal}; use workspace::Workspace; -use self::{change::change_over, delete::delete_over, yank::yank_over}; +use self::{ + change::{change_motion, change_object}, + delete::{delete_motion, delete_object}, + yank::{yank_motion, yank_object}, +}; actions!( vim, @@ -43,48 +49,73 @@ pub fn init(cx: &mut MutableAppContext) { cx.add_action(insert_line_below); cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| { Vim::update(cx, |vim, cx| { - delete_over(vim, Motion::Left, cx); + let times = vim.pop_number_operator(cx); + delete_motion(vim, Motion::Left, times, cx); }) }); cx.add_action(|_: &mut Workspace, _: &DeleteRight, cx| { Vim::update(cx, |vim, cx| { - delete_over(vim, Motion::Right, cx); + let times = vim.pop_number_operator(cx); + delete_motion(vim, Motion::Right, times, cx); }) }); cx.add_action(|_: &mut Workspace, _: &ChangeToEndOfLine, cx| { Vim::update(cx, |vim, cx| { - change_over(vim, Motion::EndOfLine, cx); + let times = vim.pop_number_operator(cx); + change_motion(vim, Motion::EndOfLine, times, cx); }) }); cx.add_action(|_: &mut Workspace, _: &DeleteToEndOfLine, cx| { Vim::update(cx, |vim, cx| { - delete_over(vim, Motion::EndOfLine, cx); + let times = vim.pop_number_operator(cx); + delete_motion(vim, Motion::EndOfLine, times, cx); }) }); cx.add_action(paste); - - change_init(cx); } -pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) { +pub fn normal_motion( + motion: Motion, + operator: Option, + times: usize, + cx: &mut MutableAppContext, +) { Vim::update(cx, |vim, cx| { - match vim.state.operator_stack.pop() { - None => move_cursor(vim, motion, cx), - Some(Operator::Namespace(_)) => { - // Can't do anything for a namespace operator. Ignoring + match operator { + None => move_cursor(vim, motion, times, cx), + Some(Operator::Change) => change_motion(vim, motion, times, cx), + Some(Operator::Delete) => delete_motion(vim, motion, times, cx), + Some(Operator::Yank) => yank_motion(vim, motion, times, cx), + _ => { + // Can't do anything for text objects or namespace operators. Ignoring } - Some(Operator::Change) => change_over(vim, motion, cx), - Some(Operator::Delete) => delete_over(vim, motion, cx), - Some(Operator::Yank) => yank_over(vim, motion, cx), } - vim.clear_operator(cx); }); } -fn move_cursor(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { +pub fn normal_object(object: Object, cx: &mut MutableAppContext) { + Vim::update(cx, |vim, cx| { + match vim.state.operator_stack.pop() { + Some(Operator::Object { around }) => match vim.state.operator_stack.pop() { + Some(Operator::Change) => change_object(vim, object, around, cx), + Some(Operator::Delete) => delete_object(vim, object, around, cx), + Some(Operator::Yank) => yank_object(vim, object, around, cx), + _ => { + // Can't do anything for namespace operators. Ignoring + } + }, + _ => { + // Can't do anything with change/delete/yank and text objects. Ignoring + } + } + vim.clear_operator(cx); + }) +} + +fn move_cursor(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) { vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::Fit), cx, |s| { - s.move_cursors_with(|map, cursor, goal| motion.move_point(map, cursor, goal)) + s.move_cursors_with(|map, cursor, goal| motion.move_point(map, cursor, goal, times)) }) }); } @@ -95,7 +126,7 @@ fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext) { clipboard_text = Cow::Owned(newline_separated_text); } - let mut new_selections = Vec::new(); + // If the pasted text is a single line, the cursor should be placed after + // the newly pasted text. This is easiest done with an anchor after the + // insertion, and then with a fixup to move the selection back one position. + // However if the pasted text is linewise, the cursor should be placed at the start + // of the new text on the following line. This is easiest done with a manually adjusted + // point. + // This enum lets us represent both cases + enum NewPosition { + Inside(Point), + After(Anchor), + } + let mut new_selections: HashMap = Default::default(); editor.buffer().update(cx, |buffer, cx| { let snapshot = buffer.snapshot(cx); let mut start_offset = 0; @@ -253,8 +295,10 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext) { edits.push((point..point, "\n")); } // Drop selection at the start of the next line - let selection_point = Point::new(point.row + 1, 0); - new_selections.push(selection.map(|_| selection_point)); + new_selections.insert( + selection.id, + NewPosition::Inside(Point::new(point.row + 1, 0)), + ); point } else { let mut point = selection.end; @@ -264,7 +308,14 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext) { .clip_point(point, Bias::Right) .to_point(&display_map); - new_selections.push(selection.map(|_| point)); + new_selections.insert( + selection.id, + if to_insert.contains('\n') { + NewPosition::Inside(point) + } else { + NewPosition::After(snapshot.anchor_after(point)) + }, + ); point }; @@ -282,7 +333,25 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext) { }); editor.change_selections(Some(Autoscroll::Fit), cx, |s| { - s.select(new_selections) + s.move_with(|map, selection| { + if let Some(new_position) = new_selections.get(&selection.id) { + match new_position { + NewPosition::Inside(new_point) => { + selection.collapse_to( + new_point.to_display_point(map), + SelectionGoal::None, + ); + } + NewPosition::After(after_point) => { + let mut new_point = after_point.to_display_point(map); + *new_point.column_mut() = + new_point.column().saturating_sub(1); + new_point = map.clip_point(new_point, Bias::Left); + selection.collapse_to(new_point, SelectionGoal::None); + } + } + } + }); }); } else { editor.insert(&clipboard_text, cx); @@ -297,364 +366,165 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext) { #[cfg(test)] mod test { use indoc::indoc; - use util::test::marked_text_offsets; use crate::{ state::{ Mode::{self, *}, Namespace, Operator, }, - vim_test_context::VimTestContext, + test::{NeovimBackedTestContext, VimTestContext}, }; #[gpui::test] async fn test_h(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["h"]); - cx.assert("The qˇuick", "The ˇquick"); - cx.assert("ˇThe quick", "ˇThe quick"); - cx.assert( - indoc! {" - The quick - ˇbrown"}, - indoc! {" - The quick - ˇbrown"}, - ); + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["h"]); + cx.assert_all(indoc! {" + ˇThe qˇuick + ˇbrown" + }) + .await; } #[gpui::test] async fn test_backspace(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["backspace"]); - cx.assert("The qˇuick", "The ˇquick"); - cx.assert("ˇThe quick", "ˇThe quick"); - cx.assert( - indoc! {" - The quick - ˇbrown"}, - indoc! {" - The quick - ˇbrown"}, - ); + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["backspace"]); + cx.assert_all(indoc! {" + ˇThe qˇuick + ˇbrown" + }) + .await; } #[gpui::test] async fn test_j(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["j"]); - cx.assert( - indoc! {" - The ˇquick - brown fox"}, - indoc! {" - The quick - browˇn fox"}, - ); - cx.assert( - indoc! {" - The quick - browˇn fox"}, - indoc! {" - The quick - browˇn fox"}, - ); - cx.assert( - indoc! {" - The quicˇk - brown"}, - indoc! {" - The quick - browˇn"}, - ); - cx.assert( - indoc! {" - The quick - ˇbrown"}, - indoc! {" - The quick - ˇbrown"}, - ); + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["j"]); + cx.assert_all(indoc! {" + ˇThe qˇuick broˇwn + ˇfox jumps" + }) + .await; } #[gpui::test] async fn test_k(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["k"]); - cx.assert( - indoc! {" - The ˇquick - brown fox"}, - indoc! {" - The ˇquick - brown fox"}, - ); - cx.assert( - indoc! {" - The quick - browˇn fox"}, - indoc! {" - The ˇquick - brown fox"}, - ); - cx.assert( - indoc! {" - The - quicˇk"}, - indoc! {" - Thˇe - quick"}, - ); + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["k"]); + cx.assert_all(indoc! {" + ˇThe qˇuick + ˇbrown fˇox jumˇps" + }) + .await; } #[gpui::test] async fn test_l(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["l"]); - cx.assert("The qˇuick", "The quˇick"); - cx.assert("The quicˇk", "The quicˇk"); - cx.assert( - indoc! {" - The quicˇk - brown"}, - indoc! {" - The quicˇk - brown"}, - ); + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["l"]); + cx.assert_all(indoc! {" + ˇThe qˇuicˇk + ˇbrowˇn"}) + .await; } #[gpui::test] async fn test_jump_to_line_boundaries(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["$"]); - cx.assert("Tˇest test", "Test tesˇt"); - cx.assert("Test tesˇt", "Test tesˇt"); - cx.assert( + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.assert_binding_matches_all( + ["$"], indoc! {" - The ˇquick - brown"}, + ˇThe qˇuicˇk + ˇbrowˇn"}, + ) + .await; + cx.assert_binding_matches_all( + ["0"], indoc! {" - The quicˇk - brown"}, - ); - cx.assert( - indoc! {" - The quicˇk - brown"}, - indoc! {" - The quicˇk - brown"}, - ); - - let mut cx = cx.binding(["0"]); - cx.assert("Test ˇtest", "ˇTest test"); - cx.assert("ˇTest test", "ˇTest test"); - cx.assert( - indoc! {" - The ˇquick - brown"}, - indoc! {" - ˇThe quick - brown"}, - ); - cx.assert( - indoc! {" - ˇThe quick - brown"}, - indoc! {" - ˇThe quick - brown"}, - ); + ˇThe qˇuicˇk + ˇbrowˇn"}, + ) + .await; } #[gpui::test] async fn test_jump_to_end(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-g"]); + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-g"]); - cx.assert( - indoc! {" + cx.assert_all(indoc! {" The ˇquick brown fox jumps - over the lazy dog"}, - indoc! {" - The quick - - brown fox jumps - overˇ the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick - - brown fox jumps - overˇ the lazy dog"}, - indoc! {" - The quick - - brown fox jumps - overˇ the lazy dog"}, - ); - cx.assert( - indoc! {" + overˇ the lazy doˇg"}) + .await; + cx.assert(indoc! {" The quiˇck - brown"}, - indoc! {" - The quick - - browˇn"}, - ); - cx.assert( - indoc! {" + brown"}) + .await; + cx.assert(indoc! {" The quiˇck - "}, - indoc! {" - The quick - - ˇ"}, - ); + "}) + .await; } #[gpui::test] async fn test_w(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true).await; - let (_, cursor_offsets) = marked_text_offsets(indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["w"]); + cx.assert_all(indoc! {" The ˇquickˇ-ˇbrown ˇ ˇ ˇfox_jumps ˇover - ˇthˇˇe"}); - cx.set_state( - indoc! {" - ˇThe quick-brown - - - fox_jumps over - the"}, - Mode::Normal, - ); - - for cursor_offset in cursor_offsets { - cx.simulate_keystroke("w"); - cx.assert_editor_selections(vec![cursor_offset..cursor_offset]); - } - - // Reset and test ignoring punctuation - let (_, cursor_offsets) = marked_text_offsets(indoc! {" - The ˇquick-brown + ˇthˇe"}) + .await; + let mut cx = cx.binding(["shift-w"]); + cx.assert_all(indoc! {" + The ˇquickˇ-ˇbrown ˇ ˇ ˇfox_jumps ˇover - ˇthˇˇe"}); - cx.set_state( - indoc! {" - ˇThe quick-brown - - - fox_jumps over - the"}, - Mode::Normal, - ); - - for cursor_offset in cursor_offsets { - cx.simulate_keystroke("shift-w"); - cx.assert_editor_selections(vec![cursor_offset..cursor_offset]); - } + ˇthˇe"}) + .await; } #[gpui::test] async fn test_e(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true).await; - let (_, cursor_offsets) = marked_text_offsets(indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["e"]); + cx.assert_all(indoc! {" Thˇe quicˇkˇ-browˇn fox_jumpˇs oveˇr - thˇe"}); - cx.set_state( - indoc! {" - ˇThe quick-brown - - - fox_jumps over - the"}, - Mode::Normal, - ); - - for cursor_offset in cursor_offsets { - cx.simulate_keystroke("e"); - cx.assert_editor_selections(vec![cursor_offset..cursor_offset]); - } - - // Reset and test ignoring punctuation - let (_, cursor_offsets) = marked_text_offsets(indoc! {" - Thˇe quick-browˇn + thˇe"}) + .await; + let mut cx = cx.binding(["shift-e"]); + cx.assert_all(indoc! {" + Thˇe quicˇkˇ-browˇn fox_jumpˇs oveˇr - thˇˇe"}); - cx.set_state( - indoc! {" - ˇThe quick-brown - - - fox_jumps over - the"}, - Mode::Normal, - ); - for cursor_offset in cursor_offsets { - cx.simulate_keystroke("shift-e"); - cx.assert_editor_selections(vec![cursor_offset..cursor_offset]); - } + thˇe"}) + .await; } #[gpui::test] async fn test_b(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true).await; - let (_, cursor_offsets) = marked_text_offsets(indoc! {" - ˇˇThe ˇquickˇ-ˇbrown + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["b"]); + cx.assert_all(indoc! {" + ˇThe ˇquickˇ-ˇbrown ˇ ˇ ˇfox_jumps ˇover - ˇthe"}); - cx.set_state( - indoc! {" - The quick-brown - - - fox_jumps over - thˇe"}, - Mode::Normal, - ); - - for cursor_offset in cursor_offsets.into_iter().rev() { - cx.simulate_keystroke("b"); - cx.assert_editor_selections(vec![cursor_offset..cursor_offset]); - } - - // Reset and test ignoring punctuation - let (_, cursor_offsets) = marked_text_offsets(indoc! {" - ˇˇThe ˇquick-brown + ˇthe"}) + .await; + let mut cx = cx.binding(["shift-b"]); + cx.assert_all(indoc! {" + ˇThe ˇquickˇ-ˇbrown ˇ ˇ ˇfox_jumps ˇover - ˇthe"}); - cx.set_state( - indoc! {" - The quick-brown - - - fox_jumps over - thˇe"}, - Mode::Normal, - ); - for cursor_offset in cursor_offsets.into_iter().rev() { - cx.simulate_keystroke("shift-b"); - cx.assert_editor_selections(vec![cursor_offset..cursor_offset]); - } + ˇthe"}) + .await; } #[gpui::test] @@ -675,513 +545,271 @@ mod test { #[gpui::test] async fn test_gg(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["g", "g"]); - cx.assert( - indoc! {" - The quick - - brown fox jumps - over ˇthe lazy dog"}, + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.assert_binding_matches_all( + ["g", "g"], indoc! {" The qˇuick brown fox jumps - over the lazy dog"}, - ); - cx.assert( - indoc! {" - The qˇuick - - brown fox jumps - over the lazy dog"}, - indoc! {" - The qˇuick - - brown fox jumps - over the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick - - brown fox jumps - over the laˇzy dog"}, - indoc! {" - The quicˇk - - brown fox jumps - over the lazy dog"}, - ); - cx.assert( + over ˇthe laˇzy dog"}, + ) + .await; + cx.assert_binding_matches( + ["g", "g"], indoc! {" brown fox jumps over the laˇzy dog"}, + ) + .await; + cx.assert_binding_matches( + ["2", "g", "g"], indoc! {" - ˇ - - brown fox jumps - over the lazy dog"}, - ); + + + brown fox juˇmps + over the lazydog"}, + ) + .await; } #[gpui::test] async fn test_a(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["a"]).mode_after(Mode::Insert); - - cx.assert("The qˇuick", "The quˇick"); - cx.assert("The quicˇk", "The quickˇ"); + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["a"]); + cx.assert_all("The qˇuicˇk").await; } #[gpui::test] async fn test_insert_end_of_line(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-a"]).mode_after(Mode::Insert); - cx.assert("The qˇuick", "The quickˇ"); - cx.assert("The qˇuick ", "The quick ˇ"); - cx.assert("ˇ", "ˇ"); - cx.assert( - indoc! {" - The qˇuick - brown fox"}, - indoc! {" - The quickˇ - brown fox"}, - ); - cx.assert( - indoc! {" - ˇ - The quick"}, - indoc! {" - ˇ - The quick"}, - ); + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-a"]); + cx.assert_all(indoc! {" + ˇ + The qˇuick + brown ˇfox "}) + .await; } #[gpui::test] async fn test_jump_to_first_non_whitespace(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["^"]); - cx.assert("The qˇuick", "ˇThe quick"); - cx.assert(" The qˇuick", " ˇThe quick"); - cx.assert("ˇ", "ˇ"); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["^"]); + cx.assert("The qˇuick").await; + cx.assert(" The qˇuick").await; + cx.assert("ˇ").await; + cx.assert(indoc! {" The qˇuick - brown fox"}, - indoc! {" - ˇThe quick - brown fox"}, - ); - cx.assert( - indoc! {" + brown fox"}) + .await; + cx.assert(indoc! {" ˇ - The quick"}, - indoc! {" - ˇ - The quick"}, - ); + The quick"}) + .await; // Indoc disallows trailing whitspace. - cx.assert(" ˇ \nThe quick", " ˇ \nThe quick"); + cx.assert(" ˇ \nThe quick").await; } #[gpui::test] async fn test_insert_first_non_whitespace(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-i"]).mode_after(Mode::Insert); - cx.assert("The qˇuick", "ˇThe quick"); - cx.assert(" The qˇuick", " ˇThe quick"); - cx.assert("ˇ", "ˇ"); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-i"]); + cx.assert("The qˇuick").await; + cx.assert(" The qˇuick").await; + cx.assert("ˇ").await; + cx.assert(indoc! {" The qˇuick - brown fox"}, - indoc! {" - ˇThe quick - brown fox"}, - ); - cx.assert( - indoc! {" + brown fox"}) + .await; + cx.assert(indoc! {" ˇ - The quick"}, - indoc! {" - ˇ - The quick"}, - ); + The quick"}) + .await; } #[gpui::test] async fn test_delete_to_end_of_line(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-d"]); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-d"]); + cx.assert(indoc! {" The qˇuick - brown fox"}, - indoc! {" - The ˇq - brown fox"}, - ); - cx.assert( - indoc! {" + brown fox"}) + .await; + cx.assert(indoc! {" The quick ˇ - brown fox"}, - indoc! {" - The quick - ˇ - brown fox"}, - ); + brown fox"}) + .await; } #[gpui::test] async fn test_x(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["x"]); - cx.assert("ˇTest", "ˇest"); - cx.assert("Teˇst", "Teˇt"); - cx.assert("Tesˇt", "Teˇs"); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["x"]); + cx.assert_all("ˇTeˇsˇt").await; + cx.assert(indoc! {" Tesˇt - test"}, - indoc! {" - Teˇs - test"}, - ); + test"}) + .await; } #[gpui::test] async fn test_delete_left(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-x"]); - cx.assert("Teˇst", "Tˇst"); - cx.assert("Tˇest", "ˇest"); - cx.assert("ˇTest", "ˇTest"); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-x"]); + cx.assert_all("ˇTˇeˇsˇt").await; + cx.assert(indoc! {" Test - ˇtest"}, - indoc! {" - Test - ˇtest"}, - ); + ˇtest"}) + .await; } #[gpui::test] async fn test_o(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["o"]).mode_after(Mode::Insert); - - cx.assert( - "ˇ", - indoc! {" - - ˇ"}, - ); - cx.assert( - "The ˇquick", - indoc! {" - The quick - ˇ"}, - ); - cx.assert( - indoc! {" - The quick - brown ˇfox - jumps over"}, - indoc! {" - The quick - brown fox - ˇ - jumps over"}, - ); - cx.assert( - indoc! {" - The quick - brown fox - jumps ˇover"}, - indoc! {" - The quick - brown fox - jumps over - ˇ"}, - ); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["o"]); + cx.assert("ˇ").await; + cx.assert("The ˇquick").await; + cx.assert_all(indoc! {" The qˇuick - brown fox - jumps over"}, - indoc! {" + brown ˇfox + jumps ˇover"}) + .await; + cx.assert(indoc! {" The quick ˇ - brown fox - jumps over"}, - ); - cx.assert( - indoc! {" - The quick - ˇ - brown fox"}, - indoc! {" - The quick - - ˇ - brown fox"}, - ); - cx.assert( - indoc! {" + brown fox"}) + .await; + cx.assert(indoc! {" fn test() { println!(ˇ); } - "}, - indoc! {" - fn test() { - println!(); - ˇ - } - "}, - ); - cx.assert( - indoc! {" + "}) + .await; + cx.assert(indoc! {" fn test(ˇ) { println!(); - }"}, - indoc! {" - fn test() { - ˇ - println!(); - }"}, - ); + }"}) + .await; } #[gpui::test] async fn test_insert_line_above(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-o"]).mode_after(Mode::Insert); + let cx = NeovimBackedTestContext::new(cx).await; + let mut cx = cx.binding(["shift-o"]); + cx.assert("ˇ").await; + cx.assert("The ˇquick").await; + cx.assert_all(indoc! {" + The qˇuick + brown ˇfox + jumps ˇover"}) + .await; + cx.assert(indoc! {" + The quick + ˇ + brown fox"}) + .await; - cx.assert( - "ˇ", - indoc! {" - ˇ - "}, - ); - cx.assert( - "The ˇquick", - indoc! {" - ˇ - The quick"}, - ); - cx.assert( - indoc! {" - The quick - brown ˇfox - jumps over"}, - indoc! {" - The quick - ˇ - brown fox - jumps over"}, - ); - cx.assert( - indoc! {" - The quick - brown fox - jumps ˇover"}, - indoc! {" - The quick - brown fox - ˇ - jumps over"}, - ); - cx.assert( - indoc! {" - The qˇuick - brown fox - jumps over"}, - indoc! {" - ˇ - The quick - brown fox - jumps over"}, - ); - cx.assert( - indoc! {" - The quick - ˇ - brown fox"}, - indoc! {" - The quick - ˇ - - brown fox"}, - ); - cx.assert( + // Our indentation is smarter than vims. So we don't match here + cx.assert_manual( indoc! {" fn test() println!(ˇ);"}, + Mode::Normal, indoc! {" fn test() ˇ println!();"}, + Mode::Insert, ); - cx.assert( + cx.assert_manual( indoc! {" fn test(ˇ) { println!(); }"}, + Mode::Normal, indoc! {" ˇ fn test() { println!(); }"}, + Mode::Insert, ); } #[gpui::test] async fn test_dd(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["d", "d"]); - - cx.assert("ˇ", "ˇ"); - cx.assert("The ˇquick", "ˇ"); - cx.assert( - indoc! {" - The quick - brown ˇfox - jumps over"}, - indoc! {" - The quick - jumps ˇover"}, - ); - cx.assert( - indoc! {" - The quick - brown fox - jumps ˇover"}, - indoc! {" - The quick - brown ˇfox"}, - ); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["d", "d"]); + cx.assert("ˇ").await; + cx.assert("The ˇquick").await; + cx.assert_all(indoc! {" The qˇuick - brown fox - jumps over"}, - indoc! {" - brownˇ fox - jumps over"}, - ); - cx.assert( - indoc! {" + brown ˇfox + jumps ˇover"}) + .await; + cx.assert(indoc! {" The quick ˇ - brown fox"}, - indoc! {" - The quick - ˇbrown fox"}, - ); + brown fox"}) + .await; } #[gpui::test] async fn test_cc(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["c", "c"]).mode_after(Mode::Insert); - - cx.assert("ˇ", "ˇ"); - cx.assert("The ˇquick", "ˇ"); - cx.assert( - indoc! {" - The quick + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["c", "c"]); + cx.assert("ˇ").await; + cx.assert("The ˇquick").await; + cx.assert_all(indoc! {" + The quˇick brown ˇfox - jumps over"}, - indoc! {" + jumps ˇover"}) + .await; + cx.assert(indoc! {" The quick ˇ - jumps over"}, - ); - cx.assert( - indoc! {" - The quick - brown fox - jumps ˇover"}, - indoc! {" - The quick - brown fox - ˇ"}, - ); - cx.assert( - indoc! {" - The qˇuick - brown fox - jumps over"}, - indoc! {" - ˇ - brown fox - jumps over"}, - ); - cx.assert( - indoc! {" - The quick - ˇ - brown fox"}, - indoc! {" - The quick - ˇ - brown fox"}, - ); + brown fox"}) + .await; } #[gpui::test] async fn test_p(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true).await; - cx.set_state( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state(indoc! {" The quick brown fox juˇmps over - the lazy dog"}, - Mode::Normal, - ); + the lazy dog"}) + .await; - cx.simulate_keystrokes(["d", "d"]); - cx.assert_editor_state(indoc! {" - The quick brown - the laˇzy dog"}); + cx.simulate_shared_keystrokes(["d", "d"]).await; + cx.assert_state_matches().await; - cx.simulate_keystroke("p"); - cx.assert_state( - indoc! {" + cx.simulate_shared_keystroke("p").await; + cx.assert_state_matches().await; + + cx.set_shared_state(indoc! {" The quick brown - the lazy dog - ˇfox jumps over"}, - Mode::Normal, - ); - - cx.set_state( - indoc! {" - The quick brown - fox «jumpˇ»s over - the lazy dog"}, - Mode::Visual { line: false }, - ); - cx.simulate_keystroke("y"); - cx.set_state( - indoc! {" + fox ˇjumps over + the lazy dog"}) + .await; + cx.simulate_shared_keystrokes(["v", "w", "y"]).await; + cx.set_shared_state(indoc! {" The quick brown fox jumps oveˇr - the lazy dog"}, - Mode::Normal, - ); - cx.simulate_keystroke("p"); - cx.assert_state( - indoc! {" - The quick brown - fox jumps overˇjumps - the lazy dog"}, - Mode::Normal, - ); + the lazy dog"}) + .await; + cx.simulate_shared_keystroke("p").await; + cx.assert_state_matches().await; + } + + #[gpui::test] + async fn test_repeated_word(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + for count in 1..=5 { + cx.assert_binding_matches_all( + [&count.to_string(), "w"], + indoc! {" + ˇThe quˇickˇ browˇn + ˇ + ˇfox ˇjumpsˇ-ˇoˇver + ˇthe lazy dog + "}, + ) + .await; + } } } diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index 8695c9668b..ee83c3490d 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -1,30 +1,20 @@ -use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim}; -use editor::{char_kind, movement, Autoscroll}; -use gpui::{impl_actions, MutableAppContext, ViewContext}; -use serde::Deserialize; -use workspace::Workspace; +use crate::{motion::Motion, object::Object, state::Mode, utils::copy_selections_content, Vim}; +use editor::{char_kind, display_map::DisplaySnapshot, movement, Autoscroll, DisplayPoint}; +use gpui::MutableAppContext; +use language::Selection; -#[derive(Clone, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -struct ChangeWord { - #[serde(default)] - ignore_punctuation: bool, -} - -impl_actions!(vim, [ChangeWord]); - -pub fn init(cx: &mut MutableAppContext) { - cx.add_action(change_word); -} - -pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { +pub fn change_motion(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { // We are swapping to insert mode anyway. Just set the line end clipping behavior now editor.set_clip_at_line_ends(false, cx); editor.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_with(|map, selection| { - motion.expand_selection(map, selection, false); + if let Motion::NextWordStart { ignore_punctuation } = motion { + expand_changed_word_selection(map, selection, times, ignore_punctuation); + } else { + motion.expand_selection(map, selection, times, false); + } }); }); copy_selections_content(editor, motion.linewise(), cx); @@ -34,43 +24,60 @@ pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { vim.switch_mode(Mode::Insert, false, cx) } +pub fn change_object(vim: &mut Vim, object: Object, around: bool, cx: &mut MutableAppContext) { + let mut objects_found = false; + vim.update_active_editor(cx, |editor, cx| { + // We are swapping to insert mode anyway. Just set the line end clipping behavior now + editor.set_clip_at_line_ends(false, cx); + editor.transact(cx, |editor, cx| { + editor.change_selections(Some(Autoscroll::Fit), cx, |s| { + s.move_with(|map, selection| { + objects_found |= object.expand_selection(map, selection, around); + }); + }); + if objects_found { + copy_selections_content(editor, false, cx); + editor.insert("", cx); + } + }); + }); + + if objects_found { + vim.switch_mode(Mode::Insert, false, cx); + } else { + vim.switch_mode(Mode::Normal, false, cx); + } +} + // From the docs https://vimhelp.org/change.txt.html#cw // Special case: When the cursor is in a word, "cw" and "cW" do not include the // white space after a word, they only change up to the end of the word. This is // because Vim interprets "cw" as change-word, and a word does not include the // following white space. -fn change_word( - _: &mut Workspace, - &ChangeWord { ignore_punctuation }: &ChangeWord, - cx: &mut ViewContext, +fn expand_changed_word_selection( + map: &DisplaySnapshot, + selection: &mut Selection, + times: usize, + ignore_punctuation: bool, ) { - Vim::update(cx, |vim, cx| { - vim.update_active_editor(cx, |editor, cx| { - editor.transact(cx, |editor, cx| { - // We are swapping to insert mode anyway. Just set the line end clipping behavior now - editor.set_clip_at_line_ends(false, cx); - editor.change_selections(Some(Autoscroll::Fit), cx, |s| { - s.move_with(|map, selection| { - if selection.end.column() == map.line_len(selection.end.row()) { - return; - } + if times > 1 { + Motion::NextWordStart { ignore_punctuation }.expand_selection( + map, + selection, + times - 1, + false, + ); + } - selection.end = - movement::find_boundary(map, selection.end, |left, right| { - let left_kind = - char_kind(left).coerce_punctuation(ignore_punctuation); - let right_kind = - char_kind(right).coerce_punctuation(ignore_punctuation); + if times == 1 && selection.end.column() == map.line_len(selection.end.row()) { + return; + } - left_kind != right_kind || left == '\n' || right == '\n' - }); - }); - }); - copy_selections_content(editor, false, cx); - editor.insert("", cx); - }); - }); - vim.switch_mode(Mode::Insert, false, cx); + selection.end = movement::find_boundary(map, selection.end, |left, right| { + let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); + let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); + + left_kind != right_kind || left == '\n' || right == '\n' }); } @@ -78,7 +85,10 @@ fn change_word( mod test { use indoc::indoc; - use crate::{state::Mode, vim_test_context::VimTestContext}; + use crate::{ + state::Mode, + test::{NeovimBackedTestContext, VimTestContext}, + }; #[gpui::test] async fn test_change_h(cx: &mut gpui::TestAppContext) { @@ -170,8 +180,7 @@ mod test { test"}, indoc! {" Test test - ˇ - test"}, + ˇ"}, ); let mut cx = cx.binding(["c", "shift-e"]); @@ -193,6 +202,7 @@ mod test { Test ˇ test"}, ); + println!("Marker"); cx.assert( indoc! {" Test test @@ -442,4 +452,85 @@ mod test { the lazy"}, ); } + + #[gpui::test] + async fn test_repeated_cj(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + for count in 1..=5 { + cx.assert_binding_matches_all( + ["c", &count.to_string(), "j"], + indoc! {" + ˇThe quˇickˇ browˇn + ˇ + ˇfox ˇjumpsˇ-ˇoˇver + ˇthe lazy dog + "}, + ) + .await; + } + } + + #[gpui::test] + async fn test_repeated_cl(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + for count in 1..=5 { + cx.assert_binding_matches_all( + ["c", &count.to_string(), "l"], + indoc! {" + ˇThe quˇickˇ browˇn + ˇ + ˇfox ˇjumpsˇ-ˇoˇver + ˇthe lazy dog + "}, + ) + .await; + } + } + + #[gpui::test] + async fn test_repeated_cb(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // Changing back any number of times from the start of the file doesn't + // switch to insert mode in vim. This is weird and painful to implement + cx.add_initial_state_exemption(indoc! {" + ˇThe quick brown + + fox jumps-over + the lazy dog + "}); + + for count in 1..=5 { + cx.assert_binding_matches_all( + ["c", &count.to_string(), "b"], + indoc! {" + ˇThe quˇickˇ browˇn + ˇ + ˇfox ˇjumpsˇ-ˇoˇver + ˇthe lazy dog + "}, + ) + .await; + } + } + + #[gpui::test] + async fn test_repeated_ce(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + for count in 1..=5 { + cx.assert_binding_matches_all( + ["c", &count.to_string(), "e"], + indoc! {" + ˇThe quˇickˇ browˇn + ˇ + ˇfox ˇjumpsˇ-ˇoˇver + ˇthe lazy dog + "}, + ) + .await; + } + } } diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index b2e228bdb1..a2c540a59c 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -1,9 +1,9 @@ -use crate::{motion::Motion, utils::copy_selections_content, Vim}; -use collections::HashMap; -use editor::{Autoscroll, Bias}; +use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim}; +use collections::{HashMap, HashSet}; +use editor::{display_map::ToDisplayPoint, Autoscroll, Bias}; use gpui::MutableAppContext; -pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { +pub fn delete_motion(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); @@ -11,8 +11,8 @@ pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { editor.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_with(|map, selection| { let original_head = selection.head(); - motion.expand_selection(map, selection, true); original_columns.insert(selection.id, original_head.column()); + motion.expand_selection(map, selection, times, true); }); }); copy_selections_content(editor, motion.linewise(), cx); @@ -36,11 +36,67 @@ pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { }); } +pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut MutableAppContext) { + vim.update_active_editor(cx, |editor, cx| { + editor.transact(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + // Emulates behavior in vim where if we expanded backwards to include a newline + // the cursor gets set back to the start of the line + let mut should_move_to_start: HashSet<_> = Default::default(); + editor.change_selections(Some(Autoscroll::Fit), cx, |s| { + s.move_with(|map, selection| { + object.expand_selection(map, selection, around); + let offset_range = selection.map(|p| p.to_offset(map, Bias::Left)).range(); + let contains_only_newlines = map + .chars_at(selection.start) + .take_while(|(_, p)| p < &selection.end) + .all(|(char, _)| char == '\n') + && !offset_range.is_empty(); + let end_at_newline = map + .chars_at(selection.end) + .next() + .map(|(c, _)| c == '\n') + .unwrap_or(false); + + // If expanded range contains only newlines and + // the object is around or sentence, expand to include a newline + // at the end or start + if (around || object == Object::Sentence) && contains_only_newlines { + if end_at_newline { + selection.end = + (offset_range.end + '\n'.len_utf8()).to_display_point(map); + } else if selection.start.row() > 0 { + should_move_to_start.insert(selection.id); + selection.start = + (offset_range.start - '\n'.len_utf8()).to_display_point(map); + } + } + }); + }); + copy_selections_content(editor, false, cx); + editor.insert("", cx); + + // Fixup cursor position after the deletion + editor.set_clip_at_line_ends(true, cx); + editor.change_selections(Some(Autoscroll::Fit), cx, |s| { + s.move_with(|map, selection| { + let mut cursor = selection.head(); + if should_move_to_start.contains(&selection.id) { + *cursor.column_mut() = 0; + } + cursor = map.clip_point(cursor, Bias::Left); + selection.collapse_to(cursor, selection.goal) + }); + }); + }); + }); +} + #[cfg(test)] mod test { use indoc::indoc; - use crate::{state::Mode, vim_test_context::VimTestContext}; + use crate::{state::Mode, test::VimTestContext}; #[gpui::test] async fn test_delete_h(cx: &mut gpui::TestAppContext) { @@ -140,8 +196,7 @@ mod test { test"}, indoc! {" Test test - ˇ - test"}, + ˇ"}, ); let mut cx = cx.binding(["d", "shift-e"]); diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs index 17a9e47d3d..e7d6b3076b 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -1,8 +1,8 @@ -use crate::{motion::Motion, utils::copy_selections_content, Vim}; +use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim}; use collections::HashMap; use gpui::MutableAppContext; -pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { +pub fn yank_motion(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); @@ -10,8 +10,8 @@ pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { editor.change_selections(None, cx, |s| { s.move_with(|map, selection| { let original_position = (selection.head(), selection.goal); - motion.expand_selection(map, selection, true); original_positions.insert(selection.id, original_position); + motion.expand_selection(map, selection, times, true); }); }); copy_selections_content(editor, motion.linewise(), cx); @@ -24,3 +24,26 @@ pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { }); }); } + +pub fn yank_object(vim: &mut Vim, object: Object, around: bool, cx: &mut MutableAppContext) { + vim.update_active_editor(cx, |editor, cx| { + editor.transact(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + let mut original_positions: HashMap<_, _> = Default::default(); + editor.change_selections(None, cx, |s| { + s.move_with(|map, selection| { + let original_position = (selection.head(), selection.goal); + object.expand_selection(map, selection, around); + original_positions.insert(selection.id, original_position); + }); + }); + copy_selections_content(editor, false, cx); + editor.change_selections(None, cx, |s| { + s.move_with(|_, selection| { + let (head, goal) = original_positions.remove(&selection.id).unwrap(); + selection.collapse_to(head, goal); + }); + }); + }); + }); +} diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs new file mode 100644 index 0000000000..b39dc6790b --- /dev/null +++ b/crates/vim/src/object.rs @@ -0,0 +1,640 @@ +use std::ops::Range; + +use editor::{char_kind, display_map::DisplaySnapshot, movement, Bias, CharKind, DisplayPoint}; +use gpui::{actions, impl_actions, MutableAppContext}; +use language::Selection; +use serde::Deserialize; +use workspace::Workspace; + +use crate::{motion::right, normal::normal_object, state::Mode, visual::visual_object, Vim}; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum Object { + Word { ignore_punctuation: bool }, + Sentence, + Quotes, + BackQuotes, + DoubleQuotes, + Parentheses, + SquareBrackets, + CurlyBrackets, + AngleBrackets, +} + +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct Word { + #[serde(default)] + ignore_punctuation: bool, +} + +actions!( + vim, + [ + Sentence, + Quotes, + BackQuotes, + DoubleQuotes, + Parentheses, + SquareBrackets, + CurlyBrackets, + AngleBrackets + ] +); +impl_actions!(vim, [Word]); + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action( + |_: &mut Workspace, &Word { ignore_punctuation }: &Word, cx: _| { + object(Object::Word { ignore_punctuation }, cx) + }, + ); + cx.add_action(|_: &mut Workspace, _: &Sentence, cx: _| object(Object::Sentence, cx)); + cx.add_action(|_: &mut Workspace, _: &Quotes, cx: _| object(Object::Quotes, cx)); + cx.add_action(|_: &mut Workspace, _: &BackQuotes, cx: _| object(Object::BackQuotes, cx)); + cx.add_action(|_: &mut Workspace, _: &DoubleQuotes, cx: _| object(Object::DoubleQuotes, cx)); + cx.add_action(|_: &mut Workspace, _: &Parentheses, cx: _| object(Object::Parentheses, cx)); + cx.add_action(|_: &mut Workspace, _: &SquareBrackets, cx: _| { + object(Object::SquareBrackets, cx) + }); + cx.add_action(|_: &mut Workspace, _: &CurlyBrackets, cx: _| object(Object::CurlyBrackets, cx)); + cx.add_action(|_: &mut Workspace, _: &AngleBrackets, cx: _| object(Object::AngleBrackets, cx)); +} + +fn object(object: Object, cx: &mut MutableAppContext) { + match Vim::read(cx).state.mode { + Mode::Normal => normal_object(object, cx), + Mode::Visual { .. } => visual_object(object, cx), + Mode::Insert => { + // Shouldn't execute a text object in insert mode. Ignoring + } + } +} + +impl Object { + pub fn range( + self, + map: &DisplaySnapshot, + relative_to: DisplayPoint, + around: bool, + ) -> Option> { + match self { + Object::Word { ignore_punctuation } => { + if around { + around_word(map, relative_to, ignore_punctuation) + } else { + in_word(map, relative_to, ignore_punctuation) + } + } + Object::Sentence => sentence(map, relative_to, around), + Object::Quotes => surrounding_markers(map, relative_to, around, false, '\'', '\''), + Object::BackQuotes => surrounding_markers(map, relative_to, around, false, '`', '`'), + Object::DoubleQuotes => surrounding_markers(map, relative_to, around, false, '"', '"'), + Object::Parentheses => surrounding_markers(map, relative_to, around, true, '(', ')'), + Object::SquareBrackets => surrounding_markers(map, relative_to, around, true, '[', ']'), + Object::CurlyBrackets => surrounding_markers(map, relative_to, around, true, '{', '}'), + Object::AngleBrackets => surrounding_markers(map, relative_to, around, true, '<', '>'), + } + } + + pub fn expand_selection( + self, + map: &DisplaySnapshot, + selection: &mut Selection, + around: bool, + ) -> bool { + if let Some(range) = self.range(map, selection.head(), around) { + selection.start = range.start; + selection.end = range.end; + true + } else { + false + } + } +} + +/// Return a range that surrounds the word relative_to is in +/// If relative_to is at the start of a word, return the word. +/// If relative_to is between words, return the space between +fn in_word( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + ignore_punctuation: bool, +) -> Option> { + // Use motion::right so that we consider the character under the cursor when looking for the start + let start = movement::find_preceding_boundary_in_line( + map, + right(map, relative_to, 1), + |left, right| { + char_kind(left).coerce_punctuation(ignore_punctuation) + != char_kind(right).coerce_punctuation(ignore_punctuation) + }, + ); + let end = movement::find_boundary_in_line(map, relative_to, |left, right| { + char_kind(left).coerce_punctuation(ignore_punctuation) + != char_kind(right).coerce_punctuation(ignore_punctuation) + }); + + Some(start..end) +} + +/// Return a range that surrounds the word and following whitespace +/// relative_to is in. +/// If relative_to is at the start of a word, return the word and following whitespace. +/// If relative_to is between words, return the whitespace back and the following word + +/// if in word +/// delete that word +/// if there is whitespace following the word, delete that as well +/// otherwise, delete any preceding whitespace +/// otherwise +/// delete whitespace around cursor +/// delete word following the cursor +fn around_word( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + ignore_punctuation: bool, +) -> Option> { + let in_word = map + .chars_at(relative_to) + .next() + .map(|(c, _)| char_kind(c) != CharKind::Whitespace) + .unwrap_or(false); + + if in_word { + around_containing_word(map, relative_to, ignore_punctuation) + } else { + around_next_word(map, relative_to, ignore_punctuation) + } +} + +fn around_containing_word( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + ignore_punctuation: bool, +) -> Option> { + in_word(map, relative_to, ignore_punctuation) + .map(|range| expand_to_include_whitespace(map, range, true)) +} + +fn around_next_word( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + ignore_punctuation: bool, +) -> Option> { + // Get the start of the word + let start = movement::find_preceding_boundary_in_line( + map, + right(map, relative_to, 1), + |left, right| { + char_kind(left).coerce_punctuation(ignore_punctuation) + != char_kind(right).coerce_punctuation(ignore_punctuation) + }, + ); + + let mut word_found = false; + let end = movement::find_boundary(map, relative_to, |left, right| { + let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); + let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); + + let found = (word_found && left_kind != right_kind) || right == '\n' && left == '\n'; + + if right_kind != CharKind::Whitespace { + word_found = true; + } + + found + }); + + Some(start..end) +} + +fn sentence( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + around: bool, +) -> Option> { + let mut start = None; + let mut previous_end = relative_to; + + let mut chars = map.chars_at(relative_to).peekable(); + + // Search backwards for the previous sentence end or current sentence start. Include the character under relative_to + for (char, point) in chars + .peek() + .cloned() + .into_iter() + .chain(map.reverse_chars_at(relative_to)) + { + if is_sentence_end(map, point) { + break; + } + + if is_possible_sentence_start(char) { + start = Some(point); + } + + previous_end = point; + } + + // Search forward for the end of the current sentence or if we are between sentences, the start of the next one + let mut end = relative_to; + for (char, point) in chars { + if start.is_none() && is_possible_sentence_start(char) { + if around { + start = Some(point); + continue; + } else { + end = point; + break; + } + } + + end = point; + *end.column_mut() += char.len_utf8() as u32; + end = map.clip_point(end, Bias::Left); + + if is_sentence_end(map, end) { + break; + } + } + + let mut range = start.unwrap_or(previous_end)..end; + if around { + range = expand_to_include_whitespace(map, range, false); + } + + Some(range) +} + +fn is_possible_sentence_start(character: char) -> bool { + !character.is_whitespace() && character != '.' +} + +const SENTENCE_END_PUNCTUATION: &[char] = &['.', '!', '?']; +const SENTENCE_END_FILLERS: &[char] = &[')', ']', '"', '\'']; +const SENTENCE_END_WHITESPACE: &[char] = &[' ', '\t', '\n']; +fn is_sentence_end(map: &DisplaySnapshot, point: DisplayPoint) -> bool { + let mut next_chars = map.chars_at(point).peekable(); + if let Some((char, _)) = next_chars.next() { + // We are at a double newline. This position is a sentence end. + if char == '\n' && next_chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) { + return true; + } + + // The next text is not a valid whitespace. This is not a sentence end + if !SENTENCE_END_WHITESPACE.contains(&char) { + return false; + } + } + + for (char, _) in map.reverse_chars_at(point) { + if SENTENCE_END_PUNCTUATION.contains(&char) { + return true; + } + + if !SENTENCE_END_FILLERS.contains(&char) { + return false; + } + } + + return false; +} + +/// Expands the passed range to include whitespace on one side or the other in a line. Attempts to add the +/// whitespace to the end first and falls back to the start if there was none. +fn expand_to_include_whitespace( + map: &DisplaySnapshot, + mut range: Range, + stop_at_newline: bool, +) -> Range { + let mut whitespace_included = false; + + let mut chars = map.chars_at(range.end).peekable(); + while let Some((char, point)) = chars.next() { + if char == '\n' && stop_at_newline { + break; + } + + if char.is_whitespace() { + // Set end to the next display_point or the character position after the current display_point + range.end = chars.peek().map(|(_, point)| *point).unwrap_or_else(|| { + let mut end = point; + *end.column_mut() += char.len_utf8() as u32; + map.clip_point(end, Bias::Left) + }); + + if char != '\n' { + whitespace_included = true; + } + } else { + // Found non whitespace. Quit out. + break; + } + } + + if !whitespace_included { + for (char, point) in map.reverse_chars_at(range.start) { + if char == '\n' && stop_at_newline { + break; + } + + if !char.is_whitespace() { + break; + } + + range.start = point; + } + } + + range +} + +fn surrounding_markers( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + around: bool, + search_across_lines: bool, + start_marker: char, + end_marker: char, +) -> Option> { + let mut matched_ends = 0; + let mut start = None; + for (char, mut point) in map.reverse_chars_at(relative_to) { + if char == start_marker { + if matched_ends > 0 { + matched_ends -= 1; + } else { + if around { + start = Some(point) + } else { + *point.column_mut() += char.len_utf8() as u32; + start = Some(point); + } + break; + } + } else if char == end_marker { + matched_ends += 1; + } else if char == '\n' && !search_across_lines { + break; + } + } + + let mut matched_starts = 0; + let mut end = None; + for (char, mut point) in map.chars_at(relative_to) { + if char == end_marker { + if start.is_none() { + break; + } + + if matched_starts > 0 { + matched_starts -= 1; + } else { + if around { + *point.column_mut() += char.len_utf8() as u32; + end = Some(point); + } else { + end = Some(point); + } + + break; + } + } + + if char == start_marker { + if start.is_none() { + if around { + start = Some(point); + } else { + *point.column_mut() += char.len_utf8() as u32; + start = Some(point); + } + } else { + matched_starts += 1; + } + } + + if char == '\n' && !search_across_lines { + break; + } + } + + if let (Some(start), Some(end)) = (start, end) { + Some(start..end) + } else { + None + } +} + +#[cfg(test)] +mod test { + use indoc::indoc; + + use crate::test::NeovimBackedTestContext; + + const WORD_LOCATIONS: &'static str = indoc! {" + The quick ˇbrowˇnˇ + fox ˇjuˇmpsˇ over + the lazy dogˇ + ˇ + ˇ + ˇ + Thˇeˇ-ˇquˇickˇ ˇbrownˇ + ˇ + ˇ + ˇ fox-jumpˇs over + the lazy dogˇ + ˇ + "}; + + #[gpui::test] + async fn test_change_word_object(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.assert_binding_matches_all(["c", "i", "w"], WORD_LOCATIONS) + .await; + cx.assert_binding_matches_all(["c", "i", "shift-w"], WORD_LOCATIONS) + .await; + cx.assert_binding_matches_all(["c", "a", "w"], WORD_LOCATIONS) + .await; + cx.assert_binding_matches_all(["c", "a", "shift-w"], WORD_LOCATIONS) + .await; + } + + #[gpui::test] + async fn test_delete_word_object(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.assert_binding_matches_all(["d", "i", "w"], WORD_LOCATIONS) + .await; + cx.assert_binding_matches_all(["d", "i", "shift-w"], WORD_LOCATIONS) + .await; + cx.assert_binding_matches_all(["d", "a", "w"], WORD_LOCATIONS) + .await; + cx.assert_binding_matches_all(["d", "a", "shift-w"], WORD_LOCATIONS) + .await; + } + + #[gpui::test] + async fn test_visual_word_object(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.assert_binding_matches_all(["v", "i", "w"], WORD_LOCATIONS) + .await; + // Visual text objects are slightly broken when used with non empty selections + // cx.assert_binding_matches_all(["v", "h", "i", "w"], WORD_LOCATIONS) + // .await; + // cx.assert_binding_matches_all(["v", "l", "i", "w"], WORD_LOCATIONS) + // .await; + cx.assert_binding_matches_all(["v", "i", "shift-w"], WORD_LOCATIONS) + .await; + + // Visual text objects are slightly broken when used with non empty selections + // cx.assert_binding_matches_all(["v", "i", "h", "shift-w"], WORD_LOCATIONS) + // .await; + // cx.assert_binding_matches_all(["v", "i", "l", "shift-w"], WORD_LOCATIONS) + // .await; + + // Visual around words is somewhat broken right now when it comes to newlines + // cx.assert_binding_matches_all(["v", "a", "w"], WORD_LOCATIONS) + // .await; + // cx.assert_binding_matches_all(["v", "a", "shift-w"], WORD_LOCATIONS) + // .await; + } + + const SENTENCE_EXAMPLES: &[&'static str] = &[ + "ˇThe quick ˇbrownˇ?ˇ ˇFox Jˇumpsˇ!ˇ Ovˇer theˇ lazyˇ.", + indoc! {" + ˇThe quick ˇbrownˇ + fox jumps over + the lazy doˇgˇ.ˇ ˇThe quick ˇ + brown fox jumps over + "}, + // Position of the cursor after deletion between lines isn't quite right. + // Deletion in a sentence at the start of a line with whitespace is incorrect. + // indoc! {" + // The quick brown fox jumps. + // Over the lazy dog + // ˇ + // ˇ + // ˇ fox-jumpˇs over + // the lazy dog.ˇ + // ˇ + // "}, + r#"ˇThe ˇquick brownˇ.)ˇ]ˇ'ˇ" Brown ˇfox jumpsˇ.ˇ "#, + ]; + + #[gpui::test] + async fn test_change_sentence_object(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["c", "i", "s"]); + for sentence_example in SENTENCE_EXAMPLES { + cx.assert_all(sentence_example).await; + } + + let mut cx = cx.binding(["c", "a", "s"]); + // Resulting position is slightly incorrect for unintuitive reasons. + cx.add_initial_state_exemption("The quick brown?ˇ Fox Jumps! Over the lazy."); + // Changing around the sentence at the end of the line doesn't remove whitespace.' + cx.add_initial_state_exemption("The quick brown.)]\'\" Brown fox jumps.ˇ "); + + for sentence_example in SENTENCE_EXAMPLES { + cx.assert_all(sentence_example).await; + } + } + + #[gpui::test] + async fn test_delete_sentence_object(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["d", "i", "s"]); + for sentence_example in SENTENCE_EXAMPLES { + cx.assert_all(sentence_example).await; + } + + let mut cx = cx.binding(["d", "a", "s"]); + // Resulting position is slightly incorrect for unintuitive reasons. + cx.add_initial_state_exemption("The quick brown?ˇ Fox Jumps! Over the lazy."); + // Changing around the sentence at the end of the line doesn't remove whitespace.' + cx.add_initial_state_exemption("The quick brown.)]\'\" Brown fox jumps.ˇ "); + + for sentence_example in SENTENCE_EXAMPLES { + cx.assert_all(sentence_example).await; + } + } + + #[gpui::test] + async fn test_visual_sentence_object(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["v", "i", "s"]); + for sentence_example in SENTENCE_EXAMPLES { + cx.assert_all(sentence_example).await; + } + + // Visual around sentences is somewhat broken right now when it comes to newlines + // let mut cx = cx.binding(["d", "a", "s"]); + // for sentence_example in SENTENCE_EXAMPLES { + // cx.assert_all(sentence_example).await; + // } + } + + // Test string with "`" for opening surrounders and "'" for closing surrounders + const SURROUNDING_MARKER_STRING: &str = indoc! {" + ˇTh'ˇe ˇ`ˇ'ˇquˇi`ˇck broˇ'wn` + 'ˇfox juˇmps ovˇ`ˇer + the ˇlazy dˇ'ˇoˇ`ˇg"}; + + const SURROUNDING_OBJECTS: &[(char, char)] = &[ + // ('\'', '\''), // Quote, + // ('`', '`'), // Back Quote + // ('"', '"'), // Double Quote + // ('"', '"'), // Double Quote + ('(', ')'), // Parentheses + ('[', ']'), // SquareBrackets + ('{', '}'), // CurlyBrackets + ('<', '>'), // AngleBrackets + ]; + + #[gpui::test] + async fn test_change_surrounding_character_objects(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + for (start, end) in SURROUNDING_OBJECTS { + let marked_string = SURROUNDING_MARKER_STRING + .replace('`', &start.to_string()) + .replace('\'', &end.to_string()); + + // cx.assert_binding_matches_all(["c", "i", &start.to_string()], &marked_string) + // .await; + cx.assert_binding_matches_all(["c", "i", &end.to_string()], &marked_string) + .await; + // cx.assert_binding_matches_all(["c", "a", &start.to_string()], &marked_string) + // .await; + cx.assert_binding_matches_all(["c", "a", &end.to_string()], &marked_string) + .await; + } + } + + #[gpui::test] + async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + for (start, end) in SURROUNDING_OBJECTS { + let marked_string = SURROUNDING_MARKER_STRING + .replace('`', &start.to_string()) + .replace('\'', &end.to_string()); + + // cx.assert_binding_matches_all(["d", "i", &start.to_string()], &marked_string) + // .await; + cx.assert_binding_matches_all(["d", "i", &end.to_string()], &marked_string) + .await; + // cx.assert_binding_matches_all(["d", "a", &start.to_string()], &marked_string) + // .await; + cx.assert_binding_matches_all(["d", "a", &end.to_string()], &marked_string) + .await; + } + } +} diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index e556048ea8..fef0da2099 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1,8 +1,8 @@ use editor::CursorShape; use gpui::keymap::Context; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] pub enum Mode { Normal, Insert, @@ -22,10 +22,12 @@ pub enum Namespace { #[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)] pub enum Operator { + Number(usize), Namespace(Namespace), Change, Delete, Yank, + Object { around: bool }, } #[derive(Default)] @@ -77,7 +79,12 @@ impl VimState { context.set.insert("VimControl".to_string()); } - Operator::set_context(self.operator_stack.last(), &mut context); + let active_operator = self.operator_stack.last(); + if matches!(active_operator, Some(Operator::Object { .. })) { + context.set.insert("VimObject".to_string()); + } + + Operator::set_context(active_operator, &mut context); context } @@ -86,10 +93,14 @@ impl VimState { impl Operator { pub fn set_context(operator: Option<&Operator>, context: &mut Context) { let operator_context = match operator { + Some(Operator::Number(_)) => "n", Some(Operator::Namespace(Namespace::G)) => "g", + Some(Operator::Object { around: false }) => "i", + Some(Operator::Object { around: true }) => "a", Some(Operator::Change) => "c", Some(Operator::Delete) => "d", Some(Operator::Yank) => "y", + None => "none", } .to_owned(); diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs new file mode 100644 index 0000000000..e320962cfa --- /dev/null +++ b/crates/vim/src/test.rs @@ -0,0 +1,103 @@ +mod neovim_backed_binding_test_context; +mod neovim_backed_test_context; +mod neovim_connection; +mod vim_binding_test_context; +mod vim_test_context; + +pub use neovim_backed_binding_test_context::*; +pub use neovim_backed_test_context::*; +pub use vim_binding_test_context::*; +pub use vim_test_context::*; + +use indoc::indoc; +use search::BufferSearchBar; + +use crate::state::Mode; + +#[gpui::test] +async fn test_initially_disabled(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, false).await; + cx.simulate_keystrokes(["h", "j", "k", "l"]); + cx.assert_editor_state("hjklˇ"); +} + +#[gpui::test] +async fn test_neovim(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.simulate_shared_keystroke("i").await; + cx.assert_state_matches().await; + cx.simulate_shared_keystrokes([ + "shift-T", "e", "s", "t", " ", "t", "e", "s", "t", "escape", "0", "d", "w", + ]) + .await; + cx.assert_state_matches().await; + cx.assert_editor_state("ˇtest"); +} + +#[gpui::test] +async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.simulate_keystroke("i"); + assert_eq!(cx.mode(), Mode::Insert); + + // Editor acts as though vim is disabled + cx.disable_vim(); + cx.simulate_keystrokes(["h", "j", "k", "l"]); + cx.assert_editor_state("hjklˇ"); + + // Selections aren't changed if editor is blurred but vim-mode is still disabled. + cx.set_state("«hjklˇ»", Mode::Normal); + cx.assert_editor_state("«hjklˇ»"); + cx.update_editor(|_, cx| cx.blur()); + cx.assert_editor_state("«hjklˇ»"); + cx.update_editor(|_, cx| cx.focus_self()); + cx.assert_editor_state("«hjklˇ»"); + + // Enabling dynamically sets vim mode again and restores normal mode + cx.enable_vim(); + assert_eq!(cx.mode(), Mode::Normal); + cx.simulate_keystrokes(["h", "h", "h", "l"]); + assert_eq!(cx.buffer_text(), "hjkl".to_owned()); + cx.assert_editor_state("hˇjkl"); + cx.simulate_keystrokes(["i", "T", "e", "s", "t"]); + cx.assert_editor_state("hTestˇjkl"); + + // Disabling and enabling resets to normal mode + assert_eq!(cx.mode(), Mode::Insert); + cx.disable_vim(); + cx.enable_vim(); + assert_eq!(cx.mode(), Mode::Normal); +} + +#[gpui::test] +async fn test_buffer_search(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {" + The quick brown + fox juˇmps over + the lazy dog"}, + Mode::Normal, + ); + cx.simulate_keystroke("/"); + + // We now use a weird insert mode with selection when jumping to a single line editor + assert_eq!(cx.mode(), Mode::Insert); + + let search_bar = cx.workspace(|workspace, cx| { + workspace + .active_pane() + .read(cx) + .toolbar() + .read(cx) + .item_of_type::() + .expect("Buffer search bar should be deployed") + }); + + search_bar.read_with(cx.cx, |bar, cx| { + assert_eq!(bar.query_editor.read(cx).text(cx), "jumps"); + }) +} diff --git a/crates/vim/src/test/neovim_backed_binding_test_context.rs b/crates/vim/src/test/neovim_backed_binding_test_context.rs new file mode 100644 index 0000000000..a768aff59d --- /dev/null +++ b/crates/vim/src/test/neovim_backed_binding_test_context.rs @@ -0,0 +1,80 @@ +use std::ops::{Deref, DerefMut}; + +use gpui::ContextHandle; + +use crate::state::Mode; + +use super::NeovimBackedTestContext; + +pub struct NeovimBackedBindingTestContext<'a, const COUNT: usize> { + cx: NeovimBackedTestContext<'a>, + keystrokes_under_test: [&'static str; COUNT], +} + +impl<'a, const COUNT: usize> NeovimBackedBindingTestContext<'a, COUNT> { + pub fn new( + keystrokes_under_test: [&'static str; COUNT], + cx: NeovimBackedTestContext<'a>, + ) -> Self { + Self { + cx, + keystrokes_under_test, + } + } + + pub fn consume(self) -> NeovimBackedTestContext<'a> { + self.cx + } + + pub fn binding( + self, + keystrokes: [&'static str; NEW_COUNT], + ) -> NeovimBackedBindingTestContext<'a, NEW_COUNT> { + self.consume().binding(keystrokes) + } + + pub async fn assert( + &mut self, + marked_positions: &str, + ) -> Option<(ContextHandle, ContextHandle)> { + self.cx + .assert_binding_matches(self.keystrokes_under_test, marked_positions) + .await + } + + pub fn assert_manual( + &mut self, + initial_state: &str, + mode_before: Mode, + state_after: &str, + mode_after: Mode, + ) { + self.cx.assert_binding( + self.keystrokes_under_test, + initial_state, + mode_before, + state_after, + mode_after, + ); + } + + pub async fn assert_all(&mut self, marked_positions: &str) { + self.cx + .assert_binding_matches_all(self.keystrokes_under_test, marked_positions) + .await + } +} + +impl<'a, const COUNT: usize> Deref for NeovimBackedBindingTestContext<'a, COUNT> { + type Target = NeovimBackedTestContext<'a>; + + fn deref(&self) -> &Self::Target { + &self.cx + } +} + +impl<'a, const COUNT: usize> DerefMut for NeovimBackedBindingTestContext<'a, COUNT> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.cx + } +} diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs new file mode 100644 index 0000000000..e66099963b --- /dev/null +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -0,0 +1,158 @@ +use std::ops::{Deref, DerefMut}; + +use collections::{HashMap, HashSet}; +use gpui::ContextHandle; +use language::{OffsetRangeExt, Point}; +use util::test::marked_text_offsets; + +use super::{neovim_connection::NeovimConnection, NeovimBackedBindingTestContext, VimTestContext}; +use crate::state::Mode; + +pub struct NeovimBackedTestContext<'a> { + cx: VimTestContext<'a>, + // Lookup for exempted assertions. Keyed by the insertion text, and with a value indicating which + // bindings are exempted. If None, all bindings are ignored for that insertion text. + exemptions: HashMap>>, + neovim: NeovimConnection, +} + +impl<'a> NeovimBackedTestContext<'a> { + pub async fn new(cx: &'a mut gpui::TestAppContext) -> NeovimBackedTestContext<'a> { + let function_name = cx.function_name.clone(); + let cx = VimTestContext::new(cx, true).await; + Self { + cx, + exemptions: Default::default(), + neovim: NeovimConnection::new(function_name).await, + } + } + + pub fn add_initial_state_exemption(&mut self, initial_state: &str) { + let initial_state = initial_state.to_string(); + // None represents all keybindings being exempted for that initial state + self.exemptions.insert(initial_state, None); + } + + pub async fn simulate_shared_keystroke(&mut self, keystroke_text: &str) -> ContextHandle { + self.neovim.send_keystroke(keystroke_text).await; + self.simulate_keystroke(keystroke_text) + } + + pub async fn simulate_shared_keystrokes( + &mut self, + keystroke_texts: [&str; COUNT], + ) -> ContextHandle { + for keystroke_text in keystroke_texts.into_iter() { + self.neovim.send_keystroke(keystroke_text).await; + } + self.simulate_keystrokes(keystroke_texts) + } + + pub async fn set_shared_state(&mut self, marked_text: &str) -> ContextHandle { + let context_handle = self.set_state(marked_text, Mode::Normal); + + let selection = self.editor(|editor, cx| editor.selections.newest::(cx)); + let text = self.buffer_text(); + self.neovim.set_state(selection, &text).await; + + context_handle + } + + pub async fn assert_state_matches(&mut self) { + assert_eq!( + self.neovim.text().await, + self.buffer_text(), + "{}", + self.assertion_context() + ); + + let mut neovim_selection = self.neovim.selection().await; + // Zed selections adjust themselves to make the end point visually make sense + if neovim_selection.start > neovim_selection.end { + neovim_selection.start.column += 1; + } + let neovim_selection = neovim_selection.to_offset(&self.buffer_snapshot()); + self.assert_editor_selections(vec![neovim_selection]); + + if let Some(neovim_mode) = self.neovim.mode().await { + assert_eq!(neovim_mode, self.mode(), "{}", self.assertion_context(),); + } + } + + pub async fn assert_binding_matches( + &mut self, + keystrokes: [&str; COUNT], + initial_state: &str, + ) -> Option<(ContextHandle, ContextHandle)> { + if let Some(possible_exempted_keystrokes) = self.exemptions.get(initial_state) { + match possible_exempted_keystrokes { + Some(exempted_keystrokes) => { + if exempted_keystrokes.contains(&format!("{keystrokes:?}")) { + // This keystroke was exempted for this insertion text + return None; + } + } + None => { + // All keystrokes for this insertion text are exempted + return None; + } + } + } + + let _state_context = self.set_shared_state(initial_state).await; + let _keystroke_context = self.simulate_shared_keystrokes(keystrokes).await; + self.assert_state_matches().await; + Some((_state_context, _keystroke_context)) + } + + pub async fn assert_binding_matches_all( + &mut self, + keystrokes: [&str; COUNT], + marked_positions: &str, + ) { + let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions); + + for cursor_offset in cursor_offsets.iter() { + let mut marked_text = unmarked_text.clone(); + marked_text.insert(*cursor_offset, 'ˇ'); + + self.assert_binding_matches(keystrokes, &marked_text).await; + } + } + + pub fn binding( + self, + keystrokes: [&'static str; COUNT], + ) -> NeovimBackedBindingTestContext<'a, COUNT> { + NeovimBackedBindingTestContext::new(keystrokes, self) + } +} + +impl<'a> Deref for NeovimBackedTestContext<'a> { + type Target = VimTestContext<'a>; + + fn deref(&self) -> &Self::Target { + &self.cx + } +} + +impl<'a> DerefMut for NeovimBackedTestContext<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.cx + } +} + +#[cfg(test)] +mod test { + use gpui::TestAppContext; + + use crate::test::NeovimBackedTestContext; + + #[gpui::test] + async fn neovim_backed_test_context_works(cx: &mut TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.assert_state_matches().await; + cx.set_shared_state("This is a tesˇt").await; + cx.assert_state_matches().await; + } +} diff --git a/crates/vim/src/test/neovim_connection.rs b/crates/vim/src/test/neovim_connection.rs new file mode 100644 index 0000000000..e2522a76aa --- /dev/null +++ b/crates/vim/src/test/neovim_connection.rs @@ -0,0 +1,385 @@ +#[cfg(feature = "neovim")] +use std::ops::{Deref, DerefMut}; +use std::{ops::Range, path::PathBuf}; + +#[cfg(feature = "neovim")] +use async_compat::Compat; +#[cfg(feature = "neovim")] +use async_trait::async_trait; +#[cfg(feature = "neovim")] +use gpui::keymap::Keystroke; + +use language::{Point, Selection}; + +#[cfg(feature = "neovim")] +use lazy_static::lazy_static; +#[cfg(feature = "neovim")] +use nvim_rs::{ + create::tokio::new_child_cmd, error::LoopError, Handler, Neovim, UiAttachOptions, Value, +}; +#[cfg(feature = "neovim")] +use parking_lot::ReentrantMutex; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "neovim")] +use tokio::{ + process::{Child, ChildStdin, Command}, + task::JoinHandle, +}; + +use crate::state::Mode; +use collections::VecDeque; + +// Neovim doesn't like to be started simultaneously from multiple threads. We use thsi lock +// to ensure we are only constructing one neovim connection at a time. +#[cfg(feature = "neovim")] +lazy_static! { + static ref NEOVIM_LOCK: ReentrantMutex<()> = ReentrantMutex::new(()); +} + +#[derive(Serialize, Deserialize)] +pub enum NeovimData { + Text(String), + Selection { start: (u32, u32), end: (u32, u32) }, + Mode(Option), +} + +pub struct NeovimConnection { + data: VecDeque, + #[cfg(feature = "neovim")] + test_case_id: String, + #[cfg(feature = "neovim")] + nvim: Neovim>, + #[cfg(feature = "neovim")] + _join_handle: JoinHandle>>, + #[cfg(feature = "neovim")] + _child: Child, +} + +impl NeovimConnection { + pub async fn new(test_case_id: String) -> Self { + #[cfg(feature = "neovim")] + let handler = NvimHandler {}; + #[cfg(feature = "neovim")] + let (nvim, join_handle, child) = Compat::new(async { + // Ensure we don't create neovim connections in parallel + let _lock = NEOVIM_LOCK.lock(); + let (nvim, join_handle, child) = new_child_cmd( + &mut Command::new("nvim").arg("--embed").arg("--clean"), + handler, + ) + .await + .expect("Could not connect to neovim process"); + + nvim.ui_attach(100, 100, &UiAttachOptions::default()) + .await + .expect("Could not attach to ui"); + + // Makes system act a little more like zed in terms of indentation + nvim.set_option("smartindent", nvim_rs::Value::Boolean(true)) + .await + .expect("Could not set smartindent on startup"); + + (nvim, join_handle, child) + }) + .await; + + Self { + #[cfg(feature = "neovim")] + data: Default::default(), + #[cfg(not(feature = "neovim"))] + data: Self::read_test_data(&test_case_id), + #[cfg(feature = "neovim")] + test_case_id, + #[cfg(feature = "neovim")] + nvim, + #[cfg(feature = "neovim")] + _join_handle: join_handle, + #[cfg(feature = "neovim")] + _child: child, + } + } + + // Sends a keystroke to the neovim process. + #[cfg(feature = "neovim")] + pub async fn send_keystroke(&mut self, keystroke_text: &str) { + let keystroke = Keystroke::parse(keystroke_text).unwrap(); + let special = keystroke.shift + || keystroke.ctrl + || keystroke.alt + || keystroke.cmd + || keystroke.key.len() > 1; + let start = if special { "<" } else { "" }; + let shift = if keystroke.shift { "S-" } else { "" }; + let ctrl = if keystroke.ctrl { "C-" } else { "" }; + let alt = if keystroke.alt { "M-" } else { "" }; + let cmd = if keystroke.cmd { "D-" } else { "" }; + let end = if special { ">" } else { "" }; + + let key = format!("{start}{shift}{ctrl}{alt}{cmd}{}{end}", keystroke.key); + + self.nvim + .input(&key) + .await + .expect("Could not input keystroke"); + } + + // If not running with a live neovim connection, this is a no-op + #[cfg(not(feature = "neovim"))] + pub async fn send_keystroke(&mut self, _keystroke_text: &str) {} + + #[cfg(feature = "neovim")] + pub async fn set_state(&mut self, selection: Selection, text: &str) { + let nvim_buffer = self + .nvim + .get_current_buf() + .await + .expect("Could not get neovim buffer"); + let lines = text + .split('\n') + .map(|line| line.to_string()) + .collect::>(); + + nvim_buffer + .set_lines(0, -1, false, lines) + .await + .expect("Could not set nvim buffer text"); + + self.nvim + .input("") + .await + .expect("Could not send escape to nvim"); + self.nvim + .input("") + .await + .expect("Could not send escape to nvim"); + + let nvim_window = self + .nvim + .get_current_win() + .await + .expect("Could not get neovim window"); + + if !selection.is_empty() { + panic!("Setting neovim state with non empty selection not yet supported"); + } + let cursor = selection.head(); + nvim_window + .set_cursor((cursor.row as i64 + 1, cursor.column as i64)) + .await + .expect("Could not set nvim cursor position"); + } + + #[cfg(not(feature = "neovim"))] + pub async fn set_state(&mut self, _selection: Selection, _text: &str) {} + + #[cfg(feature = "neovim")] + pub async fn text(&mut self) -> String { + let nvim_buffer = self + .nvim + .get_current_buf() + .await + .expect("Could not get neovim buffer"); + let text = nvim_buffer + .get_lines(0, -1, false) + .await + .expect("Could not get buffer text") + .join("\n"); + + self.data.push_back(NeovimData::Text(text.clone())); + + text + } + + #[cfg(not(feature = "neovim"))] + pub async fn text(&mut self) -> String { + if let Some(NeovimData::Text(text)) = self.data.pop_front() { + text + } else { + panic!("Invalid test data. Is test deterministic? Try running with '--features neovim' to regenerate"); + } + } + + #[cfg(feature = "neovim")] + pub async fn selection(&mut self) -> Range { + let cursor_row: u32 = self + .nvim + .command_output("echo line('.')") + .await + .unwrap() + .parse::() + .unwrap() + - 1; // Neovim rows start at 1 + let cursor_col: u32 = self + .nvim + .command_output("echo col('.')") + .await + .unwrap() + .parse::() + .unwrap() + - 1; // Neovim columns start at 1 + + let (start, end) = if let Some(Mode::Visual { .. }) = self.mode().await { + self.nvim + .input("") + .await + .expect("Could not exit visual mode"); + let nvim_buffer = self + .nvim + .get_current_buf() + .await + .expect("Could not get neovim buffer"); + let (start_row, start_col) = nvim_buffer + .get_mark("<") + .await + .expect("Could not get selection start"); + let (end_row, end_col) = nvim_buffer + .get_mark(">") + .await + .expect("Could not get selection end"); + self.nvim + .input("gv") + .await + .expect("Could not reselect visual selection"); + + if cursor_row == start_row as u32 - 1 && cursor_col == start_col as u32 { + ( + (end_row as u32 - 1, end_col as u32), + (start_row as u32 - 1, start_col as u32), + ) + } else { + ( + (start_row as u32 - 1, start_col as u32), + (end_row as u32 - 1, end_col as u32), + ) + } + } else { + ((cursor_row, cursor_col), (cursor_row, cursor_col)) + }; + + self.data.push_back(NeovimData::Selection { start, end }); + + Point::new(start.0, start.1)..Point::new(end.0, end.1) + } + + #[cfg(not(feature = "neovim"))] + pub async fn selection(&mut self) -> Range { + // Selection code fetches the mode. This emulates that. + let _mode = self.mode().await; + if let Some(NeovimData::Selection { start, end }) = self.data.pop_front() { + Point::new(start.0, start.1)..Point::new(end.0, end.1) + } else { + panic!("Invalid test data. Is test deterministic? Try running with '--features neovim' to regenerate"); + } + } + + #[cfg(feature = "neovim")] + pub async fn mode(&mut self) -> Option { + let nvim_mode_text = self + .nvim + .get_mode() + .await + .expect("Could not get mode") + .into_iter() + .find_map(|(key, value)| { + if key.as_str() == Some("mode") { + Some(value.as_str().unwrap().to_owned()) + } else { + None + } + }) + .expect("Could not find mode value"); + + let mode = match nvim_mode_text.as_ref() { + "i" => Some(Mode::Insert), + "n" => Some(Mode::Normal), + "v" => Some(Mode::Visual { line: false }), + "V" => Some(Mode::Visual { line: true }), + _ => None, + }; + + self.data.push_back(NeovimData::Mode(mode.clone())); + + mode + } + + #[cfg(not(feature = "neovim"))] + pub async fn mode(&mut self) -> Option { + if let Some(NeovimData::Mode(mode)) = self.data.pop_front() { + mode + } else { + panic!("Invalid test data. Is test deterministic? Try running with '--features neovim' to regenerate"); + } + } + + fn test_data_path(test_case_id: &str) -> PathBuf { + let mut data_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + data_path.push("test_data"); + data_path.push(format!("{}.json", test_case_id)); + data_path + } + + #[cfg(not(feature = "neovim"))] + fn read_test_data(test_case_id: &str) -> VecDeque { + let path = Self::test_data_path(test_case_id); + let json = std::fs::read_to_string(path).expect( + "Could not read test data. Is it generated? Try running test with '--features neovim'", + ); + + serde_json::from_str(&json) + .expect("Test data corrupted. Try regenerating it with '--features neovim'") + } +} + +#[cfg(feature = "neovim")] +impl Deref for NeovimConnection { + type Target = Neovim>; + + fn deref(&self) -> &Self::Target { + &self.nvim + } +} + +#[cfg(feature = "neovim")] +impl DerefMut for NeovimConnection { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.nvim + } +} + +#[cfg(feature = "neovim")] +impl Drop for NeovimConnection { + fn drop(&mut self) { + let path = Self::test_data_path(&self.test_case_id); + std::fs::create_dir_all(path.parent().unwrap()) + .expect("Could not create test data directory"); + let json = serde_json::to_string(&self.data).expect("Could not serialize test data"); + std::fs::write(path, json).expect("Could not write out test data"); + } +} + +#[cfg(feature = "neovim")] +#[derive(Clone)] +struct NvimHandler {} + +#[cfg(feature = "neovim")] +#[async_trait] +impl Handler for NvimHandler { + type Writer = nvim_rs::compat::tokio::Compat; + + async fn handle_request( + &self, + _event_name: String, + _arguments: Vec, + _neovim: Neovim, + ) -> Result { + unimplemented!(); + } + + async fn handle_notify( + &self, + _event_name: String, + _arguments: Vec, + _neovim: Neovim, + ) { + } +} diff --git a/crates/vim/src/test/vim_binding_test_context.rs b/crates/vim/src/test/vim_binding_test_context.rs new file mode 100644 index 0000000000..0974684a34 --- /dev/null +++ b/crates/vim/src/test/vim_binding_test_context.rs @@ -0,0 +1,69 @@ +use std::ops::{Deref, DerefMut}; + +use crate::*; + +use super::VimTestContext; + +pub struct VimBindingTestContext<'a, const COUNT: usize> { + cx: VimTestContext<'a>, + keystrokes_under_test: [&'static str; COUNT], + mode_before: Mode, + mode_after: Mode, +} + +impl<'a, const COUNT: usize> VimBindingTestContext<'a, COUNT> { + pub fn new( + keystrokes_under_test: [&'static str; COUNT], + mode_before: Mode, + mode_after: Mode, + cx: VimTestContext<'a>, + ) -> Self { + Self { + cx, + keystrokes_under_test, + mode_before, + mode_after, + } + } + + pub fn binding( + self, + keystrokes_under_test: [&'static str; NEW_COUNT], + ) -> VimBindingTestContext<'a, NEW_COUNT> { + VimBindingTestContext { + keystrokes_under_test, + cx: self.cx, + mode_before: self.mode_before, + mode_after: self.mode_after, + } + } + + pub fn mode_after(mut self, mode_after: Mode) -> Self { + self.mode_after = mode_after; + self + } + + pub fn assert(&mut self, initial_state: &str, state_after: &str) { + self.cx.assert_binding( + self.keystrokes_under_test, + initial_state, + self.mode_before, + state_after, + self.mode_after, + ) + } +} + +impl<'a, const COUNT: usize> Deref for VimBindingTestContext<'a, COUNT> { + type Target = VimTestContext<'a>; + + fn deref(&self) -> &Self::Target { + &self.cx + } +} + +impl<'a, const COUNT: usize> DerefMut for VimBindingTestContext<'a, COUNT> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.cx + } +} diff --git a/crates/vim/src/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs similarity index 70% rename from crates/vim/src/vim_test_context.rs rename to crates/vim/src/test/vim_test_context.rs index 0e77b05ba2..2fb446d127 100644 --- a/crates/vim/src/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -1,13 +1,15 @@ use std::ops::{Deref, DerefMut}; -use editor::test::EditorTestContext; -use gpui::{json::json, AppContext, ViewHandle}; +use editor::test::editor_test_context::EditorTestContext; +use gpui::{json::json, AppContext, ContextHandle, ViewHandle}; use project::Project; use search::{BufferSearchBar, ProjectSearchBar}; use workspace::{pane, AppState, WorkspaceHandle}; use crate::{state::Operator, *}; +use super::VimBindingTestContext; + pub struct VimTestContext<'a> { cx: EditorTestContext<'a>, workspace: ViewHandle, @@ -117,18 +119,18 @@ impl<'a> VimTestContext<'a> { .read(|cx| cx.global::().state.operator_stack.last().copied()) } - pub fn set_state(&mut self, text: &str, mode: Mode) { + pub fn set_state(&mut self, text: &str, mode: Mode) -> ContextHandle { self.cx.update(|cx| { Vim::update(cx, |vim, cx| { vim.switch_mode(mode, false, cx); }) }); - self.cx.set_state(text); + self.cx.set_state(text) } pub fn assert_state(&mut self, text: &str, mode: Mode) { self.assert_editor_state(text); - assert_eq!(self.mode(), mode); + assert_eq!(self.mode(), mode, "{}", self.assertion_context()); } pub fn assert_binding( @@ -142,8 +144,8 @@ impl<'a> VimTestContext<'a> { self.set_state(initial_state, initial_mode); self.cx.simulate_keystrokes(keystrokes); self.cx.assert_editor_state(state_after); - assert_eq!(self.mode(), mode_after); - assert_eq!(self.active_operator(), None); + assert_eq!(self.mode(), mode_after, "{}", self.assertion_context()); + assert_eq!(self.active_operator(), None, "{}", self.assertion_context()); } pub fn binding( @@ -168,67 +170,3 @@ impl<'a> DerefMut for VimTestContext<'a> { &mut self.cx } } - -pub struct VimBindingTestContext<'a, const COUNT: usize> { - cx: VimTestContext<'a>, - keystrokes_under_test: [&'static str; COUNT], - mode_before: Mode, - mode_after: Mode, -} - -impl<'a, const COUNT: usize> VimBindingTestContext<'a, COUNT> { - pub fn new( - keystrokes_under_test: [&'static str; COUNT], - mode_before: Mode, - mode_after: Mode, - cx: VimTestContext<'a>, - ) -> Self { - Self { - cx, - keystrokes_under_test, - mode_before, - mode_after, - } - } - - pub fn binding( - self, - keystrokes_under_test: [&'static str; NEW_COUNT], - ) -> VimBindingTestContext<'a, NEW_COUNT> { - VimBindingTestContext { - keystrokes_under_test, - cx: self.cx, - mode_before: self.mode_before, - mode_after: self.mode_after, - } - } - - pub fn mode_after(mut self, mode_after: Mode) -> Self { - self.mode_after = mode_after; - self - } - - pub fn assert(&mut self, initial_state: &str, state_after: &str) { - self.cx.assert_binding( - self.keystrokes_under_test, - initial_state, - self.mode_before, - state_after, - self.mode_after, - ) - } -} - -impl<'a, const COUNT: usize> Deref for VimBindingTestContext<'a, COUNT> { - type Target = VimTestContext<'a>; - - fn deref(&self) -> &Self::Target { - &self.cx - } -} - -impl<'a, const COUNT: usize> DerefMut for VimBindingTestContext<'a, COUNT> { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.cx - } -} diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index ecad33ce3f..81bafcf3e2 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -1,10 +1,11 @@ #[cfg(test)] -mod vim_test_context; +mod test; mod editor_events; mod insert; mod motion; mod normal; +mod object; mod state; mod utils; mod visual; @@ -25,13 +26,17 @@ pub struct SwitchMode(pub Mode); #[derive(Clone, Deserialize, PartialEq)] pub struct PushOperator(pub Operator); -impl_actions!(vim, [SwitchMode, PushOperator]); +#[derive(Clone, Deserialize, PartialEq)] +struct Number(u8); + +impl_actions!(vim, [Number, SwitchMode, PushOperator]); pub fn init(cx: &mut MutableAppContext) { editor_events::init(cx); normal::init(cx); visual::init(cx); insert::init(cx); + object::init(cx); motion::init(cx); // Vim Actions @@ -43,6 +48,9 @@ pub fn init(cx: &mut MutableAppContext) { Vim::update(cx, |vim, cx| vim.push_operator(operator, cx)) }, ); + cx.add_action(|_: &mut Workspace, n: &Number, cx: _| { + Vim::update(cx, |vim, cx| vim.push_number(n, cx)); + }); // Editor Actions cx.add_action(|_: &mut Editor, _: &Cancel, cx| { @@ -143,12 +151,31 @@ impl Vim { self.sync_vim_settings(cx); } + fn push_number(&mut self, Number(number): &Number, cx: &mut MutableAppContext) { + if let Some(Operator::Number(current_number)) = self.active_operator() { + self.pop_operator(cx); + self.push_operator(Operator::Number(current_number * 10 + *number as usize), cx); + } else { + self.push_operator(Operator::Number(*number as usize), cx); + } + } + fn pop_operator(&mut self, cx: &mut MutableAppContext) -> Operator { - let popped_operator = self.state.operator_stack.pop().expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config"); + let popped_operator = self.state.operator_stack.pop() + .expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config"); self.sync_vim_settings(cx); popped_operator } + fn pop_number_operator(&mut self, cx: &mut MutableAppContext) -> usize { + let mut times = 1; + if let Some(Operator::Number(number)) = self.active_operator() { + times = number; + self.pop_operator(cx); + } + times + } + fn clear_operator(&mut self, cx: &mut MutableAppContext) { self.state.operator_stack.clear(); self.sync_vim_settings(cx); @@ -204,85 +231,3 @@ impl Vim { } } } - -#[cfg(test)] -mod test { - use indoc::indoc; - use search::BufferSearchBar; - - use crate::{state::Mode, vim_test_context::VimTestContext}; - - #[gpui::test] - async fn test_initially_disabled(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, false).await; - cx.simulate_keystrokes(["h", "j", "k", "l"]); - cx.assert_editor_state("hjklˇ"); - } - - #[gpui::test] - async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true).await; - - cx.simulate_keystroke("i"); - assert_eq!(cx.mode(), Mode::Insert); - - // Editor acts as though vim is disabled - cx.disable_vim(); - cx.simulate_keystrokes(["h", "j", "k", "l"]); - cx.assert_editor_state("hjklˇ"); - - // Selections aren't changed if editor is blurred but vim-mode is still disabled. - cx.set_state("«hjklˇ»", Mode::Normal); - cx.assert_editor_state("«hjklˇ»"); - cx.update_editor(|_, cx| cx.blur()); - cx.assert_editor_state("«hjklˇ»"); - cx.update_editor(|_, cx| cx.focus_self()); - cx.assert_editor_state("«hjklˇ»"); - - // Enabling dynamically sets vim mode again and restores normal mode - cx.enable_vim(); - assert_eq!(cx.mode(), Mode::Normal); - cx.simulate_keystrokes(["h", "h", "h", "l"]); - assert_eq!(cx.buffer_text(), "hjkl".to_owned()); - cx.assert_editor_state("hˇjkl"); - cx.simulate_keystrokes(["i", "T", "e", "s", "t"]); - cx.assert_editor_state("hTestˇjkl"); - - // Disabling and enabling resets to normal mode - assert_eq!(cx.mode(), Mode::Insert); - cx.disable_vim(); - cx.enable_vim(); - assert_eq!(cx.mode(), Mode::Normal); - } - - #[gpui::test] - async fn test_buffer_search(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true).await; - - cx.set_state( - indoc! {" - The quick brown - fox juˇmps over - the lazy dog"}, - Mode::Normal, - ); - cx.simulate_keystroke("/"); - - // We now use a weird insert mode with selection when jumping to a single line editor - assert_eq!(cx.mode(), Mode::Insert); - - let search_bar = cx.workspace(|workspace, cx| { - workspace - .active_pane() - .read(cx) - .toolbar() - .read(cx) - .item_of_type::() - .expect("Buffer search bar should be deployed") - }); - - search_bar.read_with(cx.cx, |bar, cx| { - assert_eq!(bar.query_editor.read(cx).text(cx), "jumps"); - }) - } -} diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index d468393027..481d2570ae 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -6,7 +6,13 @@ use gpui::{actions, MutableAppContext, ViewContext}; use language::{AutoindentMode, SelectionGoal}; use workspace::Workspace; -use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim}; +use crate::{ + motion::Motion, + object::Object, + state::{Mode, Operator}, + utils::copy_selections_content, + Vim, +}; actions!(vim, [VisualDelete, VisualChange, VisualYank, VisualPaste]); @@ -17,13 +23,15 @@ pub fn init(cx: &mut MutableAppContext) { cx.add_action(paste); } -pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) { +pub fn visual_motion(motion: Motion, times: usize, cx: &mut MutableAppContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_with(|map, selection| { - let (new_head, goal) = motion.move_point(map, selection.head(), selection.goal); let was_reversed = selection.reversed; + + let (new_head, goal) = + motion.move_point(map, selection.head(), selection.goal, times); selection.set_head(new_head, goal); if was_reversed && !selection.reversed { @@ -43,6 +51,36 @@ pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) { }); } +pub fn visual_object(object: Object, cx: &mut MutableAppContext) { + Vim::update(cx, |vim, cx| { + if let Operator::Object { around } = vim.pop_operator(cx) { + vim.update_active_editor(cx, |editor, cx| { + editor.change_selections(Some(Autoscroll::Fit), cx, |s| { + s.move_with(|map, selection| { + let head = selection.head(); + if let Some(mut range) = object.range(map, head, around) { + if !range.is_empty() { + if let Some((_, end)) = map.reverse_chars_at(range.end).next() { + range.end = end; + } + + if selection.is_empty() { + selection.start = range.start; + selection.end = range.end; + } else if selection.reversed { + selection.start = range.start; + } else { + selection.end = range.end; + } + } + } + }); + }); + }); + } + }); +} + pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { @@ -274,365 +312,151 @@ pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext mod test { use indoc::indoc; - use crate::{state::Mode, vim_test_context::VimTestContext}; + use crate::{ + state::Mode, + test::{NeovimBackedTestContext, VimTestContext}, + }; #[gpui::test] async fn test_enter_visual_mode(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx - .binding(["v", "w", "j"]) - .mode_after(Mode::Visual { line: false }); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["v", "w", "j"]); + cx.assert_all(indoc! {" The ˇquick brown - fox jumps over - the lazy dog"}, - indoc! {" - The «quick brown - fox jumps ˇ»over - the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the ˇlazy dog"}, - indoc! {" - The quick brown - fox jumps over - the «lazy ˇ»dog"}, - ); - cx.assert( - indoc! {" - The quick brown fox jumps ˇover - the lazy dog"}, - indoc! {" - The quick brown - fox jumps «over - ˇ»the lazy dog"}, - ); - let mut cx = cx - .binding(["v", "b", "k"]) - .mode_after(Mode::Visual { line: false }); - cx.assert( - indoc! {" + the ˇlazy dog"}) + .await; + let mut cx = cx.binding(["v", "b", "k"]); + cx.assert_all(indoc! {" The ˇquick brown - fox jumps over - the lazy dog"}, - indoc! {" - «ˇThe q»uick brown - fox jumps over - the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the ˇlazy dog"}, - indoc! {" - The quick brown - «ˇfox jumps over - the l»azy dog"}, - ); - cx.assert( - indoc! {" - The quick brown fox jumps ˇover - the lazy dog"}, - indoc! {" - The «ˇquick brown - fox jumps o»ver - the lazy dog"}, - ); + the ˇlazy dog"}) + .await; } #[gpui::test] async fn test_visual_delete(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["v", "w", "x"]); - cx.assert("The quick ˇbrown", "The quickˇ "); - let mut cx = cx.binding(["v", "w", "j", "x"]); - cx.assert( - indoc! {" - The ˇquick brown - fox jumps over - the lazy dog"}, - indoc! {" - The ˇver - the lazy dog"}, - ); - // Test pasting code copied on delete - cx.simulate_keystrokes(["j", "p"]); - cx.assert_editor_state(indoc! {" - The ver - the lˇquick brown - fox jumps oazy dog"}); + let mut cx = NeovimBackedTestContext::new(cx).await; - cx.assert( - indoc! {" - The quick brown - fox jumps over - the ˇlazy dog"}, - indoc! {" - The quick brown - fox jumps over - the ˇog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps ˇover - the lazy dog"}, - indoc! {" - The quick brown - fox jumps ˇhe lazy dog"}, - ); - let mut cx = cx.binding(["v", "b", "k", "x"]); - cx.assert( + cx.assert_binding_matches(["v", "w", "x"], "The quick ˇbrown") + .await; + cx.assert_binding_matches( + ["v", "w", "j", "x"], indoc! {" The ˇquick brown fox jumps over the lazy dog"}, - indoc! {" - ˇuick brown + ) + .await; + // Test pasting code copied on delete + cx.simulate_shared_keystrokes(["j", "p"]).await; + cx.assert_state_matches().await; + + let mut cx = cx.binding(["v", "w", "j", "x"]); + cx.assert_all(indoc! {" + The ˇquick brown fox jumps over - the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the ˇlazy dog"}, - indoc! {" - The quick brown - ˇazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown + the ˇlazy dog"}) + .await; + let mut cx = cx.binding(["v", "b", "k", "x"]); + cx.assert_all(indoc! {" + The ˇquick brown fox jumps ˇover - the lazy dog"}, - indoc! {" - The ˇver - the lazy dog"}, - ); + the ˇlazy dog"}) + .await; } #[gpui::test] async fn test_visual_line_delete(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-v", "x"]); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["shift-v", "x"]); + cx.assert(indoc! {" The quˇick brown fox jumps over - the lazy dog"}, - indoc! {" - fox juˇmps over - the lazy dog"}, - ); + the lazy dog"}) + .await; // Test pasting code copied on delete - cx.simulate_keystroke("p"); - cx.assert_editor_state(indoc! {" - fox jumps over - ˇThe quick brown - the lazy dog"}); + cx.simulate_shared_keystroke("p").await; + cx.assert_state_matches().await; - cx.assert( - indoc! {" + cx.assert_all(indoc! {" The quick brown fox juˇmps over - the lazy dog"}, - indoc! {" - The quick brown - the laˇzy dog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the laˇzy dog"}, - indoc! {" - The quick brown - fox juˇmps over"}, - ); + the laˇzy dog"}) + .await; let mut cx = cx.binding(["shift-v", "j", "x"]); - cx.assert( - indoc! {" + cx.assert(indoc! {" The quˇick brown fox jumps over - the lazy dog"}, - "the laˇzy dog", - ); + the lazy dog"}) + .await; // Test pasting code copied on delete - cx.simulate_keystroke("p"); - cx.assert_editor_state(indoc! {" - the lazy dog - ˇThe quick brown - fox jumps over"}); + cx.simulate_shared_keystroke("p").await; + cx.assert_state_matches().await; - cx.assert( - indoc! {" + cx.assert_all(indoc! {" The quick brown fox juˇmps over - the lazy dog"}, - "The quˇick brown", - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the laˇzy dog"}, - indoc! {" - The quick brown - fox juˇmps over"}, - ); + the laˇzy dog"}) + .await; } #[gpui::test] async fn test_visual_change(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["v", "w", "c"]).mode_after(Mode::Insert); - cx.assert("The quick ˇbrown", "The quick ˇ"); - let mut cx = cx.binding(["v", "w", "j", "c"]).mode_after(Mode::Insert); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["v", "w", "c"]); + cx.assert("The quick ˇbrown").await; + let mut cx = cx.binding(["v", "w", "j", "c"]); + cx.assert_all(indoc! {" The ˇquick brown - fox jumps over - the lazy dog"}, - indoc! {" - The ˇver - the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the ˇlazy dog"}, - indoc! {" - The quick brown - fox jumps over - the ˇog"}, - ); - cx.assert( - indoc! {" - The quick brown fox jumps ˇover - the lazy dog"}, - indoc! {" - The quick brown - fox jumps ˇhe lazy dog"}, - ); - let mut cx = cx.binding(["v", "b", "k", "c"]).mode_after(Mode::Insert); - cx.assert( - indoc! {" + the ˇlazy dog"}) + .await; + let mut cx = cx.binding(["v", "b", "k", "c"]); + cx.assert_all(indoc! {" The ˇquick brown - fox jumps over - the lazy dog"}, - indoc! {" - ˇuick brown - fox jumps over - the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the ˇlazy dog"}, - indoc! {" - The quick brown - ˇazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown fox jumps ˇover - the lazy dog"}, - indoc! {" - The ˇver - the lazy dog"}, - ); + the ˇlazy dog"}) + .await; } #[gpui::test] async fn test_visual_line_change(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-v", "c"]).mode_after(Mode::Insert); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["shift-v", "c"]); + cx.assert(indoc! {" The quˇick brown fox jumps over - the lazy dog"}, - indoc! {" - ˇ - fox jumps over - the lazy dog"}, - ); + the lazy dog"}) + .await; // Test pasting code copied on change - cx.simulate_keystrokes(["escape", "j", "p"]); - cx.assert_editor_state(indoc! {" - - fox jumps over - ˇThe quick brown - the lazy dog"}); + cx.simulate_shared_keystrokes(["escape", "j", "p"]).await; + cx.assert_state_matches().await; - cx.assert( - indoc! {" + cx.assert_all(indoc! {" The quick brown fox juˇmps over - the lazy dog"}, - indoc! {" - The quick brown - ˇ - the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the laˇzy dog"}, - indoc! {" - The quick brown - fox jumps over - ˇ"}, - ); - let mut cx = cx.binding(["shift-v", "j", "c"]).mode_after(Mode::Insert); - cx.assert( - indoc! {" + the laˇzy dog"}) + .await; + let mut cx = cx.binding(["shift-v", "j", "c"]); + cx.assert(indoc! {" The quˇick brown fox jumps over - the lazy dog"}, - indoc! {" - ˇ - the lazy dog"}, - ); + the lazy dog"}) + .await; // Test pasting code copied on delete - cx.simulate_keystrokes(["escape", "j", "p"]); - cx.assert_editor_state(indoc! {" - - the lazy dog - ˇThe quick brown - fox jumps over"}); - cx.assert( - indoc! {" + cx.simulate_shared_keystrokes(["escape", "j", "p"]).await; + cx.assert_state_matches().await; + + cx.assert_all(indoc! {" The quick brown fox juˇmps over - the lazy dog"}, - indoc! {" - The quick brown - ˇ"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the laˇzy dog"}, - indoc! {" - The quick brown - fox jumps over - ˇ"}, - ); + the laˇzy dog"}) + .await; } #[gpui::test] @@ -741,7 +565,7 @@ mod test { cx.assert_state( indoc! {" The quick brown - fox jumpsˇjumps over + fox jumpsjumpˇs over the lazy dog"}, Mode::Normal, ); diff --git a/crates/vim/test_data/neovim_backed_test_context_works.json b/crates/vim/test_data/neovim_backed_test_context_works.json new file mode 100644 index 0000000000..807c9010e8 --- /dev/null +++ b/crates/vim/test_data/neovim_backed_test_context_works.json @@ -0,0 +1 @@ +[{"Text":""},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"This is a test"},{"Mode":"Normal"},{"Selection":{"start":[0,13],"end":[0,13]}},{"Mode":"Normal"}] \ No newline at end of file diff --git a/crates/vim/test_data/test_a.json b/crates/vim/test_data/test_a.json new file mode 100644 index 0000000000..32ea8ac6a6 --- /dev/null +++ b/crates/vim/test_data/test_a.json @@ -0,0 +1 @@ +[{"Text":"The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,9],"end":[0,9]}},{"Mode":"Insert"}] \ No newline at end of file diff --git a/crates/vim/test_data/test_b.json b/crates/vim/test_data/test_b.json new file mode 100644 index 0000000000..635edf536b --- /dev/null +++ b/crates/vim/test_data/test_b.json @@ -0,0 +1 @@ +[{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,9],"end":[0,9]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[3,0],"end":[3,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[3,10],"end":[3,10]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[3,0],"end":[3,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[3,10],"end":[3,10]}},{"Mode":"Normal"}] \ No newline at end of file diff --git a/crates/vim/test_data/test_backspace.json b/crates/vim/test_data/test_backspace.json new file mode 100644 index 0000000000..d002dfa718 --- /dev/null +++ b/crates/vim/test_data/test_backspace.json @@ -0,0 +1 @@ +[{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"}] \ No newline at end of file diff --git a/crates/vim/test_data/test_cc.json b/crates/vim/test_data/test_cc.json new file mode 100644 index 0000000000..67492d827e --- /dev/null +++ b/crates/vim/test_data/test_cc.json @@ -0,0 +1 @@ +[{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick\n\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\n"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"The quick\n\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}] \ No newline at end of file diff --git a/crates/vim/test_data/test_change_sentence_object.json b/crates/vim/test_data/test_change_sentence_object.json new file mode 100644 index 0000000000..7827bc8a28 --- /dev/null +++ b/crates/vim/test_data/test_change_sentence_object.json @@ -0,0 +1 @@ +[{"Text":" Fox Jumps! Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" Fox Jumps! Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" Fox Jumps! Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick brown?Fox Jumps! Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,16],"end":[0,16]}},{"Mode":"Insert"},{"Text":"The quick brown? Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,17],"end":[0,17]}},{"Mode":"Insert"},{"Text":"The quick brown? Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,17],"end":[0,17]}},{"Mode":"Insert"},{"Text":"The quick brown? Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,17],"end":[0,17]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps!Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,27],"end":[0,27]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps! "},{"Mode":"Insert"},{"Selection":{"start":[0,28],"end":[0,28]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps! "},{"Mode":"Insert"},{"Selection":{"start":[0,28],"end":[0,28]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps! "},{"Mode":"Insert"},{"Selection":{"start":[0,28],"end":[0,28]}},{"Mode":"Insert"},{"Text":" The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick brown \nfox jumps over\nthe lazy dog.The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Insert"},{"Text":"The quick brown \nfox jumps over\nthe lazy dog. \n"},{"Mode":"Insert"},{"Selection":{"start":[2,14],"end":[2,14]}},{"Mode":"Insert"},{"Text":"The quick brown \nfox jumps over\nthe lazy dog. \n"},{"Mode":"Insert"},{"Selection":{"start":[2,14],"end":[2,14]}},{"Mode":"Insert"},{"Text":" Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick brown.)]'\" "},{"Mode":"Insert"},{"Selection":{"start":[0,21],"end":[0,21]}},{"Mode":"Insert"},{"Text":"The quick brown.)]'\" "},{"Mode":"Insert"},{"Selection":{"start":[0,21],"end":[0,21]}},{"Mode":"Insert"},{"Text":"The quick brown.)]'\" Brown fox jumps."},{"Mode":"Insert"},{"Selection":{"start":[0,37],"end":[0,37]}},{"Mode":"Insert"},{"Text":"Fox Jumps! Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"Fox Jumps! Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"Fox Jumps! Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick brown? Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,17],"end":[0,17]}},{"Mode":"Insert"},{"Text":"The quick brown? Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,17],"end":[0,17]}},{"Mode":"Insert"},{"Text":"The quick brown? Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,17],"end":[0,17]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps!"},{"Mode":"Insert"},{"Selection":{"start":[0,27],"end":[0,27]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps!"},{"Mode":"Insert"},{"Selection":{"start":[0,27],"end":[0,27]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps!"},{"Mode":"Insert"},{"Selection":{"start":[0,27],"end":[0,27]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps!"},{"Mode":"Insert"},{"Selection":{"start":[0,27],"end":[0,27]}},{"Mode":"Insert"},{"Text":"The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick brown \nfox jumps over\nthe lazy dog.\n"},{"Mode":"Insert"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Insert"},{"Text":"The quick brown \nfox jumps over\nthe lazy dog.\n"},{"Mode":"Insert"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Insert"},{"Text":"The quick brown \nfox jumps over\nthe lazy dog.\n"},{"Mode":"Insert"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Insert"},{"Text":"Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick brown.)]'\" "},{"Mode":"Insert"},{"Selection":{"start":[0,21],"end":[0,21]}},{"Mode":"Insert"},{"Text":"The quick brown.)]'\" "},{"Mode":"Insert"},{"Selection":{"start":[0,21],"end":[0,21]}},{"Mode":"Insert"}] \ No newline at end of file diff --git a/crates/vim/test_data/test_change_surrounding_character_objects.json b/crates/vim/test_data/test_change_surrounding_character_objects.json new file mode 100644 index 0000000000..8a66f5b144 --- /dev/null +++ b/crates/vim/test_data/test_change_surrounding_character_objects.json @@ -0,0 +1 @@ +[{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th)e ()qui()wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th)e ()qui()wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th)e ()qui()wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th)e ()qui()wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov()o(g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov()o(g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov()o(g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov()o(g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov()o(g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov()o(g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[2,11],"end":[2,11]}},{"Mode":"Normal"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[2,12],"end":[2,12]}},{"Mode":"Normal"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Normal"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Th)e qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th)e qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th)e qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th)e ()quiwn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th)e ()quiwn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th)e ()quiwn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th)e ()quiwn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ovo(g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ovo(g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ovo(g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ovo(g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ovo(g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ovo(g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[2,11],"end":[2,11]}},{"Mode":"Normal"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[2,12],"end":[2,12]}},{"Mode":"Normal"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Normal"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th]e []qui[]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th]e []qui[]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th]e []qui[]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th]e []qui[]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[]o[g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[]o[g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[]o[g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[]o[g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[]o[g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[]o[g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[2,11],"end":[2,11]}},{"Mode":"Normal"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[2,12],"end":[2,12]}},{"Mode":"Normal"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Normal"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Th]e qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th]e qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th]e qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th]e []quiwn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th]e []quiwn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th]e []quiwn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th]e []quiwn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ovo[g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ovo[g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ovo[g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ovo[g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ovo[g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ovo[g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[2,11],"end":[2,11]}},{"Mode":"Normal"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[2,12],"end":[2,12]}},{"Mode":"Normal"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Normal"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{}o{g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{}o{g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{}o{g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{}o{g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{}o{g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{}o{g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[2,11],"end":[2,11]}},{"Mode":"Normal"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[2,12],"end":[2,12]}},{"Mode":"Normal"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Normal"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Th}e qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th}e qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th}e qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th}e {}quiwn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th}e {}quiwn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th}e {}quiwn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th}e {}quiwn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ovo{g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ovo{g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ovo{g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ovo{g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ovo{g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ovo{g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[2,11],"end":[2,11]}},{"Mode":"Normal"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[2,12],"end":[2,12]}},{"Mode":"Normal"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Normal"},{"Text":"Th>e <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe quiwn<\n>fox jumps ovoe quiwn<\n>fox jumps ovoe quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe quiwn<\n>fox jumps ovoe quiwn<\n>fox jumps ovoe quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovo( resize_side as usize, @@ -281,8 +285,8 @@ impl Dock { enum ExpandedDockPane {} Container::new( MouseEventHandler::::new(0, cx, |_state, cx| { - MouseEventHandler::::new(0, cx, |_state, _cx| { - ChildView::new(self.pane.clone()).boxed() + MouseEventHandler::::new(0, cx, |_state, cx| { + ChildView::new(&self.pane, cx).boxed() }) .capture_all() .contained() @@ -583,10 +587,11 @@ mod tests { } pub fn center_pane_handle(&self) -> ViewHandle { - self.workspace(|workspace, _| { + self.workspace(|workspace, cx| { workspace .last_active_center_pane .clone() + .and_then(|pane| pane.upgrade(cx)) .unwrap_or_else(|| workspace.center.panes()[0].clone()) }) } @@ -597,6 +602,7 @@ mod tests { let pane = workspace .last_active_center_pane .clone() + .and_then(|pane| pane.upgrade(cx)) .unwrap_or_else(|| workspace.center.panes()[0].clone()); Pane::add_item( workspace, diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 752a941c59..b7ae7f2ba0 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -112,10 +112,10 @@ pub fn init(cx: &mut MutableAppContext) { pane.activate_item(pane.items.len() - 1, true, true, cx); }); cx.add_action(|pane: &mut Pane, _: &ActivatePrevItem, cx| { - pane.activate_prev_item(cx); + pane.activate_prev_item(true, cx); }); cx.add_action(|pane: &mut Pane, _: &ActivateNextItem, cx| { - pane.activate_next_item(cx); + pane.activate_next_item(true, cx); }); cx.add_async_action(Pane::close_active_item); cx.add_async_action(Pane::close_inactive_items); @@ -189,7 +189,6 @@ pub fn init(cx: &mut MutableAppContext) { #[derive(Debug)] pub enum Event { - Focused, ActivateItem { local: bool }, Remove, RemoveItem { item_id: usize }, @@ -201,7 +200,7 @@ pub struct Pane { items: Vec>, is_active: bool, active_item_index: usize, - last_focused_view: Option, + last_focused_view_by_item: HashMap, autoscroll: bool, nav_history: Rc>, toolbar: ViewHandle, @@ -263,7 +262,7 @@ impl Pane { items: Vec::new(), is_active: true, active_item_index: 0, - last_focused_view: None, + last_focused_view_by_item: Default::default(), autoscroll: false, nav_history: Rc::new(RefCell::new(NavHistory { mode: NavigationMode::Normal, @@ -632,32 +631,29 @@ impl Pane { if focus_item { self.focus_active_item(cx); } - if activate_pane { - cx.emit(Event::Focused); - } self.autoscroll = true; cx.notify(); } } - pub fn activate_prev_item(&mut self, cx: &mut ViewContext) { + pub fn activate_prev_item(&mut self, activate_pane: bool, cx: &mut ViewContext) { let mut index = self.active_item_index; if index > 0 { index -= 1; } else if !self.items.is_empty() { index = self.items.len() - 1; } - self.activate_item(index, true, true, cx); + self.activate_item(index, activate_pane, activate_pane, cx); } - pub fn activate_next_item(&mut self, cx: &mut ViewContext) { + pub fn activate_next_item(&mut self, activate_pane: bool, cx: &mut ViewContext) { let mut index = self.active_item_index; if index + 1 < self.items.len() { index += 1; } else { index = 0; } - self.activate_item(index, true, true, cx); + self.activate_item(index, activate_pane, activate_pane, cx); } pub fn close_active_item( @@ -784,7 +780,7 @@ impl Pane { // Remove the item from the pane. pane.update(&mut cx, |pane, cx| { if let Some(item_ix) = pane.items.iter().position(|i| i.id() == item.id()) { - pane.remove_item(item_ix, cx); + pane.remove_item(item_ix, false, cx); } }); } @@ -794,15 +790,15 @@ impl Pane { }) } - fn remove_item(&mut self, item_ix: usize, cx: &mut ViewContext) { + fn remove_item(&mut self, item_ix: usize, activate_pane: bool, cx: &mut ViewContext) { if item_ix == self.active_item_index { // Activate the previous item if possible. // This returns the user to the previously opened tab if they closed // a new item they just navigated to. if item_ix > 0 { - self.activate_prev_item(cx); + self.activate_prev_item(activate_pane, cx); } else if item_ix + 1 < self.items.len() { - self.activate_next_item(cx); + self.activate_next_item(activate_pane, cx); } } @@ -965,26 +961,27 @@ impl Pane { log::warn!("Tried to move item handle which was not in `from` pane. Maybe tab was closed during drop"); return; } - let (item_ix, item_handle) = item_to_move.unwrap(); + let item_handle = item_handle.clone(); + + if from != to { + // Close item from previous pane + from.update(cx, |from, cx| { + from.remove_item(item_ix, false, cx); + }); + } + // This automatically removes duplicate items in the pane Pane::add_item( workspace, &to, - item_handle.clone(), + item_handle, true, true, Some(destination_index), cx, ); - if from != to { - // Close item from previous pane - from.update(cx, |from, cx| { - from.remove_item(item_ix, cx); - }); - } - cx.focus(to); } @@ -1091,7 +1088,7 @@ impl Pane { move |mouse_state, cx| { let tab_style = theme.workspace.tab_bar.tab_style(pane_active, tab_active); - let hovered = mouse_state.hovered; + let hovered = mouse_state.hovered(); Self::render_tab( &item, pane, @@ -1164,7 +1161,8 @@ impl Pane { .with_style(filler_style.container) .with_border(filler_style.container.border); - if let Some(overlay) = Self::tab_overlay_color(mouse_state.hovered, &theme, cx) + if let Some(overlay) = + Self::tab_overlay_color(mouse_state.hovered(), &theme, cx) { filler = filler.with_overlay_color(overlay); } @@ -1286,7 +1284,7 @@ impl Pane { enum TabCloseButton {} let icon = Svg::new("icons/x_mark_thin_8.svg"); MouseEventHandler::::new(item_id, cx, |mouse_state, _| { - if mouse_state.hovered { + if mouse_state.hovered() { icon.with_color(tab_style.icon_close_active).boxed() } else { icon.with_color(tab_style.icon_close).boxed() @@ -1442,8 +1440,8 @@ impl View for Pane { .flex(1., false) .named("tab bar") }) - .with_child(ChildView::new(&self.toolbar).expanded().boxed()) - .with_child(ChildView::new(active_item).flex(1., true).boxed()) + .with_child(ChildView::new(&self.toolbar, cx).expanded().boxed()) + .with_child(ChildView::new(active_item, cx).flex(1., true).boxed()) .boxed() } else { enum EmptyPane {} @@ -1483,25 +1481,32 @@ impl View for Pane { }) .boxed(), ) - .with_child(ChildView::new(&self.tab_bar_context_menu).boxed()) + .with_child(ChildView::new(&self.tab_bar_context_menu, cx).boxed()) .named("pane") } fn on_focus_in(&mut self, focused: AnyViewHandle, cx: &mut ViewContext) { - if cx.is_self_focused() { - if let Some(last_focused_view) = self - .last_focused_view - .as_ref() - .and_then(|handle| handle.upgrade(cx)) - { - cx.focus(last_focused_view); + if let Some(active_item) = self.active_item() { + if cx.is_self_focused() { + // Pane was focused directly. We need to either focus a view inside the active item, + // or focus the active item itself + if let Some(weak_last_focused_view) = + self.last_focused_view_by_item.get(&active_item.id()) + { + if let Some(last_focused_view) = weak_last_focused_view.upgrade(cx) { + cx.focus(last_focused_view); + return; + } else { + self.last_focused_view_by_item.remove(&active_item.id()); + } + } + + cx.focus(active_item); } else { - self.focus_active_item(cx); + self.last_focused_view_by_item + .insert(active_item.id(), focused.downgrade()); } - } else { - self.last_focused_view = Some(focused.downgrade()); } - cx.emit(Event::Focused); } } diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 94acf427e4..10fac09fff 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -1,9 +1,10 @@ -use crate::{FollowerStatesByLeader, Pane}; +use crate::{FollowerStatesByLeader, JoinProject, Pane, Workspace}; use anyhow::{anyhow, Result}; -use client::PeerId; -use collections::HashMap; -use gpui::{elements::*, Axis, Border, ViewHandle}; -use project::Collaborator; +use call::ActiveCall; +use gpui::{ + elements::*, Axis, Border, CursorStyle, ModelHandle, MouseButton, RenderContext, ViewHandle, +}; +use project::Project; use serde::Deserialize; use theme::Theme; @@ -56,11 +57,14 @@ impl PaneGroup { pub(crate) fn render( &self, + project: &ModelHandle, theme: &Theme, follower_states: &FollowerStatesByLeader, - collaborators: &HashMap, + active_call: Option<&ModelHandle>, + cx: &mut RenderContext, ) -> ElementBox { - self.root.render(theme, follower_states, collaborators) + self.root + .render(project, theme, follower_states, active_call, cx) } pub(crate) fn panes(&self) -> Vec<&ViewHandle> { @@ -100,13 +104,16 @@ impl Member { pub fn render( &self, + project: &ModelHandle, theme: &Theme, follower_states: &FollowerStatesByLeader, - collaborators: &HashMap, + active_call: Option<&ModelHandle>, + cx: &mut RenderContext, ) -> ElementBox { + enum FollowIntoExternalProject {} + match self { Member::Pane(pane) => { - let mut border = Border::default(); let leader = follower_states .iter() .find_map(|(leader_id, follower_states)| { @@ -116,21 +123,115 @@ impl Member { None } }) - .and_then(|leader_id| collaborators.get(leader_id)); - if let Some(leader) = leader { - let leader_color = theme - .editor - .replica_selection_style(leader.replica_id) - .cursor; + .and_then(|leader_id| { + let room = active_call?.read(cx).room()?.read(cx); + let collaborator = project.read(cx).collaborators().get(leader_id)?; + let participant = room.remote_participants().get(&leader_id)?; + Some((collaborator.replica_id, participant)) + }); + + let mut border = Border::default(); + + let prompt = if let Some((replica_id, leader)) = leader { + let leader_color = theme.editor.replica_selection_style(replica_id).cursor; border = Border::all(theme.workspace.leader_border_width, leader_color); border .color .fade_out(1. - theme.workspace.leader_border_opacity); border.overlay = true; - } - ChildView::new(pane).contained().with_border(border).boxed() + + match leader.location { + call::ParticipantLocation::SharedProject { + project_id: leader_project_id, + } => { + if Some(leader_project_id) == project.read(cx).remote_id() { + None + } else { + let leader_user = leader.user.clone(); + let leader_user_id = leader.user.id; + Some( + MouseEventHandler::::new( + pane.id(), + cx, + |_, _| { + Label::new( + format!( + "Follow {} on their active project", + leader_user.github_login, + ), + theme + .workspace + .external_location_message + .text + .clone(), + ) + .contained() + .with_style( + theme.workspace.external_location_message.container, + ) + .boxed() + }, + ) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(JoinProject { + project_id: leader_project_id, + follow_user_id: leader_user_id, + }) + }) + .aligned() + .bottom() + .right() + .boxed(), + ) + } + } + call::ParticipantLocation::UnsharedProject => Some( + Label::new( + format!( + "{} is viewing an unshared Zed project", + leader.user.github_login + ), + theme.workspace.external_location_message.text.clone(), + ) + .contained() + .with_style(theme.workspace.external_location_message.container) + .aligned() + .bottom() + .right() + .boxed(), + ), + call::ParticipantLocation::External => Some( + Label::new( + format!( + "{} is viewing a window outside of Zed", + leader.user.github_login + ), + theme.workspace.external_location_message.text.clone(), + ) + .contained() + .with_style(theme.workspace.external_location_message.container) + .aligned() + .bottom() + .right() + .boxed(), + ), + } + } else { + None + }; + + Stack::new() + .with_child( + ChildView::new(pane, cx) + .contained() + .with_border(border) + .boxed(), + ) + .with_children(prompt) + .boxed() } - Member::Axis(axis) => axis.render(theme, follower_states, collaborators), + Member::Axis(axis) => axis.render(project, theme, follower_states, active_call, cx), } } @@ -232,14 +333,16 @@ impl PaneAxis { fn render( &self, + project: &ModelHandle, theme: &Theme, follower_state: &FollowerStatesByLeader, - collaborators: &HashMap, + active_call: Option<&ModelHandle>, + cx: &mut RenderContext, ) -> ElementBox { let last_member_ix = self.members.len() - 1; Flex::new(self.axis) .with_children(self.members.iter().enumerate().map(|(ix, member)| { - let mut member = member.render(theme, follower_state, collaborators); + let mut member = member.render(project, theme, follower_state, active_call, cx); if ix < last_member_ix { let mut border = theme.workspace.pane_divider; border.left = false; diff --git a/crates/workspace/src/sidebar.rs b/crates/workspace/src/sidebar.rs index 5cf986128a..214f227757 100644 --- a/crates/workspace/src/sidebar.rs +++ b/crates/workspace/src/sidebar.rs @@ -192,7 +192,7 @@ impl View for Sidebar { if let Some(active_item) = self.active_item() { enum ResizeHandleTag {} let style = &cx.global::().theme.workspace.sidebar; - ChildView::new(active_item.to_any()) + ChildView::new(active_item.to_any(), cx) .contained() .with_style(style.container) .with_resize_handle::( diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index f055168075..5261d22b6c 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -42,14 +42,14 @@ impl View for StatusBar { let theme = &cx.global::().theme.workspace.status_bar; Flex::row() .with_children(self.left_items.iter().map(|i| { - ChildView::new(i.as_ref()) + ChildView::new(i.as_ref(), cx) .aligned() .contained() .with_margin_right(theme.item_spacing) .boxed() })) .with_children(self.right_items.iter().rev().map(|i| { - ChildView::new(i.as_ref()) + ChildView::new(i.as_ref(), cx) .aligned() .contained() .with_margin_left(theme.item_spacing) diff --git a/crates/workspace/src/toolbar.rs b/crates/workspace/src/toolbar.rs index d1d666e031..7443f19003 100644 --- a/crates/workspace/src/toolbar.rs +++ b/crates/workspace/src/toolbar.rs @@ -67,7 +67,7 @@ impl View for Toolbar { match *position { ToolbarItemLocation::Hidden => {} ToolbarItemLocation::PrimaryLeft { flex } => { - let left_item = ChildView::new(item.as_ref()) + let left_item = ChildView::new(item.as_ref(), cx) .aligned() .contained() .with_margin_right(spacing); @@ -78,7 +78,7 @@ impl View for Toolbar { } } ToolbarItemLocation::PrimaryRight { flex } => { - let right_item = ChildView::new(item.as_ref()) + let right_item = ChildView::new(item.as_ref(), cx) .aligned() .contained() .with_margin_left(spacing) @@ -91,7 +91,7 @@ impl View for Toolbar { } ToolbarItemLocation::Secondary => { secondary_item = Some( - ChildView::new(item.as_ref()) + ChildView::new(item.as_ref(), cx) .constrained() .with_height(theme.height) .boxed(), diff --git a/crates/workspace/src/waiting_room.rs b/crates/workspace/src/waiting_room.rs deleted file mode 100644 index bdced26c8b..0000000000 --- a/crates/workspace/src/waiting_room.rs +++ /dev/null @@ -1,185 +0,0 @@ -use crate::{sidebar::SidebarSide, AppState, ToggleFollow, Workspace}; -use anyhow::Result; -use client::{proto, Client, Contact}; -use gpui::{ - elements::*, ElementBox, Entity, ImageData, MutableAppContext, RenderContext, Task, View, - ViewContext, -}; -use project::Project; -use settings::Settings; -use std::sync::Arc; -use util::ResultExt; - -pub struct WaitingRoom { - project_id: u64, - avatar: Option>, - message: String, - waiting: bool, - client: Arc, - _join_task: Task>, -} - -impl Entity for WaitingRoom { - type Event = (); - - fn release(&mut self, _: &mut MutableAppContext) { - if self.waiting { - self.client - .send(proto::LeaveProject { - project_id: self.project_id, - }) - .log_err(); - } - } -} - -impl View for WaitingRoom { - fn ui_name() -> &'static str { - "WaitingRoom" - } - - fn render(&mut self, cx: &mut RenderContext) -> ElementBox { - let theme = &cx.global::().theme.workspace; - - Flex::column() - .with_children(self.avatar.clone().map(|avatar| { - Image::new(avatar) - .with_style(theme.joining_project_avatar) - .aligned() - .boxed() - })) - .with_child( - Text::new( - self.message.clone(), - theme.joining_project_message.text.clone(), - ) - .contained() - .with_style(theme.joining_project_message.container) - .aligned() - .boxed(), - ) - .aligned() - .contained() - .with_background_color(theme.background) - .boxed() - } -} - -impl WaitingRoom { - pub fn new( - contact: Arc, - project_index: usize, - app_state: Arc, - cx: &mut ViewContext, - ) -> Self { - let project_id = contact.projects[project_index].id; - let client = app_state.client.clone(); - let _join_task = cx.spawn_weak({ - let contact = contact.clone(); - |this, mut cx| async move { - let project = Project::remote( - project_id, - app_state.client.clone(), - app_state.user_store.clone(), - app_state.project_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - cx.clone(), - ) - .await; - - if let Some(this) = this.upgrade(&cx) { - this.update(&mut cx, |this, cx| { - this.waiting = false; - match project { - Ok(project) => { - cx.replace_root_view(|cx| { - let mut workspace = - Workspace::new(project, app_state.default_item_factory, cx); - (app_state.initialize_workspace)( - &mut workspace, - &app_state, - cx, - ); - workspace.toggle_sidebar(SidebarSide::Left, cx); - if let Some((host_peer_id, _)) = workspace - .project - .read(cx) - .collaborators() - .iter() - .find(|(_, collaborator)| collaborator.replica_id == 0) - { - if let Some(follow) = workspace - .toggle_follow(&ToggleFollow(*host_peer_id), cx) - { - follow.detach_and_log_err(cx); - } - } - workspace - }); - } - Err(error) => { - let login = &contact.user.github_login; - let message = match error { - project::JoinProjectError::HostDeclined => { - format!("@{} declined your request.", login) - } - project::JoinProjectError::HostClosedProject => { - format!( - "@{} closed their copy of {}.", - login, - humanize_list( - &contact.projects[project_index] - .visible_worktree_root_names - ) - ) - } - project::JoinProjectError::HostWentOffline => { - format!("@{} went offline.", login) - } - project::JoinProjectError::Other(error) => { - log::error!("error joining project: {}", error); - "An error occurred.".to_string() - } - }; - this.message = message; - cx.notify(); - } - } - }) - } - - Ok(()) - } - }); - - Self { - project_id, - avatar: contact.user.avatar.clone(), - message: format!( - "Asking to join @{}'s copy of {}...", - contact.user.github_login, - humanize_list(&contact.projects[project_index].visible_worktree_root_names) - ), - waiting: true, - client, - _join_task, - } - } -} - -fn humanize_list<'a>(items: impl IntoIterator) -> String { - let mut list = String::new(); - let mut items = items.into_iter().enumerate().peekable(); - while let Some((ix, item)) = items.next() { - if ix > 0 { - list.push_str(", "); - if items.peek().is_none() { - list.push_str("and "); - } - } - - list.push_str(item); - } - list -} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 017964d9a1..ece8cedfb1 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,5 +1,4 @@ -/// NOTE: Focus only 'takes' after an update has flushed_effects. Pane sends an event in on_focus_in -/// which the workspace uses to change the activated pane. +/// NOTE: Focus only 'takes' after an update has flushed_effects. /// /// This may cause issues when you're trying to write tests that use workspace focus to add items at /// specific locations. @@ -10,35 +9,30 @@ pub mod searchable; pub mod sidebar; mod status_bar; mod toolbar; -mod waiting_room; use anyhow::{anyhow, Context, Result}; -use client::{ - proto, Authenticate, Client, Contact, PeerId, Subscription, TypedEnvelope, User, UserStore, -}; -use clock::ReplicaId; +use call::ActiveCall; +use client::{proto, Client, PeerId, TypedEnvelope, UserStore}; use collections::{hash_map, HashMap, HashSet}; use dock::{DefaultItemFactory, Dock, ToggleDockButton}; use drag_and_drop::DragAndDrop; -use futures::{channel::oneshot, FutureExt}; +use fs::{self, Fs}; +use futures::{channel::oneshot, FutureExt, StreamExt}; use gpui::{ actions, - color::Color, elements::*, - geometry::{rect::RectF, vector::vec2f, PathBuilder}, impl_actions, impl_internal_actions, - json::{self, ToJson}, platform::{CursorStyle, WindowOptions}, - AnyModelHandle, AnyViewHandle, AppContext, AsyncAppContext, Border, Entity, ImageData, - ModelContext, ModelHandle, MouseButton, MutableAppContext, PathPromptOptions, PromptLevel, - RenderContext, Task, View, ViewContext, ViewHandle, WeakViewHandle, + AnyModelHandle, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, + MouseButton, MutableAppContext, PathPromptOptions, PromptLevel, RenderContext, Task, View, + ViewContext, ViewHandle, WeakViewHandle, }; use language::LanguageRegistry; use log::{error, warn}; pub use pane::*; pub use pane_group::*; use postage::prelude::Stream; -use project::{fs, Fs, Project, ProjectEntryId, ProjectPath, ProjectStore, Worktree, WorktreeId}; +use project::{Project, ProjectEntryId, ProjectPath, ProjectStore, Worktree, WorktreeId}; use searchable::SearchableItemHandle; use serde::Deserialize; use settings::{Autosave, DockAnchor, Settings}; @@ -52,8 +46,6 @@ use std::{ cell::RefCell, fmt, future::Future, - mem, - ops::Range, path::{Path, PathBuf}, rc::Rc, sync::{ @@ -65,7 +57,6 @@ use std::{ use theme::{Theme, ThemeRegistry}; pub use toolbar::{ToolbarItemLocation, ToolbarItemView}; use util::ResultExt; -use waiting_room::WaitingRoom; type ProjectItemBuilders = HashMap< TypeId, @@ -116,12 +107,6 @@ pub struct OpenPaths { pub paths: Vec, } -#[derive(Clone, Deserialize, PartialEq)] -pub struct ToggleProjectOnline { - #[serde(skip_deserializing)] - pub project: Option>, -} - #[derive(Clone, Deserialize, PartialEq)] pub struct ActivatePane(pub usize); @@ -130,8 +115,8 @@ pub struct ToggleFollow(pub PeerId); #[derive(Clone, PartialEq)] pub struct JoinProject { - pub contact: Arc, - pub project_index: usize, + pub project_id: u64, + pub follow_user_id: u64, } impl_internal_actions!( @@ -143,7 +128,7 @@ impl_internal_actions!( RemoveWorktreeFromProject ] ); -impl_actions!(workspace, [ToggleProjectOnline, ActivatePane]); +impl_actions!(workspace, [ActivatePane]); pub fn init(app_state: Arc, cx: &mut MutableAppContext) { pane::init(cx); @@ -174,14 +159,6 @@ pub fn init(app_state: Arc, cx: &mut MutableAppContext) { } } }); - cx.add_global_action({ - let app_state = Arc::downgrade(&app_state); - move |action: &JoinProject, cx: &mut MutableAppContext| { - if let Some(app_state) = app_state.upgrade() { - join_project(action.contact.clone(), action.project_index, &app_state, cx); - } - } - }); cx.add_async_action(Workspace::toggle_follow); cx.add_async_action(Workspace::follow_next_collaborator); @@ -189,7 +166,6 @@ pub fn init(app_state: Arc, cx: &mut MutableAppContext) { cx.add_async_action(Workspace::save_all); cx.add_action(Workspace::add_folder_to_project); cx.add_action(Workspace::remove_folder_from_project); - cx.add_action(Workspace::toggle_project_online); cx.add_action( |workspace: &mut Workspace, _: &Unfollow, cx: &mut ViewContext| { let pane = workspace.active_pane().clone(); @@ -318,7 +294,23 @@ pub trait Item: View { project: ModelHandle, cx: &mut ViewContext, ) -> Task>; + fn git_diff_recalc( + &mut self, + _project: ModelHandle, + _cx: &mut ViewContext, + ) -> Task> { + Task::ready(Ok(())) + } fn to_item_events(event: &Self::Event) -> Vec; + fn should_close_item_on_event(_: &Self::Event) -> bool { + false + } + fn should_update_tab_on_event(_: &Self::Event) -> bool { + false + } + fn is_edit_event(_: &Self::Event) -> bool { + false + } fn act_as_type( &self, type_id: TypeId, @@ -435,6 +427,57 @@ impl FollowableItemHandle for ViewHandle { } } +struct DelayedDebouncedEditAction { + task: Option>, + cancel_channel: Option>, +} + +impl DelayedDebouncedEditAction { + fn new() -> DelayedDebouncedEditAction { + DelayedDebouncedEditAction { + task: None, + cancel_channel: None, + } + } + + fn fire_new( + &mut self, + delay: Duration, + workspace: &Workspace, + cx: &mut ViewContext, + f: F, + ) where + F: FnOnce(ModelHandle, AsyncAppContext) -> Fut + 'static, + Fut: 'static + Future, + { + if let Some(channel) = self.cancel_channel.take() { + _ = channel.send(()); + } + + let project = workspace.project().downgrade(); + + let (sender, mut receiver) = oneshot::channel::<()>(); + self.cancel_channel = Some(sender); + + let previous_task = self.task.take(); + self.task = Some(cx.spawn_weak(|_, cx| async move { + let mut timer = cx.background().timer(delay).fuse(); + if let Some(previous_task) = previous_task { + previous_task.await; + } + + futures::select_biased! { + _ = receiver => return, + _ = timer => {} + } + + if let Some(project) = project.upgrade(&cx) { + (f)(project, cx).await; + } + })); + } +} + pub trait ItemHandle: 'static + fmt::Debug { fn subscribe_to_item_events( &self, @@ -473,6 +516,11 @@ pub trait ItemHandle: 'static + fmt::Debug { ) -> Task>; fn reload(&self, project: ModelHandle, cx: &mut MutableAppContext) -> Task>; + fn git_diff_recalc( + &self, + project: ModelHandle, + cx: &mut MutableAppContext, + ) -> Task>; fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option; fn to_followable_item_handle(&self, cx: &AppContext) -> Option>; fn on_release( @@ -578,8 +626,8 @@ impl ItemHandle for ViewHandle { .insert(self.id(), pane.downgrade()) .is_none() { - let mut pending_autosave = None; - let mut cancel_pending_autosave = oneshot::channel::<()>().0; + let mut pending_autosave = DelayedDebouncedEditAction::new(); + let mut pending_git_update = DelayedDebouncedEditAction::new(); let pending_update = Rc::new(RefCell::new(None)); let pending_update_scheduled = Rc::new(AtomicBool::new(false)); @@ -637,45 +685,66 @@ impl ItemHandle for ViewHandle { .detach_and_log_err(cx); return; } + ItemEvent::UpdateTab => { pane.update(cx, |_, cx| { cx.emit(pane::Event::ChangeItemTitle); cx.notify(); }); } + ItemEvent::Edit => { if let Autosave::AfterDelay { milliseconds } = cx.global::().autosave { - let prev_autosave = pending_autosave - .take() - .unwrap_or_else(|| Task::ready(Some(()))); - let (cancel_tx, mut cancel_rx) = oneshot::channel::<()>(); - let prev_cancel_tx = - mem::replace(&mut cancel_pending_autosave, cancel_tx); - let project = workspace.project.downgrade(); - let _ = prev_cancel_tx.send(()); + let delay = Duration::from_millis(milliseconds); let item = item.clone(); - pending_autosave = - Some(cx.spawn_weak(|_, mut cx| async move { - let mut timer = cx - .background() - .timer(Duration::from_millis(milliseconds)) - .fuse(); - prev_autosave.await; - futures::select_biased! { - _ = cancel_rx => return None, - _ = timer => {} - } - - let project = project.upgrade(&cx)?; + pending_autosave.fire_new( + delay, + workspace, + cx, + |project, mut cx| async move { cx.update(|cx| Pane::autosave_item(&item, project, cx)) .await .log_err(); - None - })); + }, + ); + } + + let settings = cx.global::(); + let debounce_delay = settings.git_overrides.gutter_debounce; + + let item = item.clone(); + + if let Some(delay) = debounce_delay { + const MIN_GIT_DELAY: u64 = 50; + + let delay = delay.max(MIN_GIT_DELAY); + let duration = Duration::from_millis(delay); + + pending_git_update.fire_new( + duration, + workspace, + cx, + |project, mut cx| async move { + cx.update(|cx| item.git_diff_recalc(project, cx)) + .await + .log_err(); + }, + ); + } else { + let project = workspace.project().downgrade(); + cx.spawn_weak(|_, mut cx| async move { + if let Some(project) = project.upgrade(&cx) { + cx.update(|cx| item.git_diff_recalc(project, cx)) + .await + .log_err(); + } + }) + .detach(); } } + _ => {} } } @@ -755,6 +824,14 @@ impl ItemHandle for ViewHandle { self.update(cx, |item, cx| item.reload(project, cx)) } + fn git_diff_recalc( + &self, + project: ModelHandle, + cx: &mut MutableAppContext, + ) -> Task> { + self.update(cx, |item, cx| item.git_diff_recalc(project, cx)) + } + fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option { self.read(cx).act_as_type(type_id, self, cx) } @@ -853,11 +930,11 @@ impl AppState { let settings = Settings::test(cx); cx.set_global(settings); - let fs = project::FakeFs::new(cx.background().clone()); + let fs = fs::FakeFs::new(cx.background().clone()); let languages = Arc::new(LanguageRegistry::test()); let http_client = client::test::FakeHttpClient::with_404_response(); - let client = Client::new(http_client.clone()); - let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake())); + let client = Client::new(http_client.clone(), cx); + let project_store = cx.add_model(|_| ProjectStore::new()); let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx)); let themes = ThemeRegistry::new((), cx.font_cache().clone()); Arc::new(Self { @@ -884,7 +961,7 @@ pub struct Workspace { weak_self: WeakViewHandle, client: Arc, user_store: ModelHandle, - remote_entity_subscription: Option, + remote_entity_subscription: Option, fs: Arc, modal: Option, center: PaneGroup, @@ -893,8 +970,9 @@ pub struct Workspace { panes: Vec>, panes_by_item: HashMap>, active_pane: ViewHandle, - last_active_center_pane: Option>, + last_active_center_pane: Option>, status_bar: ViewHandle, + titlebar_item: Option, dock: Dock, notifications: Vec<(TypeId, usize, Box)>, project: ModelHandle, @@ -902,7 +980,9 @@ pub struct Workspace { follower_states_by_leader: FollowerStatesByLeader, last_leaders_by_pane: HashMap, PeerId>, window_edited: bool, + active_call: Option>, _observe_current_user: Task<()>, + _active_call_observation: Option, } #[derive(Default)] @@ -1011,6 +1091,14 @@ impl Workspace { drag_and_drop.register_container(weak_handle.clone()); }); + let mut active_call = None; + let mut active_call_observation = None; + if cx.has_global::>() { + let call = cx.global::>().clone(); + active_call_observation = Some(cx.observe(&call, |_, _, cx| cx.notify())); + active_call = Some(call); + } + let mut this = Workspace { modal: None, weak_self: weak_handle, @@ -1022,8 +1110,9 @@ impl Workspace { panes: vec![dock_pane, center_pane.clone()], panes_by_item: Default::default(), active_pane: center_pane.clone(), - last_active_center_pane: Some(center_pane.clone()), + last_active_center_pane: Some(center_pane.downgrade()), status_bar, + titlebar_item: None, notifications: Default::default(), client, remote_entity_subscription: None, @@ -1036,7 +1125,9 @@ impl Workspace { follower_states_by_leader: Default::default(), last_leaders_by_pane: Default::default(), window_edited: false, + active_call, _observe_current_user, + _active_call_observation: active_call_observation, }; this.project_remote_id_changed(this.project.read(cx).remote_id(), cx); cx.defer(|this, cx| this.update_window_title(cx)); @@ -1068,6 +1159,23 @@ impl Workspace { &self.project } + pub fn client(&self) -> &Arc { + &self.client + } + + pub fn set_titlebar_item( + &mut self, + item: impl Into, + cx: &mut ViewContext, + ) { + self.titlebar_item = Some(item.into()); + cx.notify(); + } + + pub fn titlebar_item(&self) -> Option { + self.titlebar_item.clone() + } + /// Call the given callback with a workspace whose project is local. /// /// If the given workspace has a local project, then it will be passed @@ -1088,7 +1196,6 @@ impl Workspace { let (_, workspace) = cx.add_window((app_state.build_window_options)(), |cx| { let mut workspace = Workspace::new( Project::local( - false, app_state.client.clone(), app_state.user_store.clone(), app_state.project_store.clone(), @@ -1138,7 +1245,7 @@ impl Workspace { _: &CloseWindow, cx: &mut ViewContext, ) -> Option>> { - let prepare = self.prepare_to_close(cx); + let prepare = self.prepare_to_close(false, cx); Some(cx.spawn(|this, mut cx| async move { if prepare.await? { this.update(&mut cx, |_, cx| { @@ -1150,8 +1257,44 @@ impl Workspace { })) } - pub fn prepare_to_close(&mut self, cx: &mut ViewContext) -> Task> { - self.save_all_internal(true, cx) + pub fn prepare_to_close( + &mut self, + quitting: bool, + cx: &mut ViewContext, + ) -> Task> { + let active_call = self.active_call.clone(); + let window_id = cx.window_id(); + let workspace_count = cx + .window_ids() + .flat_map(|window_id| cx.root_view::(window_id)) + .count(); + cx.spawn(|this, mut cx| async move { + if let Some(active_call) = active_call { + if !quitting + && workspace_count == 1 + && active_call.read_with(&cx, |call, _| call.room().is_some()) + { + let answer = cx + .prompt( + window_id, + PromptLevel::Warning, + "Do you want to leave the current call?", + &["Close window and hang up", "Cancel"], + ) + .next() + .await; + if answer == Some(1) { + return anyhow::Ok(false); + } else { + active_call.update(&mut cx, |call, cx| call.hang_up(cx))?; + } + } + } + + Ok(this + .update(&mut cx, |this, cx| this.save_all_internal(true, cx)) + .await?) + }) } fn save_all(&mut self, _: &SaveAll, cx: &mut ViewContext) -> Option>> { @@ -1293,17 +1436,6 @@ impl Workspace { .update(cx, |project, cx| project.remove_worktree(*worktree_id, cx)); } - fn toggle_project_online(&mut self, action: &ToggleProjectOnline, cx: &mut ViewContext) { - let project = action - .project - .clone() - .unwrap_or_else(|| self.project.clone()); - project.update(cx, |project, cx| { - let public = !project.is_online(); - project.set_online(public, cx); - }); - } - fn project_path_for_path( &self, abs_path: &Path, @@ -1717,7 +1849,7 @@ impl Workspace { if &pane == self.dock_pane() { Dock::show(self, cx); } else { - self.last_active_center_pane = Some(pane.clone()); + self.last_active_center_pane = Some(pane.downgrade()); if self.dock.is_anchored_at(DockAnchor::Expanded) { Dock::hide(self, cx); } @@ -1748,7 +1880,6 @@ impl Workspace { } pane::Event::Remove if !is_dock => self.remove_pane(pane, cx), pane::Event::Remove if is_dock => Dock::hide(self, cx), - pane::Event::Focused => self.handle_pane_focused(pane, cx), pane::Event::ActivateItem { local } => { if *local { self.unfollow(&pane, cx); @@ -1809,7 +1940,7 @@ impl Workspace { for removed_item in pane.read(cx).items() { self.panes_by_item.remove(&removed_item.id()); } - if self.last_active_center_pane == Some(pane) { + if self.last_active_center_pane == Some(pane.downgrade()) { self.last_active_center_pane = None; } @@ -1968,46 +2099,12 @@ impl Workspace { None } - fn render_connection_status(&self, cx: &mut RenderContext) -> Option { - let theme = &cx.global::().theme; - match &*self.client.status().borrow() { - client::Status::ConnectionError - | client::Status::ConnectionLost - | client::Status::Reauthenticating { .. } - | client::Status::Reconnecting { .. } - | client::Status::ReconnectionError { .. } => Some( - Container::new( - Align::new( - ConstrainedBox::new( - Svg::new("icons/cloud_slash_12.svg") - .with_color(theme.workspace.titlebar.offline_icon.color) - .boxed(), - ) - .with_width(theme.workspace.titlebar.offline_icon.width) - .boxed(), - ) - .boxed(), - ) - .with_style(theme.workspace.titlebar.offline_icon.container) - .boxed(), - ), - client::Status::UpgradeRequired => Some( - Label::new( - "Please update Zed to collaborate".to_string(), - theme.workspace.titlebar.outdated_warning.text.clone(), - ) - .contained() - .with_style(theme.workspace.titlebar.outdated_warning.container) - .aligned() - .boxed(), - ), - _ => None, - } + pub fn is_following(&self, peer_id: PeerId) -> bool { + self.follower_states_by_leader.contains_key(&peer_id) } fn render_titlebar(&self, theme: &Theme, cx: &mut RenderContext) -> ElementBox { let project = &self.project.read(cx); - let replica_id = project.replica_id(); let mut worktree_root_names = String::new(); for (i, name) in project.worktree_root_names(cx).enumerate() { if i > 0 { @@ -2038,21 +2135,10 @@ impl Workspace { .left() .boxed(), ) - .with_child( - Align::new( - Flex::row() - .with_children(self.render_collaborators(theme, cx)) - .with_children(self.render_current_user( - self.user_store.read(cx).current_user().as_ref(), - replica_id, - theme, - cx, - )) - .with_children(self.render_connection_status(cx)) - .boxed(), - ) - .right() - .boxed(), + .with_children( + self.titlebar_item + .as_ref() + .map(|item| ChildView::new(item, cx).aligned().right().boxed()), ) .boxed(), ) @@ -2121,125 +2207,6 @@ impl Workspace { } } - fn render_collaborators(&self, theme: &Theme, cx: &mut RenderContext) -> Vec { - let mut collaborators = self - .project - .read(cx) - .collaborators() - .values() - .cloned() - .collect::>(); - collaborators.sort_unstable_by_key(|collaborator| collaborator.replica_id); - collaborators - .into_iter() - .filter_map(|collaborator| { - Some(self.render_avatar( - collaborator.user.avatar.clone()?, - collaborator.replica_id, - Some((collaborator.peer_id, &collaborator.user.github_login)), - theme, - cx, - )) - }) - .collect() - } - - fn render_current_user( - &self, - user: Option<&Arc>, - replica_id: ReplicaId, - theme: &Theme, - cx: &mut RenderContext, - ) -> Option { - let status = *self.client.status().borrow(); - if let Some(avatar) = user.and_then(|user| user.avatar.clone()) { - Some(self.render_avatar(avatar, replica_id, None, theme, cx)) - } else if matches!(status, client::Status::UpgradeRequired) { - None - } else { - Some( - MouseEventHandler::::new(0, cx, |state, _| { - let style = theme - .workspace - .titlebar - .sign_in_prompt - .style_for(state, false); - Label::new("Sign in".to_string(), style.text.clone()) - .contained() - .with_style(style.container) - .boxed() - }) - .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate)) - .with_cursor_style(CursorStyle::PointingHand) - .aligned() - .boxed(), - ) - } - } - - fn render_avatar( - &self, - avatar: Arc, - replica_id: ReplicaId, - peer: Option<(PeerId, &str)>, - theme: &Theme, - cx: &mut RenderContext, - ) -> ElementBox { - let replica_color = theme.editor.replica_selection_style(replica_id).cursor; - let is_followed = peer.map_or(false, |(peer_id, _)| { - self.follower_states_by_leader.contains_key(&peer_id) - }); - let mut avatar_style = theme.workspace.titlebar.avatar; - if is_followed { - avatar_style.border = Border::all(1.0, replica_color); - } - let content = Stack::new() - .with_child( - Image::new(avatar) - .with_style(avatar_style) - .constrained() - .with_width(theme.workspace.titlebar.avatar_width) - .aligned() - .boxed(), - ) - .with_child( - AvatarRibbon::new(replica_color) - .constrained() - .with_width(theme.workspace.titlebar.avatar_ribbon.width) - .with_height(theme.workspace.titlebar.avatar_ribbon.height) - .aligned() - .bottom() - .boxed(), - ) - .constrained() - .with_width(theme.workspace.titlebar.avatar_width) - .contained() - .with_margin_left(theme.workspace.titlebar.avatar_margin) - .boxed(); - - if let Some((peer_id, peer_github_login)) = peer { - MouseEventHandler::::new(replica_id.into(), cx, move |_, _| content) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, cx| { - cx.dispatch_action(ToggleFollow(peer_id)) - }) - .with_tooltip::( - peer_id.0 as usize, - if is_followed { - format!("Unfollow {}", peer_github_login) - } else { - format!("Follow {}", peer_github_login) - }, - Some(Box::new(FollowNextCollaborator)), - theme.tooltip.clone(), - cx, - ) - .boxed() - } else { - content - } - } - fn render_disconnected_overlay(&self, cx: &mut RenderContext) -> Option { if self.project.read(cx).is_read_only() { enum DisconnectedOverlay {} @@ -2264,14 +2231,18 @@ impl Workspace { } } - fn render_notifications(&self, theme: &theme::Workspace) -> Option { + fn render_notifications( + &self, + theme: &theme::Workspace, + cx: &AppContext, + ) -> Option { if self.notifications.is_empty() { None } else { Some( Flex::column() .with_children(self.notifications.iter().map(|(_, _, notification)| { - ChildView::new(notification.as_ref()) + ChildView::new(notification.as_ref(), cx) .contained() .with_style(theme.notification) .boxed() @@ -2598,11 +2569,12 @@ impl View for Workspace { .with_child( Stack::new() .with_child({ + let project = self.project.clone(); Flex::row() .with_children( if self.left_sidebar.read(cx).active_item().is_some() { Some( - ChildView::new(&self.left_sidebar) + ChildView::new(&self.left_sidebar, cx) .flex(0.8, false) .boxed(), ) @@ -2615,9 +2587,11 @@ impl View for Workspace { Flex::column() .with_child( FlexItem::new(self.center.render( + &project, &theme, &self.follower_states_by_leader, - self.project.read(cx).collaborators(), + self.active_call.as_ref(), + cx, )) .flex(1., true) .boxed(), @@ -2636,7 +2610,7 @@ impl View for Workspace { .with_children( if self.right_sidebar.read(cx).active_item().is_some() { Some( - ChildView::new(&self.right_sidebar) + ChildView::new(&self.right_sidebar, cx) .flex(0.8, false) .boxed(), ) @@ -2654,15 +2628,17 @@ impl View for Workspace { DockAnchor::Expanded, cx, )) - .with_children(self.modal.as_ref().map(|m| { - ChildView::new(m) + .with_children(self.modal.as_ref().map(|modal| { + ChildView::new(modal, cx) .contained() .with_style(theme.workspace.modal) .aligned() .top() .boxed() })) - .with_children(self.render_notifications(&theme.workspace)) + .with_children( + self.render_notifications(&theme.workspace, cx), + ) .boxed(), ) .boxed(), @@ -2670,7 +2646,7 @@ impl View for Workspace { .flex(1.0, true) .boxed(), ) - .with_child(ChildView::new(&self.status_bar).boxed()) + .with_child(ChildView::new(&self.status_bar, cx).boxed()) .contained() .with_background_color(theme.workspace.background) .boxed(), @@ -2680,9 +2656,17 @@ impl View for Workspace { .named("workspace") } - fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { + fn on_focus_in(&mut self, view: AnyViewHandle, cx: &mut ViewContext) { if cx.is_self_focused() { cx.focus(&self.active_pane); + } else { + for pane in self.panes() { + let view = view.clone(); + if pane.update(cx, |_, cx| cx.is_child(view)) { + self.handle_pane_focused(pane.clone(), cx); + break; + } + } } } @@ -2714,87 +2698,6 @@ impl WorkspaceHandle for ViewHandle { } } -pub struct AvatarRibbon { - color: Color, -} - -impl AvatarRibbon { - pub fn new(color: Color) -> AvatarRibbon { - AvatarRibbon { color } - } -} - -impl Element for AvatarRibbon { - type LayoutState = (); - - type PaintState = (); - - fn layout( - &mut self, - constraint: gpui::SizeConstraint, - _: &mut gpui::LayoutContext, - ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) { - (constraint.max, ()) - } - - fn paint( - &mut self, - bounds: gpui::geometry::rect::RectF, - _: gpui::geometry::rect::RectF, - _: &mut Self::LayoutState, - cx: &mut gpui::PaintContext, - ) -> Self::PaintState { - let mut path = PathBuilder::new(); - path.reset(bounds.lower_left()); - path.curve_to( - bounds.origin() + vec2f(bounds.height(), 0.), - bounds.origin(), - ); - path.line_to(bounds.upper_right() - vec2f(bounds.height(), 0.)); - path.curve_to(bounds.lower_right(), bounds.upper_right()); - path.line_to(bounds.lower_left()); - cx.scene.push_path(path.build(self.color, None)); - } - - fn dispatch_event( - &mut self, - _: &gpui::Event, - _: RectF, - _: RectF, - _: &mut Self::LayoutState, - _: &mut Self::PaintState, - _: &mut gpui::EventContext, - ) -> bool { - false - } - - fn rect_for_text_range( - &self, - _: Range, - _: RectF, - _: RectF, - _: &Self::LayoutState, - _: &Self::PaintState, - _: &gpui::MeasurementContext, - ) -> Option { - None - } - - fn debug( - &self, - bounds: gpui::geometry::rect::RectF, - _: &Self::LayoutState, - _: &Self::PaintState, - _: &gpui::DebugContext, - ) -> gpui::json::Value { - json::json!({ - "type": "AvatarRibbon", - "bounds": bounds.to_json(), - "color": self.color.to_json(), - }) - } -} - impl std::fmt::Debug for OpenPaths { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("OpenPaths") @@ -2864,7 +2767,6 @@ pub fn open_paths( cx.add_window((app_state.build_window_options)(), |cx| { let project = Project::local( - false, app_state.client.clone(), app_state.user_store.clone(), app_state.project_store.clone(), @@ -2889,44 +2791,14 @@ pub fn open_paths( }) .await; - if let Some(project) = new_project { - project - .update(&mut cx, |project, cx| project.restore_state(cx)) - .await - .log_err(); - } - (workspace, items) }) } -pub fn join_project( - contact: Arc, - project_index: usize, - app_state: &Arc, - cx: &mut MutableAppContext, -) { - let project_id = contact.projects[project_index].id; - - for window_id in cx.window_ids().collect::>() { - if let Some(workspace) = cx.root_view::(window_id) { - if workspace.read(cx).project().read(cx).remote_id() == Some(project_id) { - cx.activate_window(window_id); - return; - } - } - } - - cx.add_window((app_state.build_window_options)(), |cx| { - WaitingRoom::new(contact, project_index, app_state.clone(), cx) - }); -} - fn open_new(app_state: &Arc, cx: &mut MutableAppContext) { let (window_id, workspace) = cx.add_window((app_state.build_window_options)(), |cx| { let mut workspace = Workspace::new( Project::local( - false, app_state.client.clone(), app_state.user_store.clone(), app_state.project_store.clone(), @@ -2950,8 +2822,9 @@ mod tests { use crate::sidebar::SidebarItem; use super::*; + use fs::FakeFs; use gpui::{executor::Deterministic, ModelHandle, TestAppContext, ViewContext}; - use project::{FakeFs, Project, ProjectEntryId}; + use project::{Project, ProjectEntryId}; use serde_json::json; pub fn default_item_factory( @@ -3136,7 +3009,7 @@ mod tests { // When there are no dirty items, there's nothing to do. let item1 = cx.add_view(&workspace, |_| TestItem::new()); workspace.update(cx, |w, cx| w.add_item(Box::new(item1.clone()), cx)); - let task = workspace.update(cx, |w, cx| w.prepare_to_close(cx)); + let task = workspace.update(cx, |w, cx| w.prepare_to_close(false, cx)); assert!(task.await.unwrap()); // When there are dirty untitled items, prompt to save each one. If the user @@ -3156,7 +3029,7 @@ mod tests { w.add_item(Box::new(item2.clone()), cx); w.add_item(Box::new(item3.clone()), cx); }); - let task = workspace.update(cx, |w, cx| w.prepare_to_close(cx)); + let task = workspace.update(cx, |w, cx| w.prepare_to_close(false, cx)); cx.foreground().run_until_parked(); cx.simulate_prompt_answer(window_id, 2 /* cancel */); cx.foreground().run_until_parked(); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index b2b795243b..cc0a3b62fb 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -3,7 +3,7 @@ authors = ["Nathan Sobo "] description = "The fast, collaborative code editor." edition = "2021" name = "zed" -version = "0.54.1" +version = "0.60.4" [lib] name = "zed" @@ -19,18 +19,19 @@ activity_indicator = { path = "../activity_indicator" } assets = { path = "../assets" } auto_update = { path = "../auto_update" } breadcrumbs = { path = "../breadcrumbs" } +call = { path = "../call" } cli = { path = "../cli" } +collab_ui = { path = "../collab_ui" } collections = { path = "../collections" } command_palette = { path = "../command_palette" } context_menu = { path = "../context_menu" } client = { path = "../client" } clock = { path = "../clock" } -contacts_panel = { path = "../contacts_panel" } -contacts_status_item = { path = "../contacts_status_item" } diagnostics = { path = "../diagnostics" } editor = { path = "../editor" } file_finder = { path = "../file_finder" } search = { path = "../search" } +fs = { path = "../fs" } fsevent = { path = "../fsevent" } fuzzy = { path = "../fuzzy" } go_to_line = { path = "../go_to_line" } @@ -92,6 +93,7 @@ toml = "0.5" tree-sitter = "0.20" tree-sitter-c = "0.20.1" tree-sitter-cpp = "0.20.0" +tree-sitter-css = { git = "https://github.com/tree-sitter/tree-sitter-css", rev = "769203d0f9abe1a9a691ac2b9fe4bb4397a73c51" } tree-sitter-elixir = { git = "https://github.com/elixir-lang/tree-sitter-elixir", rev = "05e3631c6a0701c1fa518b0fee7be95a2ceef5e2" } tree-sitter-go = { git = "https://github.com/tree-sitter/tree-sitter-go", rev = "aeb2f33b366fd78d5789ff104956ce23508b85db" } tree-sitter-json = { git = "https://github.com/tree-sitter/tree-sitter-json", rev = "137e1ce6a02698fc246cdb9c6b886ed1de9a1ed8" } @@ -100,20 +102,23 @@ tree-sitter-markdown = { git = "https://github.com/MDeiml/tree-sitter-markdown", tree-sitter-python = "0.20.2" tree-sitter-toml = { git = "https://github.com/tree-sitter/tree-sitter-toml", rev = "342d9be207c2dba869b9967124c679b5e6fd0ebe" } tree-sitter-typescript = "0.20.1" +tree-sitter-html = "0.19.0" url = "2.2" [dev-dependencies] -text = { path = "../text", features = ["test-support"] } +call = { path = "../call", features = ["test-support"] } +client = { path = "../client", features = ["test-support"] } editor = { path = "../editor", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] } language = { path = "../language", features = ["test-support"] } lsp = { path = "../lsp", features = ["test-support"] } project = { path = "../project", features = ["test-support"] } rpc = { path = "../rpc", features = ["test-support"] } -client = { path = "../client", features = ["test-support"] } settings = { path = "../settings", features = ["test-support"] } +text = { path = "../text", features = ["test-support"] } util = { path = "../util", features = ["test-support"] } workspace = { path = "../workspace", features = ["test-support"] } + env_logger = "0.9" serde_json = { version = "1.0", features = ["preserve_order"] } unindent = "0.1.7" diff --git a/crates/zed/build.rs b/crates/zed/build.rs index e39946876e..3d21703a15 100644 --- a/crates/zed/build.rs +++ b/crates/zed/build.rs @@ -3,6 +3,10 @@ use std::process::Command; fn main() { println!("cargo:rustc-env=MACOSX_DEPLOYMENT_TARGET=10.14"); + if let Ok(api_key) = std::env::var("ZED_AMPLITUDE_API_KEY") { + println!("cargo:rustc-env=ZED_AMPLITUDE_API_KEY={api_key}"); + } + let output = Command::new("npm") .current_dir("../../styles") .args(["install", "--no-save"]) @@ -17,7 +21,7 @@ fn main() { let output = Command::new("npm") .current_dir("../../styles") - .args(["run", "build-themes"]) + .args(["run", "build"]) .output() .expect("failed to run npm"); if !output.status.success() { diff --git a/crates/zed/src/languages.rs b/crates/zed/src/languages.rs index 6e57106e87..2745fa824a 100644 --- a/crates/zed/src/languages.rs +++ b/crates/zed/src/languages.rs @@ -7,6 +7,7 @@ use std::{borrow::Cow, str, sync::Arc}; mod c; mod elixir; mod go; +mod html; mod installation; mod json; mod language_plugin; @@ -46,6 +47,11 @@ pub async fn init(languages: Arc, _executor: Arc) tree_sitter_cpp::language(), Some(CachedLspAdapter::new(c::CLspAdapter).await), ), + ( + "css", + tree_sitter_css::language(), + None, // + ), ( "elixir", tree_sitter_elixir::language(), @@ -96,8 +102,13 @@ pub async fn init(languages: Arc, _executor: Arc) tree_sitter_typescript::language_tsx(), Some(CachedLspAdapter::new(typescript::TypeScriptLspAdapter).await), ), + ( + "html", + tree_sitter_html::language(), + Some(CachedLspAdapter::new(html::HtmlLspAdapter).await), + ), ] { - languages.add(Arc::new(language(name, grammar, lsp_adapter))); + languages.add(language(name, grammar, lsp_adapter)); } } @@ -105,7 +116,7 @@ pub(crate) fn language( name: &str, grammar: tree_sitter::Language, lsp_adapter: Option>, -) -> Language { +) -> Arc { let config = toml::from_slice( &LanguageDir::get(&format!("{}/config.toml", name)) .unwrap() @@ -142,7 +153,7 @@ pub(crate) fn language( if let Some(lsp_adapter) = lsp_adapter { language = language.with_lsp_adapter(lsp_adapter) } - language + Arc::new(language) } fn load_query(name: &str, filename_prefix: &str) -> Option> { diff --git a/crates/zed/src/languages/c.rs b/crates/zed/src/languages/c.rs index 6aa750f6a0..712e87101b 100644 --- a/crates/zed/src/languages/c.rs +++ b/crates/zed/src/languages/c.rs @@ -112,7 +112,7 @@ impl super::LspAdapter for CLspAdapter { async fn label_for_completion( &self, completion: &lsp::CompletionItem, - language: &Language, + language: &Arc, ) -> Option { let label = completion .label @@ -190,7 +190,7 @@ impl super::LspAdapter for CLspAdapter { &self, name: &str, kind: lsp::SymbolKind, - language: &Language, + language: &Arc, ) -> Option { let (text, filter_range, display_range) = match kind { lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { @@ -251,7 +251,6 @@ mod tests { use gpui::MutableAppContext; use language::{AutoindentMode, Buffer}; use settings::Settings; - use std::sync::Arc; #[gpui::test] fn test_c_autoindent(cx: &mut MutableAppContext) { @@ -262,7 +261,7 @@ mod tests { let language = crate::languages::language("c", tree_sitter_c::language(), None); cx.add_model(|cx| { - let mut buffer = Buffer::new(0, "", cx).with_language(Arc::new(language), cx); + let mut buffer = Buffer::new(0, "", cx).with_language(language, cx); // empty function buffer.edit([(0..0, "int main() {}")], None, cx); diff --git a/crates/zed/src/languages/c/highlights.scm b/crates/zed/src/languages/c/highlights.scm index 007c871ffa..064ec61a37 100644 --- a/crates/zed/src/languages/c/highlights.scm +++ b/crates/zed/src/languages/c/highlights.scm @@ -86,7 +86,7 @@ (identifier) @variable ((identifier) @constant - (#match? @constant "^[A-Z][A-Z\\d_]*$")) + (#match? @constant "^_*[A-Z][A-Z\\d_]*$")) (call_expression function: (identifier) @function) diff --git a/crates/zed/src/languages/cpp/highlights.scm b/crates/zed/src/languages/cpp/highlights.scm index 2dd9188308..bcfa01ca5c 100644 --- a/crates/zed/src/languages/cpp/highlights.scm +++ b/crates/zed/src/languages/cpp/highlights.scm @@ -37,11 +37,11 @@ (type_identifier) @type ((identifier) @constant - (#match? @constant "^[A-Z][A-Z\\d_]*$")) + (#match? @constant "^_*[A-Z][A-Z\\d_]*$")) (field_identifier) @property (statement_identifier) @label -(this) @variable.builtin +(this) @variable.special [ "break" diff --git a/crates/zed/src/languages/css/brackets.scm b/crates/zed/src/languages/css/brackets.scm new file mode 100644 index 0000000000..191fd9c084 --- /dev/null +++ b/crates/zed/src/languages/css/brackets.scm @@ -0,0 +1,3 @@ +("(" @open ")" @close) +("[" @open "]" @close) +("{" @open "}" @close) diff --git a/crates/zed/src/languages/css/config.toml b/crates/zed/src/languages/css/config.toml new file mode 100644 index 0000000000..28def3abd5 --- /dev/null +++ b/crates/zed/src/languages/css/config.toml @@ -0,0 +1,9 @@ +name = "CSS" +path_suffixes = ["css"] +autoclose_before = ";:.,=}])>" +brackets = [ + { start = "{", end = "}", close = true, newline = true }, + { start = "[", end = "]", close = true, newline = true }, + { start = "(", end = ")", close = true, newline = true }, + { start = "\"", end = "\"", close = true, newline = false } +] diff --git a/crates/zed/src/languages/css/highlights.scm b/crates/zed/src/languages/css/highlights.scm new file mode 100644 index 0000000000..e271d8583c --- /dev/null +++ b/crates/zed/src/languages/css/highlights.scm @@ -0,0 +1,78 @@ +(comment) @comment + +[ + (tag_name) + (nesting_selector) + (universal_selector) +] @tag + +[ + "~" + ">" + "+" + "-" + "*" + "/" + "=" + "^=" + "|=" + "~=" + "$=" + "*=" + "and" + "or" + "not" + "only" +] @operator + +(attribute_selector (plain_value) @string) + +(attribute_name) @attribute +(pseudo_element_selector (tag_name) @attribute) +(pseudo_class_selector (class_name) @attribute) + +[ + (class_name) + (id_name) + (namespace_name) + (property_name) + (feature_name) +] @property + +(function_name) @function + +( + [ + (property_name) + (plain_value) + ] @variable.special + (#match? @variable.special "^--") +) + +[ + "@media" + "@import" + "@charset" + "@namespace" + "@supports" + "@keyframes" + (at_keyword) + (to) + (from) + (important) +] @keyword + +(string_value) @string +(color_value) @string.special + +[ + (integer_value) + (float_value) +] @number + +(unit) @type + +[ + "," + ":" +] @punctuation.delimiter diff --git a/crates/zed/src/languages/css/indents.scm b/crates/zed/src/languages/css/indents.scm new file mode 100644 index 0000000000..e975469092 --- /dev/null +++ b/crates/zed/src/languages/css/indents.scm @@ -0,0 +1 @@ +(_ "{" "}" @end) @indent diff --git a/crates/zed/src/languages/elixir.rs b/crates/zed/src/languages/elixir.rs index 4959338522..75b35bb630 100644 --- a/crates/zed/src/languages/elixir.rs +++ b/crates/zed/src/languages/elixir.rs @@ -113,7 +113,7 @@ impl LspAdapter for ElixirLspAdapter { async fn label_for_completion( &self, completion: &lsp::CompletionItem, - language: &Language, + language: &Arc, ) -> Option { match completion.kind.zip(completion.detail.as_ref()) { Some((_, detail)) if detail.starts_with("(function)") => { @@ -168,7 +168,7 @@ impl LspAdapter for ElixirLspAdapter { &self, name: &str, kind: SymbolKind, - language: &Language, + language: &Arc, ) -> Option { let (text, filter_range, display_range) = match kind { SymbolKind::METHOD | SymbolKind::FUNCTION => { diff --git a/crates/zed/src/languages/go.rs b/crates/zed/src/languages/go.rs index 729d39b513..19692fdf44 100644 --- a/crates/zed/src/languages/go.rs +++ b/crates/zed/src/languages/go.rs @@ -134,7 +134,7 @@ impl super::LspAdapter for GoLspAdapter { async fn label_for_completion( &self, completion: &lsp::CompletionItem, - language: &Language, + language: &Arc, ) -> Option { let label = &completion.label; @@ -235,7 +235,7 @@ impl super::LspAdapter for GoLspAdapter { &self, name: &str, kind: lsp::SymbolKind, - language: &Language, + language: &Arc, ) -> Option { let (text, filter_range, display_range) = match kind { lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { diff --git a/crates/zed/src/languages/html.rs b/crates/zed/src/languages/html.rs new file mode 100644 index 0000000000..5497841d88 --- /dev/null +++ b/crates/zed/src/languages/html.rs @@ -0,0 +1,101 @@ +use super::installation::{npm_install_packages, npm_package_latest_version}; +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use client::http::HttpClient; +use futures::StreamExt; +use language::{LanguageServerName, LspAdapter}; +use serde_json::json; +use smol::fs; +use std::{any::Any, path::PathBuf, sync::Arc}; +use util::ResultExt; + +pub struct HtmlLspAdapter; + +impl HtmlLspAdapter { + const BIN_PATH: &'static str = + "node_modules/vscode-langservers-extracted/bin/vscode-html-language-server"; +} + +#[async_trait] +impl LspAdapter for HtmlLspAdapter { + async fn name(&self) -> LanguageServerName { + LanguageServerName("vscode-html-language-server".into()) + } + + async fn server_args(&self) -> Vec { + vec!["--stdio".into()] + } + + async fn fetch_latest_server_version( + &self, + _: Arc, + ) -> Result> { + Ok(Box::new(npm_package_latest_version("vscode-langservers-extracted").await?) as Box<_>) + } + + async fn fetch_server_binary( + &self, + version: Box, + _: Arc, + container_dir: PathBuf, + ) -> Result { + let version = version.downcast::().unwrap(); + let version_dir = container_dir.join(version.as_str()); + fs::create_dir_all(&version_dir) + .await + .context("failed to create version directory")?; + let binary_path = version_dir.join(Self::BIN_PATH); + + if fs::metadata(&binary_path).await.is_err() { + npm_install_packages( + [("vscode-langservers-extracted", version.as_str())], + &version_dir, + ) + .await?; + + if let Some(mut entries) = fs::read_dir(&container_dir).await.log_err() { + while let Some(entry) = entries.next().await { + if let Some(entry) = entry.log_err() { + let entry_path = entry.path(); + if entry_path.as_path() != version_dir { + fs::remove_dir_all(&entry_path).await.log_err(); + } + } + } + } + } + + Ok(binary_path) + } + + async fn cached_server_binary(&self, container_dir: PathBuf) -> Option { + (|| async move { + let mut last_version_dir = None; + let mut entries = fs::read_dir(&container_dir).await?; + while let Some(entry) = entries.next().await { + let entry = entry?; + if entry.file_type().await?.is_dir() { + last_version_dir = Some(entry.path()); + } + } + let last_version_dir = last_version_dir.ok_or_else(|| anyhow!("no cached binary"))?; + let bin_path = last_version_dir.join(Self::BIN_PATH); + if bin_path.exists() { + Ok(bin_path) + } else { + Err(anyhow!( + "missing executable in directory {:?}", + last_version_dir + )) + } + })() + .await + .log_err() + } + + async fn initialization_options(&self) -> Option { + Some(json!({ + "provideFormatter": true + })) + } +} diff --git a/crates/zed/src/languages/html/brackets.scm b/crates/zed/src/languages/html/brackets.scm new file mode 100644 index 0000000000..2d12b17daa --- /dev/null +++ b/crates/zed/src/languages/html/brackets.scm @@ -0,0 +1,2 @@ +("<" @open ">" @close) +("\"" @open "\"" @close) diff --git a/crates/zed/src/languages/html/config.toml b/crates/zed/src/languages/html/config.toml new file mode 100644 index 0000000000..3e618da25e --- /dev/null +++ b/crates/zed/src/languages/html/config.toml @@ -0,0 +1,12 @@ +name = "HTML" +path_suffixes = ["html"] +autoclose_before = ">})" +brackets = [ + { start = "<", end = ">", close = true, newline = true }, + { start = "{", end = "}", close = true, newline = true }, + { start = "(", end = ")", close = true, newline = true }, + { start = "\"", end = "\"", close = true, newline = false }, + { start = "!--", end = " --", close = true, newline = false }, +] + +block_comment = [""] \ No newline at end of file diff --git a/crates/zed/src/languages/html/highlights.scm b/crates/zed/src/languages/html/highlights.scm new file mode 100644 index 0000000000..0ce535fad4 --- /dev/null +++ b/crates/zed/src/languages/html/highlights.scm @@ -0,0 +1,15 @@ +(tag_name) @keyword +(erroneous_end_tag_name) @keyword +(doctype) @constant +(attribute_name) @property +(attribute_value) @string +(comment) @comment + +"=" @operator + +[ + "<" + ">" + "" +] @punctuation.bracket \ No newline at end of file diff --git a/crates/zed/src/languages/html/indents.scm b/crates/zed/src/languages/html/indents.scm new file mode 100644 index 0000000000..436663dba3 --- /dev/null +++ b/crates/zed/src/languages/html/indents.scm @@ -0,0 +1,6 @@ +(start_tag ">" @end) @indent +(self_closing_tag "/>" @end) @indent + +(element + (start_tag) @start + (end_tag)? @end) @indent diff --git a/crates/zed/src/languages/html/injections.scm b/crates/zed/src/languages/html/injections.scm new file mode 100644 index 0000000000..9084e373f2 --- /dev/null +++ b/crates/zed/src/languages/html/injections.scm @@ -0,0 +1,7 @@ +(script_element + (raw_text) @content + (#set! "language" "javascript")) + +(style_element + (raw_text) @content + (#set! "language" "css")) diff --git a/crates/zed/src/languages/html/outline.scm b/crates/zed/src/languages/html/outline.scm new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/zed/src/languages/javascript/highlights.scm b/crates/zed/src/languages/javascript/highlights.scm index d3921cdbc8..bd1986b6b3 100644 --- a/crates/zed/src/languages/javascript/highlights.scm +++ b/crates/zed/src/languages/javascript/highlights.scm @@ -51,12 +51,12 @@ (shorthand_property_identifier) (shorthand_property_identifier_pattern) ] @constant - (#match? @constant "^[A-Z_][A-Z\\d_]+$")) + (#match? @constant "^_*[A-Z_][A-Z\\d_]*$")) ; Literals -(this) @variable.builtin -(super) @variable.builtin +(this) @variable.special +(super) @variable.special [ (true) diff --git a/crates/zed/src/languages/python.rs b/crates/zed/src/languages/python.rs index 274fc3216c..e6e55eeac4 100644 --- a/crates/zed/src/languages/python.rs +++ b/crates/zed/src/languages/python.rs @@ -90,7 +90,7 @@ impl LspAdapter for PythonLspAdapter { async fn label_for_completion( &self, item: &lsp::CompletionItem, - language: &language::Language, + language: &Arc, ) -> Option { let label = &item.label; let grammar = language.grammar()?; @@ -112,7 +112,7 @@ impl LspAdapter for PythonLspAdapter { &self, name: &str, kind: lsp::SymbolKind, - language: &language::Language, + language: &Arc, ) -> Option { let (text, filter_range, display_range) = match kind { lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { @@ -149,7 +149,6 @@ mod tests { use gpui::{ModelContext, MutableAppContext}; use language::{AutoindentMode, Buffer}; use settings::Settings; - use std::sync::Arc; #[gpui::test] fn test_python_autoindent(cx: &mut MutableAppContext) { @@ -160,7 +159,7 @@ mod tests { cx.set_global(settings); cx.add_model(|cx| { - let mut buffer = Buffer::new(0, "", cx).with_language(Arc::new(language), cx); + let mut buffer = Buffer::new(0, "", cx).with_language(language, cx); let append = |buffer: &mut Buffer, text: &str, cx: &mut ModelContext| { let ix = buffer.len(); buffer.edit([(ix..ix, text)], Some(AutoindentMode::EachLine), cx); diff --git a/crates/zed/src/languages/python/highlights.scm b/crates/zed/src/languages/python/highlights.scm index 118af92aaa..71ab963d82 100644 --- a/crates/zed/src/languages/python/highlights.scm +++ b/crates/zed/src/languages/python/highlights.scm @@ -21,7 +21,7 @@ (#match? @type "^[A-Z]")) ((identifier) @constant - (#match? @constant "^[A-Z][A-Z_]*$")) + (#match? @constant "^_*[A-Z][A-Z\\d_]*$")) ; Builtin functions diff --git a/crates/zed/src/languages/rust.rs b/crates/zed/src/languages/rust.rs index adbe431279..f5776f3420 100644 --- a/crates/zed/src/languages/rust.rs +++ b/crates/zed/src/languages/rust.rs @@ -119,7 +119,7 @@ impl LspAdapter for RustLspAdapter { async fn label_for_completion( &self, completion: &lsp::CompletionItem, - language: &Language, + language: &Arc, ) -> Option { match completion.kind { Some(lsp::CompletionItemKind::FIELD) if completion.detail.is_some() => { @@ -196,7 +196,7 @@ impl LspAdapter for RustLspAdapter { &self, name: &str, kind: lsp::SymbolKind, - language: &Language, + language: &Arc, ) -> Option { let (text, filter_range, display_range) = match kind { lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { @@ -439,7 +439,7 @@ mod tests { cx.set_global(settings); cx.add_model(|cx| { - let mut buffer = Buffer::new(0, "", cx).with_language(Arc::new(language), cx); + let mut buffer = Buffer::new(0, "", cx).with_language(language, cx); // indent between braces buffer.set_text("fn a() {}", cx); diff --git a/crates/zed/src/languages/rust/highlights.scm b/crates/zed/src/languages/rust/highlights.scm index 72482b4073..98ea1ee40e 100644 --- a/crates/zed/src/languages/rust/highlights.scm +++ b/crates/zed/src/languages/rust/highlights.scm @@ -1,6 +1,6 @@ (type_identifier) @type (primitive_type) @type.builtin -(self) @variable.builtin +(self) @variable.special (field_identifier) @property (call_expression @@ -27,22 +27,13 @@ ; Identifier conventions -; Assume uppercase names are enum constructors -((identifier) @variant - (#match? @variant "^[A-Z]")) - -; Assume that uppercase names in paths are types -((scoped_identifier - path: (identifier) @type) - (#match? @type "^[A-Z]")) -((scoped_identifier - path: (scoped_identifier - name: (identifier) @type)) +; Assume uppercase names are types/enum-constructors +((identifier) @type (#match? @type "^[A-Z]")) ; Assume all-caps names are constants ((identifier) @constant - (#match? @constant "^[A-Z][A-Z\\d_]+$")) + (#match? @constant "^_*[A-Z][A-Z\\d_]*$")) [ "(" diff --git a/crates/zed/src/languages/typescript.rs b/crates/zed/src/languages/typescript.rs index 85a1bd6400..95f56bce5b 100644 --- a/crates/zed/src/languages/typescript.rs +++ b/crates/zed/src/languages/typescript.rs @@ -115,7 +115,7 @@ impl LspAdapter for TypeScriptLspAdapter { async fn label_for_completion( &self, item: &lsp::CompletionItem, - language: &language::Language, + language: &Arc, ) -> Option { use lsp::CompletionItemKind as Kind; let len = item.label.len(); @@ -144,7 +144,6 @@ impl LspAdapter for TypeScriptLspAdapter { #[cfg(test)] mod tests { - use std::sync::Arc; use gpui::MutableAppContext; use unindent::Unindent; @@ -172,9 +171,8 @@ mod tests { "# .unindent(); - let buffer = cx.add_model(|cx| { - language::Buffer::new(0, text, cx).with_language(Arc::new(language), cx) - }); + let buffer = + cx.add_model(|cx| language::Buffer::new(0, text, cx).with_language(language, cx)); let outline = buffer.read(cx).snapshot().outline(None).unwrap(); assert_eq!( outline diff --git a/crates/zed/src/languages/typescript/highlights.scm b/crates/zed/src/languages/typescript/highlights.scm index d3921cdbc8..bd1986b6b3 100644 --- a/crates/zed/src/languages/typescript/highlights.scm +++ b/crates/zed/src/languages/typescript/highlights.scm @@ -51,12 +51,12 @@ (shorthand_property_identifier) (shorthand_property_identifier_pattern) ] @constant - (#match? @constant "^[A-Z_][A-Z\\d_]+$")) + (#match? @constant "^_*[A-Z_][A-Z\\d_]*$")) ; Literals -(this) @variable.builtin -(super) @variable.builtin +(this) @variable.special +(super) @variable.special [ (true) diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 7e047429a3..8fd2d2451e 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -14,32 +14,32 @@ use client::{ http::{self, HttpClient}, UserStore, ZED_SECRET_CLIENT_TOKEN, }; -use fs::OpenOptions; use futures::{ channel::{mpsc, oneshot}, FutureExt, SinkExt, StreamExt, }; use gpui::{executor::Background, App, AssetSource, AsyncAppContext, Task, ViewContext}; -use isahc::{config::Configurable, AsyncBody, Request}; +use isahc::{config::Configurable, Request}; use language::LanguageRegistry; use log::LevelFilter; use parking_lot::Mutex; use project::{Fs, ProjectStore}; use serde_json::json; -use settings::{self, KeymapFileContent, Settings, SettingsFileContent, WorkingDirectory}; +use settings::{ + self, settings_file::SettingsFile, KeymapFileContent, Settings, SettingsFileContent, + WorkingDirectory, +}; use smol::process::Command; -use std::{env, ffi::OsStr, fs, panic, path::PathBuf, sync::Arc, thread, time::Duration}; +use std::fs::OpenOptions; +use std::{env, ffi::OsStr, panic, path::PathBuf, sync::Arc, thread, time::Duration}; use terminal::terminal_container_view::{get_working_directory, TerminalContainer}; +use fs::RealFs; +use settings::watched_json::{watch_keymap_file, watch_settings_file, WatchedJsonFile}; use theme::ThemeRegistry; use util::{ResultExt, TryFutureExt}; use workspace::{self, AppState, ItemHandle, NewFile, OpenPaths, Workspace}; -use zed::{ - self, build_window_options, - fs::RealFs, - initialize_workspace, languages, menus, - settings_file::{watch_keymap_file, watch_settings_file, WatchedJsonFile}, -}; +use zed::{self, build_window_options, initialize_workspace, languages, menus}; fn main() { let http = http::client(); @@ -88,7 +88,7 @@ fn main() { }); app.run(move |cx| { - let client = client::Client::new(http.clone()); + let client = client::Client::new(http.clone(), cx); let mut languages = LanguageRegistry::new(login_shell_env_loaded); languages.set_language_server_download_dir(zed::paths::LANGUAGES_DIR.clone()); let languages = Arc::new(languages); @@ -97,10 +97,15 @@ fn main() { .spawn(languages::init(languages.clone(), cx.background().clone())); let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http.clone(), cx)); - let (settings_file, keymap_file) = cx.background().block(config_files).unwrap(); + let (settings_file_content, keymap_file) = cx.background().block(config_files).unwrap(); //Setup settings global before binding actions - watch_settings_file(default_settings, settings_file, themes.clone(), cx); + cx.set_global(SettingsFile::new( + &*zed::paths::SETTINGS, + settings_file_content.clone(), + fs.clone(), + )); + watch_settings_file(default_settings, settings_file_content, themes.clone(), cx); watch_keymap_file(keymap_file, cx); context_menu::init(cx); @@ -111,7 +116,6 @@ fn main() { editor::init(cx); go_to_line::init(cx); file_finder::init(cx); - contacts_panel::init(cx); outline::init(cx); project_symbols::init(cx); project_panel::init(cx); @@ -121,7 +125,6 @@ fn main() { terminal::init(cx); theme_testbench::init(cx); - let db = cx.background().block(db); cx.spawn(|cx| watch_themes(fs.clone(), themes.clone(), cx)) .detach(); @@ -139,7 +142,11 @@ fn main() { }) .detach(); - let project_store = cx.add_model(|_| ProjectStore::new(db.clone())); + let project_store = cx.add_model(|_| ProjectStore::new()); + let db = cx.background().block(db); + client.start_telemetry(db.clone()); + client.report_event("start app", Default::default()); + let app_state = Arc::new(AppState { languages, themes, @@ -156,6 +163,7 @@ fn main() { journal::init(app_state.clone(), cx); theme_selector::init(app_state.clone(), cx); zed::init(&app_state, cx); + collab_ui::init(app_state.clone(), cx); cx.set_menus(menus::menus()); @@ -197,23 +205,23 @@ fn main() { } fn init_paths() { - fs::create_dir_all(&*zed::paths::CONFIG_DIR).expect("could not create config path"); - fs::create_dir_all(&*zed::paths::LANGUAGES_DIR).expect("could not create languages path"); - fs::create_dir_all(&*zed::paths::DB_DIR).expect("could not create database path"); - fs::create_dir_all(&*zed::paths::LOGS_DIR).expect("could not create logs path"); + std::fs::create_dir_all(&*zed::paths::CONFIG_DIR).expect("could not create config path"); + std::fs::create_dir_all(&*zed::paths::LANGUAGES_DIR).expect("could not create languages path"); + std::fs::create_dir_all(&*zed::paths::DB_DIR).expect("could not create database path"); + std::fs::create_dir_all(&*zed::paths::LOGS_DIR).expect("could not create logs path"); // Copy setting files from legacy locations. TODO: remove this after a few releases. thread::spawn(|| { - if fs::metadata(&*zed::paths::legacy::SETTINGS).is_ok() - && fs::metadata(&*zed::paths::SETTINGS).is_err() + if std::fs::metadata(&*zed::paths::legacy::SETTINGS).is_ok() + && std::fs::metadata(&*zed::paths::SETTINGS).is_err() { - fs::copy(&*zed::paths::legacy::SETTINGS, &*zed::paths::SETTINGS).log_err(); + std::fs::copy(&*zed::paths::legacy::SETTINGS, &*zed::paths::SETTINGS).log_err(); } - if fs::metadata(&*zed::paths::legacy::KEYMAP).is_ok() - && fs::metadata(&*zed::paths::KEYMAP).is_err() + if std::fs::metadata(&*zed::paths::legacy::KEYMAP).is_ok() + && std::fs::metadata(&*zed::paths::KEYMAP).is_err() { - fs::copy(&*zed::paths::legacy::KEYMAP, &*zed::paths::KEYMAP).log_err(); + std::fs::copy(&*zed::paths::legacy::KEYMAP, &*zed::paths::KEYMAP).log_err(); } }); } @@ -228,9 +236,10 @@ fn init_logger() { const KIB: u64 = 1024; const MIB: u64 = 1024 * KIB; const MAX_LOG_BYTES: u64 = MIB; - if fs::metadata(&*zed::paths::LOG).map_or(false, |metadata| metadata.len() > MAX_LOG_BYTES) + if std::fs::metadata(&*zed::paths::LOG) + .map_or(false, |metadata| metadata.len() > MAX_LOG_BYTES) { - let _ = fs::rename(&*zed::paths::LOG, &*zed::paths::OLD_LOG); + let _ = std::fs::rename(&*zed::paths::LOG, &*zed::paths::OLD_LOG); } let log_file = OpenOptions::new() @@ -280,15 +289,13 @@ fn init_panic_hook(app_version: String, http: Arc, background: A "token": ZED_SECRET_CLIENT_TOKEN, })) .unwrap(); - let request = Request::builder() - .uri(&panic_report_url) - .method(http::Method::POST) + let request = Request::post(&panic_report_url) .redirect_policy(isahc::config::RedirectPolicy::Follow) .header("Content-Type", "application/json") - .body(AsyncBody::from(body))?; + .body(body.into())?; let response = http.send(request).await.context("error sending panic")?; if response.status().is_success() { - fs::remove_file(child_path) + std::fs::remove_file(child_path) .context("error removing panic after sending it successfully") .log_err(); } else { @@ -337,7 +344,7 @@ fn init_panic_hook(app_version: String, http: Arc, background: A }; let panic_filename = chrono::Utc::now().format("%Y_%m_%d %H_%M_%S").to_string(); - fs::write( + std::fs::write( zed::paths::LOGS_DIR.join(format!("zed-{}-{}.panic", app_version, panic_filename)), &message, ) @@ -394,7 +401,7 @@ fn stdout_is_a_pty() -> bool { fn collect_path_args() -> Vec { env::args() .skip(1) - .filter_map(|arg| match fs::canonicalize(arg) { + .filter_map(|arg| match std::fs::canonicalize(arg) { Ok(path) => Some(path), Err(error) => { log::error!("error parsing path argument: {}", error); diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index 3a34166ba6..132bbc4477 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -244,10 +244,6 @@ pub fn menus() -> Vec> { name: "Project Panel", action: Box::new(project_panel::ToggleFocus), }, - MenuItem::Action { - name: "Contacts Panel", - action: Box::new(contacts_panel::ToggleFocus), - }, MenuItem::Action { name: "Command Palette", action: Box::new(command_palette::Toggle), @@ -332,6 +328,11 @@ pub fn menus() -> Vec> { action: Box::new(command_palette::Toggle), }, MenuItem::Separator, + MenuItem::Action { + name: "View Telemetry Log", + action: Box::new(crate::OpenTelemetryLog), + }, + MenuItem::Separator, MenuItem::Action { name: "Documentation", action: Box::new(crate::OpenBrowser { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index cd906500ee..ef0c84909a 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -2,7 +2,6 @@ mod feedback; pub mod languages; pub mod menus; pub mod paths; -pub mod settings_file; #[cfg(any(test, feature = "test-support"))] pub mod test; @@ -10,11 +9,11 @@ use anyhow::{anyhow, Context, Result}; use assets::Assets; use breadcrumbs::Breadcrumbs; pub use client; +use collab_ui::{CollabTitlebarItem, ToggleCollaborationMenu}; use collections::VecDeque; -pub use contacts_panel; -use contacts_panel::ContactsPanel; pub use editor; use editor::{Editor, MultiBuffer}; + use gpui::{ actions, geometry::vector::vec2f, @@ -24,7 +23,7 @@ use gpui::{ }; use language::Rope; pub use lsp; -pub use project::{self, fs}; +pub use project; use project_panel::ProjectPanel; use search::{BufferSearchBar, ProjectSearchBar}; use serde::Deserialize; @@ -56,6 +55,7 @@ actions!( DebugElements, OpenSettings, OpenLog, + OpenTelemetryLog, OpenKeymap, OpenDefaultSettings, OpenDefaultKeymap, @@ -94,6 +94,22 @@ pub fn init(app_state: &Arc, cx: &mut gpui::MutableAppContext) { cx.toggle_full_screen(); }, ); + cx.add_action( + |workspace: &mut Workspace, + _: &ToggleCollaborationMenu, + cx: &mut ViewContext| { + if let Some(item) = workspace + .titlebar_item() + .and_then(|item| item.downcast::()) + { + cx.as_mut().defer(move |cx| { + item.update(cx, |item, cx| { + item.toggle_contacts_popover(&Default::default(), cx); + }); + }); + } + }, + ); cx.add_global_action(quit); cx.add_global_action(move |action: &OpenBrowser, cx| cx.platform().open_url(&action.url)); cx.add_global_action(move |_: &IncreaseBufferFontSize, cx| { @@ -146,6 +162,12 @@ pub fn init(app_state: &Arc, cx: &mut gpui::MutableAppContext) { open_log_file(workspace, app_state.clone(), cx); } }); + cx.add_action({ + let app_state = app_state.clone(); + move |workspace: &mut Workspace, _: &OpenTelemetryLog, cx: &mut ViewContext| { + open_telemetry_log_file(workspace, app_state.clone(), cx); + } + }); cx.add_action({ let app_state = app_state.clone(); move |_: &mut Workspace, _: &OpenKeymap, cx: &mut ViewContext| { @@ -207,15 +229,9 @@ pub fn init(app_state: &Arc, cx: &mut gpui::MutableAppContext) { workspace.toggle_sidebar_item_focus(SidebarSide::Left, 0, cx); }, ); - cx.add_action( - |workspace: &mut Workspace, - _: &contacts_panel::ToggleFocus, - cx: &mut ViewContext| { - workspace.toggle_sidebar_item_focus(SidebarSide::Right, 0, cx); - }, - ); activity_indicator::init(cx); + call::init(app_state.client.clone(), app_state.user_store.clone(), cx); settings::KeymapFileContent::load_defaults(cx); } @@ -224,7 +240,8 @@ pub fn initialize_workspace( app_state: &Arc, cx: &mut ViewContext, ) { - cx.subscribe(&cx.handle(), { + let workspace_handle = cx.handle(); + cx.subscribe(&workspace_handle, { move |_, _, event, cx| { if let workspace::Event::PaneAdded(pane) = event { pane.update(cx, |pane, cx| { @@ -278,16 +295,11 @@ pub fn initialize_workspace( })); }); - let project_panel = ProjectPanel::new(workspace.project().clone(), cx); - let contact_panel = cx.add_view(|cx| { - ContactsPanel::new( - app_state.user_store.clone(), - app_state.project_store.clone(), - workspace.weak_handle(), - cx, - ) - }); + let collab_titlebar_item = + cx.add_view(|cx| CollabTitlebarItem::new(&workspace_handle, &app_state.user_store, cx)); + workspace.set_titlebar_item(collab_titlebar_item, cx); + let project_panel = ProjectPanel::new(workspace.project().clone(), cx); workspace.left_sidebar().update(cx, |sidebar, cx| { sidebar.add_item( "icons/folder_tree_16.svg", @@ -296,14 +308,6 @@ pub fn initialize_workspace( cx, ) }); - workspace.right_sidebar().update(cx, |sidebar, cx| { - sidebar.add_item( - "icons/user_group_16.svg", - "Contacts Panel".to_string(), - contact_panel, - cx, - ) - }); let diagnostic_summary = cx.add_view(|cx| diagnostics::items::DiagnosticIndicator::new(workspace.project(), cx)); @@ -356,7 +360,9 @@ fn quit(_: &Quit, cx: &mut gpui::MutableAppContext) { // If the user cancels any save prompt, then keep the app open. for workspace in workspaces { if !workspace - .update(&mut cx, |workspace, cx| workspace.prepare_to_close(cx)) + .update(&mut cx, |workspace, cx| { + workspace.prepare_to_close(true, cx) + }) .await? { return Ok(()); @@ -504,6 +510,62 @@ fn open_log_file( }); } +fn open_telemetry_log_file( + workspace: &mut Workspace, + app_state: Arc, + cx: &mut ViewContext, +) { + workspace.with_local_workspace(cx, app_state.clone(), |_, cx| { + cx.spawn_weak(|workspace, mut cx| async move { + let workspace = workspace.upgrade(&cx)?; + let path = app_state.client.telemetry_log_file_path()?; + let log = app_state.fs.load(&path).await.log_err()?; + + const MAX_TELEMETRY_LOG_LEN: usize = 5 * 1024 * 1024; + let mut start_offset = log.len().saturating_sub(MAX_TELEMETRY_LOG_LEN); + if let Some(newline_offset) = log[start_offset..].find('\n') { + start_offset += newline_offset + 1; + } + let log_suffix = &log[start_offset..]; + + workspace.update(&mut cx, |workspace, cx| { + let project = workspace.project().clone(); + let buffer = project + .update(cx, |project, cx| project.create_buffer("", None, cx)) + .expect("creating buffers on a local workspace always succeeds"); + buffer.update(cx, |buffer, cx| { + buffer.set_language(app_state.languages.get_language("JSON"), cx); + buffer.edit( + [( + 0..0, + concat!( + "// Zed collects anonymous usage data to help us understand how people are using the app.\n", + "// After the beta release, we'll provide the ability to opt out of this telemetry.\n", + "// Here is the data that has been reported for the current session:\n", + "\n" + ), + )], + None, + cx, + ); + buffer.edit([(buffer.len()..buffer.len(), log_suffix)], None, cx); + }); + + let buffer = cx.add_model(|cx| { + MultiBuffer::singleton(buffer, cx).with_title("Telemetry Log".into()) + }); + workspace.add_item( + Box::new(cx.add_view(|cx| Editor::for_multibuffer(buffer, Some(project), cx))), + cx, + ); + }); + + Some(()) + }) + .detach(); + }); +} + fn open_bundled_config_file( workspace: &mut Workspace, app_state: Arc, @@ -1070,7 +1132,7 @@ mod tests { assert!(!editor.is_dirty(cx)); assert_eq!(editor.title(cx), "untitled"); assert!(Arc::ptr_eq( - editor.language_at(0, cx).unwrap(), + &editor.language_at(0, cx).unwrap(), &languages::PLAIN_TEXT )); editor.handle_input("hi", cx); @@ -1157,7 +1219,7 @@ mod tests { editor.update(cx, |editor, cx| { assert!(Arc::ptr_eq( - editor.language_at(0, cx).unwrap(), + &editor.language_at(0, cx).unwrap(), &languages::PLAIN_TEXT )); editor.handle_input("hi", cx); @@ -1709,6 +1771,7 @@ mod tests { let state = Arc::get_mut(&mut app_state).unwrap(); state.initialize_workspace = initialize_workspace; state.build_window_options = build_window_options; + call::init(app_state.client.clone(), app_state.user_store.clone(), cx); workspace::init(app_state.clone(), cx); editor::init(cx); pane::init(cx); diff --git a/script/amplitude_release/main.py b/script/amplitude_release/main.py new file mode 100644 index 0000000000..160e40b66c --- /dev/null +++ b/script/amplitude_release/main.py @@ -0,0 +1,30 @@ +import datetime +import sys + +from amplitude_python_sdk.v2.clients.releases_client import ReleasesAPIClient +from amplitude_python_sdk.v2.models.releases import Release + + +def main(): + version = sys.argv[1] + version = version.removeprefix("v") + + api_key = sys.argv[2] + secret_key = sys.argv[3] + + current_datetime = datetime.datetime.now(datetime.timezone.utc) + current_datetime = current_datetime.strftime("%Y-%m-%d %H:%M:%S") + + release = Release( + title=version, + version=version, + release_start=current_datetime, + created_by="GitHub Release Workflow", + chart_visibility=True + ) + + ReleasesAPIClient(api_key=api_key, secret_key=secret_key).create(release) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/script/amplitude_release/requirements.txt b/script/amplitude_release/requirements.txt new file mode 100644 index 0000000000..7ed3ea6515 --- /dev/null +++ b/script/amplitude_release/requirements.txt @@ -0,0 +1 @@ +amplitude-python-sdk==0.2.0 \ No newline at end of file diff --git a/script/changes-since-last-release b/script/changes-since-last-release index 2be7a95e11..5d0f94db12 100755 --- a/script/changes-since-last-release +++ b/script/changes-since-last-release @@ -20,13 +20,17 @@ async function main() { // Print the previous release console.log(`Changes from ${oldTag} to ${newTag}\n`); - const hasProtocolChanges = - execFileSync("git", ["diff", oldTag, newTag, "--", "crates/rpc"]).status != 0; + let hasProtocolChanges = false; + try { + execFileSync("git", ["diff", oldTag, newTag, "--exit-code", "--", "crates/rpc"]).status != 0; + } catch (error) { + hasProtocolChanges = true; + } if (hasProtocolChanges) { - console.log("No RPC protocol changes\n"); + console.warn("\033[31;1;4mRPC protocol changes, server should be re-deployed\033[0m\n"); } else { - console.warn("RPC protocol changes\n"); + console.log("No RPC protocol changes\n"); } // Get the PRs merged between those two tags. diff --git a/styles/package.json b/styles/package.json index 38412aa1da..11bcbadf73 100644 --- a/styles/package.json +++ b/styles/package.json @@ -1,19 +1,18 @@ { - "name": "styles", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "build": "npm run build-themes && npm run build-tokens", - "build-themes": "ts-node ./src/buildThemes.ts" - }, - "author": "", - "license": "ISC", - "dependencies": { - "@types/chroma-js": "^2.1.3", - "@types/node": "^17.0.23", - "case-anything": "^2.1.10", - "chroma-js": "^2.4.2", - "ts-node": "^10.7.0" - } + "name": "styles", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "build": "ts-node ./src/buildThemes.ts" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@types/chroma-js": "^2.1.3", + "@types/node": "^17.0.23", + "case-anything": "^2.1.10", + "chroma-js": "^2.4.2", + "ts-node": "^10.7.0" + } } diff --git a/styles/src/styleTree/app.ts b/styles/src/styleTree/app.ts index 23d980ba4e..0a15cc032a 100644 --- a/styles/src/styleTree/app.ts +++ b/styles/src/styleTree/app.ts @@ -1,6 +1,5 @@ import { text } from "./components"; import contactFinder from "./contactFinder"; -import contactsPanel from "./contactsPanel"; import contactsPopover from "./contactsPopover"; import commandPalette from "./commandPalette"; import editor from "./editor"; @@ -12,8 +11,11 @@ import contextMenu from "./contextMenu"; import projectDiagnostics from "./projectDiagnostics"; import contactNotification from "./contactNotification"; import updateNotification from "./updateNotification"; +import projectSharedNotification from "./projectSharedNotification"; import tooltip from "./tooltip"; import terminal from "./terminal"; +import contactList from "./contactList"; +import incomingCallNotification from "./incomingCallNotification"; import { ColorScheme } from "../themes/common/colorScheme"; // export const panel = { @@ -26,16 +28,19 @@ export default function app(colorScheme: ColorScheme): Object { name: colorScheme.name, isLight: colorScheme.isLight, }, + commandPalette: commandPalette(colorScheme), + contactNotification: contactNotification(colorScheme), + projectSharedNotification: projectSharedNotification(colorScheme), + incomingCallNotification: incomingCallNotification(colorScheme), picker: picker(colorScheme), workspace: workspace(colorScheme), contextMenu: contextMenu(colorScheme), editor: editor(colorScheme), projectDiagnostics: projectDiagnostics(colorScheme), - commandPalette: commandPalette(colorScheme), projectPanel: projectPanel(colorScheme), contactsPopover: contactsPopover(colorScheme), - contactsPanel: contactsPanel(colorScheme), contactFinder: contactFinder(colorScheme), + contactList: contactList(colorScheme), search: search(colorScheme), breadcrumbs: { ...text(colorScheme.lowest.top, "sans", "variant"), @@ -43,7 +48,6 @@ export default function app(colorScheme: ColorScheme): Object { left: 6, }, }, - contactNotification: contactNotification(colorScheme), updateNotification: updateNotification(colorScheme), tooltip: tooltip(colorScheme), terminal: terminal(colorScheme.lowest), diff --git a/styles/src/styleTree/contactFinder.ts b/styles/src/styleTree/contactFinder.ts index 2dc7091883..4ed49ef26c 100644 --- a/styles/src/styleTree/contactFinder.ts +++ b/styles/src/styleTree/contactFinder.ts @@ -1,9 +1,11 @@ import picker from "./picker"; import { ColorScheme } from "../themes/common/colorScheme"; -import { background, foreground } from "./components"; +import { background, border, foreground, text } from "./components"; export default function contactFinder(colorScheme: ColorScheme) { let layer = colorScheme.highest.top; + + const sideMargin = 6; const contactButton = { background: background(layer, "variant"), color: foreground(layer, "variant"), @@ -13,7 +15,31 @@ export default function contactFinder(colorScheme: ColorScheme) { }; return { - ...picker(colorScheme), + picker: { + item: { + ...picker(colorScheme).item, + margin: { left: sideMargin, right: sideMargin } + }, + empty: picker(colorScheme).empty, + inputEditor: { + background: background(layer, "on"), + cornerRadius: 6, + text: text(layer, "mono",), + placeholderText: text(layer, "mono", "variant", { size: "sm" }), + selection: colorScheme.players[0], + border: border(layer), + padding: { + bottom: 4, + left: 8, + right: 8, + top: 4, + }, + margin: { + left: sideMargin, + right: sideMargin, + } + } + }, rowHeight: 28, contactAvatar: { cornerRadius: 10, diff --git a/styles/src/styleTree/contactsPanel.ts b/styles/src/styleTree/contactList.ts similarity index 74% rename from styles/src/styleTree/contactsPanel.ts rename to styles/src/styleTree/contactList.ts index dc9224e93b..42f6ce884e 100644 --- a/styles/src/styleTree/contactsPanel.ts +++ b/styles/src/styleTree/contactList.ts @@ -13,6 +13,13 @@ export default function contactsPanel(colorScheme: ColorScheme) { let layer = colorScheme.lowest.middle; + const contactButton = { + background: background(layer, "on"), + color: foreground(layer, "on"), + iconWidth: 8, + buttonWidth: 16, + cornerRadius: 8, + }; const projectRow = { guestAvatarSpacing: 4, height: 24, @@ -39,14 +46,6 @@ export default function contactsPanel(colorScheme: ColorScheme) { }, }; - const contactButton = { - background: background(layer, "on"), - color: foreground(layer, "on"), - iconWidth: 8, - buttonWidth: 16, - cornerRadius: 8, - }; - return { background: background(layer), padding: { top: 12, bottom: 0 }, @@ -64,23 +63,16 @@ export default function contactsPanel(colorScheme: ColorScheme) { top: 4, }, margin: { - left: sidePadding, - right: sidePadding, + left: 6 }, }, - userQueryEditorHeight: 32, + userQueryEditorHeight: 33, addContactButton: { margin: { left: 6, right: 12 }, color: foreground(layer, "on"), - buttonWidth: 16, + buttonWidth: 28, iconWidth: 16, }, - privateButton: { - iconWidth: 12, - color: foreground(layer, "on"), - cornerRadius: 5, - buttonWidth: 12, - }, rowHeight: 28, sectionIconSize: 8, headerRow: { @@ -95,6 +87,26 @@ export default function contactsPanel(colorScheme: ColorScheme) { background: background(layer, "active"), }, }, + leaveCall: { + background: background(layer), + border: border(layer), + cornerRadius: 6, + margin: { + top: 1, + }, + padding: { + top: 1, + bottom: 1, + left: 7, + right: 7, + }, + ...text(layer, "sans", "variant", { size: "xs" }), + hover: { + ...text(layer, "sans", "hovered", { size: "xs" }), + background: background(layer, "hovered"), + border: border(layer, "hovered"), + }, + }, contactRow: { padding: { left: sidePadding, @@ -104,20 +116,22 @@ export default function contactsPanel(colorScheme: ColorScheme) { background: background(layer, "active"), }, }, - treeBranch: { - color: borderColor(layer), - width: 1, - hover: { - color: borderColor(layer, "hovered"), - }, - active: { - color: borderColor(layer, "active"), - }, - }, contactAvatar: { cornerRadius: 10, width: 18, }, + contactStatusFree: { + cornerRadius: 4, + padding: 4, + margin: { top: 12, left: 12 }, + background: foreground(layer, "positive"), + }, + contactStatusBusy: { + cornerRadius: 4, + padding: 4, + margin: { top: 12, left: 12 }, + background: foreground(layer, "negative"), + }, contactUsername: { ...text(layer, "mono", { size: "sm" }), margin: { @@ -136,6 +150,19 @@ export default function contactsPanel(colorScheme: ColorScheme) { background: background(layer, "on"), color: foreground(layer, "on"), }, + callingIndicator: { + ...text(layer, "mono", "variant", { size: "xs" }) + }, + treeBranch: { + color: borderColor(layer), + width: 1, + hover: { + color: borderColor(layer), + }, + active: { + color: borderColor(layer), + }, + }, projectRow: { ...projectRow, background: background(layer, "on"), @@ -144,22 +171,11 @@ export default function contactsPanel(colorScheme: ColorScheme) { ...text(layer, "mono", { size: "sm" }), }, hover: { - background: background(layer, "hovered"), + background: background(layer, "on", "hovered"), }, active: { - background: background(layer, "active"), + background: background(layer, "on", "active"), }, }, - inviteRow: { - padding: { - left: sidePadding, - right: sidePadding, - }, - border: border(layer, { top: true }), - text: text(layer, "sans", { size: "sm" }), - hover: { - text: text(layer, "sans", "hovered", { size: "sm" }), - }, - }, - }; + } } diff --git a/styles/src/styleTree/contactsPopover.ts b/styles/src/styleTree/contactsPopover.ts index 1131a5be4a..be792a1981 100644 --- a/styles/src/styleTree/contactsPopover.ts +++ b/styles/src/styleTree/contactsPopover.ts @@ -1,8 +1,29 @@ import { ColorScheme } from "../themes/common/colorScheme"; -import { background } from "./components"; +import { background, border, text } from "./components"; -export default function workspace(colorScheme: ColorScheme) { +export default function contactsPopover(colorScheme: ColorScheme) { + let layer = colorScheme.middle.middle; + const sidePadding = 12; return { - background: background(colorScheme.lowest.middle), - }; + background: background(layer), + cornerRadius: 6, + padding: { top: 6 }, + margin: { top: -6 }, + shadow: colorScheme.middle.shadow, + border: border(layer), + width: 300, + height: 400, + inviteRowHeight: 28, + inviteRow: { + padding: { + left: sidePadding, + right: sidePadding, + }, + border: border(layer, { top: true }), + text: text(layer, "sans", "variant", { size: "sm" }), + hover: { + text: text(layer, "sans", "hovered", { size: "sm" }), + }, + }, + } } diff --git a/styles/src/styleTree/editor.ts b/styles/src/styleTree/editor.ts index 67e38cdc62..9cc7215e06 100644 --- a/styles/src/styleTree/editor.ts +++ b/styles/src/styleTree/editor.ts @@ -1,4 +1,5 @@ import { fontWeights } from "../common"; +import { withOpacity } from "../utils/color"; import { ColorScheme, Layer, @@ -143,8 +144,14 @@ export default function editor(colorScheme: ColorScheme) { indicator: foreground(layer, "variant"), verticalScale: 0.55, }, - diffBackgroundDeleted: background(layer, "negative"), - diffBackgroundInserted: background(layer, "positive"), + diff: { + deleted: foreground(layer, "negative"), + modified: foreground(layer, "warning"), + inserted: foreground(layer, "positive"), + removedWidthEm: 0.275, + widthEm: 0.16, + cornerRadius: 0.05, + }, documentHighlightReadBackground: elevation.ramps .neutral(0.5) .alpha(0.2) @@ -252,6 +259,20 @@ export default function editor(colorScheme: ColorScheme) { background: background(layer, "on", "hovered"), }, }, + scrollbar: { + width: 12, + minHeightFactor: 1.0, + track: { + border: border(layer, "variant", { left: true }), + }, + thumb: { + background: withOpacity(borderColor(layer, "variant"), 0.5), + border: { + width: 1, + color: withOpacity(borderColor(layer, 'variant'), 0.5), + } + } + }, compositionMark: { underline: { thickness: 1.0, diff --git a/styles/src/styleTree/incomingCallNotification.ts b/styles/src/styleTree/incomingCallNotification.ts new file mode 100644 index 0000000000..f0eb6c3bf5 --- /dev/null +++ b/styles/src/styleTree/incomingCallNotification.ts @@ -0,0 +1,45 @@ +import { ColorScheme } from "../themes/common/colorScheme"; +import { background, border, text } from "./components"; + +export default function incomingCallNotification(colorScheme: ColorScheme): Object { + let layer = colorScheme.middle.middle; + const avatarSize = 48; + return { + windowHeight: 74, + windowWidth: 380, + background: background(layer), + callerContainer: { + padding: 12, + }, + callerAvatar: { + height: avatarSize, + width: avatarSize, + cornerRadius: avatarSize / 2, + }, + callerMetadata: { + margin: { left: 10 }, + }, + callerUsername: { + ...text(layer, "sans", { size: "sm", weight: "bold" }), + margin: { top: -3 }, + }, + callerMessage: { + ...text(layer, "sans", "variant", { size: "xs" }), + margin: { top: -3 }, + }, + worktreeRoots: { + ...text(layer, "sans", "variant", { size: "xs", weight: "bold" }), + margin: { top: -3 }, + }, + buttonWidth: 96, + acceptButton: { + background: background(layer, "accent"), + border: border(layer, { left: true, bottom: true }), + ...text(layer, "sans", "positive", { size: "xs", weight: "extra_bold" }) + }, + declineButton: { + border: border(layer, { left: true }), + ...text(layer, "sans", "negative", { size: "xs", weight: "extra_bold" }) + }, + }; +} diff --git a/styles/src/styleTree/projectSharedNotification.ts b/styles/src/styleTree/projectSharedNotification.ts new file mode 100644 index 0000000000..4c40544c03 --- /dev/null +++ b/styles/src/styleTree/projectSharedNotification.ts @@ -0,0 +1,47 @@ +import { ColorScheme } from "../themes/common/colorScheme"; +import { background, border, text } from "./components"; + +export default function projectSharedNotification(colorScheme: ColorScheme): Object { + let elevation = colorScheme.middle; + let layer = elevation.middle; + + const avatarSize = 48; + return { + windowHeight: 74, + windowWidth: 380, + background: background(layer,), + ownerContainer: { + padding: 12, + }, + ownerAvatar: { + height: avatarSize, + width: avatarSize, + cornerRadius: avatarSize / 2, + }, + ownerMetadata: { + margin: { left: 10 }, + }, + ownerUsername: { + ...text(layer, "sans", { size: "sm", weight: "bold" }), + margin: { top: -3 }, + }, + message: { + ...text(layer, "sans", "variant", { size: "xs" }), + margin: { top: -3 }, + }, + worktreeRoots: { + ...text(layer, "sans", "variant", { size: "xs", weight: "bold" }), + margin: { top: -3 }, + }, + buttonWidth: 96, + openButton: { + background: background(layer, "accent"), + border: border(layer, { left: true, bottom: true, }), + ...text(layer, "sans", "accent", { size: "xs", weight: "extra_bold" }) + }, + dismissButton: { + border: border(layer, { left: true }), + ...text(layer, "sans", "variant", { size: "xs", weight: "extra_bold" }) + }, + }; +} diff --git a/styles/src/styleTree/workspace.ts b/styles/src/styleTree/workspace.ts index 7f6b39c4c2..9641e3f92f 100644 --- a/styles/src/styleTree/workspace.ts +++ b/styles/src/styleTree/workspace.ts @@ -14,6 +14,24 @@ export default function workspace(colorScheme: ColorScheme) { const elevation = colorScheme.lowest; const layer = elevation.bottom; const titlebarPadding = 6; + const titlebarButton = { + cornerRadius: 6, + padding: { + top: 1, + bottom: 1, + left: 8, + right: 8, + }, + ...text(layer, "sans", { size: "xs" }), + background: background(layer), + border: border(layer), + hover: { + ...text(layer, "sans", "hovered", { size: "xs" }), + background: background(layer, "hovered"), + border: border(elevation.top, "hovered"), + }, + }; + const avatarWidth = 18; return { background: background(layer), @@ -25,6 +43,14 @@ export default function workspace(colorScheme: ColorScheme) { padding: 12, ...text(layer, "sans", { size: "lg" }), }, + externalLocationMessage: { + background: background(elevation.middle, "accent"), + border: border(elevation.middle, "accent"), + cornerRadius: 6, + padding: 12, + margin: { bottom: 8, right: 8 }, + ...text(elevation.middle, "sans", "accent", { size: "xs" }), + }, leaderBorderOpacity: 0.7, leaderBorderWidth: 2.0, tabBar: tabBar(colorScheme), @@ -45,6 +71,8 @@ export default function workspace(colorScheme: ColorScheme) { }, statusBar: statusBar(colorScheme), titlebar: { + avatarWidth, + avatarMargin: 8, height: 33, // 32px + 1px for overlaid border background: background(layer), border: border(layer, { bottom: true, overlay: true }), @@ -57,15 +85,21 @@ export default function workspace(colorScheme: ColorScheme) { title: text(layer, "sans", "variant"), // Collaborators - avatarWidth: 18, - avatarMargin: 8, avatar: { - cornerRadius: 10, + cornerRadius: avatarWidth / 2, border: { color: "#00000088", width: 1, }, }, + inactiveAvatar: { + cornerRadius: avatarWidth / 2, + border: { + color: "#00000088", + width: 1, + }, + grayscale: true, + }, avatarRibbon: { height: 3, width: 12, @@ -75,20 +109,7 @@ export default function workspace(colorScheme: ColorScheme) { // Sign in buttom // FlatButton, Variant signInPrompt: { - ...text(layer, "sans", { size: "xs" }), - background: background(layer), - border: border(layer), - cornerRadius: 6, - padding: { - top: 1, - bottom: 1, - left: 8, - right: 8, - }, - hover: { - ...text(layer, "sans", "hovered", { size: "xs" }), - background: background(layer, "hovered"), - }, + ...titlebarButton }, // Offline Indicator @@ -117,6 +138,30 @@ export default function workspace(colorScheme: ColorScheme) { }, cornerRadius: 6, }, + toggleContactsButton: { + cornerRadius: 6, + color: foreground(layer), + iconWidth: 8, + buttonWidth: 20, + active: { + background: background(layer, "active"), + color: foreground(layer, "active"), + }, + hover: { + background: background(layer, "hovered"), + color: foreground(layer, "hovered"), + }, + }, + toggleContactsBadge: { + cornerRadius: 3, + padding: 2, + margin: { top: 3, left: 3 }, + border: border(layer), + background: foreground(layer, "accent"), + }, + shareButton: { + ...titlebarButton + } }, toolbar: { diff --git a/styles/src/themes/common/base16.ts b/styles/src/themes/common/base16.ts new file mode 100644 index 0000000000..1c4a5e4076 --- /dev/null +++ b/styles/src/themes/common/base16.ts @@ -0,0 +1,293 @@ +import chroma, { Color, Scale } from "chroma-js"; +import { fontWeights } from "../../common"; +import { withOpacity } from "../../utils/color"; +import Theme, { buildPlayer, Syntax } from "./theme"; + +export function colorRamp(color: Color): Scale { + let hue = color.hsl()[0]; + let endColor = chroma.hsl(hue, 0.88, 0.96); + let startColor = chroma.hsl(hue, 0.68, 0.12); + return chroma.scale([startColor, color, endColor]).mode("hsl"); +} + +export function createTheme( + name: string, + isLight: boolean, + color_ramps: { [rampName: string]: Scale } +): Theme { + let ramps: typeof color_ramps = {}; + // Chromajs mutates the underlying ramp when you call domain. This causes problems because + // we now store the ramps object in the theme so that we can pull colors out of them. + // So instead of calling domain and storing the result, we have to construct new ramps for each + // theme so that we don't modify the passed in ramps. + // This combined with an error in the type definitions for chroma js means we have to cast the colors + // function to any in order to get the colors back out from the original ramps. + if (isLight) { + for (var rampName in color_ramps) { + ramps[rampName] = chroma + .scale((color_ramps[rampName].colors as any)()) + .domain([1, 0]); + } + ramps.neutral = chroma + .scale((color_ramps.neutral.colors as any)()) + .domain([7, 0]); + } else { + for (var rampName in color_ramps) { + ramps[rampName] = chroma + .scale((color_ramps[rampName].colors as any)()) + .domain([0, 1]); + } + ramps.neutral = chroma + .scale((color_ramps.neutral.colors as any)()) + .domain([0, 7]); + } + + let blend = isLight ? 0.12 : 0.24; + + function sample(ramp: Scale, index: number): string { + return ramp(index).hex(); + } + const darkest = ramps.neutral(isLight ? 7 : 0).hex(); + + const backgroundColor = { + // Title bar + 100: { + base: sample(ramps.neutral, 1.25), + hovered: sample(ramps.neutral, 1.5), + active: sample(ramps.neutral, 1.75), + }, + // Midground (panels, etc) + 300: { + base: sample(ramps.neutral, 1), + hovered: sample(ramps.neutral, 1.25), + active: sample(ramps.neutral, 1.5), + }, + // Editor + 500: { + base: sample(ramps.neutral, 0), + hovered: sample(ramps.neutral, 0.25), + active: sample(ramps.neutral, 0.5), + }, + on300: { + base: sample(ramps.neutral, 0), + hovered: sample(ramps.neutral, 0.5), + active: sample(ramps.neutral, 1), + }, + on500: { + base: sample(ramps.neutral, 1), + hovered: sample(ramps.neutral, 1.5), + active: sample(ramps.neutral, 2), + }, + ok: { + base: withOpacity(sample(ramps.green, 0.5), 0.15), + hovered: withOpacity(sample(ramps.green, 0.5), 0.2), + active: withOpacity(sample(ramps.green, 0.5), 0.25), + }, + error: { + base: withOpacity(sample(ramps.red, 0.5), 0.15), + hovered: withOpacity(sample(ramps.red, 0.5), 0.2), + active: withOpacity(sample(ramps.red, 0.5), 0.25), + }, + on500Error: { + base: sample(ramps.red, 0.05), + hovered: sample(ramps.red, 0.1), + active: sample(ramps.red, 0.15), + }, + warning: { + base: withOpacity(sample(ramps.yellow, 0.5), 0.15), + hovered: withOpacity(sample(ramps.yellow, 0.5), 0.2), + active: withOpacity(sample(ramps.yellow, 0.5), 0.25), + }, + on500Warning: { + base: sample(ramps.yellow, 0.05), + hovered: sample(ramps.yellow, 0.1), + active: sample(ramps.yellow, 0.15), + }, + info: { + base: withOpacity(sample(ramps.blue, 0.5), 0.15), + hovered: withOpacity(sample(ramps.blue, 0.5), 0.2), + active: withOpacity(sample(ramps.blue, 0.5), 0.25), + }, + on500Info: { + base: sample(ramps.blue, 0.05), + hovered: sample(ramps.blue, 0.1), + active: sample(ramps.blue, 0.15), + }, + on500Ok: { + base: sample(ramps.green, 0.05), + hovered: sample(ramps.green, 0.1), + active: sample(ramps.green, 0.15) + } + }; + + const borderColor = { + primary: sample(ramps.neutral, isLight ? 1.5 : 0), + secondary: sample(ramps.neutral, isLight ? 1.25 : 1), + muted: sample(ramps.neutral, isLight ? 1.25 : 3), + active: sample(ramps.neutral, isLight ? 4 : 3), + onMedia: withOpacity(darkest, 0.1), + ok: sample(ramps.green, 0.3), + error: sample(ramps.red, 0.3), + warning: sample(ramps.yellow, 0.3), + info: sample(ramps.blue, 0.3), + }; + + const textColor = { + primary: sample(ramps.neutral, 6), + secondary: sample(ramps.neutral, 5), + muted: sample(ramps.neutral, 4), + placeholder: sample(ramps.neutral, 3), + active: sample(ramps.neutral, 7), + feature: sample(ramps.blue, 0.5), + ok: sample(ramps.green, 0.5), + error: sample(ramps.red, 0.5), + warning: sample(ramps.yellow, 0.5), + info: sample(ramps.blue, 0.5), + onMedia: darkest, + }; + + const player = { + 1: buildPlayer(sample(ramps.blue, 0.5)), + 2: buildPlayer(sample(ramps.green, 0.5)), + 3: buildPlayer(sample(ramps.magenta, 0.5)), + 4: buildPlayer(sample(ramps.orange, 0.5)), + 5: buildPlayer(sample(ramps.violet, 0.5)), + 6: buildPlayer(sample(ramps.cyan, 0.5)), + 7: buildPlayer(sample(ramps.red, 0.5)), + 8: buildPlayer(sample(ramps.yellow, 0.5)), + }; + + const editor = { + background: backgroundColor[500].base, + indent_guide: borderColor.muted, + indent_guide_active: borderColor.secondary, + line: { + active: sample(ramps.neutral, 1), + highlighted: sample(ramps.neutral, 1.25), // TODO: Where is this used? + }, + highlight: { + selection: player[1].selectionColor, + occurrence: withOpacity(sample(ramps.neutral, 3.5), blend), + activeOccurrence: withOpacity(sample(ramps.neutral, 3.5), blend * 2), // TODO: Not hooked up - https://github.com/zed-industries/zed/issues/751 + matchingBracket: backgroundColor[500].active, // TODO: Not hooked up + match: sample(ramps.violet, 0.15), + activeMatch: withOpacity(sample(ramps.violet, 0.4), blend * 2), // TODO: Not hooked up - https://github.com/zed-industries/zed/issues/751 + related: backgroundColor[500].hovered, + }, + gutter: { + primary: textColor.placeholder, + active: textColor.active, + }, + }; + + const syntax: Syntax = { + primary: { + color: sample(ramps.neutral, 7), + weight: fontWeights.normal, + }, + "variable.special": { + color: sample(ramps.blue, 0.80), + weight: fontWeights.normal, + }, + comment: { + color: sample(ramps.neutral, 5), + weight: fontWeights.normal, + }, + punctuation: { + color: sample(ramps.neutral, 6), + weight: fontWeights.normal, + }, + constant: { + color: sample(ramps.neutral, 4), + weight: fontWeights.normal, + }, + keyword: { + color: sample(ramps.blue, 0.5), + weight: fontWeights.normal, + }, + function: { + color: sample(ramps.yellow, 0.5), + weight: fontWeights.normal, + }, + type: { + color: sample(ramps.cyan, 0.5), + weight: fontWeights.normal, + }, + constructor: { + color: sample(ramps.cyan, 0.5), + weight: fontWeights.normal, + }, + property: { + color: sample(ramps.blue, 0.6), + weight: fontWeights.normal, + }, + enum: { + color: sample(ramps.orange, 0.5), + weight: fontWeights.normal, + }, + operator: { + color: sample(ramps.orange, 0.5), + weight: fontWeights.normal, + }, + string: { + color: sample(ramps.orange, 0.5), + weight: fontWeights.normal, + }, + number: { + color: sample(ramps.green, 0.5), + weight: fontWeights.normal, + }, + boolean: { + color: sample(ramps.green, 0.5), + weight: fontWeights.normal, + }, + predictive: { + color: textColor.muted, + weight: fontWeights.normal, + }, + title: { + color: sample(ramps.yellow, 0.5), + weight: fontWeights.bold, + }, + emphasis: { + color: textColor.feature, + weight: fontWeights.normal, + }, + "emphasis.strong": { + color: textColor.feature, + weight: fontWeights.bold, + }, + linkUri: { + color: sample(ramps.green, 0.5), + weight: fontWeights.normal, + underline: true, + }, + linkText: { + color: sample(ramps.orange, 0.5), + weight: fontWeights.normal, + italic: true, + }, + }; + + const shadow = withOpacity( + ramps + .neutral(isLight ? 7 : 0) + .darken() + .hex(), + blend + ); + + return { + name, + isLight, + backgroundColor, + borderColor, + textColor, + iconColor: textColor, + editor, + syntax, + player, + shadow, + ramps, + }; +} diff --git a/styles/src/themes/common/theme.ts b/styles/src/themes/common/theme.ts new file mode 100644 index 0000000000..a787443f31 --- /dev/null +++ b/styles/src/themes/common/theme.ts @@ -0,0 +1,165 @@ +import { Scale } from "chroma-js"; +import { FontWeight } from "../../common"; +import { withOpacity } from "../../utils/color"; + +export interface SyntaxHighlightStyle { + color: string; + weight?: FontWeight; + underline?: boolean; + italic?: boolean; +} + +export interface Player { + baseColor: string; + cursorColor: string; + selectionColor: string; + borderColor: string; +} +export function buildPlayer( + color: string, + cursorOpacity?: number, + selectionOpacity?: number, + borderOpacity?: number +) { + return { + baseColor: color, + cursorColor: withOpacity(color, cursorOpacity || 1.0), + selectionColor: withOpacity(color, selectionOpacity || 0.24), + borderColor: withOpacity(color, borderOpacity || 0.8), + }; +} + +export interface BackgroundColorSet { + base: string; + hovered: string; + active: string; +} + +export interface Syntax { + primary: SyntaxHighlightStyle; + comment: SyntaxHighlightStyle; + punctuation: SyntaxHighlightStyle; + constant: SyntaxHighlightStyle; + keyword: SyntaxHighlightStyle; + function: SyntaxHighlightStyle; + type: SyntaxHighlightStyle; + constructor: SyntaxHighlightStyle; + property: SyntaxHighlightStyle; + enum: SyntaxHighlightStyle; + operator: SyntaxHighlightStyle; + string: SyntaxHighlightStyle; + number: SyntaxHighlightStyle; + boolean: SyntaxHighlightStyle; + predictive: SyntaxHighlightStyle; + title: SyntaxHighlightStyle; + emphasis: SyntaxHighlightStyle; + linkUri: SyntaxHighlightStyle; + linkText: SyntaxHighlightStyle; + + [key: string]: SyntaxHighlightStyle; +} + +export default interface Theme { + name: string; + isLight: boolean; + backgroundColor: { + // Basically just Title Bar + // Lowest background level + 100: BackgroundColorSet; + // Tab bars, panels, popovers + // Mid-ground + 300: BackgroundColorSet; + // The editor + // Foreground + 500: BackgroundColorSet; + // Hacks for elements on top of the midground + // Buttons in a panel, tab bar, or panel + on300: BackgroundColorSet; + // Hacks for elements on top of the editor + on500: BackgroundColorSet; + ok: BackgroundColorSet; + on500Ok: BackgroundColorSet; + error: BackgroundColorSet; + on500Error: BackgroundColorSet; + warning: BackgroundColorSet; + on500Warning: BackgroundColorSet; + info: BackgroundColorSet; + on500Info: BackgroundColorSet; + }; + borderColor: { + primary: string; + secondary: string; + muted: string; + active: string; + /** + * Used for rendering borders on top of media like avatars, images, video, etc. + */ + onMedia: string; + ok: string; + error: string; + warning: string; + info: string; + }; + textColor: { + primary: string; + secondary: string; + muted: string; + placeholder: string; + active: string; + feature: string; + ok: string; + error: string; + warning: string; + info: string; + onMedia: string; + }; + iconColor: { + primary: string; + secondary: string; + muted: string; + placeholder: string; + active: string; + feature: string; + ok: string; + error: string; + warning: string; + info: string; + }; + editor: { + background: string; + indent_guide: string; + indent_guide_active: string; + line: { + active: string; + highlighted: string; + }; + highlight: { + selection: string; + occurrence: string; + activeOccurrence: string; + matchingBracket: string; + match: string; + activeMatch: string; + related: string; + }; + gutter: { + primary: string; + active: string; + }; + }; + + syntax: Syntax; + + player: { + 1: Player; + 2: Player; + 3: Player; + 4: Player; + 5: Player; + 6: Player; + 7: Player; + 8: Player; + }; + shadow: string; + ramps: { [rampName: string]: Scale }; +}