Detailed changes
@@ -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: |
@@ -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 }}
@@ -8,4 +8,5 @@
/vendor/bin
/assets/themes/*.json
/assets/themes/internal/*.json
-/assets/themes/experiments/*.json
+/assets/themes/experiments/*.json
+**/venv
@@ -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,20 +183,33 @@ 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",
"futures-core",
]
+[[package]]
+name = "async-compat"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b48b4ff0c2026db683dea961cd8ea874737f56cffca86fa84415eaddc51c00d"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "once_cell",
+ "pin-project-lite 0.2.9",
+ "tokio",
+]
+
[[package]]
name = "async-compression"
-version = "0.3.14"
+version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "345fd392ab01f746c717b1357165b76f0b67a60192007b234058c9045fdcf695"
+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",
]
@@ -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
+
@@ -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 . .
@@ -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 \
@@ -1,4 +0,0 @@
-<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M5 11C5 14.3137 7.68629 17 11 17C14.3137 17 17 14.3137 17 11C17 7.68629 14.3137 5 11 5C7.68629 5 5 7.68629 5 11ZM11 3C6.58172 3 3 6.58172 3 11C3 15.4183 6.58172 19 11 19C15.4183 19 19 15.4183 19 11C19 6.58172 15.4183 3 11 3Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M8.09092 8.09088H14.6364L10.5511 12.4545H12.4546L13.9091 13.9091H7.36365L11.7273 9.54543H9.54547L8.09092 8.09088Z" fill="white"/>
-</svg>
@@ -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"
+ ]
}
}
]
@@ -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",
@@ -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": {
@@ -46,6 +46,7 @@ impl ActivityIndicator {
cx: &mut ViewContext<Workspace>,
) -> ViewHandle<ActivityIndicator> {
let project = workspace.project().clone();
+ let auto_updater = AutoUpdater::get(cx);
let this = cx.add_view(|cx: &mut ViewContext<Self>| {
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
@@ -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"] }
@@ -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<Client>, user_store: ModelHandle<UserStore>, 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<User>,
+ pub participants: Vec<Arc<User>>,
+ pub initial_project: Option<proto::ParticipantProject>,
+}
+
+pub struct ActiveCall {
+ room: Option<(ModelHandle<Room>, Vec<Subscription>)>,
+ incoming_call: (
+ watch::Sender<Option<IncomingCall>>,
+ watch::Receiver<Option<IncomingCall>>,
+ ),
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ _subscriptions: Vec<client::Subscription>,
+}
+
+impl Entity for ActiveCall {
+ type Event = room::Event;
+}
+
+impl ActiveCall {
+ fn new(
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut ModelContext<Self>,
+ ) -> 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<Self>,
+ envelope: TypedEnvelope<proto::IncomingCall>,
+ _: Arc<Client>,
+ mut cx: AsyncAppContext,
+ ) -> Result<proto::Ack> {
+ 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<Self>,
+ _: TypedEnvelope<proto::CallCanceled>,
+ _: Arc<Client>,
+ mut cx: AsyncAppContext,
+ ) -> Result<()> {
+ this.update(&mut cx, |this, _| {
+ *this.incoming_call.0.borrow_mut() = None;
+ });
+ Ok(())
+ }
+
+ pub fn global(cx: &AppContext) -> ModelHandle<Self> {
+ cx.global::<ModelHandle<Self>>().clone()
+ }
+
+ pub fn invite(
+ &mut self,
+ recipient_user_id: u64,
+ initial_project: Option<ModelHandle<Project>>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ 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<Self>,
+ ) -> Task<Result<()>> {
+ 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<Option<IncomingCall>> {
+ self.incoming_call.1.clone()
+ }
+
+ pub fn accept_incoming(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+ 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<Self>) -> 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<Project>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<u64>> {
+ 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<Project>>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ 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<ModelHandle<Room>>, cx: &mut ModelContext<Self>) {
+ 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<Room>> {
+ self.room.as_ref().map(|(room, _)| room)
+ }
+}
@@ -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<proto::ParticipantLocation>) -> Result<Self> {
+ 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<proto::ParticipantProject>,
+ pub active_project: Option<WeakModelHandle<Project>>,
+}
+
+#[derive(Clone, Debug)]
+pub struct RemoteParticipant {
+ pub user: Arc<User>,
+ pub projects: Vec<proto::ParticipantProject>,
+ pub location: ParticipantLocation,
+}
@@ -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<User>,
+ project_id: u64,
+ worktree_root_names: Vec<String>,
+ },
+ RemoteProjectUnshared {
+ project_id: u64,
+ },
+ Left,
+}
+
+pub struct Room {
+ id: u64,
+ status: RoomStatus,
+ local_participant: LocalParticipant,
+ remote_participants: BTreeMap<PeerId, RemoteParticipant>,
+ pending_participants: Vec<Arc<User>>,
+ participant_user_ids: HashSet<u64>,
+ pending_call_count: usize,
+ leave_when_empty: bool,
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ subscriptions: Vec<client::Subscription>,
+ pending_room_update: Option<Task<()>>,
+}
+
+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<Client>,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut ModelContext<Self>,
+ ) -> 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<ModelHandle<Project>>,
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut MutableAppContext,
+ ) -> Task<Result<ModelHandle<Self>>> {
+ 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<Client>,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut MutableAppContext,
+ ) -> Task<Result<ModelHandle<Self>>> {
+ 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<Self>) -> 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<PeerId, RemoteParticipant> {
+ &self.remote_participants
+ }
+
+ pub fn pending_participants(&self) -> &[Arc<User>] {
+ &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<Self>,
+ envelope: TypedEnvelope<proto::RoomUpdated>,
+ _: Arc<Client>,
+ 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<Self>,
+ ) -> 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::<Vec<_>>();
+ 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::<HashSet<_>>();
+ let new_projects = participant
+ .projects
+ .iter()
+ .map(|project| project.id)
+ .collect::<HashSet<_>>();
+
+ 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<u64>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ 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<Project>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<u64>> {
+ 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<Project>>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ 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)
+ }
+}
@@ -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"] }
@@ -530,7 +530,7 @@ impl ChannelMessage {
) -> Result<Self> {
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);
@@ -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<Client>, 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<Client>, 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<Peer>,
http: Arc<dyn HttpClient>,
+ telemetry: Arc<Telemetry>,
state: RwLock<ClientState>,
#[allow(clippy::type_complexity)]
@@ -232,10 +245,11 @@ impl Drop for Subscription {
}
impl Client {
- pub fn new(http: Arc<dyn HttpClient>) -> Arc<Self> {
+ pub fn new(http: Arc<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
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<M, E, H, F>(
+ self: &Arc<Self>,
+ model: ModelHandle<E>,
+ handler: H,
+ ) -> Subscription
+ where
+ M: RequestMessage,
+ E: Entity,
+ H: 'static
+ + Send
+ + Sync
+ + Fn(ModelHandle<E>, TypedEnvelope<M>, Arc<Self>, AsyncAppContext) -> F,
+ F: 'static + Future<Output = Result<M::Response>>,
+ {
+ 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<M, E, H, F>(self: &Arc<Self>, 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<Self>, conn: Connection, cx: &AsyncAppContext) {
+ fn set_connection(self: &Arc<Self>, 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<Result<Credentials>> {
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<Db>) {
+ 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<PathBuf> {
+ 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<Deterministic>, 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());
@@ -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<dyn HttpClient>,
+ executor: Arc<Background>,
+ session_id: u128,
+ state: Mutex<TelemetryState>,
+}
+
+#[derive(Default)]
+struct TelemetryState {
+ metrics_id: Option<Arc<str>>,
+ device_id: Option<Arc<str>>,
+ app_version: Option<Arc<str>>,
+ os_version: Option<Arc<str>>,
+ os_name: &'static str,
+ queue: Vec<AmplitudeEvent>,
+ next_event_id: usize,
+ flush_task: Option<Task<()>>,
+ log_file: Option<NamedTempFile>,
+}
+
+const AMPLITUDE_EVENTS_URL: &'static str = "https://api2.amplitude.com/batch";
+
+lazy_static! {
+ static ref AMPLITUDE_API_KEY: Option<String> = 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<AmplitudeEvent>,
+}
+
+#[derive(Serialize)]
+struct AmplitudeEvent {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ user_id: Option<Arc<str>>,
+ device_id: Option<Arc<str>>,
+ event_type: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ event_properties: Option<Map<String, Value>>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ user_properties: Option<Map<String, Value>>,
+ os_name: &'static str,
+ os_version: Option<Arc<str>>,
+ app_version: Option<Arc<str>>,
+ 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<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
+ 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<PathBuf> {
+ Some(self.state.lock().log_file.as_ref()?.path().to_path_buf())
+ }
+
+ pub fn start(self: &Arc<Self>, db: Arc<Db>) {
+ 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<Self>,
+ metrics_id: Option<String>,
+ 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<Self>, kind: &str, properties: Value) {
+ self.report_event_with_user_properties(kind, properties, Default::default());
+ }
+
+ fn report_event_with_user_properties(
+ self: &Arc<Self>,
+ 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<Self>) {
+ 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();
+ }
+ }
+}
@@ -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<M: proto::EnvelopedMessage>(&self) -> Result<TypedEnvelope<M>> {
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::<TypedEnvelope<M>>()
- .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::<TypedEnvelope<M>>() {
+ return Ok(*message.downcast().unwrap());
+ }
+
+ if message.is::<TypedEnvelope<GetPrivateUserInfo>>() {
+ self.respond(
+ message
+ .downcast::<TypedEnvelope<GetPrivateUserInfo>>()
+ .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<T: proto::RequestMessage>(
@@ -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<User>,
pub online: bool,
- pub projects: Vec<ProjectMetadata>,
-}
-
-#[derive(Clone, Debug, PartialEq)]
-pub struct ProjectMetadata {
- pub id: u64,
- pub visible_worktree_root_names: Vec<String>,
- pub guests: BTreeSet<Arc<User>>,
+ 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<u64>,
+ user_ids: Vec<u64>,
cx: &mut ModelContext<Self>,
- ) -> Task<Result<()>> {
- 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<Result<Vec<Arc<User>>>> {
+ 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<Self>,
@@ -612,39 +631,15 @@ impl Contact {
) -> Result<Self> {
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<Item = &ProjectMetadata> {
- self.projects
- .iter()
- .filter(|project| !project.visible_worktree_root_names.is_empty())
- }
}
async fn fetch_avatar(http: &dyn HttpClient, url: &str) -> Result<Arc<ImageData>> {
@@ -1,5 +1,5 @@
[package]
-authors = ["Nathan Sobo <nathan@warp.dev>"]
+authors = ["Nathan Sobo <nathan@zed.dev>"]
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"]
@@ -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");
@@ -0,0 +1,2 @@
+ALTER TABLE "users"
+ ADD "metrics_id" uuid NOT NULL DEFAULT gen_random_uuid();
@@ -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<rpc::Server>, state: Arc<AppState>) -> Router<Body> {
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<rpc::Server>, state: Arc<AppState>) -> Router<Bod
)
.route("/user_activity/counts", get(get_active_user_counts))
.route("/project_metadata", get(get_project_metadata))
+ .route("/signups", post(create_signup))
+ .route("/signups_summary", get(get_waitlist_summary))
+ .route("/user_invites", post(create_invite_from_code))
+ .route("/unsent_invites", get(get_unsent_invites))
+ .route("/sent_invites", post(record_sent_invites))
.layer(
ServiceBuilder::new()
.layer(Extension(state))
@@ -84,6 +86,31 @@ pub async fn validate_api_token<B>(req: Request<B>, next: Next<B>) -> 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<AuthenticatedUserParams>,
+ Extension(app): Extension<Arc<AppState>>,
+) -> Result<Json<AuthenticatedUserResponse>> {
+ 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<String>,
@@ -108,48 +135,76 @@ async fn get_users(
#[derive(Deserialize, Debug)]
struct CreateUserParams {
+ github_user_id: i32,
github_login: String,
- invite_code: Option<String>,
- email_address: Option<String>,
+ email_address: String,
+ email_confirmation_code: Option<String>,
+ #[serde(default)]
admin: bool,
+ #[serde(default)]
+ invite_count: i32,
+}
+
+#[derive(Serialize, Debug)]
+struct CreateUserResponse {
+ user: User,
+ signup_device_id: Option<String>,
+ metrics_id: String,
}
async fn create_user(
Json(params): Json<CreateUserParams>,
Extension(app): Extension<Arc<AppState>>,
Extension(rpc_server): Extension<Arc<rpc::Server>>,
-) -> Result<Json<User>> {
- 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<Json<CreateUserResponse>> {
+ 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<String>,
- Extension(app): Extension<Arc<AppState>>,
-) -> Result<Json<User>> {
- 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<CreateUsersEntry>,
-}
-
-#[derive(Deserialize)]
-struct CreateUsersEntry {
- github_login: String,
- email_address: String,
- invite_count: usize,
-}
-
-async fn create_users(
- Json(params): Json<CreateUsersParams>,
- Extension(app): Extension<Arc<AppState>>,
-) -> Result<Json<Vec<User>>> {
- 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<String>,
+ Path(user_id): Path<UserId>,
Query(params): Query<CreateAccessTokenQueryParams>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<CreateAccessTokenResponse>> {
- // 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<Json<User>> {
Ok(Json(app.db.get_user_for_invite_code(&code).await?))
}
+
+async fn create_signup(
+ Json(params): Json<Signup>,
+ Extension(app): Extension<Arc<AppState>>,
+) -> Result<()> {
+ app.db.create_signup(params).await?;
+ Ok(())
+}
+
+async fn get_waitlist_summary(
+ Extension(app): Extension<Arc<AppState>>,
+) -> Result<Json<WaitlistSummary>> {
+ Ok(Json(app.db.get_waitlist_summary().await?))
+}
+
+#[derive(Deserialize)]
+pub struct CreateInviteFromCodeParams {
+ invite_code: String,
+ email_address: String,
+ device_id: Option<String>,
+}
+
+async fn create_invite_from_code(
+ Json(params): Json<CreateInviteFromCodeParams>,
+ Extension(app): Extension<Arc<AppState>>,
+) -> Result<Json<Invite>> {
+ 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<GetUnsentInvitesParams>,
+ Extension(app): Extension<Arc<AppState>>,
+) -> Result<Json<Vec<Invite>>> {
+ Ok(Json(app.db.get_unsent_invites(params.count).await?))
+}
+
+async fn record_sent_invites(
+ Json(params): Json<Vec<Invite>>,
+ Extension(app): Extension<Arc<AppState>>,
+) -> Result<()> {
+ app.db.record_sent_invites(¶ms).await?;
+ Ok(())
+}
@@ -11,7 +11,7 @@ mod db;
#[derive(Debug, Deserialize)]
struct GitHubUser {
- id: usize,
+ id: i32,
login: String,
email: Option<String>,
}
@@ -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::<GitHubUser>(&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::<Vec<GitHubUser>>(
&client,
&github_token,
@@ -64,16 +67,40 @@ async fn main() {
let mut zed_user_ids = Vec::<UserId>::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,
);
}
}
@@ -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<UserId>;
+ params: NewUserParams,
+ ) -> Result<NewUserResult>;
async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<User>>;
- async fn create_users(&self, users: Vec<(String, String, usize)>) -> Result<Vec<UserId>>;
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>>;
async fn get_user_by_id(&self, id: UserId) -> Result<Option<User>>;
+ async fn get_user_metrics_id(&self, id: UserId) -> Result<String>;
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>>;
async fn get_users_with_no_invites(&self, invited_by_another_user: bool) -> Result<Vec<User>>;
- async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>>;
+ async fn get_user_by_github_account(
+ &self,
+ github_login: &str,
+ github_user_id: Option<i32>,
+ ) -> Result<Option<User>>;
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<Option<(String, u32)>>;
async fn get_user_for_invite_code(&self, code: &str) -> Result<User>;
- async fn redeem_invite_code(
+ async fn create_invite_from_code(
&self,
code: &str,
- login: &str,
- email_address: Option<&str>,
- ) -> Result<UserId>;
+ email_address: &str,
+ device_id: Option<&str>,
+ ) -> Result<Invite>;
+
+ async fn create_signup(&self, signup: Signup) -> Result<()>;
+ async fn get_waitlist_summary(&self) -> Result<WaitlistSummary>;
+ async fn get_unsent_invites(&self, count: usize) -> Result<Vec<Invite>>;
+ async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()>;
+ async fn create_user_from_invite(
+ &self,
+ invite: &Invite,
+ user: NewUserParams,
+ ) -> Result<NewUserResult>;
/// Registers a new project for the given user.
async fn register_project(&self, host_user_id: UserId) -> Result<ProjectId>;
@@ -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<Vec<String>>;
- #[cfg(any(test, feature = "seed-support"))]
+ #[cfg(any(test, feature = "seed-support"))]
async fn find_org_by_slug(&self, slug: &str) -> Result<Option<Org>>;
#[cfg(any(test, feature = "seed-support"))]
async fn create_org(&self, name: &str, slug: &str) -> Result<OrgId>;
@@ -130,6 +143,7 @@ pub trait Db: Send + Sync {
async fn get_accessible_channels(&self, user_id: UserId) -> Result<Vec<Channel>>;
async fn can_user_access_channel(&self, user_id: UserId, channel_id: ChannelId)
-> Result<bool>;
+
#[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<MessageId>,
) -> Result<Vec<ChannelMessage>>;
+
#[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<UserId> {
+ params: NewUserParams,
+ ) -> Result<NewUserResult> {
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<Vec<User>> {
@@ -206,43 +240,8 @@ impl Db for PostgresDb {
.await?)
}
- async fn create_users(&self, users: Vec<(String, String, usize)>) -> Result<Vec<UserId>> {
- 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::<UserId, _>(0).ok())
- .collect())
- }
-
async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result<Vec<User>> {
- 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<String> {
+ 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<UserId>) -> Result<Vec<User>> {
let ids = ids.into_iter().map(|id| id.0).collect::<Vec<_>>();
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<Option<User>> {
- 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<i32>,
+ ) -> Result<Option<User>> {
+ 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<WaitlistSummary> {
+ 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<Vec<Invite>> {
+ 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::<Vec<_>>(),
+ )
+ .execute(&self.pool)
+ .await?;
+ Ok(())
+ }
+
+ async fn create_user_from_invite(
+ &self,
+ invite: &Invite,
+ user: NewUserParams,
+ ) -> Result<NewUserResult> {
+ let mut tx = self.pool.begin().await?;
+
+ let (signup_id, existing_user_id, inviting_user_id, signup_device_id): (
+ i32,
+ Option<UserId>,
+ Option<UserId>,
+ Option<String>,
+ ) = 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<UserId> = 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<UserId> {
+ email_address: &str,
+ device_id: Option<&str>,
+ ) -> Result<Invite> {
let mut tx = self.pool.begin().await?;
- let inviter_id: Option<UserId> = sqlx::query_scalar(
+ let existing_user: Option<UserId> = 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(code)
+ .bind(email_address)
.fetch_optional(&mut tx)
.await?;
+ if existing_user.is_some() {
+ Err(anyhow!("email address is already in use"))?;
+ }
- 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 invitee_id = sqlx::query_scalar(
+ let row: Option<(UserId, i32)> = sqlx::query_as(
"
- INSERT INTO users
- (github_login, email_address, admin, inviter_id, invite_code, invite_count)
- VALUES
- ($1, $2, 'f', $3, $4, $5)
- RETURNING id
+ SELECT id, invite_count
+ FROM users
+ WHERE invite_code = $1
",
)
- .bind(login)
- .bind(email_address)
- .bind(inviter_id)
- .bind(random_invite_code())
- .bind(5)
- .fetch_one(&mut tx)
- .await
- .map(UserId)?;
+ .bind(code)
+ .fetch_optional(&mut tx)
+ .await?;
- sqlx::query(
+ let (inviter_id, invite_count) = match row {
+ Some(row) => row,
+ None => Err(Error::Http(
+ StatusCode::NOT_FOUND,
+ "invite code not found".to_string(),
+ ))?,
+ };
+
+ if invite_count == 0 {
+ Err(Error::Http(
+ StatusCode::UNAUTHORIZED,
+ "no invites remaining".to_string(),
+ ))?;
+ }
+
+ 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<i32>,
pub email_address: Option<String>,
pub admin: bool,
pub invite_code: Option<String>,
@@ -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<String, usize>,
+ pub end: OffsetDateTime,
+ pub extensions: HashMap<String, usize>,
}
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<String>,
+ pub programming_languages: Vec<String>,
+ pub device_id: Option<String>,
+}
+
+#[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<UserId>,
+ pub signup_device_id: Option<String>,
}
fn random_invite_code() -> String {
nanoid::nanoid!(16)
}
+fn random_email_confirmation_code() -> String {
+ nanoid::nanoid!(64)
+}
+
+#[cfg(test)]
+pub use test::*;
+
#[cfg(test)]
-pub mod tests {
+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,994 +1773,22 @@ 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::<HashMap<_, _>>()
- ),
- (
- 101,
- [("ts".into(), 2), ("md".into(), 1),]
- .into_iter()
- .collect::<HashMap<_, _>>()
- )
- ]
- .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::<Vec<_>>(),
- ["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::<Vec<_>>(),
- ["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<dyn Db>, query: &str) -> Vec<String> {
- db.fuzzy_search_users(query, 10)
- .await
- .unwrap()
- .into_iter()
- .map(|user| user.github_login)
- .collect::<Vec<_>>()
- }
- }
-
- #[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<Arc<dyn Db>>,
- 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::<u128>());
- 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<Background>) -> Self {
- Self {
- db: Some(Arc::new(FakeDb::new(background))),
- url: Default::default(),
- }
- }
-
- pub fn db(&self) -> &Arc<dyn Db> {
- 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<Background>,
- pub users: Mutex<BTreeMap<UserId, User>>,
- pub projects: Mutex<BTreeMap<ProjectId, Project>>,
- pub worktree_extensions: Mutex<BTreeMap<(ProjectId, u64, String), u32>>,
- pub orgs: Mutex<BTreeMap<OrgId, Org>>,
- pub org_memberships: Mutex<BTreeMap<(OrgId, UserId), bool>>,
- pub channels: Mutex<BTreeMap<ChannelId, Channel>>,
- pub channel_memberships: Mutex<BTreeMap<(ChannelId, UserId), bool>>,
- pub channel_messages: Mutex<BTreeMap<MessageId, ChannelMessage>>,
- pub contacts: Mutex<Vec<FakeContact>>,
- next_channel_message_id: Mutex<i32>,
- next_user_id: Mutex<i32>,
- next_org_id: Mutex<i32>,
- next_channel_id: Mutex<i32>,
- next_project_id: Mutex<i32>,
+ pub struct FakeDb {
+ background: Arc<Background>,
+ pub users: Mutex<BTreeMap<UserId, User>>,
+ pub projects: Mutex<BTreeMap<ProjectId, Project>>,
+ pub worktree_extensions: Mutex<BTreeMap<(ProjectId, u64, String), u32>>,
+ pub orgs: Mutex<BTreeMap<OrgId, Org>>,
+ pub org_memberships: Mutex<BTreeMap<(OrgId, UserId), bool>>,
+ pub channels: Mutex<BTreeMap<ChannelId, Channel>>,
+ pub channel_memberships: Mutex<BTreeMap<(ChannelId, UserId), bool>>,
+ pub channel_messages: Mutex<BTreeMap<MessageId, ChannelMessage>>,
+ pub contacts: Mutex<Vec<FakeContact>>,
+ next_channel_message_id: Mutex<i32>,
+ next_user_id: Mutex<i32>,
+ next_org_id: Mutex<i32>,
+ next_channel_id: Mutex<i32>,
+ next_project_id: Mutex<i32>,
}
#[derive(Debug)]
@@ -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::<HashMap<_, _>>()
+ ),
+ (
+ 101,
+ [("ts".into(), 2), ("md".into(), 1),]
+ .into_iter()
+ .collect::<HashMap<_, _>>()
+ )
+ ]
+ .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::<Vec<_>>(),
+ ["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::<Vec<_>>(),
+ ["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<dyn Db>, query: &str) -> Vec<String> {
+ db.fuzzy_search_users(query, 10)
+ .await
+ .unwrap()
+ .into_iter()
+ .map(|user| user.github_login)
+ .collect::<Vec<_>>()
+ }
+}
+
+#[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::<Vec<_>>();
+ 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::<Vec<_>>();
+ 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<Background> {
+ Deterministic::new(0).build_background()
+}
@@ -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,20 +62,490 @@ fn init_logger() {
}
#[gpui::test(iterations = 10)]
-async fn test_share_project(
+async fn test_basic_calls(
deterministic: Arc<Deterministic>,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
cx_b2: &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(&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<Deterministic>,
+ 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<Deterministic>,
+ 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(&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<Deterministic>,
+ 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<Deterministic>,
+ 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(vec![(&client_a, cx_a), (&client_b, cx_b)])
+ .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
@@ -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<Deterministic>,
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))
+ 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();
-}
-
-#[gpui::test(iterations = 10)]
-async fn test_decline_join_request(
- deterministic: Arc<Deterministic>,
- 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
- ));
+ active_call_a
+ .update(cx_a, |call, cx| call.accept_incoming(cx))
+ .await
+ .unwrap();
- // 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,
- )
- });
+ active_call_a
+ .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+ .await
+ .unwrap();
- // 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
- ));
+ // 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_cancel_join_request(
+async fn test_active_call_events(
deterministic: Arc<Deterministic>,
cx_a: &mut TestAppContext,
cx_b: &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;
+ 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)])
+ .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
+ let active_call_a = cx_a.read(ActiveCall::global);
+ let active_call_b = cx_b.read(ActiveCall::global);
- client_a.fs.insert_tree("/a", json!({})).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 events_a = active_call_events(cx_a);
+ let events_b = active_call_events(cx_b);
- let user_b = client_a
- .user_store
- .update(cx_a, |store, cx| {
- store.fetch_user(client_b.user_id().unwrap(), cx)
- })
+ let project_a_id = active_call_a
+ .update(cx_a, |call, cx| call.share_project(project_a.clone(), 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!(mem::take(&mut *events_a.borrow_mut()), vec![]);
assert_eq!(
- &*project_a_events.borrow(),
- &[project::Event::ContactRequestedJoin(user_b.clone())]
+ 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()],
+ }]
);
- project_a_events.borrow_mut().clear();
- // Cancel the join request by leaving the project
- client_b
- .client
- .send(proto::LeaveProject { project_id })
+ let project_b_id = active_call_b
+ .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx))
+ .await
.unwrap();
- drop(project_b);
-
deterministic.run_until_parked();
assert_eq!(
- &*project_a_events.borrow(),
- &[project::Event::ContactCancelledJoinRequest(user_b)]
+ 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<RefCell<Vec<room::Event>>> {
+ 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_offline_projects(
+async fn test_room_location(
deterministic: Arc<Deterministic>,
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());
- server
- .make_contacts(vec![
- (&client_a, cx_a),
- (&client_b, cx_b),
- (&client_c, cx_c),
- ])
- .await;
-
- // 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)
- }));
-
- 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<ProjectStore>,
- user_store: ModelHandle<UserStore>,
- 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
- );
- }
- }
- }
- }
- }
+ client_a.fs.insert_tree("/a", json!({})).await;
+ client_b.fs.insert_tree("/b", json!({})).await;
- EmptyView
- });
+ let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
+ let (project_b, _) = client_b.build_local_project("/b", cx_b).await;
- // Build an offline project with two worktrees.
- client_a
- .fs
- .insert_tree(
- "/code",
- json!({
- "crate1": { "a.rs": "" },
- "crate2": { "b.rs": "" },
- }),
- )
+ 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,
- )
- });
- project
- .update(cx_a, |p, cx| {
- p.find_or_create_local_worktree("/code/crate1", true, cx)
- })
- .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(),
- }]
- );
+ 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()
+ }
});
- // 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(),
- }]
- );
+ 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()
+ }
});
- project
- .update(cx_a, |p, cx| {
- p.find_or_create_local_worktree("/code/crate3", true, cx)
- })
+ room_a
+ .update(cx_a, |room, cx| room.set_location(Some(&project_a), 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(),
- }]
- );
- });
+ 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)]
+ );
- // 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)
- })
+ let project_a_id = active_call_a
+ .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
- project2_a
- .update(cx_a, |project, cx| project.restore_state(cx))
+ 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();
-
- // 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(),
- }
- ]
- );
- });
+ 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 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(),
- ));
+ 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
+ }
+ )]
+ );
- // 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));
+ room_b
+ .update(cx_b, |room, cx| room.set_location(None, cx))
+ .await
+ .unwrap();
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(),
- },]
- );
- });
+ 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
+ }
+ )]
+ );
- cx_a.update(|cx| {
- drop(subscriptions);
- drop(view);
- cx.remove_window(window_id);
- });
+ fn participant_locations(
+ room: &ModelHandle<Room>,
+ 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()
+ })
+ }
}
#[gpui::test(iterations = 10)]
@@ -4,6 +4,8 @@ mod db;
mod env;
mod rpc;
+#[cfg(test)]
+mod db_tests;
#[cfg(test)]
mod integration_tests;
@@ -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<R: RequestMessage> Response<R> {
self.server.peer.respond(self.receipt, payload)?;
Ok(())
}
-
- fn into_receipt(self) -> Receipt<R> {
- 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<Self>, 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 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,
+ },
+ )
+ });
}
- 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 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());
}
- removed_user_id = removed_connection.user_id;
+ 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<Self>,
- 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<Server>,
+ request: TypedEnvelope<proto::CreateRoom>,
+ response: Response<proto::CreateRoom>,
+ ) -> 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<Server>,
+ request: TypedEnvelope<proto::JoinRoom>,
+ response: Response<proto::JoinRoom>,
+ ) -> 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<Server>, message: TypedEnvelope<proto::LeaveRoom>) -> 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<Server>,
+ request: TypedEnvelope<proto::Call>,
+ response: Response<proto::Call>,
+ ) -> 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::<FuturesUnordered<_>>()
+ };
+ 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<Server>,
+ request: TypedEnvelope<proto::CancelCall>,
+ response: Response<proto::CancelCall>,
+ ) -> 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<Server>,
+ message: TypedEnvelope<proto::DeclineCall>,
+ ) -> 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<Server>,
+ request: TypedEnvelope<proto::UpdateParticipantLocation>,
+ response: Response<proto::UpdateParticipantLocation>,
+ ) -> 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<Server>,
- request: TypedEnvelope<proto::RegisterProject>,
- response: Response<proto::RegisterProject>,
+ request: TypedEnvelope<proto::ShareProject>,
+ response: Response<proto::ShareProject>,
) -> 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<Server>,
- request: TypedEnvelope<proto::UnregisterProject>,
- response: Response<proto::UnregisterProject>,
+ message: TypedEnvelope<proto::UnshareProject>,
) -> 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"))?;
- }
-
- 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<Server>,
- request: TypedEnvelope<proto::RespondToJoinProjectRequest>,
- ) -> Result<()> {
- let host_user_id;
+ 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::<Vec<_>>();
- {
- 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"))?;
+ // 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(),
+ });
}
+ }
- 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(());
+ 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(),
+ }),
+ },
+ )?;
}
+ }
- 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"))?;
+ // 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(),
+ })?;
- 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::<Vec<_>>();
-
- // 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 (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;
- 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(),
- }),
- },
- )?;
- }
- }
+ // 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())?;
}
- // 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(),
- },
- )),
+ // 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()),
},
)?;
}
+ }
- 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 entries.
- let message = proto::UpdateWorktree {
+ for language_server in &project.language_servers {
+ self.peer.send(
+ request.sender_id,
+ proto::UpdateLanguageServer {
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())?;
- }
- }
-
- // 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()),
- },
- )?;
- }
- }
- }
+ language_server_id: language_server.id,
+ variant: Some(
+ proto::update_language_server::Variant::DiskBasedDiagnosticsUpdated(
+ proto::LspDiskBasedDiagnosticsUpdated {},
+ ),
+ ),
+ },
+ )?;
}
- self.update_user_contacts(host_user_id).await?;
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<proto::UpdateProject>,
) -> 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<Server>,
+ request: TypedEnvelope<proto::UpdateDiffBase>,
+ ) -> 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<Self>,
+ request: TypedEnvelope<proto::GetPrivateUserInfo>,
+ response: Response<proto::GetPrivateUserInfo>,
+ ) -> 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;
@@ -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<ConnectionId, ConnectionState>,
- connections_by_user_id: BTreeMap<UserId, HashSet<ConnectionId>>,
+ connected_users: BTreeMap<UserId, ConnectedUser>,
+ next_room_id: RoomId,
+ rooms: BTreeMap<RoomId, proto::Room>,
projects: BTreeMap<ProjectId, Project>,
#[serde(skip)]
channels: BTreeMap<ChannelId, Channel>,
}
+#[derive(Default, Serialize)]
+struct ConnectedUser {
+ connection_ids: HashSet<ConnectionId>,
+ active_call: Option<Call>,
+}
+
#[derive(Serialize)]
struct ConnectionState {
user_id: UserId,
admin: bool,
projects: BTreeSet<ProjectId>,
- requested_projects: HashSet<ProjectId>,
channels: HashSet<ChannelId>,
}
+#[derive(Copy, Clone, Eq, PartialEq, Serialize)]
+pub struct Call {
+ pub caller_user_id: UserId,
+ pub room_id: RoomId,
+ pub connection_id: Option<ConnectionId>,
+ pub initial_project_id: Option<ProjectId>,
+}
+
#[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<ConnectionId, Collaborator>,
- #[serde(skip)]
- pub join_requests: HashMap<UserId, Vec<Receipt<proto::JoinProject>>>,
pub active_replica_ids: HashSet<ReplicaId>,
pub worktrees: BTreeMap<u64, Worktree>,
pub language_servers: Vec<proto::LanguageServer>,
@@ -69,23 +86,26 @@ pub type ReplicaId = u16;
#[derive(Default)]
pub struct RemovedConnectionState {
pub user_id: UserId,
- pub hosted_projects: HashMap<ProjectId, Project>,
- pub guest_project_ids: HashSet<ProjectId>,
+ pub hosted_projects: Vec<Project>,
+ pub guest_projects: Vec<LeftProject>,
pub contact_ids: HashSet<UserId>,
+ pub room_id: Option<RoomId>,
+ pub canceled_call_connection_ids: Vec<ConnectionId>,
}
pub struct LeftProject {
+ pub id: ProjectId,
pub host_user_id: UserId,
pub host_connection_id: ConnectionId,
pub connection_ids: Vec<ConnectionId>,
pub remove_collaborator: bool,
- pub cancel_request: Option<UserId>,
- pub unshare: bool,
}
-pub struct UnsharedProject {
- pub guests: HashMap<ConnectionId, Collaborator>,
- pub pending_join_requests: HashMap<UserId, Vec<Receipt<proto::JoinProject>>>,
+pub struct LeftRoom<'a> {
+ pub room: Option<&'a proto::Room>,
+ pub unshared_projects: Vec<Project>,
+ pub left_projects: Vec<LeftProject>,
+ pub canceled_call_connection_ids: Vec<ConnectionId>,
}
#[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<proto::IncomingCall> {
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<Item = ConnectionId> + '_ {
- 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<db::Contact>,
@@ -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<proto::ProjectMetadata> {
- 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<RoomId> {
+ 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(),
- });
- }
+ 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 join_room(
+ &mut self,
+ room_id: RoomId,
+ connection_id: ConnectionId,
+ ) -> Result<(&proto::Room, Vec<ConnectionId>)> {
+ 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::<Vec<_>>();
+
+ 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<LeftRoom> {
+ 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,
+ })
+ }
- metadata
+ pub fn room(&self, room_id: RoomId) -> Option<&proto::Room> {
+ self.rooms.get(&room_id)
}
- pub fn register_project(
+ pub fn call(
&mut self,
- host_connection_id: ConnectionId,
+ room_id: RoomId,
+ recipient_user_id: UserId,
+ initial_project_id: Option<ProjectId>,
+ from_connection_id: ConnectionId,
+ ) -> Result<(&proto::Room, Vec<ConnectionId>, 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::<Vec<_>>();
+ 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<ConnectionId>)> {
+ 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<ConnectionId>)> {
+ 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::<Vec<_>>();
+ 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<proto::WorktreeMetadata>,
+ 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<Option<UnsharedProject>> {
+ ) -> 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);
- }
- }
-
- project.active_replica_ids.clear();
- project.language_servers.clear();
- for worktree in project.worktrees.values_mut() {
- worktree.diagnostic_summaries.clear();
- worktree.entries.clear();
- }
+ 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();
- 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<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);
- }
- }
-
- 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<proto::JoinProject>,
- ) -> 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"))
- }
- }
-
- pub fn deny_join_project_request(
- &mut self,
- responder_connection_id: ConnectionId,
- requester_id: UserId,
- project_id: ProjectId,
- ) -> Option<Vec<Receipt<proto::JoinProject>>> {
- 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());
+ anyhow::ensure!(project.room_id == active_call.room_id, "no such project");
- 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<proto::JoinProject>, 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<LeftProject> {
- 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,
})
}
@@ -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"] }
@@ -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<Workspace>,
+ user_store: ModelHandle<UserStore>,
+ contacts_popover: Option<ViewHandle<ContactsPopover>>,
+ _subscriptions: Vec<Subscription>,
+}
+
+impl Entity for CollabTitlebarItem {
+ type Event = ();
+}
+
+impl View for CollabTitlebarItem {
+ fn ui_name() -> &'static str {
+ "CollabTitlebarItem"
+ }
+
+ fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+ let workspace = if let Some(workspace) = self.workspace.upgrade(cx) {
+ workspace
+ } else {
+ return Empty::new().boxed();
+ };
+
+ let theme = cx.global::<Settings>().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<Workspace>,
+ user_store: &ModelHandle<UserStore>,
+ cx: &mut ViewContext<Self>,
+ ) -> 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<Self>) {
+ 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<Self>) {
+ 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<Self>,
+ ) {
+ 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<Self>,
+ ) -> 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::<ToggleCollaborationMenu>::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<Self>) -> ElementBox {
+ enum Share {}
+
+ let titlebar = &theme.workspace.titlebar;
+ MouseEventHandler::<Share>::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::<Share, _>(
+ 0,
+ "Share project with call participants".into(),
+ None,
+ theme.tooltip.clone(),
+ cx,
+ )
+ .aligned()
+ .boxed()
+ }
+
+ fn render_collaborators(
+ &self,
+ workspace: &ViewHandle<Workspace>,
+ theme: &Theme,
+ cx: &mut RenderContext<Self>,
+ ) -> Vec<ElementBox> {
+ 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::<Vec<_>>();
+ 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<Workspace>,
+ theme: &Theme,
+ cx: &mut RenderContext<Self>,
+ ) -> Option<ElementBox> {
+ 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::<Authenticate>::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<ReplicaId>,
+ peer: Option<(PeerId, &str, ParticipantLocation)>,
+ workspace: &ViewHandle<Workspace>,
+ theme: &Theme,
+ cx: &mut RenderContext<Self>,
+ ) -> 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::<ToggleFollow>::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::<ToggleFollow, _>(
+ 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::<JoinProject>::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::<JoinProject, _>(
+ 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<Workspace>,
+ cx: &mut RenderContext<Self>,
+ ) -> Option<ElementBox> {
+ let theme = &cx.global::<Settings>().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<usize>,
+ _: RectF,
+ _: RectF,
+ _: &Self::LayoutState,
+ _: &Self::PaintState,
+ _: &gpui::MeasurementContext,
+ ) -> Option<RectF> {
+ 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(),
+ })
+ }
+}
@@ -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<AppState>, 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::<Workspace>(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);
+ });
+}
@@ -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::<ContactFinder>::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<Self>) -> ElementBox {
- ChildView::new(self.picker.clone()).boxed()
+ fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+ ChildView::new(self.picker.clone(), cx).boxed()
}
fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
@@ -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>) {
- 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<UserStore>, cx: &mut ViewContext<Self>) -> 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::<Settings>().theme.contact_finder.picker)
+ }),
potential_contacts: Arc::from([]),
user_store,
selected_index: 0,
}
}
-
- fn on_event(
- workspace: &mut Workspace,
- _: ViewHandle<Self>,
- event: &Event,
- cx: &mut ViewContext<Workspace>,
- ) {
- match event {
- Event::Dismissed => {
- workspace.dismiss_modal(cx);
- }
- }
- }
}
@@ -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<ModelHandle<Project>>,
+}
+
+#[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<User>,
+ is_pending: bool,
+ },
+ ParticipantProject {
+ project_id: u64,
+ worktree_root_names: Vec<String>,
+ host_user_id: u64,
+ is_last: bool,
+ },
+ IncomingRequest(Arc<User>),
+ OutgoingRequest(Arc<User>),
+ Contact(Arc<Contact>),
+}
+
+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<ContactEntry>,
+ match_candidates: Vec<StringMatchCandidate>,
+ list_state: ListState,
+ project: ModelHandle<Project>,
+ user_store: ModelHandle<UserStore>,
+ filter_editor: ViewHandle<Editor>,
+ collapsed_sections: Vec<Section>,
+ selection: Option<usize>,
+ _subscriptions: Vec<Subscription>,
+}
+
+impl ContactList {
+ pub fn new(
+ project: ModelHandle<Project>,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut ViewContext<Self>,
+ ) -> 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::<Settings>().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>) {
+ 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>,
+ ) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Self>) {
+ 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::<Vec<_>, _>(|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<Self>,
+ ) -> 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::<JoinProject>::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<Self>,
+ ) -> 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::<LeaveCall>::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::<Header>::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<Project>,
+ theme: &theme::ContactList,
+ is_selected: bool,
+ cx: &mut RenderContext<Self>,
+ ) -> ElementBox {
+ let online = contact.online;
+ let busy = contact.busy;
+ let user_id = contact.user.id;
+ let initial_project = project.clone();
+ let mut element =
+ MouseEventHandler::<Contact>::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>,
+ user_store: ModelHandle<UserStore>,
+ theme: &theme::ContactList,
+ is_incoming: bool,
+ is_selected: bool,
+ cx: &mut RenderContext<Self>,
+ ) -> 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::<Decline>::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::<Accept>::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::<Cancel>::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<Self>) {
+ 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<Self>) {
+ 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<Self>) -> ElementBox {
+ enum AddContact {}
+ let theme = cx.global::<Settings>().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::<AddContact>::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::<AddContact, _>(
+ 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<Self>) {
+ if !self.filter_editor.is_focused(cx) {
+ cx.focus(&self.filter_editor);
+ }
+ }
+
+ fn on_focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+ 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)
+}
@@ -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",
@@ -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<ContactList>),
+ ContactFinder(ViewHandle<ContactFinder>),
+}
+
+pub struct ContactsPopover {
+ child: Child,
+ project: ModelHandle<Project>,
+ user_store: ModelHandle<UserStore>,
+ _subscription: Option<gpui::Subscription>,
+}
+
+impl ContactsPopover {
+ pub fn new(
+ project: ModelHandle<Project>,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut ViewContext<Self>,
+ ) -> 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<Self>) {
+ 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<ContactsPopover>) {
+ 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<ContactsPopover>) {
+ 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<Self>) -> ElementBox {
+ let theme = cx.global::<Settings>().theme.clone();
+ let child = match &self.child {
+ Child::ContactList(child) => ChildView::new(child, cx),
+ Child::ContactFinder(child) => ChildView::new(child, cx),
+ };
+
+ MouseEventHandler::<ContactsPopover>::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::<InviteLink>::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<Self>) {
+ if cx.is_self_focused() {
+ match &self.child {
+ Child::ContactList(child) => cx.focus(child),
+ Child::ContactFinder(child) => cx.focus(child),
+ }
+ }
+ }
+}
@@ -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::<Settings>().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<Self>) {
+ 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<Self>) -> ElementBox {
+ let theme = &cx.global::<Settings>().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<Self>) -> ElementBox {
+ enum Accept {}
+ enum Decline {}
+
+ Flex::column()
+ .with_child(
+ MouseEventHandler::<Accept>::new(0, cx, |_, cx| {
+ let theme = &cx.global::<Settings>().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::<Decline>::new(0, cx, |_, cx| {
+ let theme = &cx.global::<Settings>().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::<Settings>()
+ .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<Self>) -> gpui::ElementBox {
+ let background = cx
+ .global::<Settings>()
+ .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()
+ }
+}
@@ -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<V: View, A: Action + Clone>(
)
.with_child(
MouseEventHandler::<Dismiss>::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.))
@@ -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::<Settings>().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<String>,
+ owner: Arc<User>,
+}
+
+impl ProjectSharedNotification {
+ fn new(owner: Arc<User>, project_id: u64, worktree_root_names: Vec<String>) -> Self {
+ Self {
+ project_id,
+ worktree_root_names,
+ owner,
+ }
+ }
+
+ fn join(&mut self, _: &JoinProject, cx: &mut ViewContext<Self>) {
+ let window_id = cx.window_id();
+ cx.remove_window(window_id);
+ cx.propagate_action();
+ }
+
+ fn dismiss(&mut self, _: &DismissProject, cx: &mut ViewContext<Self>) {
+ let window_id = cx.window_id();
+ cx.remove_window(window_id);
+ }
+
+ fn render_owner(&self, cx: &mut RenderContext<Self>) -> ElementBox {
+ let theme = &cx.global::<Settings>().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<Self>) -> ElementBox {
+ enum Open {}
+ enum Dismiss {}
+
+ let project_id = self.project_id;
+ let owner_user_id = self.owner.id;
+
+ Flex::column()
+ .with_child(
+ MouseEventHandler::<Open>::new(0, cx, |_, cx| {
+ let theme = &cx.global::<Settings>().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::<Dismiss>::new(0, cx, |_, cx| {
+ let theme = &cx.global::<Settings>().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::<Settings>()
+ .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<Self>) -> gpui::ElementBox {
+ let background = cx
+ .global::<Settings>()
+ .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()
+ }
+}
@@ -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<Self>) -> gpui::ElementBox {
+ ChildView::new(self.picker.clone(), cx).boxed()
}
fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
@@ -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 {
@@ -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"] }
@@ -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<User>),
- OutgoingRequest(Arc<User>),
- Contact(Arc<Contact>),
- ContactProject(Arc<Contact>, usize, Option<WeakModelHandle<Project>>),
- OfflineProject(WeakModelHandle<Project>),
-}
-
-#[derive(Clone, PartialEq)]
-struct ToggleExpanded(Section);
-
-pub struct ContactsPanel {
- entries: Vec<ContactEntry>,
- match_candidates: Vec<StringMatchCandidate>,
- list_state: ListState,
- user_store: ModelHandle<UserStore>,
- project_store: ModelHandle<ProjectStore>,
- filter_editor: ViewHandle<Editor>,
- collapsed_sections: Vec<Section>,
- selection: Option<usize>,
- _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<UserStore>,
- project_store: ModelHandle<ProjectStore>,
- workspace: WeakViewHandle<Workspace>,
- cx: &mut ViewContext<Self>,
- ) -> 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::<Settings>().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<Self>,
- ) -> 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::<Header>::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<Contact>,
- current_user_id: Option<u64>,
- project_index: usize,
- open_project: Option<WeakModelHandle<Project>>,
- theme: &theme::ContactsPanel,
- tooltip_style: &TooltipStyle,
- is_last_project: bool,
- is_selected: bool,
- cx: &mut RenderContext<Self>,
- ) -> 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::<JoinProject>::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::<ToggleProjectOnline>::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::<ToggleOnline, _>(
- 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<Project>,
- theme: &theme::ContactsPanel,
- tooltip_style: &TooltipStyle,
- is_selected: bool,
- cx: &mut RenderContext<Self>,
- ) -> 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::<LocalProject>::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::<ToggleOnline>::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::<ToggleOnline, _>(
- 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>,
- user_store: ModelHandle<UserStore>,
- theme: &theme::ContactsPanel,
- is_incoming: bool,
- is_selected: bool,
- cx: &mut RenderContext<ContactsPanel>,
- ) -> 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::<Decline>::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::<Accept>::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::<Cancel>::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<Self>) {
- 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::<Vec<_>, _>(|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::<Vec<_>>();
- 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>) {
- 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>) {
- 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>,
- ) {
- 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<Self>) {
- 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<Self>) {
- 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<Self>) {
- 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<Self>) {
- 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<Self>) {
- 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<Self>) -> ElementBox {
- enum AddContact {}
-
- let theme = cx.global::<Settings>().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::<AddContact>::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::<InviteLink>::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<Self>) {
- 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::<HashSet<_>>().len() == entries.len(),
- "Duplicate contact panel entries {:?}",
- entries
- )
- })
- .detach();
- });
-
- let get_users_request = server.receive::<proto::GetUsers>().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::<proto::RegisterProject>().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::<proto::UpdateProject>()
- .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::<proto::UpdateProject>()
- .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::<proto::UpdateWorktreeExtensions>()
- .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::<proto::UpdateProject>()
- .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<ContactsPanel>, cx: &AppContext) -> Vec<String> {
- 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::<Vec<_>>()
- .join(", "),
- if project.is_online() {
- " (going online...)"
- } else {
- ""
- },
- )
- }
- };
-
- if panel.selection == Some(ix) {
- string.push_str(" <=== selected");
- }
-
- string
- }));
- entries
- }
-}
@@ -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<Project>,
- user: Arc<User>,
-}
-
-impl JoinProjectNotification {
- pub fn new(project: ModelHandle<Project>, user: Arc<User>, cx: &mut ViewContext<Self>) -> 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>) {
- 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>) {
- 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<Self>) -> 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: &<Self as Entity>::Event) -> bool {
- matches!(event, Event::Dismiss)
- }
-}
@@ -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"] }
@@ -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<Editor>,
-}
-
-impl Entity for ContactsPopover {
- type Event = Event;
-}
-
-impl View for ContactsPopover {
- fn ui_name() -> &'static str {
- "ContactsPopover"
- }
-
- fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
- let theme = &cx.global::<Settings>().theme.contacts_popover;
-
- Flex::row()
- .with_child(
- ChildView::new(self.filter_editor.clone())
- .contained()
- .with_style(
- cx.global::<Settings>()
- .theme
- .contacts_panel
- .user_query_editor
- .container,
- )
- .flex(1., true)
- .boxed(),
- )
- // .with_child(
- // MouseEventHandler::<AddContact>::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::<Settings>()
- .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>) -> 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<Self>) {
- if !is_active {
- cx.emit(Event::Deactivated);
- }
- }
-}
@@ -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<ViewHandle<ContactsPopover>>,
-}
-
-impl Entity for ContactsStatusItem {
- type Event = ();
-}
-
-impl View for ContactsStatusItem {
- fn ui_name() -> &'static str {
- "ContactsStatusItem"
- }
-
- fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
- let color = match cx.appearance {
- Appearance::Light | Appearance::VibrantLight => Color::black(),
- Appearance::Dark | Appearance::VibrantDark => Color::white(),
- };
- MouseEventHandler::<Self>::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<Self>) {
- 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<ContactsPopover>,
- event: &contacts_popover::Event,
- cx: &mut ViewContext<Self>,
- ) {
- match event {
- contacts_popover::Event::Deactivated => {
- self.popover.take();
- cx.remove_window(popover.window_id());
- }
- }
- }
-}
@@ -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,
@@ -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" }
@@ -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()
}
}
@@ -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"
@@ -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<Item = &str> {
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<Item = &str> {
+ (0..=display_row).into_iter().rev().flat_map(|row| {
+ self.blocks_snapshot
+ .chunks(row..row + 1, false, None)
+ .map(|h| h.text)
+ .collect::<Vec<_>>()
+ .into_iter()
+ .rev()
+ })
+ }
+
pub fn chunks(&self, display_rows: Range<u32>, 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<Item = char> + '_ {
- 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<Item = (char, DisplayPoint)> + '_ {
+ 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<Item = (char, DisplayPoint)> + '_ {
+ 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::<String>();
+ let text = util::RandomCharIter::new(&mut rng)
+ .take(len)
+ .collect::<String>();
MultiBuffer::build_simple(&text, cx)
} else {
MultiBuffer::build_random(&mut rng, cx)
@@ -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() {
@@ -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]
@@ -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<TabSnapshot>);
@@ -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<super::Point> for TabPoint {
- fn from(point: super::Point) -> Self {
+impl From<Point> for TabPoint {
+ fn from(point: Point) -> Self {
Self(point)
}
}
@@ -362,7 +361,7 @@ pub type TabEdit = text::Edit<TabPoint>;
#[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::<String>();
+ let text = util::RandomCharIter::new(&mut rng)
+ .take(len)
+ .collect::<String>();
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,
@@ -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<u32>,
@@ -959,7 +960,7 @@ impl SumTreeExt for SumTree<Transform> {
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::<String>();
+ let text = util::RandomCharIter::new(&mut rng)
+ .take(len)
+ .collect::<String>();
MultiBuffer::build_simple(&text, cx)
}
});
@@ -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<AddSelectionsState>,
select_next_state: Option<SelectNextState>,
selection_history: SelectionHistory,
- autoclose_stack: InvalidationStack<BracketPairState>,
+ autoclose_regions: Vec<AutocloseRegion>,
snippet_stack: InvalidationStack<SnippetState>,
select_larger_syntax_node_stack: Vec<Box<[Selection<usize>]>>,
ime_transaction: Option<TransactionId>,
@@ -419,6 +449,8 @@ pub struct Editor {
focused: bool,
show_local_cursors: bool,
show_local_selections: bool,
+ show_scrollbars: bool,
+ hide_scrollbar_task: Option<Task<()>>,
blink_epoch: usize,
blinking_paused: bool,
mode: EditorMode,
@@ -443,6 +475,7 @@ pub struct Editor {
leader_replica_id: Option<u16>,
hover_state: HoverState,
link_go_to_definition_state: LinkGoToDefinitionState,
+ visible_line_count: Option<f32>,
_subscriptions: Vec<Subscription>,
}
@@ -563,8 +596,10 @@ struct SelectNextState {
done: bool,
}
-struct BracketPairState {
- ranges: Vec<Range<Anchor>>,
+#[derive(Debug)]
+struct AutocloseRegion {
+ selection_id: usize,
+ range: Range<Anchor>,
pair: BracketPair,
}
@@ -589,6 +624,18 @@ enum ContextMenu {
}
impl ContextMenu {
+ fn select_first(&mut self, cx: &mut ViewContext<Editor>) -> 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<Editor>) -> bool {
if self.visible() {
match self {
@@ -613,6 +660,18 @@ impl ContextMenu {
}
}
+ fn select_last(&mut self, cx: &mut ViewContext<Editor>) -> 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<Editor>) {
+ self.selected_item = 0;
+ self.list.scroll_to(ScrollTarget::Show(self.selected_item));
+ cx.notify();
+ }
+
fn select_prev(&mut self, cx: &mut ViewContext<Editor>) {
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<Editor>) {
+ 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<Editor>) {
+ self.selected_item = 0;
+ cx.notify()
+ }
+
fn select_prev(&mut self, cx: &mut ViewContext<Editor>) {
if self.selected_item > 0 {
self.selected_item -= 1;
@@ -806,6 +882,11 @@ impl CodeActionsMenu {
}
}
+ fn select_last(&mut self, cx: &mut ViewContext<Editor>) {
+ self.selected_item = self.actions.len() - 1;
+ cx.notify()
+ }
+
fn visible(&self) -> bool {
!self.actions.is_empty()
}
@@ -833,7 +914,7 @@ impl CodeActionsMenu {
MouseEventHandler::<ActionTag>::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<Language>> {
+ ) -> Option<Arc<Language>> {
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<str> = 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::<usize, _>(new_anchor_selections, &snapshot)
+ .zip(new_selection_deltas)
+ .map(|(selection, delta)| selection.map(|e| e + delta))
+ .collect::<Vec<_>>();
+
+ 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<Self>) {
@@ -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<Self>) -> 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::<usize>(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<str> = pair.start.clone().into();
- let pair_end: Arc<str> = 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<Self>) {
+ /// 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<Self>) {
let selections = self.selections.all::<usize>(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::<Vec<_>>();
-
- 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::<SmallVec<[_; 32]>>();
-
- let pair_end: Arc<str> = 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::<usize, _>(left_biased_selections.iter(), &snapshot)
- .collect::<Vec<_>>(),
- );
-
- 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,
- });
}
}
- });
-
- 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);
- }
- }
-
- fn skip_autoclose_end(&mut self, text: &str, cx: &mut ViewContext<Self>) -> bool {
- let buffer = self.buffer.read(cx).snapshot(cx);
- let old_selections = self.selections.all::<usize>(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;
+ new_selections.push(selection);
}
- 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
- }
+ drop(buffer);
+ self.change_selections(None, cx, |selections| selections.select(new_selections));
}
- fn select_autoclose_pair(&mut self, cx: &mut ViewContext<Self>) -> bool {
- let buffer = self.buffer.read(cx).snapshot(cx);
- let old_selections = self.selections.all::<usize>(cx);
- let autoclose_pair = if let Some(autoclose_pair) = self.autoclose_stack.last() {
- autoclose_pair
- } else {
- 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<Item = Selection<D>>,
+ buffer: &'a MultiBufferSnapshot,
+ ) -> impl Iterator<Item = (Selection<D>, 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);
+
+ 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;
+ }
+ }
- debug_assert_eq!(old_selections.len(), autoclose_pair.ranges.len());
+ (selection.clone(), enclosing)
+ })
+ }
- 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;
+ /// Remove any autoclose regions that no longer contain their selection.
+ fn invalidate_autoclose_regions(
+ &mut self,
+ mut selections: &[Selection<Anchor>],
+ 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;
+ }
}
- }
-
- self.change_selections(Some(Autoscroll::Fit), cx, |selections| {
- selections.select(new_selections)
+ false
});
- true
}
fn completion_query(buffer: &MultiBufferSnapshot, position: impl ToOffset) -> Option<String> {
@@ -2902,51 +2999,49 @@ impl Editor {
pub fn backspace(&mut self, _: &Backspace, cx: &mut ViewContext<Self>) {
self.transact(cx, |this, cx| {
- if !this.select_autoclose_pair(cx) {
- let mut selections = this.selections.all::<Point>(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::<Settings>().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::<Point>(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::<Settings>().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,15 +3913,13 @@ impl Editor {
})
}
- pub fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext<Self>) {
+ pub fn center_screen(&mut self, _: &CenterScreen, cx: &mut ViewContext<Self>) {
if self.take_rename(true, cx).is_some() {
return;
}
- if let Some(context_menu) = self.context_menu.as_mut() {
- if context_menu.select_prev(cx) {
- return;
- }
+ if let Some(_) = self.context_menu.as_mut() {
+ return;
}
if matches!(self.mode, EditorMode::SingleLine) {
@@ -3834,10 +3927,29 @@ impl Editor {
return;
}
- self.change_selections(Some(Autoscroll::Fit), cx, |s| {
- let line_mode = s.line_mode;
- s.move_with(|map, selection| {
- if !selection.is_empty() && !line_mode {
+ self.request_autoscroll(Autoscroll::Center, cx);
+ }
+
+ pub fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext<Self>) {
+ if self.take_rename(true, cx).is_some() {
+ return;
+ }
+
+ if let Some(context_menu) = self.context_menu.as_mut() {
+ if context_menu.select_prev(cx) {
+ return;
+ }
+ }
+
+ if matches!(self.mode, EditorMode::SingleLine) {
+ cx.propagate_action();
+ return;
+ }
+
+ self.change_selections(Some(Autoscroll::Fit), 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(map, selection.start, selection.goal, false);
@@ -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::<Vec<_>>(),
+ snapshot.folds_in_range(0..text.len()).collect::<Vec<_>>(),
+ );
+ assert_set_eq!(
+ cloned_editor.read(cx).selections.ranges::<Point>(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<NavigationEntry> {
+ 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, _| {
+ 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, _| {
+ 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, _| {
+ 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#"
+ <body>ˇ
+ <script>
+ var x = 1;ˇ
+ </script>
+ </body>ˇ
+ "#
+ .unindent(),
+ );
+
+ // Precondition: different languages are active at different locations.
+ cx.update_editor(|editor, cx| {
+ let snapshot = editor.snapshot(cx);
+ let cursors = editor.selections.ranges::<usize>(cx);
+ let languages = cursors
+ .iter()
+ .map(|c| snapshot.language_at(c.start).unwrap().name())
+ .collect::<Vec<_>>();
+ 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#"
+ <body><aˇ>
+ <script>
+ var x = 1;<aˇ
+ </script>
+ </body><aˇ>
+ "#
+ .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#"
+ <body><a b={c(ˇ)}>
+ <script>
+ var x = 1;<a b={c(ˇ)}
+ </script>
+ </body><a b={c(ˇ)}>
+ "#
+ .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#"
+ <body><a b={c()d}ˇ>
+ <script>
+ var x = 1;<a b={c()d}ˇ
+ </script>
+ </body><a b={c()d}ˇ>
+ "#
+ .unindent(),
+ );
+ cx.update_editor(|editor, cx| {
+ editor.handle_input(">", cx);
+ });
+ cx.assert_editor_state(
+ &r#"
+ <body><a b={c()d}>ˇ
+ <script>
+ var x = 1;<a b={c()d}>ˇ
+ </script>
+ </body><a b={c()d}>ˇ
+ "#
+ .unindent(),
+ );
+
+ // Reset
+ cx.set_state(
+ &r#"
+ <body>ˇ
+ <script>
+ var x = 1;ˇ
+ </script>
+ </body>ˇ
+ "#
+ .unindent(),
+ );
+
+ cx.update_editor(|editor, cx| {
+ editor.handle_input("<", cx);
+ });
+ cx.assert_editor_state(
+ &r#"
+ <body><ˇ>
+ <script>
+ var x = 1;<ˇ
+ </script>
+ </body><ˇ>
+ "#
+ .unindent(),
+ );
+
+ // When backspacing, the closing angle brackets are removed.
+ cx.update_editor(|editor, cx| {
+ editor.backspace(&Backspace, cx);
+ });
+ cx.assert_editor_state(
+ &r#"
+ <body>ˇ
+ <script>
+ var x = 1;ˇ
+ </script>
+ </body>ˇ
+ "#
+ .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#"
+ <body>/*ˇ
+ <script>
+ var x = 1;/*ˇ */
+ </script>
+ </body>/*ˇ
+ "#
+ .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::<Point>(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::<Point>(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::<Point>(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<Editor>, 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::<usize>(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::<lsp::request::Formatting, _, _>(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::<lsp::request::Formatting, _, _>(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, _| {
+ 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::<lsp::request::Formatting, _, _>(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::<lsp::request::RangeFormatting, _, _>(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::<lsp::request::RangeFormatting, _, _>(
+ 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, _| {
+ 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::<lsp::request::RangeFormatting, _, _>(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::<lsp::request::Formatting, _, _>(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::<lsp::request::Formatting, _, _>(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::<lsp::request::Formatting, _, _>(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 <s|>
+ 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 <si|>
+ 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, _| {
+ 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.<clo|>", 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::<lsp::request::Completion, _, _>(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::<lsp::request::ResolveCompletionItem, _, _>(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(), " -->".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#"
+ <p>A</p>ˇ
+ <p>B</p>ˇ
+ <p>C</p>ˇ
+ "#
+ .unindent(),
+ );
+ cx.update_editor(|editor, cx| editor.toggle_comments(&ToggleComments, cx));
+ cx.assert_editor_state(
+ &r#"
+ <!-- <p>A</p>ˇ -->
+ <!-- <p>B</p>ˇ -->
+ <!-- <p>C</p>ˇ -->
+ "#
+ .unindent(),
+ );
+ cx.update_editor(|editor, cx| editor.toggle_comments(&ToggleComments, cx));
+ cx.assert_editor_state(
+ &r#"
+ <p>A</p>ˇ
+ <p>B</p>ˇ
+ <p>C</p>ˇ
+ "#
+ .unindent(),
+ );
+
+ // Toggle comments for mixture of empty and non-empty selections, where
+ // multiple selections occupy a given line.
+ cx.set_state(
+ &r#"
+ <p>A«</p>
+ <p>ˇ»B</p>ˇ
+ <p>C«</p>
+ <p>ˇ»D</p>ˇ
+ "#
+ .unindent(),
+ );
+
+ cx.update_editor(|editor, cx| editor.toggle_comments(&ToggleComments, cx));
+ cx.assert_editor_state(
+ &r#"
+ <!-- <p>A«</p>
+ <p>ˇ»B</p>ˇ -->
+ <!-- <p>C«</p>
+ <p>ˇ»D</p>ˇ -->
+ "#
+ .unindent(),
+ );
+ cx.update_editor(|editor, cx| editor.toggle_comments(&ToggleComments, cx));
+ cx.assert_editor_state(
+ &r#"
+ <p>A«</p>
+ <p>ˇ»B</p>ˇ
+ <p>C«</p>
+ <p>ˇ»D</p>ˇ
+ "#
+ .unindent(),
+ );
+
+ // Toggle comments when different languages are active for different
+ // selections.
+ cx.set_state(
+ &r#"
+ ˇ<script>
+ ˇvar x = new Y();
+ ˇ</script>
+ "#
+ .unindent(),
+ );
+ cx.foreground().run_until_parked();
+ cx.update_editor(|editor, cx| editor.toggle_comments(&ToggleComments, cx));
+ cx.assert_editor_state(
+ &r#"
+ <!-- ˇ<script> -->
+ // ˇvar x = new Y();
+ <!-- ˇ</script> -->
+ "#
+ .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<Point>| buffer.anchor_after(range.start)..buffer.anchor_after(range.end);
+
+ editor.highlight_background::<Type1>(
+ 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::<Type2>(
+ 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::<Settings>().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::<Settings>().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<DisplayPoint> {
+ 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<Editor>) {
+ 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
+ );
+}
@@ -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<u32>,
+ status: DiffHunkStatus,
+ is_folded: bool,
+}
+
struct SelectionLayout {
head: DisplayPoint,
range: Range<DisplayPoint>,
@@ -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::<Settings>()
+ .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::<Settings>().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::<ScrollbarMouseHandlers>(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<DisplayPoint>,
- 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<u32>,
+ snapshot: &EditorSnapshot,
+ ) -> Vec<DiffHunkLayout> {
+ 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::<DiffHunkLayout>::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<u32>,
@@ -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<u32>,
active_rows: BTreeMap<u32, bool>,
highlighted_rows: Option<Range<u32>>,
line_number_layouts: Vec<Option<text_layout::Line>>,
+ hunk_layouts: Vec<DiffHunkLayout>,
blocks: Vec<BlockLayout>,
highlighted_ranges: Vec<(Range<DisplayPoint>, Color)>,
selections: Vec<(ReplicaId, Vec<SelectionLayout>)>,
+ scrollbar_row_range: Range<f32>,
+ show_scrollbars: bool,
+ max_row: u32,
context_menu: Option<(DisplayPoint, ElementBox)>,
code_actions_indicator: Option<(u32, ElementBox)>,
hover_popovers: Option<(DisplayPoint, Vec<ElementBox>)>,
@@ -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};
@@ -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::*;
@@ -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<Project>,
cx: &mut ViewContext<Self>,
) -> Task<Result<()>> {
+ 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<Project>,
+ cx: &mut ViewContext<Self>,
+ ) -> Task<Result<()>> {
+ self.buffer().update(cx, |multibuffer, cx| {
+ multibuffer.git_diff_recalc(cx);
+ });
+ Task::ready(Ok(()))
+ }
+
fn to_item_events(event: &Self::Event) -> Vec<workspace::ItemEvent> {
let mut result = Vec::new();
match event {
@@ -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::*;
@@ -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]
@@ -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 the current point is in the soft_wrap, skip comparing it
+ if point.column() < start_column {
+ continue;
+ }
- if let Some(prev_ch) = prev_ch {
- if is_boundary(prev_ch, ch) {
- boundary = Some(point);
- }
+ if let Some((prev_ch, prev_point)) = prev {
+ if is_boundary(ch, prev_ch) {
+ return prev_point;
}
+ }
- if ch == '\n' {
- break;
- }
+ prev = Some((ch, point));
+ }
+ DisplayPoint::zero()
+}
- prev_ch = Some(ch);
- *point.column_mut() += ch.len_utf8() as u32;
+/// 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 let Some(boundary) = boundary {
- return boundary;
- } else if point.row() == 0 {
- return DisplayPoint::zero();
- } else {
- *point.row_mut() -= 1;
+ 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);
+ }
+ }
+
+ prev_ch = Some(ch);
+ }
+ 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' {
- *point.row_mut() += 1;
- *point.column_mut() = 0;
- } else {
- *point.column_mut() += ch.len_utf8() as u32;
+ break;
}
- prev_ch = Some(ch);
}
- map.clip_point(point, Bias::Right)
+
+ // 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]
@@ -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<ExcerptId>,
_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<u32>,
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<Self>) {
+ 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<I, S, T>(
&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<Language>> {
+ ) -> Option<Arc<Language>> {
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<Self>,
) {
- use text::RandomCharIter;
+ use util::RandomCharIter;
let snapshot = self.read(cx);
let mut edits: Vec<(Range<usize>, Arc<str>)> = 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<T: ToOffset>(
+ &self,
+ point: T,
+ ) -> Option<(&BufferSnapshot, usize)> {
+ let offset = point.to_offset(&self);
+ let mut cursor = self.excerpts.cursor::<usize>();
+ 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<Item = u32>,
@@ -1949,8 +1994,10 @@ impl MultiBufferSnapshot {
let mut rows_for_excerpt = Vec::new();
let mut cursor = self.excerpts.cursor::<Point>();
-
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<Language>> {
- self.excerpts
- .iter()
- .next()
- .and_then(|excerpt| excerpt.buffer.language())
+ pub fn language_at<'a, T: ToOffset>(&'a self, point: T) -> Option<&'a Arc<Language>> {
+ 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<u32>,
+ ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+ self.as_singleton()
+ .into_iter()
+ .flat_map(move |(_, _, buffer)| buffer.git_diff_hunks_in_range(row_range.clone()))
+ }
+
pub fn range_for_syntax_ancestor<T: ToOffset>(&self, range: Range<T>) -> Option<Range<usize>> {
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::<String>();
+ let base_text = util::RandomCharIter::new(&mut rng)
+ .take(10)
+ .collect::<String>();
buffers.push(cx.add_model(|cx| Buffer::new(0, base_text, cx)));
buffers.last().unwrap()
} else {
@@ -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 {
@@ -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::{
@@ -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,
-};
-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},
+ DisplayPoint, Editor, EditorMode, MultiBuffer,
};
-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<Editor>,
-}
-
-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<Output = ()> {
- self.editor.condition(self.cx, predicate)
- }
-
- pub fn editor<F, T>(&self, read: F) -> T
- where
- F: FnOnce(&Editor, &AppContext) -> T,
- {
- self.editor.read_with(self.cx, read)
- }
-
- pub fn update_editor<F, T>(&mut self, update: F) -> T
- where
- F: FnOnce(&mut Editor, &mut ViewContext<Editor>) -> T,
- {
- self.editor.update(self.cx, update)
- }
-
- pub fn multibuffer<F, T>(&self, read: F) -> T
- where
- F: FnOnce(&MultiBuffer, &AppContext) -> T,
- {
- self.editor(|editor, cx| read(editor.buffer().read(cx), cx))
- }
-
- pub fn update_multibuffer<F, T>(&mut self, update: F) -> T
- where
- F: FnOnce(&mut MultiBuffer, &mut ModelContext<MultiBuffer>) -> 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<F, T>(&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<F, T>(&mut self, update: F) -> T
- where
- F: FnOnce(&mut Buffer, &mut ModelContext<Buffer>) -> 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<const COUNT: usize>(&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<Range<usize>> {
- 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<language::Anchor> {
- 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<Tag: 'static>(&mut self, marked_text: &str) {
- let expected_ranges = self.ranges(marked_text);
- let actual_ranges: Vec<Range<usize>> = self.update_editor(|editor, cx| {
- let snapshot = editor.snapshot(cx);
- editor
- .background_highlights
- .get(&TypeId::of::<Tag>())
- .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<Tag: ?Sized + 'static>(&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<Range<usize>> = snapshot
- .highlight_ranges::<Tag>()
- .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<Range<usize>>) {
- 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<Range<usize>>,
- expected_marked_text: String,
- ) {
- let actual_selections = self
- .editor
- .read_with(self.cx, |editor, cx| editor.selections.all::<usize>(cx))
- .into_iter()
- .map(|s| {
- if s.reversed {
- s.end..s.start
- } else {
- s.start..s.end
- }
- })
- .collect::<Vec<_>>();
- 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<Workspace>,
- 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::<Editor>(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<usize>) -> 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<F, T>(&mut self, update: F) -> T
- where
- F: FnOnce(&mut Workspace, &mut ViewContext<Workspace>) -> T,
- {
- self.workspace.update(self.cx.cx, update)
- }
-
- pub fn handle_request<T, F, Fut>(
- &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<Output = Result<T::Result>>,
- {
- let url = self.buffer_lsp_url.clone();
- self.lsp.handle_request::<T, _, _>(move |params, cx| {
- let url = url.clone();
- handler(url, params, cx)
- })
- }
-
- pub fn notify<T: notification::Notification>(&self, params: T::Params) {
- self.lsp.notify::<T>(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
- }
-}
@@ -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<Workspace>,
+ 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::<Editor>(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<usize>) -> 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<F, T>(&mut self, update: F) -> T
+ where
+ F: FnOnce(&mut Workspace, &mut ViewContext<Workspace>) -> T,
+ {
+ self.workspace.update(self.cx.cx, update)
+ }
+
+ pub fn handle_request<T, F, Fut>(
+ &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<Output = Result<T::Result>>,
+ {
+ let url = self.buffer_lsp_url.clone();
+ self.lsp.handle_request::<T, _, _>(move |params, cx| {
+ let url = url.clone();
+ handler(url, params, cx)
+ })
+ }
+
+ pub fn notify<T: notification::Notification>(&self, params: T::Params) {
+ self.lsp.notify::<T>(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
+ }
+}
@@ -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<Editor>,
+}
+
+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<Output = ()> {
+ self.editor.condition(self.cx, predicate)
+ }
+
+ pub fn editor<F, T>(&self, read: F) -> T
+ where
+ F: FnOnce(&Editor, &AppContext) -> T,
+ {
+ self.editor.read_with(self.cx, read)
+ }
+
+ pub fn update_editor<F, T>(&mut self, update: F) -> T
+ where
+ F: FnOnce(&mut Editor, &mut ViewContext<Editor>) -> T,
+ {
+ self.editor.update(self.cx, update)
+ }
+
+ pub fn multibuffer<F, T>(&self, read: F) -> T
+ where
+ F: FnOnce(&MultiBuffer, &AppContext) -> T,
+ {
+ self.editor(|editor, cx| read(editor.buffer().read(cx), cx))
+ }
+
+ pub fn update_multibuffer<F, T>(&mut self, update: F) -> T
+ where
+ F: FnOnce(&mut MultiBuffer, &mut ModelContext<MultiBuffer>) -> 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<F, T>(&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<F, T>(&mut self, update: F) -> T
+ where
+ F: FnOnce(&mut Buffer, &mut ModelContext<Buffer>) -> 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<const COUNT: usize>(
+ &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<Range<usize>> {
+ 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<language::Anchor> {
+ 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<Tag: 'static>(&mut self, marked_text: &str) {
+ let expected_ranges = self.ranges(marked_text);
+ let actual_ranges: Vec<Range<usize>> = self.update_editor(|editor, cx| {
+ let snapshot = editor.snapshot(cx);
+ editor
+ .background_highlights
+ .get(&TypeId::of::<Tag>())
+ .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<Tag: ?Sized + 'static>(&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<Range<usize>> = snapshot
+ .highlight_ranges::<Tag>()
+ .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<Range<usize>>) {
+ 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<Range<usize>>,
+ expected_marked_text: String,
+ ) {
+ let actual_selections = self
+ .editor
+ .read_with(self.cx, |editor, cx| editor.selections.all::<usize>(cx))
+ .into_iter()
+ .map(|s| {
+ if s.reversed {
+ s.end..s.start
+ } else {
+ s.start..s.end
+ }
+ })
+ .collect::<Vec<_>>();
+ 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
+ }
+}
@@ -49,8 +49,8 @@ impl View for FileFinder {
"FileFinder"
}
- fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
- ChildView::new(self.picker.clone()).boxed()
+ fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+ ChildView::new(self.picker.clone(), cx).boxed()
}
fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
@@ -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 {
@@ -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 = []
@@ -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<str>) -> Arc<str> {
+ 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<Box<dyn io::Read>>;
async fn load(&self, path: &Path) -> Result<String>;
+ 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<PathBuf>;
async fn is_file(&self, path: &Path) -> bool;
@@ -42,6 +116,7 @@ pub trait Fs: Send + Sync {
path: &Path,
latency: Duration,
) -> Pin<Box<dyn Send + Stream<Item = Vec<fsevent::Event>>>>;
+ fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<SyncMutex<dyn GitRepository>>>;
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<lsp::CreateFileOptions> 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<lsp::RenameFileOptions> 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<lsp::DeleteFileOptions> 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<Arc<SyncMutex<dyn GitRepository>>> {
+ LibGitRepository::open(&dotgit_path)
+ .log_err()
+ .and_then::<Arc<SyncMutex<dyn GitRepository>>, _>(|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<String, Arc<Mutex<FakeFsEntry>>>,
+ git_repo_state: Option<Arc<SyncMutex<repository::FakeGitRepositoryState>>>,
},
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<PathBuf> {
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<Arc<SyncMutex<dyn GitRepository>>> {
+ 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
}
@@ -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<String>;
+}
+
+#[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<String> {
+ fn logic(repo: &LibGitRepository, relative_file_path: &Path) -> Result<Option<String>> {
+ 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<Mutex<FakeGitRepositoryState>>,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct FakeGitRepositoryState {
+ pub index_contents: HashMap<PathBuf, String>,
+}
+
+impl FakeGitRepository {
+ pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<Mutex<dyn GitRepository>> {
+ 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<String> {
+ let state = self.state.lock();
+ state.index_contents.get(path).cloned()
+ }
+}
@@ -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 = []
@@ -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<T> {
+ pub buffer_range: Range<T>,
+ pub head_byte_range: Range<usize>,
+}
+
+impl DiffHunk<u32> {
+ 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<Anchor> {
+ 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<Anchor>,
+}
+
+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<clock::Global>,
+ tree: SumTree<DiffHunk<Anchor>>,
+}
+
+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<u32>,
+ buffer: &'a BufferSnapshot,
+ ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+ 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<Item = DiffHunk<u32>> {
+ self.hunks_in_range(0..u32::MAX, text)
+ }
+
+ fn diff<'a>(head: &'a str, current: &'a str) -> Option<GitPatch<'a>> {
+ 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<Anchor> {
+ let line_item_count = patch.num_lines_in_hunk(hunk_index).unwrap();
+ assert!(line_item_count > 0);
+
+ let mut first_deletion_buffer_row: Option<u32> = None;
+ let mut buffer_row_range: Option<Range<u32>> = None;
+ let mut head_byte_range: Option<Range<usize>> = 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<Iter>(
+ diff_hunks: Iter,
+ buffer: &BufferSnapshot,
+ diff_base: &str,
+ expected_hunks: &[(Range<u32>, &str, &str)],
+) where
+ Iter: Iterator<Item = DiffHunk<u32>>,
+{
+ 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::<String>(),
+ )
+ })
+ .collect::<Vec<_>>();
+
+ 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"),
+ ],
+ );
+ }
+}
@@ -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");
+}
@@ -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(),
)
@@ -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"
@@ -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<RefCell<MutableAppContext>>);
#[derive(Clone)]
pub struct AsyncAppContext(Rc<RefCell<MutableAppContext>>);
-#[cfg(any(test, feature = "test-support"))]
-pub struct TestAppContext {
- cx: Rc<RefCell<MutableAppContext>>,
- foreground_platform: Rc<platform::test::ForegroundPlatform>,
- condition_duration: Option<Duration>,
-}
-
pub struct WindowInputHandler {
app: Rc<RefCell<MutableAppContext>>,
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::test::ForegroundPlatform>,
- platform: Arc<dyn Platform>,
- foreground: Rc<executor::Foreground>,
- background: Arc<executor::Background>,
- font_cache: Arc<FontCache>,
- leak_detector: Arc<Mutex<LeakDetector>>,
- 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<A: 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<A: 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<T, F>(&mut self, build_model: F) -> ModelHandle<T>
- where
- T: Entity,
- F: FnOnce(&mut ModelContext<T>) -> T,
- {
- self.cx.borrow_mut().add_model(build_model)
- }
-
- pub fn add_window<T, F>(&mut self, build_root_view: F) -> (usize, ViewHandle<T>)
- where
- T: View,
- F: FnOnce(&mut ViewContext<T>) -> 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<T, F>(
- &mut self,
- parent_handle: impl Into<AnyViewHandle>,
- build_view: F,
- ) -> ViewHandle<T>
- where
- T: View,
- F: FnOnce(&mut ViewContext<T>) -> T,
- {
- self.cx.borrow_mut().add_view(parent_handle, build_view)
- }
-
- pub fn window_ids(&self) -> Vec<usize> {
- self.cx.borrow().window_ids().collect()
- }
-
- pub fn root_view<T: View>(&self, window_id: usize) -> Option<ViewHandle<T>> {
- self.cx.borrow().root_view(window_id)
- }
-
- pub fn read<T, F: FnOnce(&AppContext) -> T>(&self, callback: F) -> T {
- callback(self.cx.borrow().as_ref())
- }
-
- pub fn update<T, F: FnOnce(&mut MutableAppContext) -> 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<F, V, T>(&mut self, handle: &ViewHandle<V>, f: F) -> T
- where
- F: FnOnce(&mut V, &mut RenderContext<V>) -> 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<FontCache> {
- self.cx.borrow().cx.font_cache.clone()
- }
-
- pub fn foreground_platform(&self) -> Rc<platform::test::ForegroundPlatform> {
- self.foreground_platform.clone()
- }
-
- pub fn platform(&self) -> Arc<dyn platform::Platform> {
- self.cx.borrow().cx.platform.clone()
- }
-
- pub fn foreground(&self) -> Rc<executor::Foreground> {
- self.cx.borrow().foreground().clone()
- }
-
- pub fn background(&self) -> Arc<executor::Background> {
- self.cx.borrow().background().clone()
- }
-
- pub fn spawn<F, Fut, T>(&self, f: F) -> Task<T>
- where
- F: FnOnce(AsyncAppContext) -> Fut,
- Fut: 'static + Future<Output = T>,
- 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<PathBuf>) {
- 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<String> {
- 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<usize>) {
- 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::<platform::test::Window>()
- .unwrap();
- handlers.insert(
- *window_id,
- mem::take(&mut window.active_status_change_handlers),
- );
- }
- };
- let mut handlers = handlers.into_iter().collect::<Vec<_>>();
- 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<Mutex<LeakDetector>> {
- 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<platform::test::Window> {
- 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::<platform::test::Window>()
- .unwrap();
- test_window
- })
- }
-
- pub fn set_condition_duration(&mut self, duration: Option<Duration>) {
- 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<F, Fut, T>(&self, f: F) -> Task<T>
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<usize> {
+ self.update(|cx| cx.prompt(window_id, level, msg, answers))
+ }
+
pub fn platform(&self) -> Arc<dyn Platform> {
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<T: Entity, O>(
- &mut self,
- handle: &ModelHandle<T>,
- update: &mut dyn FnMut(&mut T, &mut ModelContext<T>) -> O,
- ) -> O {
- self.cx.borrow_mut().update_model(handle, update)
- }
-}
-
-#[cfg(any(test, feature = "test-support"))]
-impl ReadModelWith for TestAppContext {
- fn read_model_with<E: Entity, T>(
- &self,
- handle: &ModelHandle<E>,
- 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<T, S>(
- &mut self,
- handle: &ViewHandle<T>,
- update: &mut dyn FnMut(&mut T, &mut ViewContext<T>) -> 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<V, T>(
- &self,
- handle: &ViewHandle<V>,
- 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<usize, (Rc<RefCell<Presenter>>, Box<dyn platform::Window>)>,
foreground: Rc<executor::Foreground>,
pending_effects: VecDeque<Effect>,
- pending_focus_index: Option<usize>,
pending_notifications: HashSet<usize>,
pending_global_notifications: HashSet<TypeId>,
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<G, F>(&mut self, observe: F) -> Subscription
+ where
+ G: Any + Default,
+ F: 'static + FnMut(&mut MutableAppContext),
+ {
+ if !self.has_global::<G>() {
+ self.set_global(G::default());
+ }
+ self.observe_global::<G, F>(observe)
+ }
+
pub fn observe_release<E, H, F>(&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<T, F>(&mut self, build_model: F) -> ModelHandle<T>
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<usize>) {
- 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<usize>) {
- 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<executor::Background> {
&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<AnyViewHandle>) -> 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<MouseButton>,
+ hovered: bool,
+ clicked: Option<MouseButton>,
+ 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<MouseButton> {
+ 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<T: Entity> ModelHandle<T> {
update(model, cx)
})
}
-
- #[cfg(any(test, feature = "test-support"))]
- pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> {
- 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<Output = T::Event>
- 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<Output = ()> {
- 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<T: Entity> Clone for ModelHandle<T> {
@@ -4650,6 +4225,12 @@ impl<T> PartialEq for WeakModelHandle<T> {
impl<T> Eq for WeakModelHandle<T> {}
+impl<T: Entity> PartialEq<ModelHandle<T>> for WeakModelHandle<T> {
+ fn eq(&self, other: &ModelHandle<T>) -> bool {
+ self.model_id == other.model_id
+ }
+}
+
impl<T> Clone for WeakModelHandle<T> {
fn clone(&self) -> Self {
Self {
@@ -4746,93 +4327,6 @@ impl<T: View> ViewHandle<T> {
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<Output = ()> {
- 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<Output = ()> {
- 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<T: View> Clone for ViewHandle<T> {
@@ -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<AnyViewHandle> {
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<Cell<bool>>,
+ dropped: Rc<Cell<bool>>,
+ }
+
+ 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<Self>) -> ElementBox {
+ self.rendered.set(true);
+ Empty::new().boxed()
+ }
+ }
+
+ impl Drop for Child {
+ fn drop(&mut self) {
+ self.dropped.set(true);
+ }
+ }
+
+ struct Parent {
+ child: Option<ViewHandle<Child>>,
+ }
+
+ 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<Self>) -> 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());
+ }
}
@@ -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<RefCell<MutableAppContext>>,
+ foreground_platform: Rc<platform::test::ForegroundPlatform>,
+ condition_duration: Option<Duration>,
+ pub function_name: String,
+ assertion_context: AssertionContextManager,
+}
+
+impl TestAppContext {
+ pub fn new(
+ foreground_platform: Rc<platform::test::ForegroundPlatform>,
+ platform: Arc<dyn Platform>,
+ foreground: Rc<executor::Foreground>,
+ background: Arc<executor::Background>,
+ font_cache: Arc<FontCache>,
+ leak_detector: Arc<Mutex<LeakDetector>>,
+ 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<A: 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<A: 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<T, F>(&mut self, build_model: F) -> ModelHandle<T>
+ where
+ T: Entity,
+ F: FnOnce(&mut ModelContext<T>) -> T,
+ {
+ self.cx.borrow_mut().add_model(build_model)
+ }
+
+ pub fn add_window<T, F>(&mut self, build_root_view: F) -> (usize, ViewHandle<T>)
+ where
+ T: View,
+ F: FnOnce(&mut ViewContext<T>) -> 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<T, F>(
+ &mut self,
+ parent_handle: impl Into<AnyViewHandle>,
+ build_view: F,
+ ) -> ViewHandle<T>
+ where
+ T: View,
+ F: FnOnce(&mut ViewContext<T>) -> T,
+ {
+ self.cx.borrow_mut().add_view(parent_handle, build_view)
+ }
+
+ pub fn window_ids(&self) -> Vec<usize> {
+ self.cx.borrow().window_ids().collect()
+ }
+
+ pub fn root_view<T: View>(&self, window_id: usize) -> Option<ViewHandle<T>> {
+ self.cx.borrow().root_view(window_id)
+ }
+
+ pub fn read<T, F: FnOnce(&AppContext) -> T>(&self, callback: F) -> T {
+ callback(self.cx.borrow().as_ref())
+ }
+
+ pub fn update<T, F: FnOnce(&mut MutableAppContext) -> 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<F, V, T>(&mut self, handle: &ViewHandle<V>, f: F) -> T
+ where
+ F: FnOnce(&mut V, &mut RenderContext<V>) -> 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<FontCache> {
+ self.cx.borrow().cx.font_cache.clone()
+ }
+
+ pub fn foreground_platform(&self) -> Rc<platform::test::ForegroundPlatform> {
+ self.foreground_platform.clone()
+ }
+
+ pub fn platform(&self) -> Arc<dyn platform::Platform> {
+ self.cx.borrow().cx.platform.clone()
+ }
+
+ pub fn foreground(&self) -> Rc<executor::Foreground> {
+ self.cx.borrow().foreground().clone()
+ }
+
+ pub fn background(&self) -> Arc<executor::Background> {
+ self.cx.borrow().background().clone()
+ }
+
+ pub fn spawn<F, Fut, T>(&self, f: F) -> Task<T>
+ where
+ F: FnOnce(AsyncAppContext) -> Fut,
+ Fut: 'static + Future<Output = T>,
+ 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<PathBuf>) {
+ 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<String> {
+ 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<usize>) {
+ 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::<platform::test::Window>()
+ .unwrap();
+ handlers.insert(
+ *window_id,
+ mem::take(&mut window.active_status_change_handlers),
+ );
+ }
+ };
+ let mut handlers = handlers.into_iter().collect::<Vec<_>>();
+ 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<Mutex<LeakDetector>> {
+ 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<platform::test::Window> {
+ 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::<platform::test::Window>()
+ .unwrap();
+ test_window
+ })
+ }
+
+ pub fn set_condition_duration(&mut self, duration: Option<Duration>) {
+ 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<T: Entity, O>(
+ &mut self,
+ handle: &ModelHandle<T>,
+ update: &mut dyn FnMut(&mut T, &mut ModelContext<T>) -> O,
+ ) -> O {
+ self.cx.borrow_mut().update_model(handle, update)
+ }
+}
+
+impl ReadModelWith for TestAppContext {
+ fn read_model_with<E: Entity, T>(
+ &self,
+ handle: &ModelHandle<E>,
+ 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<T, S>(
+ &mut self,
+ handle: &ViewHandle<T>,
+ update: &mut dyn FnMut(&mut T, &mut ViewContext<T>) -> S,
+ ) -> S
+ where
+ T: View,
+ {
+ self.cx.borrow_mut().update_view(handle, update)
+ }
+}
+
+impl ReadViewWith for TestAppContext {
+ fn read_view_with<V, T>(
+ &self,
+ handle: &ViewHandle<V>,
+ 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<T: Entity> ModelHandle<T> {
+ pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> {
+ 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<Output = T::Event>
+ 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<Output = ()> {
+ 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<T: View> ViewHandle<T> {
+ pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> {
+ 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<Output = ()> {
+ 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<AtomicUsize>,
+ contexts: Arc<RwLock<BTreeMap<usize, String>>>,
+}
+
+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);
+ }
+}
@@ -271,9 +271,6 @@ impl<T: Element> AnyElement for Lifecycle<T> {
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<T: Element> AnyElement for Lifecycle<T> {
..
} => {
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,
@@ -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<usize>,
- scroll_position: f32,
+ scroll_to: Cell<Option<usize>>,
+ scroll_position: Cell<f32>,
}
pub struct Flex {
axis: Axis,
children: Vec<ElementBox>,
- scroll_state: Option<ElementStateHandle<ScrollState>>,
+ scroll_state: Option<(ElementStateHandle<Rc<ScrollState>>, usize)>,
}
impl Flex {
@@ -52,9 +51,9 @@ impl Flex {
Tag: 'static,
V: View,
{
- let scroll_state = cx.default_element_state::<Tag, ScrollState>(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::<Tag, Rc<ScrollState>>(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::<Self>(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
}
@@ -27,6 +27,8 @@ pub struct ImageStyle {
pub height: Option<f32>,
#[serde(default)]
pub width: Option<f32>,
+ #[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(),
});
}
@@ -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::<Self>(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 {
@@ -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<Tag: 'static> {
cursor_style: Option<CursorStyle>,
handlers: HandlerSet,
hoverable: bool,
+ notify_on_hover: bool,
+ notify_on_click: bool,
padding: Padding,
_tag: PhantomData<Tag>,
}
@@ -29,13 +32,19 @@ impl<Tag> MouseEventHandler<Tag> {
pub fn new<V, F>(region_id: usize, cx: &mut RenderContext<V>, render_child: F) -> Self
where
V: View,
- F: FnOnce(MouseState, &mut RenderContext<V>) -> ElementBox,
+ F: FnOnce(&mut MouseState, &mut RenderContext<V>) -> ElementBox,
{
+ let mut mouse_state = cx.mouse_state::<Tag>(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::<Tag>(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<Tag> MouseEventHandler<Tag> {
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<Tag> Element for MouseEventHandler<Tag> {
_: &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<Tag> Element for MouseEventHandler<Tag> {
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);
@@ -14,6 +14,7 @@ pub struct Overlay {
anchor_position: Option<Vector2F>,
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();
}
@@ -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<Box<dyn Action>>,
@@ -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<usize>,
+ 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>(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
}
@@ -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 })
}
@@ -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<PathBuf>;
fn app_path(&self) -> Result<PathBuf>;
fn app_version(&self) -> Result<AppVersion>;
+ fn os_name(&self) -> &'static str;
+ fn os_version(&self) -> Result<AppVersion>;
}
pub(crate) trait ForegroundPlatform {
@@ -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<str> {
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()
}
}
@@ -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<crate::AppVersion> {
+ 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 {
@@ -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);
@@ -90,6 +90,7 @@ typedef struct {
float border_left;
vector_uchar4 border_color;
float corner_radius;
+ uint8_t grayscale;
} GPUIImage;
typedef enum {
@@ -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,
};
}
@@ -34,11 +34,11 @@ pub struct ForegroundPlatform {
struct Dispatcher;
pub struct Window {
- size: Vector2F,
+ pub(crate) size: Vector2F,
scale_factor: f32,
current_scene: Option<crate::Scene>,
event_handlers: Vec<Box<dyn FnMut(super::Event) -> bool>>,
- resize_handlers: Vec<Box<dyn FnMut()>>,
+ pub(crate) resize_handlers: Vec<Box<dyn FnMut()>>,
close_handlers: Vec<Box<dyn FnOnce()>>,
fullscreen_handlers: Vec<Box<dyn FnMut(bool)>>,
pub(crate) active_status_change_handlers: Vec<Box<dyn FnMut(bool)>>,
@@ -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<AppVersion> {
+ Ok(AppVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ })
+ }
}
impl Window {
@@ -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<usize> = Default::default();
+ let mut notified_views: HashSet<usize> = 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,14 +387,28 @@ 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());
}
}
}
@@ -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<usize>,
+ notified_views: &'a mut HashSet<usize>,
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<usize>,
handled: bool,
- invalidated_views: &'a mut HashSet<usize>,
+ notified_views: &'a mut HashSet<usize>,
}
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<AnyViewHandle>) -> Self {
- Self { view: view.into() }
+ pub fn new(view: impl Into<AnyViewHandle>, 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<usize>,
_: RectF,
_: RectF,
- _: &Self::LayoutState,
+ view_is_valid: &Self::LayoutState,
_: &Self::PaintState,
cx: &MeasurementContext,
) -> Option<RectF> {
- 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 {
@@ -172,6 +172,7 @@ pub struct Image {
pub bounds: RectF,
pub border: Border,
pub corner_radius: f32,
+ pub grayscale: bool,
pub data: Arc<ImageData>,
}
@@ -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)]
@@ -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 {
@@ -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,
)
})
@@ -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(),
);
}
}
@@ -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"
@@ -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<AppState>, cx: &mut MutableAppContext) {
}
pub fn new_journal_entry(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
- let now = Local::now();
- let home_dir = match dirs::home_dir() {
- Some(home_dir) => home_dir,
+ let settings = cx.global::<Settings>();
+ 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<AppState>, 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<AppState>, cx: &mut MutableAppContext) {
})
.detach();
}
+
+fn journal_dir(settings: &Settings) -> Option<PathBuf> {
+ 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<HourFormat>) -> 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);
+ }
+ }
+}
@@ -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 = "*"
@@ -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<String>,
+ git_diff_status: GitDiffStatus,
file: Option<Arc<dyn File>>,
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<String>,
completion_triggers_timestamp: clock::Lamport,
deferred_ops: OperationQueue<Operation>,
@@ -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<Arc<dyn File>>,
diagnostics: DiagnosticSet,
diagnostics_update_count: usize,
file_update_count: usize,
+ git_diff_update_count: usize,
remote_selections: TreeMap<ReplicaId, SelectionSet>,
selections_update_count: usize,
language: Option<Arc<Language>>,
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<AutoindentRequestEntry>,
- 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<u32>,
}
@@ -267,7 +282,7 @@ struct BufferChunkHighlights<'a> {
pub struct BufferChunks<'a> {
range: Range<usize>,
- chunks: rope::Chunks<'a>,
+ chunks: text::Chunks<'a>,
diagnostic_endpoints: Peekable<vec::IntoIter<DiagnosticEndpoint>>,
error_depth: usize,
warning_depth: usize,
@@ -288,10 +303,8 @@ pub struct Chunk<'a> {
pub struct Diff {
base_version: clock::Global,
- new_text: Arc<str>,
- changes: Vec<(ChangeTag, usize)>,
line_ending: LineEnding,
- start_offset: usize,
+ edits: Vec<(Range<usize>, Arc<str>)>,
}
#[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<T: Into<String>>(
replica_id: ReplicaId,
base_text: T,
+ diff_base: Option<T>,
file: Arc<dyn File>,
cx: &mut ModelContext<Self>,
) -> 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<Arc<dyn File>>,
) -> Result<Self> {
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<Arc<dyn File>>) -> Self {
+ fn build(buffer: TextBuffer, diff_base: Option<String>, file: Option<Arc<dyn File>>) -> 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<String>, cx: &mut ModelContext<Self>) {
+ 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<Self>) {
+ 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<Self>) {
cx.emit(Event::Closed);
}
@@ -641,6 +726,16 @@ impl Buffer {
self.language.as_ref()
}
+ pub fn language_at<D: ToOffset>(&self, position: D) -> Option<Arc<Language>> {
+ 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::<u32, IndentSize>::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::<Vec<_>>();
+ 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<str> = "".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<T: ToOffset>(&self, position: T, cx: &AppContext) -> IndentSize {
+ let language_name = self.language_at(position).map(|language| language.name());
let settings = cx.global::<Settings>();
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<Language>> {
- self.language.as_ref()
+ pub fn language_at<D: ToOffset>(&self, position: D) -> Option<&Arc<Language>> {
+ 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<T: ToOffset>(&self, start: T) -> (Range<usize>, Option<CharKind>) {
@@ -1856,8 +1981,8 @@ impl BufferSnapshot {
pub fn range_for_syntax_ancestor<T: ToOffset>(&self, range: Range<T>) -> Option<Range<usize>> {
let range = range.start.to_offset(self)..range.end.to_offset(self);
let mut result: Option<Range<usize>> = 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<u32>,
+ ) -> impl 'a + Iterator<Item = git::diff::DiffHunk<u32>> {
+ self.git_diff.hunks_in_range(query_row_range, self)
+ }
+
pub fn diagnostics_in_range<'a, T, O>(
&'a self,
search_range: Range<T>,
@@ -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,
}
@@ -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(
+ &"
+ <div>ˇ
+ </div>
+ <script>
+ init({ˇ
+ })
+ </script>
+ <span>ˇ
+ </span>
+ "
+ .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(),
+ "
+ <div>
+ a
+ </div>
+ <script>
+ init({
+ a
+ })
+ </script>
+ <span>
+ a
+ </span>
+ "
+ .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<Buffer>, 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()
})
}
@@ -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<Language>,
) -> Option<CodeLabel> {
self.adapter
.label_for_completion(completion_item, language)
@@ -146,7 +148,7 @@ impl CachedLspAdapter {
&self,
name: &str,
kind: lsp::SymbolKind,
- language: &Language,
+ language: &Arc<Language>,
) -> Option<CodeLabel> {
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<Language>,
) -> Option<CodeLabel> {
None
}
@@ -184,7 +186,7 @@ pub trait LspAdapter: 'static + Send + Sync {
&self,
_: &str,
_: lsp::SymbolKind,
- _: &Language,
+ _: &Arc<Language>,
) -> Option<CodeLabel> {
None
}
@@ -230,7 +232,10 @@ pub struct LanguageConfig {
pub decrease_indent_pattern: Option<Regex>,
#[serde(default)]
pub autoclose_before: String,
- pub line_comment: Option<String>,
+ #[serde(default)]
+ pub line_comment: Option<Arc<str>>,
+ #[serde(default)]
+ pub block_comment: Option<(Arc<str>, Arc<str>)>,
}
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<String>,
}
-#[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<u32>,
end_capture_ix: Option<u32>,
}
@@ -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<str>> {
+ self.config.line_comment.as_ref()
+ }
+
+ pub fn block_comment_delimiters(&self) -> Option<(&Arc<str>, &Arc<str>)> {
+ 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<Self>,
completion: &lsp::CompletionItem,
) -> Option<CodeLabel> {
self.adapter
@@ -798,7 +815,11 @@ impl Language {
.await
}
- pub async fn label_for_symbol(&self, name: &str, kind: lsp::SymbolKind) -> Option<CodeLabel> {
+ pub async fn label_for_symbol(
+ self: &Arc<Self>,
+ name: &str,
+ kind: lsp::SymbolKind,
+ ) -> Option<CodeLabel> {
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<Self>,
text: &'a Rope,
range: Range<usize>,
) -> Vec<(Range<usize>, 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
@@ -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,
}
}
@@ -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<Language>,
}
+#[derive(Debug)]
+pub struct SyntaxLayerInfo<'a> {
+ pub depth: usize,
+ pub node: Node<'a>,
+ pub language: &'a Arc<Language>,
+}
+
#[derive(Debug, Clone)]
struct SyntaxLayerSummary {
min_depth: usize,
@@ -127,7 +134,7 @@ struct ChangeRegionSet(Vec<ChangedRegion>);
struct TextProvider<'a>(&'a Rope);
-struct ByteChunks<'a>(rope::Chunks<'a>);
+struct ByteChunks<'a>(text::Chunks<'a>);
struct QueryCursorHandle(Option<QueryCursor>);
@@ -473,13 +480,18 @@ impl SyntaxSnapshot {
range: Range<usize>,
text: &'a Rope,
tree: &'a Tree,
- grammar: &'a Grammar,
+ language: &'a Arc<Language>,
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<SyntaxLayerInfo> {
+ self.layers_for_range(0..buffer.len(), buffer).collect()
}
pub fn layers_for_range<'a, T: ToOffset>(
- &self,
+ &'a self,
range: Range<T>,
- buffer: &BufferSnapshot,
- ) -> Vec<(&Grammar, usize, Node)> {
+ buffer: &'a BufferSnapshot,
+ ) -> impl 'a + Iterator<Item = SyntaxLayerInfo> {
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<usize>,
text: &'a Rope,
- layers: impl Iterator<Item = (&'a Grammar, usize, Node<'a>)>,
+ layers: impl Iterator<Item = SyntaxLayerInfo<'a>>,
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<usize>,
text: &'a Rope,
- layers: impl Iterator<Item = (&'a Grammar, usize, Node<'a>)>,
+ layers: impl Iterator<Item = SyntaxLayerInfo<'a>>,
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<Point>,
expected_layers: &[&str],
) {
- let layers = syntax_map.layers_for_range(range, &buffer);
+ let layers = syntax_map
+ .layers_for_range(range, &buffer)
+ .collect::<Vec<_>>();
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();
@@ -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<T> {
+ jsonrpc: &'static str,
id: usize,
result: Option<T>,
error: Option<Error>,
@@ -80,8 +81,7 @@ struct Response<T> {
#[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 {
@@ -48,8 +48,8 @@ impl View for OutlineView {
"OutlineView"
}
- fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
- ChildView::new(self.picker.clone()).boxed()
+ fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+ ChildView::new(self.picker.clone(), cx).boxed()
}
fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
@@ -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 {
@@ -19,6 +19,7 @@ pub struct Picker<D: PickerDelegate> {
query_editor: ViewHandle<Editor>,
list_state: UniformListState,
max_size: Vector2F,
+ theme: Box<dyn FnMut(&AppContext) -> &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<D: PickerDelegate> View for Picker<D> {
}
fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
- let settings = cx.global::<Settings>();
- 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<D: PickerDelegate> View for Picker<D> {
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<D: PickerDelegate> Picker<D> {
list_state: Default::default(),
delegate,
max_size: vec2f(540., 420.),
+ theme: Box::new(|cx| &cx.global::<Settings>().theme.picker),
confirmed: false,
};
cx.defer(|this, cx| {
@@ -163,6 +162,14 @@ impl<D: PickerDelegate> Picker<D> {
self
}
+ pub fn with_theme<F>(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)
}
@@ -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"] }
@@ -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<Db>,
projects: Vec<WeakModelHandle<Project>>,
}
@@ -108,7 +104,7 @@ pub struct Project {
user_store: ModelHandle<UserStore>,
project_store: ModelHandle<ProjectStore>,
fs: Arc<dyn Fs>,
- client_state: ProjectClientState,
+ client_state: Option<ProjectClientState>,
collaborators: HashMap<PeerId, Collaborator>,
client_subscriptions: Vec<client::Subscription>,
_subscriptions: Vec<gpui::Subscription>,
@@ -125,8 +121,8 @@ pub struct Project {
opened_buffers: HashMap<u64, OpenBuffer>,
incomplete_buffers: HashMap<u64, ModelHandle<Buffer>>,
buffer_snapshots: HashMap<u64, Vec<(i32, TextBufferSnapshot)>>,
+ buffers_being_formatted: HashSet<usize>,
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<Option<u64>>,
- remote_id_rx: watch::Receiver<Option<u64>>,
- online_tx: watch::Sender<bool>,
- online_rx: watch::Receiver<bool>,
- _maintain_remote_id: Task<Option<()>>,
- _maintain_online_status: Task<Option<()>>,
+ remote_id: u64,
+ _detect_unshare: Task<Option<()>>,
},
Remote {
sharing_has_stopped: bool,
@@ -173,7 +164,6 @@ enum ProjectClientState {
#[derive(Clone, Debug)]
pub struct Collaborator {
- pub user: Arc<User>,
pub peer_id: PeerId,
pub replica_id: ReplicaId,
}
@@ -196,8 +186,6 @@ pub enum Event {
RemoteIdChanged(Option<u64>),
DisconnectedFromHost,
CollaboratorLeft(PeerId),
- ContactRequestedJoin(Arc<User>),
- ContactCancelledJoinRequest(Arc<User>),
}
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>) {
- 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<Client>,
user_store: ModelHandle<UserStore>,
project_store: ModelHandle<ProjectStore>,
@@ -417,43 +418,6 @@ impl Project {
cx: &mut MutableAppContext,
) -> ModelHandle<Self> {
cx.add_model(|cx: &mut ModelContext<Self>| {
- 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::<Settings, _>(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<Self>) -> Task<Result<()>> {
- 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::<Settings>().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<Self>) -> Task<Result<()>> {
- 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<Self>) {
let settings = cx.global::<Settings>();
@@ -844,208 +734,67 @@ impl Project {
&self.fs
}
- pub fn set_online(&mut self, online: bool, _: &mut ModelContext<Self>) {
- 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 remote_id(&self) -> Option<u64> {
+ match self.client_state.as_ref()? {
+ ProjectClientState::Local { remote_id, .. }
+ | ProjectClientState::Remote { remote_id, .. } => Some(*remote_id),
}
}
- pub fn is_online(&self) -> bool {
+ pub fn replica_id(&self) -> ReplicaId {
match &self.client_state {
- ProjectClientState::Local { online_rx, .. } => *online_rx.borrow(),
- ProjectClientState::Remote { .. } => true,
+ Some(ProjectClientState::Remote { replica_id, .. }) => *replica_id,
+ _ => 0,
}
}
- fn unregister(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
- 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;
+ fn metadata_changed(&mut self, cx: &mut ModelContext<Self>) {
+ 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 = 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();
- 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(()))
- }
+ let worktrees = self.visible_worktrees(cx).collect::<Vec<_>>();
+ let scans_complete = futures::future::join_all(
+ worktrees
+ .iter()
+ .filter_map(|worktree| Some(worktree.read(cx).as_local()?.scan_complete())),
+ );
- fn register(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
- if let ProjectClientState::Local {
- remote_id_rx,
- online_rx,
- ..
- } = &self.client_state
- {
- if remote_id_rx.borrow().is_some() {
- return Task::ready(Ok(()));
- }
+ let worktrees = worktrees.into_iter().map(|handle| handle.downgrade());
- 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);
+ 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);
+ }
}
-
- 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<u64> {
- 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<Output = u64> {
- 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<u64> {
- 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),
- }
- }
-
- pub fn replica_id(&self) -> ReplicaId {
- match &self.client_state {
- ProjectClientState::Local { .. } => 0,
- ProjectClientState::Remote { replica_id, .. } => *replica_id,
+ .detach();
}
- }
- fn metadata_changed(&mut self, persist: bool, cx: &mut ModelContext<Self>) {
- if let ProjectClientState::Local {
- remote_id_rx,
- online_rx,
- ..
- } = &self.client_state
- {
- // Broadcast worktrees only if the project is online.
- let worktrees = if *online_rx.borrow() {
- self.worktrees
- .iter()
- .filter_map(|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::<Vec<_>>();
- 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();
- }
+ self.project_store.update(cx, |_, cx| cx.notify());
+ cx.notify();
}
pub fn collaborators(&self) -> &HashMap<PeerId, Collaborator> {
@@ -1081,23 +830,6 @@ impl Project {
.map(|tree| tree.read(cx).root_name())
}
- fn db_keys_for_online_state(&self, cx: &AppContext) -> Vec<String> {
- 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::<Vec<_>>()
- }
-
pub fn worktree_for_id(
&self,
id: WorktreeId,
@@ -1301,30 +1033,12 @@ impl Project {
}
}
- fn share(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
- 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<Self>) -> Task<Result<()>> {
+ 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::<Vec<_>>() {
- 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::<Vec<_>>() {
+ 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<Self>) {
- if let ProjectClientState::Local { is_shared, .. } = &mut self.client_state {
- if !*is_shared {
- return;
- }
+ pub fn unshare(&mut self, cx: &mut ModelContext<Self>) -> 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<Self>,
- ) {
- 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<Self>) {
- 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<ModelHandle<Buffer>>,
push_to_history: bool,
+ trigger: FormatTrigger,
cx: &mut ModelContext<Project>,
) -> Task<Result<ProjectTransaction>> {
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::<Settings>();
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<Worktree>, cx: &mut ModelContext<Self>) {
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();
@@ -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<Deterministic>,
+ 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(),
&[
@@ -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<Mutex<dyn GitRepository>>,
+
+ pub(crate) scan_id: usize,
+ // Path to folder containing the .git file or directory
+ pub(crate) content_path: Arc<Path>,
+ // 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<Path>,
+}
+
+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<Path>,
ignores_by_parent_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, usize)>,
+ git_repositories: Vec<GitRepositoryEntry>,
removed_entry_ids: HashMap<u64, ProjectEntryId>,
next_entry_id: Arc<AtomicUsize>,
snapshot: Snapshot,
extension_counts: HashMap<OsString, usize>,
}
+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<GitRepositoryEntry>),
}
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<Result<ModelHandle<Buffer>>> {
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<Worktree>) {
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<GitRepositoryEntry> {
+ 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<Output = ()> {
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<Worktree>) -> Task<Result<(File, String)>> {
+ fn load(
+ &self,
+ path: &Path,
+ cx: &mut ModelContext<Worktree>,
+ ) -> Task<Result<(File, String, Option<String>)>> {
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<GitRepositoryEntry> {
+ 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<Path>,
entries: impl IntoIterator<Item = Entry>,
ignore: Option<Arc<Gitignore>>,
+ 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<Path> = 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<Gitignore> {
@@ -1634,8 +1815,9 @@ pub struct File {
pub worktree: ModelHandle<Worktree>,
pub path: Arc<Path>,
pub mtime: SystemTime,
- pub(crate) entry_id: Option<ProjectEntryId>,
+ 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<ProjectEntryId> {
- 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<Client>, 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<Path>, 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<GitRepositoryEntry> = vec![
+ fake_entry("/.git", 0),
+ fake_entry("/a/.git", 0),
+ fake_entry("/a/b/.git", 0),
+ ];
+
+ let new_repos: Vec<GitRepositoryEntry> = 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),
@@ -1012,7 +1012,7 @@ impl ProjectPanel {
) -> ElementBox {
let kind = details.kind;
let show_editor = details.is_editing && !details.is_processing;
- MouseEventHandler::<Self>::new(entry_id.to_usize(), cx, |state, _| {
+ MouseEventHandler::<Self>::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()
}
@@ -47,8 +47,8 @@ impl View for ProjectSymbolsView {
"ProjectSymbolsView"
}
- fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
- ChildView::new(self.picker.clone()).boxed()
+ fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+ ChildView::new(self.picker.clone(), cx).boxed()
}
fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
@@ -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 {
@@ -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"] }
@@ -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<u32>) -> 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]
@@ -10,104 +10,116 @@ message Envelope {
Error error = 5;
Ping ping = 6;
Test test = 7;
-
- 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;
-
- 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;
-
- UpdateProject update_project = 32;
- RegisterProjectActivity register_project_activity = 33;
- UpdateWorktree update_worktree = 34;
- UpdateWorktreeExtensions update_worktree_extensions = 35;
-
- 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;
-
- UpdateDiagnosticSummary update_diagnostic_summary = 41;
- StartLanguageServer start_language_server = 42;
- UpdateLanguageServer update_language_server = 43;
-
- 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;
-
- 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;
-
- UpdateContacts update_contacts = 83;
- UpdateInviteInfo update_invite_info = 84;
- ShowContacts show_contacts = 85;
-
- 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;
-
- Follow follow = 92;
- FollowResponse follow_response = 93;
- UpdateFollowers update_followers = 94;
- Unfollow unfollow = 95;
+
+ 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;
+
+ 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 = 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 = 40;
+ RegisterProjectActivity register_project_activity = 41;
+ UpdateWorktree update_worktree = 42;
+ UpdateWorktreeExtensions update_worktree_extensions = 43;
+
+ 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 = 49;
+ StartLanguageServer start_language_server = 50;
+ UpdateLanguageServer update_language_server = 51;
+
+ 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 = 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 = 91;
+ UpdateInviteInfo update_invite_info = 92;
+ ShowContacts show_contacts = 93;
+
+ 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 = 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,42 +137,110 @@ message Test {
uint64 id = 1;
}
-message RegisterProject {
- bool online = 1;
+message CreateRoom {}
+
+message CreateRoomResponse {
+ uint64 id = 1;
}
-message RegisterProjectResponse {
- uint64 project_id = 1;
+message JoinRoom {
+ uint64 id = 1;
}
-message UnregisterProject {
- uint64 project_id = 1;
+message JoinRoomResponse {
+ Room room = 1;
}
-message UpdateProject {
- uint64 project_id = 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;
- bool online = 3;
}
-message RegisterProjectActivity {
+message ShareProjectResponse {
uint64 project_id = 1;
}
-message RequestJoinProject {
- uint64 requester_id = 1;
- uint64 project_id = 2;
+message UnshareProject {
+ uint64 project_id = 1;
}
-message RespondToJoinProjectRequest {
- uint64 requester_id = 1;
- uint64 project_id = 2;
- bool allow = 3;
+message UpdateProject {
+ uint64 project_id = 1;
+ repeated WorktreeMetadata worktrees = 2;
}
-message JoinProjectRequestCancelled {
- uint64 requester_id = 1;
- uint64 project_id = 2;
+message RegisterProjectActivity {
+ uint64 project_id = 1;
}
message JoinProject {
@@ -168,27 +248,10 @@ message JoinProject {
}
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;
+}
@@ -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<F, Fut, Out>(
+ pub fn add_connection<F, Fut, Out>(
self: &Arc<Self>,
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<Self>,
connection: Connection,
executor: Arc<gpui::executor::Background>,
@@ -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 })
@@ -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);
@@ -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;
@@ -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)
@@ -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)
@@ -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"] }
@@ -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<Arc<str>, 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<GitGutter>,
+ pub gutter_debounce: Option<u64>,
+}
+
+#[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<NonZeroU32>,
pub hard_tabs: Option<bool>,
pub soft_wrap: Option<SoftWrap>,
pub preferred_line_length: Option<u32>,
pub format_on_save: Option<FormatOnSave>,
+ pub formatter: Option<Formatter>,
pub enable_language_server: Option<bool>,
}
-#[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<String>,
+ },
+}
+
+#[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<String>,
+ pub hour_format: Option<HourFormat>,
+}
+
+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<Shell>,
pub working_directory: Option<WorkingDirectory>,
@@ -100,9 +162,10 @@ pub struct TerminalSettings {
pub blinking: Option<TerminalBlink>,
pub alternate_scroll: Option<AlternateScroll>,
pub option_as_meta: Option<bool>,
+ pub copy_on_select: Option<bool>,
}
-#[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<FeatureFlags>,
#[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<GitSettings>,
+ #[serde(default)]
#[serde(alias = "language_overrides")]
pub languages: HashMap<Arc<str>, EditorSettings>,
#[serde(default)]
@@ -195,7 +262,7 @@ pub struct SettingsFileContent {
pub staff_mode: Option<bool>,
}
-#[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<Value>,
@@ -207,6 +274,7 @@ impl Settings {
font_cache: &FontCache,
themes: &ThemeRegistry,
) -> Self {
+ #[track_caller]
fn required<T>(value: Option<T>) -> Option<T> {
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<T: Copy>(target: &mut T, value: Option<T>) {
if let Some(value) = value {
*target = value;
@@ -459,3 +649,114 @@ pub fn parse_json_with_comments<T: DeserializeOwned>(content: &str) -> Result<T>
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)
+ }
+}
@@ -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<T>(pub watch::Receiver<T>);
+pub struct SettingsFile {
+ path: &'static Path,
+ settings_file_content: WatchedJsonFile<SettingsFileContent>,
+ fs: Arc<dyn Fs>,
+}
-impl<T> WatchedJsonFile<T>
-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<SettingsFileContent>,
fs: Arc<dyn Fs>,
- executor: &executor::Background,
- path: impl Into<Arc<Path>>,
) -> 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::<SettingsFile>();
+
+ let current_file_content = this.settings_file_content.current();
+ let mut new_file_content = current_file_content.clone();
+
+ update(&mut new_file_content);
+
+ let fs = this.fs.clone();
+ let path = this.path.clone();
+
+ cx.background()
.spawn(async move {
- 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"
+ );
}
- }
- }
- })
- .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<dyn Fs>, path: &Path) -> Option<T> {
- if !fs.is_file(path).await {
- return Some(T::default());
- }
+ let new_json = serde_json::to_string_pretty(new_value)
+ .expect("Could not serialize new json field to string");
- 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()
- }
- })
- }
-}
+ diffs.push((key, new_json));
+ }
+ }
-pub fn watch_settings_file(
- defaults: Settings,
- mut file: WatchedJsonFile<SettingsFileContent>,
- theme_registry: Arc<ThemeRegistry>,
- 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();
-}
+ // Have diffs, rewrite the settings file now.
+ let mut content = fs.load(path).await?;
-pub fn keymap_updated(content: KeymapFileContent, cx: &mut MutableAppContext) {
- cx.clear_bindings();
- settings::KeymapFileContent::load_defaults(cx);
- content.add_to_cx(cx).log_err();
-}
+ for (key, new_value) in diffs {
+ content = write_top_level_setting(content, key, &new_value)
+ }
-pub fn settings_updated(
- defaults: &Settings,
- content: SettingsFileContent,
- theme_registry: &Arc<ThemeRegistry>,
- 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();
-}
+ fs.atomic_write(path.to_path_buf(), content).await?;
-pub fn watch_keymap_file(mut file: WatchedJsonFile<KeymapFileContent>, 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();
+ Ok(()) as Result<()>
+ })
+ .detach_and_log_err(cx);
+ }
}
#[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) {
@@ -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<T>(pub watch::Receiver<T>);
+
+impl<T> WatchedJsonFile<T>
+where
+ T: 'static + for<'de> Deserialize<'de> + Clone + Default + Send + Sync,
+{
+ pub async fn new(
+ fs: Arc<dyn Fs>,
+ executor: &executor::Background,
+ path: impl Into<Arc<Path>>,
+ ) -> 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<dyn Fs>, path: &Path) -> Option<T> {
+ 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<SettingsFileContent>,
+ theme_registry: Arc<ThemeRegistry>,
+ 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<ThemeRegistry>,
+ 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<KeymapFileContent>, 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();
+}
@@ -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<Ordering> {
Some(self.cmp(other))
@@ -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"
@@ -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(
@@ -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<PathBuf>,
shell: Option<Shell>,
env: Option<HashMap<String, String>>,
- initial_size: TerminalSize,
blink_settings: Option<TerminalBlink>,
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<SelectionRange>,
cursor: RenderableCursor,
cursor_char: char,
+ size: TerminalSize,
+ last_hovered_hyperlink: Option<(String, RangeInclusive<Point>, 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<FairMutex<Term<ZedListener>>>,
events: VecDeque<InternalEvent>,
+ /// 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<Vector2F>,
pub matches: Vec<RangeInclusive<Point>>,
- cur_size: TerminalSize,
last_content: TerminalContent,
last_synced: Instant,
sync_task: Option<Task<()>>,
@@ -485,6 +517,8 @@ pub struct Terminal {
shell_fd: u32,
foreground_process_info: Option<LocalProcessInfo>,
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::<Vec<(usize, Cell)>>();
+
+ 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<Point>, usize)>,
+ url: String,
+ url_match: RangeInclusive<Point>,
+ ) {
+ 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<Self>) {
@@ -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<ZedListener>) -> TerminalContent {
+ fn make_content(term: &Term<ZedListener>, 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);
-
- 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);
+ 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);
+
+ 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<Vector2F>) {
+ 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<f32> {
//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<Self>) {
+ let settings = cx.global::<Settings>();
+ 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<T>(term: &Term<T>, point: Point, regex: &RegexSearch) -> Option<Match> {
+ 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<T>,
+ regex: &'a RegexSearch,
+) -> impl Iterator<Item = Match> + '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<Point>) -> 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<T>,
-// regex: &'a RegexSearch,
-// ) -> impl Iterator<Item = Match> + '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<T>,
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]
+ );
+ }
}
@@ -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>,
) -> 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::<Settings>();
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()
}
@@ -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<ElementBox>,
}
///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<Point>)>,
) -> (Vec<LayoutCell>, Vec<LayoutRect>) {
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<Point>)>,
) -> 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<E>(
@@ -360,7 +383,7 @@ impl TerminalElement {
) {
let connection = self.terminal;
- let mut region = MouseRegion::new::<Self>(view_id, view_id, visible_bounds);
+ let mut region = MouseRegion::new::<Self>(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::<TerminalElement, _>(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<Point>,
layout: &LayoutState,
@@ -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<Self>) {
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<Self>) {
- self.clear_bel(cx);
- self.terminal.update(cx, |term, _| {
- term.try_keystroke(&Keystroke::parse("up").unwrap(), false)
- });
- }
-
- ///Synthesize the keyboard event corresponding to 'down'
- fn down(&mut self, _: &Down, cx: &mut ViewContext<Self>) {
- 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>) {
- 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>) {
+ fn send_text(&mut self, text: &SendText, cx: &mut ViewContext<Self>) {
self.clear_bel(cx);
self.terminal.update(cx, |term, _| {
- term.try_keystroke(&Keystroke::parse("escape").unwrap(), false)
+ term.input(text.0.to_string());
});
}
- ///Synthesize the keyboard event corresponding to 'enter'
- fn enter(&mut self, _: &Enter, cx: &mut ViewContext<Self>) {
- 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<Self>) {
+ 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::<Settings>()
+ .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>) {
- self.terminal.read(cx).focus_out();
+ self.terminal.update(cx, |terminal, _| {
+ terminal.focus_out();
+ });
cx.notify();
}
@@ -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<Vec<char>>) {
+ 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> {
@@ -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"
@@ -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,
@@ -1,36 +0,0 @@
-use rand::prelude::*;
-
-pub struct RandomCharIter<T: Rng>(T);
-
-impl<T: Rng> RandomCharIter<T> {
- pub fn new(rng: T) -> Self {
- Self(rng)
- }
-}
-
-impl<T: Rng> Iterator for RandomCharIter<T> {
- type Item = char;
-
- fn next(&mut self) -> Option<Self::Item> {
- 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()),
- }
- }
-}
@@ -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;
@@ -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<clock::Local, Vec<(clock::Local, u32)>>);
-
-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<Item = (&clock::Local, &[(clock::Local, u32)])> {
- 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<str>) -> Arc<str> {
- 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;
}
@@ -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<UndoMapEntry>);
+
+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::<Vec<_>>();
+ 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::<UndoMapKey>();
+ 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::<UndoMapKey>();
+ 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
+ }
+}
@@ -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<ContainedText>,
pub outdated_warning: ContainedText,
+ pub share_button: Interactive<ContainedText>,
+ pub toggle_contacts_button: Interactive<IconButton>,
+ 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<ContainedLabel>,
+}
+
+#[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<ContainedText>,
+ pub leave_call: Interactive<ContainedText>,
+ pub contact_row: Interactive<ContainerStyle>,
+ pub row_height: f32,
+ pub project_row: Interactive<ProjectRow>,
+ pub tree_branch: Interactive<TreeBranch>,
+ pub contact_avatar: ImageStyle,
+ pub contact_status_free: ContainerStyle,
+ pub contact_status_busy: ContainerStyle,
+ pub contact_username: ContainedText,
+ pub contact_button: Interactive<IconButton>,
+ 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<ContainedText>,
- pub contact_row: Interactive<ContainerStyle>,
- pub project_row: Interactive<ProjectRow>,
- pub row_height: f32,
- pub contact_avatar: ImageStyle,
- pub contact_username: ContainedText,
- pub contact_button: Interactive<IconButton>,
- pub contact_button_spacing: f32,
- pub disabled_button: IconButton,
- pub tree_branch: Interactive<TreeBranch>,
- pub private_button: Interactive<IconButton>,
- pub section_icon_size: f32,
- pub invite_row: Interactive<ContainedLabel>,
-}
-
#[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<IconButton>,
}
+#[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<SelectionStyle>,
@@ -499,6 +542,15 @@ pub struct Editor {
pub link_definition: HighlightStyle,
pub composition_mark: HighlightStyle,
pub jump_icon: Interactive<IconButton>,
+ 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<T> {
pub default: T,
@@ -571,12 +633,12 @@ pub struct Interactive<T> {
}
impl<T> Interactive<T> {
- 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
@@ -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"
+
@@ -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<Self>) {
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>) {
self.selection_completed = true;
+
+ let theme_name = cx.global::<Settings>().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<Self>) -> ElementBox {
- ChildView::new(self.picker.clone()).boxed()
+ fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+ ChildView::new(self.picker.clone(), cx).boxed()
}
fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
@@ -209,9 +209,9 @@ impl ThemeTestbench {
MouseEventHandler::<TestBenchButton>::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
@@ -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 }
@@ -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: FnOnce()>(f: F) -> impl Drop {
Defer(Some(f))
}
+pub struct RandomCharIter<T: Rng>(T);
+
+impl<T: Rng> RandomCharIter<T> {
+ pub fn new(rng: T) -> Self {
+ Self(rng)
+ }
+}
+
+impl<T: Rng> Iterator for RandomCharIter<T> {
+ type Item = char;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ 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::*;
@@ -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 => {
@@ -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"] }
@@ -26,7 +26,7 @@ fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Works
#[cfg(test)]
mod test {
- use crate::{state::Mode, vim_test_context::VimTestContext};
+ use crate::{state::Mode, test::VimTestContext};
#[gpui::test]
async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) {
@@ -18,6 +18,7 @@ use crate::{
#[derive(Copy, Clone, Debug)]
pub enum Motion {
Left,
+ Backspace,
Down,
Up,
Right,
@@ -58,6 +59,7 @@ actions!(
vim,
[
Left,
+ Backspace,
Down,
Up,
Right,
@@ -74,6 +76,7 @@ impl_actions!(vim, [NextWordStart, NextWordEnd, PreviousWordStart]);
pub fn init(cx: &mut MutableAppContext) {
cx.add_action(|_: &mut Workspace, _: &Left, cx: _| motion(Motion::Left, cx));
+ cx.add_action(|_: &mut Workspace, _: &Backspace, cx: _| motion(Motion::Backspace, cx));
cx.add_action(|_: &mut Workspace, _: &Down, cx: _| motion(Motion::Down, cx));
cx.add_action(|_: &mut Workspace, _: &Up, cx: _| motion(Motion::Up, cx));
cx.add_action(|_: &mut Workspace, _: &Right, cx: _| motion(Motion::Right, cx));
@@ -106,19 +109,21 @@ pub fn init(cx: &mut MutableAppContext) {
);
}
-fn motion(motion: Motion, cx: &mut MutableAppContext) {
- Vim::update(cx, |vim, cx| {
- if let Some(Operator::Namespace(_)) = vim.active_operator() {
- vim.pop_operator(cx);
- }
- });
+pub(crate) fn motion(motion: Motion, cx: &mut MutableAppContext) {
+ if let Some(Operator::Namespace(_)) = Vim::read(cx).active_operator() {
+ Vim::update(cx, |vim, cx| vim.pop_operator(cx));
+ }
+
+ let times = Vim::update(cx, |vim, cx| vim.pop_number_operator(cx));
+ let operator = Vim::read(cx).active_operator();
match Vim::read(cx).state.mode {
- Mode::Normal => 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<DisplayPoint>,
+ 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 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;
+ }
+ }
+ point
+}
+
+fn backspace(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
+ for _ in 0..times {
+ point = movement::left(map, point);
+ }
+ point
}
-fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
- *point.column_mut() += 1;
- map.clip_point(point, Bias::Right)
+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 next_word_start(
+fn up(
map: &DisplaySnapshot,
- point: DisplayPoint,
+ 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 {
- 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;
- }
- found
- })
+ 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);
-
- 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);
+ 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_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);
-
- (left_kind != right_kind && !right.is_whitespace()) || left == '\n'
- });
+ 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'
+ });
+ }
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)
}
@@ -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,
+ operator: Option<Operator>,
+ times: usize,
+ cx: &mut MutableAppContext,
+) {
+ Vim::update(cx, |vim, cx| {
+ 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
+ }
+ }
+ });
}
-pub fn normal_motion(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() {
- None => move_cursor(vim, motion, cx),
- Some(Operator::Namespace(_)) => {
- // Can't do anything for a namespace operator. Ignoring
+ 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
}
- 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) {
+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<Workspa
vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
s.move_cursors_with(|map, cursor, goal| {
- Motion::Right.move_point(map, cursor, goal)
+ Motion::Right.move_point(map, cursor, goal, 1)
});
});
});
@@ -112,7 +143,7 @@ fn insert_first_non_whitespace(
vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
s.move_cursors_with(|map, cursor, goal| {
- Motion::FirstNonWhitespace.move_point(map, cursor, goal)
+ Motion::FirstNonWhitespace.move_point(map, cursor, goal, 1)
});
});
});
@@ -125,7 +156,7 @@ fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewConte
vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
s.move_cursors_with(|map, cursor, goal| {
- Motion::EndOfLine.move_point(map, cursor, goal)
+ Motion::EndOfLine.move_point(map, cursor, goal, 1)
});
});
});
@@ -185,7 +216,7 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex
});
editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
s.move_cursors_with(|map, cursor, goal| {
- Motion::EndOfLine.move_point(map, cursor, goal)
+ Motion::EndOfLine.move_point(map, cursor, goal, 1)
});
});
editor.edit_with_autoindent(edits, cx);
@@ -223,7 +254,18 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
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<usize, NewPosition> = 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<Workspace>) {
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<Workspace>) {
.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<Workspace>) {
});
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<Workspace>) {
#[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(
- indoc! {"
- The ˇquick
- brown"},
- 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"},
- );
+ let mut cx = NeovimBackedTestContext::new(cx).await;
+ cx.assert_binding_matches_all(
+ ["$"],
+ indoc! {"
+ ˇThe qˇuicˇk
+ ˇbrowˇn"},
+ )
+ .await;
+ cx.assert_binding_matches_all(
+ ["0"],
+ indoc! {"
+ ˇ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"},
- indoc! {"
- The qˇuick
-
- brown fox jumps
- over the lazy dog"},
- );
- cx.assert(
- indoc! {"
- The qˇuick
-
- 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 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! {"
- ˇ
- The quick"},
- indoc! {"
+ brown fox"})
+ .await;
+ cx.assert(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! {"
- The quick
- ˇ
- brown fox"},
- indoc! {"
+ brown fox"})
+ .await;
+ cx.assert(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! {"
- The quick
- ˇ
- brown fox
- jumps over"},
- );
- cx.assert(
- indoc! {"
+ brown ˇfox
+ jumps ˇover"})
+ .await;
+ 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! {"
- The quick brown
- the lazy dog
- ˇfox jumps over"},
- Mode::Normal,
- );
+ cx.simulate_shared_keystroke("p").await;
+ cx.assert_state_matches().await;
- cx.set_state(
- indoc! {"
+ cx.set_shared_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;
+ }
}
}
@@ -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;
-
-#[derive(Clone, Deserialize, PartialEq)]
-#[serde(rename_all = "camelCase")]
-struct ChangeWord {
- #[serde(default)]
- ignore_punctuation: bool,
-}
+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;
-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<Workspace>,
+fn expand_changed_word_selection(
+ map: &DisplaySnapshot,
+ selection: &mut Selection<DisplayPoint>,
+ 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;
- }
-
- 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'
- });
- });
- });
- copy_selections_content(editor, false, cx);
- editor.insert("", cx);
- });
- });
- vim.switch_mode(Mode::Insert, false, cx);
+ if times > 1 {
+ Motion::NextWordStart { ignore_punctuation }.expand_selection(
+ map,
+ selection,
+ times - 1,
+ false,
+ );
+ }
+
+ if times == 1 && selection.end.column() == map.line_len(selection.end.row()) {
+ return;
+ }
+
+ 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;
+ }
+ }
}
@@ -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"]);
@@ -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);
+ });
+ });
+ });
+ });
+}
@@ -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<Range<DisplayPoint>> {
+ 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<DisplayPoint>,
+ 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<Range<DisplayPoint>> {
+ // 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<Range<DisplayPoint>> {
+ 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<Range<DisplayPoint>> {
+ 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<Range<DisplayPoint>> {
+ // 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<Range<DisplayPoint>> {
+ 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<DisplayPoint>,
+ stop_at_newline: bool,
+) -> Range<DisplayPoint> {
+ 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<Range<DisplayPoint>> {
+ 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;
+ }
+ }
+}
@@ -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();
@@ -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::<BufferSearchBar>()
+ .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");
+ })
+}
@@ -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<const NEW_COUNT: usize>(
+ 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
+ }
+}
@@ -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<String, Option<HashSet<String>>>,
+ 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<const COUNT: usize>(
+ &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::<Point>(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<const COUNT: usize>(
+ &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<const COUNT: usize>(
+ &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<const COUNT: usize>(
+ 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;
+ }
+}
@@ -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<Mode>),
+}
+
+pub struct NeovimConnection {
+ data: VecDeque<NeovimData>,
+ #[cfg(feature = "neovim")]
+ test_case_id: String,
+ #[cfg(feature = "neovim")]
+ nvim: Neovim<nvim_rs::compat::tokio::Compat<ChildStdin>>,
+ #[cfg(feature = "neovim")]
+ _join_handle: JoinHandle<Result<(), Box<LoopError>>>,
+ #[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<Point>, 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::<Vec<_>>();
+
+ nvim_buffer
+ .set_lines(0, -1, false, lines)
+ .await
+ .expect("Could not set nvim buffer text");
+
+ self.nvim
+ .input("<escape>")
+ .await
+ .expect("Could not send escape to nvim");
+ self.nvim
+ .input("<escape>")
+ .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<Point>, _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<Point> {
+ let cursor_row: u32 = self
+ .nvim
+ .command_output("echo line('.')")
+ .await
+ .unwrap()
+ .parse::<u32>()
+ .unwrap()
+ - 1; // Neovim rows start at 1
+ let cursor_col: u32 = self
+ .nvim
+ .command_output("echo col('.')")
+ .await
+ .unwrap()
+ .parse::<u32>()
+ .unwrap()
+ - 1; // Neovim columns start at 1
+
+ let (start, end) = if let Some(Mode::Visual { .. }) = self.mode().await {
+ self.nvim
+ .input("<escape>")
+ .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<Point> {
+ // 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<Mode> {
+ 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<Mode> {
+ 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<NeovimData> {
+ 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<nvim_rs::compat::tokio::Compat<ChildStdin>>;
+
+ 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<ChildStdin>;
+
+ async fn handle_request(
+ &self,
+ _event_name: String,
+ _arguments: Vec<Value>,
+ _neovim: Neovim<Self::Writer>,
+ ) -> Result<Value, Value> {
+ unimplemented!();
+ }
+
+ async fn handle_notify(
+ &self,
+ _event_name: String,
+ _arguments: Vec<Value>,
+ _neovim: Neovim<Self::Writer>,
+ ) {
+ }
+}
@@ -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<const NEW_COUNT: usize>(
+ 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
+ }
+}
@@ -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<Workspace>,
@@ -117,18 +119,18 @@ impl<'a> VimTestContext<'a> {
.read(|cx| cx.global::<Vim>().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<const COUNT: usize>(
@@ -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<const COUNT: usize>(
@@ -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<const NEW_COUNT: usize>(
- 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
- }
-}
@@ -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::<BufferSearchBar>()
- .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");
- })
- }
-}
@@ -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<Workspace>) {
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<Workspace>
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(
+ let mut cx = NeovimBackedTestContext::new(cx).await;
+
+ 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! {"
- The ˇver
- the lazy dog"},
- );
+ )
+ .await;
// 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"});
+ cx.simulate_shared_keystrokes(["j", "p"]).await;
+ cx.assert_state_matches().await;
- cx.assert(
- indoc! {"
- The quick brown
- fox jumps over
- the ˇlazy dog"},
- indoc! {"
- The quick brown
+ let mut cx = cx.binding(["v", "w", "j", "x"]);
+ cx.assert_all(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"},
- );
+ the ˇlazy dog"})
+ .await;
let mut cx = cx.binding(["v", "b", "k", "x"]);
- cx.assert(
- indoc! {"
+ 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_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,
);
@@ -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"}]
@@ -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"}]
@@ -0,0 +1 @@
@@ -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"}]
@@ -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"}]
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+[{"Text":""},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":""},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"brown fox\njumps over"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\njumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":"Test"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"est"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Tst"},{"Mode":"Normal"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Normal"},{"Text":"Tet"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"},{"Text":"Test\ntest"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+[{"Text":"The q\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+[{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"},{"Text":"\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"\n\nbrown fox jumps\nover the lazydog"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]
@@ -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":[1,0],"end":[1,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":"\nThe quick\nbrown fox "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick\nbrown fox "},{"Mode":"Insert"},{"Selection":{"start":[1,9],"end":[1,9]}},{"Mode":"Insert"},{"Text":"\nThe quick\nbrown fox "},{"Mode":"Insert"},{"Selection":{"start":[2,10],"end":[2,10]}},{"Mode":"Insert"}]
@@ -0,0 +1 @@
+[{"Text":"The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Insert"},{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"}]
@@ -0,0 +1 @@
+[{"Text":"\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick\n\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\n\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"The quick\n\n\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}]
@@ -0,0 +1 @@
+[{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,5],"end":[1,5]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,8],"end":[1,8]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[3,4],"end":[3,4]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[3,4],"end":[3,4]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[3,16],"end":[3,16]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Normal"},{"Text":"The quick\n\n"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":"The quick"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":" The quick"},{"Mode":"Normal"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Normal"},{"Text":""},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"\nThe quick"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":" \nThe quick"},{"Mode":"Normal"},{"Selection":{"start":[0,3],"end":[0,3]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+[{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,7],"end":[0,7]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[1,1],"end":[1,1]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[1,4],"end":[1,4]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"test"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":"\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\n\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\n\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\njumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[3,0],"end":[3,0]}},{"Mode":"Insert"},{"Text":"The quick\n\n\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"fn test() {\n println!();\n \n}\n"},{"Mode":"Insert"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Insert"},{"Text":"fn test() {\n\n println!();\n}"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}]
@@ -0,0 +1 @@
+[{"Text":"The quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick brown\nthe lazy dog\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps overjumps o\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,20],"end":[1,20]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+[{"Text":"The quick "},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps he lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[1,10],"end":[1,10]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps over\nthe og"},{"Mode":"Insert"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Insert"},{"Text":"uick brown\nfox jumps over\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Insert"},{"Text":"The quick brown\nazy dog"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}]
@@ -0,0 +1 @@
+[{"Text":"The quick "},{"Mode":"Normal"},{"Selection":{"start":[0,9],"end":[0,9]}},{"Mode":"Normal"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The ver\nthe lquick brown\nfox jumps oazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,5],"end":[1,5]}},{"Mode":"Normal"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps over\nthe og"},{"Mode":"Normal"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Normal"},{"Text":"uick brown\nfox jumps over\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick brown\nazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":"\nfox jumps over\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nfox jumps over\nThe quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick brown\n\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nthe lazy dog\nThe quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick brown\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"}]
@@ -0,0 +1 @@
+[{"Text":"fox jumps over\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"fox jumps over\nThe quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"the lazy dog\nThe quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick brown"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+[{"Text":"est"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Tet"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"},{"Text":"Tes"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"},{"Text":"Tes\ntest"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"}]
@@ -8,14 +8,22 @@ path = "src/workspace.rs"
doctest = false
[features]
-test-support = ["client/test-support", "project/test-support", "settings/test-support"]
+test-support = [
+ "call/test-support",
+ "client/test-support",
+ "project/test-support",
+ "settings/test-support",
+ "gpui/test-support",
+ "fs/test-support"
+]
[dependencies]
+call = { path = "../call" }
client = { path = "../client" }
-clock = { path = "../clock" }
collections = { path = "../collections" }
context_menu = { path = "../context_menu" }
drag_and_drop = { path = "../drag_and_drop" }
+fs = { path = "../fs" }
gpui = { path = "../gpui" }
language = { path = "../language" }
menu = { path = "../menu" }
@@ -33,7 +41,9 @@ serde_json = { version = "1.0", features = ["preserve_order"] }
smallvec = { version = "1.6", features = ["union"] }
[dev-dependencies]
+call = { path = "../call", features = ["test-support"] }
client = { path = "../client", features = ["test-support"] }
gpui = { path = "../gpui", features = ["test-support"] }
project = { path = "../project", features = ["test-support"] }
-settings = { path = "../settings", features = ["test-support"] }
+settings = { path = "../settings", features = ["test-support"] }
+fs = { path = "../fs", features = ["test-support"] }
@@ -170,7 +170,11 @@ impl Dock {
} else {
cx.focus(pane);
}
- } else if let Some(last_active_center_pane) = workspace.last_active_center_pane.clone() {
+ } else if let Some(last_active_center_pane) = workspace
+ .last_active_center_pane
+ .as_ref()
+ .and_then(|pane| pane.upgrade(cx))
+ {
cx.focus(last_active_center_pane);
}
cx.emit(crate::Event::DockAnchorChanged);
@@ -251,7 +255,7 @@ impl Dock {
enum DockResizeHandle {}
- let resizable = Container::new(ChildView::new(self.pane.clone()).boxed())
+ let resizable = Container::new(ChildView::new(self.pane.clone(), cx).boxed())
.with_style(panel_style)
.with_resize_handle::<DockResizeHandle, _>(
resize_side as usize,
@@ -281,8 +285,8 @@ impl Dock {
enum ExpandedDockPane {}
Container::new(
MouseEventHandler::<ExpandedDockWash>::new(0, cx, |_state, cx| {
- MouseEventHandler::<ExpandedDockPane>::new(0, cx, |_state, _cx| {
- ChildView::new(self.pane.clone()).boxed()
+ MouseEventHandler::<ExpandedDockPane>::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<Pane> {
- 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,
@@ -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<Box<dyn ItemHandle>>,
is_active: bool,
active_item_index: usize,
- last_focused_view: Option<AnyWeakViewHandle>,
+ last_focused_view_by_item: HashMap<usize, AnyWeakViewHandle>,
autoscroll: bool,
nav_history: Rc<RefCell<NavHistory>>,
toolbar: ViewHandle<Toolbar>,
@@ -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<Self>) {
+ pub fn activate_prev_item(&mut self, activate_pane: bool, cx: &mut ViewContext<Self>) {
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<Self>) {
+ pub fn activate_next_item(&mut self, activate_pane: bool, cx: &mut ViewContext<Self>) {
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<Self>) {
+ fn remove_item(&mut self, item_ix: usize, activate_pane: bool, cx: &mut ViewContext<Self>) {
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::<TabCloseButton>::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<Self>) {
- 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);
}
}
@@ -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<Project>,
theme: &Theme,
follower_states: &FollowerStatesByLeader,
- collaborators: &HashMap<PeerId, Collaborator>,
+ active_call: Option<&ModelHandle<ActiveCall>>,
+ cx: &mut RenderContext<Workspace>,
) -> 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<Pane>> {
@@ -100,13 +104,16 @@ impl Member {
pub fn render(
&self,
+ project: &ModelHandle<Project>,
theme: &Theme,
follower_states: &FollowerStatesByLeader,
- collaborators: &HashMap<PeerId, Collaborator>,
+ active_call: Option<&ModelHandle<ActiveCall>>,
+ cx: &mut RenderContext<Workspace>,
) -> 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::<FollowIntoExternalProject>::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<Project>,
theme: &Theme,
follower_state: &FollowerStatesByLeader,
- collaborators: &HashMap<PeerId, Collaborator>,
+ active_call: Option<&ModelHandle<ActiveCall>>,
+ cx: &mut RenderContext<Workspace>,
) -> 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;
@@ -192,7 +192,7 @@ impl View for Sidebar {
if let Some(active_item) = self.active_item() {
enum ResizeHandleTag {}
let style = &cx.global::<Settings>().theme.workspace.sidebar;
- ChildView::new(active_item.to_any())
+ ChildView::new(active_item.to_any(), cx)
.contained()
.with_style(style.container)
.with_resize_handle::<ResizeHandleTag, _>(
@@ -42,14 +42,14 @@ impl View for StatusBar {
let theme = &cx.global::<Settings>().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)
@@ -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(),
@@ -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<Arc<ImageData>>,
- message: String,
- waiting: bool,
- client: Arc<Client>,
- _join_task: Task<Result<()>>,
-}
-
-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<Self>) -> ElementBox {
- let theme = &cx.global::<Settings>().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<Contact>,
- project_index: usize,
- app_state: Arc<AppState>,
- cx: &mut ViewContext<Self>,
- ) -> 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<Item = &'a String>) -> 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
-}
@@ -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<PathBuf>,
}
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct ToggleProjectOnline {
- #[serde(skip_deserializing)]
- pub project: Option<ModelHandle<Project>>,
-}
-
#[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<Contact>,
- 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<AppState>, cx: &mut MutableAppContext) {
pane::init(cx);
@@ -174,14 +159,6 @@ pub fn init(app_state: Arc<AppState>, 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<AppState>, 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<Workspace>| {
let pane = workspace.active_pane().clone();
@@ -318,7 +294,23 @@ pub trait Item: View {
project: ModelHandle<Project>,
cx: &mut ViewContext<Self>,
) -> Task<Result<()>>;
+ fn git_diff_recalc(
+ &mut self,
+ _project: ModelHandle<Project>,
+ _cx: &mut ViewContext<Self>,
+ ) -> Task<Result<()>> {
+ Task::ready(Ok(()))
+ }
fn to_item_events(event: &Self::Event) -> Vec<ItemEvent>;
+ fn should_close_item_on_event(_: &Self::Event) -> bool {
+ false
+ }
+ fn should_update_tab_on_event(_: &Self::Event) -> bool {
+ false
+ }
+ fn is_edit_event(_: &Self::Event) -> bool {
+ false
+ }
fn act_as_type(
&self,
type_id: TypeId,
@@ -435,6 +427,57 @@ impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
}
}
+struct DelayedDebouncedEditAction {
+ task: Option<Task<()>>,
+ cancel_channel: Option<oneshot::Sender<()>>,
+}
+
+impl DelayedDebouncedEditAction {
+ fn new() -> DelayedDebouncedEditAction {
+ DelayedDebouncedEditAction {
+ task: None,
+ cancel_channel: None,
+ }
+ }
+
+ fn fire_new<F, Fut>(
+ &mut self,
+ delay: Duration,
+ workspace: &Workspace,
+ cx: &mut ViewContext<Workspace>,
+ f: F,
+ ) where
+ F: FnOnce(ModelHandle<Project>, AsyncAppContext) -> Fut + 'static,
+ Fut: 'static + Future<Output = ()>,
+ {
+ 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<Result<()>>;
fn reload(&self, project: ModelHandle<Project>, cx: &mut MutableAppContext)
-> Task<Result<()>>;
+ fn git_diff_recalc(
+ &self,
+ project: ModelHandle<Project>,
+ cx: &mut MutableAppContext,
+ ) -> Task<Result<()>>;
fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option<AnyViewHandle>;
fn to_followable_item_handle(&self, cx: &AppContext) -> Option<Box<dyn FollowableItemHandle>>;
fn on_release(
@@ -578,8 +626,8 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
.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<T: Item> ItemHandle for ViewHandle<T> {
.detach_and_log_err(cx);
return;
}
+
ItemEvent::UpdateTab => {
pane.update(cx, |_, cx| {
cx.emit(pane::Event::ChangeItemTitle);
cx.notify();
});
}
+
ItemEvent::Edit => {
if let Autosave::AfterDelay { milliseconds } =
cx.global::<Settings>().autosave
{
- let 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::<Settings>();
+ let debounce_delay = settings.git_overrides.gutter_debounce;
+
+ let item = item.clone();
+
+ if let Some(delay) = debounce_delay {
+ const MIN_GIT_DELAY: u64 = 50;
+
+ let delay = delay.max(MIN_GIT_DELAY);
+ let duration = Duration::from_millis(delay);
+
+ pending_git_update.fire_new(
+ duration,
+ workspace,
+ cx,
+ |project, mut cx| async move {
+ cx.update(|cx| item.git_diff_recalc(project, cx))
+ .await
+ .log_err();
+ },
+ );
+ } else {
+ let project = workspace.project().downgrade();
+ cx.spawn_weak(|_, mut cx| async move {
+ if let Some(project) = project.upgrade(&cx) {
+ cx.update(|cx| item.git_diff_recalc(project, cx))
+ .await
+ .log_err();
+ }
+ })
+ .detach();
}
}
+
_ => {}
}
}
@@ -755,6 +824,14 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
self.update(cx, |item, cx| item.reload(project, cx))
}
+ fn git_diff_recalc(
+ &self,
+ project: ModelHandle<Project>,
+ cx: &mut MutableAppContext,
+ ) -> Task<Result<()>> {
+ self.update(cx, |item, cx| item.git_diff_recalc(project, cx))
+ }
+
fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option<AnyViewHandle> {
self.read(cx).act_as_type(type_id, self, cx)
}
@@ -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<Self>,
client: Arc<Client>,
user_store: ModelHandle<client::UserStore>,
- remote_entity_subscription: Option<Subscription>,
+ remote_entity_subscription: Option<client::Subscription>,
fs: Arc<dyn Fs>,
modal: Option<AnyViewHandle>,
center: PaneGroup,
@@ -893,8 +970,9 @@ pub struct Workspace {
panes: Vec<ViewHandle<Pane>>,
panes_by_item: HashMap<usize, WeakViewHandle<Pane>>,
active_pane: ViewHandle<Pane>,
- last_active_center_pane: Option<ViewHandle<Pane>>,
+ last_active_center_pane: Option<WeakViewHandle<Pane>>,
status_bar: ViewHandle<StatusBar>,
+ titlebar_item: Option<AnyViewHandle>,
dock: Dock,
notifications: Vec<(TypeId, usize, Box<dyn NotificationHandle>)>,
project: ModelHandle<Project>,
@@ -902,7 +980,9 @@ pub struct Workspace {
follower_states_by_leader: FollowerStatesByLeader,
last_leaders_by_pane: HashMap<WeakViewHandle<Pane>, PeerId>,
window_edited: bool,
+ active_call: Option<ModelHandle<ActiveCall>>,
_observe_current_user: Task<()>,
+ _active_call_observation: Option<gpui::Subscription>,
}
#[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::<ModelHandle<ActiveCall>>() {
+ let call = cx.global::<ModelHandle<ActiveCall>>().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<Client> {
+ &self.client
+ }
+
+ pub fn set_titlebar_item(
+ &mut self,
+ item: impl Into<AnyViewHandle>,
+ cx: &mut ViewContext<Self>,
+ ) {
+ self.titlebar_item = Some(item.into());
+ cx.notify();
+ }
+
+ pub fn titlebar_item(&self) -> Option<AnyViewHandle> {
+ 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<Self>,
) -> Option<Task<Result<()>>> {
- 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<Self>) -> Task<Result<bool>> {
- self.save_all_internal(true, cx)
+ pub fn prepare_to_close(
+ &mut self,
+ quitting: bool,
+ cx: &mut ViewContext<Self>,
+ ) -> Task<Result<bool>> {
+ 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::<Workspace>(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<Self>) -> Option<Task<Result<()>>> {
@@ -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<Self>) {
- 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<Self>) -> Option<ElementBox> {
- let theme = &cx.global::<Settings>().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<Self>) -> 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<Self>) -> Vec<ElementBox> {
- let mut collaborators = self
- .project
- .read(cx)
- .collaborators()
- .values()
- .cloned()
- .collect::<Vec<_>>();
- 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<User>>,
- replica_id: ReplicaId,
- theme: &Theme,
- cx: &mut RenderContext<Self>,
- ) -> Option<ElementBox> {
- 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::<Authenticate>::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<ImageData>,
- replica_id: ReplicaId,
- peer: Option<(PeerId, &str)>,
- theme: &Theme,
- cx: &mut RenderContext<Self>,
- ) -> 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::<ToggleFollow>::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::<ToggleFollow, _>(
- 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<Workspace>) -> Option<ElementBox> {
if self.project.read(cx).is_read_only() {
enum DisconnectedOverlay {}
@@ -2264,14 +2231,18 @@ impl Workspace {
}
}
- fn render_notifications(&self, theme: &theme::Workspace) -> Option<ElementBox> {
+ fn render_notifications(
+ &self,
+ theme: &theme::Workspace,
+ cx: &AppContext,
+ ) -> Option<ElementBox> {
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<Self>) {
+ fn on_focus_in(&mut self, view: AnyViewHandle, cx: &mut ViewContext<Self>) {
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<Workspace> {
}
}
-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<usize>,
- _: RectF,
- _: RectF,
- _: &Self::LayoutState,
- _: &Self::PaintState,
- _: &gpui::MeasurementContext,
- ) -> Option<RectF> {
- 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<Contact>,
- project_index: usize,
- app_state: &Arc<AppState>,
- cx: &mut MutableAppContext,
-) {
- let project_id = contact.projects[project_index].id;
-
- for window_id in cx.window_ids().collect::<Vec<_>>() {
- if let Some(workspace) = cx.root_view::<Workspace>(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<AppState>, 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(
@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathansobo@gmail.com>"]
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"
@@ -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() {
@@ -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<LanguageRegistry>, _executor: Arc<Background>)
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<LanguageRegistry>, _executor: Arc<Background>)
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<Arc<CachedLspAdapter>>,
-) -> Language {
+) -> Arc<Language> {
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<Cow<'static, str>> {
@@ -112,7 +112,7 @@ impl super::LspAdapter for CLspAdapter {
async fn label_for_completion(
&self,
completion: &lsp::CompletionItem,
- language: &Language,
+ language: &Arc<Language>,
) -> Option<CodeLabel> {
let label = completion
.label
@@ -190,7 +190,7 @@ impl super::LspAdapter for CLspAdapter {
&self,
name: &str,
kind: lsp::SymbolKind,
- language: &Language,
+ language: &Arc<Language>,
) -> Option<CodeLabel> {
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);
@@ -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)
@@ -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"
@@ -0,0 +1,3 @@
+("(" @open ")" @close)
+("[" @open "]" @close)
+("{" @open "}" @close)
@@ -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 }
+]
@@ -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
@@ -0,0 +1 @@
+(_ "{" "}" @end) @indent
@@ -113,7 +113,7 @@ impl LspAdapter for ElixirLspAdapter {
async fn label_for_completion(
&self,
completion: &lsp::CompletionItem,
- language: &Language,
+ language: &Arc<Language>,
) -> Option<CodeLabel> {
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<Language>,
) -> Option<CodeLabel> {
let (text, filter_range, display_range) = match kind {
SymbolKind::METHOD | SymbolKind::FUNCTION => {
@@ -134,7 +134,7 @@ impl super::LspAdapter for GoLspAdapter {
async fn label_for_completion(
&self,
completion: &lsp::CompletionItem,
- language: &Language,
+ language: &Arc<Language>,
) -> Option<CodeLabel> {
let label = &completion.label;
@@ -235,7 +235,7 @@ impl super::LspAdapter for GoLspAdapter {
&self,
name: &str,
kind: lsp::SymbolKind,
- language: &Language,
+ language: &Arc<Language>,
) -> Option<CodeLabel> {
let (text, filter_range, display_range) = match kind {
lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
@@ -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<String> {
+ vec!["--stdio".into()]
+ }
+
+ async fn fetch_latest_server_version(
+ &self,
+ _: Arc<dyn HttpClient>,
+ ) -> Result<Box<dyn 'static + Any + Send>> {
+ Ok(Box::new(npm_package_latest_version("vscode-langservers-extracted").await?) as Box<_>)
+ }
+
+ async fn fetch_server_binary(
+ &self,
+ version: Box<dyn 'static + Send + Any>,
+ _: Arc<dyn HttpClient>,
+ container_dir: PathBuf,
+ ) -> Result<PathBuf> {
+ let version = version.downcast::<String>().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<PathBuf> {
+ (|| 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<serde_json::Value> {
+ Some(json!({
+ "provideFormatter": true
+ }))
+ }
+}
@@ -0,0 +1,2 @@
+("<" @open ">" @close)
+("\"" @open "\"" @close)
@@ -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 = ["<!-- ", " -->"]
@@ -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
@@ -0,0 +1,6 @@
+(start_tag ">" @end) @indent
+(self_closing_tag "/>" @end) @indent
+
+(element
+ (start_tag) @start
+ (end_tag)? @end) @indent
@@ -0,0 +1,7 @@
+(script_element
+ (raw_text) @content
+ (#set! "language" "javascript"))
+
+(style_element
+ (raw_text) @content
+ (#set! "language" "css"))
@@ -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)
@@ -90,7 +90,7 @@ impl LspAdapter for PythonLspAdapter {
async fn label_for_completion(
&self,
item: &lsp::CompletionItem,
- language: &language::Language,
+ language: &Arc<language::Language>,
) -> Option<language::CodeLabel> {
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<language::Language>,
) -> Option<language::CodeLabel> {
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<Buffer>| {
let ix = buffer.len();
buffer.edit([(ix..ix, text)], Some(AutoindentMode::EachLine), cx);
@@ -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
@@ -119,7 +119,7 @@ impl LspAdapter for RustLspAdapter {
async fn label_for_completion(
&self,
completion: &lsp::CompletionItem,
- language: &Language,
+ language: &Arc<Language>,
) -> Option<CodeLabel> {
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<Language>,
) -> Option<CodeLabel> {
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);
@@ -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_]*$"))
[
"("
@@ -115,7 +115,7 @@ impl LspAdapter for TypeScriptLspAdapter {
async fn label_for_completion(
&self,
item: &lsp::CompletionItem,
- language: &language::Language,
+ language: &Arc<language::Language>,
) -> Option<language::CodeLabel> {
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
@@ -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)
@@ -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<dyn HttpClient>, 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<dyn HttpClient>, 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<PathBuf> {
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);
@@ -244,10 +244,6 @@ pub fn menus() -> Vec<Menu<'static>> {
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<Menu<'static>> {
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 {
@@ -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<AppState>, cx: &mut gpui::MutableAppContext) {
cx.toggle_full_screen();
},
);
+ cx.add_action(
+ |workspace: &mut Workspace,
+ _: &ToggleCollaborationMenu,
+ cx: &mut ViewContext<Workspace>| {
+ if let Some(item) = workspace
+ .titlebar_item()
+ .and_then(|item| item.downcast::<CollabTitlebarItem>())
+ {
+ 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<AppState>, 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<Workspace>| {
+ 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<Workspace>| {
@@ -207,15 +229,9 @@ pub fn init(app_state: &Arc<AppState>, 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>| {
- 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<AppState>,
cx: &mut ViewContext<Workspace>,
) {
- 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<AppState>,
+ cx: &mut ViewContext<Workspace>,
+) {
+ 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<AppState>,
@@ -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);
@@ -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()
@@ -0,0 +1 @@
+amplitude-python-sdk==0.2.0
@@ -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.
@@ -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"
+ }
}
@@ -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),
@@ -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,
@@ -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"),
- },
- },
- 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" }),
+ background: background(layer, "on", "active"),
},
},
- };
+ }
}
@@ -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" }),
+ },
+ },
+ }
}
@@ -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,
@@ -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" })
+ },
+ };
+}
@@ -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" })
+ },
+ };
+}
@@ -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,14 +85,20 @@ 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,
@@ -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: {
@@ -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,
+ };
+}
@@ -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 };
+}