catchup with main

KCaverly created

Change summary

Cargo.lock                                          | 303 +++--
Procfile                                            |   3 
README.md                                           |   9 
assets/keymaps/default.json                         |  11 
assets/keymaps/vim.json                             |   8 
crates/ai/Cargo.toml                                |   1 
crates/ai/src/ai.rs                                 |   3 
crates/ai/src/assistant.rs                          | 601 ++---------
crates/ai/src/codegen.rs                            | 704 +++++++++++++++
crates/collab/Cargo.toml                            |   2 
crates/collab/admin_api.conf                        |   4 
crates/collab/k8s/manifest.template.yml             |  60 +
crates/collab/src/api.rs                            | 217 ----
crates/collab/src/db/queries.rs                     |   1 
crates/collab/src/db/queries/signups.rs             | 349 -------
crates/collab/src/db/queries/users.rs               |  36 
crates/collab/src/db/tests/db_tests.rs              | 541 -----------
crates/collab/src/rpc.rs                            |  10 
crates/collab/src/tests/integration_tests.rs        |   1 
crates/diagnostics/src/items.rs                     |   3 
crates/editor/src/display_map.rs                    |  96 +
crates/editor/src/display_map/block_map.rs          |  20 
crates/editor/src/display_map/fold_map.rs           |  22 
crates/editor/src/display_map/inlay_map.rs          | 318 ++++--
crates/editor/src/display_map/tab_map.rs            |  40 
crates/editor/src/display_map/wrap_map.rs           |  24 
crates/editor/src/editor.rs                         | 182 +--
crates/editor/src/hover_popover.rs                  |  71 -
crates/editor/src/inlay_hint_cache.rs               | 101 +
crates/editor/src/link_go_to_definition.rs          | 207 +--
crates/editor/src/multi_buffer.rs                   |  43 
crates/editor/src/test/editor_test_context.rs       |   4 
crates/language/src/language.rs                     |  79 
crates/project/src/project.rs                       |   1 
crates/search/src/buffer_search.rs                  |  50 
crates/search/src/project_search.rs                 |   5 
crates/search/src/search.rs                         |   2 
crates/semantic_index/Cargo.toml                    |   1 
crates/semantic_index/src/db.rs                     |  50 
crates/semantic_index/src/embedding.rs              |  11 
crates/semantic_index/src/parsing.rs                |  30 
crates/semantic_index/src/semantic_index.rs         | 258 ++++
crates/vim/src/editor_events.rs                     |   2 
crates/vim/src/insert.rs                            | 119 ++
crates/vim/src/motion.rs                            |  33 
crates/vim/src/normal.rs                            |  36 
crates/vim/src/normal/case.rs                       |   2 
crates/vim/src/normal/change.rs                     | 170 ++-
crates/vim/src/normal/delete.rs                     |  92 +
crates/vim/src/normal/repeat.rs                     | 327 ++++--
crates/vim/src/normal/scroll.rs                     |   2 
crates/vim/src/normal/search.rs                     |   6 
crates/vim/src/normal/substitute.rs                 |   4 
crates/vim/src/state.rs                             |  20 
crates/vim/src/test.rs                              |  46 
crates/vim/src/test/neovim_backed_test_context.rs   |  26 
crates/vim/src/vim.rs                               |  97 +
crates/vim/test_data/test_clear_counts.json         |   7 
crates/vim/test_data/test_delete_with_counts.json   |  16 
crates/vim/test_data/test_dot_repeat.json           |   2 
crates/vim/test_data/test_insert_with_counts.json   |  36 
crates/vim/test_data/test_insert_with_repeat.json   |  23 
crates/vim/test_data/test_repeat_motion_counts.json |  13 
crates/vim/test_data/test_zero.json                 |   7 
crates/zed/Cargo.toml                               |   2 
script/deploy                                       |   3 
styles/src/style_tree/search.ts                     |   8 
67 files changed, 2,819 insertions(+), 2,762 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -88,9 +88,9 @@ dependencies = [
 
 [[package]]
 name = "aho-corasick"
-version = "1.0.4"
+version = "1.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6748e8def348ed4d14996fa801f4122cd763fff530258cdc03f64b25f89d3a5a"
+checksum = "0c378d78423fdad8089616f827526ee33c19f2fddbd5de1629152c9593ba4783"
 dependencies = [
  "memchr",
 ]
@@ -114,6 +114,7 @@ dependencies = [
  "log",
  "menu",
  "ordered-float",
+ "parking_lot 0.11.2",
  "project",
  "rand 0.8.5",
  "regex",
@@ -136,7 +137,7 @@ source = "git+https://github.com/zed-industries/alacritty?rev=33306142195b354ef3
 dependencies = [
  "log",
  "serde",
- "toml 0.7.6",
+ "toml 0.7.8",
 ]
 
 [[package]]
@@ -146,7 +147,7 @@ source = "git+https://github.com/zed-industries/alacritty?rev=33306142195b354ef3
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.29",
+ "syn 2.0.33",
 ]
 
 [[package]]
@@ -165,14 +166,14 @@ dependencies = [
  "mio-anonymous-pipes",
  "mio-extras",
  "miow 0.3.7",
- "nix 0.26.2",
+ "nix 0.26.4",
  "parking_lot 0.12.1",
  "regex-automata 0.1.10",
  "serde",
  "serde_yaml",
  "signal-hook",
  "signal-hook-mio",
- "toml 0.7.6",
+ "toml 0.7.8",
  "unicode-width",
  "vte",
  "windows-sys",
@@ -235,24 +236,23 @@ dependencies = [
 
 [[package]]
 name = "anstream"
-version = "0.3.2"
+version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163"
+checksum = "b1f58811cfac344940f1a400b6e6231ce35171f614f26439e80f8c1465c5cc0c"
 dependencies = [
  "anstyle",
  "anstyle-parse",
  "anstyle-query",
  "anstyle-wincon",
  "colorchoice",
- "is-terminal 0.4.9",
  "utf8parse",
 ]
 
 [[package]]
 name = "anstyle"
-version = "1.0.2"
+version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15c4c2c83f81532e5845a733998b6971faca23490340a418e9b72a3ec9de12ea"
+checksum = "b84bf0a05bbb2a83e5eb6fa36bb6e87baa08193c35ff52bbf6b38d8af2890e46"
 
 [[package]]
 name = "anstyle-parse"
@@ -274,9 +274,9 @@ dependencies = [
 
 [[package]]
 name = "anstyle-wincon"
-version = "1.0.2"
+version = "2.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c677ab05e09154296dd37acecd46420c17b9713e8366facafa8fc0885167cf4c"
+checksum = "58f54d10c6dfa51283a066ceab3ec1ab78d13fae00aa49243a45e4571fb79dfd"
 dependencies = [
  "anstyle",
  "windows-sys",
@@ -343,7 +343,7 @@ dependencies = [
  "futures-core",
  "futures-io",
  "once_cell",
- "pin-project-lite 0.2.12",
+ "pin-project-lite 0.2.13",
  "tokio",
 ]
 
@@ -357,7 +357,7 @@ dependencies = [
  "futures-core",
  "futures-io",
  "memchr",
- "pin-project-lite 0.2.12",
+ "pin-project-lite 0.2.13",
 ]
 
 [[package]]
@@ -482,13 +482,13 @@ dependencies = [
 
 [[package]]
 name = "async-recursion"
-version = "1.0.4"
+version = "1.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e97ce7de6cf12de5d7226c73f5ba9811622f4db3a5b91b55c53e987e5f91cba"
+checksum = "5fd55a5ba1179988837d24ab4c7cc8ed6efdeff578ede0416b4225a5fca35bd0"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.29",
+ "syn 2.0.33",
 ]
 
 [[package]]
@@ -511,7 +511,7 @@ dependencies = [
  "log",
  "memchr",
  "once_cell",
- "pin-project-lite 0.2.12",
+ "pin-project-lite 0.2.13",
  "pin-utils",
  "slab",
  "wasm-bindgen-futures",
@@ -525,7 +525,7 @@ checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51"
 dependencies = [
  "async-stream-impl",
  "futures-core",
- "pin-project-lite 0.2.12",
+ "pin-project-lite 0.2.13",
 ]
 
 [[package]]
@@ -536,7 +536,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.29",
+ "syn 2.0.33",
 ]
 
 [[package]]
@@ -579,7 +579,7 @@ checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.29",
+ "syn 2.0.33",
 ]
 
 [[package]]
@@ -592,7 +592,7 @@ dependencies = [
  "futures-io",
  "futures-util",
  "log",
- "pin-project-lite 0.2.12",
+ "pin-project-lite 0.2.13",
  "tungstenite 0.16.0",
 ]
 
@@ -681,7 +681,7 @@ dependencies = [
  "axum-core",
  "base64 0.13.1",
  "bitflags 1.3.2",
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "futures-util",
  "headers",
  "http",
@@ -692,7 +692,7 @@ dependencies = [
  "memchr",
  "mime",
  "percent-encoding",
- "pin-project-lite 0.2.12",
+ "pin-project-lite 0.2.13",
  "serde",
  "serde_json",
  "serde_urlencoded",
@@ -713,7 +713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "37e5939e02c56fecd5c017c37df4238c0a839fa76b7f97acdd7efb804fd181cc"
 dependencies = [
  "async-trait",
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "futures-util",
  "http",
  "http-body",
@@ -729,11 +729,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "69034b3b0fd97923eee2ce8a47540edb21e07f48f87f67d44bb4271cec622bdb"
 dependencies = [
  "axum",
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "futures-util",
  "http",
  "mime",
- "pin-project-lite 0.2.12",
+ "pin-project-lite 0.2.13",
  "serde",
  "serde_json",
  "tokio",
@@ -754,7 +754,7 @@ dependencies = [
  "cfg-if 1.0.0",
  "libc",
  "miniz_oxide 0.7.1",
- "object 0.32.0",
+ "object 0.32.1",
  "rustc-demangle",
 ]
 
@@ -779,9 +779,9 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
 
 [[package]]
 name = "base64"
-version = "0.21.2"
+version = "0.21.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d"
+checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2"
 
 [[package]]
 name = "base64ct"
@@ -837,7 +837,7 @@ dependencies = [
  "regex",
  "rustc-hash",
  "shlex",
- "syn 2.0.29",
+ "syn 2.0.33",
  "which",
 ]
 
@@ -974,7 +974,7 @@ dependencies = [
  "collections",
  "editor",
  "gpui",
- "itertools",
+ "itertools 0.10.5",
  "language",
  "outline",
  "project",
@@ -997,20 +997,31 @@ dependencies = [
 
 [[package]]
 name = "bstr"
-version = "1.6.0"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223"
+dependencies = [
+ "lazy_static",
+ "memchr",
+ "regex-automata 0.1.10",
+]
+
+[[package]]
+name = "bstr"
+version = "1.6.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6798148dccfbff0fae41c7574d2fa8f1ef3492fba0face179de5d8d447d67b05"
+checksum = "4c2f7349907b712260e64b0afe2f84692af14a454be26187d9df565c7f69266a"
 dependencies = [
  "memchr",
- "regex-automata 0.3.6",
+ "regex-automata 0.3.8",
  "serde",
 ]
 
 [[package]]
 name = "bumpalo"
-version = "3.13.0"
+version = "3.14.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1"
+checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec"
 
 [[package]]
 name = "bytecheck"
@@ -1036,9 +1047,9 @@ dependencies = [
 
 [[package]]
 name = "bytemuck"
-version = "1.13.1"
+version = "1.14.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17febce684fd15d89027105661fec94afb475cb995fbc59d2865198446ba2eea"
+checksum = "374d28ec25809ee0e23827c2ab573d729e293f281dfe393500e7ad618baa61c6"
 
 [[package]]
 name = "byteorder"
@@ -1058,9 +1069,9 @@ dependencies = [
 
 [[package]]
 name = "bytes"
-version = "1.4.0"
+version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be"
+checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
 
 [[package]]
 name = "call"
@@ -1226,7 +1237,7 @@ dependencies = [
  "tempfile",
  "text",
  "thiserror",
- "time 0.3.27",
+ "time",
  "tiny_http",
  "url",
  "util",
@@ -1235,18 +1246,17 @@ dependencies = [
 
 [[package]]
 name = "chrono"
-version = "0.4.26"
+version = "0.4.30"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5"
+checksum = "defd4e7873dbddba6c7c91e199c7fcb946abc4a6a4ac3195400bcfb01b5de877"
 dependencies = [
  "android-tzdata",
  "iana-time-zone",
  "js-sys",
  "num-traits",
  "serde",
- "time 0.1.45",
  "wasm-bindgen",
- "winapi 0.3.9",
+ "windows-targets 0.48.5",
 ]
 
 [[package]]
@@ -1294,24 +1304,23 @@ dependencies = [
 
 [[package]]
 name = "clap"
-version = "4.3.24"
+version = "4.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fb690e81c7840c0d7aade59f242ea3b41b9bc27bcd5997890e7702ae4b32e487"
+checksum = "84ed82781cea27b43c9b106a979fe450a13a31aab0500595fb3fc06616de08e6"
 dependencies = [
  "clap_builder",
- "clap_derive 4.3.12",
- "once_cell",
+ "clap_derive 4.4.2",
 ]
 
 [[package]]
 name = "clap_builder"
-version = "4.3.24"
+version = "4.4.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ed2e96bc16d8d740f6f48d663eddf4b8a0983e79210fd55479b7bcd0a69860e"
+checksum = "2bb9faaa7c2ef94b2743a21f5a29e6f0010dff4caa69ac8e9d6cf8b6fa74da08"
 dependencies = [
  "anstream",
  "anstyle",
- "clap_lex 0.5.0",
+ "clap_lex 0.5.1",
  "strsim",
 ]
 
@@ -1330,14 +1339,14 @@ dependencies = [
 
 [[package]]
 name = "clap_derive"
-version = "4.3.12"
+version = "4.4.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "54a9bb5758fc5dfe728d1019941681eccaf0cf8a4189b692a0ee2f2ecf90a050"
+checksum = "0862016ff20d69b84ef8247369fabf5c008a7417002411897d40ee1f4532b873"
 dependencies = [
  "heck 0.4.1",
  "proc-macro2",
  "quote",
- "syn 2.0.29",
+ "syn 2.0.33",
 ]
 
 [[package]]
@@ -1351,9 +1360,9 @@ dependencies = [
 
 [[package]]
 name = "clap_lex"
-version = "0.5.0"
+version = "0.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b"
+checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961"
 
 [[package]]
 name = "cli"
@@ -1399,7 +1408,7 @@ dependencies = [
  "tempfile",
  "text",
  "thiserror",
- "time 0.3.27",
+ "time",
  "tiny_http",
  "url",
  "util",
@@ -1453,7 +1462,7 @@ dependencies = [
 
 [[package]]
 name = "collab"
-version = "0.20.0"
+version = "0.21.0"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -1507,7 +1516,7 @@ dependencies = [
  "sqlx",
  "text",
  "theme",
- "time 0.3.27",
+ "time",
  "tokio",
  "tokio-tungstenite",
  "toml 0.5.11",
@@ -1584,7 +1593,7 @@ version = "4.6.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4"
 dependencies = [
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "memchr",
 ]
 
@@ -1753,9 +1762,9 @@ dependencies = [
 
 [[package]]
 name = "core-services"
-version = "0.2.0"
+version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "51b344b958cae90858bf6086f49599ecc5ec8698eacad0ea155509ba11fab347"
+checksum = "92567e81db522550ebaf742c5d875624ec7820c2c7ee5f8c60e4ce7c2ae3c0fd"
 dependencies = [
  "core-foundation",
 ]
@@ -1924,7 +1933,7 @@ dependencies = [
  "cranelift-codegen",
  "cranelift-entity",
  "cranelift-frontend",
- "itertools",
+ "itertools 0.10.5",
  "log",
  "smallvec",
  "wasmparser",
@@ -2070,9 +2079,9 @@ dependencies = [
 
 [[package]]
 name = "dashmap"
-version = "5.5.1"
+version = "5.5.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edd72493923899c6f10c641bdbdeddc7183d6396641d99c1a0d1597f37f92e28"
+checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
 dependencies = [
  "cfg-if 1.0.0",
  "hashbrown 0.14.0",
@@ -2345,7 +2354,7 @@ dependencies = [
  "git",
  "gpui",
  "indoc",
- "itertools",
+ "itertools 0.10.5",
  "language",
  "lazy_static",
  "log",
@@ -2435,9 +2444,9 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
 
 [[package]]
 name = "erased-serde"
-version = "0.3.29"
+version = "0.3.31"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc978899517288e3ebbd1a3bfc1d9537dbb87eeab149e53ea490e63bcdff561a"
+checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c"
 dependencies = [
  "serde",
 ]
@@ -2455,9 +2464,9 @@ dependencies = [
 
 [[package]]
 name = "errno"
-version = "0.3.2"
+version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6b30f669a7961ef1631673d2766cc92f52d64f7ef354d4fe0ddfd30ed52f0f4f"
+checksum = "136526188508e25c6fef639d7927dfb3e0e3084488bf202267829cf7fc23dbdd"
 dependencies = [
  "errno-dragonfly",
  "libc",
@@ -2618,6 +2627,12 @@ dependencies = [
  "windows-sys",
 ]
 
+[[package]]
+name = "finl_unicode"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6"
+
 [[package]]
 name = "fixedbitset"
 version = "0.4.2"
@@ -2770,7 +2785,7 @@ dependencies = [
  "sum_tree",
  "tempfile",
  "text",
- "time 0.3.27",
+ "time",
  "util",
 ]
 
@@ -2908,7 +2923,7 @@ dependencies = [
  "futures-io",
  "memchr",
  "parking",
- "pin-project-lite 0.2.12",
+ "pin-project-lite 0.2.13",
  "waker-fn",
 ]
 
@@ -2920,7 +2935,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.29",
+ "syn 2.0.33",
 ]
 
 [[package]]
@@ -2949,7 +2964,7 @@ dependencies = [
  "futures-sink",
  "futures-task",
  "memchr",
- "pin-project-lite 0.2.12",
+ "pin-project-lite 0.2.13",
  "pin-utils",
  "slab",
  "tokio-io",
@@ -3078,8 +3093,8 @@ version = "0.4.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "759c97c1e17c55525b57192c06a267cda0ac5210b222d6b82189a2338fa1c13d"
 dependencies = [
- "aho-corasick 1.0.4",
- "bstr",
+ "aho-corasick 1.0.5",
+ "bstr 1.6.2",
  "fnv",
  "log",
  "regex",
@@ -3137,7 +3152,7 @@ dependencies = [
  "futures 0.3.28",
  "gpui_macros",
  "image",
- "itertools",
+ "itertools 0.10.5",
  "lazy_static",
  "log",
  "media",
@@ -3166,7 +3181,7 @@ dependencies = [
  "sum_tree",
  "taffy",
  "thiserror",
- "time 0.3.27",
+ "time",
  "tiny-skia",
  "usvg",
  "util",
@@ -3226,7 +3241,7 @@ version = "0.3.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "91fc23aa11be92976ef4729127f1a74adf36d8436f7816b185d18df956790833"
 dependencies = [
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "fnv",
  "futures-core",
  "futures-sink",
@@ -3287,22 +3302,21 @@ dependencies = [
 
 [[package]]
 name = "hashlink"
-version = "0.8.3"
+version = "0.8.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "312f66718a2d7789ffef4f4b7b213138ed9f1eb3aa1d0d82fc99f88fb3ffd26f"
+checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7"
 dependencies = [
  "hashbrown 0.14.0",
 ]
 
 [[package]]
 name = "headers"
-version = "0.3.8"
+version = "0.3.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3e372db8e5c0d213e0cd0b9be18be2aca3d44cf2fe30a9d46a65581cd454584"
+checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270"
 dependencies = [
- "base64 0.13.1",
- "bitflags 1.3.2",
- "bytes 1.4.0",
+ "base64 0.21.4",
+ "bytes 1.5.0",
  "headers-core",
  "http",
  "httpdate",
@@ -3416,7 +3430,7 @@ version = "0.2.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482"
 dependencies = [
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "fnv",
  "itoa",
 ]
@@ -3427,9 +3441,9 @@ version = "0.4.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1"
 dependencies = [
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "http",
- "pin-project-lite 0.2.12",
+ "pin-project-lite 0.2.13",
 ]
 
 [[package]]
@@ -3452,9 +3466,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
 
 [[package]]
 name = "human_bytes"
-version = "0.4.2"
+version = "0.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27e2b089f28ad15597b48d8c0a8fe94eeb1c1cb26ca99b6f66ac9582ae10c5e6"
+checksum = "91f255a4535024abf7640cb288260811fc14794f62b063652ed349f9a6c2348e"
 
 [[package]]
 name = "humantime"
@@ -3468,7 +3482,7 @@ version = "0.14.27"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468"
 dependencies = [
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "futures-channel",
  "futures-core",
  "futures-util",
@@ -3478,7 +3492,7 @@ dependencies = [
  "httparse",
  "httpdate",
  "itoa",
- "pin-project-lite 0.2.12",
+ "pin-project-lite 0.2.13",
  "socket2 0.4.9",
  "tokio",
  "tower-service",
@@ -3493,7 +3507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1"
 dependencies = [
  "hyper",
- "pin-project-lite 0.2.12",
+ "pin-project-lite 0.2.13",
  "tokio",
  "tokio-io-timeout",
 ]
@@ -3504,7 +3518,7 @@ version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
 dependencies = [
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "hyper",
  "native-tls",
  "tokio",
@@ -3711,7 +3725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b"
 dependencies = [
  "hermit-abi 0.3.2",
- "rustix 0.38.8",
+ "rustix 0.38.13",
  "windows-sys",
 ]
 
@@ -3751,6 +3765,15 @@ dependencies = [
  "either",
 ]
 
+[[package]]
+name = "itertools"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
+dependencies = [
+ "either",
+]
+
 [[package]]
 name = "itoa"
 version = "1.0.9"
@@ -4005,9 +4028,9 @@ checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
 
 [[package]]
 name = "libc"
-version = "0.2.147"
+version = "0.2.148"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
+checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b"
 
 [[package]]
 name = "libgit2-sys"
@@ -4115,9 +4138,9 @@ checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519"
 
 [[package]]
 name = "linux-raw-sys"
-version = "0.4.5"
+version = "0.4.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57bcfdad1b858c2db7c38303a6d2ad4dfaf5eb53dfeb0910128b2c26d6158503"
+checksum = "1a9bad9f94746442c783ca431b22403b519cd7fbeed0533fdd6328b2f2212128"
 
 [[package]]
 name = "lipsum"
@@ -4138,7 +4161,7 @@ dependencies = [
  "async-trait",
  "block",
  "byteorder",
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "cocoa",
  "collections",
  "core-foundation",
@@ -4316,7 +4339,7 @@ dependencies = [
  "anyhow",
  "bindgen 0.65.1",
  "block",
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "core-foundation",
  "foreign-types",
  "metal",
@@ -4325,9 +4348,9 @@ dependencies = [
 
 [[package]]
 name = "memchr"
-version = "2.6.0"
+version = "2.6.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76fc44e2588d5b436dbc3c6cf62aef290f90dab6235744a93dfe1cc18f451e2c"
+checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c"
 
 [[package]]
 name = "memfd"
@@ -4615,14 +4638,13 @@ dependencies = [
 
 [[package]]
 name = "nix"
-version = "0.26.2"
+version = "0.26.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfdda3d196821d6af13126e40375cdf7da646a96114af134d5f417a9a1dc8e1a"
+checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
 dependencies = [
  "bitflags 1.3.2",
  "cfg-if 1.0.0",
  "libc",
- "static_assertions",
 ]
 
 [[package]]
@@ -4889,9 +4911,9 @@ dependencies = [
 
 [[package]]
 name = "object"
-version = "0.32.0"
+version = "0.32.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77ac5bbd07aea88c60a577a1ce218075ffd59208b2d7ca97adf9bfc5aeb21ebe"
+checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0"
 dependencies = [
  "memchr",
 ]
@@ -4933,11 +4955,11 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
 
 [[package]]
 name = "openssl"
-version = "0.10.56"
+version = "0.10.57"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "729b745ad4a5575dd06a3e1af1414bd330ee561c01b3899eb584baeaa8def17e"
+checksum = "bac25ee399abb46215765b1cb35bc0212377e58a061560d8b29b024fd0430e7c"
 dependencies = [
- "bitflags 1.3.2",
+ "bitflags 2.4.0",
  "cfg-if 1.0.0",
  "foreign-types",
  "libc",
@@ -4954,7 +4976,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.29",
+ "syn 2.0.33",
 ]
 
 [[package]]
@@ -4965,9 +4987,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
 
 [[package]]
 name = "openssl-sys"
-version = "0.9.91"
+version = "0.9.93"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "866b5f16f90776b9bb8dc1e1802ac6f0513de3a7a7465867bfbc563dc737faac"
+checksum = "db4d56a4c0478783083cfafcc42493dd4a981d41669da64b4572a2a089b51b1d"
 dependencies = [
  "cc",
  "libc",
@@ -5195,10 +5217,11 @@ checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
 
 [[package]]
 name = "pest"
-version = "2.7.2"
+version = "2.7.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1acb4a4365a13f749a93f1a094a7805e5cfa0955373a9de860d962eaa3a5fe5a"
+checksum = "d7a4d085fd991ac8d5b05a147b437791b4260b76326baf0fc60cf7c9c27ecd33"
 dependencies = [
+ "memchr",
  "thiserror",
  "ucd-trie",
 ]
@@ -5253,7 +5276,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.29",
+ "syn 2.0.33",
 ]
 
 [[package]]
@@ -5264,9 +5287,9 @@ checksum = "257b64915a082f7811703966789728173279bdebb956b143dbcd23f6f970a777"
 
 [[package]]
 name = "pin-project-lite"
-version = "0.2.12"
+version = "0.2.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "12cc1b0bf1727a77a54b6654e7b5f1af8604923edc8b81885f8ec92f9e3f0a05"
+checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
 
 [[package]]
 name = "pin-utils"
@@ -5286,12 +5309,12 @@ version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bdc0001cfea3db57a2e24bc0d818e9e20e554b5f97fabb9bc231dc240269ae06"
 dependencies = [
- "base64 0.21.2",
+ "base64 0.21.4",
  "indexmap 1.9.3",
  "line-wrap",
  "quick-xml",
  "serde",
- "time 0.3.27",
+ "time",
 ]
 
 [[package]]
@@ -5356,7 +5379,7 @@ dependencies = [
  "concurrent-queue",
  "libc",
  "log",
- "pin-project-lite 0.2.12",
+ "pin-project-lite 0.2.13",
  "windows-sys",
 ]
 
@@ -5401,12 +5424,12 @@ dependencies = [
 
 [[package]]
 name = "prettyplease"
-version = "0.2.12"
+version = "0.2.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c64d9ba0963cdcea2e1b2230fbae2bab30eb25a174be395c41e764bfb65dd62"
+checksum = "ae005bd773ab59b4725093fd7df83fd7892f7d8eafb48dbd7de6e024e4215f9d"
 dependencies = [
  "proc-macro2",
- "syn 2.0.29",
+ "syn 2.0.33",
 ]
 
 [[package]]
@@ -5454,9 +5477,9 @@ dependencies = [
 
 [[package]]
 name = "proc-macro2"
-version = "1.0.66"
+version = "1.0.67"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9"
+checksum = "3d433d9f1a3e8c1263d9456598b16fec66f4acc9a74dacffd35c7bb09b3a1328"
 dependencies = [
  "unicode-ident",
 ]
@@ -5496,7 +5519,7 @@ dependencies = [
  "globset",
  "gpui",
  "ignore",
- "itertools",
+ "itertools 0.10.5",
  "language",
  "lazy_static",
  "log",
@@ -5598,7 +5621,7 @@ version = "0.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "de5e2533f59d08fcf364fd374ebda0692a70bd6d7e66ef97f306f45c6c5d8020"
 dependencies = [
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "prost-derive 0.8.0",
 ]
 
@@ -5608,7 +5631,7 @@ version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "444879275cb4fd84958b1a1d5420d15e6fcf7c235fe47f053c9c2a80aceb6001"
 dependencies = [
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "prost-derive 0.9.0",
 ]
 
@@ -5618,9 +5641,9 @@ version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "62941722fb675d463659e49c4f3fe1fe792ff24fe5bbaa9c08cd3b98a1c354f5"
 dependencies = [
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "heck 0.3.3",
- "itertools",
+ "itertools 0.10.5",
  "lazy_static",
  "log",
  "multimap",
@@ -5639,7 +5662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "600d2f334aa05acb02a755e217ef1ab6dea4d51b58b7846588b747edec04efba"
 dependencies = [
  "anyhow",
- "itertools",
+ "itertools 0.10.5",
  "proc-macro2",
  "quote",
  "syn 1.0.109",
@@ -5652,7 +5675,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f9cc1a3263e07e0bf68e96268f37665207b49560d98739662cdfaae215c720fe"
 dependencies = [
  "anyhow",
- "itertools",
+ "itertools 0.10.5",
  "proc-macro2",
  "quote",
  "syn 1.0.109",
@@ -5664,7 +5687,7 @@ version = "0.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "603bbd6394701d13f3f25aada59c7de9d35a6a5887cfc156181234a44002771b"
 dependencies = [
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "prost 0.8.0",
 ]
 
@@ -5674,7 +5697,7 @@ version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "534b7a0e836e3c482d2693070f982e39e7611da9695d4d1f5a4b186b51faef0a"
 dependencies = [
- "bytes 1.4.0",
+ "bytes 1.5.0",
  "prost 0.9.0",
 ]
 

Procfile 🔗

@@ -1,3 +1,4 @@
 web: cd ../zed.dev && PORT=3000 npx vercel dev
 collab: cd crates/collab && cargo run serve
-livekit: livekit-server --dev
+livekit: livekit-server --dev
+postgrest: postgrest crates/collab/admin_api.conf

README.md 🔗

@@ -12,14 +12,14 @@ Welcome to Zed, a lightning-fast, collaborative code editor that makes your drea
   ```
   sudo xcodebuild -license
   ```
-  
+
 * Install homebrew, node and rustup-init (rutup, rust, cargo, etc.)
   ```
   /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
   brew install node rustup-init
   rustup-init # follow the installation steps
   ```
-  
+
 * Install postgres and configure the database
   ```
   brew install postgresql@15
@@ -27,11 +27,12 @@ Welcome to Zed, a lightning-fast, collaborative code editor that makes your drea
   psql -c "CREATE ROLE postgres SUPERUSER LOGIN" postgres
   psql -U postgres -c "CREATE DATABASE zed"
   ```
-  
-* Install the `LiveKit` server and the `foreman` process supervisor:
+
+* Install the `LiveKit` server, the `PostgREST` API server, and the `foreman` process supervisor:
 
     ```
     brew install livekit
+    brew install postgrest
     brew install foreman
     ```
 

assets/keymaps/default.json 🔗

@@ -231,7 +231,14 @@
     }
   },
   {
-    "context": "BufferSearchBar > Editor",
+    "context": "BufferSearchBar && in_replace",
+    "bindings": {
+      "enter": "search::ReplaceNext",
+      "cmd-enter": "search::ReplaceAll"
+    }
+  },
+  {
+    "context": "BufferSearchBar && !in_replace > Editor",
     "bindings": {
       "up": "search::PreviousHistoryQuery",
       "down": "search::NextHistoryQuery"
@@ -533,7 +540,7 @@
       // TODO: Move this to a dock open action
       "cmd-shift-c": "collab_panel::ToggleFocus",
       "cmd-alt-i": "zed::DebugElements",
-      "ctrl-:": "editor::ToggleInlayHints",
+      "ctrl-:": "editor::ToggleInlayHints"
     }
   },
   {

assets/keymaps/vim.json 🔗

@@ -32,6 +32,8 @@
       "right": "vim::Right",
       "$": "vim::EndOfLine",
       "^": "vim::FirstNonWhitespace",
+      "_": "vim::StartOfLineDownward",
+      "g _": "vim::EndOfLineDownward",
       "shift-g": "vim::EndOfDocument",
       "w": "vim::NextWordStart",
       "{": "vim::StartOfParagraph",
@@ -326,7 +328,7 @@
     }
   },
   {
-    "context": "Editor && vim_mode == normal && (vim_operator == none || vim_operator == n) && !VimWaiting",
+    "context": "Editor && vim_mode == normal && vim_operator == none && !VimWaiting",
     "bindings": {
       ".": "vim::Repeat",
       "c": [
@@ -389,7 +391,7 @@
     }
   },
   {
-    "context": "Editor && vim_operator == n",
+    "context": "Editor && VimCount",
     "bindings": {
       "0": [
         "vim::Number",
@@ -497,7 +499,7 @@
             "around": true
           }
         }
-      ],
+      ]
     }
   },
   {

crates/ai/Cargo.toml 🔗

@@ -27,6 +27,7 @@ futures.workspace = true
 indoc.workspace = true
 isahc.workspace = true
 ordered-float.workspace = true
+parking_lot.workspace = true
 regex.workspace = true
 schemars.workspace = true
 serde.workspace = true

crates/ai/src/ai.rs 🔗

@@ -1,5 +1,6 @@
 pub mod assistant;
 mod assistant_settings;
+mod codegen;
 mod streaming_diff;
 
 use anyhow::{anyhow, Result};
@@ -26,7 +27,7 @@ use util::paths::CONVERSATIONS_DIR;
 const OPENAI_API_URL: &'static str = "https://api.openai.com/v1";
 
 // Data types for chat completion requests
-#[derive(Debug, Serialize)]
+#[derive(Debug, Default, Serialize)]
 pub struct OpenAIRequest {
     model: String,
     messages: Vec<RequestMessage>,

crates/ai/src/assistant.rs 🔗

@@ -1,9 +1,8 @@
 use crate::{
     assistant_settings::{AssistantDockPosition, AssistantSettings, OpenAIModel},
-    stream_completion,
-    streaming_diff::{Hunk, StreamingDiff},
-    MessageId, MessageMetadata, MessageStatus, OpenAIRequest, RequestMessage, Role,
-    SavedConversation, SavedConversationMetadata, SavedMessage, OPENAI_API_URL,
+    codegen::{self, Codegen, CodegenKind, OpenAICompletionProvider},
+    stream_completion, MessageId, MessageMetadata, MessageStatus, OpenAIRequest, RequestMessage,
+    Role, SavedConversation, SavedConversationMetadata, SavedMessage, OPENAI_API_URL,
 };
 use anyhow::{anyhow, Result};
 use chrono::{DateTime, Local};
@@ -13,10 +12,10 @@ use editor::{
         BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint,
     },
     scroll::autoscroll::{Autoscroll, AutoscrollStrategy},
-    Anchor, Editor, MoveDown, MoveUp, MultiBufferSnapshot, ToOffset, ToPoint,
+    Anchor, Editor, MoveDown, MoveUp, MultiBufferSnapshot, ToOffset,
 };
 use fs::Fs;
-use futures::{channel::mpsc, SinkExt, Stream, StreamExt};
+use futures::StreamExt;
 use gpui::{
     actions,
     elements::{
@@ -30,17 +29,14 @@ use gpui::{
     ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
     WindowContext,
 };
-use language::{
-    language_settings::SoftWrap, Buffer, LanguageRegistry, Point, Rope, ToOffset as _,
-    TransactionId,
-};
+use language::{language_settings::SoftWrap, Buffer, LanguageRegistry, ToOffset as _};
 use search::BufferSearchBar;
 use settings::SettingsStore;
 use std::{
     cell::{Cell, RefCell},
     cmp, env,
     fmt::Write,
-    future, iter,
+    iter,
     ops::Range,
     path::{Path, PathBuf},
     rc::Rc,
@@ -266,23 +262,40 @@ impl AssistantPanel {
     }
 
     fn new_inline_assist(&mut self, editor: &ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
+        let api_key = if let Some(api_key) = self.api_key.borrow().clone() {
+            api_key
+        } else {
+            return;
+        };
+
         let inline_assist_id = post_inc(&mut self.next_inline_assist_id);
         let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
+        let provider = Arc::new(OpenAICompletionProvider::new(
+            api_key,
+            cx.background().clone(),
+        ));
         let selection = editor.read(cx).selections.newest_anchor().clone();
-        let range = selection.start.bias_left(&snapshot)..selection.end.bias_right(&snapshot);
-        let assist_kind = if editor.read(cx).selections.newest::<usize>(cx).is_empty() {
-            InlineAssistKind::Generate
+        let codegen_kind = if editor.read(cx).selections.newest::<usize>(cx).is_empty() {
+            CodegenKind::Generate {
+                position: selection.start,
+            }
         } else {
-            InlineAssistKind::Transform
+            CodegenKind::Transform {
+                range: selection.start..selection.end,
+            }
         };
+        let codegen = cx.add_model(|cx| {
+            Codegen::new(editor.read(cx).buffer().clone(), codegen_kind, provider, cx)
+        });
+
         let measurements = Rc::new(Cell::new(BlockMeasurements::default()));
         let inline_assistant = cx.add_view(|cx| {
             let assistant = InlineAssistant::new(
                 inline_assist_id,
-                assist_kind,
                 measurements.clone(),
                 self.include_conversation_in_next_inline_assist,
                 self.inline_prompt_history.clone(),
+                codegen.clone(),
                 cx,
             );
             cx.focus_self();
@@ -321,45 +334,63 @@ impl AssistantPanel {
         self.pending_inline_assists.insert(
             inline_assist_id,
             PendingInlineAssist {
-                kind: assist_kind,
                 editor: editor.downgrade(),
-                range,
-                highlighted_ranges: Default::default(),
                 inline_assistant: Some((block_id, inline_assistant.clone())),
-                code_generation: Task::ready(None),
-                transaction_id: None,
+                codegen: codegen.clone(),
                 _subscriptions: vec![
                     cx.subscribe(&inline_assistant, Self::handle_inline_assistant_event),
                     cx.subscribe(editor, {
                         let inline_assistant = inline_assistant.downgrade();
-                        move |this, editor, event, cx| {
+                        move |_, editor, event, cx| {
                             if let Some(inline_assistant) = inline_assistant.upgrade(cx) {
-                                match event {
-                                    editor::Event::SelectionsChanged { local } => {
-                                        if *local && inline_assistant.read(cx).has_focus {
-                                            cx.focus(&editor);
-                                        }
+                                if let editor::Event::SelectionsChanged { local } = event {
+                                    if *local && inline_assistant.read(cx).has_focus {
+                                        cx.focus(&editor);
                                     }
-                                    editor::Event::TransactionUndone {
-                                        transaction_id: tx_id,
-                                    } => {
-                                        if let Some(pending_assist) =
-                                            this.pending_inline_assists.get(&inline_assist_id)
-                                        {
-                                            if pending_assist.transaction_id == Some(*tx_id) {
-                                                // Notice we are supplying `undo: false` here. This
-                                                // is because there's no need to undo the transaction
-                                                // because the user just did so.
-                                                this.close_inline_assist(
-                                                    inline_assist_id,
-                                                    false,
-                                                    cx,
-                                                );
-                                            }
-                                        }
+                                }
+                            }
+                        }
+                    }),
+                    cx.observe(&codegen, {
+                        let editor = editor.downgrade();
+                        move |this, _, cx| {
+                            if let Some(editor) = editor.upgrade(cx) {
+                                this.update_highlights_for_editor(&editor, cx);
+                            }
+                        }
+                    }),
+                    cx.subscribe(&codegen, move |this, codegen, event, cx| match event {
+                        codegen::Event::Undone => {
+                            this.finish_inline_assist(inline_assist_id, false, cx)
+                        }
+                        codegen::Event::Finished => {
+                            let pending_assist = if let Some(pending_assist) =
+                                this.pending_inline_assists.get(&inline_assist_id)
+                            {
+                                pending_assist
+                            } else {
+                                return;
+                            };
+
+                            let error = codegen
+                                .read(cx)
+                                .error()
+                                .map(|error| format!("Inline assistant error: {}", error));
+                            if let Some(error) = error {
+                                if pending_assist.inline_assistant.is_none() {
+                                    if let Some(workspace) = this.workspace.upgrade(cx) {
+                                        workspace.update(cx, |workspace, cx| {
+                                            workspace.show_toast(
+                                                Toast::new(inline_assist_id, error),
+                                                cx,
+                                            );
+                                        })
                                     }
-                                    _ => {}
+
+                                    this.finish_inline_assist(inline_assist_id, false, cx);
                                 }
+                            } else {
+                                this.finish_inline_assist(inline_assist_id, false, cx);
                             }
                         }
                     }),
@@ -388,7 +419,7 @@ impl AssistantPanel {
                 self.confirm_inline_assist(assist_id, prompt, *include_conversation, cx);
             }
             InlineAssistantEvent::Canceled => {
-                self.close_inline_assist(assist_id, true, cx);
+                self.finish_inline_assist(assist_id, true, cx);
             }
             InlineAssistantEvent::Dismissed => {
                 self.hide_inline_assist(assist_id, cx);
@@ -417,7 +448,7 @@ impl AssistantPanel {
                         .get(&editor.downgrade())
                         .and_then(|assist_ids| assist_ids.last().copied())
                     {
-                        panel.close_inline_assist(assist_id, true, cx);
+                        panel.finish_inline_assist(assist_id, true, cx);
                         true
                     } else {
                         false
@@ -432,7 +463,7 @@ impl AssistantPanel {
         cx.propagate_action();
     }
 
-    fn close_inline_assist(&mut self, assist_id: usize, undo: bool, cx: &mut ViewContext<Self>) {
+    fn finish_inline_assist(&mut self, assist_id: usize, undo: bool, cx: &mut ViewContext<Self>) {
         self.hide_inline_assist(assist_id, cx);
 
         if let Some(pending_assist) = self.pending_inline_assists.remove(&assist_id) {
@@ -450,13 +481,9 @@ impl AssistantPanel {
                 self.update_highlights_for_editor(&editor, cx);
 
                 if undo {
-                    if let Some(transaction_id) = pending_assist.transaction_id {
-                        editor.update(cx, |editor, cx| {
-                            editor.buffer().update(cx, |buffer, cx| {
-                                buffer.undo_transaction(transaction_id, cx)
-                            });
-                        });
-                    }
+                    pending_assist
+                        .codegen
+                        .update(cx, |codegen, cx| codegen.undo(cx));
                 }
             }
         }
@@ -481,12 +508,6 @@ impl AssistantPanel {
         include_conversation: bool,
         cx: &mut ViewContext<Self>,
     ) {
-        let api_key = if let Some(api_key) = self.api_key.borrow().clone() {
-            api_key
-        } else {
-            return;
-        };
-
         let conversation = if include_conversation {
             self.active_editor()
                 .map(|editor| editor.read(cx).conversation.clone())
@@ -514,56 +535,9 @@ impl AssistantPanel {
             self.inline_prompt_history.pop_front();
         }
 
-        let range = pending_assist.range.clone();
         let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
-        let selected_text = snapshot
-            .text_for_range(range.start..range.end)
-            .collect::<Rope>();
-
-        let selection_start = range.start.to_point(&snapshot);
-        let selection_end = range.end.to_point(&snapshot);
-
-        let mut base_indent: Option<language::IndentSize> = None;
-        let mut start_row = selection_start.row;
-        if snapshot.is_line_blank(start_row) {
-            if let Some(prev_non_blank_row) = snapshot.prev_non_blank_row(start_row) {
-                start_row = prev_non_blank_row;
-            }
-        }
-        for row in start_row..=selection_end.row {
-            if snapshot.is_line_blank(row) {
-                continue;
-            }
-
-            let line_indent = snapshot.indent_size_for_line(row);
-            if let Some(base_indent) = base_indent.as_mut() {
-                if line_indent.len < base_indent.len {
-                    *base_indent = line_indent;
-                }
-            } else {
-                base_indent = Some(line_indent);
-            }
-        }
-
-        let mut normalized_selected_text = selected_text.clone();
-        if let Some(base_indent) = base_indent {
-            for row in selection_start.row..=selection_end.row {
-                let selection_row = row - selection_start.row;
-                let line_start =
-                    normalized_selected_text.point_to_offset(Point::new(selection_row, 0));
-                let indent_len = if row == selection_start.row {
-                    base_indent.len.saturating_sub(selection_start.column)
-                } else {
-                    let line_len = normalized_selected_text.line_len(selection_row);
-                    cmp::min(line_len, base_indent.len)
-                };
-                let indent_end = cmp::min(
-                    line_start + indent_len as usize,
-                    normalized_selected_text.len(),
-                );
-                normalized_selected_text.replace(line_start..indent_end, "");
-            }
-        }
+        let range = pending_assist.codegen.read(cx).range();
+        let selected_text = snapshot.text_for_range(range.clone()).collect::<String>();
 
         let language = snapshot.language_at(range.start);
         let language_name = if let Some(language) = language.as_ref() {
@@ -581,8 +555,8 @@ impl AssistantPanel {
         if let Some(language_name) = language_name {
             writeln!(prompt, "You're an expert {language_name} engineer.").unwrap();
         }
-        match pending_assist.kind {
-            InlineAssistKind::Transform => {
+        match pending_assist.codegen.read(cx).kind() {
+            CodegenKind::Transform { .. } => {
                 writeln!(
                     prompt,
                     "You're currently working inside an editor on this file:"
@@ -608,7 +582,7 @@ impl AssistantPanel {
                 } else {
                     writeln!(prompt, "```").unwrap();
                 }
-                writeln!(prompt, "{normalized_selected_text}").unwrap();
+                writeln!(prompt, "{selected_text}").unwrap();
                 writeln!(prompt, "```").unwrap();
                 writeln!(prompt).unwrap();
                 writeln!(
@@ -622,7 +596,7 @@ impl AssistantPanel {
                 )
                 .unwrap();
             }
-            InlineAssistKind::Generate => {
+            CodegenKind::Generate { .. } => {
                 writeln!(
                     prompt,
                     "You're currently working inside an editor on this file:"
@@ -689,209 +663,9 @@ impl AssistantPanel {
             messages,
             stream: true,
         };
-        let response = stream_completion(api_key, cx.background().clone(), request);
-        let editor = editor.downgrade();
-
-        pending_assist.code_generation = cx.spawn(|this, mut cx| {
-            async move {
-                let mut edit_start = range.start.to_offset(&snapshot);
-
-                let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1);
-                let diff = cx.background().spawn(async move {
-                    let chunks = strip_markdown_codeblock(response.await?.filter_map(
-                        |message| async move {
-                            match message {
-                                Ok(mut message) => Some(Ok(message.choices.pop()?.delta.content?)),
-                                Err(error) => Some(Err(error)),
-                            }
-                        },
-                    ));
-                    futures::pin_mut!(chunks);
-                    let mut diff = StreamingDiff::new(selected_text.to_string());
-
-                    let mut indent_len;
-                    let indent_text;
-                    if let Some(base_indent) = base_indent {
-                        indent_len = base_indent.len;
-                        indent_text = match base_indent.kind {
-                            language::IndentKind::Space => " ",
-                            language::IndentKind::Tab => "\t",
-                        };
-                    } else {
-                        indent_len = 0;
-                        indent_text = "";
-                    };
-
-                    let mut first_line_len = 0;
-                    let mut first_line_non_whitespace_char_ix = None;
-                    let mut first_line = true;
-                    let mut new_text = String::new();
-
-                    while let Some(chunk) = chunks.next().await {
-                        let chunk = chunk?;
-
-                        let mut lines = chunk.split('\n');
-                        if let Some(mut line) = lines.next() {
-                            if first_line {
-                                if first_line_non_whitespace_char_ix.is_none() {
-                                    if let Some(mut char_ix) =
-                                        line.find(|ch: char| !ch.is_whitespace())
-                                    {
-                                        line = &line[char_ix..];
-                                        char_ix += first_line_len;
-                                        first_line_non_whitespace_char_ix = Some(char_ix);
-                                        let first_line_indent = char_ix
-                                            .saturating_sub(selection_start.column as usize)
-                                            as usize;
-                                        new_text.push_str(&indent_text.repeat(first_line_indent));
-                                        indent_len = indent_len.saturating_sub(char_ix as u32);
-                                    }
-                                }
-                                first_line_len += line.len();
-                            }
-
-                            if first_line_non_whitespace_char_ix.is_some() {
-                                new_text.push_str(line);
-                            }
-                        }
-
-                        for line in lines {
-                            first_line = false;
-                            new_text.push('\n');
-                            if !line.is_empty() {
-                                new_text.push_str(&indent_text.repeat(indent_len as usize));
-                            }
-                            new_text.push_str(line);
-                        }
-
-                        let hunks = diff.push_new(&new_text);
-                        hunks_tx.send(hunks).await?;
-                        new_text.clear();
-                    }
-                    hunks_tx.send(diff.finish()).await?;
-
-                    anyhow::Ok(())
-                });
-
-                while let Some(hunks) = hunks_rx.next().await {
-                    let editor = if let Some(editor) = editor.upgrade(&cx) {
-                        editor
-                    } else {
-                        break;
-                    };
-
-                    let this = if let Some(this) = this.upgrade(&cx) {
-                        this
-                    } else {
-                        break;
-                    };
-
-                    this.update(&mut cx, |this, cx| {
-                        let pending_assist = if let Some(pending_assist) =
-                            this.pending_inline_assists.get_mut(&inline_assist_id)
-                        {
-                            pending_assist
-                        } else {
-                            return;
-                        };
-
-                        pending_assist.highlighted_ranges.clear();
-                        editor.update(cx, |editor, cx| {
-                            let transaction = editor.buffer().update(cx, |buffer, cx| {
-                                // Avoid grouping assistant edits with user edits.
-                                buffer.finalize_last_transaction(cx);
-
-                                buffer.start_transaction(cx);
-                                buffer.edit(
-                                    hunks.into_iter().filter_map(|hunk| match hunk {
-                                        Hunk::Insert { text } => {
-                                            let edit_start = snapshot.anchor_after(edit_start);
-                                            Some((edit_start..edit_start, text))
-                                        }
-                                        Hunk::Remove { len } => {
-                                            let edit_end = edit_start + len;
-                                            let edit_range = snapshot.anchor_after(edit_start)
-                                                ..snapshot.anchor_before(edit_end);
-                                            edit_start = edit_end;
-                                            Some((edit_range, String::new()))
-                                        }
-                                        Hunk::Keep { len } => {
-                                            let edit_end = edit_start + len;
-                                            let edit_range = snapshot.anchor_after(edit_start)
-                                                ..snapshot.anchor_before(edit_end);
-                                            edit_start += len;
-                                            pending_assist.highlighted_ranges.push(edit_range);
-                                            None
-                                        }
-                                    }),
-                                    None,
-                                    cx,
-                                );
-
-                                buffer.end_transaction(cx)
-                            });
-
-                            if let Some(transaction) = transaction {
-                                if let Some(first_transaction) = pending_assist.transaction_id {
-                                    // Group all assistant edits into the first transaction.
-                                    editor.buffer().update(cx, |buffer, cx| {
-                                        buffer.merge_transactions(
-                                            transaction,
-                                            first_transaction,
-                                            cx,
-                                        )
-                                    });
-                                } else {
-                                    pending_assist.transaction_id = Some(transaction);
-                                    editor.buffer().update(cx, |buffer, cx| {
-                                        buffer.finalize_last_transaction(cx)
-                                    });
-                                }
-                            }
-                        });
-
-                        this.update_highlights_for_editor(&editor, cx);
-                    });
-                }
-
-                if let Err(error) = diff.await {
-                    this.update(&mut cx, |this, cx| {
-                        let pending_assist = if let Some(pending_assist) =
-                            this.pending_inline_assists.get_mut(&inline_assist_id)
-                        {
-                            pending_assist
-                        } else {
-                            return;
-                        };
-
-                        if let Some((_, inline_assistant)) =
-                            pending_assist.inline_assistant.as_ref()
-                        {
-                            inline_assistant.update(cx, |inline_assistant, cx| {
-                                inline_assistant.set_error(error, cx);
-                            });
-                        } else if let Some(workspace) = this.workspace.upgrade(cx) {
-                            workspace.update(cx, |workspace, cx| {
-                                workspace.show_toast(
-                                    Toast::new(
-                                        inline_assist_id,
-                                        format!("Inline assistant error: {}", error),
-                                    ),
-                                    cx,
-                                );
-                            })
-                        }
-                    })?;
-                } else {
-                    let _ = this.update(&mut cx, |this, cx| {
-                        this.close_inline_assist(inline_assist_id, false, cx)
-                    });
-                }
-
-                anyhow::Ok(())
-            }
-            .log_err()
-        });
+        pending_assist
+            .codegen
+            .update(cx, |codegen, cx| codegen.start(request, cx));
     }
 
     fn update_highlights_for_editor(
@@ -909,8 +683,9 @@ impl AssistantPanel {
 
         for inline_assist_id in inline_assist_ids {
             if let Some(pending_assist) = self.pending_inline_assists.get(inline_assist_id) {
-                background_ranges.push(pending_assist.range.clone());
-                foreground_ranges.extend(pending_assist.highlighted_ranges.iter().cloned());
+                let codegen = pending_assist.codegen.read(cx);
+                background_ranges.push(codegen.range());
+                foreground_ranges.extend(codegen.last_equal_ranges().iter().cloned());
             }
         }
 
@@ -929,7 +704,7 @@ impl AssistantPanel {
             }
 
             if foreground_ranges.is_empty() {
-                editor.clear_text_highlights::<PendingInlineAssist>(cx);
+                editor.clear_highlights::<PendingInlineAssist>(cx);
             } else {
                 editor.highlight_text::<PendingInlineAssist>(
                     foreground_ranges,
@@ -2887,12 +2662,6 @@ enum InlineAssistantEvent {
     },
 }
 
-#[derive(Copy, Clone)]
-enum InlineAssistKind {
-    Transform,
-    Generate,
-}
-
 struct InlineAssistant {
     id: usize,
     prompt_editor: ViewHandle<Editor>,
@@ -2900,11 +2669,11 @@ struct InlineAssistant {
     has_focus: bool,
     include_conversation: bool,
     measurements: Rc<Cell<BlockMeasurements>>,
-    error: Option<anyhow::Error>,
     prompt_history: VecDeque<String>,
     prompt_history_ix: Option<usize>,
     pending_prompt: String,
-    _subscription: Subscription,
+    codegen: ModelHandle<Codegen>,
+    _subscriptions: Vec<Subscription>,
 }
 
 impl Entity for InlineAssistant {
@@ -2933,7 +2702,7 @@ impl View for InlineAssistant {
                             .element()
                             .aligned(),
                     )
-                    .with_children(if let Some(error) = self.error.as_ref() {
+                    .with_children(if let Some(error) = self.codegen.read(cx).error() {
                         Some(
                             Svg::new("icons/circle_x_mark_12.svg")
                                 .with_color(theme.assistant.error_icon.color)
@@ -3007,10 +2776,10 @@ impl View for InlineAssistant {
 impl InlineAssistant {
     fn new(
         id: usize,
-        kind: InlineAssistKind,
         measurements: Rc<Cell<BlockMeasurements>>,
         include_conversation: bool,
         prompt_history: VecDeque<String>,
+        codegen: ModelHandle<Codegen>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
         let prompt_editor = cx.add_view(|cx| {
@@ -3018,14 +2787,17 @@ impl InlineAssistant {
                 Some(Arc::new(|theme| theme.assistant.inline.editor.clone())),
                 cx,
             );
-            let placeholder = match kind {
-                InlineAssistKind::Transform => "Enter transformation prompt…",
-                InlineAssistKind::Generate => "Enter generation prompt…",
+            let placeholder = match codegen.read(cx).kind() {
+                CodegenKind::Transform { .. } => "Enter transformation prompt…",
+                CodegenKind::Generate { .. } => "Enter generation prompt…",
             };
             editor.set_placeholder_text(placeholder, cx);
             editor
         });
-        let subscription = cx.subscribe(&prompt_editor, Self::handle_prompt_editor_events);
+        let subscriptions = vec![
+            cx.observe(&codegen, Self::handle_codegen_changed),
+            cx.subscribe(&prompt_editor, Self::handle_prompt_editor_events),
+        ];
         Self {
             id,
             prompt_editor,
@@ -3033,11 +2805,11 @@ impl InlineAssistant {
             has_focus: false,
             include_conversation,
             measurements,
-            error: None,
             prompt_history,
             prompt_history_ix: None,
             pending_prompt: String::new(),
-            _subscription: subscription,
+            codegen,
+            _subscriptions: subscriptions,
         }
     }
 
@@ -3053,6 +2825,32 @@ impl InlineAssistant {
         }
     }
 
+    fn handle_codegen_changed(&mut self, _: ModelHandle<Codegen>, cx: &mut ViewContext<Self>) {
+        let is_read_only = !self.codegen.read(cx).idle();
+        self.prompt_editor.update(cx, |editor, cx| {
+            let was_read_only = editor.read_only();
+            if was_read_only != is_read_only {
+                if is_read_only {
+                    editor.set_read_only(true);
+                    editor.set_field_editor_style(
+                        Some(Arc::new(|theme| {
+                            theme.assistant.inline.disabled_editor.clone()
+                        })),
+                        cx,
+                    );
+                } else {
+                    self.confirmed = false;
+                    editor.set_read_only(false);
+                    editor.set_field_editor_style(
+                        Some(Arc::new(|theme| theme.assistant.inline.editor.clone())),
+                        cx,
+                    );
+                }
+            }
+        });
+        cx.notify();
+    }
+
     fn cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext<Self>) {
         cx.emit(InlineAssistantEvent::Canceled);
     }
@@ -3076,7 +2874,6 @@ impl InlineAssistant {
                 include_conversation: self.include_conversation,
             });
             self.confirmed = true;
-            self.error = None;
             cx.notify();
         }
     }
@@ -3093,19 +2890,6 @@ impl InlineAssistant {
         cx.notify();
     }
 
-    fn set_error(&mut self, error: anyhow::Error, cx: &mut ViewContext<Self>) {
-        self.error = Some(error);
-        self.confirmed = false;
-        self.prompt_editor.update(cx, |editor, cx| {
-            editor.set_read_only(false);
-            editor.set_field_editor_style(
-                Some(Arc::new(|theme| theme.assistant.inline.editor.clone())),
-                cx,
-            );
-        });
-        cx.notify();
-    }
-
     fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext<Self>) {
         if let Some(ix) = self.prompt_history_ix {
             if ix > 0 {
@@ -3152,13 +2936,9 @@ struct BlockMeasurements {
 }
 
 struct PendingInlineAssist {
-    kind: InlineAssistKind,
     editor: WeakViewHandle<Editor>,
-    range: Range<Anchor>,
-    highlighted_ranges: Vec<Range<Anchor>>,
     inline_assistant: Option<(BlockId, ViewHandle<InlineAssistant>)>,
-    code_generation: Task<Option<()>>,
-    transaction_id: Option<TransactionId>,
+    codegen: ModelHandle<Codegen>,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -3184,65 +2964,10 @@ fn merge_ranges(ranges: &mut Vec<Range<Anchor>>, buffer: &MultiBufferSnapshot) {
     }
 }
 
-fn strip_markdown_codeblock(
-    stream: impl Stream<Item = Result<String>>,
-) -> impl Stream<Item = Result<String>> {
-    let mut first_line = true;
-    let mut buffer = String::new();
-    let mut starts_with_fenced_code_block = false;
-    stream.filter_map(move |chunk| {
-        let chunk = match chunk {
-            Ok(chunk) => chunk,
-            Err(err) => return future::ready(Some(Err(err))),
-        };
-        buffer.push_str(&chunk);
-
-        if first_line {
-            if buffer == "" || buffer == "`" || buffer == "``" {
-                return future::ready(None);
-            } else if buffer.starts_with("```") {
-                starts_with_fenced_code_block = true;
-                if let Some(newline_ix) = buffer.find('\n') {
-                    buffer.replace_range(..newline_ix + 1, "");
-                    first_line = false;
-                } else {
-                    return future::ready(None);
-                }
-            }
-        }
-
-        let text = if starts_with_fenced_code_block {
-            buffer
-                .strip_suffix("\n```\n")
-                .or_else(|| buffer.strip_suffix("\n```"))
-                .or_else(|| buffer.strip_suffix("\n``"))
-                .or_else(|| buffer.strip_suffix("\n`"))
-                .or_else(|| buffer.strip_suffix('\n'))
-                .unwrap_or(&buffer)
-        } else {
-            &buffer
-        };
-
-        if text.contains('\n') {
-            first_line = false;
-        }
-
-        let remainder = buffer.split_off(text.len());
-        let result = if buffer.is_empty() {
-            None
-        } else {
-            Some(Ok(buffer.clone()))
-        };
-        buffer = remainder;
-        future::ready(result)
-    })
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
     use crate::MessageId;
-    use futures::stream;
     use gpui::AppContext;
 
     #[gpui::test]
@@ -3611,62 +3336,6 @@ mod tests {
         );
     }
 
-    #[gpui::test]
-    async fn test_strip_markdown_codeblock() {
-        assert_eq!(
-            strip_markdown_codeblock(chunks("Lorem ipsum dolor", 2))
-                .map(|chunk| chunk.unwrap())
-                .collect::<String>()
-                .await,
-            "Lorem ipsum dolor"
-        );
-        assert_eq!(
-            strip_markdown_codeblock(chunks("```\nLorem ipsum dolor", 2))
-                .map(|chunk| chunk.unwrap())
-                .collect::<String>()
-                .await,
-            "Lorem ipsum dolor"
-        );
-        assert_eq!(
-            strip_markdown_codeblock(chunks("```\nLorem ipsum dolor\n```", 2))
-                .map(|chunk| chunk.unwrap())
-                .collect::<String>()
-                .await,
-            "Lorem ipsum dolor"
-        );
-        assert_eq!(
-            strip_markdown_codeblock(chunks("```\nLorem ipsum dolor\n```\n", 2))
-                .map(|chunk| chunk.unwrap())
-                .collect::<String>()
-                .await,
-            "Lorem ipsum dolor"
-        );
-        assert_eq!(
-            strip_markdown_codeblock(chunks("```html\n```js\nLorem ipsum dolor\n```\n```", 2))
-                .map(|chunk| chunk.unwrap())
-                .collect::<String>()
-                .await,
-            "```js\nLorem ipsum dolor\n```"
-        );
-        assert_eq!(
-            strip_markdown_codeblock(chunks("``\nLorem ipsum dolor\n```", 2))
-                .map(|chunk| chunk.unwrap())
-                .collect::<String>()
-                .await,
-            "``\nLorem ipsum dolor\n```"
-        );
-
-        fn chunks(text: &str, size: usize) -> impl Stream<Item = Result<String>> {
-            stream::iter(
-                text.chars()
-                    .collect::<Vec<_>>()
-                    .chunks(size)
-                    .map(|chunk| Ok(chunk.iter().collect::<String>()))
-                    .collect::<Vec<_>>(),
-            )
-        }
-    }
-
     fn messages(
         conversation: &ModelHandle<Conversation>,
         cx: &AppContext,

crates/ai/src/codegen.rs 🔗

@@ -0,0 +1,704 @@
+use crate::{
+    stream_completion,
+    streaming_diff::{Hunk, StreamingDiff},
+    OpenAIRequest,
+};
+use anyhow::Result;
+use editor::{
+    multi_buffer, Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint,
+};
+use futures::{
+    channel::mpsc, future::BoxFuture, stream::BoxStream, FutureExt, SinkExt, Stream, StreamExt,
+};
+use gpui::{executor::Background, Entity, ModelContext, ModelHandle, Task};
+use language::{Rope, TransactionId};
+use std::{cmp, future, ops::Range, sync::Arc};
+
+pub trait CompletionProvider {
+    fn complete(
+        &self,
+        prompt: OpenAIRequest,
+    ) -> BoxFuture<'static, Result<BoxStream<'static, Result<String>>>>;
+}
+
+pub struct OpenAICompletionProvider {
+    api_key: String,
+    executor: Arc<Background>,
+}
+
+impl OpenAICompletionProvider {
+    pub fn new(api_key: String, executor: Arc<Background>) -> Self {
+        Self { api_key, executor }
+    }
+}
+
+impl CompletionProvider for OpenAICompletionProvider {
+    fn complete(
+        &self,
+        prompt: OpenAIRequest,
+    ) -> BoxFuture<'static, Result<BoxStream<'static, Result<String>>>> {
+        let request = stream_completion(self.api_key.clone(), self.executor.clone(), prompt);
+        async move {
+            let response = request.await?;
+            let stream = response
+                .filter_map(|response| async move {
+                    match response {
+                        Ok(mut response) => Some(Ok(response.choices.pop()?.delta.content?)),
+                        Err(error) => Some(Err(error)),
+                    }
+                })
+                .boxed();
+            Ok(stream)
+        }
+        .boxed()
+    }
+}
+
+pub enum Event {
+    Finished,
+    Undone,
+}
+
+#[derive(Clone)]
+pub enum CodegenKind {
+    Transform { range: Range<Anchor> },
+    Generate { position: Anchor },
+}
+
+pub struct Codegen {
+    provider: Arc<dyn CompletionProvider>,
+    buffer: ModelHandle<MultiBuffer>,
+    snapshot: MultiBufferSnapshot,
+    kind: CodegenKind,
+    last_equal_ranges: Vec<Range<Anchor>>,
+    transaction_id: Option<TransactionId>,
+    error: Option<anyhow::Error>,
+    generation: Task<()>,
+    idle: bool,
+    _subscription: gpui::Subscription,
+}
+
+impl Entity for Codegen {
+    type Event = Event;
+}
+
+impl Codegen {
+    pub fn new(
+        buffer: ModelHandle<MultiBuffer>,
+        mut kind: CodegenKind,
+        provider: Arc<dyn CompletionProvider>,
+        cx: &mut ModelContext<Self>,
+    ) -> Self {
+        let snapshot = buffer.read(cx).snapshot(cx);
+        match &mut kind {
+            CodegenKind::Transform { range } => {
+                let mut point_range = range.to_point(&snapshot);
+                point_range.start.column = 0;
+                if point_range.end.column > 0 || point_range.start.row == point_range.end.row {
+                    point_range.end.column = snapshot.line_len(point_range.end.row);
+                }
+                range.start = snapshot.anchor_before(point_range.start);
+                range.end = snapshot.anchor_after(point_range.end);
+            }
+            CodegenKind::Generate { position } => {
+                *position = position.bias_right(&snapshot);
+            }
+        }
+
+        Self {
+            provider,
+            buffer: buffer.clone(),
+            snapshot,
+            kind,
+            last_equal_ranges: Default::default(),
+            transaction_id: Default::default(),
+            error: Default::default(),
+            idle: true,
+            generation: Task::ready(()),
+            _subscription: cx.subscribe(&buffer, Self::handle_buffer_event),
+        }
+    }
+
+    fn handle_buffer_event(
+        &mut self,
+        _buffer: ModelHandle<MultiBuffer>,
+        event: &multi_buffer::Event,
+        cx: &mut ModelContext<Self>,
+    ) {
+        if let multi_buffer::Event::TransactionUndone { transaction_id } = event {
+            if self.transaction_id == Some(*transaction_id) {
+                self.transaction_id = None;
+                self.generation = Task::ready(());
+                cx.emit(Event::Undone);
+            }
+        }
+    }
+
+    pub fn range(&self) -> Range<Anchor> {
+        match &self.kind {
+            CodegenKind::Transform { range } => range.clone(),
+            CodegenKind::Generate { position } => position.bias_left(&self.snapshot)..*position,
+        }
+    }
+
+    pub fn kind(&self) -> &CodegenKind {
+        &self.kind
+    }
+
+    pub fn last_equal_ranges(&self) -> &[Range<Anchor>] {
+        &self.last_equal_ranges
+    }
+
+    pub fn idle(&self) -> bool {
+        self.idle
+    }
+
+    pub fn error(&self) -> Option<&anyhow::Error> {
+        self.error.as_ref()
+    }
+
+    pub fn start(&mut self, prompt: OpenAIRequest, cx: &mut ModelContext<Self>) {
+        let range = self.range();
+        let snapshot = self.snapshot.clone();
+        let selected_text = snapshot
+            .text_for_range(range.start..range.end)
+            .collect::<Rope>();
+
+        let selection_start = range.start.to_point(&snapshot);
+        let suggested_line_indent = snapshot
+            .suggested_indents(selection_start.row..selection_start.row + 1, cx)
+            .into_values()
+            .next()
+            .unwrap_or_else(|| snapshot.indent_size_for_line(selection_start.row));
+
+        let response = self.provider.complete(prompt);
+        self.generation = cx.spawn_weak(|this, mut cx| {
+            async move {
+                let generate = async {
+                    let mut edit_start = range.start.to_offset(&snapshot);
+
+                    let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1);
+                    let diff = cx.background().spawn(async move {
+                        let chunks = strip_markdown_codeblock(response.await?);
+                        futures::pin_mut!(chunks);
+                        let mut diff = StreamingDiff::new(selected_text.to_string());
+
+                        let mut new_text = String::new();
+                        let mut base_indent = None;
+                        let mut line_indent = None;
+                        let mut first_line = true;
+
+                        while let Some(chunk) = chunks.next().await {
+                            let chunk = chunk?;
+
+                            let mut lines = chunk.split('\n').peekable();
+                            while let Some(line) = lines.next() {
+                                new_text.push_str(line);
+                                if line_indent.is_none() {
+                                    if let Some(non_whitespace_ch_ix) =
+                                        new_text.find(|ch: char| !ch.is_whitespace())
+                                    {
+                                        line_indent = Some(non_whitespace_ch_ix);
+                                        base_indent = base_indent.or(line_indent);
+
+                                        let line_indent = line_indent.unwrap();
+                                        let base_indent = base_indent.unwrap();
+                                        let indent_delta = line_indent as i32 - base_indent as i32;
+                                        let mut corrected_indent_len = cmp::max(
+                                            0,
+                                            suggested_line_indent.len as i32 + indent_delta,
+                                        )
+                                            as usize;
+                                        if first_line {
+                                            corrected_indent_len = corrected_indent_len
+                                                .saturating_sub(selection_start.column as usize);
+                                        }
+
+                                        let indent_char = suggested_line_indent.char();
+                                        let mut indent_buffer = [0; 4];
+                                        let indent_str =
+                                            indent_char.encode_utf8(&mut indent_buffer);
+                                        new_text.replace_range(
+                                            ..line_indent,
+                                            &indent_str.repeat(corrected_indent_len),
+                                        );
+                                    }
+                                }
+
+                                if line_indent.is_some() {
+                                    hunks_tx.send(diff.push_new(&new_text)).await?;
+                                    new_text.clear();
+                                }
+
+                                if lines.peek().is_some() {
+                                    hunks_tx.send(diff.push_new("\n")).await?;
+                                    line_indent = None;
+                                    first_line = false;
+                                }
+                            }
+                        }
+                        hunks_tx.send(diff.push_new(&new_text)).await?;
+                        hunks_tx.send(diff.finish()).await?;
+
+                        anyhow::Ok(())
+                    });
+
+                    while let Some(hunks) = hunks_rx.next().await {
+                        let this = if let Some(this) = this.upgrade(&cx) {
+                            this
+                        } else {
+                            break;
+                        };
+
+                        this.update(&mut cx, |this, cx| {
+                            this.last_equal_ranges.clear();
+
+                            let transaction = this.buffer.update(cx, |buffer, cx| {
+                                // Avoid grouping assistant edits with user edits.
+                                buffer.finalize_last_transaction(cx);
+
+                                buffer.start_transaction(cx);
+                                buffer.edit(
+                                    hunks.into_iter().filter_map(|hunk| match hunk {
+                                        Hunk::Insert { text } => {
+                                            let edit_start = snapshot.anchor_after(edit_start);
+                                            Some((edit_start..edit_start, text))
+                                        }
+                                        Hunk::Remove { len } => {
+                                            let edit_end = edit_start + len;
+                                            let edit_range = snapshot.anchor_after(edit_start)
+                                                ..snapshot.anchor_before(edit_end);
+                                            edit_start = edit_end;
+                                            Some((edit_range, String::new()))
+                                        }
+                                        Hunk::Keep { len } => {
+                                            let edit_end = edit_start + len;
+                                            let edit_range = snapshot.anchor_after(edit_start)
+                                                ..snapshot.anchor_before(edit_end);
+                                            edit_start = edit_end;
+                                            this.last_equal_ranges.push(edit_range);
+                                            None
+                                        }
+                                    }),
+                                    None,
+                                    cx,
+                                );
+
+                                buffer.end_transaction(cx)
+                            });
+
+                            if let Some(transaction) = transaction {
+                                if let Some(first_transaction) = this.transaction_id {
+                                    // Group all assistant edits into the first transaction.
+                                    this.buffer.update(cx, |buffer, cx| {
+                                        buffer.merge_transactions(
+                                            transaction,
+                                            first_transaction,
+                                            cx,
+                                        )
+                                    });
+                                } else {
+                                    this.transaction_id = Some(transaction);
+                                    this.buffer.update(cx, |buffer, cx| {
+                                        buffer.finalize_last_transaction(cx)
+                                    });
+                                }
+                            }
+
+                            cx.notify();
+                        });
+                    }
+
+                    diff.await?;
+                    anyhow::Ok(())
+                };
+
+                let result = generate.await;
+                if let Some(this) = this.upgrade(&cx) {
+                    this.update(&mut cx, |this, cx| {
+                        this.last_equal_ranges.clear();
+                        this.idle = true;
+                        if let Err(error) = result {
+                            this.error = Some(error);
+                        }
+                        cx.emit(Event::Finished);
+                        cx.notify();
+                    });
+                }
+            }
+        });
+        self.error.take();
+        self.idle = false;
+        cx.notify();
+    }
+
+    pub fn undo(&mut self, cx: &mut ModelContext<Self>) {
+        if let Some(transaction_id) = self.transaction_id {
+            self.buffer
+                .update(cx, |buffer, cx| buffer.undo_transaction(transaction_id, cx));
+        }
+    }
+}
+
+fn strip_markdown_codeblock(
+    stream: impl Stream<Item = Result<String>>,
+) -> impl Stream<Item = Result<String>> {
+    let mut first_line = true;
+    let mut buffer = String::new();
+    let mut starts_with_fenced_code_block = false;
+    stream.filter_map(move |chunk| {
+        let chunk = match chunk {
+            Ok(chunk) => chunk,
+            Err(err) => return future::ready(Some(Err(err))),
+        };
+        buffer.push_str(&chunk);
+
+        if first_line {
+            if buffer == "" || buffer == "`" || buffer == "``" {
+                return future::ready(None);
+            } else if buffer.starts_with("```") {
+                starts_with_fenced_code_block = true;
+                if let Some(newline_ix) = buffer.find('\n') {
+                    buffer.replace_range(..newline_ix + 1, "");
+                    first_line = false;
+                } else {
+                    return future::ready(None);
+                }
+            }
+        }
+
+        let text = if starts_with_fenced_code_block {
+            buffer
+                .strip_suffix("\n```\n")
+                .or_else(|| buffer.strip_suffix("\n```"))
+                .or_else(|| buffer.strip_suffix("\n``"))
+                .or_else(|| buffer.strip_suffix("\n`"))
+                .or_else(|| buffer.strip_suffix('\n'))
+                .unwrap_or(&buffer)
+        } else {
+            &buffer
+        };
+
+        if text.contains('\n') {
+            first_line = false;
+        }
+
+        let remainder = buffer.split_off(text.len());
+        let result = if buffer.is_empty() {
+            None
+        } else {
+            Some(Ok(buffer.clone()))
+        };
+        buffer = remainder;
+        future::ready(result)
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use futures::stream;
+    use gpui::{executor::Deterministic, TestAppContext};
+    use indoc::indoc;
+    use language::{language_settings, tree_sitter_rust, Buffer, Language, LanguageConfig, Point};
+    use parking_lot::Mutex;
+    use rand::prelude::*;
+    use settings::SettingsStore;
+
+    #[gpui::test(iterations = 10)]
+    async fn test_transform_autoindent(
+        cx: &mut TestAppContext,
+        mut rng: StdRng,
+        deterministic: Arc<Deterministic>,
+    ) {
+        cx.set_global(cx.read(SettingsStore::test));
+        cx.update(language_settings::init);
+
+        let text = indoc! {"
+            fn main() {
+                let x = 0;
+                for _ in 0..10 {
+                    x += 1;
+                }
+            }
+        "};
+        let buffer =
+            cx.add_model(|cx| Buffer::new(0, 0, text).with_language(Arc::new(rust_lang()), cx));
+        let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+        let range = buffer.read_with(cx, |buffer, cx| {
+            let snapshot = buffer.snapshot(cx);
+            snapshot.anchor_before(Point::new(1, 4))..snapshot.anchor_after(Point::new(4, 4))
+        });
+        let provider = Arc::new(TestCompletionProvider::new());
+        let codegen = cx.add_model(|cx| {
+            Codegen::new(
+                buffer.clone(),
+                CodegenKind::Transform { range },
+                provider.clone(),
+                cx,
+            )
+        });
+        codegen.update(cx, |codegen, cx| codegen.start(Default::default(), cx));
+
+        let mut new_text = concat!(
+            "       let mut x = 0;\n",
+            "       while x < 10 {\n",
+            "           x += 1;\n",
+            "       }",
+        );
+        while !new_text.is_empty() {
+            let max_len = cmp::min(new_text.len(), 10);
+            let len = rng.gen_range(1..=max_len);
+            let (chunk, suffix) = new_text.split_at(len);
+            provider.send_completion(chunk);
+            new_text = suffix;
+            deterministic.run_until_parked();
+        }
+        provider.finish_completion();
+        deterministic.run_until_parked();
+
+        assert_eq!(
+            buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()),
+            indoc! {"
+                fn main() {
+                    let mut x = 0;
+                    while x < 10 {
+                        x += 1;
+                    }
+                }
+            "}
+        );
+    }
+
+    #[gpui::test(iterations = 10)]
+    async fn test_autoindent_when_generating_past_indentation(
+        cx: &mut TestAppContext,
+        mut rng: StdRng,
+        deterministic: Arc<Deterministic>,
+    ) {
+        cx.set_global(cx.read(SettingsStore::test));
+        cx.update(language_settings::init);
+
+        let text = indoc! {"
+            fn main() {
+                le
+            }
+        "};
+        let buffer =
+            cx.add_model(|cx| Buffer::new(0, 0, text).with_language(Arc::new(rust_lang()), cx));
+        let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+        let position = buffer.read_with(cx, |buffer, cx| {
+            let snapshot = buffer.snapshot(cx);
+            snapshot.anchor_before(Point::new(1, 6))
+        });
+        let provider = Arc::new(TestCompletionProvider::new());
+        let codegen = cx.add_model(|cx| {
+            Codegen::new(
+                buffer.clone(),
+                CodegenKind::Generate { position },
+                provider.clone(),
+                cx,
+            )
+        });
+        codegen.update(cx, |codegen, cx| codegen.start(Default::default(), cx));
+
+        let mut new_text = concat!(
+            "t mut x = 0;\n",
+            "while x < 10 {\n",
+            "    x += 1;\n",
+            "}", //
+        );
+        while !new_text.is_empty() {
+            let max_len = cmp::min(new_text.len(), 10);
+            let len = rng.gen_range(1..=max_len);
+            let (chunk, suffix) = new_text.split_at(len);
+            provider.send_completion(chunk);
+            new_text = suffix;
+            deterministic.run_until_parked();
+        }
+        provider.finish_completion();
+        deterministic.run_until_parked();
+
+        assert_eq!(
+            buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()),
+            indoc! {"
+                fn main() {
+                    let mut x = 0;
+                    while x < 10 {
+                        x += 1;
+                    }
+                }
+            "}
+        );
+    }
+
+    #[gpui::test(iterations = 10)]
+    async fn test_autoindent_when_generating_before_indentation(
+        cx: &mut TestAppContext,
+        mut rng: StdRng,
+        deterministic: Arc<Deterministic>,
+    ) {
+        cx.set_global(cx.read(SettingsStore::test));
+        cx.update(language_settings::init);
+
+        let text = concat!(
+            "fn main() {\n",
+            "  \n",
+            "}\n" //
+        );
+        let buffer =
+            cx.add_model(|cx| Buffer::new(0, 0, text).with_language(Arc::new(rust_lang()), cx));
+        let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+        let position = buffer.read_with(cx, |buffer, cx| {
+            let snapshot = buffer.snapshot(cx);
+            snapshot.anchor_before(Point::new(1, 2))
+        });
+        let provider = Arc::new(TestCompletionProvider::new());
+        let codegen = cx.add_model(|cx| {
+            Codegen::new(
+                buffer.clone(),
+                CodegenKind::Generate { position },
+                provider.clone(),
+                cx,
+            )
+        });
+        codegen.update(cx, |codegen, cx| codegen.start(Default::default(), cx));
+
+        let mut new_text = concat!(
+            "let mut x = 0;\n",
+            "while x < 10 {\n",
+            "    x += 1;\n",
+            "}", //
+        );
+        while !new_text.is_empty() {
+            let max_len = cmp::min(new_text.len(), 10);
+            let len = rng.gen_range(1..=max_len);
+            let (chunk, suffix) = new_text.split_at(len);
+            provider.send_completion(chunk);
+            new_text = suffix;
+            deterministic.run_until_parked();
+        }
+        provider.finish_completion();
+        deterministic.run_until_parked();
+
+        assert_eq!(
+            buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()),
+            indoc! {"
+                fn main() {
+                    let mut x = 0;
+                    while x < 10 {
+                        x += 1;
+                    }
+                }
+            "}
+        );
+    }
+
+    #[gpui::test]
+    async fn test_strip_markdown_codeblock() {
+        assert_eq!(
+            strip_markdown_codeblock(chunks("Lorem ipsum dolor", 2))
+                .map(|chunk| chunk.unwrap())
+                .collect::<String>()
+                .await,
+            "Lorem ipsum dolor"
+        );
+        assert_eq!(
+            strip_markdown_codeblock(chunks("```\nLorem ipsum dolor", 2))
+                .map(|chunk| chunk.unwrap())
+                .collect::<String>()
+                .await,
+            "Lorem ipsum dolor"
+        );
+        assert_eq!(
+            strip_markdown_codeblock(chunks("```\nLorem ipsum dolor\n```", 2))
+                .map(|chunk| chunk.unwrap())
+                .collect::<String>()
+                .await,
+            "Lorem ipsum dolor"
+        );
+        assert_eq!(
+            strip_markdown_codeblock(chunks("```\nLorem ipsum dolor\n```\n", 2))
+                .map(|chunk| chunk.unwrap())
+                .collect::<String>()
+                .await,
+            "Lorem ipsum dolor"
+        );
+        assert_eq!(
+            strip_markdown_codeblock(chunks("```html\n```js\nLorem ipsum dolor\n```\n```", 2))
+                .map(|chunk| chunk.unwrap())
+                .collect::<String>()
+                .await,
+            "```js\nLorem ipsum dolor\n```"
+        );
+        assert_eq!(
+            strip_markdown_codeblock(chunks("``\nLorem ipsum dolor\n```", 2))
+                .map(|chunk| chunk.unwrap())
+                .collect::<String>()
+                .await,
+            "``\nLorem ipsum dolor\n```"
+        );
+
+        fn chunks(text: &str, size: usize) -> impl Stream<Item = Result<String>> {
+            stream::iter(
+                text.chars()
+                    .collect::<Vec<_>>()
+                    .chunks(size)
+                    .map(|chunk| Ok(chunk.iter().collect::<String>()))
+                    .collect::<Vec<_>>(),
+            )
+        }
+    }
+
+    struct TestCompletionProvider {
+        last_completion_tx: Mutex<Option<mpsc::Sender<String>>>,
+    }
+
+    impl TestCompletionProvider {
+        fn new() -> Self {
+            Self {
+                last_completion_tx: Mutex::new(None),
+            }
+        }
+
+        fn send_completion(&self, completion: impl Into<String>) {
+            let mut tx = self.last_completion_tx.lock();
+            tx.as_mut().unwrap().try_send(completion.into()).unwrap();
+        }
+
+        fn finish_completion(&self) {
+            self.last_completion_tx.lock().take().unwrap();
+        }
+    }
+
+    impl CompletionProvider for TestCompletionProvider {
+        fn complete(
+            &self,
+            _prompt: OpenAIRequest,
+        ) -> BoxFuture<'static, Result<BoxStream<'static, Result<String>>>> {
+            let (tx, rx) = mpsc::channel(1);
+            *self.last_completion_tx.lock() = Some(tx);
+            async move { Ok(rx.map(|rx| Ok(rx)).boxed()) }.boxed()
+        }
+    }
+
+    fn rust_lang() -> Language {
+        Language::new(
+            LanguageConfig {
+                name: "Rust".into(),
+                path_suffixes: vec!["rs".to_string()],
+                ..Default::default()
+            },
+            Some(tree_sitter_rust::language()),
+        )
+        .with_indents_query(
+            r#"
+            (call_expression) @indent
+            (field_expression) @indent
+            (_ "(" ")" @end) @indent
+            (_ "{" "}" @end) @indent
+            "#,
+        )
+        .unwrap()
+    }
+}

crates/collab/Cargo.toml 🔗

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

crates/collab/admin_api.conf 🔗

@@ -0,0 +1,4 @@
+db-uri = "postgres://postgres@localhost/zed"
+server-port = 8081
+jwt-secret = "the-postgrest-jwt-secret-for-authorization"
+log-level = "info"

crates/collab/k8s/manifest.template.yml 🔗

@@ -3,6 +3,7 @@ apiVersion: v1
 kind: Namespace
 metadata:
   name: ${ZED_KUBE_NAMESPACE}
+
 ---
 kind: Service
 apiVersion: v1
@@ -11,7 +12,7 @@ metadata:
   name: collab
   annotations:
     service.beta.kubernetes.io/do-loadbalancer-tls-ports: "443"
-    service.beta.kubernetes.io/do-loadbalancer-certificate-id: "08d9d8ce-761f-4ab3-bc78-4923ab5b0e33"
+    service.beta.kubernetes.io/do-loadbalancer-certificate-id: ${ZED_DO_CERTIFICATE_ID}
 spec:
   type: LoadBalancer
   selector:
@@ -21,6 +22,26 @@ spec:
       protocol: TCP
       port: 443
       targetPort: 8080
+
+---
+kind: Service
+apiVersion: v1
+metadata:
+  namespace: ${ZED_KUBE_NAMESPACE}
+  name: pgadmin
+  annotations:
+    service.beta.kubernetes.io/do-loadbalancer-tls-ports: "443"
+    service.beta.kubernetes.io/do-loadbalancer-certificate-id: ${ZED_DO_CERTIFICATE_ID}
+spec:
+  type: LoadBalancer
+  selector:
+    app: postgrest
+  ports:
+    - name: web
+      protocol: TCP
+      port: 443
+      targetPort: 8080
+
 ---
 apiVersion: apps/v1
 kind: Deployment
@@ -117,3 +138,40 @@ spec:
               # FIXME - Switch to the more restrictive `PERFMON` capability.
               # This capability isn't yet available in a stable version of Debian.
               add: ["SYS_ADMIN"]
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  namespace: ${ZED_KUBE_NAMESPACE}
+  name: postgrest
+
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: postgrest
+  template:
+    metadata:
+      labels:
+        app: postgrest
+    spec:
+      containers:
+        - name: postgrest
+          image: "postgrest/postgrest"
+          ports:
+            - containerPort: 8080
+              protocol: TCP
+          env:
+            - name: PGRST_SERVER_PORT
+              value: "8080"
+            - name: PGRST_DB_URI
+              valueFrom:
+                secretKeyRef:
+                  name: database
+                  key: url
+            - name: PGRST_JWT_SECRET
+              valueFrom:
+                secretKeyRef:
+                  name: postgrest
+                  key: jwt_secret

crates/collab/src/api.rs 🔗

@@ -1,8 +1,7 @@
 use crate::{
     auth,
-    db::{Invite, NewSignup, NewUserParams, User, UserId, WaitlistSummary},
-    rpc::{self, ResultExt},
-    AppState, Error, Result,
+    db::{User, UserId},
+    rpc, AppState, Error, Result,
 };
 use anyhow::anyhow;
 use axum::{
@@ -11,7 +10,7 @@ use axum::{
     http::{self, Request, StatusCode},
     middleware::{self, Next},
     response::IntoResponse,
-    routing::{get, post, put},
+    routing::{get, post},
     Extension, Json, Router,
 };
 use axum_extra::response::ErasedJson;
@@ -23,18 +22,9 @@ 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))
         .route("/users/:id/access_tokens", post(create_access_token))
-        .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))
         .route("/rpc_server_snapshot", get(get_rpc_server_snapshot))
-        .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))
@@ -104,28 +94,6 @@ async fn get_authenticated_user(
     return Ok(Json(AuthenticatedUserResponse { user, metrics_id }));
 }
 
-#[derive(Debug, Deserialize)]
-struct GetUsersQueryParams {
-    query: Option<String>,
-    page: Option<u32>,
-    limit: Option<u32>,
-}
-
-async fn get_users(
-    Query(params): Query<GetUsersQueryParams>,
-    Extension(app): Extension<Arc<AppState>>,
-) -> Result<Json<Vec<User>>> {
-    let limit = params.limit.unwrap_or(100);
-    let users = if let Some(query) = params.query {
-        app.db.fuzzy_search_users(&query, limit).await?
-    } else {
-        app.db
-            .get_all_users(params.page.unwrap_or(0), limit)
-            .await?
-    };
-    Ok(Json(users))
-}
-
 #[derive(Deserialize, Debug)]
 struct CreateUserParams {
     github_user_id: i32,
@@ -145,119 +113,6 @@ struct CreateUserResponse {
     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<Option<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 {
-        if let Some(result) = app
-            .db
-            .create_user_from_invite(
-                &Invite {
-                    email_address: params.email_address,
-                    email_confirmation_code,
-                },
-                user,
-            )
-            .await?
-        {
-            result
-        } else {
-            return Ok(Json(None));
-        }
-    }
-    // Creating a user as an admin
-    else if params.admin {
-        app.db
-            .create_user(&params.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(result.user_id)
-        .await?
-        .ok_or_else(|| anyhow!("couldn't find the user we just created"))?;
-
-    Ok(Json(Some(CreateUserResponse {
-        user,
-        metrics_id: result.metrics_id,
-        signup_device_id: result.signup_device_id,
-    })))
-}
-
-#[derive(Deserialize)]
-struct UpdateUserParams {
-    admin: Option<bool>,
-    invite_count: Option<i32>,
-}
-
-async fn update_user(
-    Path(user_id): Path<i32>,
-    Json(params): Json<UpdateUserParams>,
-    Extension(app): Extension<Arc<AppState>>,
-    Extension(rpc_server): Extension<Arc<rpc::Server>>,
-) -> Result<()> {
-    let user_id = UserId(user_id);
-
-    if let Some(admin) = params.admin {
-        app.db.set_user_is_admin(user_id, admin).await?;
-    }
-
-    if let Some(invite_count) = params.invite_count {
-        app.db
-            .set_invite_count_for_user(user_id, invite_count)
-            .await?;
-        rpc_server.invite_count_updated(user_id).await.trace_err();
-    }
-
-    Ok(())
-}
-
-async fn destroy_user(
-    Path(user_id): Path<i32>,
-    Extension(app): Extension<Arc<AppState>>,
-) -> Result<()> {
-    app.db.destroy_user(UserId(user_id)).await?;
-    Ok(())
-}
-
-#[derive(Debug, Deserialize)]
-struct GetUsersWithNoInvites {
-    invited_by_another_user: bool,
-}
-
-async fn get_users_with_no_invites(
-    Query(params): Query<GetUsersWithNoInvites>,
-    Extension(app): Extension<Arc<AppState>>,
-) -> Result<Json<Vec<User>>> {
-    Ok(Json(
-        app.db
-            .get_users_with_no_invites(params.invited_by_another_user)
-            .await?,
-    ))
-}
-
 #[derive(Debug, Deserialize)]
 struct Panic {
     version: String,
@@ -327,69 +182,3 @@ async fn create_access_token(
         encrypted_access_token,
     }))
 }
-
-async fn get_user_for_invite_code(
-    Path(code): Path<String>,
-    Extension(app): Extension<Arc<AppState>>,
-) -> Result<Json<User>> {
-    Ok(Json(app.db.get_user_for_invite_code(&code).await?))
-}
-
-async fn create_signup(
-    Json(params): Json<NewSignup>,
-    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>,
-    #[serde(default)]
-    added_to_mailing_list: bool,
-}
-
-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(
-                &params.invite_code,
-                &params.email_address,
-                params.device_id.as_deref(),
-                params.added_to_mailing_list,
-            )
-            .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(&params).await?;
-    Ok(())
-}

crates/collab/src/db/queries/signups.rs 🔗

@@ -1,349 +0,0 @@
-use super::*;
-use hyper::StatusCode;
-
-impl Database {
-    pub async fn create_invite_from_code(
-        &self,
-        code: &str,
-        email_address: &str,
-        device_id: Option<&str>,
-        added_to_mailing_list: bool,
-    ) -> Result<Invite> {
-        self.transaction(|tx| async move {
-            let existing_user = user::Entity::find()
-                .filter(user::Column::EmailAddress.eq(email_address))
-                .one(&*tx)
-                .await?;
-
-            if existing_user.is_some() {
-                Err(anyhow!("email address is already in use"))?;
-            }
-
-            let inviting_user_with_invites = match user::Entity::find()
-                .filter(
-                    user::Column::InviteCode
-                        .eq(code)
-                        .and(user::Column::InviteCount.gt(0)),
-                )
-                .one(&*tx)
-                .await?
-            {
-                Some(inviting_user) => inviting_user,
-                None => {
-                    return Err(Error::Http(
-                        StatusCode::UNAUTHORIZED,
-                        "unable to find an invite code with invites remaining".to_string(),
-                    ))?
-                }
-            };
-            user::Entity::update_many()
-                .filter(
-                    user::Column::Id
-                        .eq(inviting_user_with_invites.id)
-                        .and(user::Column::InviteCount.gt(0)),
-                )
-                .col_expr(
-                    user::Column::InviteCount,
-                    Expr::col(user::Column::InviteCount).sub(1),
-                )
-                .exec(&*tx)
-                .await?;
-
-            let signup = signup::Entity::insert(signup::ActiveModel {
-                email_address: ActiveValue::set(email_address.into()),
-                email_confirmation_code: ActiveValue::set(random_email_confirmation_code()),
-                email_confirmation_sent: ActiveValue::set(false),
-                inviting_user_id: ActiveValue::set(Some(inviting_user_with_invites.id)),
-                platform_linux: ActiveValue::set(false),
-                platform_mac: ActiveValue::set(false),
-                platform_windows: ActiveValue::set(false),
-                platform_unknown: ActiveValue::set(true),
-                device_id: ActiveValue::set(device_id.map(|device_id| device_id.into())),
-                added_to_mailing_list: ActiveValue::set(added_to_mailing_list),
-                ..Default::default()
-            })
-            .on_conflict(
-                OnConflict::column(signup::Column::EmailAddress)
-                    .update_column(signup::Column::InvitingUserId)
-                    .to_owned(),
-            )
-            .exec_with_returning(&*tx)
-            .await?;
-
-            Ok(Invite {
-                email_address: signup.email_address,
-                email_confirmation_code: signup.email_confirmation_code,
-            })
-        })
-        .await
-    }
-
-    pub async fn create_user_from_invite(
-        &self,
-        invite: &Invite,
-        user: NewUserParams,
-    ) -> Result<Option<NewUserResult>> {
-        self.transaction(|tx| async {
-            let tx = tx;
-            let signup = signup::Entity::find()
-                .filter(
-                    signup::Column::EmailAddress
-                        .eq(invite.email_address.as_str())
-                        .and(
-                            signup::Column::EmailConfirmationCode
-                                .eq(invite.email_confirmation_code.as_str()),
-                        ),
-                )
-                .one(&*tx)
-                .await?
-                .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "no such invite".to_string()))?;
-
-            if signup.user_id.is_some() {
-                return Ok(None);
-            }
-
-            let user = user::Entity::insert(user::ActiveModel {
-                email_address: ActiveValue::set(Some(invite.email_address.clone())),
-                github_login: ActiveValue::set(user.github_login.clone()),
-                github_user_id: ActiveValue::set(Some(user.github_user_id)),
-                admin: ActiveValue::set(false),
-                invite_count: ActiveValue::set(user.invite_count),
-                invite_code: ActiveValue::set(Some(random_invite_code())),
-                metrics_id: ActiveValue::set(Uuid::new_v4()),
-                ..Default::default()
-            })
-            .on_conflict(
-                OnConflict::column(user::Column::GithubLogin)
-                    .update_columns([
-                        user::Column::EmailAddress,
-                        user::Column::GithubUserId,
-                        user::Column::Admin,
-                    ])
-                    .to_owned(),
-            )
-            .exec_with_returning(&*tx)
-            .await?;
-
-            let mut signup = signup.into_active_model();
-            signup.user_id = ActiveValue::set(Some(user.id));
-            let signup = signup.update(&*tx).await?;
-
-            if let Some(inviting_user_id) = signup.inviting_user_id {
-                let (user_id_a, user_id_b, a_to_b) = if inviting_user_id < user.id {
-                    (inviting_user_id, user.id, true)
-                } else {
-                    (user.id, inviting_user_id, false)
-                };
-
-                contact::Entity::insert(contact::ActiveModel {
-                    user_id_a: ActiveValue::set(user_id_a),
-                    user_id_b: ActiveValue::set(user_id_b),
-                    a_to_b: ActiveValue::set(a_to_b),
-                    should_notify: ActiveValue::set(true),
-                    accepted: ActiveValue::set(true),
-                    ..Default::default()
-                })
-                .on_conflict(OnConflict::new().do_nothing().to_owned())
-                .exec_without_returning(&*tx)
-                .await?;
-            }
-
-            Ok(Some(NewUserResult {
-                user_id: user.id,
-                metrics_id: user.metrics_id.to_string(),
-                inviting_user_id: signup.inviting_user_id,
-                signup_device_id: signup.device_id,
-            }))
-        })
-        .await
-    }
-
-    pub async fn set_invite_count_for_user(&self, id: UserId, count: i32) -> Result<()> {
-        self.transaction(|tx| async move {
-            if count > 0 {
-                user::Entity::update_many()
-                    .filter(
-                        user::Column::Id
-                            .eq(id)
-                            .and(user::Column::InviteCode.is_null()),
-                    )
-                    .set(user::ActiveModel {
-                        invite_code: ActiveValue::set(Some(random_invite_code())),
-                        ..Default::default()
-                    })
-                    .exec(&*tx)
-                    .await?;
-            }
-
-            user::Entity::update_many()
-                .filter(user::Column::Id.eq(id))
-                .set(user::ActiveModel {
-                    invite_count: ActiveValue::set(count),
-                    ..Default::default()
-                })
-                .exec(&*tx)
-                .await?;
-            Ok(())
-        })
-        .await
-    }
-
-    pub async fn get_invite_code_for_user(&self, id: UserId) -> Result<Option<(String, i32)>> {
-        self.transaction(|tx| async move {
-            match user::Entity::find_by_id(id).one(&*tx).await? {
-                Some(user) if user.invite_code.is_some() => {
-                    Ok(Some((user.invite_code.unwrap(), user.invite_count)))
-                }
-                _ => Ok(None),
-            }
-        })
-        .await
-    }
-
-    pub async fn get_user_for_invite_code(&self, code: &str) -> Result<User> {
-        self.transaction(|tx| async move {
-            user::Entity::find()
-                .filter(user::Column::InviteCode.eq(code))
-                .one(&*tx)
-                .await?
-                .ok_or_else(|| {
-                    Error::Http(
-                        StatusCode::NOT_FOUND,
-                        "that invite code does not exist".to_string(),
-                    )
-                })
-        })
-        .await
-    }
-
-    pub async fn create_signup(&self, signup: &NewSignup) -> Result<()> {
-        self.transaction(|tx| async move {
-            signup::Entity::insert(signup::ActiveModel {
-                email_address: ActiveValue::set(signup.email_address.clone()),
-                email_confirmation_code: ActiveValue::set(random_email_confirmation_code()),
-                email_confirmation_sent: ActiveValue::set(false),
-                platform_mac: ActiveValue::set(signup.platform_mac),
-                platform_windows: ActiveValue::set(signup.platform_windows),
-                platform_linux: ActiveValue::set(signup.platform_linux),
-                platform_unknown: ActiveValue::set(false),
-                editor_features: ActiveValue::set(Some(signup.editor_features.clone())),
-                programming_languages: ActiveValue::set(Some(signup.programming_languages.clone())),
-                device_id: ActiveValue::set(signup.device_id.clone()),
-                added_to_mailing_list: ActiveValue::set(signup.added_to_mailing_list),
-                ..Default::default()
-            })
-            .on_conflict(
-                OnConflict::column(signup::Column::EmailAddress)
-                    .update_columns([
-                        signup::Column::PlatformMac,
-                        signup::Column::PlatformWindows,
-                        signup::Column::PlatformLinux,
-                        signup::Column::EditorFeatures,
-                        signup::Column::ProgrammingLanguages,
-                        signup::Column::DeviceId,
-                        signup::Column::AddedToMailingList,
-                    ])
-                    .to_owned(),
-            )
-            .exec(&*tx)
-            .await?;
-            Ok(())
-        })
-        .await
-    }
-
-    pub async fn get_signup(&self, email_address: &str) -> Result<signup::Model> {
-        self.transaction(|tx| async move {
-            let signup = signup::Entity::find()
-                .filter(signup::Column::EmailAddress.eq(email_address))
-                .one(&*tx)
-                .await?
-                .ok_or_else(|| {
-                    anyhow!("signup with email address {} doesn't exist", email_address)
-                })?;
-
-            Ok(signup)
-        })
-        .await
-    }
-
-    pub async fn get_waitlist_summary(&self) -> Result<WaitlistSummary> {
-        self.transaction(|tx| async move {
-            let query = "
-                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
-            ";
-            Ok(
-                WaitlistSummary::find_by_statement(Statement::from_sql_and_values(
-                    self.pool.get_database_backend(),
-                    query.into(),
-                    vec![],
-                ))
-                .one(&*tx)
-                .await?
-                .ok_or_else(|| anyhow!("invalid result"))?,
-            )
-        })
-        .await
-    }
-
-    pub async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()> {
-        let emails = invites
-            .iter()
-            .map(|s| s.email_address.as_str())
-            .collect::<Vec<_>>();
-        self.transaction(|tx| async {
-            let tx = tx;
-            signup::Entity::update_many()
-                .filter(signup::Column::EmailAddress.is_in(emails.iter().copied()))
-                .set(signup::ActiveModel {
-                    email_confirmation_sent: ActiveValue::set(true),
-                    ..Default::default()
-                })
-                .exec(&*tx)
-                .await?;
-            Ok(())
-        })
-        .await
-    }
-
-    pub async fn get_unsent_invites(&self, count: usize) -> Result<Vec<Invite>> {
-        self.transaction(|tx| async move {
-            Ok(signup::Entity::find()
-                .select_only()
-                .column(signup::Column::EmailAddress)
-                .column(signup::Column::EmailConfirmationCode)
-                .filter(
-                    signup::Column::EmailConfirmationSent.eq(false).and(
-                        signup::Column::PlatformMac
-                            .eq(true)
-                            .or(signup::Column::PlatformUnknown.eq(true)),
-                    ),
-                )
-                .order_by_asc(signup::Column::CreatedAt)
-                .limit(count as u64)
-                .into_model()
-                .all(&*tx)
-                .await?)
-        })
-        .await
-    }
-}
-
-fn random_invite_code() -> String {
-    nanoid::nanoid!(16)
-}
-
-fn random_email_confirmation_code() -> String {
-    nanoid::nanoid!(64)
-}

crates/collab/src/db/queries/users.rs 🔗

@@ -123,27 +123,6 @@ impl Database {
         .await
     }
 
-    pub async fn get_users_with_no_invites(
-        &self,
-        invited_by_another_user: bool,
-    ) -> Result<Vec<User>> {
-        self.transaction(|tx| async move {
-            Ok(user::Entity::find()
-                .filter(
-                    user::Column::InviteCount
-                        .eq(0)
-                        .and(if invited_by_another_user {
-                            user::Column::InviterId.is_not_null()
-                        } else {
-                            user::Column::InviterId.is_null()
-                        }),
-                )
-                .all(&*tx)
-                .await?)
-        })
-        .await
-    }
-
     pub async fn get_user_metrics_id(&self, id: UserId) -> Result<String> {
         #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
         enum QueryAs {
@@ -163,21 +142,6 @@ impl Database {
         .await
     }
 
-    pub async fn set_user_is_admin(&self, id: UserId, is_admin: bool) -> Result<()> {
-        self.transaction(|tx| async move {
-            user::Entity::update_many()
-                .filter(user::Column::Id.eq(id))
-                .set(user::ActiveModel {
-                    admin: ActiveValue::set(is_admin),
-                    ..Default::default()
-                })
-                .exec(&*tx)
-                .await?;
-            Ok(())
-        })
-        .await
-    }
-
     pub async fn set_user_connected_once(&self, id: UserId, connected_once: bool) -> Result<()> {
         self.transaction(|tx| async move {
             user::Entity::update_many()

crates/collab/src/db/tests/db_tests.rs 🔗

@@ -575,308 +575,6 @@ async fn test_fuzzy_search_users() {
     }
 }
 
-#[gpui::test]
-async fn test_invite_codes() {
-    let test_db = TestDb::postgres(build_background_executor());
-    let db = test_db.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"),
-            true,
-        )
-        .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()
-        .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,
-            busy: false,
-        }]
-    );
-    assert_eq!(
-        db.get_contacts(user2).await.unwrap(),
-        [Contact::Accepted {
-            user_id: user1,
-            should_notify: false,
-            busy: false,
-        }]
-    );
-    assert!(db.has_contact(user1, user2).await.unwrap());
-    assert!(db.has_contact(user2, user1).await.unwrap());
-    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, true)
-        .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()
-        .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,
-                busy: false,
-            },
-            Contact::Accepted {
-                user_id: user3,
-                should_notify: true,
-                busy: false,
-            }
-        ]
-    );
-    assert_eq!(
-        db.get_contacts(user3).await.unwrap(),
-        [Contact::Accepted {
-            user_id: user1,
-            should_notify: false,
-            busy: false,
-        }]
-    );
-    assert!(db.has_contact(user1, user3).await.unwrap());
-    assert!(db.has_contact(user3, user1).await.unwrap());
-    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"),
-        true,
-    )
-    .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"),
-            true,
-        )
-        .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()
-        .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,
-                busy: false,
-            },
-            Contact::Accepted {
-                user_id: user3,
-                should_notify: true,
-                busy: false,
-            },
-            Contact::Accepted {
-                user_id: user4,
-                should_notify: true,
-                busy: false,
-            }
-        ]
-    );
-    assert_eq!(
-        db.get_contacts(user4).await.unwrap(),
-        [Contact::Accepted {
-            user_id: user1,
-            should_notify: false,
-            busy: false,
-        }]
-    );
-    assert!(db.has_contact(user1, user4).await.unwrap());
-    assert!(db.has_contact(user4, user1).await.unwrap());
-    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"),
-        true,
-    )
-    .await
-    .unwrap_err();
-    let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
-    assert_eq!(invite_count, 1);
-
-    // A newer user can invite an existing one via a different email address
-    // than the one they used to sign up.
-    let user5 = db
-        .create_user(
-            "user5@example.com",
-            false,
-            NewUserParams {
-                github_login: "user5".into(),
-                github_user_id: 5,
-                invite_count: 0,
-            },
-        )
-        .await
-        .unwrap()
-        .user_id;
-    db.set_invite_count_for_user(user5, 5).await.unwrap();
-    let (user5_invite_code, _) = db.get_invite_code_for_user(user5).await.unwrap().unwrap();
-    let user5_invite_to_user1 = db
-        .create_invite_from_code(&user5_invite_code, "user1@different.com", None, true)
-        .await
-        .unwrap();
-    let user1_2 = db
-        .create_user_from_invite(
-            &user5_invite_to_user1,
-            NewUserParams {
-                github_login: "user1".into(),
-                github_user_id: 1,
-                invite_count: 5,
-            },
-        )
-        .await
-        .unwrap()
-        .unwrap()
-        .user_id;
-    assert_eq!(user1_2, user1);
-    assert_eq!(
-        db.get_contacts(user1).await.unwrap(),
-        [
-            Contact::Accepted {
-                user_id: user2,
-                should_notify: true,
-                busy: false,
-            },
-            Contact::Accepted {
-                user_id: user3,
-                should_notify: true,
-                busy: false,
-            },
-            Contact::Accepted {
-                user_id: user4,
-                should_notify: true,
-                busy: false,
-            },
-            Contact::Accepted {
-                user_id: user5,
-                should_notify: false,
-                busy: false,
-            }
-        ]
-    );
-    assert_eq!(
-        db.get_contacts(user5).await.unwrap(),
-        [Contact::Accepted {
-            user_id: user1,
-            should_notify: true,
-            busy: false,
-        }]
-    );
-    assert!(db.has_contact(user1, user5).await.unwrap());
-    assert!(db.has_contact(user5, user1).await.unwrap());
-}
-
 test_both_dbs!(test_channels, test_channels_postgres, test_channels_sqlite);
 
 async fn test_channels(db: &Arc<Database>) {
@@ -1329,245 +1027,6 @@ async fn test_channel_renames(db: &Arc<Database>) {
     assert!(bad_name_rename.is_err())
 }
 
-#[gpui::test]
-async fn test_multiple_signup_overwrite() {
-    let test_db = TestDb::postgres(build_background_executor());
-    let db = test_db.db();
-
-    let email_address = "user_1@example.com".to_string();
-
-    let initial_signup_created_at_milliseconds = 0;
-
-    let initial_signup = NewSignup {
-        email_address: email_address.clone(),
-        platform_mac: false,
-        platform_linux: true,
-        platform_windows: false,
-        editor_features: vec!["speed".into()],
-        programming_languages: vec!["rust".into(), "c".into()],
-        device_id: Some(format!("device_id")),
-        added_to_mailing_list: false,
-        created_at: Some(
-            DateTime::from_timestamp_millis(initial_signup_created_at_milliseconds).unwrap(),
-        ),
-    };
-
-    db.create_signup(&initial_signup).await.unwrap();
-
-    let initial_signup_from_db = db.get_signup(&email_address).await.unwrap();
-
-    assert_eq!(
-        initial_signup_from_db.clone(),
-        signup::Model {
-            email_address: initial_signup.email_address,
-            platform_mac: initial_signup.platform_mac,
-            platform_linux: initial_signup.platform_linux,
-            platform_windows: initial_signup.platform_windows,
-            editor_features: Some(initial_signup.editor_features),
-            programming_languages: Some(initial_signup.programming_languages),
-            added_to_mailing_list: initial_signup.added_to_mailing_list,
-            ..initial_signup_from_db
-        }
-    );
-
-    let subsequent_signup = NewSignup {
-        email_address: email_address.clone(),
-        platform_mac: true,
-        platform_linux: false,
-        platform_windows: true,
-        editor_features: vec!["git integration".into(), "clean design".into()],
-        programming_languages: vec!["d".into(), "elm".into()],
-        device_id: Some(format!("different_device_id")),
-        added_to_mailing_list: true,
-        // subsequent signup happens next day
-        created_at: Some(
-            DateTime::from_timestamp_millis(
-                initial_signup_created_at_milliseconds + (1000 * 60 * 60 * 24),
-            )
-            .unwrap(),
-        ),
-    };
-
-    db.create_signup(&subsequent_signup).await.unwrap();
-
-    let subsequent_signup_from_db = db.get_signup(&email_address).await.unwrap();
-
-    assert_eq!(
-        subsequent_signup_from_db.clone(),
-        signup::Model {
-            platform_mac: subsequent_signup.platform_mac,
-            platform_linux: subsequent_signup.platform_linux,
-            platform_windows: subsequent_signup.platform_windows,
-            editor_features: Some(subsequent_signup.editor_features),
-            programming_languages: Some(subsequent_signup.programming_languages),
-            device_id: subsequent_signup.device_id,
-            added_to_mailing_list: subsequent_signup.added_to_mailing_list,
-            // shouldn't overwrite their creation Datetime - user shouldn't lose their spot in line
-            created_at: initial_signup_from_db.created_at,
-            ..subsequent_signup_from_db
-        }
-    );
-}
-
-#[gpui::test]
-async fn test_signups() {
-    let test_db = TestDb::postgres(build_background_executor());
-    let db = test_db.db();
-
-    let usernames = (0..8).map(|i| format!("person-{i}")).collect::<Vec<_>>();
-
-    let all_signups = usernames
-        .iter()
-        .enumerate()
-        .map(|(i, username)| NewSignup {
-            email_address: format!("{username}@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}")),
-            added_to_mailing_list: i != 0, // One user failed to subscribe
-            created_at: Some(DateTime::from_timestamp_millis(i as i64).unwrap()), // Signups are consecutive
-        })
-        .collect::<Vec<NewSignup>>();
-
-    // people sign up on the waitlist
-    for signup in &all_signups {
-        // users can sign up multiple times without issues
-        for _ in 0..2 {
-            db.create_signup(&signup).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,
-        &[
-            all_signups[0].email_address.as_str(),
-            all_signups[1].email_address.as_str(),
-            all_signups[2].email_address.as_str()
-        ]
-    );
-    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,
-        &[
-            all_signups[3].email_address.as_str(),
-            all_signups[4].email_address.as_str(),
-            all_signups[5].email_address.as_str()
-        ]
-    );
-
-    // 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 {
-                ..signups_batch1[0].clone()
-            },
-            NewUserParams {
-                github_login: usernames[0].clone(),
-                github_user_id: 0,
-                invite_count: 5,
-            },
-        )
-        .await
-        .unwrap()
-        .unwrap();
-    let user = db.get_user_by_id(user_id).await.unwrap().unwrap();
-    assert!(inviting_user_id.is_none());
-    assert_eq!(user.github_login, usernames[0]);
-    assert_eq!(
-        user.email_address,
-        Some(all_signups[0].email_address.clone())
-    );
-    assert_eq!(user.invite_count, 5);
-    assert_eq!(signup_device_id.unwrap(), "device_id_0");
-
-    // cannot redeem the same signup again.
-    assert!(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()
-        .is_none());
-
-    // 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: usernames[1].clone(),
-            github_user_id: 2,
-            invite_count: 5,
-        },
-    )
-    .await
-    .unwrap_err();
-}
-
 fn build_background_executor() -> Arc<Background> {
     Deterministic::new(0).build_background()
 }

crates/collab/src/rpc.rs 🔗

@@ -553,9 +553,8 @@ impl Server {
                 this.app_state.db.set_user_connected_once(user_id, true).await?;
             }
 
-            let (contacts, invite_code, channels_for_user, channel_invites) = future::try_join4(
+            let (contacts, channels_for_user, channel_invites) = future::try_join3(
                 this.app_state.db.get_contacts(user_id),
-                this.app_state.db.get_invite_code_for_user(user_id),
                 this.app_state.db.get_channels_for_user(user_id),
                 this.app_state.db.get_channel_invites_for_user(user_id)
             ).await?;
@@ -568,13 +567,6 @@ impl Server {
                     channels_for_user,
                     channel_invites
                 ))?;
-
-                if let Some((code, count)) = invite_code {
-                    this.peer.send(connection_id, proto::UpdateInviteInfo {
-                        url: format!("{}{}", this.app_state.config.invite_link_prefix, code),
-                        count: count as u32,
-                    })?;
-                }
             }
 
             if let Some(incoming_call) = this.app_state.db.incoming_call_for_user(user_id).await? {

crates/collab/src/tests/integration_tests.rs 🔗

@@ -3146,6 +3146,7 @@ async fn test_local_settings(
         )
         .await;
     let (project_a, _) = client_a.build_local_project("/dir", cx_a).await;
+    deterministic.run_until_parked();
     let project_id = active_call_a
         .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
         .await

crates/diagnostics/src/items.rs 🔗

@@ -32,7 +32,8 @@ impl DiagnosticIndicator {
                 this.in_progress_checks.insert(*language_server_id);
                 cx.notify();
             }
-            project::Event::DiskBasedDiagnosticsFinished { language_server_id } => {
+            project::Event::DiskBasedDiagnosticsFinished { language_server_id }
+            | project::Event::LanguageServerRemoved(language_server_id) => {
                 this.summary = project.read(cx).diagnostic_summary(cx);
                 this.in_progress_checks.remove(language_server_id);
                 cx.notify();

crates/editor/src/display_map.rs 🔗

@@ -5,11 +5,11 @@ mod tab_map;
 mod wrap_map;
 
 use crate::{
-    link_go_to_definition::{DocumentRange, InlayRange},
-    Anchor, AnchorRangeExt, InlayId, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint,
+    link_go_to_definition::InlayHighlight, Anchor, AnchorRangeExt, InlayId, MultiBuffer,
+    MultiBufferSnapshot, ToOffset, ToPoint,
 };
 pub use block_map::{BlockMap, BlockPoint};
-use collections::{HashMap, HashSet};
+use collections::{BTreeMap, HashMap, HashSet};
 use fold_map::FoldMap;
 use gpui::{
     color::Color,
@@ -43,7 +43,8 @@ pub trait ToDisplayPoint {
     fn to_display_point(&self, map: &DisplaySnapshot) -> DisplayPoint;
 }
 
-type TextHighlights = TreeMap<Option<TypeId>, Arc<(HighlightStyle, Vec<DocumentRange>)>>;
+type TextHighlights = TreeMap<Option<TypeId>, Arc<(HighlightStyle, Vec<Range<Anchor>>)>>;
+type InlayHighlights = BTreeMap<TypeId, HashMap<InlayId, (HighlightStyle, InlayHighlight)>>;
 
 pub struct DisplayMap {
     buffer: ModelHandle<MultiBuffer>,
@@ -54,6 +55,7 @@ pub struct DisplayMap {
     wrap_map: ModelHandle<WrapMap>,
     block_map: BlockMap,
     text_highlights: TextHighlights,
+    inlay_highlights: InlayHighlights,
     pub clip_at_line_ends: bool,
 }
 
@@ -89,6 +91,7 @@ impl DisplayMap {
             wrap_map,
             block_map,
             text_highlights: Default::default(),
+            inlay_highlights: Default::default(),
             clip_at_line_ends: false,
         }
     }
@@ -113,6 +116,7 @@ impl DisplayMap {
             wrap_snapshot,
             block_snapshot,
             text_highlights: self.text_highlights.clone(),
+            inlay_highlights: self.inlay_highlights.clone(),
             clip_at_line_ends: self.clip_at_line_ends,
         }
     }
@@ -215,37 +219,32 @@ impl DisplayMap {
         ranges: Vec<Range<Anchor>>,
         style: HighlightStyle,
     ) {
-        self.text_highlights.insert(
-            Some(type_id),
-            Arc::new((style, ranges.into_iter().map(DocumentRange::Text).collect())),
-        );
+        self.text_highlights
+            .insert(Some(type_id), Arc::new((style, ranges)));
     }
 
     pub fn highlight_inlays(
         &mut self,
         type_id: TypeId,
-        ranges: Vec<InlayRange>,
+        highlights: Vec<InlayHighlight>,
         style: HighlightStyle,
     ) {
-        self.text_highlights.insert(
-            Some(type_id),
-            Arc::new((
-                style,
-                ranges.into_iter().map(DocumentRange::Inlay).collect(),
-            )),
-        );
+        for highlight in highlights {
+            self.inlay_highlights
+                .entry(type_id)
+                .or_default()
+                .insert(highlight.inlay, (style, highlight));
+        }
     }
 
-    pub fn text_highlights(&self, type_id: TypeId) -> Option<(HighlightStyle, &[DocumentRange])> {
+    pub fn text_highlights(&self, type_id: TypeId) -> Option<(HighlightStyle, &[Range<Anchor>])> {
         let highlights = self.text_highlights.get(&Some(type_id))?;
         Some((highlights.0, &highlights.1))
     }
-
-    pub fn clear_text_highlights(
-        &mut self,
-        type_id: TypeId,
-    ) -> Option<Arc<(HighlightStyle, Vec<DocumentRange>)>> {
-        self.text_highlights.remove(&Some(type_id))
+    pub fn clear_highlights(&mut self, type_id: TypeId) -> bool {
+        let mut cleared = self.text_highlights.remove(&Some(type_id)).is_some();
+        cleared |= self.inlay_highlights.remove(&type_id).is_none();
+        cleared
     }
 
     pub fn set_font(&self, font_id: FontId, font_size: f32, cx: &mut ModelContext<Self>) -> bool {
@@ -309,6 +308,14 @@ impl DisplayMap {
     }
 }
 
+#[derive(Debug, Default)]
+pub struct Highlights<'a> {
+    pub text_highlights: Option<&'a TextHighlights>,
+    pub inlay_highlights: Option<&'a InlayHighlights>,
+    pub inlay_highlight_style: Option<HighlightStyle>,
+    pub suggestion_highlight_style: Option<HighlightStyle>,
+}
+
 pub struct DisplaySnapshot {
     pub buffer_snapshot: MultiBufferSnapshot,
     pub fold_snapshot: fold_map::FoldSnapshot,
@@ -317,6 +324,7 @@ pub struct DisplaySnapshot {
     wrap_snapshot: wrap_map::WrapSnapshot,
     block_snapshot: block_map::BlockSnapshot,
     text_highlights: TextHighlights,
+    inlay_highlights: InlayHighlights,
     clip_at_line_ends: bool,
 }
 
@@ -422,15 +430,6 @@ impl DisplaySnapshot {
             .to_inlay_offset(anchor.to_offset(&self.buffer_snapshot))
     }
 
-    pub fn inlay_offset_to_display_point(&self, offset: InlayOffset, bias: Bias) -> DisplayPoint {
-        let inlay_point = self.inlay_snapshot.to_point(offset);
-        let fold_point = self.fold_snapshot.to_fold_point(inlay_point, bias);
-        let tab_point = self.tab_snapshot.to_tab_point(fold_point);
-        let wrap_point = self.wrap_snapshot.tab_point_to_wrap_point(tab_point);
-        let block_point = self.block_snapshot.to_block_point(wrap_point);
-        DisplayPoint(block_point)
-    }
-
     fn display_point_to_inlay_point(&self, point: DisplayPoint, bias: Bias) -> InlayPoint {
         let block_point = point.0;
         let wrap_point = self.block_snapshot.to_wrap_point(block_point);
@@ -463,9 +462,7 @@ impl DisplaySnapshot {
             .chunks(
                 display_row..self.max_point().row() + 1,
                 false,
-                None,
-                None,
-                None,
+                Highlights::default(),
             )
             .map(|h| h.text)
     }
@@ -474,7 +471,7 @@ impl DisplaySnapshot {
     pub fn reverse_text_chunks(&self, display_row: u32) -> impl Iterator<Item = &str> {
         (0..=display_row).into_iter().rev().flat_map(|row| {
             self.block_snapshot
-                .chunks(row..row + 1, false, None, None, None)
+                .chunks(row..row + 1, false, Highlights::default())
                 .map(|h| h.text)
                 .collect::<Vec<_>>()
                 .into_iter()
@@ -482,19 +479,22 @@ impl DisplaySnapshot {
         })
     }
 
-    pub fn chunks(
-        &self,
+    pub fn chunks<'a>(
+        &'a self,
         display_rows: Range<u32>,
         language_aware: bool,
-        hint_highlight_style: Option<HighlightStyle>,
+        inlay_highlight_style: Option<HighlightStyle>,
         suggestion_highlight_style: Option<HighlightStyle>,
     ) -> DisplayChunks<'_> {
         self.block_snapshot.chunks(
             display_rows,
             language_aware,
-            Some(&self.text_highlights),
-            hint_highlight_style,
-            suggestion_highlight_style,
+            Highlights {
+                text_highlights: Some(&self.text_highlights),
+                inlay_highlights: Some(&self.inlay_highlights),
+                inlay_highlight_style,
+                suggestion_highlight_style,
+            },
         )
     }
 
@@ -752,12 +752,20 @@ impl DisplaySnapshot {
     }
 
     #[cfg(any(test, feature = "test-support"))]
-    pub fn highlight_ranges<Tag: ?Sized + 'static>(
+    pub fn text_highlight_ranges<Tag: ?Sized + 'static>(
         &self,
-    ) -> Option<Arc<(HighlightStyle, Vec<DocumentRange>)>> {
+    ) -> Option<Arc<(HighlightStyle, Vec<Range<Anchor>>)>> {
         let type_id = TypeId::of::<Tag>();
         self.text_highlights.get(&Some(type_id)).cloned()
     }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn inlay_highlights<Tag: ?Sized + 'static>(
+        &self,
+    ) -> Option<&HashMap<InlayId, (HighlightStyle, InlayHighlight)>> {
+        let type_id = TypeId::of::<Tag>();
+        self.inlay_highlights.get(&type_id)
+    }
 }
 
 #[derive(Copy, Clone, Default, Eq, Ord, PartialOrd, PartialEq)]

crates/editor/src/display_map/block_map.rs 🔗

@@ -1,10 +1,10 @@
 use super::{
     wrap_map::{self, WrapEdit, WrapPoint, WrapSnapshot},
-    TextHighlights,
+    Highlights,
 };
 use crate::{Anchor, Editor, ExcerptId, ExcerptRange, ToPoint as _};
 use collections::{Bound, HashMap, HashSet};
-use gpui::{fonts::HighlightStyle, AnyElement, ViewContext};
+use gpui::{AnyElement, ViewContext};
 use language::{BufferSnapshot, Chunk, Patch, Point};
 use parking_lot::Mutex;
 use std::{
@@ -576,9 +576,7 @@ impl BlockSnapshot {
         self.chunks(
             0..self.transforms.summary().output_rows,
             false,
-            None,
-            None,
-            None,
+            Highlights::default(),
         )
         .map(|chunk| chunk.text)
         .collect()
@@ -588,9 +586,7 @@ impl BlockSnapshot {
         &'a self,
         rows: Range<u32>,
         language_aware: bool,
-        text_highlights: Option<&'a TextHighlights>,
-        hint_highlight_style: Option<HighlightStyle>,
-        suggestion_highlight_style: Option<HighlightStyle>,
+        highlights: Highlights<'a>,
     ) -> BlockChunks<'a> {
         let max_output_row = cmp::min(rows.end, self.transforms.summary().output_rows);
         let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>();
@@ -622,9 +618,7 @@ impl BlockSnapshot {
             input_chunks: self.wrap_snapshot.chunks(
                 input_start..input_end,
                 language_aware,
-                text_highlights,
-                hint_highlight_style,
-                suggestion_highlight_style,
+                highlights,
             ),
             input_chunk: Default::default(),
             transforms: cursor,
@@ -1501,9 +1495,7 @@ mod tests {
                     .chunks(
                         start_row as u32..blocks_snapshot.max_point().row + 1,
                         false,
-                        None,
-                        None,
-                        None,
+                        Highlights::default(),
                     )
                     .map(|chunk| chunk.text)
                     .collect::<String>();

crates/editor/src/display_map/fold_map.rs 🔗

@@ -1,6 +1,6 @@
 use super::{
     inlay_map::{InlayBufferRows, InlayChunks, InlayEdit, InlayOffset, InlayPoint, InlaySnapshot},
-    TextHighlights,
+    Highlights,
 };
 use crate::{Anchor, AnchorRangeExt, MultiBufferSnapshot, ToOffset};
 use gpui::{color::Color, fonts::HighlightStyle};
@@ -475,7 +475,7 @@ pub struct FoldSnapshot {
 impl FoldSnapshot {
     #[cfg(test)]
     pub fn text(&self) -> String {
-        self.chunks(FoldOffset(0)..self.len(), false, None, None, None)
+        self.chunks(FoldOffset(0)..self.len(), false, Highlights::default())
             .map(|c| c.text)
             .collect()
     }
@@ -651,9 +651,7 @@ impl FoldSnapshot {
         &'a self,
         range: Range<FoldOffset>,
         language_aware: bool,
-        text_highlights: Option<&'a TextHighlights>,
-        hint_highlight_style: Option<HighlightStyle>,
-        suggestion_highlight_style: Option<HighlightStyle>,
+        highlights: Highlights<'a>,
     ) -> FoldChunks<'a> {
         let mut transform_cursor = self.transforms.cursor::<(FoldOffset, InlayOffset)>();
 
@@ -674,9 +672,7 @@ impl FoldSnapshot {
             inlay_chunks: self.inlay_snapshot.chunks(
                 inlay_start..inlay_end,
                 language_aware,
-                text_highlights,
-                hint_highlight_style,
-                suggestion_highlight_style,
+                highlights,
             ),
             inlay_chunk: None,
             inlay_offset: inlay_start,
@@ -687,8 +683,12 @@ impl FoldSnapshot {
     }
 
     pub fn chars_at(&self, start: FoldPoint) -> impl '_ + Iterator<Item = char> {
-        self.chunks(start.to_offset(self)..self.len(), false, None, None, None)
-            .flat_map(|chunk| chunk.text.chars())
+        self.chunks(
+            start.to_offset(self)..self.len(),
+            false,
+            Highlights::default(),
+        )
+        .flat_map(|chunk| chunk.text.chars())
     }
 
     #[cfg(test)]
@@ -1496,7 +1496,7 @@ mod tests {
                 let text = &expected_text[start.0..end.0];
                 assert_eq!(
                     snapshot
-                        .chunks(start..end, false, None, None, None)
+                        .chunks(start..end, false, Highlights::default())
                         .map(|c| c.text)
                         .collect::<String>(),
                     text,

crates/editor/src/display_map/inlay_map.rs 🔗

@@ -1,5 +1,4 @@
 use crate::{
-    link_go_to_definition::DocumentRange,
     multi_buffer::{MultiBufferChunks, MultiBufferRows},
     Anchor, InlayId, MultiBufferSnapshot, ToOffset,
 };
@@ -11,12 +10,13 @@ use std::{
     cmp,
     iter::Peekable,
     ops::{Add, AddAssign, Range, Sub, SubAssign},
+    sync::Arc,
     vec,
 };
-use sum_tree::{Bias, Cursor, SumTree};
+use sum_tree::{Bias, Cursor, SumTree, TreeMap};
 use text::{Patch, Rope};
 
-use super::TextHighlights;
+use super::Highlights;
 
 pub struct InlayMap {
     snapshot: InlaySnapshot,
@@ -214,10 +214,11 @@ pub struct InlayChunks<'a> {
     inlay_chunk: Option<&'a str>,
     output_offset: InlayOffset,
     max_output_offset: InlayOffset,
-    hint_highlight_style: Option<HighlightStyle>,
+    inlay_highlight_style: Option<HighlightStyle>,
     suggestion_highlight_style: Option<HighlightStyle>,
     highlight_endpoints: Peekable<vec::IntoIter<HighlightEndpoint>>,
     active_highlights: BTreeMap<Option<TypeId>, HighlightStyle>,
+    highlights: Highlights<'a>,
     snapshot: &'a InlaySnapshot,
 }
 
@@ -293,8 +294,41 @@ impl<'a> Iterator for InlayChunks<'a> {
                 prefix
             }
             Transform::Inlay(inlay) => {
+                let mut inlay_style_and_highlight = None;
+                if let Some(inlay_highlights) = self.highlights.inlay_highlights {
+                    for (_, inlay_id_to_data) in inlay_highlights.iter() {
+                        let style_and_highlight = inlay_id_to_data.get(&inlay.id);
+                        if style_and_highlight.is_some() {
+                            inlay_style_and_highlight = style_and_highlight;
+                            break;
+                        }
+                    }
+                }
+
+                let mut highlight_style = match inlay.id {
+                    InlayId::Suggestion(_) => self.suggestion_highlight_style,
+                    InlayId::Hint(_) => self.inlay_highlight_style,
+                };
+                let next_inlay_highlight_endpoint;
+                let offset_in_inlay = self.output_offset - self.transforms.start().0;
+                if let Some((style, highlight)) = inlay_style_and_highlight {
+                    let range = &highlight.range;
+                    if offset_in_inlay.0 < range.start {
+                        next_inlay_highlight_endpoint = range.start - offset_in_inlay.0;
+                    } else if offset_in_inlay.0 >= range.end {
+                        next_inlay_highlight_endpoint = usize::MAX;
+                    } else {
+                        next_inlay_highlight_endpoint = range.end - offset_in_inlay.0;
+                        highlight_style
+                            .get_or_insert_with(|| Default::default())
+                            .highlight(style.clone());
+                    }
+                } else {
+                    next_inlay_highlight_endpoint = usize::MAX;
+                }
+
                 let inlay_chunks = self.inlay_chunks.get_or_insert_with(|| {
-                    let start = self.output_offset - self.transforms.start().0;
+                    let start = offset_in_inlay;
                     let end = cmp::min(self.max_output_offset, self.transforms.end(&()).0)
                         - self.transforms.start().0;
                     inlay.text.chunks_in_range(start.0..end.0)
@@ -302,21 +336,15 @@ impl<'a> Iterator for InlayChunks<'a> {
                 let inlay_chunk = self
                     .inlay_chunk
                     .get_or_insert_with(|| inlay_chunks.next().unwrap());
-                let (chunk, remainder) = inlay_chunk.split_at(
-                    inlay_chunk
-                        .len()
-                        .min(next_highlight_endpoint.0 - self.output_offset.0),
-                );
+                let (chunk, remainder) =
+                    inlay_chunk.split_at(inlay_chunk.len().min(next_inlay_highlight_endpoint));
                 *inlay_chunk = remainder;
                 if inlay_chunk.is_empty() {
                     self.inlay_chunk = None;
                 }
 
                 self.output_offset.0 += chunk.len();
-                let mut highlight_style = match inlay.id {
-                    InlayId::Suggestion(_) => self.suggestion_highlight_style,
-                    InlayId::Hint(_) => self.hint_highlight_style,
-                };
+
                 if !self.active_highlights.is_empty() {
                     for active_highlight in self.active_highlights.values() {
                         highlight_style
@@ -625,18 +653,20 @@ impl InlayMap {
                     .filter(|ch| *ch != '\r')
                     .take(len)
                     .collect::<String>();
-                log::info!(
-                    "creating inlay at buffer offset {} with bias {:?} and text {:?}",
-                    position,
-                    bias,
-                    text
-                );
 
                 let inlay_id = if i % 2 == 0 {
                     InlayId::Hint(post_inc(next_inlay_id))
                 } else {
                     InlayId::Suggestion(post_inc(next_inlay_id))
                 };
+                log::info!(
+                    "creating inlay {:?} at buffer offset {} with bias {:?} and text {:?}",
+                    inlay_id,
+                    position,
+                    bias,
+                    text
+                );
+
                 to_insert.push(Inlay {
                     id: inlay_id,
                     position: snapshot.buffer.anchor_at(position, bias),
@@ -992,77 +1022,24 @@ impl InlaySnapshot {
         &'a self,
         range: Range<InlayOffset>,
         language_aware: bool,
-        text_highlights: Option<&'a TextHighlights>,
-        hint_highlight_style: Option<HighlightStyle>,
-        suggestion_highlight_style: Option<HighlightStyle>,
+        highlights: Highlights<'a>,
     ) -> InlayChunks<'a> {
         let mut cursor = self.transforms.cursor::<(InlayOffset, usize)>();
         cursor.seek(&range.start, Bias::Right, &());
 
         let mut highlight_endpoints = Vec::new();
-        if let Some(text_highlights) = text_highlights {
+        if let Some(text_highlights) = highlights.text_highlights {
             if !text_highlights.is_empty() {
-                while cursor.start().0 < range.end {
-                    let transform_start = self.buffer.anchor_after(
-                        self.to_buffer_offset(cmp::max(range.start, cursor.start().0)),
-                    );
-                    let transform_start =
-                        self.to_inlay_offset(transform_start.to_offset(&self.buffer));
-
-                    let transform_end = {
-                        let overshoot = InlayOffset(range.end.0 - cursor.start().0 .0);
-                        self.buffer.anchor_before(self.to_buffer_offset(cmp::min(
-                            cursor.end(&()).0,
-                            cursor.start().0 + overshoot,
-                        )))
-                    };
-                    let transform_end = self.to_inlay_offset(transform_end.to_offset(&self.buffer));
-
-                    for (tag, text_highlights) in text_highlights.iter() {
-                        let style = text_highlights.0;
-                        let ranges = &text_highlights.1;
-
-                        let start_ix = match ranges.binary_search_by(|probe| {
-                            let cmp = self
-                                .document_to_inlay_range(probe)
-                                .end
-                                .cmp(&transform_start);
-                            if cmp.is_gt() {
-                                cmp::Ordering::Greater
-                            } else {
-                                cmp::Ordering::Less
-                            }
-                        }) {
-                            Ok(i) | Err(i) => i,
-                        };
-                        for range in &ranges[start_ix..] {
-                            let range = self.document_to_inlay_range(range);
-                            if range.start.cmp(&transform_end).is_ge() {
-                                break;
-                            }
-
-                            highlight_endpoints.push(HighlightEndpoint {
-                                offset: range.start,
-                                is_start: true,
-                                tag: *tag,
-                                style,
-                            });
-                            highlight_endpoints.push(HighlightEndpoint {
-                                offset: range.end,
-                                is_start: false,
-                                tag: *tag,
-                                style,
-                            });
-                        }
-                    }
-
-                    cursor.next(&());
-                }
-                highlight_endpoints.sort();
+                self.apply_text_highlights(
+                    &mut cursor,
+                    &range,
+                    text_highlights,
+                    &mut highlight_endpoints,
+                );
                 cursor.seek(&range.start, Bias::Right, &());
             }
         }
-
+        highlight_endpoints.sort();
         let buffer_range = self.to_buffer_offset(range.start)..self.to_buffer_offset(range.end);
         let buffer_chunks = self.buffer.chunks(buffer_range, language_aware);
 
@@ -1074,29 +1051,76 @@ impl InlaySnapshot {
             buffer_chunk: None,
             output_offset: range.start,
             max_output_offset: range.end,
-            hint_highlight_style,
-            suggestion_highlight_style,
+            inlay_highlight_style: highlights.inlay_highlight_style,
+            suggestion_highlight_style: highlights.suggestion_highlight_style,
             highlight_endpoints: highlight_endpoints.into_iter().peekable(),
             active_highlights: Default::default(),
+            highlights,
             snapshot: self,
         }
     }
 
-    fn document_to_inlay_range(&self, range: &DocumentRange) -> Range<InlayOffset> {
-        match range {
-            DocumentRange::Text(text_range) => {
-                self.to_inlay_offset(text_range.start.to_offset(&self.buffer))
-                    ..self.to_inlay_offset(text_range.end.to_offset(&self.buffer))
-            }
-            DocumentRange::Inlay(inlay_range) => {
-                inlay_range.highlight_start..inlay_range.highlight_end
+    fn apply_text_highlights(
+        &self,
+        cursor: &mut Cursor<'_, Transform, (InlayOffset, usize)>,
+        range: &Range<InlayOffset>,
+        text_highlights: &TreeMap<Option<TypeId>, Arc<(HighlightStyle, Vec<Range<Anchor>>)>>,
+        highlight_endpoints: &mut Vec<HighlightEndpoint>,
+    ) {
+        while cursor.start().0 < range.end {
+            let transform_start = self
+                .buffer
+                .anchor_after(self.to_buffer_offset(cmp::max(range.start, cursor.start().0)));
+            let transform_end =
+                {
+                    let overshoot = InlayOffset(range.end.0 - cursor.start().0 .0);
+                    self.buffer.anchor_before(self.to_buffer_offset(cmp::min(
+                        cursor.end(&()).0,
+                        cursor.start().0 + overshoot,
+                    )))
+                };
+
+            for (tag, text_highlights) in text_highlights.iter() {
+                let style = text_highlights.0;
+                let ranges = &text_highlights.1;
+
+                let start_ix = match ranges.binary_search_by(|probe| {
+                    let cmp = probe.end.cmp(&transform_start, &self.buffer);
+                    if cmp.is_gt() {
+                        cmp::Ordering::Greater
+                    } else {
+                        cmp::Ordering::Less
+                    }
+                }) {
+                    Ok(i) | Err(i) => i,
+                };
+                for range in &ranges[start_ix..] {
+                    if range.start.cmp(&transform_end, &self.buffer).is_ge() {
+                        break;
+                    }
+
+                    highlight_endpoints.push(HighlightEndpoint {
+                        offset: self.to_inlay_offset(range.start.to_offset(&self.buffer)),
+                        is_start: true,
+                        tag: *tag,
+                        style,
+                    });
+                    highlight_endpoints.push(HighlightEndpoint {
+                        offset: self.to_inlay_offset(range.end.to_offset(&self.buffer)),
+                        is_start: false,
+                        tag: *tag,
+                        style,
+                    });
+                }
             }
+
+            cursor.next(&());
         }
     }
 
     #[cfg(test)]
     pub fn text(&self) -> String {
-        self.chunks(Default::default()..self.len(), false, None, None, None)
+        self.chunks(Default::default()..self.len(), false, Highlights::default())
             .map(|chunk| chunk.text)
             .collect()
     }
@@ -1144,7 +1168,11 @@ fn push_isomorphic(sum_tree: &mut SumTree<Transform>, summary: TextSummary) {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::{link_go_to_definition::InlayRange, InlayId, MultiBuffer};
+    use crate::{
+        display_map::{InlayHighlights, TextHighlights},
+        link_go_to_definition::InlayHighlight,
+        InlayId, MultiBuffer,
+    };
     use gpui::AppContext;
     use project::{InlayHint, InlayHintLabel, ResolveState};
     use rand::prelude::*;
@@ -1619,8 +1647,8 @@ mod tests {
                 })
                 .collect::<Vec<_>>();
             let mut expected_text = Rope::from(buffer_snapshot.text());
-            for (offset, inlay) in inlays.into_iter().rev() {
-                expected_text.replace(offset..offset, &inlay.text.to_string());
+            for (offset, inlay) in inlays.iter().rev() {
+                expected_text.replace(*offset..*offset, &inlay.text.to_string());
             }
             assert_eq!(inlay_snapshot.text(), expected_text.to_string());
 
@@ -1640,51 +1668,87 @@ mod tests {
                 );
             }
 
-            let mut highlights = TextHighlights::default();
-            let highlight_count = rng.gen_range(0_usize..10);
-            let mut highlight_ranges = (0..highlight_count)
+            let mut text_highlights = TextHighlights::default();
+            let text_highlight_count = rng.gen_range(0_usize..10);
+            let mut text_highlight_ranges = (0..text_highlight_count)
                 .map(|_| buffer_snapshot.random_byte_range(0, &mut rng))
                 .collect::<Vec<_>>();
-            highlight_ranges.sort_by_key(|range| (range.start, Reverse(range.end)));
-            log::info!("highlighting ranges {:?}", highlight_ranges);
-            let highlight_ranges = if rng.gen_bool(0.5) {
-                highlight_ranges
-                    .into_iter()
-                    .map(|range| InlayRange {
-                        inlay_position: buffer_snapshot.anchor_before(range.start),
-                        highlight_start: inlay_snapshot.to_inlay_offset(range.start),
-                        highlight_end: inlay_snapshot.to_inlay_offset(range.end),
-                    })
-                    .map(DocumentRange::Inlay)
-                    .collect::<Vec<_>>()
-            } else {
-                highlight_ranges
-                    .into_iter()
-                    .map(|range| {
-                        buffer_snapshot.anchor_before(range.start)
-                            ..buffer_snapshot.anchor_after(range.end)
-                    })
-                    .map(DocumentRange::Text)
-                    .collect::<Vec<_>>()
-            };
-            highlights.insert(
+            text_highlight_ranges.sort_by_key(|range| (range.start, Reverse(range.end)));
+            log::info!("highlighting text ranges {text_highlight_ranges:?}");
+            text_highlights.insert(
                 Some(TypeId::of::<()>()),
-                Arc::new((HighlightStyle::default(), highlight_ranges)),
+                Arc::new((
+                    HighlightStyle::default(),
+                    text_highlight_ranges
+                        .into_iter()
+                        .map(|range| {
+                            buffer_snapshot.anchor_before(range.start)
+                                ..buffer_snapshot.anchor_after(range.end)
+                        })
+                        .collect(),
+                )),
             );
 
+            let mut inlay_highlights = InlayHighlights::default();
+            if !inlays.is_empty() {
+                let inlay_highlight_count = rng.gen_range(0..inlays.len());
+                let mut inlay_indices = BTreeSet::default();
+                while inlay_indices.len() < inlay_highlight_count {
+                    inlay_indices.insert(rng.gen_range(0..inlays.len()));
+                }
+                let new_highlights = inlay_indices
+                    .into_iter()
+                    .filter_map(|i| {
+                        let (_, inlay) = &inlays[i];
+                        let inlay_text_len = inlay.text.len();
+                        match inlay_text_len {
+                            0 => None,
+                            1 => Some(InlayHighlight {
+                                inlay: inlay.id,
+                                inlay_position: inlay.position,
+                                range: 0..1,
+                            }),
+                            n => {
+                                let inlay_text = inlay.text.to_string();
+                                let mut highlight_end = rng.gen_range(1..n);
+                                let mut highlight_start = rng.gen_range(0..highlight_end);
+                                while !inlay_text.is_char_boundary(highlight_end) {
+                                    highlight_end += 1;
+                                }
+                                while !inlay_text.is_char_boundary(highlight_start) {
+                                    highlight_start -= 1;
+                                }
+                                Some(InlayHighlight {
+                                    inlay: inlay.id,
+                                    inlay_position: inlay.position,
+                                    range: highlight_start..highlight_end,
+                                })
+                            }
+                        }
+                    })
+                    .map(|highlight| (highlight.inlay, (HighlightStyle::default(), highlight)))
+                    .collect();
+                log::info!("highlighting inlay ranges {new_highlights:?}");
+                inlay_highlights.insert(TypeId::of::<()>(), new_highlights);
+            }
+
             for _ in 0..5 {
                 let mut end = rng.gen_range(0..=inlay_snapshot.len().0);
                 end = expected_text.clip_offset(end, Bias::Right);
                 let mut start = rng.gen_range(0..=end);
                 start = expected_text.clip_offset(start, Bias::Right);
 
+                let range = InlayOffset(start)..InlayOffset(end);
+                log::info!("calling inlay_snapshot.chunks({range:?})");
                 let actual_text = inlay_snapshot
                     .chunks(
-                        InlayOffset(start)..InlayOffset(end),
+                        range,
                         false,
-                        Some(&highlights),
-                        None,
-                        None,
+                        Highlights {
+                            text_highlights: Some(&text_highlights),
+                            inlay_highlights: Some(&inlay_highlights),
+                            ..Highlights::default()
+                        },
                     )
                     .map(|chunk| chunk.text)
                     .collect::<String>();

crates/editor/src/display_map/tab_map.rs 🔗

@@ -1,9 +1,8 @@
 use super::{
     fold_map::{self, FoldChunks, FoldEdit, FoldPoint, FoldSnapshot},
-    TextHighlights,
+    Highlights,
 };
 use crate::MultiBufferSnapshot;
-use gpui::fonts::HighlightStyle;
 use language::{Chunk, Point};
 use std::{cmp, mem, num::NonZeroU32, ops::Range};
 use sum_tree::Bias;
@@ -68,9 +67,7 @@ impl TabMap {
                 'outer: for chunk in old_snapshot.fold_snapshot.chunks(
                     fold_edit.old.end..old_end_row_successor_offset,
                     false,
-                    None,
-                    None,
-                    None,
+                    Highlights::default(),
                 ) {
                     for (ix, _) in chunk.text.match_indices('\t') {
                         let offset_from_edit = offset_from_edit + (ix as u32);
@@ -183,7 +180,7 @@ impl TabSnapshot {
             self.max_point()
         };
         for c in self
-            .chunks(range.start..line_end, false, None, None, None)
+            .chunks(range.start..line_end, false, Highlights::default())
             .flat_map(|chunk| chunk.text.chars())
         {
             if c == '\n' {
@@ -200,9 +197,7 @@ impl TabSnapshot {
                 .chunks(
                     TabPoint::new(range.end.row(), 0)..range.end,
                     false,
-                    None,
-                    None,
-                    None,
+                    Highlights::default(),
                 )
                 .flat_map(|chunk| chunk.text.chars())
             {
@@ -223,9 +218,7 @@ impl TabSnapshot {
         &'a self,
         range: Range<TabPoint>,
         language_aware: bool,
-        text_highlights: Option<&'a TextHighlights>,
-        hint_highlight_style: Option<HighlightStyle>,
-        suggestion_highlight_style: Option<HighlightStyle>,
+        highlights: Highlights<'a>,
     ) -> TabChunks<'a> {
         let (input_start, expanded_char_column, to_next_stop) =
             self.to_fold_point(range.start, Bias::Left);
@@ -245,9 +238,7 @@ impl TabSnapshot {
             fold_chunks: self.fold_snapshot.chunks(
                 input_start..input_end,
                 language_aware,
-                text_highlights,
-                hint_highlight_style,
-                suggestion_highlight_style,
+                highlights,
             ),
             input_column,
             column: expanded_char_column,
@@ -270,9 +261,13 @@ impl TabSnapshot {
 
     #[cfg(test)]
     pub fn text(&self) -> String {
-        self.chunks(TabPoint::zero()..self.max_point(), false, None, None, None)
-            .map(|chunk| chunk.text)
-            .collect()
+        self.chunks(
+            TabPoint::zero()..self.max_point(),
+            false,
+            Highlights::default(),
+        )
+        .map(|chunk| chunk.text)
+        .collect()
     }
 
     pub fn max_point(&self) -> TabPoint {
@@ -597,9 +592,7 @@ mod tests {
                     .chunks(
                         TabPoint::new(0, ix as u32)..tab_snapshot.max_point(),
                         false,
-                        None,
-                        None,
-                        None,
+                        Highlights::default(),
                     )
                     .map(|c| c.text)
                     .collect::<String>(),
@@ -674,7 +667,8 @@ mod tests {
             let mut chunks = Vec::new();
             let mut was_tab = false;
             let mut text = String::new();
-            for chunk in snapshot.chunks(start..snapshot.max_point(), false, None, None, None) {
+            for chunk in snapshot.chunks(start..snapshot.max_point(), false, Highlights::default())
+            {
                 if chunk.is_tab != was_tab {
                     if !text.is_empty() {
                         chunks.push((mem::take(&mut text), was_tab));
@@ -743,7 +737,7 @@ mod tests {
             let expected_summary = TextSummary::from(expected_text.as_str());
             assert_eq!(
                 tabs_snapshot
-                    .chunks(start..end, false, None, None, None)
+                    .chunks(start..end, false, Highlights::default())
                     .map(|c| c.text)
                     .collect::<String>(),
                 expected_text,

crates/editor/src/display_map/wrap_map.rs 🔗

@@ -1,13 +1,11 @@
 use super::{
     fold_map::FoldBufferRows,
     tab_map::{self, TabEdit, TabPoint, TabSnapshot},
-    TextHighlights,
+    Highlights,
 };
 use crate::MultiBufferSnapshot;
 use gpui::{
-    fonts::{FontId, HighlightStyle},
-    text_layout::LineWrapper,
-    AppContext, Entity, ModelContext, ModelHandle, Task,
+    fonts::FontId, text_layout::LineWrapper, AppContext, Entity, ModelContext, ModelHandle, Task,
 };
 use language::{Chunk, Point};
 use lazy_static::lazy_static;
@@ -444,9 +442,7 @@ impl WrapSnapshot {
                 let mut chunks = new_tab_snapshot.chunks(
                     TabPoint::new(edit.new_rows.start, 0)..new_tab_snapshot.max_point(),
                     false,
-                    None,
-                    None,
-                    None,
+                    Highlights::default(),
                 );
                 let mut edit_transforms = Vec::<Transform>::new();
                 for _ in edit.new_rows.start..edit.new_rows.end {
@@ -575,9 +571,7 @@ impl WrapSnapshot {
         &'a self,
         rows: Range<u32>,
         language_aware: bool,
-        text_highlights: Option<&'a TextHighlights>,
-        hint_highlight_style: Option<HighlightStyle>,
-        suggestion_highlight_style: Option<HighlightStyle>,
+        highlights: Highlights<'a>,
     ) -> WrapChunks<'a> {
         let output_start = WrapPoint::new(rows.start, 0);
         let output_end = WrapPoint::new(rows.end, 0);
@@ -594,9 +588,7 @@ impl WrapSnapshot {
             input_chunks: self.tab_snapshot.chunks(
                 input_start..input_end,
                 language_aware,
-                text_highlights,
-                hint_highlight_style,
-                suggestion_highlight_style,
+                highlights,
             ),
             input_chunk: Default::default(),
             output_position: output_start,
@@ -1323,9 +1315,7 @@ mod tests {
             self.chunks(
                 wrap_row..self.max_point().row() + 1,
                 false,
-                None,
-                None,
-                None,
+                Highlights::default(),
             )
             .map(|h| h.text)
         }
@@ -1350,7 +1340,7 @@ mod tests {
                 }
 
                 let actual_text = self
-                    .chunks(start_row..end_row, true, None, None, None)
+                    .chunks(start_row..end_row, true, Highlights::default())
                     .map(|c| c.text)
                     .collect::<String>();
                 assert_eq!(

crates/editor/src/editor.rs 🔗

@@ -66,7 +66,7 @@ use language::{
     TransactionId,
 };
 use link_go_to_definition::{
-    hide_link_definition, show_link_definition, DocumentRange, GoToDefinitionLink, InlayRange,
+    hide_link_definition, show_link_definition, GoToDefinitionLink, InlayHighlight,
     LinkGoToDefinitionState,
 };
 use log::error;
@@ -99,6 +99,7 @@ use std::{
     time::{Duration, Instant},
 };
 pub use sum_tree::Bias;
+use sum_tree::TreeMap;
 use text::Rope;
 use theme::{DiagnosticStyle, Theme, ThemeSettings};
 use util::{post_inc, RangeExt, ResultExt, TryFutureExt};
@@ -548,7 +549,8 @@ type CompletionId = usize;
 type GetFieldEditorTheme = dyn Fn(&theme::Theme) -> theme::FieldEditor;
 type OverrideTextStyle = dyn Fn(&EditorStyle) -> Option<HighlightStyle>;
 
-type BackgroundHighlight = (fn(&Theme) -> Color, Vec<DocumentRange>);
+type BackgroundHighlight = (fn(&Theme) -> Color, Vec<Range<Anchor>>);
+type InlayBackgroundHighlight = (fn(&Theme) -> Color, Vec<InlayHighlight>);
 
 pub struct Editor {
     handle: WeakViewHandle<Self>,
@@ -580,6 +582,7 @@ pub struct Editor {
     placeholder_text: Option<Arc<str>>,
     highlighted_rows: Option<Range<u32>>,
     background_highlights: BTreeMap<TypeId, BackgroundHighlight>,
+    inlay_background_highlights: TreeMap<Option<TypeId>, InlayBackgroundHighlight>,
     nav_history: Option<ItemNavHistory>,
     context_menu: Option<ContextMenu>,
     mouse_context_menu: ViewHandle<context_menu::ContextMenu>,
@@ -1523,6 +1526,7 @@ impl Editor {
             placeholder_text: None,
             highlighted_rows: None,
             background_highlights: Default::default(),
+            inlay_background_highlights: Default::default(),
             nav_history: None,
             context_menu: None,
             mouse_context_menu: cx
@@ -1734,6 +1738,10 @@ impl Editor {
         }
     }
 
+    pub fn read_only(&self) -> bool {
+        self.read_only
+    }
+
     pub fn set_read_only(&mut self, read_only: bool) {
         self.read_only = read_only;
     }
@@ -2285,14 +2293,18 @@ impl Editor {
                 // bracket of any of this language's bracket pairs.
                 let mut bracket_pair = None;
                 let mut is_bracket_pair_start = false;
-                for (pair, enabled) in scope.brackets() {
-                    if enabled && 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;
+                if !text.is_empty() {
+                    // `text` can be empty when an user is using IME (e.g. Chinese Wubi Simplified)
+                    //  and they are removing the character that triggered IME popup.
+                    for (pair, enabled) in scope.brackets() {
+                        if enabled && 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;
+                        }
                     }
                 }
 
@@ -5121,9 +5133,6 @@ impl Editor {
             self.unmark_text(cx);
             self.refresh_copilot_suggestions(true, cx);
             cx.emit(Event::Edited);
-            cx.emit(Event::TransactionUndone {
-                transaction_id: tx_id,
-            });
         }
     }
 
@@ -7065,16 +7074,8 @@ impl Editor {
             } else {
                 this.update(&mut cx, |this, cx| {
                     let buffer = this.buffer.read(cx).snapshot(cx);
-                    let display_snapshot = this
-                        .display_map
-                        .update(cx, |display_map, cx| display_map.snapshot(cx));
                     let mut buffer_highlights = this
-                        .document_highlights_for_position(
-                            selection.head(),
-                            &buffer,
-                            &display_snapshot,
-                        )
-                        .filter_map(|highlight| highlight.as_text_range())
+                        .document_highlights_for_position(selection.head(), &buffer)
                         .filter(|highlight| {
                             highlight.start.excerpt_id() == selection.head().excerpt_id()
                                 && highlight.end.excerpt_id() == selection.head().excerpt_id()
@@ -7129,15 +7130,11 @@ impl Editor {
                     let ranges = this
                         .clear_background_highlights::<DocumentHighlightWrite>(cx)
                         .into_iter()
-                        .flat_map(|(_, ranges)| {
-                            ranges.into_iter().filter_map(|range| range.as_text_range())
-                        })
+                        .flat_map(|(_, ranges)| ranges.into_iter())
                         .chain(
                             this.clear_background_highlights::<DocumentHighlightRead>(cx)
                                 .into_iter()
-                                .flat_map(|(_, ranges)| {
-                                    ranges.into_iter().filter_map(|range| range.as_text_range())
-                                }),
+                                .flat_map(|(_, ranges)| ranges.into_iter()),
                         )
                         .collect();
 
@@ -7238,7 +7235,7 @@ impl Editor {
             Some(Autoscroll::fit()),
             cx,
         );
-        self.clear_text_highlights::<Rename>(cx);
+        self.clear_highlights::<Rename>(cx);
         self.show_local_selections = true;
 
         if moving_cursor {
@@ -7815,29 +7812,20 @@ impl Editor {
         color_fetcher: fn(&Theme) -> Color,
         cx: &mut ViewContext<Self>,
     ) {
-        self.background_highlights.insert(
-            TypeId::of::<T>(),
-            (
-                color_fetcher,
-                ranges.into_iter().map(DocumentRange::Text).collect(),
-            ),
-        );
+        self.background_highlights
+            .insert(TypeId::of::<T>(), (color_fetcher, ranges));
         cx.notify();
     }
 
     pub fn highlight_inlay_background<T: 'static>(
         &mut self,
-        ranges: Vec<InlayRange>,
+        ranges: Vec<InlayHighlight>,
         color_fetcher: fn(&Theme) -> Color,
         cx: &mut ViewContext<Self>,
     ) {
-        self.background_highlights.insert(
-            TypeId::of::<T>(),
-            (
-                color_fetcher,
-                ranges.into_iter().map(DocumentRange::Inlay).collect(),
-            ),
-        );
+        // TODO: no actual highlights happen for inlays currently, find a way to do that
+        self.inlay_background_highlights
+            .insert(Some(TypeId::of::<T>()), (color_fetcher, ranges));
         cx.notify();
     }
 
@@ -7845,15 +7833,18 @@ impl Editor {
         &mut self,
         cx: &mut ViewContext<Self>,
     ) -> Option<BackgroundHighlight> {
-        let highlights = self.background_highlights.remove(&TypeId::of::<T>());
-        if highlights.is_some() {
+        let text_highlights = self.background_highlights.remove(&TypeId::of::<T>());
+        let inlay_highlights = self
+            .inlay_background_highlights
+            .remove(&Some(TypeId::of::<T>()));
+        if text_highlights.is_some() || inlay_highlights.is_some() {
             cx.notify();
         }
-        highlights
+        text_highlights
     }
 
     #[cfg(feature = "test-support")]
-    pub fn all_background_highlights(
+    pub fn all_text_background_highlights(
         &mut self,
         cx: &mut ViewContext<Self>,
     ) -> Vec<(Range<DisplayPoint>, Color)> {
@@ -7869,8 +7860,7 @@ impl Editor {
         &'a self,
         position: Anchor,
         buffer: &'a MultiBufferSnapshot,
-        display_snapshot: &'a DisplaySnapshot,
-    ) -> impl 'a + Iterator<Item = &DocumentRange> {
+    ) -> impl 'a + Iterator<Item = &Range<Anchor>> {
         let read_highlights = self
             .background_highlights
             .get(&TypeId::of::<DocumentHighlightRead>())
@@ -7879,16 +7869,14 @@ impl Editor {
             .background_highlights
             .get(&TypeId::of::<DocumentHighlightWrite>())
             .map(|h| &h.1);
-        let left_position = display_snapshot.anchor_to_inlay_offset(position.bias_left(buffer));
-        let right_position = display_snapshot.anchor_to_inlay_offset(position.bias_right(buffer));
+        let left_position = position.bias_left(buffer);
+        let right_position = position.bias_right(buffer);
         read_highlights
             .into_iter()
             .chain(write_highlights)
             .flat_map(move |ranges| {
                 let start_ix = match ranges.binary_search_by(|probe| {
-                    let cmp = document_to_inlay_range(probe, display_snapshot)
-                        .end
-                        .cmp(&left_position);
+                    let cmp = probe.end.cmp(&left_position, buffer);
                     if cmp.is_ge() {
                         Ordering::Greater
                     } else {
@@ -7899,12 +7887,9 @@ impl Editor {
                 };
 
                 let right_position = right_position.clone();
-                ranges[start_ix..].iter().take_while(move |range| {
-                    document_to_inlay_range(range, display_snapshot)
-                        .start
-                        .cmp(&right_position)
-                        .is_le()
-                })
+                ranges[start_ix..]
+                    .iter()
+                    .take_while(move |range| range.start.cmp(&right_position, buffer).is_le())
             })
     }
 
@@ -7914,15 +7899,13 @@ impl Editor {
         display_snapshot: &DisplaySnapshot,
         theme: &Theme,
     ) -> Vec<(Range<DisplayPoint>, Color)> {
-        let search_range = display_snapshot.anchor_to_inlay_offset(search_range.start)
-            ..display_snapshot.anchor_to_inlay_offset(search_range.end);
         let mut results = Vec::new();
         for (color_fetcher, ranges) in self.background_highlights.values() {
             let color = color_fetcher(theme);
             let start_ix = match ranges.binary_search_by(|probe| {
-                let cmp = document_to_inlay_range(probe, display_snapshot)
+                let cmp = probe
                     .end
-                    .cmp(&search_range.start);
+                    .cmp(&search_range.start, &display_snapshot.buffer_snapshot);
                 if cmp.is_gt() {
                     Ordering::Greater
                 } else {
@@ -7932,13 +7915,16 @@ impl Editor {
                 Ok(i) | Err(i) => i,
             };
             for range in &ranges[start_ix..] {
-                let range = document_to_inlay_range(range, display_snapshot);
-                if range.start.cmp(&search_range.end).is_ge() {
+                if range
+                    .start
+                    .cmp(&search_range.end, &display_snapshot.buffer_snapshot)
+                    .is_ge()
+                {
                     break;
                 }
 
-                let start = display_snapshot.inlay_offset_to_display_point(range.start, Bias::Left);
-                let end = display_snapshot.inlay_offset_to_display_point(range.end, Bias::Right);
+                let start = range.start.to_display_point(&display_snapshot);
+                let end = range.end.to_display_point(&display_snapshot);
                 results.push((start..end, color))
             }
         }
@@ -7951,17 +7937,15 @@ impl Editor {
         display_snapshot: &DisplaySnapshot,
         count: usize,
     ) -> Vec<RangeInclusive<DisplayPoint>> {
-        let search_range = display_snapshot.anchor_to_inlay_offset(search_range.start)
-            ..display_snapshot.anchor_to_inlay_offset(search_range.end);
         let mut results = Vec::new();
         let Some((_, ranges)) = self.background_highlights.get(&TypeId::of::<T>()) else {
             return vec![];
         };
 
         let start_ix = match ranges.binary_search_by(|probe| {
-            let cmp = document_to_inlay_range(probe, display_snapshot)
+            let cmp = probe
                 .end
-                .cmp(&search_range.start);
+                .cmp(&search_range.start, &display_snapshot.buffer_snapshot);
             if cmp.is_gt() {
                 Ordering::Greater
             } else {
@@ -7984,22 +7968,20 @@ impl Editor {
             return Vec::new();
         }
         for range in &ranges[start_ix..] {
-            let range = document_to_inlay_range(range, display_snapshot);
-            if range.start.cmp(&search_range.end).is_ge() {
+            if range
+                .start
+                .cmp(&search_range.end, &display_snapshot.buffer_snapshot)
+                .is_ge()
+            {
                 break;
             }
-            let end = display_snapshot
-                .inlay_offset_to_display_point(range.end, Bias::Right)
-                .to_point(display_snapshot);
+            let end = range.end.to_point(&display_snapshot.buffer_snapshot);
             if let Some(current_row) = &end_row {
                 if end.row == current_row.row {
                     continue;
                 }
             }
-            let start = display_snapshot
-                .inlay_offset_to_display_point(range.start, Bias::Left)
-                .to_point(display_snapshot);
-
+            let start = range.start.to_point(&display_snapshot.buffer_snapshot);
             if start_row.is_none() {
                 assert_eq!(end_row, None);
                 start_row = Some(start);
@@ -8038,12 +8020,12 @@ impl Editor {
 
     pub fn highlight_inlays<T: 'static>(
         &mut self,
-        ranges: Vec<InlayRange>,
+        highlights: Vec<InlayHighlight>,
         style: HighlightStyle,
         cx: &mut ViewContext<Self>,
     ) {
         self.display_map.update(cx, |map, _| {
-            map.highlight_inlays(TypeId::of::<T>(), ranges, style)
+            map.highlight_inlays(TypeId::of::<T>(), highlights, style)
         });
         cx.notify();
     }
@@ -8051,15 +8033,15 @@ impl Editor {
     pub fn text_highlights<'a, T: 'static>(
         &'a self,
         cx: &'a AppContext,
-    ) -> Option<(HighlightStyle, &'a [DocumentRange])> {
+    ) -> Option<(HighlightStyle, &'a [Range<Anchor>])> {
         self.display_map.read(cx).text_highlights(TypeId::of::<T>())
     }
 
-    pub fn clear_text_highlights<T: 'static>(&mut self, cx: &mut ViewContext<Self>) {
-        let text_highlights = self
+    pub fn clear_highlights<T: 'static>(&mut self, cx: &mut ViewContext<Self>) {
+        let cleared = self
             .display_map
-            .update(cx, |map, _| map.clear_text_highlights(TypeId::of::<T>()));
-        if text_highlights.is_some() {
+            .update(cx, |map, _| map.clear_highlights(TypeId::of::<T>()));
+        if cleared {
             cx.notify();
         }
     }
@@ -8276,7 +8258,6 @@ impl Editor {
         Some(
             ranges
                 .iter()
-                .filter_map(|range| range.as_text_range())
                 .map(move |range| {
                     range.start.to_offset_utf16(&snapshot)..range.end.to_offset_utf16(&snapshot)
                 })
@@ -8491,19 +8472,6 @@ impl Editor {
     }
 }
 
-fn document_to_inlay_range(
-    range: &DocumentRange,
-    snapshot: &DisplaySnapshot,
-) -> Range<InlayOffset> {
-    match range {
-        DocumentRange::Text(text_range) => {
-            snapshot.anchor_to_inlay_offset(text_range.start)
-                ..snapshot.anchor_to_inlay_offset(text_range.end)
-        }
-        DocumentRange::Inlay(inlay_range) => inlay_range.highlight_start..inlay_range.highlight_end,
-    }
-}
-
 fn inlay_hint_settings(
     location: Anchor,
     snapshot: &MultiBufferSnapshot,
@@ -8605,9 +8573,6 @@ pub enum Event {
         local: bool,
         autoscroll: bool,
     },
-    TransactionUndone {
-        transaction_id: TransactionId,
-    },
     Closed,
 }
 
@@ -8717,7 +8682,7 @@ impl View for Editor {
 
             self.link_go_to_definition_state.task = None;
 
-            self.clear_text_highlights::<LinkGoToDefinitionState>(cx);
+            self.clear_highlights::<LinkGoToDefinitionState>(cx);
         }
 
         false
@@ -8786,12 +8751,11 @@ impl View for Editor {
     fn marked_text_range(&self, cx: &AppContext) -> Option<Range<usize>> {
         let snapshot = self.buffer.read(cx).read(cx);
         let range = self.text_highlights::<InputComposition>(cx)?.1.get(0)?;
-        let range = range.as_text_range()?;
         Some(range.start.to_offset_utf16(&snapshot).0..range.end.to_offset_utf16(&snapshot).0)
     }
 
     fn unmark_text(&mut self, cx: &mut ViewContext<Self>) {
-        self.clear_text_highlights::<InputComposition>(cx);
+        self.clear_highlights::<InputComposition>(cx);
         self.ime_transaction.take();
     }
 

crates/editor/src/hover_popover.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
     display_map::{InlayOffset, ToDisplayPoint},
-    link_go_to_definition::{DocumentRange, InlayRange},
+    link_go_to_definition::{InlayHighlight, RangeInEditor},
     Anchor, AnchorRangeExt, DisplayPoint, Editor, EditorSettings, EditorSnapshot, EditorStyle,
     ExcerptId, RangeToAnchorExt,
 };
@@ -50,19 +50,18 @@ pub fn hover_at(editor: &mut Editor, point: Option<DisplayPoint>, cx: &mut ViewC
 
 pub struct InlayHover {
     pub excerpt: ExcerptId,
-    pub triggered_from: InlayOffset,
-    pub range: InlayRange,
+    pub range: InlayHighlight,
     pub tooltip: HoverBlock,
 }
 
 pub fn find_hovered_hint_part(
     label_parts: Vec<InlayHintLabelPart>,
-    hint_range: Range<InlayOffset>,
+    hint_start: InlayOffset,
     hovered_offset: InlayOffset,
 ) -> Option<(InlayHintLabelPart, Range<InlayOffset>)> {
-    if hovered_offset >= hint_range.start && hovered_offset <= hint_range.end {
-        let mut hovered_character = (hovered_offset - hint_range.start).0;
-        let mut part_start = hint_range.start;
+    if hovered_offset >= hint_start {
+        let mut hovered_character = (hovered_offset - hint_start).0;
+        let mut part_start = hint_start;
         for part in label_parts {
             let part_len = part.value.chars().count();
             if hovered_character > part_len {
@@ -88,10 +87,8 @@ pub fn hover_at_inlay(editor: &mut Editor, inlay_hover: InlayHover, cx: &mut Vie
         };
 
         if let Some(InfoPopover { symbol_range, .. }) = &editor.hover_state.info_popover {
-            if let DocumentRange::Inlay(range) = symbol_range {
-                if (range.highlight_start..range.highlight_end)
-                    .contains(&inlay_hover.triggered_from)
-                {
+            if let RangeInEditor::Inlay(range) = symbol_range {
+                if range == &inlay_hover.range {
                     // Hover triggered from same location as last time. Don't show again.
                     return;
                 }
@@ -99,18 +96,6 @@ pub fn hover_at_inlay(editor: &mut Editor, inlay_hover: InlayHover, cx: &mut Vie
             hide_hover(editor, cx);
         }
 
-        let snapshot = editor.snapshot(cx);
-        // Don't request again if the location is the same as the previous request
-        if let Some(triggered_from) = editor.hover_state.triggered_from {
-            if inlay_hover.triggered_from
-                == snapshot
-                    .display_snapshot
-                    .anchor_to_inlay_offset(triggered_from)
-            {
-                return;
-            }
-        }
-
         let task = cx.spawn(|this, mut cx| {
             async move {
                 cx.background()
@@ -122,7 +107,7 @@ pub fn hover_at_inlay(editor: &mut Editor, inlay_hover: InlayHover, cx: &mut Vie
 
                 let hover_popover = InfoPopover {
                     project: project.clone(),
-                    symbol_range: DocumentRange::Inlay(inlay_hover.range),
+                    symbol_range: RangeInEditor::Inlay(inlay_hover.range.clone()),
                     blocks: vec![inlay_hover.tooltip],
                     language: None,
                     rendered_content: None,
@@ -326,7 +311,7 @@ fn show_hover(
 
                 Some(InfoPopover {
                     project: project.clone(),
-                    symbol_range: DocumentRange::Text(range),
+                    symbol_range: RangeInEditor::Text(range),
                     blocks: hover_result.contents,
                     language: hover_result.language,
                     rendered_content: None,
@@ -608,8 +593,8 @@ impl HoverState {
                 self.info_popover
                     .as_ref()
                     .map(|info_popover| match &info_popover.symbol_range {
-                        DocumentRange::Text(range) => &range.start,
-                        DocumentRange::Inlay(range) => &range.inlay_position,
+                        RangeInEditor::Text(range) => &range.start,
+                        RangeInEditor::Inlay(range) => &range.inlay_position,
                     })
             })?;
         let point = anchor.to_display_point(&snapshot.display_snapshot);
@@ -635,7 +620,7 @@ impl HoverState {
 #[derive(Debug, Clone)]
 pub struct InfoPopover {
     pub project: ModelHandle<Project>,
-    symbol_range: DocumentRange,
+    symbol_range: RangeInEditor,
     pub blocks: Vec<HoverBlock>,
     language: Option<Arc<Language>>,
     rendered_content: Option<RenderedInfo>,
@@ -811,6 +796,7 @@ mod tests {
         inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels},
         link_go_to_definition::update_inlay_link_and_hover_points,
         test::editor_lsp_test_context::EditorLspTestContext,
+        InlayId,
     };
     use collections::BTreeSet;
     use gpui::fonts::Weight;
@@ -1477,25 +1463,16 @@ mod tests {
             .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
         cx.foreground().run_until_parked();
         cx.update_editor(|editor, cx| {
-            let snapshot = editor.snapshot(cx);
             let hover_state = &editor.hover_state;
             assert!(hover_state.diagnostic_popover.is_none() && hover_state.info_popover.is_some());
             let popover = hover_state.info_popover.as_ref().unwrap();
             let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
-            let entire_inlay_start = snapshot.display_point_to_inlay_offset(
-                inlay_range.start.to_display_point(&snapshot),
-                Bias::Left,
-            );
-
-            let expected_new_type_label_start = InlayOffset(entire_inlay_start.0 + ": ".len());
             assert_eq!(
                 popover.symbol_range,
-                DocumentRange::Inlay(InlayRange {
+                RangeInEditor::Inlay(InlayHighlight {
+                    inlay: InlayId::Hint(0),
                     inlay_position: buffer_snapshot.anchor_at(inlay_range.start, Bias::Right),
-                    highlight_start: expected_new_type_label_start,
-                    highlight_end: InlayOffset(
-                        expected_new_type_label_start.0 + new_type_label.len()
-                    ),
+                    range: ": ".len()..": ".len() + new_type_label.len(),
                 }),
                 "Popover range should match the new type label part"
             );
@@ -1543,23 +1520,17 @@ mod tests {
             .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
         cx.foreground().run_until_parked();
         cx.update_editor(|editor, cx| {
-            let snapshot = editor.snapshot(cx);
             let hover_state = &editor.hover_state;
             assert!(hover_state.diagnostic_popover.is_none() && hover_state.info_popover.is_some());
             let popover = hover_state.info_popover.as_ref().unwrap();
             let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
-            let entire_inlay_start = snapshot.display_point_to_inlay_offset(
-                inlay_range.start.to_display_point(&snapshot),
-                Bias::Left,
-            );
-            let expected_struct_label_start =
-                InlayOffset(entire_inlay_start.0 + ": ".len() + new_type_label.len() + "<".len());
             assert_eq!(
                 popover.symbol_range,
-                DocumentRange::Inlay(InlayRange {
+                RangeInEditor::Inlay(InlayHighlight {
+                    inlay: InlayId::Hint(0),
                     inlay_position: buffer_snapshot.anchor_at(inlay_range.start, Bias::Right),
-                    highlight_start: expected_struct_label_start,
-                    highlight_end: InlayOffset(expected_struct_label_start.0 + struct_label.len()),
+                    range: ": ".len() + new_type_label.len() + "<".len()
+                        ..": ".len() + new_type_label.len() + "<".len() + struct_label.len(),
                 }),
                 "Popover range should match the struct label part"
             );

crates/editor/src/inlay_hint_cache.rs 🔗

@@ -43,7 +43,8 @@ pub struct CachedExcerptHints {
     version: usize,
     buffer_version: Global,
     buffer_id: u64,
-    hints: Vec<(InlayId, InlayHint)>,
+    ordered_hints: Vec<InlayId>,
+    hints_by_id: HashMap<InlayId, InlayHint>,
 }
 
 #[derive(Debug, Clone, Copy)]
@@ -316,7 +317,7 @@ impl InlayHintCache {
             self.hints.retain(|cached_excerpt, cached_hints| {
                 let retain = excerpts_to_query.contains_key(cached_excerpt);
                 if !retain {
-                    invalidated_hints.extend(cached_hints.read().hints.iter().map(|&(id, _)| id));
+                    invalidated_hints.extend(cached_hints.read().ordered_hints.iter().copied());
                 }
                 retain
             });
@@ -384,7 +385,7 @@ impl InlayHintCache {
             let shown_excerpt_hints_to_remove =
                 shown_hints_to_remove.entry(*excerpt_id).or_default();
             let excerpt_cached_hints = excerpt_cached_hints.read();
-            let mut excerpt_cache = excerpt_cached_hints.hints.iter().fuse().peekable();
+            let mut excerpt_cache = excerpt_cached_hints.ordered_hints.iter().fuse().peekable();
             shown_excerpt_hints_to_remove.retain(|(shown_anchor, shown_hint_id)| {
                 let Some(buffer) = shown_anchor
                     .buffer_id
@@ -395,7 +396,8 @@ impl InlayHintCache {
                 let buffer_snapshot = buffer.read(cx).snapshot();
                 loop {
                     match excerpt_cache.peek() {
-                        Some((cached_hint_id, cached_hint)) => {
+                        Some(&cached_hint_id) => {
+                            let cached_hint = &excerpt_cached_hints.hints_by_id[cached_hint_id];
                             if cached_hint_id == shown_hint_id {
                                 excerpt_cache.next();
                                 return !new_kinds.contains(&cached_hint.kind);
@@ -428,7 +430,8 @@ impl InlayHintCache {
                 }
             });
 
-            for (cached_hint_id, maybe_missed_cached_hint) in excerpt_cache {
+            for cached_hint_id in excerpt_cache {
+                let maybe_missed_cached_hint = &excerpt_cached_hints.hints_by_id[cached_hint_id];
                 let cached_hint_kind = maybe_missed_cached_hint.kind;
                 if !old_kinds.contains(&cached_hint_kind) && new_kinds.contains(&cached_hint_kind) {
                     to_insert.push(Inlay::hint(
@@ -463,7 +466,7 @@ impl InlayHintCache {
             self.update_tasks.remove(&excerpt_to_remove);
             if let Some(cached_hints) = self.hints.remove(&excerpt_to_remove) {
                 let cached_hints = cached_hints.read();
-                to_remove.extend(cached_hints.hints.iter().map(|(id, _)| *id));
+                to_remove.extend(cached_hints.ordered_hints.iter().copied());
             }
         }
         if to_remove.is_empty() {
@@ -489,10 +492,8 @@ impl InlayHintCache {
         self.hints
             .get(&excerpt_id)?
             .read()
-            .hints
-            .iter()
-            .find(|&(id, _)| id == &hint_id)
-            .map(|(_, hint)| hint)
+            .hints_by_id
+            .get(&hint_id)
             .cloned()
     }
 
@@ -500,7 +501,13 @@ impl InlayHintCache {
         let mut hints = Vec::new();
         for excerpt_hints in self.hints.values() {
             let excerpt_hints = excerpt_hints.read();
-            hints.extend(excerpt_hints.hints.iter().map(|(_, hint)| hint).cloned());
+            hints.extend(
+                excerpt_hints
+                    .ordered_hints
+                    .iter()
+                    .map(|id| &excerpt_hints.hints_by_id[id])
+                    .cloned(),
+            );
         }
         hints
     }
@@ -518,12 +525,7 @@ impl InlayHintCache {
     ) {
         if let Some(excerpt_hints) = self.hints.get(&excerpt_id) {
             let mut guard = excerpt_hints.write();
-            if let Some(cached_hint) = guard
-                .hints
-                .iter_mut()
-                .find(|(hint_id, _)| hint_id == &id)
-                .map(|(_, hint)| hint)
-            {
+            if let Some(cached_hint) = guard.hints_by_id.get_mut(&id) {
                 if let ResolveState::CanResolve(server_id, _) = &cached_hint.resolve_state {
                     let hint_to_resolve = cached_hint.clone();
                     let server_id = *server_id;
@@ -555,12 +557,7 @@ impl InlayHintCache {
                                     editor.inlay_hint_cache.hints.get(&excerpt_id)
                                 {
                                     let mut guard = excerpt_hints.write();
-                                    if let Some(cached_hint) = guard
-                                        .hints
-                                        .iter_mut()
-                                        .find(|(hint_id, _)| hint_id == &id)
-                                        .map(|(_, hint)| hint)
-                                    {
+                                    if let Some(cached_hint) = guard.hints_by_id.get_mut(&id) {
                                         if cached_hint.resolve_state == ResolveState::Resolving {
                                             resolved_hint.resolve_state = ResolveState::Resolved;
                                             *cached_hint = resolved_hint;
@@ -986,12 +983,17 @@ fn calculate_hint_updates(
         let missing_from_cache = match &cached_excerpt_hints {
             Some(cached_excerpt_hints) => {
                 let cached_excerpt_hints = cached_excerpt_hints.read();
-                match cached_excerpt_hints.hints.binary_search_by(|probe| {
-                    probe.1.position.cmp(&new_hint.position, buffer_snapshot)
-                }) {
+                match cached_excerpt_hints
+                    .ordered_hints
+                    .binary_search_by(|probe| {
+                        cached_excerpt_hints.hints_by_id[probe]
+                            .position
+                            .cmp(&new_hint.position, buffer_snapshot)
+                    }) {
                     Ok(ix) => {
                         let mut missing_from_cache = true;
-                        for (cached_inlay_id, cached_hint) in &cached_excerpt_hints.hints[ix..] {
+                        for id in &cached_excerpt_hints.ordered_hints[ix..] {
+                            let cached_hint = &cached_excerpt_hints.hints_by_id[id];
                             if new_hint
                                 .position
                                 .cmp(&cached_hint.position, buffer_snapshot)
@@ -1000,7 +1002,7 @@ fn calculate_hint_updates(
                                 break;
                             }
                             if cached_hint == &new_hint {
-                                excerpt_hints_to_persist.insert(*cached_inlay_id, cached_hint.kind);
+                                excerpt_hints_to_persist.insert(*id, cached_hint.kind);
                                 missing_from_cache = false;
                             }
                         }
@@ -1031,12 +1033,12 @@ fn calculate_hint_updates(
             let cached_excerpt_hints = cached_excerpt_hints.read();
             remove_from_cache.extend(
                 cached_excerpt_hints
-                    .hints
+                    .ordered_hints
                     .iter()
-                    .filter(|(cached_inlay_id, _)| {
+                    .filter(|cached_inlay_id| {
                         !excerpt_hints_to_persist.contains_key(cached_inlay_id)
                     })
-                    .map(|(cached_inlay_id, _)| *cached_inlay_id),
+                    .copied(),
             );
         }
     }
@@ -1080,7 +1082,8 @@ fn apply_hint_update(
                 version: query.cache_version,
                 buffer_version: buffer_snapshot.version().clone(),
                 buffer_id: query.buffer_id,
-                hints: Vec::new(),
+                ordered_hints: Vec::new(),
+                hints_by_id: HashMap::default(),
             }))
         });
     let mut cached_excerpt_hints = cached_excerpt_hints.write();
@@ -1093,20 +1096,27 @@ fn apply_hint_update(
 
     let mut cached_inlays_changed = !new_update.remove_from_cache.is_empty();
     cached_excerpt_hints
-        .hints
-        .retain(|(hint_id, _)| !new_update.remove_from_cache.contains(hint_id));
+        .ordered_hints
+        .retain(|hint_id| !new_update.remove_from_cache.contains(hint_id));
+    cached_excerpt_hints
+        .hints_by_id
+        .retain(|hint_id, _| !new_update.remove_from_cache.contains(hint_id));
     let mut splice = InlaySplice {
         to_remove: new_update.remove_from_visible,
         to_insert: Vec::new(),
     };
     for new_hint in new_update.add_to_cache {
-        let cached_hints = &mut cached_excerpt_hints.hints;
-        let insert_position = match cached_hints
-            .binary_search_by(|probe| probe.1.position.cmp(&new_hint.position, &buffer_snapshot))
-        {
+        let insert_position = match cached_excerpt_hints
+            .ordered_hints
+            .binary_search_by(|probe| {
+                cached_excerpt_hints.hints_by_id[probe]
+                    .position
+                    .cmp(&new_hint.position, &buffer_snapshot)
+            }) {
             Ok(i) => {
                 let mut insert_position = Some(i);
-                for (_, cached_hint) in &cached_hints[i..] {
+                for id in &cached_excerpt_hints.ordered_hints[i..] {
+                    let cached_hint = &cached_excerpt_hints.hints_by_id[id];
                     if new_hint
                         .position
                         .cmp(&cached_hint.position, &buffer_snapshot)
@@ -1137,7 +1147,11 @@ fn apply_hint_update(
                     .to_insert
                     .push(Inlay::hint(new_inlay_id, new_hint_position, &new_hint));
             }
-            cached_hints.insert(insert_position, (InlayId::Hint(new_inlay_id), new_hint));
+            let new_id = InlayId::Hint(new_inlay_id);
+            cached_excerpt_hints.hints_by_id.insert(new_id, new_hint);
+            cached_excerpt_hints
+                .ordered_hints
+                .insert(insert_position, new_id);
             cached_inlays_changed = true;
         }
     }
@@ -1157,7 +1171,7 @@ fn apply_hint_update(
                 outdated_excerpt_caches.insert(*excerpt_id);
                 splice
                     .to_remove
-                    .extend(excerpt_hints.hints.iter().map(|(id, _)| id));
+                    .extend(excerpt_hints.ordered_hints.iter().copied());
             }
         }
         cached_inlays_changed |= !outdated_excerpt_caches.is_empty();
@@ -3311,8 +3325,9 @@ all hints should be invalidated and requeried for all of its visible excerpts"
     pub fn cached_hint_labels(editor: &Editor) -> Vec<String> {
         let mut labels = Vec::new();
         for (_, excerpt_hints) in &editor.inlay_hint_cache().hints {
-            for (_, inlay) in &excerpt_hints.read().hints {
-                labels.push(inlay.text());
+            let excerpt_hints = excerpt_hints.read();
+            for id in &excerpt_hints.ordered_hints {
+                labels.push(excerpt_hints.hints_by_id[id].text());
             }
         }
 
@@ -1,8 +1,8 @@
 use crate::{
-    display_map::{DisplaySnapshot, InlayOffset},
+    display_map::DisplaySnapshot,
     element::PointForPosition,
     hover_popover::{self, InlayHover},
-    Anchor, DisplayPoint, Editor, EditorSnapshot, SelectPhase,
+    Anchor, DisplayPoint, Editor, EditorSnapshot, InlayId, SelectPhase,
 };
 use gpui::{Task, ViewContext};
 use language::{Bias, ToOffset};
@@ -17,44 +17,19 @@ use util::TryFutureExt;
 #[derive(Debug, Default)]
 pub struct LinkGoToDefinitionState {
     pub last_trigger_point: Option<TriggerPoint>,
-    pub symbol_range: Option<DocumentRange>,
+    pub symbol_range: Option<RangeInEditor>,
     pub kind: Option<LinkDefinitionKind>,
     pub definitions: Vec<GoToDefinitionLink>,
     pub task: Option<Task<Option<()>>>,
 }
 
-#[derive(Debug)]
-pub enum GoToDefinitionTrigger {
-    Text(DisplayPoint),
-    InlayHint(InlayRange, lsp::Location, LanguageServerId),
-}
-
-#[derive(Debug, Clone)]
-pub enum GoToDefinitionLink {
-    Text(LocationLink),
-    InlayHint(lsp::Location, LanguageServerId),
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct InlayRange {
-    pub inlay_position: Anchor,
-    pub highlight_start: InlayOffset,
-    pub highlight_end: InlayOffset,
-}
-
-#[derive(Debug, Clone)]
-pub enum TriggerPoint {
-    Text(Anchor),
-    InlayHint(InlayRange, lsp::Location, LanguageServerId),
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum DocumentRange {
+#[derive(Debug, Eq, PartialEq, Clone)]
+pub enum RangeInEditor {
     Text(Range<Anchor>),
-    Inlay(InlayRange),
+    Inlay(InlayHighlight),
 }
 
-impl DocumentRange {
+impl RangeInEditor {
     pub fn as_text_range(&self) -> Option<Range<Anchor>> {
         match self {
             Self::Text(range) => Some(range.clone()),
@@ -64,28 +39,47 @@ impl DocumentRange {
 
     fn point_within_range(&self, trigger_point: &TriggerPoint, snapshot: &EditorSnapshot) -> bool {
         match (self, trigger_point) {
-            (DocumentRange::Text(range), TriggerPoint::Text(point)) => {
+            (Self::Text(range), TriggerPoint::Text(point)) => {
                 let point_after_start = range.start.cmp(point, &snapshot.buffer_snapshot).is_le();
                 point_after_start && range.end.cmp(point, &snapshot.buffer_snapshot).is_ge()
             }
-            (DocumentRange::Inlay(range), TriggerPoint::InlayHint(point, _, _)) => {
-                range.highlight_start.cmp(&point.highlight_end).is_le()
-                    && range.highlight_end.cmp(&point.highlight_end).is_ge()
+            (Self::Inlay(highlight), TriggerPoint::InlayHint(point, _, _)) => {
+                highlight.inlay == point.inlay
+                    && highlight.range.contains(&point.range.start)
+                    && highlight.range.contains(&point.range.end)
             }
-            (DocumentRange::Inlay(_), TriggerPoint::Text(_))
-            | (DocumentRange::Text(_), TriggerPoint::InlayHint(_, _, _)) => false,
+            (Self::Inlay(_), TriggerPoint::Text(_))
+            | (Self::Text(_), TriggerPoint::InlayHint(_, _, _)) => false,
         }
     }
 }
 
-impl TriggerPoint {
-    fn anchor(&self) -> &Anchor {
-        match self {
-            TriggerPoint::Text(anchor) => anchor,
-            TriggerPoint::InlayHint(range, _, _) => &range.inlay_position,
-        }
-    }
+#[derive(Debug)]
+pub enum GoToDefinitionTrigger {
+    Text(DisplayPoint),
+    InlayHint(InlayHighlight, lsp::Location, LanguageServerId),
+}
+
+#[derive(Debug, Clone)]
+pub enum GoToDefinitionLink {
+    Text(LocationLink),
+    InlayHint(lsp::Location, LanguageServerId),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct InlayHighlight {
+    pub inlay: InlayId,
+    pub inlay_position: Anchor,
+    pub range: Range<usize>,
+}
+
+#[derive(Debug, Clone)]
+pub enum TriggerPoint {
+    Text(Anchor),
+    InlayHint(InlayHighlight, lsp::Location, LanguageServerId),
+}
 
+impl TriggerPoint {
     pub fn definition_kind(&self, shift: bool) -> LinkDefinitionKind {
         match self {
             TriggerPoint::Text(_) => {
@@ -98,6 +92,13 @@ impl TriggerPoint {
             TriggerPoint::InlayHint(_, _, _) => LinkDefinitionKind::Type,
         }
     }
+
+    fn anchor(&self) -> &Anchor {
+        match self {
+            TriggerPoint::Text(anchor) => anchor,
+            TriggerPoint::InlayHint(inlay_range, _, _) => &inlay_range.inlay_position,
+        }
+    }
 }
 
 pub fn update_go_to_definition_link(
@@ -135,11 +136,7 @@ pub fn update_go_to_definition_link(
                 }
             }
             (TriggerPoint::InlayHint(range_a, _, _), TriggerPoint::InlayHint(range_b, _, _)) => {
-                if range_a
-                    .inlay_position
-                    .cmp(&range_b.inlay_position, &snapshot.buffer_snapshot)
-                    .is_eq()
-                {
+                if range_a == range_b {
                     return;
                 }
             }
@@ -173,10 +170,6 @@ pub fn update_inlay_link_and_hover_points(
     shift_held: bool,
     cx: &mut ViewContext<'_, '_, Editor>,
 ) {
-    let hint_start_offset =
-        snapshot.display_point_to_inlay_offset(point_for_position.previous_valid, Bias::Left);
-    let hint_end_offset =
-        snapshot.display_point_to_inlay_offset(point_for_position.next_valid, Bias::Right);
     let hovered_offset = if point_for_position.column_overshoot_after_line_end == 0 {
         Some(snapshot.display_point_to_inlay_offset(point_for_position.exact_unclipped, Bias::Left))
     } else {
@@ -224,15 +217,14 @@ pub fn update_inlay_link_and_hover_points(
                         }
                     }
                     ResolveState::Resolved => {
-                        let mut actual_hint_start = hint_start_offset;
-                        let mut actual_hint_end = hint_end_offset;
+                        let mut extra_shift_left = 0;
+                        let mut extra_shift_right = 0;
                         if cached_hint.padding_left {
-                            actual_hint_start.0 += 1;
-                            actual_hint_end.0 += 1;
+                            extra_shift_left += 1;
+                            extra_shift_right += 1;
                         }
                         if cached_hint.padding_right {
-                            actual_hint_start.0 += 1;
-                            actual_hint_end.0 += 1;
+                            extra_shift_right += 1;
                         }
                         match cached_hint.label {
                             project::InlayHintLabel::String(_) => {
@@ -253,11 +245,11 @@ pub fn update_inlay_link_and_hover_points(
                                                     }
                                                 }
                                             },
-                                            triggered_from: hovered_offset,
-                                            range: InlayRange {
+                                            range: InlayHighlight {
+                                                inlay: hovered_hint.id,
                                                 inlay_position: hovered_hint.position,
-                                                highlight_start: actual_hint_start,
-                                                highlight_end: actual_hint_end,
+                                                range: extra_shift_left
+                                                    ..hovered_hint.text.len() + extra_shift_right,
                                             },
                                         },
                                         cx,
@@ -266,13 +258,24 @@ pub fn update_inlay_link_and_hover_points(
                                 }
                             }
                             project::InlayHintLabel::LabelParts(label_parts) => {
+                                let hint_start =
+                                    snapshot.anchor_to_inlay_offset(hovered_hint.position);
                                 if let Some((hovered_hint_part, part_range)) =
                                     hover_popover::find_hovered_hint_part(
                                         label_parts,
-                                        actual_hint_start..actual_hint_end,
+                                        hint_start,
                                         hovered_offset,
                                     )
                                 {
+                                    let highlight_start =
+                                        (part_range.start - hint_start).0 + extra_shift_left;
+                                    let highlight_end =
+                                        (part_range.end - hint_start).0 + extra_shift_right;
+                                    let highlight = InlayHighlight {
+                                        inlay: hovered_hint.id,
+                                        inlay_position: hovered_hint.position,
+                                        range: highlight_start..highlight_end,
+                                    };
                                     if let Some(tooltip) = hovered_hint_part.tooltip {
                                         hover_popover::hover_at_inlay(
                                             editor,
@@ -292,12 +295,7 @@ pub fn update_inlay_link_and_hover_points(
                                                         kind: content.kind,
                                                     },
                                                 },
-                                                triggered_from: hovered_offset,
-                                                range: InlayRange {
-                                                    inlay_position: hovered_hint.position,
-                                                    highlight_start: part_range.start,
-                                                    highlight_end: part_range.end,
-                                                },
+                                                range: highlight.clone(),
                                             },
                                             cx,
                                         );
@@ -310,11 +308,7 @@ pub fn update_inlay_link_and_hover_points(
                                         update_go_to_definition_link(
                                             editor,
                                             Some(GoToDefinitionTrigger::InlayHint(
-                                                InlayRange {
-                                                    inlay_position: hovered_hint.position,
-                                                    highlight_start: part_range.start,
-                                                    highlight_end: part_range.end,
-                                                },
+                                                highlight,
                                                 location,
                                                 language_server_id,
                                             )),
@@ -425,7 +419,7 @@ pub fn show_link_definition(
                                     let end = snapshot
                                         .buffer_snapshot
                                         .anchor_in_excerpt(excerpt_id.clone(), origin.range.end);
-                                    DocumentRange::Text(start..end)
+                                    RangeInEditor::Text(start..end)
                                 })
                             }),
                             definition_result
@@ -435,8 +429,8 @@ pub fn show_link_definition(
                         )
                     })
                 }
-                TriggerPoint::InlayHint(trigger_source, lsp_location, server_id) => Some((
-                    Some(DocumentRange::Inlay(*trigger_source)),
+                TriggerPoint::InlayHint(highlight, lsp_location, server_id) => Some((
+                    Some(RangeInEditor::Inlay(highlight.clone())),
                     vec![GoToDefinitionLink::InlayHint(
                         lsp_location.clone(),
                         *server_id,
@@ -446,7 +440,7 @@ pub fn show_link_definition(
 
             this.update(&mut cx, |this, cx| {
                 // Clear any existing highlights
-                this.clear_text_highlights::<LinkGoToDefinitionState>(cx);
+                this.clear_highlights::<LinkGoToDefinitionState>(cx);
                 this.link_go_to_definition_state.kind = Some(definition_kind);
                 this.link_go_to_definition_state.symbol_range = result
                     .as_ref()
@@ -498,26 +492,26 @@ pub fn show_link_definition(
                                     // If no symbol range returned from language server, use the surrounding word.
                                     let (offset_range, _) =
                                         snapshot.surrounding_word(*trigger_anchor);
-                                    DocumentRange::Text(
+                                    RangeInEditor::Text(
                                         snapshot.anchor_before(offset_range.start)
                                             ..snapshot.anchor_after(offset_range.end),
                                     )
                                 }
-                                TriggerPoint::InlayHint(inlay_coordinates, _, _) => {
-                                    DocumentRange::Inlay(*inlay_coordinates)
+                                TriggerPoint::InlayHint(highlight, _, _) => {
+                                    RangeInEditor::Inlay(highlight.clone())
                                 }
                             });
 
                         match highlight_range {
-                            DocumentRange::Text(text_range) => this
+                            RangeInEditor::Text(text_range) => this
                                 .highlight_text::<LinkGoToDefinitionState>(
                                     vec![text_range],
                                     style,
                                     cx,
                                 ),
-                            DocumentRange::Inlay(inlay_coordinates) => this
+                            RangeInEditor::Inlay(highlight) => this
                                 .highlight_inlays::<LinkGoToDefinitionState>(
-                                    vec![inlay_coordinates],
+                                    vec![highlight],
                                     style,
                                     cx,
                                 ),
@@ -547,7 +541,7 @@ pub fn hide_link_definition(editor: &mut Editor, cx: &mut ViewContext<Editor>) {
 
     editor.link_go_to_definition_state.task = None;
 
-    editor.clear_text_highlights::<LinkGoToDefinitionState>(cx);
+    editor.clear_highlights::<LinkGoToDefinitionState>(cx);
 }
 
 pub fn go_to_fetched_definition(
@@ -1199,30 +1193,19 @@ mod tests {
         cx.foreground().run_until_parked();
         cx.update_editor(|editor, cx| {
             let snapshot = editor.snapshot(cx);
-            let actual_ranges = snapshot
-                .highlight_ranges::<LinkGoToDefinitionState>()
-                .map(|ranges| ranges.as_ref().clone().1)
-                .unwrap_or_default()
+            let actual_highlights = snapshot
+                .inlay_highlights::<LinkGoToDefinitionState>()
                 .into_iter()
-                .map(|range| match range {
-                    DocumentRange::Text(range) => {
-                        panic!("Unexpected regular text selection range {range:?}")
-                    }
-                    DocumentRange::Inlay(inlay_range) => inlay_range,
-                })
+                .flat_map(|highlights| highlights.values().map(|(_, highlight)| highlight))
                 .collect::<Vec<_>>();
 
             let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
-            let expected_highlight_start = snapshot.display_point_to_inlay_offset(
-                inlay_range.start.to_display_point(&snapshot),
-                Bias::Left,
-            );
-            let expected_ranges = vec![InlayRange {
+            let expected_highlight = InlayHighlight {
+                inlay: InlayId::Hint(0),
                 inlay_position: buffer_snapshot.anchor_at(inlay_range.start, Bias::Right),
-                highlight_start: expected_highlight_start,
-                highlight_end: InlayOffset(expected_highlight_start.0 + hint_label.len()),
-            }];
-            assert_set_eq!(actual_ranges, expected_ranges);
+                range: 0..hint_label.len(),
+            };
+            assert_set_eq!(actual_highlights, vec![&expected_highlight]);
         });
 
         // Unpress cmd causes highlight to go away
@@ -1242,17 +1225,9 @@ mod tests {
         cx.update_editor(|editor, cx| {
             let snapshot = editor.snapshot(cx);
             let actual_ranges = snapshot
-                .highlight_ranges::<LinkGoToDefinitionState>()
+                .text_highlight_ranges::<LinkGoToDefinitionState>()
                 .map(|ranges| ranges.as_ref().clone().1)
-                .unwrap_or_default()
-                .into_iter()
-                .map(|range| match range {
-                    DocumentRange::Text(range) => {
-                        panic!("Unexpected regular text selection range {range:?}")
-                    }
-                    DocumentRange::Inlay(inlay_range) => inlay_range,
-                })
-                .collect::<Vec<_>>();
+                .unwrap_or_default();
 
             assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}");
         });

crates/editor/src/multi_buffer.rs 🔗

@@ -70,6 +70,9 @@ pub enum Event {
     Edited {
         sigleton_buffer_edited: bool,
     },
+    TransactionUndone {
+        transaction_id: TransactionId,
+    },
     Reloaded,
     DiffBaseChanged,
     LanguageChanged,
@@ -771,30 +774,36 @@ impl MultiBuffer {
     }
 
     pub fn undo(&mut self, cx: &mut ModelContext<Self>) -> Option<TransactionId> {
+        let mut transaction_id = None;
         if let Some(buffer) = self.as_singleton() {
-            return buffer.update(cx, |buffer, cx| buffer.undo(cx));
-        }
+            transaction_id = buffer.update(cx, |buffer, cx| buffer.undo(cx));
+        } else {
+            while let Some(transaction) = self.history.pop_undo() {
+                let mut undone = false;
+                for (buffer_id, buffer_transaction_id) in &mut transaction.buffer_transactions {
+                    if let Some(BufferState { buffer, .. }) = self.buffers.borrow().get(buffer_id) {
+                        undone |= buffer.update(cx, |buffer, cx| {
+                            let undo_to = *buffer_transaction_id;
+                            if let Some(entry) = buffer.peek_undo_stack() {
+                                *buffer_transaction_id = entry.transaction_id();
+                            }
+                            buffer.undo_to_transaction(undo_to, cx)
+                        });
+                    }
+                }
 
-        while let Some(transaction) = self.history.pop_undo() {
-            let mut undone = false;
-            for (buffer_id, buffer_transaction_id) in &mut transaction.buffer_transactions {
-                if let Some(BufferState { buffer, .. }) = self.buffers.borrow().get(buffer_id) {
-                    undone |= buffer.update(cx, |buffer, cx| {
-                        let undo_to = *buffer_transaction_id;
-                        if let Some(entry) = buffer.peek_undo_stack() {
-                            *buffer_transaction_id = entry.transaction_id();
-                        }
-                        buffer.undo_to_transaction(undo_to, cx)
-                    });
+                if undone {
+                    transaction_id = Some(transaction.id);
+                    break;
                 }
             }
+        }
 
-            if undone {
-                return Some(transaction.id);
-            }
+        if let Some(transaction_id) = transaction_id {
+            cx.emit(Event::TransactionUndone { transaction_id });
         }
 
-        None
+        transaction_id
     }
 
     pub fn redo(&mut self, cx: &mut ModelContext<Self>) -> Option<TransactionId> {

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

@@ -225,7 +225,6 @@ impl<'a> EditorTestContext<'a> {
                 .map(|h| h.1.clone())
                 .unwrap_or_default()
                 .into_iter()
-                .filter_map(|range| range.as_text_range())
                 .map(|range| range.to_offset(&snapshot.buffer_snapshot))
                 .collect()
         });
@@ -237,11 +236,10 @@ impl<'a> EditorTestContext<'a> {
         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>()
+            .text_highlight_ranges::<Tag>()
             .map(|ranges| ranges.as_ref().clone().1)
             .unwrap_or_default()
             .into_iter()
-            .filter_map(|range| range.as_text_range())
             .map(|range| range.to_offset(&snapshot.buffer_snapshot))
             .collect();
         assert_set_eq!(actual_ranges, expected_ranges);

crates/language/src/language.rs 🔗

@@ -13,7 +13,7 @@ use anyhow::{anyhow, Context, Result};
 use async_trait::async_trait;
 use collections::{HashMap, HashSet};
 use futures::{
-    channel::oneshot,
+    channel::{mpsc, oneshot},
     future::{BoxFuture, Shared},
     FutureExt, TryFutureExt as _,
 };
@@ -48,9 +48,6 @@ use unicase::UniCase;
 use util::{http::HttpClient, paths::PathExt};
 use util::{post_inc, ResultExt, TryFutureExt as _, UnwrapFuture};
 
-#[cfg(any(test, feature = "test-support"))]
-use futures::channel::mpsc;
-
 pub use buffer::Operation;
 pub use buffer::*;
 pub use diagnostic_set::DiagnosticEntry;
@@ -64,6 +61,27 @@ pub fn init(cx: &mut AppContext) {
     language_settings::init(cx);
 }
 
+#[derive(Clone, Default)]
+struct LspBinaryStatusSender {
+    txs: Arc<Mutex<Vec<mpsc::UnboundedSender<(Arc<Language>, LanguageServerBinaryStatus)>>>>,
+}
+
+impl LspBinaryStatusSender {
+    fn subscribe(&self) -> mpsc::UnboundedReceiver<(Arc<Language>, LanguageServerBinaryStatus)> {
+        let (tx, rx) = mpsc::unbounded();
+        self.txs.lock().push(tx);
+        rx
+    }
+
+    fn send(&self, language: Arc<Language>, status: LanguageServerBinaryStatus) {
+        let mut txs = self.txs.lock();
+        txs.retain(|tx| {
+            tx.unbounded_send((language.clone(), status.clone()))
+                .is_ok()
+        });
+    }
+}
+
 thread_local! {
     static PARSER: RefCell<Parser> = RefCell::new(Parser::new());
 }
@@ -594,14 +612,13 @@ struct AvailableLanguage {
 pub struct LanguageRegistry {
     state: RwLock<LanguageRegistryState>,
     language_server_download_dir: Option<Arc<Path>>,
-    lsp_binary_statuses_tx: async_broadcast::Sender<(Arc<Language>, LanguageServerBinaryStatus)>,
-    lsp_binary_statuses_rx: async_broadcast::Receiver<(Arc<Language>, LanguageServerBinaryStatus)>,
     login_shell_env_loaded: Shared<Task<()>>,
     #[allow(clippy::type_complexity)]
     lsp_binary_paths: Mutex<
         HashMap<LanguageServerName, Shared<Task<Result<LanguageServerBinary, Arc<anyhow::Error>>>>>,
     >,
     executor: Option<Arc<Background>>,
+    lsp_binary_status_tx: LspBinaryStatusSender,
 }
 
 struct LanguageRegistryState {
@@ -624,7 +641,6 @@ pub struct PendingLanguageServer {
 
 impl LanguageRegistry {
     pub fn new(login_shell_env_loaded: Task<()>) -> Self {
-        let (lsp_binary_statuses_tx, lsp_binary_statuses_rx) = async_broadcast::broadcast(16);
         Self {
             state: RwLock::new(LanguageRegistryState {
                 next_language_server_id: 0,
@@ -638,11 +654,10 @@ impl LanguageRegistry {
                 reload_count: 0,
             }),
             language_server_download_dir: None,
-            lsp_binary_statuses_tx,
-            lsp_binary_statuses_rx,
             login_shell_env_loaded: login_shell_env_loaded.shared(),
             lsp_binary_paths: Default::default(),
             executor: None,
+            lsp_binary_status_tx: Default::default(),
         }
     }
 
@@ -918,8 +933,8 @@ impl LanguageRegistry {
         let container_dir: Arc<Path> = Arc::from(download_dir.join(adapter.name.0.as_ref()));
         let root_path = root_path.clone();
         let adapter = adapter.clone();
-        let lsp_binary_statuses = self.lsp_binary_statuses_tx.clone();
         let login_shell_env_loaded = self.login_shell_env_loaded.clone();
+        let lsp_binary_statuses = self.lsp_binary_status_tx.clone();
 
         let task = {
             let container_dir = container_dir.clone();
@@ -976,8 +991,8 @@ impl LanguageRegistry {
 
     pub fn language_server_binary_statuses(
         &self,
-    ) -> async_broadcast::Receiver<(Arc<Language>, LanguageServerBinaryStatus)> {
-        self.lsp_binary_statuses_rx.clone()
+    ) -> mpsc::UnboundedReceiver<(Arc<Language>, LanguageServerBinaryStatus)> {
+        self.lsp_binary_status_tx.subscribe()
     }
 
     pub fn delete_server_container(
@@ -1054,7 +1069,7 @@ async fn get_binary(
     language: Arc<Language>,
     delegate: Arc<dyn LspAdapterDelegate>,
     container_dir: Arc<Path>,
-    statuses: async_broadcast::Sender<(Arc<Language>, LanguageServerBinaryStatus)>,
+    statuses: LspBinaryStatusSender,
     mut cx: AsyncAppContext,
 ) -> Result<LanguageServerBinary> {
     if !container_dir.exists() {
@@ -1081,19 +1096,15 @@ async fn get_binary(
             .cached_server_binary(container_dir.to_path_buf(), delegate.as_ref())
             .await
         {
-            statuses
-                .broadcast((language.clone(), LanguageServerBinaryStatus::Cached))
-                .await?;
+            statuses.send(language.clone(), LanguageServerBinaryStatus::Cached);
             return Ok(binary);
         } else {
-            statuses
-                .broadcast((
-                    language.clone(),
-                    LanguageServerBinaryStatus::Failed {
-                        error: format!("{:?}", error),
-                    },
-                ))
-                .await?;
+            statuses.send(
+                language.clone(),
+                LanguageServerBinaryStatus::Failed {
+                    error: format!("{:?}", error),
+                },
+            );
         }
     }
 
@@ -1105,27 +1116,21 @@ async fn fetch_latest_binary(
     language: Arc<Language>,
     delegate: &dyn LspAdapterDelegate,
     container_dir: &Path,
-    lsp_binary_statuses_tx: async_broadcast::Sender<(Arc<Language>, LanguageServerBinaryStatus)>,
+    lsp_binary_statuses_tx: LspBinaryStatusSender,
 ) -> Result<LanguageServerBinary> {
     let container_dir: Arc<Path> = container_dir.into();
-    lsp_binary_statuses_tx
-        .broadcast((
-            language.clone(),
-            LanguageServerBinaryStatus::CheckingForUpdate,
-        ))
-        .await?;
+    lsp_binary_statuses_tx.send(
+        language.clone(),
+        LanguageServerBinaryStatus::CheckingForUpdate,
+    );
 
     let version_info = adapter.fetch_latest_server_version(delegate).await?;
-    lsp_binary_statuses_tx
-        .broadcast((language.clone(), LanguageServerBinaryStatus::Downloading))
-        .await?;
+    lsp_binary_statuses_tx.send(language.clone(), LanguageServerBinaryStatus::Downloading);
 
     let binary = adapter
         .fetch_server_binary(version_info, container_dir.to_path_buf(), delegate)
         .await?;
-    lsp_binary_statuses_tx
-        .broadcast((language.clone(), LanguageServerBinaryStatus::Downloaded))
-        .await?;
+    lsp_binary_statuses_tx.send(language.clone(), LanguageServerBinaryStatus::Downloaded);
 
     Ok(binary)
 }

crates/project/src/project.rs 🔗

@@ -912,7 +912,6 @@ impl Project {
         self.user_store.clone()
     }
 
-    #[cfg(any(test, feature = "test-support"))]
     pub fn opened_buffers(&self, cx: &AppContext) -> Vec<ModelHandle<Buffer>> {
         self.opened_buffers
             .values()

crates/search/src/buffer_search.rs 🔗

@@ -56,6 +56,7 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(BufferSearchBar::replace_all_on_pane);
     cx.add_action(BufferSearchBar::replace_next_on_pane);
     cx.add_action(BufferSearchBar::toggle_replace);
+    cx.add_action(BufferSearchBar::toggle_replace_on_a_pane);
     add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
     add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
 }
@@ -101,6 +102,21 @@ impl View for BufferSearchBar {
         "BufferSearchBar"
     }
 
+    fn update_keymap_context(
+        &self,
+        keymap: &mut gpui::keymap_matcher::KeymapContext,
+        cx: &AppContext,
+    ) {
+        Self::reset_to_default_keymap_context(keymap);
+        let in_replace = self
+            .replacement_editor
+            .read_with(cx, |_, cx| cx.is_self_focused())
+            .unwrap_or(false);
+        if in_replace {
+            keymap.add_identifier("in_replace");
+        }
+    }
+
     fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
         if cx.is_self_focused() {
             cx.focus(&self.query_editor);
@@ -868,9 +884,25 @@ impl BufferSearchBar {
             cx.propagate_action();
         }
     }
-    fn toggle_replace(&mut self, _: &ToggleReplace, _: &mut ViewContext<Self>) {
+    fn toggle_replace(&mut self, _: &ToggleReplace, cx: &mut ViewContext<Self>) {
         if let Some(_) = &self.active_searchable_item {
             self.replace_is_active = !self.replace_is_active;
+            cx.notify();
+        }
+    }
+    fn toggle_replace_on_a_pane(pane: &mut Pane, _: &ToggleReplace, cx: &mut ViewContext<Pane>) {
+        let mut should_propagate = true;
+        if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
+            search_bar.update(cx, |bar, cx| {
+                if let Some(_) = &bar.active_searchable_item {
+                    should_propagate = false;
+                    bar.replace_is_active = !bar.replace_is_active;
+                    cx.notify();
+                }
+            });
+        }
+        if should_propagate {
+            cx.propagate_action();
         }
     }
     fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext<Self>) {
@@ -918,12 +950,16 @@ impl BufferSearchBar {
     fn replace_next_on_pane(pane: &mut Pane, action: &ReplaceNext, cx: &mut ViewContext<Pane>) {
         if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
             search_bar.update(cx, |bar, cx| bar.replace_next(action, cx));
+            return;
         }
+        cx.propagate_action();
     }
     fn replace_all_on_pane(pane: &mut Pane, action: &ReplaceAll, cx: &mut ViewContext<Pane>) {
         if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
             search_bar.update(cx, |bar, cx| bar.replace_all(action, cx));
+            return;
         }
+        cx.propagate_action();
     }
 }
 
@@ -976,7 +1012,7 @@ mod tests {
             .unwrap();
         editor.update(cx, |editor, cx| {
             assert_eq!(
-                editor.all_background_highlights(cx),
+                editor.all_text_background_highlights(cx),
                 &[
                     (
                         DisplayPoint::new(2, 17)..DisplayPoint::new(2, 19),
@@ -997,7 +1033,7 @@ mod tests {
         editor.next_notification(cx).await;
         editor.update(cx, |editor, cx| {
             assert_eq!(
-                editor.all_background_highlights(cx),
+                editor.all_text_background_highlights(cx),
                 &[(
                     DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45),
                     Color::red(),
@@ -1013,7 +1049,7 @@ mod tests {
             .unwrap();
         editor.update(cx, |editor, cx| {
             assert_eq!(
-                editor.all_background_highlights(cx),
+                editor.all_text_background_highlights(cx),
                 &[
                     (
                         DisplayPoint::new(0, 24)..DisplayPoint::new(0, 26),
@@ -1054,7 +1090,7 @@ mod tests {
         editor.next_notification(cx).await;
         editor.update(cx, |editor, cx| {
             assert_eq!(
-                editor.all_background_highlights(cx),
+                editor.all_text_background_highlights(cx),
                 &[
                     (
                         DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43),
@@ -1265,7 +1301,7 @@ mod tests {
             .unwrap();
         editor.update(cx, |editor, cx| {
             assert_eq!(
-                editor.all_background_highlights(cx),
+                editor.all_text_background_highlights(cx),
                 &[(
                     DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45),
                     Color::red(),
@@ -1292,7 +1328,7 @@ mod tests {
         editor.next_notification(cx).await;
         editor.update(cx, |editor, cx| {
             assert_eq!(
-                editor.all_background_highlights(cx),
+                editor.all_text_background_highlights(cx),
                 &[(
                     DisplayPoint::new(0, 35)..DisplayPoint::new(0, 40),
                     Color::red(),

crates/search/src/project_search.rs 🔗

@@ -701,8 +701,9 @@ impl ProjectSearchView {
                         }));
                     return;
                 }
+            } else {
+                semantic_state.maintain_rate_limit = None;
             }
-            semantic_state.maintain_rate_limit = None;
         }
     }
 
@@ -1724,7 +1725,7 @@ pub mod tests {
             assert_eq!(
                 search_view
                     .results_editor
-                    .update(cx, |editor, cx| editor.all_background_highlights(cx)),
+                    .update(cx, |editor, cx| editor.all_text_background_highlights(cx)),
                 &[
                     (
                         DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),

crates/search/src/search.rs 🔗

@@ -110,7 +110,7 @@ fn toggle_replace_button<V: View>(
     button_style: ToggleIconButtonStyle,
 ) -> AnyElement<V> {
     Button::dynamic_action(Box::new(ToggleReplace))
-        .with_tooltip("Toggle replace", tooltip_style)
+        .with_tooltip("Toggle Replace", tooltip_style)
         .with_contents(theme::components::svg::Svg::new("icons/replace.svg"))
         .toggleable(active)
         .with_style(button_style)

crates/semantic_index/Cargo.toml 🔗

@@ -23,6 +23,7 @@ settings = { path = "../settings" }
 anyhow.workspace = true
 postage.workspace = true
 futures.workspace = true
+ordered-float.workspace = true
 smol.workspace = true
 rusqlite = { version = "0.27.0", features = ["blob", "array", "modern_sqlite"] }
 isahc.workspace = true

crates/semantic_index/src/db.rs 🔗

@@ -7,12 +7,13 @@ use anyhow::{anyhow, Context, Result};
 use collections::HashMap;
 use futures::channel::oneshot;
 use gpui::executor;
+use ordered_float::OrderedFloat;
 use project::{search::PathMatcher, Fs};
 use rpc::proto::Timestamp;
 use rusqlite::params;
 use rusqlite::types::Value;
 use std::{
-    cmp::Ordering,
+    cmp::Reverse,
     future::Future,
     ops::Range,
     path::{Path, PathBuf},
@@ -190,6 +191,10 @@ impl VectorDatabase {
                 )",
                 [],
             )?;
+            db.execute(
+                "CREATE INDEX spans_digest ON spans (digest)",
+                [],
+            )?;
 
             log::trace!("vector database initialized with updated schema.");
             Ok(())
@@ -274,6 +279,39 @@ impl VectorDatabase {
         })
     }
 
+    pub fn embeddings_for_digests(
+        &self,
+        digests: Vec<SpanDigest>,
+    ) -> impl Future<Output = Result<HashMap<SpanDigest, Embedding>>> {
+        self.transact(move |db| {
+            let mut query = db.prepare(
+                "
+                SELECT digest, embedding
+                FROM spans
+                WHERE digest IN rarray(?)
+                ",
+            )?;
+            let mut embeddings_by_digest = HashMap::default();
+            let digests = Rc::new(
+                digests
+                    .into_iter()
+                    .map(|p| Value::Blob(p.0.to_vec()))
+                    .collect::<Vec<_>>(),
+            );
+            let rows = query.query_map(params![digests], |row| {
+                Ok((row.get::<_, SpanDigest>(0)?, row.get::<_, Embedding>(1)?))
+            })?;
+
+            for row in rows {
+                if let Ok(row) = row {
+                    embeddings_by_digest.insert(row.0, row.1);
+                }
+            }
+
+            Ok(embeddings_by_digest)
+        })
+    }
+
     pub fn embeddings_for_files(
         &self,
         worktree_id_file_paths: HashMap<i64, Vec<Arc<Path>>>,
@@ -370,16 +408,16 @@ impl VectorDatabase {
         query_embedding: &Embedding,
         limit: usize,
         file_ids: &[i64],
-    ) -> impl Future<Output = Result<Vec<(i64, f32)>>> {
+    ) -> impl Future<Output = Result<Vec<(i64, OrderedFloat<f32>)>>> {
         let query_embedding = query_embedding.clone();
         let file_ids = file_ids.to_vec();
         self.transact(move |db| {
-            let mut results = Vec::<(i64, f32)>::with_capacity(limit + 1);
+            let mut results = Vec::<(i64, OrderedFloat<f32>)>::with_capacity(limit + 1);
             Self::for_each_span(db, &file_ids, |id, embedding| {
                 let similarity = embedding.similarity(&query_embedding);
-                let ix = match results.binary_search_by(|(_, s)| {
-                    similarity.partial_cmp(&s).unwrap_or(Ordering::Equal)
-                }) {
+                let ix = match results
+                    .binary_search_by_key(&Reverse(similarity), |(_, s)| Reverse(*s))
+                {
                     Ok(ix) => ix,
                     Err(ix) => ix,
                 };

crates/semantic_index/src/embedding.rs 🔗

@@ -7,6 +7,7 @@ use isahc::http::StatusCode;
 use isahc::prelude::Configurable;
 use isahc::{AsyncBody, Response};
 use lazy_static::lazy_static;
+use ordered_float::OrderedFloat;
 use parking_lot::Mutex;
 use parse_duration::parse;
 use postage::watch;
@@ -35,7 +36,7 @@ impl From<Vec<f32>> for Embedding {
 }
 
 impl Embedding {
-    pub fn similarity(&self, other: &Self) -> f32 {
+    pub fn similarity(&self, other: &Self) -> OrderedFloat<f32> {
         let len = self.0.len();
         assert_eq!(len, other.0.len());
 
@@ -58,7 +59,7 @@ impl Embedding {
                 1,
             );
         }
-        result
+        OrderedFloat(result)
     }
 }
 
@@ -379,13 +380,13 @@ mod tests {
             );
         }
 
-        fn round_to_decimals(n: f32, decimal_places: i32) -> f32 {
+        fn round_to_decimals(n: OrderedFloat<f32>, decimal_places: i32) -> f32 {
             let factor = (10.0 as f32).powi(decimal_places);
             (n * factor).round() / factor
         }
 
-        fn reference_dot(a: &[f32], b: &[f32]) -> f32 {
-            a.iter().zip(b.iter()).map(|(a, b)| a * b).sum()
+        fn reference_dot(a: &[f32], b: &[f32]) -> OrderedFloat<f32> {
+            OrderedFloat(a.iter().zip(b.iter()).map(|(a, b)| a * b).sum())
         }
     }
 }

crates/semantic_index/src/parsing.rs 🔗

@@ -7,6 +7,7 @@ use rusqlite::{
 };
 use sha1::{Digest, Sha1};
 use std::{
+    borrow::Cow,
     cmp::{self, Reverse},
     collections::HashSet,
     ops::Range,
@@ -16,7 +17,7 @@ use std::{
 use tree_sitter::{Parser, QueryCursor};
 
 #[derive(Debug, PartialEq, Eq, Clone, Hash)]
-pub struct SpanDigest([u8; 20]);
+pub struct SpanDigest(pub [u8; 20]);
 
 impl FromSql for SpanDigest {
     fn column_result(value: ValueRef) -> FromSqlResult<Self> {
@@ -94,12 +95,15 @@ impl CodeContextRetriever {
 
     fn parse_entire_file(
         &self,
-        relative_path: &Path,
+        relative_path: Option<&Path>,
         language_name: Arc<str>,
         content: &str,
     ) -> Result<Vec<Span>> {
         let document_span = ENTIRE_FILE_TEMPLATE
-            .replace("<path>", relative_path.to_string_lossy().as_ref())
+            .replace(
+                "<path>",
+                &relative_path.map_or(Cow::Borrowed("untitled"), |path| path.to_string_lossy()),
+            )
             .replace("<language>", language_name.as_ref())
             .replace("<item>", &content);
         let digest = SpanDigest::from(document_span.as_str());
@@ -114,9 +118,16 @@ impl CodeContextRetriever {
         }])
     }
 
-    fn parse_markdown_file(&self, relative_path: &Path, content: &str) -> Result<Vec<Span>> {
+    fn parse_markdown_file(
+        &self,
+        relative_path: Option<&Path>,
+        content: &str,
+    ) -> Result<Vec<Span>> {
         let document_span = MARKDOWN_CONTEXT_TEMPLATE
-            .replace("<path>", relative_path.to_string_lossy().as_ref())
+            .replace(
+                "<path>",
+                &relative_path.map_or(Cow::Borrowed("untitled"), |path| path.to_string_lossy()),
+            )
             .replace("<item>", &content);
         let digest = SpanDigest::from(document_span.as_str());
         let (document_span, token_count) = self.embedding_provider.truncate(&document_span);
@@ -188,7 +199,7 @@ impl CodeContextRetriever {
 
     pub fn parse_file_with_template(
         &mut self,
-        relative_path: &Path,
+        relative_path: Option<&Path>,
         content: &str,
         language: Arc<Language>,
     ) -> Result<Vec<Span>> {
@@ -196,14 +207,17 @@ impl CodeContextRetriever {
 
         if PARSEABLE_ENTIRE_FILE_TYPES.contains(&language_name.as_ref()) {
             return self.parse_entire_file(relative_path, language_name, &content);
-        } else if language_name.as_ref() == "Markdown" {
+        } else if ["Markdown", "Plain Text"].contains(&language_name.as_ref()) {
             return self.parse_markdown_file(relative_path, &content);
         }
 
         let mut spans = self.parse_file(content, language)?;
         for span in &mut spans {
             let document_content = CODE_CONTEXT_TEMPLATE
-                .replace("<path>", relative_path.to_string_lossy().as_ref())
+                .replace(
+                    "<path>",
+                    &relative_path.map_or(Cow::Borrowed("untitled"), |path| path.to_string_lossy()),
+                )
                 .replace("<language>", language_name.as_ref())
                 .replace("item", &span.content);
 

crates/semantic_index/src/semantic_index.rs 🔗

@@ -16,14 +16,16 @@ use embedding_queue::{EmbeddingQueue, FileToEmbed};
 use futures::{future, FutureExt, StreamExt};
 use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task, WeakModelHandle};
 use language::{Anchor, Bias, Buffer, Language, LanguageRegistry};
+use ordered_float::OrderedFloat;
 use parking_lot::Mutex;
-use parsing::{CodeContextRetriever, SpanDigest, PARSEABLE_ENTIRE_FILE_TYPES};
+use parsing::{CodeContextRetriever, Span, SpanDigest, PARSEABLE_ENTIRE_FILE_TYPES};
 use postage::watch;
 use project::{search::PathMatcher, Fs, PathChange, Project, ProjectEntryId, Worktree, WorktreeId};
 use smol::channel;
 use std::{
-    cmp::Ordering,
+    cmp::Reverse,
     future::Future,
+    mem,
     ops::Range,
     path::{Path, PathBuf},
     sync::{Arc, Weak},
@@ -37,7 +39,7 @@ use util::{
 };
 use workspace::WorkspaceCreated;
 
-const SEMANTIC_INDEX_VERSION: usize = 10;
+const SEMANTIC_INDEX_VERSION: usize = 11;
 const BACKGROUND_INDEXING_DELAY: Duration = Duration::from_secs(5 * 60);
 const EMBEDDING_QUEUE_FLUSH_TIMEOUT: Duration = Duration::from_millis(250);
 
@@ -262,9 +264,11 @@ pub struct PendingFile {
     job_handle: JobHandle,
 }
 
+#[derive(Clone)]
 pub struct SearchResult {
     pub buffer: ModelHandle<Buffer>,
     pub range: Range<Anchor>,
+    pub similarity: OrderedFloat<f32>,
 }
 
 impl SemanticIndex {
@@ -402,7 +406,7 @@ impl SemanticIndex {
 
         if let Some(content) = fs.load(&pending_file.absolute_path).await.log_err() {
             if let Some(mut spans) = retriever
-                .parse_file_with_template(&pending_file.relative_path, &content, language)
+                .parse_file_with_template(Some(&pending_file.relative_path), &content, language)
                 .log_err()
             {
                 log::trace!(
@@ -422,7 +426,7 @@ impl SemanticIndex {
                     path: pending_file.relative_path,
                     mtime: pending_file.modified_time,
                     job_handle: pending_file.job_handle,
-                    spans: spans,
+                    spans,
                 });
             }
         }
@@ -687,38 +691,70 @@ impl SemanticIndex {
     pub fn search_project(
         &mut self,
         project: ModelHandle<Project>,
-        phrase: String,
+        query: String,
         limit: usize,
         includes: Vec<PathMatcher>,
         excludes: Vec<PathMatcher>,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<Vec<SearchResult>>> {
+        if query.is_empty() {
+            return Task::ready(Ok(Vec::new()));
+        }
+
         let index = self.index_project(project.clone(), cx);
         let embedding_provider = self.embedding_provider.clone();
-        let db_path = self.db.path().clone();
-        let fs = self.fs.clone();
+
         cx.spawn(|this, mut cx| async move {
+            let query = embedding_provider
+                .embed_batch(vec![query])
+                .await?
+                .pop()
+                .ok_or_else(|| anyhow!("could not embed query"))?;
             index.await?;
 
-            let t0 = Instant::now();
-            let database =
-                VectorDatabase::new(fs.clone(), db_path.clone(), cx.background()).await?;
+            let search_start = Instant::now();
+            let modified_buffer_results = this.update(&mut cx, |this, cx| {
+                this.search_modified_buffers(&project, query.clone(), limit, &excludes, cx)
+            });
+            let file_results = this.update(&mut cx, |this, cx| {
+                this.search_files(project, query, limit, includes, excludes, cx)
+            });
+            let (modified_buffer_results, file_results) =
+                futures::join!(modified_buffer_results, file_results);
 
-            if phrase.len() == 0 {
-                return Ok(Vec::new());
+            // Weave together the results from modified buffers and files.
+            let mut results = Vec::new();
+            let mut modified_buffers = HashSet::default();
+            for result in modified_buffer_results.log_err().unwrap_or_default() {
+                modified_buffers.insert(result.buffer.clone());
+                results.push(result);
             }
+            for result in file_results.log_err().unwrap_or_default() {
+                if !modified_buffers.contains(&result.buffer) {
+                    results.push(result);
+                }
+            }
+            results.sort_by_key(|result| Reverse(result.similarity));
+            results.truncate(limit);
+            log::trace!("Semantic search took {:?}", search_start.elapsed());
+            Ok(results)
+        })
+    }
 
-            let phrase_embedding = embedding_provider
-                .embed_batch(vec![phrase])
-                .await?
-                .into_iter()
-                .next()
-                .unwrap();
-
-            log::trace!(
-                "Embedding search phrase took: {:?} milliseconds",
-                t0.elapsed().as_millis()
-            );
+    pub fn search_files(
+        &mut self,
+        project: ModelHandle<Project>,
+        query: Embedding,
+        limit: usize,
+        includes: Vec<PathMatcher>,
+        excludes: Vec<PathMatcher>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Vec<SearchResult>>> {
+        let db_path = self.db.path().clone();
+        let fs = self.fs.clone();
+        cx.spawn(|this, mut cx| async move {
+            let database =
+                VectorDatabase::new(fs.clone(), db_path.clone(), cx.background()).await?;
 
             let worktree_db_ids = this.read_with(&cx, |this, _| {
                 let project_state = this
@@ -738,6 +774,7 @@ impl SemanticIndex {
                     .collect::<Vec<i64>>();
                 anyhow::Ok(worktree_db_ids)
             })?;
+
             let file_ids = database
                 .retrieve_included_file_ids(&worktree_db_ids, &includes, &excludes)
                 .await?;
@@ -756,26 +793,26 @@ impl SemanticIndex {
                 let limit = limit.clone();
                 let fs = fs.clone();
                 let db_path = db_path.clone();
-                let phrase_embedding = phrase_embedding.clone();
+                let query = query.clone();
                 if let Some(db) = VectorDatabase::new(fs, db_path.clone(), cx.background())
                     .await
                     .log_err()
                 {
                     batch_results.push(async move {
-                        db.top_k_search(&phrase_embedding, limit, batch.as_slice())
-                            .await
+                        db.top_k_search(&query, limit, batch.as_slice()).await
                     });
                 }
             }
+
             let batch_results = futures::future::join_all(batch_results).await;
 
             let mut results = Vec::new();
             for batch_result in batch_results {
                 if batch_result.is_ok() {
                     for (id, similarity) in batch_result.unwrap() {
-                        let ix = match results.binary_search_by(|(_, s)| {
-                            similarity.partial_cmp(&s).unwrap_or(Ordering::Equal)
-                        }) {
+                        let ix = match results
+                            .binary_search_by_key(&Reverse(similarity), |(_, s)| Reverse(*s))
+                        {
                             Ok(ix) => ix,
                             Err(ix) => ix,
                         };
@@ -785,7 +822,11 @@ impl SemanticIndex {
                 }
             }
 
-            let ids = results.into_iter().map(|(id, _)| id).collect::<Vec<i64>>();
+            let ids = results.iter().map(|(id, _)| *id).collect::<Vec<i64>>();
+            let scores = results
+                .into_iter()
+                .map(|(_, score)| score)
+                .collect::<Vec<_>>();
             let spans = database.spans_for_ids(ids.as_slice()).await?;
 
             let mut tasks = Vec::new();
@@ -810,24 +851,106 @@ impl SemanticIndex {
 
             let buffers = futures::future::join_all(tasks).await;
 
-            log::trace!(
-                "Semantic Searching took: {:?} milliseconds in total",
-                t0.elapsed().as_millis()
-            );
-
             Ok(buffers
                 .into_iter()
                 .zip(ranges)
-                .filter_map(|(buffer, range)| {
+                .zip(scores)
+                .filter_map(|((buffer, range), similarity)| {
                     let buffer = buffer.log_err()?;
                     let range = buffer.read_with(&cx, |buffer, _| {
                         let start = buffer.clip_offset(range.start, Bias::Left);
                         let end = buffer.clip_offset(range.end, Bias::Right);
                         buffer.anchor_before(start)..buffer.anchor_after(end)
                     });
-                    Some(SearchResult { buffer, range })
+                    Some(SearchResult {
+                        buffer,
+                        range,
+                        similarity,
+                    })
                 })
-                .collect::<Vec<_>>())
+                .collect())
+        })
+    }
+
+    fn search_modified_buffers(
+        &self,
+        project: &ModelHandle<Project>,
+        query: Embedding,
+        limit: usize,
+        excludes: &[PathMatcher],
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Vec<SearchResult>>> {
+        let modified_buffers = project
+            .read(cx)
+            .opened_buffers(cx)
+            .into_iter()
+            .filter_map(|buffer_handle| {
+                let buffer = buffer_handle.read(cx);
+                let snapshot = buffer.snapshot();
+                let excluded = snapshot.resolve_file_path(cx, false).map_or(false, |path| {
+                    excludes.iter().any(|matcher| matcher.is_match(&path))
+                });
+                if buffer.is_dirty() && !excluded {
+                    Some((buffer_handle, snapshot))
+                } else {
+                    None
+                }
+            })
+            .collect::<HashMap<_, _>>();
+
+        let embedding_provider = self.embedding_provider.clone();
+        let fs = self.fs.clone();
+        let db_path = self.db.path().clone();
+        let background = cx.background().clone();
+        cx.background().spawn(async move {
+            let db = VectorDatabase::new(fs, db_path.clone(), background).await?;
+            let mut results = Vec::<SearchResult>::new();
+
+            let mut retriever = CodeContextRetriever::new(embedding_provider.clone());
+            for (buffer, snapshot) in modified_buffers {
+                let language = snapshot
+                    .language_at(0)
+                    .cloned()
+                    .unwrap_or_else(|| language::PLAIN_TEXT.clone());
+                let mut spans = retriever
+                    .parse_file_with_template(None, &snapshot.text(), language)
+                    .log_err()
+                    .unwrap_or_default();
+                if Self::embed_spans(&mut spans, embedding_provider.as_ref(), &db)
+                    .await
+                    .log_err()
+                    .is_some()
+                {
+                    for span in spans {
+                        let similarity = span.embedding.unwrap().similarity(&query);
+                        let ix = match results
+                            .binary_search_by_key(&Reverse(similarity), |result| {
+                                Reverse(result.similarity)
+                            }) {
+                            Ok(ix) => ix,
+                            Err(ix) => ix,
+                        };
+
+                        let range = {
+                            let start = snapshot.clip_offset(span.range.start, Bias::Left);
+                            let end = snapshot.clip_offset(span.range.end, Bias::Right);
+                            snapshot.anchor_before(start)..snapshot.anchor_after(end)
+                        };
+
+                        results.insert(
+                            ix,
+                            SearchResult {
+                                buffer: buffer.clone(),
+                                range,
+                                similarity,
+                            },
+                        );
+                        results.truncate(limit);
+                    }
+                }
+            }
+
+            Ok(results)
         })
     }
 
@@ -1009,6 +1132,63 @@ impl SemanticIndex {
             Ok(())
         })
     }
+
+    async fn embed_spans(
+        spans: &mut [Span],
+        embedding_provider: &dyn EmbeddingProvider,
+        db: &VectorDatabase,
+    ) -> Result<()> {
+        let mut batch = Vec::new();
+        let mut batch_tokens = 0;
+        let mut embeddings = Vec::new();
+
+        let digests = spans
+            .iter()
+            .map(|span| span.digest.clone())
+            .collect::<Vec<_>>();
+        let embeddings_for_digests = db
+            .embeddings_for_digests(digests)
+            .await
+            .log_err()
+            .unwrap_or_default();
+
+        for span in &*spans {
+            if embeddings_for_digests.contains_key(&span.digest) {
+                continue;
+            };
+
+            if batch_tokens + span.token_count > embedding_provider.max_tokens_per_batch() {
+                let batch_embeddings = embedding_provider
+                    .embed_batch(mem::take(&mut batch))
+                    .await?;
+                embeddings.extend(batch_embeddings);
+                batch_tokens = 0;
+            }
+
+            batch_tokens += span.token_count;
+            batch.push(span.content.clone());
+        }
+
+        if !batch.is_empty() {
+            let batch_embeddings = embedding_provider
+                .embed_batch(mem::take(&mut batch))
+                .await?;
+
+            embeddings.extend(batch_embeddings);
+        }
+
+        let mut embeddings = embeddings.into_iter();
+        for span in spans {
+            let embedding = if let Some(embedding) = embeddings_for_digests.get(&span.digest) {
+                Some(embedding.clone())
+            } else {
+                embeddings.next()
+            };
+            let embedding = embedding.ok_or_else(|| anyhow!("failed to embed spans"))?;
+            span.embedding = Some(embedding);
+        }
+        Ok(())
+    }
 }
 
 impl Entity for SemanticIndex {

crates/vim/src/editor_events.rs 🔗

@@ -34,7 +34,9 @@ fn focused(EditorFocused(editor): &EditorFocused, cx: &mut AppContext) {
 fn blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut AppContext) {
     editor.window().update(cx, |cx| {
         Vim::update(cx, |vim, cx| {
+            vim.clear_operator(cx);
             vim.workspace_state.recording = false;
+            vim.workspace_state.recorded_actions.clear();
             if let Some(previous_editor) = vim.active_editor.clone() {
                 if previous_editor == editor.clone() {
                     vim.active_editor = None;

crates/vim/src/insert.rs 🔗

@@ -1,6 +1,6 @@
-use crate::{state::Mode, Vim};
+use crate::{normal::repeat, state::Mode, Vim};
 use editor::{scroll::autoscroll::Autoscroll, Bias};
-use gpui::{actions, AppContext, ViewContext};
+use gpui::{actions, Action, AppContext, ViewContext};
 use language::SelectionGoal;
 use workspace::Workspace;
 
@@ -10,24 +10,41 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(normal_before);
 }
 
-fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Workspace>) {
-    Vim::update(cx, |vim, cx| {
-        vim.stop_recording();
-        vim.update_active_editor(cx, |editor, cx| {
-            editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
-                s.move_cursors_with(|map, mut cursor, _| {
-                    *cursor.column_mut() = cursor.column().saturating_sub(1);
-                    (map.clip_point(cursor, Bias::Left), SelectionGoal::None)
+fn normal_before(_: &mut Workspace, action: &NormalBefore, cx: &mut ViewContext<Workspace>) {
+    let should_repeat = Vim::update(cx, |vim, cx| {
+        let count = vim.take_count(cx).unwrap_or(1);
+        vim.stop_recording_immediately(action.boxed_clone());
+        if count <= 1 || vim.workspace_state.replaying {
+            vim.update_active_editor(cx, |editor, cx| {
+                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                    s.move_cursors_with(|map, mut cursor, _| {
+                        *cursor.column_mut() = cursor.column().saturating_sub(1);
+                        (map.clip_point(cursor, Bias::Left), SelectionGoal::None)
+                    });
                 });
             });
-        });
-        vim.switch_mode(Mode::Normal, false, cx);
-    })
+            vim.switch_mode(Mode::Normal, false, cx);
+            false
+        } else {
+            true
+        }
+    });
+
+    if should_repeat {
+        repeat::repeat(cx, true)
+    }
 }
 
 #[cfg(test)]
 mod test {
-    use crate::{state::Mode, test::VimTestContext};
+    use std::sync::Arc;
+
+    use gpui::executor::Deterministic;
+
+    use crate::{
+        state::Mode,
+        test::{NeovimBackedTestContext, VimTestContext},
+    };
 
     #[gpui::test]
     async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) {
@@ -40,4 +57,78 @@ mod test {
         assert_eq!(cx.mode(), Mode::Normal);
         cx.assert_editor_state("Tesˇt");
     }
+
+    #[gpui::test]
+    async fn test_insert_with_counts(
+        deterministic: Arc<Deterministic>,
+        cx: &mut gpui::TestAppContext,
+    ) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("ˇhello\n").await;
+        cx.simulate_shared_keystrokes(["5", "i", "-", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("----ˇ-hello\n").await;
+
+        cx.set_shared_state("ˇhello\n").await;
+        cx.simulate_shared_keystrokes(["5", "a", "-", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("h----ˇ-ello\n").await;
+
+        cx.simulate_shared_keystrokes(["4", "shift-i", "-", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("---ˇ-h-----ello\n").await;
+
+        cx.simulate_shared_keystrokes(["3", "shift-a", "-", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("----h-----ello--ˇ-\n").await;
+
+        cx.set_shared_state("ˇhello\n").await;
+        cx.simulate_shared_keystrokes(["3", "o", "o", "i", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("hello\noi\noi\noˇi\n").await;
+
+        cx.set_shared_state("ˇhello\n").await;
+        cx.simulate_shared_keystrokes(["3", "shift-o", "o", "i", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("oi\noi\noˇi\nhello\n").await;
+    }
+
+    #[gpui::test]
+    async fn test_insert_with_repeat(
+        deterministic: Arc<Deterministic>,
+        cx: &mut gpui::TestAppContext,
+    ) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("ˇhello\n").await;
+        cx.simulate_shared_keystrokes(["3", "i", "-", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("--ˇ-hello\n").await;
+        cx.simulate_shared_keystrokes(["."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("----ˇ--hello\n").await;
+        cx.simulate_shared_keystrokes(["2", "."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("-----ˇ---hello\n").await;
+
+        cx.set_shared_state("ˇhello\n").await;
+        cx.simulate_shared_keystrokes(["2", "o", "k", "k", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("hello\nkk\nkˇk\n").await;
+        cx.simulate_shared_keystrokes(["."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("hello\nkk\nkk\nkk\nkˇk\n").await;
+        cx.simulate_shared_keystrokes(["1", "."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("hello\nkk\nkk\nkk\nkk\nkˇk\n").await;
+    }
 }

crates/vim/src/motion.rs 🔗

@@ -40,6 +40,8 @@ pub enum Motion {
     FindForward { before: bool, char: char },
     FindBackward { after: bool, char: char },
     NextLineStart,
+    StartOfLineDownward,
+    EndOfLineDownward,
 }
 
 #[derive(Clone, Deserialize, PartialEq)]
@@ -117,6 +119,8 @@ actions!(
         EndOfDocument,
         Matching,
         NextLineStart,
+        StartOfLineDownward,
+        EndOfLineDownward,
     ]
 );
 impl_actions!(
@@ -207,6 +211,12 @@ pub fn init(cx: &mut AppContext) {
          cx: _| { motion(Motion::PreviousWordStart { ignore_punctuation }, cx) },
     );
     cx.add_action(|_: &mut Workspace, &NextLineStart, cx: _| motion(Motion::NextLineStart, cx));
+    cx.add_action(|_: &mut Workspace, &StartOfLineDownward, cx: _| {
+        motion(Motion::StartOfLineDownward, cx)
+    });
+    cx.add_action(|_: &mut Workspace, &EndOfLineDownward, cx: _| {
+        motion(Motion::EndOfLineDownward, cx)
+    });
     cx.add_action(|_: &mut Workspace, action: &RepeatFind, cx: _| {
         repeat_motion(action.backwards, cx)
     })
@@ -219,11 +229,11 @@ pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) {
         Vim::update(cx, |vim, cx| vim.pop_operator(cx));
     }
 
-    let times = Vim::update(cx, |vim, cx| vim.pop_number_operator(cx));
+    let count = Vim::update(cx, |vim, cx| vim.take_count(cx));
     let operator = Vim::read(cx).active_operator();
     match Vim::read(cx).state().mode {
-        Mode::Normal => normal_motion(motion, operator, times, cx),
-        Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_motion(motion, times, cx),
+        Mode::Normal => normal_motion(motion, operator, count, cx),
+        Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_motion(motion, count, cx),
         Mode::Insert => {
             // Shouldn't execute a motion in insert mode. Ignoring
         }
@@ -272,6 +282,7 @@ impl Motion {
             | EndOfDocument
             | CurrentLine
             | NextLineStart
+            | StartOfLineDownward
             | StartOfParagraph
             | EndOfParagraph => true,
             EndOfLine { .. }
@@ -282,6 +293,7 @@ impl Motion {
             | Backspace
             | Right
             | StartOfLine { .. }
+            | EndOfLineDownward
             | NextWordStart { .. }
             | PreviousWordStart { .. }
             | FirstNonWhitespace { .. }
@@ -305,6 +317,8 @@ impl Motion {
             | StartOfLine { .. }
             | StartOfParagraph
             | EndOfParagraph
+            | StartOfLineDownward
+            | EndOfLineDownward
             | NextWordStart { .. }
             | PreviousWordStart { .. }
             | FirstNonWhitespace { .. }
@@ -322,6 +336,7 @@ impl Motion {
             | EndOfDocument
             | CurrentLine
             | EndOfLine { .. }
+            | EndOfLineDownward
             | NextWordEnd { .. }
             | Matching
             | FindForward { .. }
@@ -330,6 +345,7 @@ impl Motion {
             | Backspace
             | Right
             | StartOfLine { .. }
+            | StartOfLineDownward
             | StartOfParagraph
             | EndOfParagraph
             | NextWordStart { .. }
@@ -396,7 +412,7 @@ impl Motion {
                 map.clip_at_line_end(movement::end_of_paragraph(map, point, times)),
                 SelectionGoal::None,
             ),
-            CurrentLine => (end_of_line(map, false, point), SelectionGoal::None),
+            CurrentLine => (next_line_end(map, point, times), SelectionGoal::None),
             StartOfDocument => (start_of_document(map, point, times), SelectionGoal::None),
             EndOfDocument => (
                 end_of_document(map, point, maybe_times),
@@ -412,6 +428,8 @@ impl Motion {
                 SelectionGoal::None,
             ),
             NextLineStart => (next_line_start(map, point, times), SelectionGoal::None),
+            StartOfLineDownward => (next_line_start(map, point, times - 1), SelectionGoal::None),
+            EndOfLineDownward => (next_line_end(map, point, times), SelectionGoal::None),
         };
 
         (new_point != point || infallible).then_some((new_point, goal))
@@ -849,6 +867,13 @@ fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) ->
     first_non_whitespace(map, false, correct_line)
 }
 
+fn next_line_end(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
+    if times > 1 {
+        point = down(map, point, SelectionGoal::None, times - 1).0;
+    }
+    end_of_line(map, false, point)
+}
+
 #[cfg(test)]
 
 mod test {

crates/vim/src/normal.rs 🔗

@@ -2,7 +2,7 @@ mod case;
 mod change;
 mod delete;
 mod paste;
-mod repeat;
+pub(crate) mod repeat;
 mod scroll;
 mod search;
 pub mod substitute;
@@ -68,21 +68,21 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| {
         Vim::update(cx, |vim, cx| {
             vim.record_current_action(cx);
-            let times = vim.pop_number_operator(cx);
+            let times = vim.take_count(cx);
             delete_motion(vim, Motion::Left, times, cx);
         })
     });
     cx.add_action(|_: &mut Workspace, _: &DeleteRight, cx| {
         Vim::update(cx, |vim, cx| {
             vim.record_current_action(cx);
-            let times = vim.pop_number_operator(cx);
+            let times = vim.take_count(cx);
             delete_motion(vim, Motion::Right, times, cx);
         })
     });
     cx.add_action(|_: &mut Workspace, _: &ChangeToEndOfLine, cx| {
         Vim::update(cx, |vim, cx| {
             vim.start_recording(cx);
-            let times = vim.pop_number_operator(cx);
+            let times = vim.take_count(cx);
             change_motion(
                 vim,
                 Motion::EndOfLine {
@@ -96,7 +96,7 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(|_: &mut Workspace, _: &DeleteToEndOfLine, cx| {
         Vim::update(cx, |vim, cx| {
             vim.record_current_action(cx);
-            let times = vim.pop_number_operator(cx);
+            let times = vim.take_count(cx);
             delete_motion(
                 vim,
                 Motion::EndOfLine {
@@ -110,7 +110,7 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(|_: &mut Workspace, _: &JoinLines, cx| {
         Vim::update(cx, |vim, cx| {
             vim.record_current_action(cx);
-            let mut times = vim.pop_number_operator(cx).unwrap_or(1);
+            let mut times = vim.take_count(cx).unwrap_or(1);
             if vim.state().mode.is_visual() {
                 times = 1;
             } else if times > 1 {
@@ -356,7 +356,7 @@ mod test {
 
     use crate::{
         state::Mode::{self},
-        test::{ExemptionFeatures, NeovimBackedTestContext},
+        test::NeovimBackedTestContext,
     };
 
     #[gpui::test]
@@ -762,20 +762,22 @@ mod test {
 
     #[gpui::test]
     async fn test_dd(cx: &mut gpui::TestAppContext) {
-        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"})
-            .await;
-        cx.assert_exempted(
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.assert_neovim_compatible("ˇ", ["d", "d"]).await;
+        cx.assert_neovim_compatible("The ˇquick", ["d", "d"]).await;
+        for marked_text in cx.each_marked_position(indoc! {"
+            The qˇuick
+            brown ˇfox
+            jumps ˇover"})
+        {
+            cx.assert_neovim_compatible(&marked_text, ["d", "d"]).await;
+        }
+        cx.assert_neovim_compatible(
             indoc! {"
                 The quick
                 ˇ
                 brown fox"},
-            ExemptionFeatures::DeletionOnEmptyLine,
+            ["d", "d"],
         )
         .await;
     }

crates/vim/src/normal/case.rs 🔗

@@ -8,7 +8,7 @@ use crate::{normal::ChangeCase, state::Mode, Vim};
 pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
         vim.record_current_action(cx);
-        let count = vim.pop_number_operator(cx).unwrap_or(1) as u32;
+        let count = vim.take_count(cx).unwrap_or(1) as u32;
         vim.update_active_editor(cx, |editor, cx| {
             let mut ranges = Vec::new();
             let mut cursor_positions = Vec::new();

crates/vim/src/normal/change.rs 🔗

@@ -121,7 +121,7 @@ fn expand_changed_word_selection(
 mod test {
     use indoc::indoc;
 
-    use crate::test::{ExemptionFeatures, NeovimBackedTestContext};
+    use crate::test::NeovimBackedTestContext;
 
     #[gpui::test]
     async fn test_change_h(cx: &mut gpui::TestAppContext) {
@@ -239,150 +239,178 @@ mod test {
 
     #[gpui::test]
     async fn test_change_0(cx: &mut gpui::TestAppContext) {
-        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["c", "0"]);
-        cx.assert(indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.assert_neovim_compatible(
+            indoc! {"
             The qˇuick
-            brown fox"})
-            .await;
-        cx.assert(indoc! {"
+            brown fox"},
+            ["c", "0"],
+        )
+        .await;
+        cx.assert_neovim_compatible(
+            indoc! {"
             The quick
             ˇ
-            brown fox"})
-            .await;
+            brown fox"},
+            ["c", "0"],
+        )
+        .await;
     }
 
     #[gpui::test]
     async fn test_change_k(cx: &mut gpui::TestAppContext) {
-        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["c", "k"]);
-        cx.assert(indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.assert_neovim_compatible(
+            indoc! {"
             The quick
             brown ˇfox
-            jumps over"})
-            .await;
-        cx.assert(indoc! {"
+            jumps over"},
+            ["c", "k"],
+        )
+        .await;
+        cx.assert_neovim_compatible(
+            indoc! {"
             The quick
             brown fox
-            jumps ˇover"})
-            .await;
-        cx.assert_exempted(
+            jumps ˇover"},
+            ["c", "k"],
+        )
+        .await;
+        cx.assert_neovim_compatible(
             indoc! {"
             The qˇuick
             brown fox
             jumps over"},
-            ExemptionFeatures::OperatorAbortsOnFailedMotion,
+            ["c", "k"],
         )
         .await;
-        cx.assert_exempted(
+        cx.assert_neovim_compatible(
             indoc! {"
             ˇ
             brown fox
             jumps over"},
-            ExemptionFeatures::OperatorAbortsOnFailedMotion,
+            ["c", "k"],
         )
         .await;
     }
 
     #[gpui::test]
     async fn test_change_j(cx: &mut gpui::TestAppContext) {
-        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["c", "j"]);
-        cx.assert(indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.assert_neovim_compatible(
+            indoc! {"
             The quick
             brown ˇfox
-            jumps over"})
-            .await;
-        cx.assert_exempted(
+            jumps over"},
+            ["c", "j"],
+        )
+        .await;
+        cx.assert_neovim_compatible(
             indoc! {"
             The quick
             brown fox
             jumps ˇover"},
-            ExemptionFeatures::OperatorAbortsOnFailedMotion,
+            ["c", "j"],
         )
         .await;
-        cx.assert(indoc! {"
+        cx.assert_neovim_compatible(
+            indoc! {"
             The qˇuick
             brown fox
-            jumps over"})
-            .await;
-        cx.assert_exempted(
+            jumps over"},
+            ["c", "j"],
+        )
+        .await;
+        cx.assert_neovim_compatible(
             indoc! {"
             The quick
             brown fox
             ˇ"},
-            ExemptionFeatures::OperatorAbortsOnFailedMotion,
+            ["c", "j"],
         )
         .await;
     }
 
     #[gpui::test]
     async fn test_change_end_of_document(cx: &mut gpui::TestAppContext) {
-        let mut cx = NeovimBackedTestContext::new(cx)
-            .await
-            .binding(["c", "shift-g"]);
-        cx.assert(indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.assert_neovim_compatible(
+            indoc! {"
             The quick
             brownˇ fox
             jumps over
-            the lazy"})
-            .await;
-        cx.assert(indoc! {"
+            the lazy"},
+            ["c", "shift-g"],
+        )
+        .await;
+        cx.assert_neovim_compatible(
+            indoc! {"
             The quick
             brownˇ fox
             jumps over
-            the lazy"})
-            .await;
-        cx.assert_exempted(
+            the lazy"},
+            ["c", "shift-g"],
+        )
+        .await;
+        cx.assert_neovim_compatible(
             indoc! {"
             The quick
             brown fox
             jumps over
             the lˇazy"},
-            ExemptionFeatures::OperatorAbortsOnFailedMotion,
+            ["c", "shift-g"],
         )
         .await;
-        cx.assert_exempted(
+        cx.assert_neovim_compatible(
             indoc! {"
             The quick
             brown fox
             jumps over
             ˇ"},
-            ExemptionFeatures::OperatorAbortsOnFailedMotion,
+            ["c", "shift-g"],
         )
         .await;
     }
 
     #[gpui::test]
     async fn test_change_gg(cx: &mut gpui::TestAppContext) {
-        let mut cx = NeovimBackedTestContext::new(cx)
-            .await
-            .binding(["c", "g", "g"]);
-        cx.assert(indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.assert_neovim_compatible(
+            indoc! {"
             The quick
             brownˇ fox
             jumps over
-            the lazy"})
-            .await;
-        cx.assert(indoc! {"
+            the lazy"},
+            ["c", "g", "g"],
+        )
+        .await;
+        cx.assert_neovim_compatible(
+            indoc! {"
             The quick
             brown fox
             jumps over
-            the lˇazy"})
-            .await;
-        cx.assert_exempted(
+            the lˇazy"},
+            ["c", "g", "g"],
+        )
+        .await;
+        cx.assert_neovim_compatible(
             indoc! {"
             The qˇuick
             brown fox
             jumps over
             the lazy"},
-            ExemptionFeatures::OperatorAbortsOnFailedMotion,
+            ["c", "g", "g"],
         )
         .await;
-        cx.assert_exempted(
+        cx.assert_neovim_compatible(
             indoc! {"
             ˇ
             brown fox
             jumps over
             the lazy"},
-            ExemptionFeatures::OperatorAbortsOnFailedMotion,
+            ["c", "g", "g"],
         )
         .await;
     }
@@ -427,27 +455,17 @@ mod test {
     async fn test_repeated_cb(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;
 
-        cx.add_initial_state_exemptions(
-            indoc! {"
-            ˇThe quick brown
-
-            fox jumps-over
-            the lazy dog
-            "},
-            ExemptionFeatures::OperatorAbortsOnFailedMotion,
-        );
-
         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;
+            for marked_text in cx.each_marked_position(indoc! {"
+                ˇThe quˇickˇ browˇn
+                ˇ
+                ˇfox ˇjumpsˇ-ˇoˇver
+                ˇthe lazy dog
+                "})
+            {
+                cx.assert_neovim_compatible(&marked_text, ["c", &count.to_string(), "b"])
+                    .await;
+            }
         }
     }
 

crates/vim/src/normal/delete.rs 🔗

@@ -278,37 +278,41 @@ mod test {
 
     #[gpui::test]
     async fn test_delete_end_of_document(cx: &mut gpui::TestAppContext) {
-        let mut cx = NeovimBackedTestContext::new(cx)
-            .await
-            .binding(["d", "shift-g"]);
-        cx.assert(indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.assert_neovim_compatible(
+            indoc! {"
             The quick
             brownˇ fox
             jumps over
-            the lazy"})
-            .await;
-        cx.assert(indoc! {"
+            the lazy"},
+            ["d", "shift-g"],
+        )
+        .await;
+        cx.assert_neovim_compatible(
+            indoc! {"
             The quick
             brownˇ fox
             jumps over
-            the lazy"})
-            .await;
-        cx.assert_exempted(
+            the lazy"},
+            ["d", "shift-g"],
+        )
+        .await;
+        cx.assert_neovim_compatible(
             indoc! {"
             The quick
             brown fox
             jumps over
             the lˇazy"},
-            ExemptionFeatures::OperatorAbortsOnFailedMotion,
+            ["d", "shift-g"],
         )
         .await;
-        cx.assert_exempted(
+        cx.assert_neovim_compatible(
             indoc! {"
             The quick
             brown fox
             jumps over
             ˇ"},
-            ExemptionFeatures::OperatorAbortsOnFailedMotion,
+            ["d", "shift-g"],
         )
         .await;
     }
@@ -318,34 +322,40 @@ mod test {
         let mut cx = NeovimBackedTestContext::new(cx)
             .await
             .binding(["d", "g", "g"]);
-        cx.assert(indoc! {"
+        cx.assert_neovim_compatible(
+            indoc! {"
             The quick
             brownˇ fox
             jumps over
-            the lazy"})
-            .await;
-        cx.assert(indoc! {"
+            the lazy"},
+            ["d", "g", "g"],
+        )
+        .await;
+        cx.assert_neovim_compatible(
+            indoc! {"
             The quick
             brown fox
             jumps over
-            the lˇazy"})
-            .await;
-        cx.assert_exempted(
+            the lˇazy"},
+            ["d", "g", "g"],
+        )
+        .await;
+        cx.assert_neovim_compatible(
             indoc! {"
             The qˇuick
             brown fox
             jumps over
             the lazy"},
-            ExemptionFeatures::OperatorAbortsOnFailedMotion,
+            ["d", "g", "g"],
         )
         .await;
-        cx.assert_exempted(
+        cx.assert_neovim_compatible(
             indoc! {"
             ˇ
             brown fox
             jumps over
             the lazy"},
-            ExemptionFeatures::OperatorAbortsOnFailedMotion,
+            ["d", "g", "g"],
         )
         .await;
     }
@@ -387,4 +397,40 @@ mod test {
         assert_eq!(cx.active_operator(), None);
         assert_eq!(cx.mode(), Mode::Normal);
     }
+
+    #[gpui::test]
+    async fn test_delete_with_counts(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {"
+                The ˇquick brown
+                fox jumps over
+                the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["d", "2", "d"]).await;
+        cx.assert_shared_state(indoc! {"
+        the ˇlazy dog"})
+            .await;
+
+        cx.set_shared_state(indoc! {"
+                The ˇquick brown
+                fox jumps over
+                the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["2", "d", "d"]).await;
+        cx.assert_shared_state(indoc! {"
+        the ˇlazy dog"})
+            .await;
+
+        cx.set_shared_state(indoc! {"
+                The ˇquick brown
+                fox jumps over
+                the moon,
+                a star, and
+                the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["2", "d", "2", "d"]).await;
+        cx.assert_shared_state(indoc! {"
+        the ˇlazy dog"})
+            .await;
+    }
 }

crates/vim/src/normal/repeat.rs 🔗

@@ -1,10 +1,11 @@
 use crate::{
+    insert::NormalBefore,
     motion::Motion,
     state::{Mode, RecordedSelection, ReplayableAction},
     visual::visual_motion,
     Vim,
 };
-use gpui::{actions, Action, AppContext};
+use gpui::{actions, Action, AppContext, WindowContext};
 use workspace::Workspace;
 
 actions!(vim, [Repeat, EndRepeat,]);
@@ -17,138 +18,187 @@ fn should_replay(action: &Box<dyn Action>) -> bool {
     true
 }
 
+fn repeatable_insert(action: &ReplayableAction) -> Option<Box<dyn Action>> {
+    match action {
+        ReplayableAction::Action(action) => {
+            if super::InsertBefore.id() == action.id()
+                || super::InsertAfter.id() == action.id()
+                || super::InsertFirstNonWhitespace.id() == action.id()
+                || super::InsertEndOfLine.id() == action.id()
+            {
+                Some(super::InsertBefore.boxed_clone())
+            } else if super::InsertLineAbove.id() == action.id()
+                || super::InsertLineBelow.id() == action.id()
+            {
+                Some(super::InsertLineBelow.boxed_clone())
+            } else {
+                None
+            }
+        }
+        ReplayableAction::Insertion { .. } => None,
+    }
+}
+
 pub(crate) fn init(cx: &mut AppContext) {
     cx.add_action(|_: &mut Workspace, _: &EndRepeat, cx| {
         Vim::update(cx, |vim, cx| {
             vim.workspace_state.replaying = false;
-            vim.update_active_editor(cx, |editor, _| {
-                editor.show_local_selections = true;
-            });
             vim.switch_mode(Mode::Normal, false, cx)
         });
     });
 
-    cx.add_action(|_: &mut Workspace, _: &Repeat, cx| {
-        let Some((actions, editor, selection)) = Vim::update(cx, |vim, cx| {
-            let actions = vim.workspace_state.recorded_actions.clone();
-            let Some(editor) = vim.active_editor.clone() else {
-                return None;
-            };
-            let count = vim.pop_number_operator(cx);
-
-            vim.workspace_state.replaying = true;
-
-            let selection = vim.workspace_state.recorded_selection.clone();
-            match selection {
-                RecordedSelection::SingleLine { .. } | RecordedSelection::Visual { .. } => {
-                    vim.workspace_state.recorded_count = None;
-                    vim.switch_mode(Mode::Visual, false, cx)
-                }
-                RecordedSelection::VisualLine { .. } => {
-                    vim.workspace_state.recorded_count = None;
-                    vim.switch_mode(Mode::VisualLine, false, cx)
-                }
-                RecordedSelection::VisualBlock { .. } => {
-                    vim.workspace_state.recorded_count = None;
-                    vim.switch_mode(Mode::VisualBlock, false, cx)
-                }
-                RecordedSelection::None => {
-                    if let Some(count) = count {
-                        vim.workspace_state.recorded_count = Some(count);
-                    }
-                }
-            }
+    cx.add_action(|_: &mut Workspace, _: &Repeat, cx| repeat(cx, false));
+}
 
-            if let Some(editor) = editor.upgrade(cx) {
-                editor.update(cx, |editor, _| {
-                    editor.show_local_selections = false;
-                })
-            } else {
-                return None;
-            }
+pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) {
+    let Some((mut actions, editor, selection)) = Vim::update(cx, |vim, cx| {
+        let actions = vim.workspace_state.recorded_actions.clone();
+        if actions.is_empty() {
+            return None;
+        }
 
-            Some((actions, editor, selection))
-        }) else {
-            return;
+        let Some(editor) = vim.active_editor.clone() else {
+            return None;
         };
+        let count = vim.take_count(cx);
 
+        let selection = vim.workspace_state.recorded_selection.clone();
         match selection {
-            RecordedSelection::SingleLine { cols } => {
-                if cols > 1 {
-                    visual_motion(Motion::Right, Some(cols as usize - 1), cx)
-                }
+            RecordedSelection::SingleLine { .. } | RecordedSelection::Visual { .. } => {
+                vim.workspace_state.recorded_count = None;
+                vim.switch_mode(Mode::Visual, false, cx)
             }
-            RecordedSelection::Visual { rows, cols } => {
-                visual_motion(
-                    Motion::Down {
-                        display_lines: false,
-                    },
-                    Some(rows as usize),
-                    cx,
-                );
-                visual_motion(
-                    Motion::StartOfLine {
-                        display_lines: false,
-                    },
-                    None,
-                    cx,
-                );
-                if cols > 1 {
-                    visual_motion(Motion::Right, Some(cols as usize - 1), cx)
-                }
+            RecordedSelection::VisualLine { .. } => {
+                vim.workspace_state.recorded_count = None;
+                vim.switch_mode(Mode::VisualLine, false, cx)
             }
-            RecordedSelection::VisualBlock { rows, cols } => {
-                visual_motion(
-                    Motion::Down {
-                        display_lines: false,
-                    },
-                    Some(rows as usize),
-                    cx,
-                );
-                if cols > 1 {
-                    visual_motion(Motion::Right, Some(cols as usize - 1), cx);
+            RecordedSelection::VisualBlock { .. } => {
+                vim.workspace_state.recorded_count = None;
+                vim.switch_mode(Mode::VisualBlock, false, cx)
+            }
+            RecordedSelection::None => {
+                if let Some(count) = count {
+                    vim.workspace_state.recorded_count = Some(count);
                 }
             }
-            RecordedSelection::VisualLine { rows } => {
-                visual_motion(
-                    Motion::Down {
-                        display_lines: false,
-                    },
-                    Some(rows as usize),
-                    cx,
-                );
+        }
+
+        Some((actions, editor, selection))
+    }) else {
+        return;
+    };
+
+    match selection {
+        RecordedSelection::SingleLine { cols } => {
+            if cols > 1 {
+                visual_motion(Motion::Right, Some(cols as usize - 1), cx)
+            }
+        }
+        RecordedSelection::Visual { rows, cols } => {
+            visual_motion(
+                Motion::Down {
+                    display_lines: false,
+                },
+                Some(rows as usize),
+                cx,
+            );
+            visual_motion(
+                Motion::StartOfLine {
+                    display_lines: false,
+                },
+                None,
+                cx,
+            );
+            if cols > 1 {
+                visual_motion(Motion::Right, Some(cols as usize - 1), cx)
+            }
+        }
+        RecordedSelection::VisualBlock { rows, cols } => {
+            visual_motion(
+                Motion::Down {
+                    display_lines: false,
+                },
+                Some(rows as usize),
+                cx,
+            );
+            if cols > 1 {
+                visual_motion(Motion::Right, Some(cols as usize - 1), cx);
+            }
+        }
+        RecordedSelection::VisualLine { rows } => {
+            visual_motion(
+                Motion::Down {
+                    display_lines: false,
+                },
+                Some(rows as usize),
+                cx,
+            );
+        }
+        RecordedSelection::None => {}
+    }
+
+    // insert internally uses repeat to handle counts
+    // vim doesn't treat 3a1 as though you literally repeated a1
+    // 3 times, instead it inserts the content thrice at the insert position.
+    if let Some(to_repeat) = repeatable_insert(&actions[0]) {
+        if let Some(ReplayableAction::Action(action)) = actions.last() {
+            if action.id() == NormalBefore.id() {
+                actions.pop();
             }
-            RecordedSelection::None => {}
         }
 
-        let window = cx.window();
-        cx.app_context()
-            .spawn(move |mut cx| async move {
-                for action in actions {
-                    match action {
-                        ReplayableAction::Action(action) => {
-                            if should_replay(&action) {
-                                window
-                                    .dispatch_action(editor.id(), action.as_ref(), &mut cx)
-                                    .ok_or_else(|| anyhow::anyhow!("window was closed"))
-                            } else {
-                                Ok(())
-                            }
+        let mut new_actions = actions.clone();
+        actions[0] = ReplayableAction::Action(to_repeat.boxed_clone());
+
+        let mut count = Vim::read(cx).workspace_state.recorded_count.unwrap_or(1);
+
+        // if we came from insert mode we're just doing repititions 2 onwards.
+        if from_insert_mode {
+            count -= 1;
+            new_actions[0] = actions[0].clone();
+        }
+
+        for _ in 1..count {
+            new_actions.append(actions.clone().as_mut());
+        }
+        new_actions.push(ReplayableAction::Action(NormalBefore.boxed_clone()));
+        actions = new_actions;
+    }
+
+    Vim::update(cx, |vim, _| vim.workspace_state.replaying = true);
+    let window = cx.window();
+    cx.app_context()
+        .spawn(move |mut cx| async move {
+            editor.update(&mut cx, |editor, _| {
+                editor.show_local_selections = false;
+            })?;
+            for action in actions {
+                match action {
+                    ReplayableAction::Action(action) => {
+                        if should_replay(&action) {
+                            window
+                                .dispatch_action(editor.id(), action.as_ref(), &mut cx)
+                                .ok_or_else(|| anyhow::anyhow!("window was closed"))
+                        } else {
+                            Ok(())
                         }
-                        ReplayableAction::Insertion {
-                            text,
-                            utf16_range_to_replace,
-                        } => editor.update(&mut cx, |editor, cx| {
-                            editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx)
-                        }),
-                    }?
-                }
-                window
-                    .dispatch_action(editor.id(), &EndRepeat, &mut cx)
-                    .ok_or_else(|| anyhow::anyhow!("window was closed"))
-            })
-            .detach_and_log_err(cx);
-    });
+                    }
+                    ReplayableAction::Insertion {
+                        text,
+                        utf16_range_to_replace,
+                    } => editor.update(&mut cx, |editor, cx| {
+                        editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx)
+                    }),
+                }?
+            }
+            editor.update(&mut cx, |editor, _| {
+                editor.show_local_selections = true;
+            })?;
+            window
+                .dispatch_action(editor.id(), &EndRepeat, &mut cx)
+                .ok_or_else(|| anyhow::anyhow!("window was closed"))
+        })
+        .detach_and_log_err(cx);
 }
 
 #[cfg(test)]
@@ -203,7 +253,7 @@ mod test {
         deterministic.run_until_parked();
         cx.simulate_shared_keystrokes(["."]).await;
         deterministic.run_until_parked();
-        cx.set_shared_state("THE QUICK ˇbrown fox").await;
+        cx.assert_shared_state("THE QUICK ˇbrown fox").await;
     }
 
     #[gpui::test]
@@ -424,4 +474,55 @@ mod test {
         })
         .await;
     }
+
+    #[gpui::test]
+    async fn test_repeat_motion_counts(
+        deterministic: Arc<Deterministic>,
+        cx: &mut gpui::TestAppContext,
+    ) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state(indoc! {
+            "ˇthe quick brown
+            fox jumps over
+            the lazy dog"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["3", "d", "3", "l"]).await;
+        cx.assert_shared_state(indoc! {
+            "ˇ brown
+            fox jumps over
+            the lazy dog"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["j", "."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state(indoc! {
+            " brown
+            ˇ over
+            the lazy dog"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["j", "2", "."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state(indoc! {
+            " brown
+             over
+            ˇe lazy dog"
+        })
+        .await;
+    }
+
+    #[gpui::test]
+    async fn test_record_interrupted(
+        deterministic: Arc<Deterministic>,
+        cx: &mut gpui::TestAppContext,
+    ) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        cx.set_state("ˇhello\n", Mode::Normal);
+        cx.simulate_keystrokes(["4", "i", "j", "cmd-shift-p", "escape", "escape"]);
+        deterministic.run_until_parked();
+        cx.assert_state("ˇjhello\n", Mode::Normal);
+    }
 }

crates/vim/src/normal/scroll.rs 🔗

@@ -48,7 +48,7 @@ pub fn init(cx: &mut AppContext) {
 
 fn scroll(cx: &mut ViewContext<Workspace>, by: fn(c: Option<f32>) -> ScrollAmount) {
     Vim::update(cx, |vim, cx| {
-        let amount = by(vim.pop_number_operator(cx).map(|c| c as f32));
+        let amount = by(vim.take_count(cx).map(|c| c as f32));
         vim.update_active_editor(cx, |editor, cx| scroll_editor(editor, &amount, cx));
     })
 }

crates/vim/src/normal/search.rs 🔗

@@ -52,7 +52,7 @@ fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Works
         Direction::Next
     };
     Vim::update(cx, |vim, cx| {
-        let count = vim.pop_number_operator(cx).unwrap_or(1);
+        let count = vim.take_count(cx).unwrap_or(1);
         pane.update(cx, |pane, cx| {
             if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
                 search_bar.update(cx, |search_bar, cx| {
@@ -119,7 +119,7 @@ pub fn move_to_internal(
 ) {
     Vim::update(cx, |vim, cx| {
         let pane = workspace.active_pane().clone();
-        let count = vim.pop_number_operator(cx).unwrap_or(1);
+        let count = vim.take_count(cx).unwrap_or(1);
         pane.update(cx, |pane, cx| {
             if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
                 let search = search_bar.update(cx, |search_bar, cx| {
@@ -227,7 +227,7 @@ mod test {
         deterministic.run_until_parked();
 
         cx.update_editor(|editor, cx| {
-            let highlights = editor.all_background_highlights(cx);
+            let highlights = editor.all_text_background_highlights(cx);
             assert_eq!(3, highlights.len());
             assert_eq!(
                 DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2),

crates/vim/src/normal/substitute.rs 🔗

@@ -11,7 +11,7 @@ pub(crate) fn init(cx: &mut AppContext) {
     cx.add_action(|_: &mut Workspace, _: &Substitute, cx| {
         Vim::update(cx, |vim, cx| {
             vim.start_recording(cx);
-            let count = vim.pop_number_operator(cx);
+            let count = vim.take_count(cx);
             substitute(vim, count, vim.state().mode == Mode::VisualLine, cx);
         })
     });
@@ -22,7 +22,7 @@ pub(crate) fn init(cx: &mut AppContext) {
             if matches!(vim.state().mode, Mode::VisualBlock | Mode::Visual) {
                 vim.switch_mode(Mode::VisualLine, false, cx)
             }
-            let count = vim.pop_number_operator(cx);
+            let count = vim.take_count(cx);
             substitute(vim, count, true, cx)
         })
     });

crates/vim/src/state.rs 🔗

@@ -33,7 +33,6 @@ impl Default for Mode {
 
 #[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
 pub enum Operator {
-    Number(usize),
     Change,
     Delete,
     Yank,
@@ -47,6 +46,12 @@ pub enum Operator {
 pub struct EditorState {
     pub mode: Mode,
     pub last_mode: Mode,
+
+    /// pre_count is the number before an operator is specified (3 in 3d2d)
+    pub pre_count: Option<usize>,
+    /// post_count is the number after an operator is specified (2 in 3d2d)
+    pub post_count: Option<usize>,
+
     pub operator_stack: Vec<Operator>,
 }
 
@@ -158,6 +163,10 @@ impl EditorState {
         }
     }
 
+    pub fn active_operator(&self) -> Option<Operator> {
+        self.operator_stack.last().copied()
+    }
+
     pub fn keymap_context_layer(&self) -> KeymapContext {
         let mut context = KeymapContext::default();
         context.add_identifier("VimEnabled");
@@ -174,7 +183,13 @@ impl EditorState {
             context.add_identifier("VimControl");
         }
 
-        let active_operator = self.operator_stack.last();
+        if self.active_operator().is_none() && self.pre_count.is_some()
+            || self.active_operator().is_some() && self.post_count.is_some()
+        {
+            context.add_identifier("VimCount");
+        }
+
+        let active_operator = self.active_operator();
 
         if let Some(active_operator) = active_operator {
             for context_flag in active_operator.context_flags().into_iter() {
@@ -194,7 +209,6 @@ impl EditorState {
 impl Operator {
     pub fn id(&self) -> &'static str {
         match self {
-            Operator::Number(_) => "n",
             Operator::Object { around: false } => "i",
             Operator::Object { around: true } => "a",
             Operator::Change => "c",

crates/vim/src/test.rs 🔗

@@ -190,7 +190,7 @@ async fn test_selection_on_search(cx: &mut gpui::TestAppContext) {
     search_bar.next_notification(&cx).await;
 
     cx.update_editor(|editor, cx| {
-        let highlights = editor.all_background_highlights(cx);
+        let highlights = editor.all_text_background_highlights(cx);
         assert_eq!(3, highlights.len());
         assert_eq!(
             DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2),
@@ -574,3 +574,47 @@ async fn test_folds(cx: &mut gpui::TestAppContext) {
     "})
         .await;
 }
+
+#[gpui::test]
+async fn test_clear_counts(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.set_shared_state(indoc! {"
+        The quick brown
+        fox juˇmps over
+        the lazy dog"})
+        .await;
+
+    cx.simulate_shared_keystrokes(["4", "escape", "3", "d", "l"])
+        .await;
+    cx.assert_shared_state(indoc! {"
+        The quick brown
+        fox juˇ over
+        the lazy dog"})
+        .await;
+}
+
+#[gpui::test]
+async fn test_zero(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.set_shared_state(indoc! {"
+        The quˇick brown
+        fox jumps over
+        the lazy dog"})
+        .await;
+
+    cx.simulate_shared_keystrokes(["0"]).await;
+    cx.assert_shared_state(indoc! {"
+        ˇThe quick brown
+        fox jumps over
+        the lazy dog"})
+        .await;
+
+    cx.simulate_shared_keystrokes(["1", "0", "l"]).await;
+    cx.assert_shared_state(indoc! {"
+        The quick ˇbrown
+        fox jumps over
+        the lazy dog"})
+        .await;
+}

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

@@ -13,20 +13,13 @@ use util::test::{generate_marked_text, marked_text_offsets};
 use super::{neovim_connection::NeovimConnection, NeovimBackedBindingTestContext, VimTestContext};
 use crate::state::Mode;
 
-pub const SUPPORTED_FEATURES: &[ExemptionFeatures] = &[
-    ExemptionFeatures::DeletionOnEmptyLine,
-    ExemptionFeatures::OperatorAbortsOnFailedMotion,
-];
+pub const SUPPORTED_FEATURES: &[ExemptionFeatures] = &[];
 
 /// Enum representing features we have tests for but which don't work, yet. Used
 /// to add exemptions and automatically
 #[derive(PartialEq, Eq)]
 pub enum ExemptionFeatures {
     // MOTIONS
-    // Deletions on empty lines miss some newlines
-    DeletionOnEmptyLine,
-    // When a motion fails, it should should not apply linewise operations
-    OperatorAbortsOnFailedMotion,
     // When an operator completes at the end of the file, an extra newline is left
     OperatorLastNewlineRemains,
     // Deleting a word on an empty line doesn't remove the newline
@@ -68,6 +61,8 @@ pub struct NeovimBackedTestContext<'a> {
 
     last_set_state: Option<String>,
     recent_keystrokes: Vec<String>,
+
+    is_dirty: bool,
 }
 
 impl<'a> NeovimBackedTestContext<'a> {
@@ -81,6 +76,7 @@ impl<'a> NeovimBackedTestContext<'a> {
 
             last_set_state: None,
             recent_keystrokes: Default::default(),
+            is_dirty: false,
         }
     }
 
@@ -128,6 +124,7 @@ impl<'a> NeovimBackedTestContext<'a> {
         self.last_set_state = Some(marked_text.to_string());
         self.recent_keystrokes = Vec::new();
         self.neovim.set_state(marked_text).await;
+        self.is_dirty = true;
         context_handle
     }
 
@@ -153,6 +150,7 @@ impl<'a> NeovimBackedTestContext<'a> {
     }
 
     pub async fn assert_shared_state(&mut self, marked_text: &str) {
+        self.is_dirty = false;
         let marked_text = marked_text.replace("•", " ");
         let neovim = self.neovim_state().await;
         let editor = self.editor_state();
@@ -258,6 +256,7 @@ impl<'a> NeovimBackedTestContext<'a> {
     }
 
     pub async fn assert_state_matches(&mut self) {
+        self.is_dirty = false;
         let neovim = self.neovim_state().await;
         let editor = self.editor_state();
         let initial_state = self
@@ -383,6 +382,17 @@ impl<'a> DerefMut for NeovimBackedTestContext<'a> {
     }
 }
 
+// a common mistake in tests is to call set_shared_state when
+// you mean asswert_shared_state. This notices that and lets
+// you know.
+impl<'a> Drop for NeovimBackedTestContext<'a> {
+    fn drop(&mut self) {
+        if self.is_dirty {
+            panic!("Test context was dropped after set_shared_state before assert_shared_state")
+        }
+    }
+}
+
 #[cfg(test)]
 mod test {
     use gpui::TestAppContext;

crates/vim/src/vim.rs 🔗

@@ -15,8 +15,8 @@ use anyhow::Result;
 use collections::{CommandPaletteFilter, HashMap};
 use editor::{movement, Editor, EditorMode, Event};
 use gpui::{
-    actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext,
-    Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
+    actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, Action,
+    AppContext, Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
 };
 use language::{CursorShape, Point, Selection, SelectionGoal};
 pub use mode_indicator::ModeIndicator;
@@ -40,9 +40,12 @@ pub struct SwitchMode(pub Mode);
 pub struct PushOperator(pub Operator);
 
 #[derive(Clone, Deserialize, PartialEq)]
-struct Number(u8);
+struct Number(usize);
 
-actions!(vim, [Tab, Enter]);
+actions!(
+    vim,
+    [Tab, Enter, Object, InnerObject, FindForward, FindBackward]
+);
 impl_actions!(vim, [Number, SwitchMode, PushOperator]);
 
 #[derive(Copy, Clone, Debug)]
@@ -70,7 +73,7 @@ pub fn init(cx: &mut AppContext) {
         },
     );
     cx.add_action(|_: &mut Workspace, n: &Number, cx: _| {
-        Vim::update(cx, |vim, cx| vim.push_number(n, cx));
+        Vim::update(cx, |vim, cx| vim.push_count_digit(n.0, cx));
     });
 
     cx.add_action(|_: &mut Workspace, _: &Tab, cx| {
@@ -225,23 +228,12 @@ impl Vim {
         let editor = self.active_editor.clone()?.upgrade(cx)?;
         Some(editor.update(cx, update))
     }
-    // ~, shift-j, x, shift-x, p
-    // shift-c, shift-d, shift-i, i, a, o, shift-o, s
-    // c, d
-    // r
 
-    // TODO: shift-j?
-    //
     pub fn start_recording(&mut self, cx: &mut WindowContext) {
         if !self.workspace_state.replaying {
             self.workspace_state.recording = true;
             self.workspace_state.recorded_actions = Default::default();
-            self.workspace_state.recorded_count =
-                if let Some(Operator::Number(number)) = self.active_operator() {
-                    Some(number)
-                } else {
-                    None
-                };
+            self.workspace_state.recorded_count = None;
 
             let selections = self
                 .active_editor
@@ -286,6 +278,16 @@ impl Vim {
         }
     }
 
+    pub fn stop_recording_immediately(&mut self, action: Box<dyn Action>) {
+        if self.workspace_state.recording {
+            self.workspace_state
+                .recorded_actions
+                .push(ReplayableAction::Action(action.boxed_clone()));
+            self.workspace_state.recording = false;
+            self.workspace_state.stop_recording_after_next_action = false;
+        }
+    }
+
     pub fn record_current_action(&mut self, cx: &mut WindowContext) {
         self.start_recording(cx);
         self.stop_recording();
@@ -300,6 +302,9 @@ impl Vim {
             state.mode = mode;
             state.operator_stack.clear();
         });
+        if mode != Mode::Insert {
+            self.take_count(cx);
+        }
 
         cx.emit_global(VimEvent::ModeChanged { mode });
 
@@ -352,6 +357,39 @@ impl Vim {
         });
     }
 
+    fn push_count_digit(&mut self, number: usize, cx: &mut WindowContext) {
+        if self.active_operator().is_some() {
+            self.update_state(|state| {
+                state.post_count = Some(state.post_count.unwrap_or(0) * 10 + number)
+            })
+        } else {
+            self.update_state(|state| {
+                state.pre_count = Some(state.pre_count.unwrap_or(0) * 10 + number)
+            })
+        }
+        // update the keymap so that 0 works
+        self.sync_vim_settings(cx)
+    }
+
+    fn take_count(&mut self, cx: &mut WindowContext) -> Option<usize> {
+        if self.workspace_state.replaying {
+            return self.workspace_state.recorded_count;
+        }
+
+        let count = if self.state().post_count == None && self.state().pre_count == None {
+            return None;
+        } else {
+            Some(self.update_state(|state| {
+                state.post_count.take().unwrap_or(1) * state.pre_count.take().unwrap_or(1)
+            }))
+        };
+        if self.workspace_state.recording {
+            self.workspace_state.recorded_count = count;
+        }
+        self.sync_vim_settings(cx);
+        count
+    }
+
     fn push_operator(&mut self, operator: Operator, cx: &mut WindowContext) {
         if matches!(
             operator,
@@ -363,15 +401,6 @@ impl Vim {
         self.sync_vim_settings(cx);
     }
 
-    fn push_number(&mut self, Number(number): &Number, cx: &mut WindowContext) {
-        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 maybe_pop_operator(&mut self) -> Option<Operator> {
         self.update_state(|state| state.operator_stack.pop())
     }
@@ -382,22 +411,8 @@ impl Vim {
         self.sync_vim_settings(cx);
         popped_operator
     }
-
-    fn pop_number_operator(&mut self, cx: &mut WindowContext) -> Option<usize> {
-        if self.workspace_state.replaying {
-            if let Some(number) = self.workspace_state.recorded_count {
-                return Some(number);
-            }
-        }
-
-        if let Some(Operator::Number(number)) = self.active_operator() {
-            self.pop_operator(cx);
-            return Some(number);
-        }
-        None
-    }
-
     fn clear_operator(&mut self, cx: &mut WindowContext) {
+        self.take_count(cx);
         self.update_state(|state| state.operator_stack.clear());
         self.sync_vim_settings(cx);
     }

crates/vim/test_data/test_clear_counts.json 🔗

@@ -0,0 +1,7 @@
+{"Put":{"state":"The quick brown\nfox juˇmps over\nthe lazy dog"}}
+{"Key":"4"}
+{"Key":"escape"}
+{"Key":"3"}
+{"Key":"d"}
+{"Key":"l"}
+{"Get":{"state":"The quick brown\nfox juˇ over\nthe lazy dog","mode":"Normal"}}

crates/vim/test_data/test_delete_with_counts.json 🔗

@@ -0,0 +1,16 @@
+{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
+{"Key":"d"}
+{"Key":"2"}
+{"Key":"d"}
+{"Get":{"state":"the ˇlazy dog","mode":"Normal"}}
+{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
+{"Key":"2"}
+{"Key":"d"}
+{"Key":"d"}
+{"Get":{"state":"the ˇlazy dog","mode":"Normal"}}
+{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe moon,\na star, and\nthe lazy dog"}}
+{"Key":"2"}
+{"Key":"d"}
+{"Key":"2"}
+{"Key":"d"}
+{"Get":{"state":"the ˇlazy dog","mode":"Normal"}}

crates/vim/test_data/test_dot_repeat.json 🔗

@@ -35,4 +35,4 @@
 {"Key":"."}
 {"Put":{"state":"THE QUIˇck brown fox"}}
 {"Key":"."}
-{"Put":{"state":"THE QUICK ˇbrown fox"}}
+{"Get":{"state":"THE QUICK ˇbrown fox","mode":"Normal"}}

crates/vim/test_data/test_insert_with_counts.json 🔗

@@ -0,0 +1,36 @@
+{"Put":{"state":"ˇhello\n"}}
+{"Key":"5"}
+{"Key":"i"}
+{"Key":"-"}
+{"Key":"escape"}
+{"Get":{"state":"----ˇ-hello\n","mode":"Normal"}}
+{"Put":{"state":"ˇhello\n"}}
+{"Key":"5"}
+{"Key":"a"}
+{"Key":"-"}
+{"Key":"escape"}
+{"Get":{"state":"h----ˇ-ello\n","mode":"Normal"}}
+{"Key":"4"}
+{"Key":"shift-i"}
+{"Key":"-"}
+{"Key":"escape"}
+{"Get":{"state":"---ˇ-h-----ello\n","mode":"Normal"}}
+{"Key":"3"}
+{"Key":"shift-a"}
+{"Key":"-"}
+{"Key":"escape"}
+{"Get":{"state":"----h-----ello--ˇ-\n","mode":"Normal"}}
+{"Put":{"state":"ˇhello\n"}}
+{"Key":"3"}
+{"Key":"o"}
+{"Key":"o"}
+{"Key":"i"}
+{"Key":"escape"}
+{"Get":{"state":"hello\noi\noi\noˇi\n","mode":"Normal"}}
+{"Put":{"state":"ˇhello\n"}}
+{"Key":"3"}
+{"Key":"shift-o"}
+{"Key":"o"}
+{"Key":"i"}
+{"Key":"escape"}
+{"Get":{"state":"oi\noi\noˇi\nhello\n","mode":"Normal"}}

crates/vim/test_data/test_insert_with_repeat.json 🔗

@@ -0,0 +1,23 @@
+{"Put":{"state":"ˇhello\n"}}
+{"Key":"3"}
+{"Key":"i"}
+{"Key":"-"}
+{"Key":"escape"}
+{"Get":{"state":"--ˇ-hello\n","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"----ˇ--hello\n","mode":"Normal"}}
+{"Key":"2"}
+{"Key":"."}
+{"Get":{"state":"-----ˇ---hello\n","mode":"Normal"}}
+{"Put":{"state":"ˇhello\n"}}
+{"Key":"2"}
+{"Key":"o"}
+{"Key":"k"}
+{"Key":"k"}
+{"Key":"escape"}
+{"Get":{"state":"hello\nkk\nkˇk\n","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"hello\nkk\nkk\nkk\nkˇk\n","mode":"Normal"}}
+{"Key":"1"}
+{"Key":"."}
+{"Get":{"state":"hello\nkk\nkk\nkk\nkk\nkˇk\n","mode":"Normal"}}

crates/vim/test_data/test_repeat_motion_counts.json 🔗

@@ -0,0 +1,13 @@
+{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}}
+{"Key":"3"}
+{"Key":"d"}
+{"Key":"3"}
+{"Key":"l"}
+{"Get":{"state":"ˇ brown\nfox jumps over\nthe lazy dog","mode":"Normal"}}
+{"Key":"j"}
+{"Key":"."}
+{"Get":{"state":" brown\nˇ over\nthe lazy dog","mode":"Normal"}}
+{"Key":"j"}
+{"Key":"2"}
+{"Key":"."}
+{"Get":{"state":" brown\n over\nˇe lazy dog","mode":"Normal"}}

crates/vim/test_data/test_zero.json 🔗

@@ -0,0 +1,7 @@
+{"Put":{"state":"The quˇick brown\nfox jumps over\nthe lazy dog"}}
+{"Key":"0"}
+{"Get":{"state":"ˇThe quick brown\nfox jumps over\nthe lazy dog","mode":"Normal"}}
+{"Key":"1"}
+{"Key":"0"}
+{"Key":"l"}
+{"Get":{"state":"The quick ˇbrown\nfox jumps over\nthe lazy dog","mode":"Normal"}}

crates/zed/Cargo.toml 🔗

@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathansobo@gmail.com>"]
 description = "The fast, collaborative code editor."
 edition = "2021"
 name = "zed"
-version = "0.104.0"
+version = "0.105.0"
 publish = false
 
 [lib]

script/deploy 🔗

@@ -13,10 +13,11 @@ version=$2
 export_vars_for_environment ${environment}
 image_id=$(image_id_for_version ${version})
 
+export ZED_DO_CERTIFICATE_ID=$(doctl compute certificate list --format ID --no-header)
 export ZED_KUBE_NAMESPACE=${environment}
 export ZED_IMAGE_ID=${image_id}
 
 target_zed_kube_cluster
 envsubst < crates/collab/k8s/manifest.template.yml | kubectl apply -f -
 
-echo "deployed collab v${version} to ${environment}"
+echo "deployed collab v${version} to ${environment}"

styles/src/style_tree/search.ts 🔗

@@ -36,6 +36,7 @@ export default function search(): any {
             left: 10,
             right: 4,
         },
+        margin: { right: SEARCH_ROW_SPACING }
     }
 
     const include_exclude_editor = {
@@ -201,7 +202,6 @@ export default function search(): any {
         },
         option_button_group: {
             padding: {
-                left: SEARCH_ROW_SPACING,
                 right: SEARCH_ROW_SPACING,
             },
         },
@@ -375,7 +375,11 @@ export default function search(): any {
         search_bar_row_height: 34,
         search_row_spacing: 8,
         option_button_height: 22,
-        modes_container: {},
+        modes_container: {
+            padding: {
+                right: SEARCH_ROW_SPACING,
+            }
+        },
         replace_icon: {
             icon: {
                 color: foreground(theme.highest, "disabled"),