diff --git a/Cargo.lock b/Cargo.lock index f534a4fe7d..623ebd982f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -362,7 +362,7 @@ dependencies = [ "async-lock", "async-task", "concurrent-queue", - "fastrand", + "fastrand 1.9.0", "futures-lite", "slab", ] @@ -481,7 +481,7 @@ checksum = "0e97ce7de6cf12de5d7226c73f5ba9811622f4db3a5b91b55c53e987e5f91cba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.27", ] [[package]] @@ -529,7 +529,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.27", ] [[package]] @@ -566,13 +566,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.71" +version = "0.1.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a564d521dd56509c4c47480d00b80ee55f7e385ae48db5744c67ad50c92d2ebf" +checksum = "cc6dde6e4ed435a4c1ee4e73592f5ba9da2151af10076cc04858746af9352d09" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.27", ] [[package]] @@ -830,7 +830,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.26", + "syn 2.0.27", "which", ] @@ -907,7 +907,7 @@ dependencies = [ "async-lock", "async-task", "atomic-waker", - "fastrand", + "fastrand 1.9.0", "futures-lite", "log", ] @@ -1070,6 +1070,10 @@ dependencies = [ "media", "postage", "project", + "schemars", + "serde", + "serde_derive", + "serde_json", "settings", "util", ] @@ -1243,9 +1247,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.3.15" +version = "4.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f644d0dac522c8b05ddc39aaaccc5b136d5dc4ff216610c5641e3be5becf56c" +checksum = "5fd304a20bff958a57f04c4e96a2e7594cc4490a0e809cbd48bb6437edaa452d" dependencies = [ "clap_builder", "clap_derive 4.3.12", @@ -1254,9 +1258,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.3.15" +version = "4.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af410122b9778e024f9e0fb35682cc09cc3f85cad5e8d3ba8f47a9702df6e73d" +checksum = "01c6a3f08f1fe5662a35cfe393aec09c4df95f60ee93b7556505260f75eee9e1" dependencies = [ "anstream", "anstyle", @@ -1286,7 +1290,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.27", ] [[package]] @@ -1970,9 +1974,9 @@ dependencies = [ [[package]] name = "curl-sys" -version = "0.4.63+curl-8.1.2" +version = "0.4.64+curl-8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeb0fef7046022a1e2ad67a004978f0e3cacb9e3123dc62ce768f92197b771dc" +checksum = "f96069f0b1cb1241c838740659a771ef143363f52772a9ce1bd9c04c75eee0dc" dependencies = [ "cc", "libc", @@ -2262,9 +2266,9 @@ dependencies = [ [[package]] name = "either" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" [[package]] name = "encoding_rs" @@ -2413,6 +2417,12 @@ dependencies = [ "instant", ] +[[package]] +name = "fastrand" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" + [[package]] name = "feedback" version = "0.1.0" @@ -2771,7 +2781,7 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" dependencies = [ - "fastrand", + "fastrand 1.9.0", "futures-core", "futures-io", "memchr", @@ -2788,7 +2798,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.27", ] [[package]] @@ -3250,9 +3260,9 @@ dependencies = [ [[package]] name = "http-range-header" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfe8eed0a9285ef776bb792479ea3834e8b94e13d615c2f66d03dd50a435a29" +checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" [[package]] name = "httparse" @@ -3893,9 +3903,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.9" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56ee889ecc9568871456d42f603d6a0ce59ff328d291063a45cbdf0036baf6db" +checksum = "d97137b25e321a73eef1418d1d5d2eda4d77e12813f8e6dead84bc52c5870a7b" dependencies = [ "cc", "libc", @@ -4561,9 +4571,9 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" dependencies = [ "autocfg", "libm", @@ -4722,7 +4732,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.27", ] [[package]] @@ -5010,7 +5020,7 @@ checksum = "ec2e072ecce94ec471b13398d5402c188e76ac03cf74dd1a975161b23a3f6d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.27", ] [[package]] @@ -5158,12 +5168,12 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.10" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92139198957b410250d43fad93e630d956499a625c527eda65175c8680f83387" +checksum = "6c64d9ba0963cdcea2e1b2230fbae2bab30eb25a174be395c41e764bfb65dd62" dependencies = [ "proc-macro2", - "syn 2.0.26", + "syn 2.0.27", ] [[package]] @@ -5288,6 +5298,7 @@ version = "0.1.0" dependencies = [ "anyhow", "client", + "collections", "context_menu", "db", "drag_and_drop", @@ -5491,9 +5502,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.31" +version = "1.0.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fe8a65d69dd0808184ebb5f836ab526bb259db23c657efa38711b1072ee47f0" +checksum = "50f3b39ccfb720540debaa0164757101c08ecb8d326b15358ce76a62c7e85965" dependencies = [ "proc-macro2", ] @@ -5895,9 +5906,9 @@ dependencies = [ [[package]] name = "rmp" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44519172358fd6d58656c86ab8e7fbc9e1490c3e8f14d35ed78ca0dd07403c9f" +checksum = "7f9860a6cc38ed1da53456442089b4dfa35e7cedaa326df63017af88385e6b20" dependencies = [ "byteorder", "num-traits", @@ -5906,9 +5917,9 @@ dependencies = [ [[package]] name = "rmpv" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de8813b3a2f95c5138fe5925bfb8784175d88d6bff059ba8ce090aa891319754" +checksum = "2e0e0214a4a2b444ecce41a4025792fc31f77c7bb89c46d253953ea8c65701ec" dependencies = [ "num-traits", "rmp", @@ -6034,7 +6045,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.26", + "syn 2.0.27", "walkdir", ] @@ -6419,6 +6430,7 @@ name = "search" version = "0.1.0" dependencies = [ "anyhow", + "bitflags 1.3.2", "client", "collections", "editor", @@ -6445,9 +6457,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc758eb7bffce5b308734e9b0c1468893cae9ff70ebf13e7090be8dcbcc83a8" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" dependencies = [ "bitflags 1.3.2", "core-foundation", @@ -6458,9 +6470,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f51d0c0d83bec45f16480d0ce0058397a69e48fcdc52d1dc8855fb68acbd31a7" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" dependencies = [ "core-foundation-sys 0.8.3", "libc", @@ -6538,22 +6550,22 @@ checksum = "5a9f47faea3cad316faa914d013d24f471cd90bfca1a0c70f05a3f42c6441e99" [[package]] name = "serde" -version = "1.0.171" +version = "1.0.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30e27d1e4fd7659406c492fd6cfaf2066ba8773de45ca75e855590f856dc34a9" +checksum = "5d25439cd7397d044e2748a6fe2432b5e85db703d6d097bd014b3c0ad1ebff0b" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.171" +version = "1.0.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389894603bd18c46fa56231694f8d827779c0951a667087194cf9de94ed24682" +checksum = "b23f7ade6f110613c0d63858ddb8b94c1041f550eab58a16b371bdf2c9c80ab4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.27", ] [[package]] @@ -6602,13 +6614,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d89a8107374290037607734c0b73a85db7ed80cae314b3c5791f192a496e731" +checksum = "e168eaaf71e8f9bd6037feb05190485708e019f4fd87d161b3c0a0d37daf85e5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.27", ] [[package]] @@ -6749,9 +6761,9 @@ checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" [[package]] name = "signal-hook" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b824b6e687aff278cdbf3b36f07aa52d4bd4099699324d5da86a2ebce3aa00b3" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" dependencies = [ "libc", "signal-hook-registry", @@ -7276,9 +7288,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.26" +version = "2.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c3457aacde3c65315de5031ec191ce46604304d2446e803d71ade03308d970" +checksum = "b60f673f44a8255b9c8c657daf66a596d435f2da81a555b06dc644d080ba45e0" dependencies = [ "proc-macro2", "quote", @@ -7346,9 +7358,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "target-lexicon" -version = "0.12.9" +version = "0.12.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8e77cb757a61f51b947ec4a7e3646efd825b73561db1c232a8ccb639e611a0" +checksum = "1d2faeef5759ab89935255b1a4cd98e0baf99d1085e37d36599c625dac49ae8e" [[package]] name = "tempdir" @@ -7362,15 +7374,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.6.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31c0432476357e58790aaa47a8efb0c5138f137343f3b5f23bd36a27e3b0a6d6" +checksum = "5486094ee78b2e5038a6382ed7645bc084dc2ec433426ca4c3cb61e2007b8998" dependencies = [ - "autocfg", "cfg-if 1.0.0", - "fastrand", + "fastrand 2.0.0", "redox_syscall 0.3.5", - "rustix 0.37.23", + "rustix 0.38.4", "windows-sys", ] @@ -7517,22 +7528,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a35fc5b8971143ca348fa6df4f024d4d55264f3468c71ad1c2f365b0a4d58c42" +checksum = "611040a08a0439f8248d1990b111c95baa9c704c805fa1f62104b39655fd7f90" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "463fe12d7993d3b327787537ce8dd4dfa058de32fc2b195ef3cde03dc4771e8f" +checksum = "090198534930841fab3a5d1bb637cde49e339654e606195f8d9c76eeb081dc96" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.27", ] [[package]] @@ -7721,7 +7732,7 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.27", ] [[package]] @@ -7926,7 +7937,7 @@ checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.27", ] [[package]] @@ -8000,11 +8011,20 @@ dependencies = [ "regex", ] +[[package]] +name = "tree-sitter-bash" +version = "0.19.0" +source = "git+https://github.com/tree-sitter/tree-sitter-bash?rev=1b0321ee85701d5036c334a6f04761cdc672e64c#1b0321ee85701d5036c334a6f04761cdc672e64c" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "tree-sitter-c" -version = "0.20.2" +version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cca211f4827d4b4dc79f388bf67b6fa3bc8a8cfa642161ef24f99f371ba34c7b" +checksum = "fa1bb73a4101c88775e4fefcd0543ee25e192034484a5bd45cb99eefb997dca9" dependencies = [ "cc", "tree-sitter", @@ -8012,9 +8032,9 @@ dependencies = [ [[package]] name = "tree-sitter-cpp" -version = "0.20.0" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a869e3c5cef4e5db4e9ab16a8dc84d73010e60ada14cdc60d2f6d8aed17779d" +checksum = "0dbedbf4066bfab725b3f9e2a21530507419a7d2f98621d3c13213502b734ec0" dependencies = [ "cc", "tree-sitter", @@ -8048,6 +8068,16 @@ dependencies = [ "tree-sitter", ] +[[package]] +name = "tree-sitter-elm" +version = "5.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95236155fa1cd5fcf92123e7e6aa7b6e8c6756b54b5d39afd792a23bd6c9eb7b" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "tree-sitter-embedded-template" version = "0.20.0" @@ -8058,6 +8088,15 @@ dependencies = [ "tree-sitter", ] +[[package]] +name = "tree-sitter-glsl" +version = "0.1.4" +source = "git+https://github.com/theHamsta/tree-sitter-glsl?rev=2a56fb7bc8bb03a1892b4741279dd0a8758b7fb3#2a56fb7bc8bb03a1892b4741279dd0a8758b7fb3" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "tree-sitter-go" version = "0.19.1" @@ -8135,9 +8174,9 @@ dependencies = [ [[package]] name = "tree-sitter-python" -version = "0.20.2" +version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dda114f58048f5059dcf158aff691dffb8e113e6d2b50d94263fd68711975287" +checksum = "f47ebd9cac632764b2f4389b08517bf2ef895431dd163eb562e3d2062cc23a14" dependencies = [ "cc", "tree-sitter", @@ -8409,9 +8448,9 @@ dependencies = [ [[package]] name = "urlencoding" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "usvg" @@ -8571,6 +8610,7 @@ dependencies = [ "indoc", "itertools", "language", + "language_selector", "log", "nvim-rs", "parking_lot 0.11.2", @@ -8580,6 +8620,7 @@ dependencies = [ "serde_derive", "serde_json", "settings", + "theme", "tokio", "util", "workspace", @@ -8711,7 +8752,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.27", "wasm-bindgen-shared", ] @@ -8745,7 +8786,7 @@ checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.27", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -9328,9 +9369,9 @@ checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" [[package]] name = "winnow" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fac9742fd1ad1bd9643b991319f72dd031016d44b77039a26977eb667141e7" +checksum = "25b5872fa2e10bd067ae946f927e726d7d603eaeb6e02fa6a350e0722d2b8c11" dependencies = [ "memchr", ] @@ -9460,7 +9501,7 @@ name = "xtask" version = "0.1.0" dependencies = [ "anyhow", - "clap 4.3.15", + "clap 4.3.19", "schemars", "serde_json", "theme", @@ -9495,7 +9536,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.96.0" +version = "0.97.0" dependencies = [ "activity_indicator", "ai", @@ -9580,11 +9621,14 @@ dependencies = [ "tiny_http", "toml", "tree-sitter", + "tree-sitter-bash", "tree-sitter-c", "tree-sitter-cpp", "tree-sitter-css", "tree-sitter-elixir 0.1.0 (git+https://github.com/elixir-lang/tree-sitter-elixir?rev=4ba9dab6e2602960d95b2b625f3386c27e08084e)", + "tree-sitter-elm", "tree-sitter-embedded-template", + "tree-sitter-glsl", "tree-sitter-go", "tree-sitter-heex", "tree-sitter-html", @@ -9636,7 +9680,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.26", + "syn 2.0.27", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 04f2147431..fc021bee79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,11 +107,14 @@ tree-sitter = "0.20" unindent = { version = "0.1.7" } pretty_assertions = "1.3.0" +tree-sitter-bash = { git = "https://github.com/tree-sitter/tree-sitter-bash", rev = "1b0321ee85701d5036c334a6f04761cdc672e64c" } tree-sitter-c = "0.20.1" tree-sitter-cpp = "0.20.0" tree-sitter-css = { git = "https://github.com/tree-sitter/tree-sitter-css", rev = "769203d0f9abe1a9a691ac2b9fe4bb4397a73c51" } tree-sitter-elixir = { git = "https://github.com/elixir-lang/tree-sitter-elixir", rev = "4ba9dab6e2602960d95b2b625f3386c27e08084e" } +tree-sitter-elm = "5.6.4" tree-sitter-embedded-template = "0.20.0" +tree-sitter-glsl = { git = "https://github.com/theHamsta/tree-sitter-glsl", rev = "2a56fb7bc8bb03a1892b4741279dd0a8758b7fb3" } tree-sitter-go = { git = "https://github.com/tree-sitter/tree-sitter-go", rev = "aeb2f33b366fd78d5789ff104956ce23508b85db" } tree-sitter-heex = { git = "https://github.com/phoenixframework/tree-sitter-heex", rev = "2e1348c3cf2c9323e87c2744796cf3f3868aa82a" } tree-sitter-json = { git = "https://github.com/tree-sitter/tree-sitter-json", rev = "40a81c01a40ac48744e0c8ccabbaba1920441199" } diff --git a/assets/icons/file_icons/archive.svg b/assets/icons/file_icons/archive.svg new file mode 100644 index 0000000000..35e3dc59bd --- /dev/null +++ b/assets/icons/file_icons/archive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_icons/audio.svg b/assets/icons/file_icons/audio.svg new file mode 100644 index 0000000000..c2275efb63 --- /dev/null +++ b/assets/icons/file_icons/audio.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/file_icons/book.svg b/assets/icons/file_icons/book.svg new file mode 100644 index 0000000000..c9aa764d72 --- /dev/null +++ b/assets/icons/file_icons/book.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/file_icons/camera.svg b/assets/icons/file_icons/camera.svg new file mode 100644 index 0000000000..bc1993ad63 --- /dev/null +++ b/assets/icons/file_icons/camera.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/file_icons/chevron_down.svg b/assets/icons/file_icons/chevron_down.svg new file mode 100644 index 0000000000..b971555cfa --- /dev/null +++ b/assets/icons/file_icons/chevron_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/file_icons/chevron_left.svg b/assets/icons/file_icons/chevron_left.svg new file mode 100644 index 0000000000..8e61beed5d --- /dev/null +++ b/assets/icons/file_icons/chevron_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/file_icons/chevron_right.svg b/assets/icons/file_icons/chevron_right.svg new file mode 100644 index 0000000000..fcd9d83fc2 --- /dev/null +++ b/assets/icons/file_icons/chevron_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/file_icons/chevron_up.svg b/assets/icons/file_icons/chevron_up.svg new file mode 100644 index 0000000000..171cdd61c0 --- /dev/null +++ b/assets/icons/file_icons/chevron_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/file_icons/code.svg b/assets/icons/file_icons/code.svg new file mode 100644 index 0000000000..5e59cbe58f --- /dev/null +++ b/assets/icons/file_icons/code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/file_icons/database.svg b/assets/icons/file_icons/database.svg new file mode 100644 index 0000000000..812d147717 --- /dev/null +++ b/assets/icons/file_icons/database.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_icons/eslint.svg b/assets/icons/file_icons/eslint.svg new file mode 100644 index 0000000000..14ac83df96 --- /dev/null +++ b/assets/icons/file_icons/eslint.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/file_icons/file.svg b/assets/icons/file_icons/file.svg new file mode 100644 index 0000000000..bfffe03684 --- /dev/null +++ b/assets/icons/file_icons/file.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_icons/file_types.json b/assets/icons/file_icons/file_types.json new file mode 100644 index 0000000000..0ccf9c2bb7 --- /dev/null +++ b/assets/icons/file_icons/file_types.json @@ -0,0 +1,159 @@ +{ + "suffixes": { + "aac": "audio", + "bash": "terminal", + "bmp": "image", + "c": "code", + "conf": "settings", + "cpp": "code", + "cc": "code", + "css": "code", + "doc": "document", + "docx": "document", + "eslintrc": "eslint", + "eslintrc.js": "eslint", + "eslintrc.json": "eslint", + "flac": "audio", + "fish": "terminal", + "gitattributes": "vcs", + "gitignore": "vcs", + "gitmodules": "vcs", + "gif": "image", + "go": "code", + "h": "code", + "handlebars": "code", + "hbs": "template", + "htm": "template", + "html": "template", + "svelte": "template", + "hpp": "code", + "ico": "image", + "ini": "settings", + "java": "code", + "jpeg": "image", + "jpg": "image", + "js": "code", + "json": "storage", + "lock": "lock", + "log": "log", + "md": "document", + "mdx": "document", + "mp3": "audio", + "mp4": "video", + "ods": "document", + "odp": "document", + "odt": "document", + "ogg": "video", + "pdf": "document", + "php": "code", + "png": "image", + "ppt": "document", + "pptx": "document", + "prettierrc": "prettier", + "prettierignore": "prettier", + "ps1": "terminal", + "psd": "image", + "py": "code", + "rb": "code", + "rkt": "code", + "rs": "rust", + "rtf": "document", + "scm": "code", + "sh": "terminal", + "bashrc": "terminal", + "bash_profile": "terminal", + "bash_aliases": "terminal", + "bash_logout": "terminal", + "profile": "terminal", + "zshrc": "terminal", + "zshenv": "terminal", + "zsh_profile": "terminal", + "zsh_aliases": "terminal", + "zsh_histfile": "terminal", + "zlogin": "terminal", + "sql": "code", + "svg": "image", + "swift": "code", + "tiff": "image", + "toml": "toml", + "ts": "typescript", + "tsx": "code", + "txt": "document", + "wav": "audio", + "webm": "video", + "xls": "document", + "xlsx": "document", + "xml": "template", + "yaml": "settings", + "yml": "settings", + "zsh": "terminal" + }, + "types": { + "audio": { + "icon": "icons/file_icons/audio.svg" + }, + "code": { + "icon": "icons/file_icons/code.svg" + }, + "collapsed_chevron": { + "icon": "icons/file_icons/chevron_right.svg" + }, + "collapsed_folder": { + "icon": "icons/file_icons/folder.svg" + }, + "default": { + "icon": "icons/file_icons/file.svg" + }, + "document": { + "icon": "icons/file_icons/book.svg" + }, + "eslint": { + "icon": "icons/file_icons/eslint.svg" + }, + "expanded_chevron": { + "icon": "icons/file_icons/chevron_down.svg" + }, + "expanded_folder": { + "icon": "icons/file_icons/folder_open.svg" + }, + "image": { + "icon": "icons/file_icons/image.svg" + }, + "lock": { + "icon": "icons/file_icons/lock.svg" + }, + "log": { + "icon": "icons/file_icons/info.svg" + }, + "prettier": { + "icon": "icons/file_icons/prettier.svg" + }, + "rust": { + "icon": "icons/file_icons/rust.svg" + }, + "settings": { + "icon": "icons/file_icons/settings.svg" + }, + "storage": { + "icon": "icons/file_icons/database.svg" + }, + "template": { + "icon": "icons/file_icons/html.svg" + }, + "terminal": { + "icon": "icons/file_icons/terminal.svg" + }, + "toml": { + "icon": "icons/file_icons/toml.svg" + }, + "typescript": { + "icon": "icons/file_icons/typescript.svg" + }, + "vcs": { + "icon": "icons/file_icons/git.svg" + }, + "video": { + "icon": "icons/file_icons/video.svg" + } + } +} diff --git a/assets/icons/file_icons/folder.svg b/assets/icons/file_icons/folder.svg new file mode 100644 index 0000000000..fd45ab1c44 --- /dev/null +++ b/assets/icons/file_icons/folder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_icons/folder_open.svg b/assets/icons/file_icons/folder_open.svg new file mode 100644 index 0000000000..55c7d51649 --- /dev/null +++ b/assets/icons/file_icons/folder_open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_icons/git.svg b/assets/icons/file_icons/git.svg new file mode 100644 index 0000000000..a30b47fb86 --- /dev/null +++ b/assets/icons/file_icons/git.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/file_icons/hash.svg b/assets/icons/file_icons/hash.svg new file mode 100644 index 0000000000..edd0462678 --- /dev/null +++ b/assets/icons/file_icons/hash.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/file_icons/html.svg b/assets/icons/file_icons/html.svg new file mode 100644 index 0000000000..ba9ec14299 --- /dev/null +++ b/assets/icons/file_icons/html.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_icons/image.svg b/assets/icons/file_icons/image.svg new file mode 100644 index 0000000000..d9d5b82af1 --- /dev/null +++ b/assets/icons/file_icons/image.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/file_icons/info.svg b/assets/icons/file_icons/info.svg new file mode 100644 index 0000000000..e84ae7c628 --- /dev/null +++ b/assets/icons/file_icons/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_icons/lock.svg b/assets/icons/file_icons/lock.svg new file mode 100644 index 0000000000..14fed3941a --- /dev/null +++ b/assets/icons/file_icons/lock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/file_icons/notebook.svg b/assets/icons/file_icons/notebook.svg new file mode 100644 index 0000000000..4f55ceac58 --- /dev/null +++ b/assets/icons/file_icons/notebook.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/file_icons/package.svg b/assets/icons/file_icons/package.svg new file mode 100644 index 0000000000..a46126e3e9 --- /dev/null +++ b/assets/icons/file_icons/package.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/file_icons/prettier.svg b/assets/icons/file_icons/prettier.svg new file mode 100644 index 0000000000..23cefe0efc --- /dev/null +++ b/assets/icons/file_icons/prettier.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/icons/file_icons/rust.svg b/assets/icons/file_icons/rust.svg new file mode 100644 index 0000000000..91982b3eeb --- /dev/null +++ b/assets/icons/file_icons/rust.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/file_icons/settings.svg b/assets/icons/file_icons/settings.svg new file mode 100644 index 0000000000..35af7e1899 --- /dev/null +++ b/assets/icons/file_icons/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/file_icons/terminal.svg b/assets/icons/file_icons/terminal.svg new file mode 100644 index 0000000000..15dd705b0b --- /dev/null +++ b/assets/icons/file_icons/terminal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_icons/toml.svg b/assets/icons/file_icons/toml.svg new file mode 100644 index 0000000000..496c41e755 --- /dev/null +++ b/assets/icons/file_icons/toml.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_icons/typescript.svg b/assets/icons/file_icons/typescript.svg new file mode 100644 index 0000000000..f7748a86c4 --- /dev/null +++ b/assets/icons/file_icons/typescript.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_icons/video.svg b/assets/icons/file_icons/video.svg new file mode 100644 index 0000000000..c7ebf98af6 --- /dev/null +++ b/assets/icons/file_icons/video.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 1a13d8cdb3..7553c19925 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -195,8 +195,8 @@ { "context": "Editor && mode == auto_height", "bindings": { - "shift-enter": "editor::Newline", - "cmd-shift-enter": "editor::NewlineBelow" + "ctrl-enter": "editor::Newline", + "ctrl-shift-enter": "editor::NewlineBelow" } }, { @@ -406,6 +406,7 @@ "cmd-b": "workspace::ToggleLeftDock", "cmd-r": "workspace::ToggleRightDock", "cmd-j": "workspace::ToggleBottomDock", + "alt-cmd-y": "workspace::CloseAllDocks", "cmd-shift-f": "workspace::NewSearch", "cmd-k cmd-t": "theme_selector::Toggle", "cmd-k cmd-s": "zed::OpenKeymap", @@ -446,8 +447,22 @@ }, { "bindings": { - "cmd-k cmd-left": "workspace::ActivatePreviousPane", - "cmd-k cmd-right": "workspace::ActivateNextPane" + "cmd-k cmd-left": [ + "workspace::ActivatePaneInDirection", + "Left" + ], + "cmd-k cmd-right": [ + "workspace::ActivatePaneInDirection", + "Right" + ], + "cmd-k cmd-up": [ + "workspace::ActivatePaneInDirection", + "Up" + ], + "cmd-k cmd-down": [ + "workspace::ActivatePaneInDirection", + "Down" + ] } }, // Bindings from Atom @@ -513,8 +528,11 @@ "cmd-alt-c": "project_panel::CopyPath", "alt-cmd-shift-c": "project_panel::CopyRelativePath", "f2": "project_panel::Rename", + "enter": "project_panel::Rename", + "space": "project_panel::Open", "backspace": "project_panel::Delete", - "alt-cmd-r": "project_panel::RevealInFinder" + "alt-cmd-r": "project_panel::RevealInFinder", + "alt-shift-f": "project_panel::NewSearchInDirectory" } }, { diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index e6421114ec..94a271f037 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -2,12 +2,6 @@ { "context": "Editor && VimControl && !VimWaiting && !menu", "bindings": { - "g": [ - "vim::PushOperator", - { - "Namespace": "G" - } - ], "i": [ "vim::PushOperator", { @@ -30,6 +24,8 @@ "j": "vim::Down", "down": "vim::Down", "enter": "vim::NextLineStart", + "tab": "vim::Tab", + "shift-tab": "vim::Tab", "k": "vim::Up", "up": "vim::Up", "l": "vim::Right", @@ -60,6 +56,8 @@ "ignorePunctuation": true } ], + "n": "search::SelectNextMatch", + "shift-n": "search::SelectPrevMatch", "%": "vim::Matching", "f": [ "vim::PushOperator", @@ -103,7 +101,35 @@ "vim::SwitchMode", "Normal" ], + "*": "vim::MoveToNext", + "#": "vim::MoveToPrev", "0": "vim::StartOfLine", // When no number operator present, use start of line motion + // "g" commands + "g g": "vim::StartOfDocument", + "g h": "editor::Hover", + "g t": "pane::ActivateNextItem", + "g shift-t": "pane::ActivatePrevItem", + "g d": "editor::GoToDefinition", + "g shift-d": "editor::GoToTypeDefinition", + "g .": "editor::ToggleCodeActions", // zed specific + "g shift-a": "editor::FindAllReferences", // zed specific + "g *": [ + "vim::MoveToNext", + { + "partialWord": true + } + ], + "g #": [ + "vim::MoveToPrev", + { + "partialWord": true + } + ], + // z commands + "z t": "editor::ScrollCursorTop", + "z z": "editor::ScrollCursorCenter", + "z b": "editor::ScrollCursorBottom", + // Count support "1": [ "vim::Number", 1 @@ -139,7 +165,75 @@ "9": [ "vim::Number", 9 - ] + ], + // window related commands (ctrl-w X) + "ctrl-w left": [ + "workspace::ActivatePaneInDirection", + "Left" + ], + "ctrl-w right": [ + "workspace::ActivatePaneInDirection", + "Right" + ], + "ctrl-w up": [ + "workspace::ActivatePaneInDirection", + "Up" + ], + "ctrl-w down": [ + "workspace::ActivatePaneInDirection", + "Down" + ], + "ctrl-w h": [ + "workspace::ActivatePaneInDirection", + "Left" + ], + "ctrl-w l": [ + "workspace::ActivatePaneInDirection", + "Right" + ], + "ctrl-w k": [ + "workspace::ActivatePaneInDirection", + "Up" + ], + "ctrl-w j": [ + "workspace::ActivatePaneInDirection", + "Down" + ], + "ctrl-w ctrl-h": [ + "workspace::ActivatePaneInDirection", + "Left" + ], + "ctrl-w ctrl-l": [ + "workspace::ActivatePaneInDirection", + "Right" + ], + "ctrl-w ctrl-k": [ + "workspace::ActivatePaneInDirection", + "Up" + ], + "ctrl-w ctrl-j": [ + "workspace::ActivatePaneInDirection", + "Down" + ], + "ctrl-w g t": "pane::ActivateNextItem", + "ctrl-w ctrl-g t": "pane::ActivateNextItem", + "ctrl-w g shift-t": "pane::ActivatePrevItem", + "ctrl-w ctrl-g shift-t": "pane::ActivatePrevItem", + "ctrl-w w": "workspace::ActivateNextPane", + "ctrl-w ctrl-w": "workspace::ActivateNextPane", + "ctrl-w p": "workspace::ActivatePreviousPane", + "ctrl-w ctrl-p": "workspace::ActivatePreviousPane", + "ctrl-w shift-w": "workspace::ActivatePreviousPane", + "ctrl-w ctrl-shift-w": "workspace::ActivatePreviousPane", + "ctrl-w v": "pane::SplitLeft", + "ctrl-w ctrl-v": "pane::SplitLeft", + "ctrl-w s": "pane::SplitUp", + "ctrl-w shift-s": "pane::SplitUp", + "ctrl-w ctrl-s": "pane::SplitUp", + "ctrl-w c": "pane::CloseAllItems", + "ctrl-w ctrl-c": "pane::CloseAllItems", + "ctrl-w q": "pane::CloseAllItems", + "ctrl-w ctrl-q": "pane::CloseAllItems" } }, { @@ -160,12 +254,6 @@ "vim::PushOperator", "Yank" ], - "z": [ - "vim::PushOperator", - { - "Namespace": "Z" - } - ], "i": [ "vim::SwitchMode", "Insert" @@ -197,10 +285,18 @@ "p": "vim::Paste", "u": "editor::Undo", "ctrl-r": "editor::Redo", - "/": [ - "buffer_search::Deploy", + "/": "vim::Search", + "?": [ + "vim::Search", { - "focus": true + "backwards": true + } + ], + ";": "vim::RepeatFind", + ",": [ + "vim::RepeatFind", + { + "backwards": true } ], "ctrl-f": "vim::PageDown", @@ -231,20 +327,11 @@ ] } }, - { - "context": "Editor && vim_operator == g", - "bindings": { - "g": "vim::StartOfDocument", - "h": "editor::Hover", - "t": "pane::ActivateNextItem", - "shift-t": "pane::ActivatePrevItem", - "d": "editor::GoToDefinition" - } - }, { "context": "Editor && vim_operator == c", "bindings": { - "c": "vim::CurrentLine" + "c": "vim::CurrentLine", + "d": "editor::Rename" // zed specific } }, { @@ -259,14 +346,6 @@ "y": "vim::CurrentLine" } }, - { - "context": "Editor && vim_operator == z", - "bindings": { - "t": "editor::ScrollCursorTop", - "z": "editor::ScrollCursorCenter", - "b": "editor::ScrollCursorBottom", - } - }, { "context": "Editor && VimObject", "bindings": { @@ -310,8 +389,8 @@ "vim::SwitchMode", "Normal" ], - "> >": "editor::Indent", - "< <": "editor::Outdent" + ">": "editor::Indent", + "<": "editor::Outdent" } }, { @@ -319,7 +398,7 @@ "bindings": { "escape": "vim::NormalBefore", "ctrl-c": "vim::NormalBefore", - "ctrl-[": "vim::NormalBefore", + "ctrl-[": "vim::NormalBefore" } }, { @@ -336,5 +415,12 @@ "Normal" ] } + }, + { + "context": "BufferSearchBar > VimEnabled", + "bindings": { + "enter": "vim::SearchSubmit", + "escape": "buffer_search::Dismiss" + } } ] diff --git a/assets/settings/default.json b/assets/settings/default.json index ce272b32e8..397dac0961 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -50,6 +50,13 @@ // Whether to pop the completions menu while typing in an editor without // explicitly requesting it. "show_completions_on_input": true, + // Whether to show wrap guides in the editor. Setting this to true will + // show a guide at the 'preferred_line_length' value if softwrap is set to + // 'preferred_line_length', and will show any additional guides as specified + // by the 'wrap_guides' setting. + "show_wrap_guides": true, + // Character counts at which to show wrap guides in the editor. + "wrap_guides": [], // Whether to use additional LSP queries to format (and amend) the code after // every "trigger" symbol input, defined by LSP server capabilities. "use_on_type_format": true, @@ -66,6 +73,11 @@ // 3. Draw all invisible symbols: // "all" "show_whitespaces": "selection", + // Settings related to calls in Zed + "calls": { + // Join calls with the microphone muted by default + "mute_on_join": true + }, // Scrollbar related settings "scrollbar": { // When to show the scrollbar in the editor. @@ -97,12 +109,18 @@ "show_other_hints": true }, "project_panel": { - // Whether to show the git status in the project panel. - "git_status": true, + // Default width of the project panel. + "default_width": 240, // Where to dock project panel. Can be 'left' or 'right'. "dock": "left", - // Default width of the project panel. - "default_width": 240 + // Whether to show file icons in the project panel. + "file_icons": true, + // Whether to show folder icons or chevrons for directories in the project panel. + "folder_icons": true, + // Whether to show the git status in the project panel. + "git_status": true, + // Amount of indentation for nested items. + "indent_size": 20 }, "assistant": { // Where to dock the assistant. Can be 'left', 'right' or 'bottom'. @@ -196,9 +214,7 @@ "copilot": { // The set of glob patterns for which copilot should be disabled // in any matching file. - "disabled_globs": [ - ".env" - ] + "disabled_globs": [".env"] }, // Settings specific to journaling "journal": { @@ -347,12 +363,6 @@ // LSP Specific settings. "lsp": { // Specify the LSP name as a key here. - // As of 8/10/22, supported LSPs are: - // pyright - // gopls - // rust-analyzer - // typescript-language-server - // vscode-json-languageserver // "rust-analyzer": { // //These initialization options are merged into Zed's defaults // "initialization_options": { diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 35c88486f7..8a4c04d338 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -298,12 +298,22 @@ impl AssistantPanel { } fn deploy(&mut self, action: &search::buffer_search::Deploy, cx: &mut ViewContext) { + let mut propagate_action = true; if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { - if search_bar.update(cx, |search_bar, cx| search_bar.show(action.focus, true, cx)) { - return; - } + search_bar.update(cx, |search_bar, cx| { + if search_bar.show(cx) { + search_bar.search_suggested(cx); + if action.focus { + search_bar.select_query(cx); + cx.focus_self(); + } + propagate_action = false + } + }); + } + if propagate_action { + cx.propagate_action(); } - cx.propagate_action(); } fn handle_editor_cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { @@ -320,13 +330,13 @@ impl AssistantPanel { fn select_next_match(&mut self, _: &search::SelectNextMatch, cx: &mut ViewContext) { if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { - search_bar.update(cx, |bar, cx| bar.select_match(Direction::Next, cx)); + search_bar.update(cx, |bar, cx| bar.select_match(Direction::Next, 1, cx)); } } fn select_prev_match(&mut self, _: &search::SelectPrevMatch, cx: &mut ViewContext) { if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { - search_bar.update(cx, |bar, cx| bar.select_match(Direction::Prev, cx)); + search_bar.update(cx, |bar, cx| bar.select_match(Direction::Prev, 1, cx)); } } diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml index 61f3593247..eb448d8d8d 100644 --- a/crates/call/Cargo.toml +++ b/crates/call/Cargo.toml @@ -36,6 +36,10 @@ anyhow.workspace = true async-broadcast = "0.4" futures.workspace = true postage.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_derive.workspace = true [dev-dependencies] client = { path = "../client", features = ["test-support"] } diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index cf6dd1799c..2defd6b40f 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -1,9 +1,11 @@ +pub mod call_settings; pub mod participant; pub mod room; use std::sync::Arc; use anyhow::{anyhow, Result}; +use call_settings::CallSettings; use client::{proto, ClickhouseEvent, Client, TelemetrySettings, TypedEnvelope, User, UserStore}; use collections::HashSet; use futures::{future::Shared, FutureExt}; @@ -19,6 +21,8 @@ pub use participant::ParticipantLocation; pub use room::Room; pub fn init(client: Arc, user_store: ModelHandle, cx: &mut AppContext) { + settings::register::(cx); + let active_call = cx.add_model(|cx| ActiveCall::new(client, user_store, cx)); cx.set_global(active_call); } @@ -280,21 +284,6 @@ impl ActiveCall { } } - pub fn toggle_screen_sharing(&self, cx: &mut AppContext) { - if let Some(room) = self.room().cloned() { - let toggle_screen_sharing = room.update(cx, |room, cx| { - if room.is_screen_sharing() { - self.report_call_event("disable screen share", cx); - Task::ready(room.unshare_screen(cx)) - } else { - self.report_call_event("enable screen share", cx); - room.share_screen(cx) - } - }); - toggle_screen_sharing.detach_and_log_err(cx); - } - } - pub fn share_project( &mut self, project: ModelHandle, diff --git a/crates/call/src/call_settings.rs b/crates/call/src/call_settings.rs new file mode 100644 index 0000000000..2808a99617 --- /dev/null +++ b/crates/call/src/call_settings.rs @@ -0,0 +1,27 @@ +use schemars::JsonSchema; +use serde_derive::{Deserialize, Serialize}; +use settings::Setting; + +#[derive(Deserialize, Debug)] +pub struct CallSettings { + pub mute_on_join: bool, +} + +#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] +pub struct CallSettingsContent { + pub mute_on_join: Option, +} + +impl Setting for CallSettings { + const KEY: Option<&'static str> = Some("calls"); + + type FileContent = CallSettingsContent; + + fn load( + default_value: &Self::FileContent, + user_values: &[&Self::FileContent], + _: &gpui::AppContext, + ) -> anyhow::Result { + Self::load_via_json_merge(default_value, user_values) + } +} diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index 87e6faf988..328a94506c 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -1,4 +1,5 @@ use crate::{ + call_settings::CallSettings, participant::{LocalParticipant, ParticipantLocation, RemoteParticipant, RemoteVideoTrack}, IncomingCall, }; @@ -153,8 +154,10 @@ impl Room { cx.spawn(|this, mut cx| async move { connect.await?; - this.update(&mut cx, |this, cx| this.share_microphone(cx)) - .await?; + if !cx.read(|cx| settings::get::(cx).mute_on_join) { + this.update(&mut cx, |this, cx| this.share_microphone(cx)) + .await?; + } anyhow::Ok(()) }) @@ -656,7 +659,7 @@ impl Room { peer_id, projects: participant.projects, location, - muted: false, + muted: true, speaking: false, video_tracks: Default::default(), audio_tracks: Default::default(), @@ -670,6 +673,10 @@ impl Room { live_kit.room.remote_video_tracks(&user.id.to_string()); let audio_tracks = live_kit.room.remote_audio_tracks(&user.id.to_string()); + let publications = live_kit + .room + .remote_audio_track_publications(&user.id.to_string()); + for track in video_tracks { this.remote_video_track_updated( RemoteVideoTrackUpdate::Subscribed(track), @@ -677,9 +684,15 @@ impl Room { ) .log_err(); } - for track in audio_tracks { + + for (track, publication) in + audio_tracks.iter().zip(publications.iter()) + { this.remote_audio_track_updated( - RemoteAudioTrackUpdate::Subscribed(track), + RemoteAudioTrackUpdate::Subscribed( + track.clone(), + publication.clone(), + ), cx, ) .log_err(); @@ -819,8 +832,8 @@ impl Room { cx.notify(); } RemoteAudioTrackUpdate::MuteChanged { track_id, muted } => { + let mut found = false; for participant in &mut self.remote_participants.values_mut() { - let mut found = false; for track in participant.audio_tracks.values() { if track.sid() == track_id { found = true; @@ -832,16 +845,20 @@ impl Room { break; } } + cx.notify(); } - RemoteAudioTrackUpdate::Subscribed(track) => { + RemoteAudioTrackUpdate::Subscribed(track, publication) => { let user_id = track.publisher_id().parse()?; let track_id = track.sid().to_string(); let participant = self .remote_participants .get_mut(&user_id) .ok_or_else(|| anyhow!("subscribed to track by unknown participant"))?; + participant.audio_tracks.insert(track_id.clone(), track); + participant.muted = publication.is_muted(); + cx.emit(Event::RemoteAudioTracksChanged { participant_id: participant.peer_id, }); @@ -1053,7 +1070,7 @@ impl Room { self.live_kit .as_ref() .and_then(|live_kit| match &live_kit.microphone_track { - LocalTrack::None => None, + LocalTrack::None => Some(true), LocalTrack::Pending { muted, .. } => Some(*muted), LocalTrack::Published { muted, .. } => Some(*muted), }) @@ -1070,6 +1087,7 @@ impl Room { self.live_kit.as_ref().map(|live_kit| live_kit.deafened) } + #[track_caller] pub fn share_microphone(&mut self, cx: &mut ModelContext) -> Task> { if self.status.is_offline() { return Task::ready(Err(anyhow!("room is offline"))); @@ -1244,6 +1262,10 @@ impl Room { pub fn toggle_mute(&mut self, cx: &mut ModelContext) -> Result>> { let should_mute = !self.is_muted(); if let Some(live_kit) = self.live_kit.as_mut() { + if matches!(live_kit.microphone_track, LocalTrack::None) { + return Ok(self.share_microphone(cx)); + } + let (ret_task, old_muted) = live_kit.set_mute(should_mute, cx)?; live_kit.muted_by_user = should_mute; diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index 959f4cc783..dc5154d96f 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -40,6 +40,7 @@ lazy_static! { struct ClickhouseEventRequestBody { token: &'static str, installation_id: Option>, + is_staff: Option, app_version: Option>, os_name: &'static str, os_version: Option>, @@ -224,6 +225,7 @@ impl Telemetry { &ClickhouseEventRequestBody { token: ZED_SECRET_CLIENT_TOKEN, installation_id: state.installation_id.clone(), + is_staff: state.is_staff.clone(), app_version: state.app_version.clone(), os_name: state.os_name, os_version: state.os_version.clone(), diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 6cfc9d8e30..ce8d10d655 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -652,10 +652,10 @@ impl CollabTitlebarItem { let is_muted = room.read(cx).is_muted(); if is_muted { icon = "icons/radix/mic-mute.svg"; - tooltip = "Unmute microphone\nRight click for options"; + tooltip = "Unmute microphone"; } else { icon = "icons/radix/mic.svg"; - tooltip = "Mute microphone\nRight click for options"; + tooltip = "Mute microphone"; } let titlebar = &theme.titlebar; @@ -705,10 +705,10 @@ impl CollabTitlebarItem { let is_deafened = room.read(cx).is_deafened().unwrap_or(false); if is_deafened { icon = "icons/radix/speaker-off.svg"; - tooltip = "Unmute speakers\nRight click for options"; + tooltip = "Unmute speakers"; } else { icon = "icons/radix/speaker-loud.svg"; - tooltip = "Mute speakers\nRight click for options"; + tooltip = "Mute speakers"; } let titlebar = &theme.titlebar; diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index 7608fdbfee..df4b502391 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -18,13 +18,7 @@ use workspace::AppState; actions!( collab, - [ - ToggleScreenSharing, - ToggleMute, - ToggleDeafen, - LeaveCall, - ShareMicrophone - ] + [ToggleScreenSharing, ToggleMute, ToggleDeafen, LeaveCall] ); pub fn init(app_state: &Arc, cx: &mut AppContext) { @@ -40,7 +34,6 @@ pub fn init(app_state: &Arc, cx: &mut AppContext) { cx.add_global_action(toggle_screen_sharing); cx.add_global_action(toggle_mute); cx.add_global_action(toggle_deafen); - cx.add_global_action(share_microphone); } pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) { @@ -71,10 +64,24 @@ pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) { } pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) { - if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { - room.update(cx, Room::toggle_mute) - .map(|task| task.detach_and_log_err(cx)) - .log_err(); + let call = ActiveCall::global(cx).read(cx); + if let Some(room) = call.room().cloned() { + let client = call.client(); + room.update(cx, |room, cx| { + if room.is_muted() { + ActiveCall::report_call_event_for_room("enable microphone", room.id(), &client, cx); + } else { + ActiveCall::report_call_event_for_room( + "disable microphone", + room.id(), + &client, + cx, + ); + } + room.toggle_mute(cx) + }) + .map(|task| task.detach_and_log_err(cx)) + .log_err(); } } @@ -85,10 +92,3 @@ pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) { .log_err(); } } - -pub fn share_microphone(_: &ShareMicrophone, cx: &mut AppContext) { - if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { - room.update(cx, Room::share_microphone) - .detach_and_log_err(cx) - } -} diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 087ce81c26..bc1c904404 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -10,7 +10,6 @@ doctest = false [features] test-support = [ - "rand", "copilot/test-support", "text/test-support", "language/test-support", @@ -62,8 +61,8 @@ serde.workspace = true serde_derive.workspace = true smallvec.workspace = true smol.workspace = true +rand.workspace = true -rand = { workspace = true, optional = true } tree-sitter-rust = { workspace = true, optional = true } tree-sitter-html = { workspace = true, optional = true } tree-sitter-typescript = { workspace = true, optional = true } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b8a7853a93..e05837740d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -74,6 +74,7 @@ pub use multi_buffer::{ }; use ordered_float::OrderedFloat; use project::{FormatTrigger, Location, LocationLink, Project, ProjectPath, ProjectTransaction}; +use rand::{seq::SliceRandom, thread_rng}; use scroll::{ autoscroll::Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager, ScrollbarAutoHide, }; @@ -226,6 +227,10 @@ actions!( MoveLineUp, MoveLineDown, JoinLines, + SortLinesCaseSensitive, + SortLinesCaseInsensitive, + ReverseLines, + ShuffleLines, Transpose, Cut, Copy, @@ -344,6 +349,10 @@ pub fn init(cx: &mut AppContext) { cx.add_action(Editor::outdent); cx.add_action(Editor::delete_line); cx.add_action(Editor::join_lines); + cx.add_action(Editor::sort_lines_case_sensitive); + cx.add_action(Editor::sort_lines_case_insensitive); + cx.add_action(Editor::reverse_lines); + cx.add_action(Editor::shuffle_lines); cx.add_action(Editor::delete_to_previous_word_start); cx.add_action(Editor::delete_to_previous_subword_start); cx.add_action(Editor::delete_to_next_word_end); @@ -549,6 +558,7 @@ pub struct Editor { pending_rename: Option, searchable: bool, cursor_shape: CursorShape, + collapse_matches: bool, workspace: Option<(WeakViewHandle, i64)>, keymap_context_layers: BTreeMap, input_enabled: bool, @@ -562,6 +572,7 @@ pub struct Editor { inlay_hint_cache: InlayHintCache, next_inlay_id: usize, _subscriptions: Vec, + pixel_position_of_newest_cursor: Option, } pub struct EditorSnapshot { @@ -1381,6 +1392,7 @@ impl Editor { searchable: true, override_text_style: None, cursor_shape: Default::default(), + collapse_matches: false, workspace: None, keymap_context_layers: Default::default(), input_enabled: true, @@ -1392,6 +1404,7 @@ impl Editor { copilot_state: Default::default(), inlay_hint_cache: InlayHintCache::new(inlay_hint_settings), gutter_hovered: false, + pixel_position_of_newest_cursor: None, _subscriptions: vec![ cx.observe(&buffer, Self::on_buffer_changed), cx.subscribe(&buffer, Self::on_buffer_event), @@ -1520,6 +1533,17 @@ impl Editor { cx.notify(); } + pub fn set_collapse_matches(&mut self, collapse_matches: bool) { + self.collapse_matches = collapse_matches; + } + + fn range_for_match(&self, range: &Range) -> Range { + if self.collapse_matches { + return range.start..range.start; + } + range.clone() + } + pub fn set_clip_at_line_ends(&mut self, clip: bool, cx: &mut ViewContext) { if self.display_map.read(cx).clip_at_line_ends != clip { self.display_map @@ -2659,11 +2683,16 @@ impl Editor { InlayRefreshReason::RefreshRequested => (InvalidationStrategy::RefreshRequested, None), }; - self.inlay_hint_cache.refresh_inlay_hints( + if let Some(InlaySplice { + to_remove, + to_insert, + }) = self.inlay_hint_cache.spawn_hint_refresh( self.excerpt_visible_offsets(required_languages.as_ref(), cx), invalidate_cache, cx, - ) + ) { + self.splice_inlay_hints(to_remove, to_insert, cx); + } } fn visible_inlay_hints(&self, cx: &ViewContext<'_, '_, Editor>) -> Vec { @@ -4185,6 +4214,96 @@ impl Editor { }); } + pub fn sort_lines_case_sensitive( + &mut self, + _: &SortLinesCaseSensitive, + cx: &mut ViewContext, + ) { + self.manipulate_lines(cx, |text| text.sort()) + } + + pub fn sort_lines_case_insensitive( + &mut self, + _: &SortLinesCaseInsensitive, + cx: &mut ViewContext, + ) { + self.manipulate_lines(cx, |text| text.sort_by_key(|line| line.to_lowercase())) + } + + pub fn reverse_lines(&mut self, _: &ReverseLines, cx: &mut ViewContext) { + self.manipulate_lines(cx, |lines| lines.reverse()) + } + + pub fn shuffle_lines(&mut self, _: &ShuffleLines, cx: &mut ViewContext) { + self.manipulate_lines(cx, |lines| lines.shuffle(&mut thread_rng())) + } + + fn manipulate_lines(&mut self, cx: &mut ViewContext, mut callback: Fn) + where + Fn: FnMut(&mut [&str]), + { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = self.buffer.read(cx).snapshot(cx); + + let mut edits = Vec::new(); + + let selections = self.selections.all::(cx); + let mut selections = selections.iter().peekable(); + let mut contiguous_row_selections = Vec::new(); + let mut new_selections = Vec::new(); + + while let Some(selection) = selections.next() { + let (start_row, end_row) = consume_contiguous_rows( + &mut contiguous_row_selections, + selection, + &display_map, + &mut selections, + ); + + let start_point = Point::new(start_row, 0); + let end_point = Point::new(end_row - 1, buffer.line_len(end_row - 1)); + let text = buffer + .text_for_range(start_point..end_point) + .collect::(); + let mut text = text.split("\n").collect_vec(); + + let text_len = text.len(); + callback(&mut text); + + // This is a current limitation with selections. + // If we wanted to support removing or adding lines, we'd need to fix the logic associated with selections. + debug_assert!( + text.len() == text_len, + "callback should not change the number of lines" + ); + + edits.push((start_point..end_point, text.join("\n"))); + let start_anchor = buffer.anchor_after(start_point); + let end_anchor = buffer.anchor_before(end_point); + + // Make selection and push + new_selections.push(Selection { + id: selection.id, + start: start_anchor.to_offset(&buffer), + end: end_anchor.to_offset(&buffer), + goal: SelectionGoal::None, + reversed: selection.reversed, + }); + } + + self.transact(cx, |this, cx| { + this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + + this.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.select(new_selections); + }); + + this.request_autoscroll(Autoscroll::fit(), cx); + }); + } + pub fn duplicate_line(&mut self, _: &DuplicateLine, cx: &mut ViewContext) { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let buffer = &display_map.buffer_snapshot; @@ -5278,7 +5397,7 @@ impl Editor { pub fn select_all(&mut self, _: &SelectAll, cx: &mut ViewContext) { let end = self.buffer.read(cx).read(cx).len(); - self.change_selections(Some(Autoscroll::fit()), cx, |s| { + self.change_selections(None, cx, |s| { s.select_ranges(vec![0..end]); }); } @@ -6256,6 +6375,7 @@ impl Editor { .to_offset(definition.target.buffer.read(cx)); if Some(&definition.target.buffer) == self.buffer.read(cx).as_singleton().as_ref() { + let range = self.range_for_match(&range); self.change_selections(Some(Autoscroll::fit()), cx, |s| { s.select_ranges([range]); }); @@ -6272,6 +6392,7 @@ impl Editor { // When selecting a definition in a different buffer, disable the nav history // to avoid creating a history entry at the previous cursor location. pane.update(cx, |pane, _| pane.disable_history()); + let range = target_editor.range_for_match(&range); target_editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.select_ranges([range]); }); @@ -7064,6 +7185,20 @@ impl Editor { .text() } + pub fn wrap_guides(&self, cx: &AppContext) -> SmallVec<[(usize, bool); 2]> { + let mut wrap_guides = smallvec::smallvec![]; + + let settings = self.buffer.read(cx).settings_at(0, cx); + if settings.show_wrap_guides { + if let SoftWrap::Column(soft_wrap) = self.soft_wrap_mode(cx) { + wrap_guides.push((soft_wrap as usize, true)); + } + wrap_guides.extend(settings.wrap_guides.iter().map(|guide| (*guide, false))) + } + + wrap_guides + } + pub fn soft_wrap_mode(&self, cx: &AppContext) -> SoftWrap { let settings = self.buffer.read(cx).settings_at(0, cx); let mode = self diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 247a7b021d..eb03d2bdc0 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -2500,6 +2500,156 @@ fn test_join_lines_with_multi_selection(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_manipulate_lines_with_single_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // Test sort_lines_case_insensitive() + cx.set_state(indoc! {" + «z + y + x + Z + Y + Xˇ» + "}); + cx.update_editor(|e, cx| e.sort_lines_case_insensitive(&SortLinesCaseInsensitive, cx)); + cx.assert_editor_state(indoc! {" + «x + X + y + Y + z + Zˇ» + "}); + + // Test reverse_lines() + cx.set_state(indoc! {" + «5 + 4 + 3 + 2 + 1ˇ» + "}); + cx.update_editor(|e, cx| e.reverse_lines(&ReverseLines, cx)); + cx.assert_editor_state(indoc! {" + «1 + 2 + 3 + 4 + 5ˇ» + "}); + + // Skip testing shuffle_line() + + // From here on out, test more complex cases of manipulate_lines() with a single driver method: sort_lines_case_sensitive() + // Since all methods calling manipulate_lines() are doing the exact same general thing (reordering lines) + + // Don't manipulate when cursor is on single line, but expand the selection + cx.set_state(indoc! {" + ddˇdd + ccc + bb + a + "}); + cx.update_editor(|e, cx| e.sort_lines_case_sensitive(&SortLinesCaseSensitive, cx)); + cx.assert_editor_state(indoc! {" + «ddddˇ» + ccc + bb + a + "}); + + // Basic manipulate case + // Start selection moves to column 0 + // End of selection shrinks to fit shorter line + cx.set_state(indoc! {" + dd«d + ccc + bb + aaaaaˇ» + "}); + cx.update_editor(|e, cx| e.sort_lines_case_sensitive(&SortLinesCaseSensitive, cx)); + cx.assert_editor_state(indoc! {" + «aaaaa + bb + ccc + dddˇ» + "}); + + // Manipulate case with newlines + cx.set_state(indoc! {" + dd«d + ccc + + bb + aaaaa + + ˇ» + "}); + cx.update_editor(|e, cx| e.sort_lines_case_sensitive(&SortLinesCaseSensitive, cx)); + cx.assert_editor_state(indoc! {" + « + + aaaaa + bb + ccc + dddˇ» + + "}); +} + +#[gpui::test] +async fn test_manipulate_lines_with_multi_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // Manipulate with multiple selections on a single line + cx.set_state(indoc! {" + dd«dd + cˇ»c«c + bb + aaaˇ»aa + "}); + cx.update_editor(|e, cx| e.sort_lines_case_sensitive(&SortLinesCaseSensitive, cx)); + cx.assert_editor_state(indoc! {" + «aaaaa + bb + ccc + ddddˇ» + "}); + + // Manipulate with multiple disjoin selections + cx.set_state(indoc! {" + 5« + 4 + 3 + 2 + 1ˇ» + + dd«dd + ccc + bb + aaaˇ»aa + "}); + cx.update_editor(|e, cx| e.sort_lines_case_sensitive(&SortLinesCaseSensitive, cx)); + cx.assert_editor_state(indoc! {" + «1 + 2 + 3 + 4 + 5ˇ» + + «aaaaa + bb + ccc + ddddˇ» + "}); +} + #[gpui::test] fn test_duplicate_line(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 4f4aa7477d..b48fa5b56d 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -61,6 +61,7 @@ enum FoldMarkers {} struct SelectionLayout { head: DisplayPoint, cursor_shape: CursorShape, + is_newest: bool, range: Range, } @@ -70,6 +71,7 @@ impl SelectionLayout { line_mode: bool, cursor_shape: CursorShape, map: &DisplaySnapshot, + is_newest: bool, ) -> Self { if line_mode { let selection = selection.map(|p| p.to_point(&map.buffer_snapshot)); @@ -77,6 +79,7 @@ impl SelectionLayout { Self { head: selection.head().to_display_point(map), cursor_shape, + is_newest, range: point_range.start.to_display_point(map) ..point_range.end.to_display_point(map), } @@ -85,6 +88,7 @@ impl SelectionLayout { Self { head: selection.head(), cursor_shape, + is_newest, range: selection.range(), } } @@ -537,6 +541,24 @@ impl EditorElement { corner_radius: 0., }); } + + for (wrap_position, active) in layout.wrap_guides.iter() { + let x = text_bounds.origin_x() + wrap_position + layout.position_map.em_width / 2.; + let color = if *active { + self.style.active_wrap_guide + } else { + self.style.wrap_guide + }; + scene.push_quad(Quad { + bounds: RectF::new( + vec2f(x, text_bounds.origin_y()), + vec2f(1., text_bounds.height()), + ), + background: Some(color), + border: Border::new(0., Color::transparent_black()), + corner_radius: 0., + }); + } } } @@ -864,6 +886,12 @@ impl EditorElement { let x = cursor_character_x - scroll_left; let y = cursor_position.row() as f32 * layout.position_map.line_height - scroll_top; + if selection.is_newest { + editor.pixel_position_of_newest_cursor = Some(vec2f( + bounds.origin_x() + x + block_width / 2., + bounds.origin_y() + y + layout.position_map.line_height / 2., + )); + } cursors.push(Cursor { color: selection_style.cursor, block_width, @@ -1310,16 +1338,15 @@ impl EditorElement { } } - fn max_line_number_width(&self, snapshot: &EditorSnapshot, cx: &ViewContext) -> f32 { - let digit_count = (snapshot.max_buffer_row() as f32).log10().floor() as usize + 1; + fn column_pixels(&self, column: usize, cx: &ViewContext) -> f32 { let style = &self.style; cx.text_layout_cache() .layout_str( - "1".repeat(digit_count).as_str(), + " ".repeat(column).as_str(), style.text.font_size, &[( - digit_count, + column, RunStyle { font_id: style.text.font_id, color: Color::black(), @@ -1330,6 +1357,11 @@ impl EditorElement { .width() } + fn max_line_number_width(&self, snapshot: &EditorSnapshot, cx: &ViewContext) -> f32 { + let digit_count = (snapshot.max_buffer_row() as f32 + 1.).log10().floor() as usize + 1; + self.column_pixels(digit_count, cx) + } + //Folds contained in a hunk are ignored apart from shrinking visual size //If a fold contains any hunks then that fold line is marked as modified fn layout_git_gutters( @@ -1977,6 +2009,7 @@ impl Element for EditorElement { let snapshot = editor.snapshot(cx); let style = self.style.clone(); + let line_height = (style.text.font_size * style.line_height_scalar).round(); let gutter_padding; @@ -2014,6 +2047,12 @@ impl Element for EditorElement { } }; + let wrap_guides = editor + .wrap_guides(cx) + .iter() + .map(|(guide, active)| (self.column_pixels(*guide, cx), *active)) + .collect(); + let scroll_height = (snapshot.max_point().row() + 1) as f32 * line_height; if let EditorMode::AutoHeight { max_lines } = snapshot.mode { size.set_y( @@ -2108,6 +2147,7 @@ impl Element for EditorElement { line_mode, cursor_shape, &snapshot.display_snapshot, + false, )); } selections.extend(remote_selections); @@ -2117,6 +2157,7 @@ impl Element for EditorElement { .selections .disjoint_in_range(start_anchor..end_anchor, cx); local_selections.extend(editor.selections.pending(cx)); + let newest = editor.selections.newest(cx); for selection in &local_selections { let is_empty = selection.start == selection.end; let selection_start = snapshot.prev_line_boundary(selection.start).1; @@ -2139,11 +2180,13 @@ impl Element for EditorElement { local_selections .into_iter() .map(|selection| { + let is_newest = selection == newest; SelectionLayout::new( selection, editor.selections.line_mode, editor.cursor_shape, &snapshot.display_snapshot, + is_newest, ) }) .collect(), @@ -2370,6 +2413,7 @@ impl Element for EditorElement { snapshot, }), visible_display_row_range: start_row..end_row, + wrap_guides, gutter_size, gutter_padding, text_size, @@ -2520,6 +2564,7 @@ pub struct LayoutState { gutter_margin: f32, text_size: Vector2F, mode: EditorMode, + wrap_guides: SmallVec<[(f32, bool); 2]>, visible_display_row_range: Range, active_rows: BTreeMap, highlighted_rows: Option>, diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 7a203d54a9..92ed9ef77d 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -198,7 +198,7 @@ fn show_hover( // Construct new hover popover from hover request let hover_popover = hover_request.await.ok().flatten().and_then(|hover_result| { - if hover_result.contents.is_empty() { + if hover_result.is_empty() { return None; } @@ -420,7 +420,7 @@ fn render_blocks( RenderedInfo { theme_id, - text, + text: text.trim().to_string(), highlights, region_ranges, regions, @@ -816,6 +816,118 @@ mod tests { }); } + #[gpui::test] + async fn test_empty_hovers_filtered(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), + ..Default::default() + }, + cx, + ) + .await; + + // Hover with keyboard has no delay + cx.set_state(indoc! {" + fˇn test() { println!(); } + "}); + cx.update_editor(|editor, cx| hover(editor, &Hover, cx)); + let symbol_range = cx.lsp_range(indoc! {" + «fn» test() { println!(); } + "}); + cx.handle_request::(move |_, _, _| async move { + Ok(Some(lsp::Hover { + contents: lsp::HoverContents::Array(vec![ + lsp::MarkedString::String("regular text for hover to show".to_string()), + lsp::MarkedString::String("".to_string()), + lsp::MarkedString::LanguageString(lsp::LanguageString { + language: "Rust".to_string(), + value: "".to_string(), + }), + ]), + range: Some(symbol_range), + })) + }) + .next() + .await; + + cx.condition(|editor, _| editor.hover_state.visible()).await; + cx.editor(|editor, _| { + assert_eq!( + editor.hover_state.info_popover.clone().unwrap().blocks, + vec![HoverBlock { + text: "regular text for hover to show".to_string(), + kind: HoverBlockKind::Markdown, + }], + "No empty string hovers should be shown" + ); + }); + } + + #[gpui::test] + async fn test_line_ends_trimmed(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), + ..Default::default() + }, + cx, + ) + .await; + + // Hover with keyboard has no delay + cx.set_state(indoc! {" + fˇn test() { println!(); } + "}); + cx.update_editor(|editor, cx| hover(editor, &Hover, cx)); + let symbol_range = cx.lsp_range(indoc! {" + «fn» test() { println!(); } + "}); + + let code_str = "\nlet hovered_point: Vector2F // size = 8, align = 0x4\n"; + let markdown_string = format!("\n```rust\n{code_str}```"); + + let closure_markdown_string = markdown_string.clone(); + cx.handle_request::(move |_, _, _| { + let future_markdown_string = closure_markdown_string.clone(); + async move { + Ok(Some(lsp::Hover { + contents: lsp::HoverContents::Markup(lsp::MarkupContent { + kind: lsp::MarkupKind::Markdown, + value: future_markdown_string, + }), + range: Some(symbol_range), + })) + } + }) + .next() + .await; + + cx.condition(|editor, _| editor.hover_state.visible()).await; + cx.editor(|editor, cx| { + let blocks = editor.hover_state.info_popover.clone().unwrap().blocks; + assert_eq!( + blocks, + vec![HoverBlock { + text: markdown_string, + kind: HoverBlockKind::Markdown, + }], + ); + + let style = editor.style(cx); + let rendered = render_blocks(0, &blocks, &Default::default(), None, &style); + assert_eq!( + rendered.text, + code_str.trim(), + "Should not have extra line breaks at end of rendered hover" + ); + }); + } + #[gpui::test] async fn test_hover_diagnostic_and_info_popovers(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlay_hint_cache.rs index 52473f9971..63076ba234 100644 --- a/crates/editor/src/inlay_hint_cache.rs +++ b/crates/editor/src/inlay_hint_cache.rs @@ -195,20 +195,41 @@ impl InlayHintCache { } } - pub fn refresh_inlay_hints( + pub fn spawn_hint_refresh( &mut self, mut excerpts_to_query: HashMap, Global, Range)>, invalidate: InvalidationStrategy, cx: &mut ViewContext, - ) { - if !self.enabled || excerpts_to_query.is_empty() { - return; + ) -> Option { + if !self.enabled { + return None; } + let update_tasks = &mut self.update_tasks; + let mut invalidated_hints = Vec::new(); if invalidate.should_invalidate() { - update_tasks - .retain(|task_excerpt_id, _| excerpts_to_query.contains_key(task_excerpt_id)); + let mut changed = false; + update_tasks.retain(|task_excerpt_id, _| { + let retain = excerpts_to_query.contains_key(task_excerpt_id); + changed |= !retain; + retain + }); + self.hints.retain(|cached_excerpt, cached_hints| { + let retain = excerpts_to_query.contains_key(cached_excerpt); + changed |= !retain; + if !retain { + invalidated_hints.extend(cached_hints.read().hints.iter().map(|&(id, _)| id)); + } + retain + }); + if changed { + self.version += 1; + } } + if excerpts_to_query.is_empty() && invalidated_hints.is_empty() { + return None; + } + let cache_version = self.version; excerpts_to_query.retain(|visible_excerpt_id, _| { match update_tasks.entry(*visible_excerpt_id) { @@ -229,6 +250,15 @@ impl InlayHintCache { .ok(); }) .detach(); + + if invalidated_hints.is_empty() { + None + } else { + Some(InlaySplice { + to_remove: invalidated_hints, + to_insert: Vec::new(), + }) + } } fn new_allowed_hint_kinds_splice( @@ -684,7 +714,7 @@ async fn fetch_and_update_hints( if query.invalidate.should_invalidate() { let mut outdated_excerpt_caches = HashSet::default(); - for (excerpt_id, excerpt_hints) in editor.inlay_hint_cache().hints.iter() { + for (excerpt_id, excerpt_hints) in &editor.inlay_hint_cache().hints { let excerpt_hints = excerpt_hints.read(); if excerpt_hints.buffer_id == query.buffer_id && excerpt_id != &query.excerpt_id @@ -1022,9 +1052,9 @@ mod tests { "Should get its first hints when opening the editor" ); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.version, edits_made, + editor.inlay_hint_cache().version, + edits_made, "The editor update the cache version after every cache/view change" ); }); @@ -1053,9 +1083,9 @@ mod tests { "Should not update hints while the work task is running" ); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.version, edits_made, + editor.inlay_hint_cache().version, + edits_made, "Should not update the cache while the work task is running" ); }); @@ -1077,9 +1107,9 @@ mod tests { "New hints should be queried after the work task is done" ); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.version, edits_made, + editor.inlay_hint_cache().version, + edits_made, "Cache version should udpate once after the work task is done" ); }); @@ -1194,9 +1224,9 @@ mod tests { "Should get its first hints when opening the editor" ); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.version, 1, + editor.inlay_hint_cache().version, + 1, "Rust editor update the cache version after every cache/view change" ); }); @@ -1252,8 +1282,7 @@ mod tests { "Markdown editor should have a separate verison, repeating Rust editor rules" ); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!(inlay_cache.version, 1); + assert_eq!(editor.inlay_hint_cache().version, 1); }); rs_editor.update(cx, |editor, cx| { @@ -1269,9 +1298,9 @@ mod tests { "Rust inlay cache should change after the edit" ); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.version, 2, + editor.inlay_hint_cache().version, + 2, "Every time hint cache changes, cache version should be incremented" ); }); @@ -1283,8 +1312,7 @@ mod tests { "Markdown editor should not be affected by Rust editor changes" ); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!(inlay_cache.version, 1); + assert_eq!(editor.inlay_hint_cache().version, 1); }); md_editor.update(cx, |editor, cx| { @@ -1300,8 +1328,7 @@ mod tests { "Rust editor should not be affected by Markdown editor changes" ); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!(inlay_cache.version, 2); + assert_eq!(editor.inlay_hint_cache().version, 2); }); rs_editor.update(cx, |editor, cx| { let expected_layers = vec!["1".to_string()]; @@ -1311,8 +1338,7 @@ mod tests { "Markdown editor should also change independently" ); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!(inlay_cache.version, 2); + assert_eq!(editor.inlay_hint_cache().version, 2); }); } @@ -1433,9 +1459,9 @@ mod tests { vec!["other hint".to_string(), "type hint".to_string()], visible_hint_labels(editor, cx) ); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.version, edits_made, + editor.inlay_hint_cache().version, + edits_made, "Should not update cache version due to new loaded hints being the same" ); }); @@ -1568,9 +1594,8 @@ mod tests { ); assert!(cached_hint_labels(editor).is_empty()); assert!(visible_hint_labels(editor, cx).is_empty()); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.version, edits_made, + editor.inlay_hint_cache().version, edits_made, "The editor should not update the cache version after /refresh query without updates" ); }); @@ -1641,8 +1666,7 @@ mod tests { vec!["parameter hint".to_string()], visible_hint_labels(editor, cx), ); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!(inlay_cache.version, edits_made); + assert_eq!(editor.inlay_hint_cache().version, edits_made); }); } @@ -1720,9 +1744,8 @@ mod tests { "Should get hints from the last edit landed only" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.version, 1, + editor.inlay_hint_cache().version, 1, "Only one update should be registered in the cache after all cancellations" ); }); @@ -1766,9 +1789,9 @@ mod tests { "Should get hints from the last edit landed only" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.version, 2, + editor.inlay_hint_cache().version, + 2, "Should update the cache version once more, for the new change" ); }); @@ -1886,9 +1909,8 @@ mod tests { "Should have hints from both LSP requests made for a big file" ); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); assert_eq!( - inlay_cache.version, 2, + editor.inlay_hint_cache().version, 2, "Both LSP queries should've bumped the cache version" ); }); @@ -1918,8 +1940,7 @@ mod tests { assert_eq!(expected_layers, cached_hint_labels(editor), "Should have hints from the new LSP response after edit"); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!(inlay_cache.version, 5, "Should update the cache for every LSP response with hints added"); + assert_eq!(editor.inlay_hint_cache().version, 5, "Should update the cache for every LSP response with hints added"); }); } @@ -2075,6 +2096,7 @@ mod tests { panic!("unexpected uri: {:?}", params.text_document.uri); }; + // one hint per excerpt let positions = [ lsp::Position::new(0, 2), lsp::Position::new(4, 2), @@ -2138,8 +2160,7 @@ mod tests { "When scroll is at the edge of a multibuffer, its visible excerpts only should be queried for inlay hints" ); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!(inlay_cache.version, 4, "Every visible excerpt hints should bump the verison"); + assert_eq!(editor.inlay_hint_cache().version, expected_layers.len(), "Every visible excerpt hints should bump the verison"); }); editor.update(cx, |editor, cx| { @@ -2169,8 +2190,8 @@ mod tests { assert_eq!(expected_layers, cached_hint_labels(editor), "With more scrolls of the multibuffer, more hints should be added into the cache and nothing invalidated without edits"); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!(inlay_cache.version, 9); + assert_eq!(editor.inlay_hint_cache().version, expected_layers.len(), + "Due to every excerpt having one hint, we update cache per new excerpt scrolled"); }); editor.update(cx, |editor, cx| { @@ -2179,7 +2200,7 @@ mod tests { }); }); cx.foreground().run_until_parked(); - editor.update(cx, |editor, cx| { + let last_scroll_update_version = editor.update(cx, |editor, cx| { let expected_layers = vec![ "main hint #0".to_string(), "main hint #1".to_string(), @@ -2197,8 +2218,8 @@ mod tests { assert_eq!(expected_layers, cached_hint_labels(editor), "After multibuffer was scrolled to the end, all hints for all excerpts should be fetched"); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!(inlay_cache.version, 12); + assert_eq!(editor.inlay_hint_cache().version, expected_layers.len()); + expected_layers.len() }); editor.update(cx, |editor, cx| { @@ -2225,12 +2246,14 @@ mod tests { assert_eq!(expected_layers, cached_hint_labels(editor), "After multibuffer was scrolled to the end, further scrolls up should not bring more hints"); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!(inlay_cache.version, 12, "No updates should happen during scrolling already scolled buffer"); + assert_eq!(editor.inlay_hint_cache().version, last_scroll_update_version, "No updates should happen during scrolling already scolled buffer"); }); editor_edited.store(true, Ordering::Release); editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(56, 0)..Point::new(56, 0)]) + }); editor.handle_input("++++more text++++", cx); }); cx.foreground().run_until_parked(); @@ -2240,19 +2263,253 @@ mod tests { "main hint(edited) #1".to_string(), "main hint(edited) #2".to_string(), "main hint(edited) #3".to_string(), - "other hint #0".to_string(), - "other hint #1".to_string(), - "other hint #2".to_string(), - "other hint #3".to_string(), - "other hint #4".to_string(), - "other hint #5".to_string(), + "main hint(edited) #4".to_string(), + "main hint(edited) #5".to_string(), + "other hint(edited) #0".to_string(), + "other hint(edited) #1".to_string(), ]; - assert_eq!(expected_layers, cached_hint_labels(editor), - "After multibuffer was edited, hints for the edited buffer (1st) should be invalidated and requeried for all of its visible excerpts, \ -unedited (2nd) buffer should have the same hint"); + assert_eq!( + expected_layers, + cached_hint_labels(editor), + "After multibuffer edit, editor gets scolled back to the last selection; \ +all hints should be invalidated and requeried for all of its visible excerpts" + ); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!(inlay_cache.version, 16); + assert_eq!( + editor.inlay_hint_cache().version, + last_scroll_update_version + expected_layers.len() + 1, + "Due to every excerpt having one hint, cache should update per new excerpt received + 1 for outdated hints removal" + ); + }); + } + + #[gpui::test] + async fn test_excerpts_removed( + deterministic: Arc, + cx: &mut gpui::TestAppContext, + ) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettings { + enabled: true, + show_type_hints: false, + show_parameter_hints: false, + show_other_hints: false, + }) + }); + + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_servers = language + .set_fake_lsp_adapter(Arc::new(FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + ..Default::default() + })) + .await; + let language = Arc::new(language); + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/a", + json!({ + "main.rs": format!("fn main() {{\n{}\n}}", (0..501).map(|i| format!("let i = {i};\n")).collect::>().join("")), + "other.rs": format!("fn main() {{\n{}\n}}", (0..501).map(|j| format!("let j = {j};\n")).collect::>().join("")), + }), + ) + .await; + let project = Project::test(fs, ["/a".as_ref()], cx).await; + project.update(cx, |project, _| { + project.languages().add(Arc::clone(&language)) + }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let buffer_1 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, "main.rs"), cx) + }) + .await + .unwrap(); + let buffer_2 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, "other.rs"), cx) + }) + .await + .unwrap(); + let multibuffer = cx.add_model(|_| MultiBuffer::new(0)); + let (buffer_1_excerpts, buffer_2_excerpts) = multibuffer.update(cx, |multibuffer, cx| { + let buffer_1_excerpts = multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange { + context: Point::new(0, 0)..Point::new(2, 0), + primary: None, + }], + cx, + ); + let buffer_2_excerpts = multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange { + context: Point::new(0, 1)..Point::new(2, 1), + primary: None, + }], + cx, + ); + (buffer_1_excerpts, buffer_2_excerpts) + }); + + assert!(!buffer_1_excerpts.is_empty()); + assert!(!buffer_2_excerpts.is_empty()); + + deterministic.run_until_parked(); + cx.foreground().run_until_parked(); + let (_, editor) = + cx.add_window(|cx| Editor::for_multibuffer(multibuffer, Some(project.clone()), cx)); + let editor_edited = Arc::new(AtomicBool::new(false)); + let fake_server = fake_servers.next().await.unwrap(); + let closure_editor_edited = Arc::clone(&editor_edited); + fake_server + .handle_request::(move |params, _| { + let task_editor_edited = Arc::clone(&closure_editor_edited); + async move { + let hint_text = if params.text_document.uri + == lsp::Url::from_file_path("/a/main.rs").unwrap() + { + "main hint" + } else if params.text_document.uri + == lsp::Url::from_file_path("/a/other.rs").unwrap() + { + "other hint" + } else { + panic!("unexpected uri: {:?}", params.text_document.uri); + }; + + let positions = [ + lsp::Position::new(0, 2), + lsp::Position::new(4, 2), + lsp::Position::new(22, 2), + lsp::Position::new(44, 2), + lsp::Position::new(56, 2), + lsp::Position::new(67, 2), + ]; + let out_of_range_hint = lsp::InlayHint { + position: lsp::Position::new( + params.range.start.line + 99, + params.range.start.character + 99, + ), + label: lsp::InlayHintLabel::String( + "out of excerpt range, should be ignored".to_string(), + ), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }; + + let edited = task_editor_edited.load(Ordering::Acquire); + Ok(Some( + std::iter::once(out_of_range_hint) + .chain(positions.into_iter().enumerate().map(|(i, position)| { + lsp::InlayHint { + position, + label: lsp::InlayHintLabel::String(format!( + "{hint_text}{} #{i}", + if edited { "(edited)" } else { "" }, + )), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + } + })) + .collect(), + )) + } + }) + .next() + .await; + cx.foreground().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!( + vec!["main hint #0".to_string(), "other hint #0".to_string()], + cached_hint_labels(editor), + "Cache should update for both excerpts despite hints display was disabled" + ); + assert!( + visible_hint_labels(editor, cx).is_empty(), + "All hints are disabled and should not be shown despite being present in the cache" + ); + assert_eq!( + editor.inlay_hint_cache().version, + 2, + "Cache should update once per excerpt query" + ); + }); + + editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |multibuffer, cx| { + multibuffer.remove_excerpts(buffer_2_excerpts, cx) + }) + }); + cx.foreground().run_until_parked(); + editor.update(cx, |editor, cx| { + assert_eq!( + vec!["main hint #0".to_string()], + cached_hint_labels(editor), + "For the removed excerpt, should clean corresponding cached hints" + ); + assert!( + visible_hint_labels(editor, cx).is_empty(), + "All hints are disabled and should not be shown despite being present in the cache" + ); + assert_eq!( + editor.inlay_hint_cache().version, + 3, + "Excerpt removal should trigger cache update" + ); + }); + + update_test_language_settings(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettings { + enabled: true, + show_type_hints: true, + show_parameter_hints: true, + show_other_hints: true, + }) + }); + cx.foreground().run_until_parked(); + editor.update(cx, |editor, cx| { + let expected_hints = vec!["main hint #0".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor), + "Hint display settings change should not change the cache" + ); + assert_eq!( + expected_hints, + visible_hint_labels(editor, cx), + "Settings change should make cached hints visible" + ); + assert_eq!( + editor.inlay_hint_cache().version, + 4, + "Settings change should trigger cache update" + ); }); } diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 9498be1844..7c8fe12aa0 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -7,8 +7,10 @@ use anyhow::{Context, Result}; use collections::HashSet; use futures::future::try_join_all; use gpui::{ - elements::*, geometry::vector::vec2f, AppContext, AsyncAppContext, Entity, ModelHandle, - Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, + elements::*, + geometry::vector::{vec2f, Vector2F}, + AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View, ViewContext, + ViewHandle, WeakViewHandle, }; use language::{ proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, OffsetRangeExt, Point, @@ -750,6 +752,10 @@ impl Item for Editor { Some(Box::new(handle.clone())) } + fn pixel_position_of_cursor(&self) -> Option { + self.pixel_position_of_newest_cursor + } + fn breadcrumb_location(&self) -> ToolbarItemLocation { ToolbarItemLocation::PrimaryLeft { flex: None } } @@ -946,21 +952,27 @@ impl SearchableItem for Editor { cx: &mut ViewContext, ) { self.unfold_ranges([matches[index].clone()], false, true, cx); + let range = self.range_for_match(&matches[index]); self.change_selections(Some(Autoscroll::fit()), cx, |s| { - s.select_ranges([matches[index].clone()]) - }); + s.select_ranges([range]); + }) } fn select_matches(&mut self, matches: Vec, cx: &mut ViewContext) { self.unfold_ranges(matches.clone(), false, false, cx); - self.change_selections(None, cx, |s| s.select_ranges(matches)); + let mut ranges = Vec::new(); + for m in &matches { + ranges.push(self.range_for_match(&m)) + } + self.change_selections(None, cx, |s| s.select_ranges(ranges)); } fn match_index_for_direction( &mut self, matches: &Vec>, - mut current_index: usize, + current_index: usize, direction: Direction, + count: usize, cx: &mut ViewContext, ) -> usize { let buffer = self.buffer().read(cx).snapshot(cx); @@ -969,40 +981,39 @@ impl SearchableItem for Editor { } else { matches[current_index].start }; - if matches[current_index] - .start - .cmp(¤t_index_position, &buffer) - .is_gt() - { - if direction == Direction::Prev { - if current_index == 0 { - current_index = matches.len() - 1; + + let mut count = count % matches.len(); + if count == 0 { + return current_index; + } + match direction { + Direction::Next => { + if matches[current_index] + .start + .cmp(¤t_index_position, &buffer) + .is_gt() + { + count = count - 1 + } + + (current_index + count) % matches.len() + } + Direction::Prev => { + if matches[current_index] + .end + .cmp(¤t_index_position, &buffer) + .is_lt() + { + count = count - 1; + } + + if current_index >= count { + current_index - count } else { - current_index -= 1; + matches.len() - (count - current_index) } } - } else if matches[current_index] - .end - .cmp(¤t_index_position, &buffer) - .is_lt() - { - if direction == Direction::Next { - current_index = 0; - } - } else if direction == Direction::Prev { - if current_index == 0 { - current_index = matches.len() - 1; - } else { - current_index -= 1; - } - } else if direction == Direction::Next { - if current_index == matches.len() - 1 { - current_index = 0 - } else { - current_index += 1; - } - }; - current_index + } } fn find_matches( diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index a22506f751..1921bc0738 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -138,7 +138,7 @@ impl SelectionsCollection { .collect() } - // Returns all of the selections, adjusted to take into account the selection line_mode + /// Returns all of the selections, adjusted to take into account the selection line_mode pub fn all_adjusted(&self, cx: &mut AppContext) -> Vec> { let mut selections = self.all::(cx); if self.line_mode { diff --git a/crates/fs/src/repository.rs b/crates/fs/src/repository.rs index 3826dae2aa..2b2aebe679 100644 --- a/crates/fs/src/repository.rs +++ b/crates/fs/src/repository.rs @@ -1,6 +1,6 @@ use anyhow::Result; use collections::HashMap; -use git2::{BranchType, ErrorCode}; +use git2::{BranchType, StatusShow}; use parking_lot::Mutex; use rpc::proto; use serde_derive::{Deserialize, Serialize}; @@ -10,6 +10,7 @@ use std::{ os::unix::prelude::OsStrExt, path::{Component, Path, PathBuf}, sync::Arc, + time::SystemTime, }; use sum_tree::{MapSeekTarget, TreeMap}; use util::ResultExt; @@ -25,24 +26,30 @@ pub struct Branch { #[async_trait::async_trait] pub trait GitRepository: Send { fn reload_index(&self); - fn load_index_text(&self, relative_file_path: &Path) -> Option; - fn branch_name(&self) -> Option; - fn statuses(&self) -> Option>; + /// Get the statuses of all of the files in the index that start with the given + /// path and have changes with resepect to the HEAD commit. This is fast because + /// the index stores hashes of trees, so that unchanged directories can be skipped. + fn staged_statuses(&self, path_prefix: &Path) -> TreeMap; - fn status(&self, path: &RepoPath) -> Result>; + /// Get the status of a given file in the working directory with respect to + /// the index. In the common case, when there are no changes, this only requires + /// an index lookup. The index stores the mtime of each file when it was added, + /// so there's no work to do if the mtime matches. + fn unstaged_status(&self, path: &RepoPath, mtime: SystemTime) -> Option; - fn branches(&self) -> Result> { - Ok(vec![]) - } - fn change_branch(&self, _: &str) -> Result<()> { - Ok(()) - } - fn create_branch(&self, _: &str) -> Result<()> { - Ok(()) - } + /// Get the status of a given file in the working directory with respect to + /// the HEAD commit. In the common case, when there are no changes, this only + /// requires an index lookup and blob comparison between the index and the HEAD + /// commit. The index stores the mtime of each file when it was added, so there's + /// no need to consider the working directory file if the mtime matches. + fn status(&self, path: &RepoPath, mtime: SystemTime) -> Option; + + fn branches(&self) -> Result>; + fn change_branch(&self, _: &str) -> Result<()>; + fn create_branch(&self, _: &str) -> Result<()>; } impl std::fmt::Debug for dyn GitRepository { @@ -51,7 +58,6 @@ impl std::fmt::Debug for dyn GitRepository { } } -#[async_trait::async_trait] impl GitRepository for LibGitRepository { fn reload_index(&self) { if let Ok(mut index) = self.index() { @@ -89,39 +95,67 @@ impl GitRepository for LibGitRepository { Some(branch.to_string()) } - fn statuses(&self) -> Option> { - let statuses = self.statuses(None).log_err()?; - + fn staged_statuses(&self, path_prefix: &Path) -> TreeMap { let mut map = TreeMap::default(); - for status in statuses - .iter() - .filter(|status| !status.status().contains(git2::Status::IGNORED)) - { - let path = RepoPath(PathBuf::from(OsStr::from_bytes(status.path_bytes()))); - let Some(status) = read_status(status.status()) else { - continue - }; + let mut options = git2::StatusOptions::new(); + options.pathspec(path_prefix); + options.show(StatusShow::Index); - map.insert(path, status) - } - - Some(map) - } - - fn status(&self, path: &RepoPath) -> Result> { - let status = self.status_file(path); - match status { - Ok(status) => Ok(read_status(status)), - Err(e) => { - if e.code() == ErrorCode::NotFound { - Ok(None) - } else { - Err(e.into()) + if let Some(statuses) = self.statuses(Some(&mut options)).log_err() { + for status in statuses.iter() { + let path = RepoPath(PathBuf::from(OsStr::from_bytes(status.path_bytes()))); + let status = status.status(); + if !status.contains(git2::Status::IGNORED) { + if let Some(status) = read_status(status) { + map.insert(path, status) + } } } } + map } + + fn unstaged_status(&self, path: &RepoPath, mtime: SystemTime) -> Option { + // If the file has not changed since it was added to the index, then + // there can't be any changes. + if matches_index(self, path, mtime) { + return None; + } + + let mut options = git2::StatusOptions::new(); + options.pathspec(&path.0); + options.disable_pathspec_match(true); + options.include_untracked(true); + options.recurse_untracked_dirs(true); + options.include_unmodified(true); + options.show(StatusShow::Workdir); + + let statuses = self.statuses(Some(&mut options)).log_err()?; + let status = statuses.get(0).and_then(|s| read_status(s.status())); + status + } + + fn status(&self, path: &RepoPath, mtime: SystemTime) -> Option { + let mut options = git2::StatusOptions::new(); + options.pathspec(&path.0); + options.disable_pathspec_match(true); + options.include_untracked(true); + options.recurse_untracked_dirs(true); + options.include_unmodified(true); + + // If the file has not changed since it was added to the index, then + // there's no need to examine the working directory file: just compare + // the blob in the index to the one in the HEAD commit. + if matches_index(self, path, mtime) { + options.show(StatusShow::Index); + } + + let statuses = self.statuses(Some(&mut options)).log_err()?; + let status = statuses.get(0).and_then(|s| read_status(s.status())); + status + } + fn branches(&self) -> Result> { let local_branches = self.branches(Some(BranchType::Local))?; let valid_branches = local_branches @@ -164,6 +198,21 @@ impl GitRepository for LibGitRepository { } } +fn matches_index(repo: &LibGitRepository, path: &RepoPath, mtime: SystemTime) -> bool { + if let Some(index) = repo.index().log_err() { + if let Some(entry) = index.get_path(&path, 0) { + if let Some(mtime) = mtime.duration_since(SystemTime::UNIX_EPOCH).log_err() { + if entry.mtime.seconds() == mtime.as_secs() as i32 + && entry.mtime.nanoseconds() == mtime.subsec_nanos() + { + return true; + } + } + } + } + false +} + fn read_status(status: git2::Status) -> Option { if status.contains(git2::Status::CONFLICTED) { Some(GitFileStatus::Conflict) @@ -213,18 +262,40 @@ impl GitRepository for FakeGitRepository { state.branch_name.clone() } - fn statuses(&self) -> Option> { - let state = self.state.lock(); + fn staged_statuses(&self, path_prefix: &Path) -> TreeMap { let mut map = TreeMap::default(); + let state = self.state.lock(); for (repo_path, status) in state.worktree_statuses.iter() { - map.insert(repo_path.to_owned(), status.to_owned()); + if repo_path.0.starts_with(path_prefix) { + map.insert(repo_path.to_owned(), status.to_owned()); + } } - Some(map) + map } - fn status(&self, path: &RepoPath) -> Result> { + fn unstaged_status(&self, _path: &RepoPath, _mtime: SystemTime) -> Option { + None + } + + fn status(&self, path: &RepoPath, _mtime: SystemTime) -> Option { let state = self.state.lock(); - Ok(state.worktree_statuses.get(path).cloned()) + state.worktree_statuses.get(path).cloned() + } + + fn branches(&self) -> Result> { + Ok(vec![]) + } + + fn change_branch(&self, name: &str) -> Result<()> { + let mut state = self.state.lock(); + state.branch_name = Some(name.to_owned()); + Ok(()) + } + + fn create_branch(&self, name: &str) -> Result<()> { + let mut state = self.state.lock(); + state.branch_name = Some(name.to_owned()); + Ok(()) } } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index b40a67db61..7af363d596 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -3411,18 +3411,14 @@ impl<'a, 'b, 'c, V: View> LayoutContext<'a, 'b, 'c, V> { handler_depth = Some(contexts.len()) } + let action_contexts = if let Some(depth) = handler_depth { + &contexts[depth..] + } else { + &contexts + }; + self.keystroke_matcher - .bindings_for_action(action.id()) - .find_map(|b| { - let highest_handler = handler_depth?; - if action.eq(b.action()) - && (0..=highest_handler).any(|depth| b.match_context(&contexts[depth..])) - { - Some(b.keystrokes().into()) - } else { - None - } - }) + .keystrokes_for_action(action, action_contexts) } fn notify_if_view_ancestors_change(&mut self, view_id: usize) { diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 381a4fbaaa..c0f0ade7b9 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -10,8 +10,8 @@ use crate::{ mac::{ platform::NSViewLayerContentsRedrawDuringViewResize, renderer::Renderer, screen::Screen, }, - Event, InputHandler, KeyDownEvent, ModifiersChangedEvent, MouseButton, MouseButtonEvent, - MouseMovedEvent, Scene, WindowBounds, WindowKind, + Event, InputHandler, KeyDownEvent, Modifiers, ModifiersChangedEvent, MouseButton, + MouseButtonEvent, MouseMovedEvent, Scene, WindowBounds, WindowKind, }, }; use block::ConcreteBlock; @@ -1053,7 +1053,44 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { let window_height = window_state_borrow.content_size().y(); let event = unsafe { Event::from_native(native_event, Some(window_height)) }; - if let Some(event) = event { + + if let Some(mut event) = event { + let synthesized_second_event = match &mut event { + Event::MouseDown( + event @ MouseButtonEvent { + button: MouseButton::Left, + modifiers: Modifiers { ctrl: true, .. }, + .. + }, + ) => { + *event = MouseButtonEvent { + button: MouseButton::Right, + modifiers: Modifiers { + ctrl: false, + ..event.modifiers + }, + click_count: 1, + ..*event + }; + + Some(Event::MouseUp(MouseButtonEvent { + button: MouseButton::Right, + ..*event + })) + } + + // Because we map a ctrl-left_down to a right_down -> right_up let's ignore + // the ctrl-left_up to avoid having a mismatch in button down/up events if the + // user is still holding ctrl when releasing the left mouse button + Event::MouseUp(MouseButtonEvent { + button: MouseButton::Left, + modifiers: Modifiers { ctrl: true, .. }, + .. + }) => return, + + _ => None, + }; + match &event { Event::MouseMoved( event @ MouseMovedEvent { @@ -1105,6 +1142,9 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { if let Some(mut callback) = window_state_borrow.event_callback.take() { drop(window_state_borrow); callback(event); + if let Some(event) = synthesized_second_event { + callback(event); + } window_state.borrow_mut().event_callback = Some(callback); } } diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 820217567a..c3f706802a 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -44,6 +44,8 @@ pub struct LanguageSettings { pub hard_tabs: bool, pub soft_wrap: SoftWrap, pub preferred_line_length: u32, + pub show_wrap_guides: bool, + pub wrap_guides: Vec, pub format_on_save: FormatOnSave, pub remove_trailing_whitespace_on_save: bool, pub ensure_final_newline_on_save: bool, @@ -84,6 +86,10 @@ pub struct LanguageSettingsContent { #[serde(default)] pub preferred_line_length: Option, #[serde(default)] + pub show_wrap_guides: Option, + #[serde(default)] + pub wrap_guides: Option>, + #[serde(default)] pub format_on_save: Option, #[serde(default)] pub remove_trailing_whitespace_on_save: Option, @@ -378,6 +384,9 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent merge(&mut settings.tab_size, src.tab_size); merge(&mut settings.hard_tabs, src.hard_tabs); merge(&mut settings.soft_wrap, src.soft_wrap); + merge(&mut settings.show_wrap_guides, src.show_wrap_guides); + merge(&mut settings.wrap_guides, src.wrap_guides.clone()); + merge( &mut settings.preferred_line_length, src.preferred_line_length, diff --git a/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift b/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift index 40d3641db2..5f22acf581 100644 --- a/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift +++ b/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift @@ -6,7 +6,7 @@ import ScreenCaptureKit class LKRoomDelegate: RoomDelegate { var data: UnsafeRawPointer var onDidDisconnect: @convention(c) (UnsafeRawPointer) -> Void - var onDidSubscribeToRemoteAudioTrack: @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer) -> Void + var onDidSubscribeToRemoteAudioTrack: @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer, UnsafeRawPointer) -> Void var onDidUnsubscribeFromRemoteAudioTrack: @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void var onMuteChangedFromRemoteAudioTrack: @convention(c) (UnsafeRawPointer, CFString, Bool) -> Void var onActiveSpeakersChanged: @convention(c) (UnsafeRawPointer, CFArray) -> Void @@ -16,7 +16,7 @@ class LKRoomDelegate: RoomDelegate { init( data: UnsafeRawPointer, onDidDisconnect: @escaping @convention(c) (UnsafeRawPointer) -> Void, - onDidSubscribeToRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer) -> Void, + onDidSubscribeToRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer, UnsafeRawPointer) -> Void, onDidUnsubscribeFromRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void, onMuteChangedFromRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, Bool) -> Void, onActiveSpeakersChanged: @convention(c) (UnsafeRawPointer, CFArray) -> Void, @@ -43,7 +43,7 @@ class LKRoomDelegate: RoomDelegate { if track.kind == .video { self.onDidSubscribeToRemoteVideoTrack(self.data, participant.identity as CFString, track.sid! as CFString, Unmanaged.passUnretained(track).toOpaque()) } else if track.kind == .audio { - self.onDidSubscribeToRemoteAudioTrack(self.data, participant.identity as CFString, track.sid! as CFString, Unmanaged.passUnretained(track).toOpaque()) + self.onDidSubscribeToRemoteAudioTrack(self.data, participant.identity as CFString, track.sid! as CFString, Unmanaged.passUnretained(track).toOpaque(), Unmanaged.passUnretained(publication).toOpaque()) } } @@ -52,12 +52,12 @@ class LKRoomDelegate: RoomDelegate { self.onMuteChangedFromRemoteAudioTrack(self.data, publication.sid as CFString, muted) } } - + func room(_ room: Room, didUpdate speakers: [Participant]) { guard let speaker_ids = speakers.compactMap({ $0.identity as CFString }) as CFArray? else { return } self.onActiveSpeakersChanged(self.data, speaker_ids) } - + func room(_ room: Room, participant: RemoteParticipant, didUnsubscribe publication: RemoteTrackPublication, track: Track) { if track.kind == .video { self.onDidUnsubscribeFromRemoteVideoTrack(self.data, participant.identity as CFString, track.sid! as CFString) @@ -104,7 +104,7 @@ class LKVideoRenderer: NSObject, VideoRenderer { public func LKRoomDelegateCreate( data: UnsafeRawPointer, onDidDisconnect: @escaping @convention(c) (UnsafeRawPointer) -> Void, - onDidSubscribeToRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer) -> Void, + onDidSubscribeToRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer, UnsafeRawPointer) -> Void, onDidUnsubscribeFromRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void, onMuteChangedFromRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, Bool) -> Void, onActiveSpeakerChanged: @escaping @convention(c) (UnsafeRawPointer, CFArray) -> Void, @@ -180,39 +180,39 @@ public func LKRoomUnpublishTrack(room: UnsafeRawPointer, publication: UnsafeRawP @_cdecl("LKRoomAudioTracksForRemoteParticipant") public func LKRoomAudioTracksForRemoteParticipant(room: UnsafeRawPointer, participantId: CFString) -> CFArray? { let room = Unmanaged.fromOpaque(room).takeUnretainedValue() - + for (_, participant) in room.remoteParticipants { if participant.identity == participantId as String { return participant.audioTracks.compactMap { $0.track as? RemoteAudioTrack } as CFArray? } } - + return nil; } @_cdecl("LKRoomAudioTrackPublicationsForRemoteParticipant") public func LKRoomAudioTrackPublicationsForRemoteParticipant(room: UnsafeRawPointer, participantId: CFString) -> CFArray? { let room = Unmanaged.fromOpaque(room).takeUnretainedValue() - + for (_, participant) in room.remoteParticipants { if participant.identity == participantId as String { return participant.audioTracks.compactMap { $0 as? RemoteTrackPublication } as CFArray? } } - + return nil; } @_cdecl("LKRoomVideoTracksForRemoteParticipant") public func LKRoomVideoTracksForRemoteParticipant(room: UnsafeRawPointer, participantId: CFString) -> CFArray? { let room = Unmanaged.fromOpaque(room).takeUnretainedValue() - + for (_, participant) in room.remoteParticipants { if participant.identity == participantId as String { return participant.videoTracks.compactMap { $0.track as? RemoteVideoTrack } as CFArray? } } - + return nil; } @@ -222,7 +222,7 @@ public func LKLocalAudioTrackCreateTrack() -> UnsafeMutableRawPointer { echoCancellation: true, noiseSuppression: true )) - + return Unmanaged.passRetained(track).toOpaque() } @@ -276,7 +276,7 @@ public func LKLocalTrackPublicationSetMute( callback_data: UnsafeRawPointer ) { let publication = Unmanaged.fromOpaque(publication).takeUnretainedValue() - + if muted { publication.mute().then { on_complete(callback_data, nil) @@ -307,3 +307,21 @@ public func LKRemoteTrackPublicationSetEnabled( on_complete(callback_data, error.localizedDescription as CFString) } } + +@_cdecl("LKRemoteTrackPublicationIsMuted") +public func LKRemoteTrackPublicationIsMuted( + publication: UnsafeRawPointer +) -> Bool { + let publication = Unmanaged.fromOpaque(publication).takeUnretainedValue() + + return publication.muted +} + +@_cdecl("LKRemoteTrackPublicationGetSid") +public func LKRemoteTrackPublicationGetSid( + publication: UnsafeRawPointer +) -> CFString { + let publication = Unmanaged.fromOpaque(publication).takeUnretainedValue() + + return publication.sid as CFString +} diff --git a/crates/live_kit_client/examples/test_app.rs b/crates/live_kit_client/examples/test_app.rs index f5f6d0e46f..f2169d7f30 100644 --- a/crates/live_kit_client/examples/test_app.rs +++ b/crates/live_kit_client/examples/test_app.rs @@ -63,7 +63,7 @@ fn main() { let audio_track = LocalAudioTrack::create(); let audio_track_publication = room_a.publish_audio_track(&audio_track).await.unwrap(); - if let RemoteAudioTrackUpdate::Subscribed(track) = + if let RemoteAudioTrackUpdate::Subscribed(track, _) = audio_track_updates.next().await.unwrap() { let remote_tracks = room_b.remote_audio_tracks("test-participant-1"); diff --git a/crates/live_kit_client/src/prod.rs b/crates/live_kit_client/src/prod.rs index 6daa0601ca..d8d0277440 100644 --- a/crates/live_kit_client/src/prod.rs +++ b/crates/live_kit_client/src/prod.rs @@ -26,6 +26,7 @@ extern "C" { publisher_id: CFStringRef, track_id: CFStringRef, remote_track: *const c_void, + remote_publication: *const c_void, ), on_did_unsubscribe_from_remote_audio_track: extern "C" fn( callback_data: *mut c_void, @@ -125,6 +126,9 @@ extern "C" { on_complete: extern "C" fn(callback_data: *mut c_void, error: CFStringRef), callback_data: *mut c_void, ); + + fn LKRemoteTrackPublicationIsMuted(publication: *const c_void) -> bool; + fn LKRemoteTrackPublicationGetSid(publication: *const c_void) -> CFStringRef; } pub type Sid = String; @@ -372,11 +376,19 @@ impl Room { rx } - fn did_subscribe_to_remote_audio_track(&self, track: RemoteAudioTrack) { + fn did_subscribe_to_remote_audio_track( + &self, + track: RemoteAudioTrack, + publication: RemoteTrackPublication, + ) { let track = Arc::new(track); + let publication = Arc::new(publication); self.remote_audio_track_subscribers.lock().retain(|tx| { - tx.unbounded_send(RemoteAudioTrackUpdate::Subscribed(track.clone())) - .is_ok() + tx.unbounded_send(RemoteAudioTrackUpdate::Subscribed( + track.clone(), + publication.clone(), + )) + .is_ok() }); } @@ -501,13 +513,15 @@ impl RoomDelegate { publisher_id: CFStringRef, track_id: CFStringRef, track: *const c_void, + publication: *const c_void, ) { let room = unsafe { Weak::from_raw(room as *mut Room) }; let publisher_id = unsafe { CFString::wrap_under_get_rule(publisher_id).to_string() }; let track_id = unsafe { CFString::wrap_under_get_rule(track_id).to_string() }; let track = RemoteAudioTrack::new(track, track_id, publisher_id); + let publication = RemoteTrackPublication::new(publication); if let Some(room) = room.upgrade() { - room.did_subscribe_to_remote_audio_track(track); + room.did_subscribe_to_remote_audio_track(track, publication); } let _ = Weak::into_raw(room); } @@ -682,6 +696,14 @@ impl RemoteTrackPublication { Self(native_track_publication) } + pub fn sid(&self) -> String { + unsafe { CFString::wrap_under_get_rule(LKRemoteTrackPublicationGetSid(self.0)).to_string() } + } + + pub fn is_muted(&self) -> bool { + unsafe { LKRemoteTrackPublicationIsMuted(self.0) } + } + pub fn set_enabled(&self, enabled: bool) -> impl Future> { let (tx, rx) = futures::channel::oneshot::channel(); @@ -832,7 +854,7 @@ pub enum RemoteVideoTrackUpdate { pub enum RemoteAudioTrackUpdate { ActiveSpeakersChanged { speakers: Vec }, MuteChanged { track_id: Sid, muted: bool }, - Subscribed(Arc), + Subscribed(Arc, Arc), Unsubscribed { publisher_id: Sid, track_id: Sid }, } diff --git a/crates/live_kit_client/src/test.rs b/crates/live_kit_client/src/test.rs index ada864fc44..704760bab7 100644 --- a/crates/live_kit_client/src/test.rs +++ b/crates/live_kit_client/src/test.rs @@ -216,6 +216,8 @@ impl TestServer { publisher_id: identity.clone(), }); + let publication = Arc::new(RemoteTrackPublication); + room.audio_tracks.push(track.clone()); for (id, client_room) in &room.client_rooms { @@ -225,7 +227,10 @@ impl TestServer { .lock() .audio_track_updates .0 - .try_broadcast(RemoteAudioTrackUpdate::Subscribed(track.clone())) + .try_broadcast(RemoteAudioTrackUpdate::Subscribed( + track.clone(), + publication.clone(), + )) .unwrap(); } } @@ -501,6 +506,14 @@ impl RemoteTrackPublication { pub fn set_enabled(&self, _enabled: bool) -> impl Future> { async { Ok(()) } } + + pub fn is_muted(&self) -> bool { + false + } + + pub fn sid(&self) -> String { + "".to_string() + } } #[derive(Clone)] @@ -579,7 +592,7 @@ pub enum RemoteVideoTrackUpdate { pub enum RemoteAudioTrackUpdate { ActiveSpeakersChanged { speakers: Vec }, MuteChanged { track_id: Sid, muted: bool }, - Subscribed(Arc), + Subscribed(Arc, Arc), Unsubscribed { publisher_id: Sid, track_id: Sid }, } diff --git a/crates/node_runtime/src/node_runtime.rs b/crates/node_runtime/src/node_runtime.rs index de9cf501ac..94858df880 100644 --- a/crates/node_runtime/src/node_runtime.rs +++ b/crates/node_runtime/src/node_runtime.rs @@ -62,6 +62,14 @@ impl NodeRuntime { args: &[&str], ) -> Result { let attempt = |installation_path: PathBuf| async move { + let mut env_path = installation_path.join("bin").into_os_string(); + if let Some(existing_path) = std::env::var_os("PATH") { + if !existing_path.is_empty() { + env_path.push(":"); + env_path.push(&existing_path); + } + } + let node_binary = installation_path.join("bin/node"); let npm_file = installation_path.join("bin/npm"); @@ -74,6 +82,7 @@ impl NodeRuntime { } let mut command = Command::new(node_binary); + command.env("PATH", env_path); command.arg(npm_file).arg(subcommand).args(args); if let Some(directory) = directory { diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index b931560d25..6b905a1faa 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -259,6 +259,7 @@ pub enum Event { LanguageServerLog(LanguageServerId, String), Notification(String), ActiveEntryChanged(Option), + ActivateProjectPanel, WorktreeAdded, WorktreeRemoved(WorktreeId), WorktreeUpdatedEntries(WorktreeId, UpdatedEntriesSet), @@ -425,6 +426,12 @@ pub struct Hover { pub language: Option>, } +impl Hover { + pub fn is_empty(&self) -> bool { + self.contents.iter().all(|block| block.text.is_empty()) + } +} + #[derive(Default)] pub struct ProjectTransaction(pub HashMap, language::Transaction>); @@ -1909,7 +1916,9 @@ impl Project { return; } - let uri = lsp::Url::from_file_path(file.abs_path(cx)).unwrap(); + let abs_path = file.abs_path(cx); + let uri = lsp::Url::from_file_path(&abs_path) + .unwrap_or_else(|()| panic!("Failed to register file {abs_path:?}")); let initial_snapshot = buffer.text_snapshot(); let language = buffer.language().cloned(); let worktree_id = file.worktree_id(cx); diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index a1730fd365..b0795818b8 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -2015,37 +2015,6 @@ impl LocalSnapshot { entry } - #[must_use = "Changed paths must be used for diffing later"] - fn scan_statuses( - &mut self, - repo_ptr: &dyn GitRepository, - work_directory: &RepositoryWorkDirectory, - ) -> Vec> { - let mut changes = vec![]; - let mut edits = vec![]; - - let statuses = repo_ptr.statuses(); - - for mut entry in self - .descendent_entries(false, false, &work_directory.0) - .cloned() - { - let Ok(repo_path) = entry.path.strip_prefix(&work_directory.0) else { - continue; - }; - let repo_path = RepoPath(repo_path.to_path_buf()); - let git_file_status = statuses.as_ref().and_then(|s| s.get(&repo_path).copied()); - if entry.git_status != git_file_status { - entry.git_status = git_file_status; - changes.push(entry.path.clone()); - edits.push(Edit::Insert(entry)); - } - } - - self.entries_by_path.edit(edits, &()); - changes - } - fn ancestor_inodes_for_path(&self, path: &Path) -> TreeSet { let mut inodes = TreeSet::default(); for ancestor in path.ancestors().skip(1) { @@ -2189,6 +2158,38 @@ impl BackgroundScannerState { .any(|p| entry.path.starts_with(p)) } + fn enqueue_scan_dir(&self, abs_path: Arc, entry: &Entry, scan_job_tx: &Sender) { + let path = entry.path.clone(); + let ignore_stack = self.snapshot.ignore_stack_for_abs_path(&abs_path, true); + let mut ancestor_inodes = self.snapshot.ancestor_inodes_for_path(&path); + let mut containing_repository = None; + if !ignore_stack.is_all() { + if let Some((workdir_path, repo)) = self.snapshot.local_repo_for_path(&path) { + if let Ok(repo_path) = path.strip_prefix(&workdir_path.0) { + containing_repository = Some(( + workdir_path, + repo.repo_ptr.clone(), + repo.repo_ptr.lock().staged_statuses(repo_path), + )); + } + } + } + if !ancestor_inodes.contains(&entry.inode) { + ancestor_inodes.insert(entry.inode); + scan_job_tx + .try_send(ScanJob { + abs_path, + path, + ignore_stack, + scan_queue: scan_job_tx.clone(), + ancestor_inodes, + is_external: entry.is_external, + containing_repository, + }) + .unwrap(); + } + } + fn reuse_entry_id(&mut self, entry: &mut Entry) { if let Some(removed_entry_id) = self.removed_entry_ids.remove(&entry.inode) { entry.id = removed_entry_id; @@ -2201,7 +2202,7 @@ impl BackgroundScannerState { self.reuse_entry_id(&mut entry); let entry = self.snapshot.insert_entry(entry, fs); if entry.path.file_name() == Some(&DOT_GIT) { - self.build_repository(entry.path.clone(), fs); + self.build_git_repository(entry.path.clone(), fs); } #[cfg(test)] @@ -2215,7 +2216,6 @@ impl BackgroundScannerState { parent_path: &Arc, entries: impl IntoIterator, ignore: Option>, - fs: &dyn Fs, ) { let mut parent_entry = if let Some(parent_entry) = self .snapshot @@ -2244,16 +2244,12 @@ impl BackgroundScannerState { .insert(abs_parent_path, (ignore, false)); } - self.scanned_dirs.insert(parent_entry.id); + let parent_entry_id = parent_entry.id; + self.scanned_dirs.insert(parent_entry_id); let mut entries_by_path_edits = vec![Edit::Insert(parent_entry)]; let mut entries_by_id_edits = Vec::new(); - let mut dotgit_path = None; for entry in entries { - if entry.path.file_name() == Some(&DOT_GIT) { - dotgit_path = Some(entry.path.clone()); - } - entries_by_id_edits.push(Edit::Insert(PathEntry { id: entry.id, path: entry.path.clone(), @@ -2268,9 +2264,6 @@ impl BackgroundScannerState { .edit(entries_by_path_edits, &()); self.snapshot.entries_by_id.edit(entries_by_id_edits, &()); - if let Some(dotgit_path) = dotgit_path { - self.build_repository(dotgit_path, fs); - } if let Err(ix) = self.changed_paths.binary_search(parent_path) { self.changed_paths.insert(ix, parent_path.clone()); } @@ -2346,7 +2339,7 @@ impl BackgroundScannerState { }); match repository { None => { - self.build_repository(dot_git_dir.into(), fs); + self.build_git_repository(dot_git_dir.into(), fs); } Some((entry_id, repository)) => { if repository.git_dir_scan_id == scan_id { @@ -2370,13 +2363,7 @@ impl BackgroundScannerState { .repository_entries .update(&work_dir, |entry| entry.branch = branch.map(Into::into)); - let changed_paths = self.snapshot.scan_statuses(&*repository, &work_dir); - util::extend_sorted( - &mut self.changed_paths, - changed_paths, - usize::MAX, - Ord::cmp, - ) + self.update_git_statuses(&work_dir, &*repository); } } } @@ -2397,7 +2384,15 @@ impl BackgroundScannerState { snapshot.repository_entries = repository_entries; } - fn build_repository(&mut self, dot_git_path: Arc, fs: &dyn Fs) -> Option<()> { + fn build_git_repository( + &mut self, + dot_git_path: Arc, + fs: &dyn Fs, + ) -> Option<( + RepositoryWorkDirectory, + Arc>, + TreeMap, + )> { log::info!("build git repository {:?}", dot_git_path); let work_dir_path: Arc = dot_git_path.parent().unwrap().into(); @@ -2429,22 +2424,54 @@ impl BackgroundScannerState { }, ); - let changed_paths = self - .snapshot - .scan_statuses(repo_lock.deref(), &work_directory); + let staged_statuses = self.update_git_statuses(&work_directory, &*repo_lock); drop(repo_lock); self.snapshot.git_repositories.insert( work_dir_id, LocalRepositoryEntry { git_dir_scan_id: 0, - repo_ptr: repository, + repo_ptr: repository.clone(), git_dir_path: dot_git_path.clone(), }, ); - util::extend_sorted(&mut self.changed_paths, changed_paths, usize::MAX, Ord::cmp); - Some(()) + Some((work_directory, repository, staged_statuses)) + } + + fn update_git_statuses( + &mut self, + work_directory: &RepositoryWorkDirectory, + repo: &dyn GitRepository, + ) -> TreeMap { + let staged_statuses = repo.staged_statuses(Path::new("")); + + let mut changes = vec![]; + let mut edits = vec![]; + + for mut entry in self + .snapshot + .descendent_entries(false, false, &work_directory.0) + .cloned() + { + let Ok(repo_path) = entry.path.strip_prefix(&work_directory.0) else { + continue; + }; + let repo_path = RepoPath(repo_path.to_path_buf()); + let git_file_status = combine_git_statuses( + staged_statuses.get(&repo_path).copied(), + repo.unstaged_status(&repo_path, entry.mtime), + ); + if entry.git_status != git_file_status { + entry.git_status = git_file_status; + changes.push(entry.path.clone()); + edits.push(Edit::Insert(entry)); + } + } + + self.snapshot.entries_by_path.edit(edits, &()); + util::extend_sorted(&mut self.changed_paths, changes, usize::MAX, Ord::cmp); + staged_statuses } } @@ -3031,16 +3058,8 @@ impl BackgroundScanner { ) { use futures::FutureExt as _; - let (root_abs_path, root_inode) = { - let snapshot = &self.state.lock().snapshot; - ( - snapshot.abs_path.clone(), - snapshot.root_entry().map(|e| e.inode), - ) - }; - // Populate ignores above the root. - let ignore_stack; + let root_abs_path = self.state.lock().snapshot.abs_path.clone(); for ancestor in root_abs_path.ancestors().skip(1) { if let Ok(ignore) = build_gitignore(&ancestor.join(&*GITIGNORE), self.fs.as_ref()).await { @@ -3051,31 +3070,24 @@ impl BackgroundScanner { .insert(ancestor.into(), (ignore.into(), false)); } } + + let (scan_job_tx, scan_job_rx) = channel::unbounded(); { let mut state = self.state.lock(); state.snapshot.scan_id += 1; - ignore_stack = state - .snapshot - .ignore_stack_for_abs_path(&root_abs_path, true); - if ignore_stack.is_all() { - if let Some(mut root_entry) = state.snapshot.root_entry().cloned() { + if let Some(mut root_entry) = state.snapshot.root_entry().cloned() { + let ignore_stack = state + .snapshot + .ignore_stack_for_abs_path(&root_abs_path, true); + if ignore_stack.is_all() { root_entry.is_ignored = true; - state.insert_entry(root_entry, self.fs.as_ref()); + state.insert_entry(root_entry.clone(), self.fs.as_ref()); } + state.enqueue_scan_dir(root_abs_path, &root_entry, &scan_job_tx); } }; // Perform an initial scan of the directory. - let (scan_job_tx, scan_job_rx) = channel::unbounded(); - smol::block_on(scan_job_tx.send(ScanJob { - abs_path: root_abs_path, - path: Arc::from(Path::new("")), - ignore_stack, - ancestor_inodes: TreeSet::from_ordered_entries(root_inode), - is_external: false, - scan_queue: scan_job_tx.clone(), - })) - .unwrap(); drop(scan_job_tx); self.scan_dirs(true, scan_job_rx).await; { @@ -3263,20 +3275,7 @@ impl BackgroundScanner { if let Some(entry) = state.snapshot.entry_for_path(ancestor) { if entry.kind == EntryKind::UnloadedDir { let abs_path = root_path.join(ancestor); - let ignore_stack = - state.snapshot.ignore_stack_for_abs_path(&abs_path, true); - let ancestor_inodes = - state.snapshot.ancestor_inodes_for_path(&ancestor); - scan_job_tx - .try_send(ScanJob { - abs_path: abs_path.into(), - path: ancestor.into(), - ignore_stack, - scan_queue: scan_job_tx.clone(), - ancestor_inodes, - is_external: entry.is_external, - }) - .unwrap(); + state.enqueue_scan_dir(abs_path.into(), entry, &scan_job_tx); state.paths_to_scan.insert(path.clone()); break; } @@ -3391,18 +3390,16 @@ impl BackgroundScanner { let mut ignore_stack = job.ignore_stack.clone(); let mut new_ignore = None; - let (root_abs_path, root_char_bag, next_entry_id, repository) = { + let (root_abs_path, root_char_bag, next_entry_id) = { let snapshot = &self.state.lock().snapshot; ( snapshot.abs_path().clone(), snapshot.root_char_bag, self.next_entry_id.clone(), - snapshot - .local_repo_for_path(&job.path) - .map(|(work_dir, repo)| (work_dir, repo.clone())), ) }; + let mut dotgit_path = None; let mut root_canonical_path = None; let mut new_entries: Vec = Vec::new(); let mut new_jobs: Vec> = Vec::new(); @@ -3465,6 +3462,10 @@ impl BackgroundScanner { } } } + // If we find a .git, we'll need to load the repository. + else if child_name == *DOT_GIT { + dotgit_path = Some(child_path.clone()); + } let mut child_entry = Entry::new( child_path.clone(), @@ -3525,6 +3526,7 @@ impl BackgroundScanner { }, ancestor_inodes, scan_queue: job.scan_queue.clone(), + containing_repository: job.containing_repository.clone(), })); } else { new_jobs.push(None); @@ -3532,14 +3534,17 @@ impl BackgroundScanner { } else { child_entry.is_ignored = ignore_stack.is_abs_path_ignored(&child_abs_path, false); if !child_entry.is_ignored { - if let Some((repo_path, repo)) = &repository { - if let Ok(path) = child_path.strip_prefix(&repo_path.0) { - child_entry.git_status = repo - .repo_ptr - .lock() - .status(&RepoPath(path.into())) - .log_err() - .flatten(); + if let Some((repository_dir, repository, staged_statuses)) = + &job.containing_repository + { + if let Ok(repo_path) = child_entry.path.strip_prefix(&repository_dir.0) { + let repo_path = RepoPath(repo_path.into()); + child_entry.git_status = combine_git_statuses( + staged_statuses.get(&repo_path).copied(), + repository + .lock() + .unstaged_status(&repo_path, child_entry.mtime), + ); } } } @@ -3549,27 +3554,39 @@ impl BackgroundScanner { } let mut state = self.state.lock(); - let mut new_jobs = new_jobs.into_iter(); + + // Identify any subdirectories that should not be scanned. + let mut job_ix = 0; for entry in &mut new_entries { state.reuse_entry_id(entry); - if entry.is_dir() { - let new_job = new_jobs.next().expect("missing scan job for entry"); if state.should_scan_directory(&entry) { - if let Some(new_job) = new_job { - job.scan_queue - .try_send(new_job) - .expect("channel is unbounded"); - } + job_ix += 1; } else { log::debug!("defer scanning directory {:?}", entry.path); entry.kind = EntryKind::UnloadedDir; + new_jobs.remove(job_ix); } } } - assert!(new_jobs.next().is_none()); - state.populate_dir(&job.path, new_entries, new_ignore, self.fs.as_ref()); + state.populate_dir(&job.path, new_entries, new_ignore); + + let repository = + dotgit_path.and_then(|path| state.build_git_repository(path, self.fs.as_ref())); + + for new_job in new_jobs { + if let Some(mut new_job) = new_job { + if let Some(containing_repository) = &repository { + new_job.containing_repository = Some(containing_repository.clone()); + } + + job.scan_queue + .try_send(new_job) + .expect("channel is unbounded"); + } + } + Ok(()) } @@ -3638,13 +3655,10 @@ impl BackgroundScanner { if let Some((work_dir, repo)) = state.snapshot.local_repo_for_path(&path) { - if let Ok(path) = path.strip_prefix(work_dir.0) { - fs_entry.git_status = repo - .repo_ptr - .lock() - .status(&RepoPath(path.into())) - .log_err() - .flatten() + if let Ok(repo_path) = path.strip_prefix(work_dir.0) { + let repo_path = RepoPath(repo_path.into()); + let repo = repo.repo_ptr.lock(); + fs_entry.git_status = repo.status(&repo_path, fs_entry.mtime); } } } @@ -3652,20 +3666,7 @@ impl BackgroundScanner { if let (Some(scan_queue_tx), true) = (&scan_queue_tx, fs_entry.is_dir()) { if state.should_scan_directory(&fs_entry) { - let mut ancestor_inodes = - state.snapshot.ancestor_inodes_for_path(&path); - if !ancestor_inodes.contains(&metadata.inode) { - ancestor_inodes.insert(metadata.inode); - smol::block_on(scan_queue_tx.send(ScanJob { - abs_path, - path: path.clone(), - ignore_stack, - ancestor_inodes, - is_external: fs_entry.is_external, - scan_queue: scan_queue_tx.clone(), - })) - .unwrap(); - } + state.enqueue_scan_dir(abs_path, &fs_entry, scan_queue_tx); } else { fs_entry.kind = EntryKind::UnloadedDir; } @@ -3822,18 +3823,7 @@ impl BackgroundScanner { if was_ignored && !entry.is_ignored && entry.kind.is_unloaded() { let state = self.state.lock(); if state.should_scan_directory(&entry) { - job.scan_queue - .try_send(ScanJob { - abs_path: abs_path.clone(), - path: entry.path.clone(), - ignore_stack: child_ignore_stack.clone(), - scan_queue: job.scan_queue.clone(), - ancestor_inodes: state - .snapshot - .ancestor_inodes_for_path(&entry.path), - is_external: false, - }) - .unwrap(); + state.enqueue_scan_dir(abs_path.clone(), &entry, &job.scan_queue); } } @@ -4022,6 +4012,11 @@ struct ScanJob { scan_queue: Sender, ancestor_inodes: TreeSet, is_external: bool, + containing_repository: Option<( + RepositoryWorkDirectory, + Arc>, + TreeMap, + )>, } struct UpdateIgnoreStatusJob { @@ -4348,3 +4343,22 @@ impl<'a> TryFrom<(&'a CharBag, proto::Entry)> for Entry { } } } + +fn combine_git_statuses( + staged: Option, + unstaged: Option, +) -> Option { + if let Some(staged) = staged { + if let Some(unstaged) = unstaged { + if unstaged != staged { + Some(GitFileStatus::Modified) + } else { + Some(staged) + } + } else { + Some(staged) + } + } else { + unstaged + } +} diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index 33606fccc4..4fe5372a51 100644 --- a/crates/project_panel/Cargo.toml +++ b/crates/project_panel/Cargo.toml @@ -10,6 +10,7 @@ doctest = false [dependencies] context_menu = { path = "../context_menu" } +collections = { path = "../collections" } db = { path = "../db" } drag_and_drop = { path = "../drag_and_drop" } editor = { path = "../editor" } diff --git a/crates/project_panel/src/file_associations.rs b/crates/project_panel/src/file_associations.rs new file mode 100644 index 0000000000..2694fa1697 --- /dev/null +++ b/crates/project_panel/src/file_associations.rs @@ -0,0 +1,103 @@ +use std::{path::Path, str, sync::Arc}; + +use collections::HashMap; + +use gpui::{AppContext, AssetSource}; +use serde_derive::Deserialize; +use util::iife; + +#[derive(Deserialize, Debug)] +struct TypeConfig { + icon: Arc, +} + +#[derive(Deserialize, Debug)] +pub struct FileAssociations { + suffixes: HashMap, + types: HashMap, +} + +const COLLAPSED_DIRECTORY_TYPE: &'static str = "collapsed_folder"; +const EXPANDED_DIRECTORY_TYPE: &'static str = "expanded_folder"; +const COLLAPSED_CHEVRON_TYPE: &'static str = "collapsed_chevron"; +const EXPANDED_CHEVRON_TYPE: &'static str = "expanded_chevron"; +pub const FILE_TYPES_ASSET: &'static str = "icons/file_icons/file_types.json"; + +pub fn init(assets: impl AssetSource, cx: &mut AppContext) { + cx.set_global(FileAssociations::new(assets)) +} + +impl FileAssociations { + pub fn new(assets: impl AssetSource) -> Self { + assets + .load("icons/file_icons/file_types.json") + .and_then(|file| { + serde_json::from_str::(str::from_utf8(&file).unwrap()) + .map_err(Into::into) + }) + .unwrap_or_else(|_| FileAssociations { + suffixes: HashMap::default(), + types: HashMap::default(), + }) + } + + pub fn get_icon(path: &Path, cx: &AppContext) -> Arc { + iife!({ + let this = cx.has_global::().then(|| cx.global::())?; + + // FIXME: Associate a type with the languages and have the file's langauge + // override these associations + iife!({ + let suffix = path + .file_name() + .and_then(|os_str| os_str.to_str()) + .and_then(|file_name| { + file_name + .find('.') + .and_then(|dot_index| file_name.get(dot_index + 1..)) + })?; + + this.suffixes + .get(suffix) + .and_then(|type_str| this.types.get(type_str)) + .map(|type_config| type_config.icon.clone()) + }) + .or_else(|| this.types.get("default").map(|config| config.icon.clone())) + }) + .unwrap_or_else(|| Arc::from("".to_string())) + } + + pub fn get_folder_icon(expanded: bool, cx: &AppContext) -> Arc { + iife!({ + let this = cx.has_global::().then(|| cx.global::())?; + + let key = if expanded { + EXPANDED_DIRECTORY_TYPE + } else { + COLLAPSED_DIRECTORY_TYPE + }; + + this.types + .get(key) + .map(|type_config| type_config.icon.clone()) + }) + .unwrap_or_else(|| Arc::from("".to_string())) + } + + pub fn get_chevron_icon(expanded: bool, cx: &AppContext) -> Arc { + iife!({ + let this = cx.has_global::().then(|| cx.global::())?; + + let key = if expanded { + EXPANDED_CHEVRON_TYPE + } else { + COLLAPSED_CHEVRON_TYPE + }; + + this.types + .get(key) + .map(|type_config| type_config.icon.clone()) + }) + .unwrap_or_else(|| Arc::from("".to_string())) + } +} diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 5442a8be74..3e20c4986e 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1,9 +1,12 @@ +pub mod file_associations; mod project_panel_settings; use context_menu::{ContextMenu, ContextMenuItem}; use db::kvp::KEY_VALUE_STORE; use drag_and_drop::{DragAndDrop, Draggable}; -use editor::{Cancel, Editor}; +use editor::{scroll::autoscroll::Autoscroll, Cancel, Editor}; +use file_associations::FileAssociations; + use futures::stream::StreamExt; use gpui::{ actions, @@ -15,8 +18,8 @@ use gpui::{ geometry::vector::Vector2F, keymap_matcher::KeymapContext, platform::{CursorStyle, MouseButton, PromptLevel}, - Action, AnyElement, AppContext, AsyncAppContext, ClipboardItem, Element, Entity, ModelHandle, - Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, + Action, AnyElement, AppContext, AssetSource, AsyncAppContext, ClipboardItem, Element, Entity, + ModelHandle, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, }; use menu::{Confirm, SelectNext, SelectPrev}; use project::{ @@ -94,6 +97,7 @@ pub enum ClipboardEntry { #[derive(Debug, PartialEq, Eq)] pub struct EntryDetails { filename: String, + icon: Option>, path: Arc, depth: usize, kind: EntryKind, @@ -121,7 +125,9 @@ actions!( Paste, Delete, Rename, - ToggleFocus + Open, + ToggleFocus, + NewSearchInDirectory, ] ); @@ -129,8 +135,9 @@ pub fn init_settings(cx: &mut AppContext) { settings::register::(cx); } -pub fn init(cx: &mut AppContext) { +pub fn init(assets: impl AssetSource, cx: &mut AppContext) { init_settings(cx); + file_associations::init(assets, cx); cx.add_action(ProjectPanel::expand_selected_entry); cx.add_action(ProjectPanel::collapse_selected_entry); cx.add_action(ProjectPanel::select_prev); @@ -140,12 +147,14 @@ pub fn init(cx: &mut AppContext) { cx.add_action(ProjectPanel::rename); cx.add_async_action(ProjectPanel::delete); cx.add_async_action(ProjectPanel::confirm); + cx.add_async_action(ProjectPanel::open_file); cx.add_action(ProjectPanel::cancel); cx.add_action(ProjectPanel::cut); cx.add_action(ProjectPanel::copy); cx.add_action(ProjectPanel::copy_path); cx.add_action(ProjectPanel::copy_relative_path); cx.add_action(ProjectPanel::reveal_in_finder); + cx.add_action(ProjectPanel::new_search_in_directory); cx.add_action( |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext| { this.paste(action, cx); @@ -164,6 +173,10 @@ pub enum Event { }, DockPositionChanged, Focus, + NewSearchInDirectory { + dir_entry: Entry, + }, + ActivatePanel, } #[derive(Serialize, Deserialize)] @@ -190,6 +203,9 @@ impl ProjectPanel { cx.notify(); } } + project::Event::ActivateProjectPanel => { + cx.emit(Event::ActivatePanel); + } project::Event::WorktreeRemoved(id) => { this.expanded_dir_ids.remove(id); this.update_visible_entries(None, cx); @@ -230,6 +246,11 @@ impl ProjectPanel { }) .detach(); + cx.observe_global::(|_, cx| { + cx.notify(); + }) + .detach(); + let view_id = cx.view_id(); let mut this = Self { project: project.clone(), @@ -407,6 +428,12 @@ impl ProjectPanel { CopyRelativePath, )); menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder)); + if entry.is_dir() { + menu_entries.push(ContextMenuItem::action( + "Search inside", + NewSearchInDirectory, + )); + } if let Some(clipboard_entry) = self.clipboard_entry { if clipboard_entry.worktree_id() == worktree.id() { menu_entries.push(ContextMenuItem::action("Paste", Paste)); @@ -535,15 +562,20 @@ impl ProjectPanel { fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) -> Option>> { if let Some(task) = self.confirm_edit(cx) { - Some(task) - } else if let Some((_, entry)) = self.selected_entry(cx) { + return Some(task); + } + + None + } + + fn open_file(&mut self, _: &Open, cx: &mut ViewContext) -> Option>> { + if let Some((_, entry)) = self.selected_entry(cx) { if entry.is_file() { self.open_entry(entry.id, true, cx); } - None - } else { - None } + + None } fn confirm_edit(&mut self, cx: &mut ViewContext) -> Option>> { @@ -720,13 +752,20 @@ impl ProjectPanel { is_dir: entry.is_dir(), processing_filename: None, }); - let filename = entry + let file_name = entry .path .file_name() - .map_or(String::new(), |s| s.to_string_lossy().to_string()); + .map(|s| s.to_string_lossy()) + .unwrap_or_default() + .to_string(); + let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy()); + let selection_end = + file_stem.map_or(file_name.len(), |file_stem| file_stem.len()); self.filename_editor.update(cx, |editor, cx| { - editor.set_text(filename, cx); - editor.select_all(&Default::default(), cx); + editor.set_text(file_name, cx); + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.select_ranges([0..selection_end]) + }) }); cx.focus(&self.filename_editor); self.update_visible_entries(None, cx); @@ -918,6 +957,20 @@ impl ProjectPanel { } } + pub fn new_search_in_directory( + &mut self, + _: &NewSearchInDirectory, + cx: &mut ViewContext, + ) { + if let Some((_, entry)) = self.selected_entry(cx) { + if entry.is_dir() { + cx.emit(Event::NewSearchInDirectory { + dir_entry: entry.clone(), + }); + } + } + } + fn move_entry( &mut self, entry_to_move: ProjectEntryId, @@ -972,7 +1025,10 @@ impl ProjectPanel { None } - fn selected_entry<'a>(&self, cx: &'a AppContext) -> Option<(&'a Worktree, &'a project::Entry)> { + pub fn selected_entry<'a>( + &self, + cx: &'a AppContext, + ) -> Option<(&'a Worktree, &'a project::Entry)> { let (worktree, entry) = self.selected_entry_handle(cx)?; Some((worktree.read(cx), entry)) } @@ -1166,7 +1222,14 @@ impl ProjectPanel { } let end_ix = range.end.min(ix + visible_worktree_entries.len()); - let git_status_setting = settings::get::(cx).git_status; + let (git_status_setting, show_file_icons, show_folder_icons) = { + let settings = settings::get::(cx); + ( + settings.git_status, + settings.file_icons, + settings.folder_icons, + ) + }; if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) { let snapshot = worktree.read(cx).snapshot(); let root_name = OsStr::new(snapshot.root_name()); @@ -1179,6 +1242,23 @@ impl ProjectPanel { let entry_range = range.start.saturating_sub(ix)..end_ix - ix; for entry in visible_worktree_entries[entry_range].iter() { let status = git_status_setting.then(|| entry.git_status).flatten(); + let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok(); + let icon = match entry.kind { + EntryKind::File(_) => { + if show_file_icons { + Some(FileAssociations::get_icon(&entry.path, cx)) + } else { + None + } + } + _ => { + if show_folder_icons { + Some(FileAssociations::get_folder_icon(is_expanded, cx)) + } else { + Some(FileAssociations::get_chevron_icon(is_expanded, cx)) + } + } + }; let mut details = EntryDetails { filename: entry @@ -1187,11 +1267,12 @@ impl ProjectPanel { .unwrap_or(root_name) .to_string_lossy() .to_string(), + icon, path: entry.path.clone(), depth: entry.path.components().count(), kind: entry.kind, is_ignored: entry.is_ignored, - is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(), + is_expanded, is_selected: self.selection.map_or(false, |e| { e.worktree_id == snapshot.id() && e.entry_id == entry.id }), @@ -1239,7 +1320,6 @@ impl ProjectPanel { style: &ProjectPanelEntry, cx: &mut ViewContext, ) -> AnyElement { - let kind = details.kind; let show_editor = details.is_editing && !details.is_processing; let mut filename_text_style = style.text.clone(); @@ -1254,23 +1334,24 @@ impl ProjectPanel { .unwrap_or(style.text.color); Flex::row() - .with_child( - if kind.is_dir() { - if details.is_expanded { - Svg::new("icons/chevron_down_8.svg").with_color(style.icon_color) - } else { - Svg::new("icons/chevron_right_8.svg").with_color(style.icon_color) - } + .with_child(if let Some(icon) = &details.icon { + Svg::new(icon.to_string()) + .with_color(style.icon_color) .constrained() - } else { - Empty::new().constrained() - } - .with_max_width(style.icon_size) - .with_max_height(style.icon_size) - .aligned() - .constrained() - .with_width(style.icon_size), - ) + .with_max_width(style.icon_size) + .with_max_height(style.icon_size) + .aligned() + .constrained() + .with_width(style.icon_size) + } else { + Empty::new() + .constrained() + .with_max_width(style.icon_size) + .with_max_height(style.icon_size) + .aligned() + .constrained() + .with_width(style.icon_size) + }) .with_child(if show_editor && editor.is_some() { ChildView::new(editor.as_ref().unwrap(), cx) .contained() @@ -1305,7 +1386,8 @@ impl ProjectPanel { ) -> AnyElement { let kind = details.kind; let path = details.path.clone(); - let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width; + let settings = settings::get::(cx); + let padding = theme.container.padding.left + details.depth as f32 * settings.indent_size; let entry_style = if details.is_cut { &theme.cut_entry @@ -1641,7 +1723,11 @@ mod tests { use project::FakeFs; use serde_json::json; use settings::SettingsStore; - use std::{collections::HashSet, path::Path}; + use std::{ + collections::HashSet, + path::Path, + sync::atomic::{self, AtomicUsize}, + }; use workspace::{pane, AppState}; #[gpui::test] @@ -1877,7 +1963,7 @@ mod tests { .update(cx, |panel, cx| { panel .filename_editor - .update(cx, |editor, cx| editor.set_text("another-filename", cx)); + .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx)); panel.confirm(&Confirm, cx).unwrap() }) .await @@ -1891,14 +1977,14 @@ mod tests { " v b", " > 3", " > 4", - " another-filename <== selected", + " another-filename.txt <== selected", " > C", " .dockerignore", " the-new-filename", ] ); - select_path(&panel, "root1/b/another-filename", cx); + select_path(&panel, "root1/b/another-filename.txt", cx); panel.update(cx, |panel, cx| panel.rename(&Rename, cx)); assert_eq!( visible_entries_as_strings(&panel, 0..10, cx), @@ -1909,7 +1995,7 @@ mod tests { " v b", " > 3", " > 4", - " [EDITOR: 'another-filename'] <== selected", + " [EDITOR: 'another-filename.txt'] <== selected", " > C", " .dockerignore", " the-new-filename", @@ -1917,9 +2003,15 @@ mod tests { ); let confirm = panel.update(cx, |panel, cx| { - panel - .filename_editor - .update(cx, |editor, cx| editor.set_text("a-different-filename", cx)); + panel.filename_editor.update(cx, |editor, cx| { + let file_name_selections = editor.selections.all::(cx); + assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}"); + let file_name_selection = &file_name_selections[0]; + assert_eq!(file_name_selection.start, 0, "Should select the file name from the start"); + assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension"); + + editor.set_text("a-different-filename.tar.gz", cx) + }); panel.confirm(&Confirm, cx).unwrap() }); assert_eq!( @@ -1931,7 +2023,7 @@ mod tests { " v b", " > 3", " > 4", - " [PROCESSING: 'a-different-filename'] <== selected", + " [PROCESSING: 'a-different-filename.tar.gz'] <== selected", " > C", " .dockerignore", " the-new-filename", @@ -1948,13 +2040,42 @@ mod tests { " v b", " > 3", " > 4", - " a-different-filename <== selected", + " a-different-filename.tar.gz <== selected", " > C", " .dockerignore", " the-new-filename", ] ); + panel.update(cx, |panel, cx| panel.rename(&Rename, cx)); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " [EDITOR: 'a-different-filename.tar.gz'] <== selected", + " > C", + " .dockerignore", + " the-new-filename", + ] + ); + + panel.update(cx, |panel, cx| { + panel.filename_editor.update(cx, |editor, cx| { + let file_name_selections = editor.selections.all::(cx); + assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}"); + let file_name_selection = &file_name_selections[0]; + assert_eq!(file_name_selection.start, 0, "Should select the file name from the start"); + assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot"); + + }); + panel.cancel(&Cancel, cx) + }); + panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx)); assert_eq!( visible_entries_as_strings(&panel, 0..10, cx), @@ -1966,7 +2087,7 @@ mod tests { " > [EDITOR: ''] <== selected", " > 3", " > 4", - " a-different-filename", + " a-different-filename.tar.gz", " > C", " .dockerignore", ] @@ -1989,7 +2110,7 @@ mod tests { " > [PROCESSING: 'new-dir']", " > 3 <== selected", " > 4", - " a-different-filename", + " a-different-filename.tar.gz", " > C", " .dockerignore", ] @@ -2006,7 +2127,7 @@ mod tests { " > 3 <== selected", " > 4", " > new-dir", - " a-different-filename", + " a-different-filename.tar.gz", " > C", " .dockerignore", ] @@ -2023,7 +2144,7 @@ mod tests { " > [EDITOR: '3'] <== selected", " > 4", " > new-dir", - " a-different-filename", + " a-different-filename.tar.gz", " > C", " .dockerignore", ] @@ -2041,7 +2162,7 @@ mod tests { " > 3 <== selected", " > 4", " > new-dir", - " a-different-filename", + " a-different-filename.tar.gz", " > C", " .dockerignore", ] @@ -2268,7 +2389,7 @@ mod tests { toggle_expand_dir(&panel, "src/test", cx); select_path(&panel, "src/test/first.rs", cx); - panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx)); + panel.update(cx, |panel, cx| panel.open_file(&Open, cx)); cx.foreground().run_until_parked(); assert_eq!( visible_entries_as_strings(&panel, 0..10, cx), @@ -2296,7 +2417,7 @@ mod tests { ensure_no_open_items_and_panes(window_id, &workspace, cx); select_path(&panel, "src/test/second.rs", cx); - panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx)); + panel.update(cx, |panel, cx| panel.open_file(&Open, cx)); cx.foreground().run_until_parked(); assert_eq!( visible_entries_as_strings(&panel, 0..10, cx), @@ -2480,6 +2601,83 @@ mod tests { ); } + #[gpui::test] + async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/src", + json!({ + "test": { + "first.rs": "// First Rust file", + "second.rs": "// Second Rust file", + "third.rs": "// Third Rust file", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await; + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)); + + let new_search_events_count = Arc::new(AtomicUsize::new(0)); + let _subscription = panel.update(cx, |_, cx| { + let subcription_count = Arc::clone(&new_search_events_count); + cx.subscribe(&cx.handle(), move |_, _, event, _| { + if matches!(event, Event::NewSearchInDirectory { .. }) { + subcription_count.fetch_add(1, atomic::Ordering::SeqCst); + } + }) + }); + + toggle_expand_dir(&panel, "src/test", cx); + select_path(&panel, "src/test/first.rs", cx); + panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx)); + cx.foreground().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " first.rs <== selected", + " second.rs", + " third.rs" + ] + ); + panel.update(cx, |panel, cx| { + panel.new_search_in_directory(&NewSearchInDirectory, cx) + }); + assert_eq!( + new_search_events_count.load(atomic::Ordering::SeqCst), + 0, + "Should not trigger new search in directory when called on a file" + ); + + select_path(&panel, "src/test", cx); + panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx)); + cx.foreground().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test <== selected", + " first.rs", + " second.rs", + " third.rs" + ] + ); + panel.update(cx, |panel, cx| { + panel.new_search_in_directory(&NewSearchInDirectory, cx) + }); + assert_eq!( + new_search_events_count.load(atomic::Ordering::SeqCst), + 1, + "Should trigger new search in directory when called on a directory" + ); + } + fn toggle_expand_dir( panel: &ViewHandle, path: impl AsRef, @@ -2581,7 +2779,7 @@ mod tests { theme::init((), cx); language::init(cx); editor::init_settings(cx); - crate::init(cx); + crate::init((), cx); workspace::init_settings(cx); Project::init_settings(cx); }); @@ -2596,7 +2794,7 @@ mod tests { language::init(cx); editor::init(cx); pane::init(cx); - crate::init(cx); + crate::init((), cx); workspace::init(app_state.clone(), cx); Project::init_settings(cx); }); diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 1d6c590710..126433e5a3 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -12,16 +12,22 @@ pub enum ProjectPanelDockPosition { #[derive(Deserialize, Debug)] pub struct ProjectPanelSettings { - pub git_status: bool, - pub dock: ProjectPanelDockPosition, pub default_width: f32, + pub dock: ProjectPanelDockPosition, + pub file_icons: bool, + pub folder_icons: bool, + pub git_status: bool, + pub indent_size: f32, } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] pub struct ProjectPanelSettingsContent { - pub git_status: Option, - pub dock: Option, pub default_width: Option, + pub dock: Option, + pub file_icons: Option, + pub folder_icons: Option, + pub git_status: Option, + pub indent_size: Option, } impl Setting for ProjectPanelSettings { diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index f6ed6c3fef..3a59f9a5cd 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -9,6 +9,7 @@ path = "src/search.rs" doctest = false [dependencies] +bitflags = "1" collections = { path = "../collections" } editor = { path = "../editor" } gpui = { path = "../gpui" } diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index f6466c85af..5429305098 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -1,15 +1,17 @@ use crate::{ - SearchOption, SelectAllMatches, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, + SearchOptions, SelectAllMatches, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex, ToggleWholeWord, }; use collections::HashMap; use editor::Editor; +use futures::channel::oneshot; use gpui::{ actions, elements::*, impl_actions, platform::{CursorStyle, MouseButton}, Action, AnyViewHandle, AppContext, Entity, Subscription, Task, View, ViewContext, ViewHandle, + WindowContext, }; use project::search::SearchQuery; use serde::Deserialize; @@ -44,20 +46,19 @@ pub fn init(cx: &mut AppContext) { cx.add_action(BufferSearchBar::select_prev_match_on_pane); cx.add_action(BufferSearchBar::select_all_matches_on_pane); cx.add_action(BufferSearchBar::handle_editor_cancel); - add_toggle_option_action::(SearchOption::CaseSensitive, cx); - add_toggle_option_action::(SearchOption::WholeWord, cx); - add_toggle_option_action::(SearchOption::Regex, cx); + add_toggle_option_action::(SearchOptions::CASE_SENSITIVE, cx); + add_toggle_option_action::(SearchOptions::WHOLE_WORD, cx); + add_toggle_option_action::(SearchOptions::REGEX, cx); } -fn add_toggle_option_action(option: SearchOption, cx: &mut AppContext) { +fn add_toggle_option_action(option: SearchOptions, cx: &mut AppContext) { cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { - if search_bar.update(cx, |search_bar, cx| search_bar.show(false, false, cx)) { - search_bar.update(cx, |search_bar, cx| { + search_bar.update(cx, |search_bar, cx| { + if search_bar.show(cx) { search_bar.toggle_search_option(option, cx); - }); - return; - } + } + }); } cx.propagate_action(); }); @@ -71,9 +72,8 @@ pub struct BufferSearchBar { searchable_items_with_matches: HashMap, Vec>>, pending_search: Option>, - case_sensitive: bool, - whole_word: bool, - regex: bool, + search_options: SearchOptions, + default_options: SearchOptions, query_contains_error: bool, dismissed: bool, } @@ -156,19 +156,19 @@ impl View for BufferSearchBar { .with_children(self.render_search_option( supported_options.case, "Case", - SearchOption::CaseSensitive, + SearchOptions::CASE_SENSITIVE, cx, )) .with_children(self.render_search_option( supported_options.word, "Word", - SearchOption::WholeWord, + SearchOptions::WHOLE_WORD, cx, )) .with_children(self.render_search_option( supported_options.regex, "Regex", - SearchOption::Regex, + SearchOptions::REGEX, cx, )) .contained() @@ -212,7 +212,7 @@ impl ToolbarItemView for BufferSearchBar { )); self.active_searchable_item = Some(searchable_item_handle); - self.update_matches(false, cx); + let _ = self.update_matches(cx); if !self.dismissed { return ToolbarItemLocation::Secondary; } @@ -253,9 +253,8 @@ impl BufferSearchBar { active_searchable_item_subscription: None, active_match_index: None, searchable_items_with_matches: Default::default(), - case_sensitive: false, - whole_word: false, - regex: false, + default_options: SearchOptions::NONE, + search_options: SearchOptions::NONE, pending_search: None, query_contains_error: false, dismissed: true, @@ -282,48 +281,86 @@ impl BufferSearchBar { cx.notify(); } - pub fn show(&mut self, focus: bool, suggest_query: bool, cx: &mut ViewContext) -> bool { - let searchable_item = if let Some(searchable_item) = &self.active_searchable_item { - SearchableItemHandle::boxed_clone(searchable_item.as_ref()) - } else { + pub fn show(&mut self, cx: &mut ViewContext) -> bool { + if self.active_searchable_item.is_none() { return false; - }; - - if suggest_query { - let text = searchable_item.query_suggestion(cx); - if !text.is_empty() { - self.set_query(&text, cx); - } } - - if focus { - let query_editor = self.query_editor.clone(); - query_editor.update(cx, |query_editor, cx| { - query_editor.select_all(&editor::SelectAll, cx); - }); - cx.focus_self(); - } - self.dismissed = false; cx.notify(); cx.emit(Event::UpdateLocation); true } - fn set_query(&mut self, query: &str, cx: &mut ViewContext) { + pub fn search_suggested(&mut self, cx: &mut ViewContext) { + let search = self + .query_suggestion(cx) + .map(|suggestion| self.search(&suggestion, Some(self.default_options), cx)); + + if let Some(search) = search { + cx.spawn(|this, mut cx| async move { + search.await?; + this.update(&mut cx, |this, cx| this.activate_current_match(cx)) + }) + .detach_and_log_err(cx); + } + } + + pub fn activate_current_match(&mut self, cx: &mut ViewContext) { + if let Some(match_ix) = self.active_match_index { + if let Some(active_searchable_item) = self.active_searchable_item.as_ref() { + if let Some(matches) = self + .searchable_items_with_matches + .get(&active_searchable_item.downgrade()) + { + active_searchable_item.activate_match(match_ix, matches, cx) + } + } + } + } + + pub fn select_query(&mut self, cx: &mut ViewContext) { self.query_editor.update(cx, |query_editor, cx| { - query_editor.buffer().update(cx, |query_buffer, cx| { - let len = query_buffer.len(cx); - query_buffer.edit([(0..len, query)], None, cx); - }); + query_editor.select_all(&Default::default(), cx); }); } + pub fn query(&self, cx: &WindowContext) -> String { + self.query_editor.read(cx).text(cx) + } + + pub fn query_suggestion(&mut self, cx: &mut ViewContext) -> Option { + self.active_searchable_item + .as_ref() + .map(|searchable_item| searchable_item.query_suggestion(cx)) + } + + pub fn search( + &mut self, + query: &str, + options: Option, + cx: &mut ViewContext, + ) -> oneshot::Receiver<()> { + let options = options.unwrap_or(self.default_options); + if query != self.query_editor.read(cx).text(cx) || self.search_options != options { + self.query_editor.update(cx, |query_editor, cx| { + query_editor.buffer().update(cx, |query_buffer, cx| { + let len = query_buffer.len(cx); + query_buffer.edit([(0..len, query)], None, cx); + }); + }); + self.search_options = options; + self.query_contains_error = false; + self.clear_matches(cx); + cx.notify(); + } + self.update_matches(cx) + } + fn render_search_option( &self, option_supported: bool, icon: &'static str, - option: SearchOption, + option: SearchOptions, cx: &mut ViewContext, ) -> Option> { if !option_supported { @@ -331,9 +368,9 @@ impl BufferSearchBar { } let tooltip_style = theme::current(cx).tooltip.clone(); - let is_active = self.is_search_option_enabled(option); + let is_active = self.search_options.contains(option); Some( - MouseEventHandler::::new(option as usize, cx, |state, cx| { + MouseEventHandler::::new(option.bits as usize, cx, |state, cx| { let theme = theme::current(cx); let style = theme .search @@ -349,7 +386,7 @@ impl BufferSearchBar { }) .with_cursor_style(CursorStyle::PointingHand) .with_tooltip::( - option as usize, + option.bits as usize, format!("Toggle {}", option.label()), Some(option.to_toggle_action()), tooltip_style, @@ -471,12 +508,23 @@ impl BufferSearchBar { } fn deploy(pane: &mut Pane, action: &Deploy, cx: &mut ViewContext) { + let mut propagate_action = true; if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { - if search_bar.update(cx, |search_bar, cx| search_bar.show(action.focus, true, cx)) { - return; - } + search_bar.update(cx, |search_bar, cx| { + if search_bar.show(cx) { + search_bar.search_suggested(cx); + if action.focus { + search_bar.select_query(cx); + cx.focus_self(); + } + propagate_action = false; + } + }); + } + + if propagate_action { + cx.propagate_action(); } - cx.propagate_action(); } fn handle_editor_cancel(pane: &mut Pane, _: &editor::Cancel, cx: &mut ViewContext) { @@ -489,41 +537,38 @@ impl BufferSearchBar { cx.propagate_action(); } - fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext) { + pub fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext) { if let Some(active_editor) = self.active_searchable_item.as_ref() { cx.focus(active_editor.as_any()); } } - fn is_search_option_enabled(&self, search_option: SearchOption) -> bool { - match search_option { - SearchOption::WholeWord => self.whole_word, - SearchOption::CaseSensitive => self.case_sensitive, - SearchOption::Regex => self.regex, - } + fn toggle_search_option(&mut self, search_option: SearchOptions, cx: &mut ViewContext) { + self.search_options.toggle(search_option); + self.default_options = self.search_options; + let _ = self.update_matches(cx); + cx.notify(); } - fn toggle_search_option(&mut self, search_option: SearchOption, cx: &mut ViewContext) { - let value = match search_option { - SearchOption::WholeWord => &mut self.whole_word, - SearchOption::CaseSensitive => &mut self.case_sensitive, - SearchOption::Regex => &mut self.regex, - }; - *value = !*value; - self.update_matches(false, cx); + pub fn set_search_options( + &mut self, + search_options: SearchOptions, + cx: &mut ViewContext, + ) { + self.search_options = search_options; cx.notify(); } fn select_next_match(&mut self, _: &SelectNextMatch, cx: &mut ViewContext) { - self.select_match(Direction::Next, cx); + self.select_match(Direction::Next, 1, cx); } fn select_prev_match(&mut self, _: &SelectPrevMatch, cx: &mut ViewContext) { - self.select_match(Direction::Prev, cx); + self.select_match(Direction::Prev, 1, cx); } fn select_all_matches(&mut self, _: &SelectAllMatches, cx: &mut ViewContext) { - if !self.dismissed { + if !self.dismissed && self.active_match_index.is_some() { if let Some(searchable_item) = self.active_searchable_item.as_ref() { if let Some(matches) = self .searchable_items_with_matches @@ -536,15 +581,15 @@ impl BufferSearchBar { } } - pub fn select_match(&mut self, direction: Direction, cx: &mut ViewContext) { + pub fn select_match(&mut self, direction: Direction, count: usize, cx: &mut ViewContext) { if let Some(index) = self.active_match_index { if let Some(searchable_item) = self.active_searchable_item.as_ref() { if let Some(matches) = self .searchable_items_with_matches .get(&searchable_item.downgrade()) { - let new_match_index = - searchable_item.match_index_for_direction(matches, index, direction, cx); + let new_match_index = searchable_item + .match_index_for_direction(matches, index, direction, count, cx); searchable_item.update_matches(matches, cx); searchable_item.activate_match(new_match_index, matches, cx); } @@ -588,17 +633,23 @@ impl BufferSearchBar { event: &editor::Event, cx: &mut ViewContext, ) { - if let editor::Event::BufferEdited { .. } = event { + if let editor::Event::Edited { .. } = event { self.query_contains_error = false; self.clear_matches(cx); - self.update_matches(true, cx); - cx.notify(); + let search = self.update_matches(cx); + cx.spawn(|this, mut cx| async move { + search.await?; + this.update(&mut cx, |this, cx| this.activate_current_match(cx)) + }) + .detach_and_log_err(cx); } } fn on_active_searchable_item_event(&mut self, event: SearchEvent, cx: &mut ViewContext) { match event { - SearchEvent::MatchesInvalidated => self.update_matches(false, cx), + SearchEvent::MatchesInvalidated => { + let _ = self.update_matches(cx); + } SearchEvent::ActiveMatchChanged => self.update_match_index(cx), } } @@ -621,19 +672,21 @@ impl BufferSearchBar { .extend(active_item_matches); } - fn update_matches(&mut self, select_closest_match: bool, cx: &mut ViewContext) { + fn update_matches(&mut self, cx: &mut ViewContext) -> oneshot::Receiver<()> { + let (done_tx, done_rx) = oneshot::channel(); let query = self.query_editor.read(cx).text(cx); self.pending_search.take(); if let Some(active_searchable_item) = self.active_searchable_item.as_ref() { if query.is_empty() { self.active_match_index.take(); active_searchable_item.clear_matches(cx); + let _ = done_tx.send(()); } else { - let query = if self.regex { + let query = if self.search_options.contains(SearchOptions::REGEX) { match SearchQuery::regex( query, - self.whole_word, - self.case_sensitive, + self.search_options.contains(SearchOptions::WHOLE_WORD), + self.search_options.contains(SearchOptions::CASE_SENSITIVE), Vec::new(), Vec::new(), ) { @@ -641,14 +694,14 @@ impl BufferSearchBar { Err(_) => { self.query_contains_error = true; cx.notify(); - return; + return done_rx; } } } else { SearchQuery::text( query, - self.whole_word, - self.case_sensitive, + self.search_options.contains(SearchOptions::WHOLE_WORD), + self.search_options.contains(SearchOptions::CASE_SENSITIVE), Vec::new(), Vec::new(), ) @@ -673,12 +726,7 @@ impl BufferSearchBar { .get(&active_searchable_item.downgrade()) .unwrap(); active_searchable_item.update_matches(matches, cx); - if select_closest_match { - if let Some(match_ix) = this.active_match_index { - active_searchable_item - .activate_match(match_ix, matches, cx); - } - } + let _ = done_tx.send(()); } cx.notify(); } @@ -687,6 +735,7 @@ impl BufferSearchBar { })); } } + done_rx } fn update_match_index(&mut self, cx: &mut ViewContext) { @@ -714,8 +763,7 @@ mod tests { use language::Buffer; use unindent::Unindent as _; - #[gpui::test] - async fn test_search_simple(cx: &mut TestAppContext) { + fn init_test(cx: &mut TestAppContext) -> (ViewHandle, ViewHandle) { crate::project_search::tests::init_test(cx); let buffer = cx.add_model(|cx| { @@ -738,16 +786,23 @@ mod tests { let search_bar = cx.add_view(window_id, |cx| { let mut search_bar = BufferSearchBar::new(cx); search_bar.set_active_pane_item(Some(&editor), cx); - search_bar.show(false, true, cx); + search_bar.show(cx); search_bar }); + (editor, search_bar) + } + + #[gpui::test] + async fn test_search_simple(cx: &mut TestAppContext) { + let (editor, search_bar) = init_test(cx); + // Search for a string that appears with different casing. // By default, search is case-insensitive. - search_bar.update(cx, |search_bar, cx| { - search_bar.set_query("us", cx); - }); - editor.next_notification(cx).await; + search_bar + .update(cx, |search_bar, cx| search_bar.search("us", None, cx)) + .await + .unwrap(); editor.update(cx, |editor, cx| { assert_eq!( editor.all_background_highlights(cx), @@ -766,7 +821,7 @@ mod tests { // Switch to a case sensitive search. search_bar.update(cx, |search_bar, cx| { - search_bar.toggle_search_option(SearchOption::CaseSensitive, cx); + search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx); }); editor.next_notification(cx).await; editor.update(cx, |editor, cx| { @@ -781,10 +836,10 @@ mod tests { // Search for a string that appears both as a whole word and // within other words. By default, all results are found. - search_bar.update(cx, |search_bar, cx| { - search_bar.set_query("or", cx); - }); - editor.next_notification(cx).await; + search_bar + .update(cx, |search_bar, cx| search_bar.search("or", None, cx)) + .await + .unwrap(); editor.update(cx, |editor, cx| { assert_eq!( editor.all_background_highlights(cx), @@ -823,7 +878,7 @@ mod tests { // Switch to a whole word search. search_bar.update(cx, |search_bar, cx| { - search_bar.toggle_search_option(SearchOption::WholeWord, cx); + search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx); }); editor.next_notification(cx).await; editor.update(cx, |editor, cx| { @@ -1025,6 +1080,65 @@ mod tests { }); } + #[gpui::test] + async fn test_search_option_handling(cx: &mut TestAppContext) { + let (editor, search_bar) = init_test(cx); + + // show with options should make current search case sensitive + search_bar + .update(cx, |search_bar, cx| { + search_bar.show(cx); + search_bar.search("us", Some(SearchOptions::CASE_SENSITIVE), cx) + }) + .await + .unwrap(); + editor.update(cx, |editor, cx| { + assert_eq!( + editor.all_background_highlights(cx), + &[( + DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45), + Color::red(), + )] + ); + }); + + // search_suggested should restore default options + search_bar.update(cx, |search_bar, cx| { + search_bar.search_suggested(cx); + assert_eq!(search_bar.search_options, SearchOptions::NONE) + }); + + // toggling a search option should update the defaults + search_bar + .update(cx, |search_bar, cx| { + search_bar.search("regex", Some(SearchOptions::CASE_SENSITIVE), cx) + }) + .await + .unwrap(); + search_bar.update(cx, |search_bar, cx| { + search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx) + }); + editor.next_notification(cx).await; + editor.update(cx, |editor, cx| { + assert_eq!( + editor.all_background_highlights(cx), + &[( + DisplayPoint::new(0, 35)..DisplayPoint::new(0, 40), + Color::red(), + ),] + ); + }); + + // defaults should still include whole word + search_bar.update(cx, |search_bar, cx| { + search_bar.search_suggested(cx); + assert_eq!( + search_bar.search_options, + SearchOptions::CASE_SENSITIVE | SearchOptions::WHOLE_WORD + ) + }); + } + #[gpui::test] async fn test_search_select_all_matches(cx: &mut TestAppContext) { crate::project_search::tests::init_test(cx); @@ -1052,15 +1166,25 @@ mod tests { let search_bar = cx.add_view(window_id, |cx| { let mut search_bar = BufferSearchBar::new(cx); search_bar.set_active_pane_item(Some(&editor), cx); - search_bar.show(false, true, cx); + search_bar.show(cx); search_bar }); + search_bar + .update(cx, |search_bar, cx| search_bar.search("a", None, cx)) + .await + .unwrap(); search_bar.update(cx, |search_bar, cx| { - search_bar.set_query("a", cx); + cx.focus(search_bar.query_editor.as_any()); + search_bar.activate_current_match(cx); }); - editor.next_notification(cx).await; + cx.read_window(window_id, |cx| { + assert!( + !editor.is_focused(cx), + "Initially, the editor should not be focused" + ); + }); let initial_selections = editor.update(cx, |editor, cx| { let initial_selections = editor.selections.display_ranges(cx); assert_eq!( @@ -1074,7 +1198,16 @@ mod tests { }); search_bar.update(cx, |search_bar, cx| { + cx.focus(search_bar.query_editor.as_any()); search_bar.select_all_matches(&SelectAllMatches, cx); + }); + cx.read_window(window_id, |cx| { + assert!( + editor.is_focused(cx), + "Should focus editor after successful SelectAllMatches" + ); + }); + search_bar.update(cx, |search_bar, cx| { let all_selections = editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)); assert_eq!( @@ -1082,8 +1215,6 @@ mod tests { expected_query_matches_count, "Should select all `a` characters in the buffer, but got: {all_selections:?}" ); - }); - search_bar.update(cx, |search_bar, _| { assert_eq!( search_bar.active_match_index, Some(0), @@ -1093,6 +1224,14 @@ mod tests { search_bar.update(cx, |search_bar, cx| { search_bar.select_next_match(&SelectNextMatch, cx); + }); + cx.read_window(window_id, |cx| { + assert!( + editor.is_focused(cx), + "Should still have editor focused after SelectNextMatch" + ); + }); + search_bar.update(cx, |search_bar, cx| { let all_selections = editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)); assert_eq!( @@ -1104,8 +1243,6 @@ mod tests { all_selections, initial_selections, "Next match should be different from the first selection" ); - }); - search_bar.update(cx, |search_bar, _| { assert_eq!( search_bar.active_match_index, Some(1), @@ -1114,7 +1251,16 @@ mod tests { }); search_bar.update(cx, |search_bar, cx| { + cx.focus(search_bar.query_editor.as_any()); search_bar.select_all_matches(&SelectAllMatches, cx); + }); + cx.read_window(window_id, |cx| { + assert!( + editor.is_focused(cx), + "Should focus editor after successful SelectAllMatches" + ); + }); + search_bar.update(cx, |search_bar, cx| { let all_selections = editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)); assert_eq!( @@ -1122,8 +1268,6 @@ mod tests { expected_query_matches_count, "Should select all `a` characters in the buffer, but got: {all_selections:?}" ); - }); - search_bar.update(cx, |search_bar, _| { assert_eq!( search_bar.active_match_index, Some(1), @@ -1133,6 +1277,14 @@ mod tests { search_bar.update(cx, |search_bar, cx| { search_bar.select_prev_match(&SelectPrevMatch, cx); + }); + cx.read_window(window_id, |cx| { + assert!( + editor.is_focused(cx), + "Should still have editor focused after SelectPrevMatch" + ); + }); + let last_match_selections = search_bar.update(cx, |search_bar, cx| { let all_selections = editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)); assert_eq!( @@ -1144,13 +1296,41 @@ mod tests { all_selections, initial_selections, "Previous match should be the same as the first selection" ); - }); - search_bar.update(cx, |search_bar, _| { assert_eq!( search_bar.active_match_index, Some(0), "Match index should be updated to the previous one" ); + all_selections + }); + + search_bar + .update(cx, |search_bar, cx| { + cx.focus(search_bar.query_editor.as_any()); + search_bar.search("abas_nonexistent_match", None, cx) + }) + .await + .unwrap(); + search_bar.update(cx, |search_bar, cx| { + search_bar.select_all_matches(&SelectAllMatches, cx); + }); + cx.read_window(window_id, |cx| { + assert!( + !editor.is_focused(cx), + "Should not switch focus to editor if SelectAllMatches does not find any matches" + ); + }); + search_bar.update(cx, |search_bar, cx| { + let all_selections = + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)); + assert_eq!( + all_selections, last_match_selections, + "Should not select anything new if there are no matches" + ); + assert!( + search_bar.active_match_index.is_none(), + "For no matches, there should be no active match index" + ); }); } } diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index ec9108f92c..52ee12c26d 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -1,5 +1,5 @@ use crate::{ - SearchOption, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex, + SearchOptions, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex, ToggleWholeWord, }; use anyhow::Result; @@ -19,7 +19,7 @@ use gpui::{ }; use menu::Confirm; use postage::stream::Stream; -use project::{search::SearchQuery, Project}; +use project::{search::SearchQuery, Entry, Project}; use semantic_index::SemanticIndex; use smallvec::SmallVec; use std::{ @@ -56,12 +56,12 @@ pub fn init(cx: &mut AppContext) { cx.add_action(ProjectSearchBar::select_prev_match); cx.capture_action(ProjectSearchBar::tab); cx.capture_action(ProjectSearchBar::tab_previous); - add_toggle_option_action::(SearchOption::CaseSensitive, cx); - add_toggle_option_action::(SearchOption::WholeWord, cx); - add_toggle_option_action::(SearchOption::Regex, cx); + add_toggle_option_action::(SearchOptions::CASE_SENSITIVE, cx); + add_toggle_option_action::(SearchOptions::WHOLE_WORD, cx); + add_toggle_option_action::(SearchOptions::REGEX, cx); } -fn add_toggle_option_action(option: SearchOption, cx: &mut AppContext) { +fn add_toggle_option_action(option: SearchOptions, cx: &mut AppContext) { cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { if search_bar.update(cx, |search_bar, cx| { @@ -94,10 +94,8 @@ pub struct ProjectSearchView { model: ModelHandle, query_editor: ViewHandle, results_editor: ViewHandle, - case_sensitive: bool, - whole_word: bool, - regex: bool, semantic: Option, + search_options: SearchOptions, panels_with_errors: HashSet, active_match_index: Option, search_id: usize, @@ -488,9 +486,7 @@ impl ProjectSearchView { let project; let excerpts; let mut query_text = String::new(); - let mut regex = false; - let mut case_sensitive = false; - let mut whole_word = false; + let mut options = SearchOptions::NONE; { let model = model.read(cx); @@ -498,9 +494,7 @@ impl ProjectSearchView { excerpts = model.excerpts.clone(); if let Some(active_query) = model.active_query.as_ref() { query_text = active_query.as_str().to_string(); - regex = active_query.is_regex(); - case_sensitive = active_query.case_sensitive(); - whole_word = active_query.whole_word(); + options = SearchOptions::from_query(active_query); } } cx.observe(&model, |this, _, cx| this.model_changed(cx)) @@ -576,10 +570,8 @@ impl ProjectSearchView { model, query_editor, results_editor, - case_sensitive, - whole_word, - regex, semantic: None, + search_options: options, panels_with_errors: HashSet::new(), active_match_index: None, query_editor_was_focused: false, @@ -590,6 +582,28 @@ impl ProjectSearchView { this } + pub fn new_search_in_directory( + workspace: &mut Workspace, + dir_entry: &Entry, + cx: &mut ViewContext, + ) { + if !dir_entry.is_dir() { + return; + } + let filter_path = dir_entry.path.join("**"); + let Some(filter_str) = filter_path.to_str() else { return; }; + + let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx)); + let search = cx.add_view(|cx| ProjectSearchView::new(model, cx)); + workspace.add_item(Box::new(search.clone()), cx); + search.update(cx, |search, cx| { + search + .included_files_editor + .update(cx, |editor, cx| editor.set_text(filter_str, cx)); + search.focus_query_editor(cx) + }); + } + // Re-activate the most recently activated search or the most recent if it has been closed. // If no search exists in the workspace, create a new one. fn deploy( @@ -723,11 +737,11 @@ impl ProjectSearchView { return None; } }; - if self.regex { + if self.search_options.contains(SearchOptions::REGEX) { match SearchQuery::regex( text, - self.whole_word, - self.case_sensitive, + self.search_options.contains(SearchOptions::WHOLE_WORD), + self.search_options.contains(SearchOptions::CASE_SENSITIVE), included_files, excluded_files, ) { @@ -744,8 +758,8 @@ impl ProjectSearchView { } else { Some(SearchQuery::text( text, - self.whole_word, - self.case_sensitive, + self.search_options.contains(SearchOptions::WHOLE_WORD), + self.search_options.contains(SearchOptions::CASE_SENSITIVE), included_files, excluded_files, )) @@ -764,7 +778,7 @@ impl ProjectSearchView { if let Some(index) = self.active_match_index { let match_ranges = self.model.read(cx).match_ranges.clone(); let new_index = self.results_editor.update(cx, |editor, cx| { - editor.match_index_for_direction(&match_ranges, index, direction, cx) + editor.match_index_for_direction(&match_ranges, index, direction, 1, cx) }); let range_to_select = match_ranges[new_index].clone(); @@ -805,7 +819,6 @@ impl ProjectSearchView { self.active_match_index = None; } else { self.active_match_index = Some(0); - self.select_match(Direction::Next, cx); self.update_match_index(cx); let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id); let is_new_search = self.search_id != prev_search_id; @@ -897,9 +910,7 @@ impl ProjectSearchBar { search_view.query_editor.update(cx, |editor, cx| { editor.set_text(old_query.as_str(), cx); }); - search_view.regex = old_query.is_regex(); - search_view.whole_word = old_query.whole_word(); - search_view.case_sensitive = old_query.case_sensitive(); + search_view.search_options = SearchOptions::from_query(&old_query); } } new_query @@ -987,19 +998,11 @@ impl ProjectSearchBar { }); } - fn toggle_search_option(&mut self, option: SearchOption, cx: &mut ViewContext) -> bool { + fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext) -> bool { if let Some(search_view) = self.active_project_search.as_ref() { search_view.update(cx, |search_view, cx| { - let value = match option { - SearchOption::WholeWord => &mut search_view.whole_word, - SearchOption::CaseSensitive => &mut search_view.case_sensitive, - SearchOption::Regex => &mut search_view.regex, - }; - *value = !*value; - - if value.clone() { - search_view.semantic = None; - } + search_view.search_options.toggle(option); + search_view.semantic = None; search_view.search(cx); }); cx.notify(); @@ -1016,9 +1019,7 @@ impl ProjectSearchBar { search_view.semantic = None; } else if let Some(semantic_index) = SemanticIndex::global(cx) { // TODO: confirm that it's ok to send this project - search_view.regex = false; - search_view.case_sensitive = false; - search_view.whole_word = false; + search_view.search_options = SearchOptions::none(); let project = search_view.model.read(cx).project.clone(); let index_task = semantic_index.update(cx, |semantic_index, cx| { @@ -1113,12 +1114,12 @@ impl ProjectSearchBar { fn render_option_button( &self, icon: &'static str, - option: SearchOption, + option: SearchOptions, cx: &mut ViewContext, ) -> AnyElement { let tooltip_style = theme::current(cx).tooltip.clone(); let is_active = self.is_option_enabled(option, cx); - MouseEventHandler::::new(option as usize, cx, |state, cx| { + MouseEventHandler::::new(option.bits as usize, cx, |state, cx| { let theme = theme::current(cx); let style = theme .search @@ -1134,7 +1135,7 @@ impl ProjectSearchBar { }) .with_cursor_style(CursorStyle::PointingHand) .with_tooltip::( - option as usize, + option.bits as usize, format!("Toggle {}", option.label()), Some(option.to_toggle_action()), tooltip_style, @@ -1179,14 +1180,9 @@ impl ProjectSearchBar { .into_any() } - fn is_option_enabled(&self, option: SearchOption, cx: &AppContext) -> bool { + fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool { if let Some(search) = self.active_project_search.as_ref() { - let search = search.read(cx); - match option { - SearchOption::WholeWord => search.whole_word, - SearchOption::CaseSensitive => search.case_sensitive, - SearchOption::Regex => search.regex, - } + search.read(cx).search_options.contains(option) } else { false } @@ -1283,17 +1279,17 @@ impl View for ProjectSearchBar { let row = row .with_child(self.render_option_button( "Case", - SearchOption::CaseSensitive, + SearchOptions::CASE_SENSITIVE, cx, )) .with_child(self.render_option_button( "Word", - SearchOption::WholeWord, + SearchOptions::WHOLE_WORD, cx, )) .with_child(self.render_option_button( "Regex", - SearchOption::Regex, + SearchOptions::REGEX, cx, )) .contained() @@ -1669,6 +1665,134 @@ pub mod tests { }); } + #[gpui::test] + async fn test_new_project_search_in_directory( + deterministic: Arc, + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "a": { + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = one::ONE + one::ONE;", + }, + "b": { + "three.rs": "const THREE: usize = one::ONE + two::TWO;", + "four.rs": "const FOUR: usize = one::ONE + three::THREE;", + }, + }), + ) + .await; + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); + + let active_item = cx.read(|cx| { + workspace + .read(cx) + .active_pane() + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + }); + assert!( + active_item.is_none(), + "Expected no search panel to be active, but got: {active_item:?}" + ); + + let one_file_entry = cx.update(|cx| { + workspace + .read(cx) + .project() + .read(cx) + .entry_for_path(&(worktree_id, "a/one.rs").into(), cx) + .expect("no entry for /a/one.rs file") + }); + assert!(one_file_entry.is_file()); + workspace.update(cx, |workspace, cx| { + ProjectSearchView::new_search_in_directory(workspace, &one_file_entry, cx) + }); + let active_search_entry = cx.read(|cx| { + workspace + .read(cx) + .active_pane() + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + }); + assert!( + active_search_entry.is_none(), + "Expected no search panel to be active for file entry" + ); + + let a_dir_entry = cx.update(|cx| { + workspace + .read(cx) + .project() + .read(cx) + .entry_for_path(&(worktree_id, "a").into(), cx) + .expect("no entry for /a/ directory") + }); + assert!(a_dir_entry.is_dir()); + workspace.update(cx, |workspace, cx| { + ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry, cx) + }); + + let Some(search_view) = cx.read(|cx| { + workspace + .read(cx) + .active_pane() + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + }) else { + panic!("Search view expected to appear after new search in directory event trigger") + }; + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert!( + search_view.query_editor.is_focused(cx), + "On new search in directory, focus should be moved into query editor" + ); + search_view.excluded_files_editor.update(cx, |editor, cx| { + assert!( + editor.display_text(cx).is_empty(), + "New search in directory should not have any excluded files" + ); + }); + search_view.included_files_editor.update(cx, |editor, cx| { + assert_eq!( + editor.display_text(cx), + a_dir_entry.path.join("**").display().to_string(), + "New search in directory should have included dir entry path" + ); + }); + }); + + search_view.update(cx, |search_view, cx| { + search_view + .query_editor + .update(cx, |query_editor, cx| query_editor.set_text("const", cx)); + search_view.search(cx); + }); + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert_eq!( + search_view + .results_editor + .update(cx, |editor, cx| editor.display_text(cx)), + "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;", + "New search in directory should have a filter that matches a certain directory" + ); + }); + } + pub fn init_test(cx: &mut TestAppContext) { cx.foreground().forbid_parking(); let fonts = cx.font_cache(); diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index 7080b4c07e..58cda0c7dc 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -1,5 +1,7 @@ +use bitflags::bitflags; pub use buffer_search::BufferSearchBar; use gpui::{actions, Action, AppContext}; +use project::search::SearchQuery; pub use project_search::{ProjectSearchBar, ProjectSearchView}; pub mod buffer_search; @@ -22,27 +24,44 @@ actions!( ] ); -#[derive(Clone, Copy, PartialEq)] -pub enum SearchOption { - WholeWord, - CaseSensitive, - Regex, +bitflags! { + #[derive(Default)] + pub struct SearchOptions: u8 { + const NONE = 0b000; + const WHOLE_WORD = 0b001; + const CASE_SENSITIVE = 0b010; + const REGEX = 0b100; + } } -impl SearchOption { +impl SearchOptions { pub fn label(&self) -> &'static str { - match self { - SearchOption::WholeWord => "Match Whole Word", - SearchOption::CaseSensitive => "Match Case", - SearchOption::Regex => "Use Regular Expression", + match *self { + SearchOptions::WHOLE_WORD => "Match Whole Word", + SearchOptions::CASE_SENSITIVE => "Match Case", + SearchOptions::REGEX => "Use Regular Expression", + _ => panic!("{:?} is not a named SearchOption", self), } } pub fn to_toggle_action(&self) -> Box { - match self { - SearchOption::WholeWord => Box::new(ToggleWholeWord), - SearchOption::CaseSensitive => Box::new(ToggleCaseSensitive), - SearchOption::Regex => Box::new(ToggleRegex), + match *self { + SearchOptions::WHOLE_WORD => Box::new(ToggleWholeWord), + SearchOptions::CASE_SENSITIVE => Box::new(ToggleCaseSensitive), + SearchOptions::REGEX => Box::new(ToggleRegex), + _ => panic!("{:?} is not a named SearchOption", self), } } + + pub fn none() -> SearchOptions { + SearchOptions::NONE + } + + pub fn from_query(query: &SearchQuery) -> SearchOptions { + let mut options = SearchOptions::NONE; + options.set(SearchOptions::WHOLE_WORD, query.whole_word()); + options.set(SearchOptions::CASE_SENSITIVE, query.case_sensitive()); + options.set(SearchOptions::REGEX, query.is_regex()); + options + } } diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 39e77b590b..e3109102d1 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -51,7 +51,7 @@ use gpui::{ fonts, geometry::vector::{vec2f, Vector2F}, keymap_matcher::Keystroke, - platform::{MouseButton, MouseMovedEvent, TouchPhase}, + platform::{Modifiers, MouseButton, MouseMovedEvent, TouchPhase}, scene::{MouseDown, MouseDrag, MouseScrollWheel, MouseUp}, AppContext, ClipboardItem, Entity, ModelContext, Task, }; @@ -72,14 +72,17 @@ const DEBUG_TERMINAL_HEIGHT: f32 = 30.; const DEBUG_CELL_WIDTH: f32 = 5.; const DEBUG_LINE_HEIGHT: f32 = 5.; -// Regex Copied from alacritty's ui_config.rs - lazy_static! { - static ref URL_REGEX: RegexSearch = RegexSearch::new("(ipfs:|ipns:|magnet:|mailto:|gemini:|gopher:|https:|http:|news:|file:|git:|ssh:|ftp:)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`]+").unwrap(); + // Regex Copied from alacritty's ui_config.rs and modified its declaration slightly: + // * avoid Rust-specific escaping. + // * use more strict regex for `file://` protocol matching: original regex has `file:` inside, but we want to avoid matching `some::file::module` strings. + static ref URL_REGEX: RegexSearch = RegexSearch::new(r#"(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file://|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>"\s{-}\^⟨⟩`]+"#).unwrap(); + + static ref WORD_REGEX: RegexSearch = RegexSearch::new("[\\w.:/@-~]+").unwrap(); } ///Upward flowing events, for changing the title and such -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub enum Event { TitleChanged, BreadcrumbsChanged, @@ -88,6 +91,18 @@ pub enum Event { Wakeup, BlinkChanged, SelectionsChanged, + NewNavigationTarget(Option), + Open(MaybeNavigationTarget), +} + +/// A string inside terminal, potentially useful as a URI that can be opened. +#[derive(Clone, Debug)] +pub enum MaybeNavigationTarget { + /// HTTP, git, etc. string determined by the [`URL_REGEX`] regex. + Url(String), + /// File system path, absolute or relative, existing or not. + /// Might have line and column number(s) attached as `file.rs:1:23` + PathLike(String), } #[derive(Clone)] @@ -493,6 +508,8 @@ impl TerminalBuilder { last_mouse_position: None, next_link_id: 0, selection_phase: SelectionPhase::Ended, + cmd_pressed: false, + hovered_word: false, }; Ok(TerminalBuilder { @@ -589,7 +606,14 @@ pub struct TerminalContent { pub cursor: RenderableCursor, pub cursor_char: char, pub size: TerminalSize, - pub last_hovered_hyperlink: Option<(String, RangeInclusive, usize)>, + pub last_hovered_word: Option, +} + +#[derive(Clone)] +pub struct HoveredWord { + pub word: String, + pub word_match: RangeInclusive, + pub id: usize, } impl Default for TerminalContent { @@ -606,7 +630,7 @@ impl Default for TerminalContent { }, cursor_char: Default::default(), size: Default::default(), - last_hovered_hyperlink: None, + last_hovered_word: None, } } } @@ -623,7 +647,7 @@ pub struct Terminal { events: VecDeque, /// This is only used for mouse mode cell change detection last_mouse: Option<(Point, AlacDirection)>, - /// This is only used for terminal hyperlink checking + /// This is only used for terminal hovered word checking last_mouse_position: Option, pub matches: Vec>, pub last_content: TerminalContent, @@ -637,6 +661,8 @@ pub struct Terminal { scroll_px: f32, next_link_id: usize, selection_phase: SelectionPhase, + cmd_pressed: bool, + hovered_word: bool, } impl Terminal { @@ -769,7 +795,7 @@ impl Terminal { } InternalEvent::Scroll(scroll) => { term.scroll_display(*scroll); - self.refresh_hyperlink(); + self.refresh_hovered_word(); } InternalEvent::SetSelection(selection) => { term.selection = selection.as_ref().map(|(sel, _)| sel.clone()); @@ -804,20 +830,20 @@ impl Terminal { } InternalEvent::ScrollToPoint(point) => { term.scroll_to_point(*point); - self.refresh_hyperlink(); + self.refresh_hovered_word(); } InternalEvent::FindHyperlink(position, open) => { - let prev_hyperlink = self.last_content.last_hovered_hyperlink.take(); + let prev_hovered_word = self.last_content.last_hovered_word.take(); let point = grid_point( *position, self.last_content.size, term.grid().display_offset(), ) - .grid_clamp(term, alacritty_terminal::index::Boundary::Cursor); + .grid_clamp(term, alacritty_terminal::index::Boundary::Grid); let link = term.grid().index(point).hyperlink(); - let found_url = if link.is_some() { + let found_word = if link.is_some() { let mut min_index = point; loop { let new_min_index = @@ -847,42 +873,80 @@ impl Terminal { let url = link.unwrap().uri().to_owned(); let url_match = min_index..=max_index; - Some((url, url_match)) - } else if let Some(url_match) = regex_match_at(term, point, &URL_REGEX) { - let url = term.bounds_to_string(*url_match.start(), *url_match.end()); - - Some((url, url_match)) + Some((url, true, url_match)) + } else if let Some(word_match) = regex_match_at(term, point, &WORD_REGEX) { + let maybe_url_or_path = + term.bounds_to_string(*word_match.start(), *word_match.end()); + let is_url = match regex_match_at(term, point, &URL_REGEX) { + Some(url_match) => url_match == word_match, + None => false, + }; + Some((maybe_url_or_path, is_url, word_match)) } else { None }; - if let Some((url, url_match)) = found_url { - if *open { - cx.platform().open_url(url.as_str()); - } else { - self.update_hyperlink(prev_hyperlink, url, url_match); + match found_word { + Some((maybe_url_or_path, is_url, url_match)) => { + if *open { + let target = if is_url { + MaybeNavigationTarget::Url(maybe_url_or_path) + } else { + MaybeNavigationTarget::PathLike(maybe_url_or_path) + }; + cx.emit(Event::Open(target)); + } else { + self.update_selected_word( + prev_hovered_word, + url_match, + maybe_url_or_path, + is_url, + cx, + ); + } + self.hovered_word = true; + } + None => { + if self.hovered_word { + cx.emit(Event::NewNavigationTarget(None)); + } + self.hovered_word = false; } } } } } - fn update_hyperlink( + fn update_selected_word( &mut self, - prev_hyperlink: Option<(String, RangeInclusive, usize)>, - url: String, - url_match: RangeInclusive, + prev_word: Option, + word_match: RangeInclusive, + word: String, + is_url: bool, + cx: &mut ModelContext, ) { - if let Some(prev_hyperlink) = prev_hyperlink { - if prev_hyperlink.0 == url && prev_hyperlink.1 == url_match { - self.last_content.last_hovered_hyperlink = Some((url, url_match, prev_hyperlink.2)); - } else { - self.last_content.last_hovered_hyperlink = - Some((url, url_match, self.next_link_id())); + if let Some(prev_word) = prev_word { + if prev_word.word == word && prev_word.word_match == word_match { + self.last_content.last_hovered_word = Some(HoveredWord { + word, + word_match, + id: prev_word.id, + }); + return; } - } else { - self.last_content.last_hovered_hyperlink = Some((url, url_match, self.next_link_id())); } + + self.last_content.last_hovered_word = Some(HoveredWord { + word: word.clone(), + word_match, + id: self.next_link_id(), + }); + let navigation_target = if is_url { + MaybeNavigationTarget::Url(word) + } else { + MaybeNavigationTarget::PathLike(word) + }; + cx.emit(Event::NewNavigationTarget(Some(navigation_target))); } fn next_link_id(&mut self) -> usize { @@ -964,6 +1028,15 @@ impl Terminal { } } + pub fn try_modifiers_change(&mut self, modifiers: &Modifiers) -> bool { + let changed = self.cmd_pressed != modifiers.cmd; + if !self.cmd_pressed && modifiers.cmd { + self.refresh_hovered_word(); + } + self.cmd_pressed = modifiers.cmd; + changed + } + ///Paste text into the terminal pub fn paste(&mut self, text: &str) { let paste_text = if self.last_content.mode.contains(TermMode::BRACKETED_PASTE) { @@ -1035,7 +1108,7 @@ impl Terminal { cursor: content.cursor, cursor_char: term.grid()[content.cursor.point].c, size: last_content.size, - last_hovered_hyperlink: last_content.last_hovered_hyperlink.clone(), + last_hovered_word: last_content.last_hovered_word.clone(), } } @@ -1089,14 +1162,14 @@ impl Terminal { self.pty_tx.notify(bytes); } } - } else { - self.hyperlink_from_position(Some(position)); + } else if self.cmd_pressed { + self.word_from_position(Some(position)); } } - fn hyperlink_from_position(&mut self, position: Option) { + fn word_from_position(&mut self, position: Option) { if self.selection_phase == SelectionPhase::Selecting { - self.last_content.last_hovered_hyperlink = None; + self.last_content.last_hovered_word = None; } else if let Some(position) = position { self.events .push_back(InternalEvent::FindHyperlink(position, false)); @@ -1208,7 +1281,7 @@ impl Terminal { let mouse_cell_index = content_index_for_mouse(position, &self.last_content.size); if let Some(link) = self.last_content.cells[mouse_cell_index].hyperlink() { cx.platform().open_url(link.uri()); - } else { + } else if self.cmd_pressed { self.events .push_back(InternalEvent::FindHyperlink(position, true)); } @@ -1255,8 +1328,8 @@ impl Terminal { } } - pub fn refresh_hyperlink(&mut self) { - self.hyperlink_from_position(self.last_mouse_position); + fn refresh_hovered_word(&mut self) { + self.word_from_position(self.last_mouse_position); } fn determine_scroll_lines(&mut self, e: &MouseScrollWheel, mouse_mode: bool) -> Option { @@ -1334,6 +1407,10 @@ impl Terminal { }) .unwrap_or_else(|| "Terminal".to_string()) } + + pub fn can_navigate_to_selected_word(&self) -> bool { + self.cmd_pressed && self.hovered_word + } } impl Drop for Terminal { diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index b92059f5d6..e29beb3ad5 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -163,6 +163,7 @@ pub struct TerminalElement { terminal: WeakModelHandle, focused: bool, cursor_visible: bool, + can_navigate_to_selected_word: bool, } impl TerminalElement { @@ -170,11 +171,13 @@ impl TerminalElement { terminal: WeakModelHandle, focused: bool, cursor_visible: bool, + can_navigate_to_selected_word: bool, ) -> TerminalElement { TerminalElement { terminal, focused, cursor_visible, + can_navigate_to_selected_word, } } @@ -580,20 +583,30 @@ impl Element for TerminalElement { let background_color = terminal_theme.background; let terminal_handle = self.terminal.upgrade(cx).unwrap(); - let last_hovered_hyperlink = terminal_handle.update(cx, |terminal, cx| { + let last_hovered_word = terminal_handle.update(cx, |terminal, cx| { terminal.set_size(dimensions); terminal.try_sync(cx); - terminal.last_content.last_hovered_hyperlink.clone() + if self.can_navigate_to_selected_word && terminal.can_navigate_to_selected_word() { + terminal.last_content.last_hovered_word.clone() + } else { + None + } }); - let hyperlink_tooltip = last_hovered_hyperlink.map(|(uri, _, id)| { + let hyperlink_tooltip = last_hovered_word.clone().map(|hovered_word| { let mut tooltip = Overlay::new( Empty::new() .contained() .constrained() .with_width(dimensions.width()) .with_height(dimensions.height()) - .with_tooltip::(id, uri, None, tooltip_style, cx), + .with_tooltip::( + hovered_word.id, + hovered_word.word, + None, + tooltip_style, + cx, + ), ) .with_position_mode(gpui::elements::OverlayPositionMode::Local) .into_any(); @@ -613,7 +626,6 @@ impl Element for TerminalElement { cursor_char, selection, cursor, - last_hovered_hyperlink, .. } = { &terminal_handle.read(cx).last_content }; @@ -634,9 +646,9 @@ impl Element for TerminalElement { &terminal_theme, cx.text_layout_cache(), cx.font_cache(), - last_hovered_hyperlink + last_hovered_word .as_ref() - .map(|(_, range, _)| (link_style, range)), + .map(|last_hovered_word| (link_style, &last_hovered_word.word_match)), ); //Layout cursor. Rectangle is used for IME, so we should lay it out even diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index ad61903a9d..6ad321c735 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -261,10 +261,14 @@ impl TerminalPanel { .create_terminal(working_directory, window_id, cx) .log_err() }) { - let terminal = - Box::new(cx.add_view(|cx| { - TerminalView::new(terminal, workspace.database_id(), cx) - })); + let terminal = Box::new(cx.add_view(|cx| { + TerminalView::new( + terminal, + workspace.weak_handle(), + workspace.database_id(), + cx, + ) + })); pane.update(cx, |pane, cx| { let focus = pane.has_focus(); pane.add_item(terminal, true, focus, None, cx); diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 3dd401e392..e108a05ccc 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -3,18 +3,21 @@ pub mod terminal_element; pub mod terminal_panel; use crate::{persistence::TERMINAL_DB, terminal_element::TerminalElement}; +use anyhow::Context; use context_menu::{ContextMenu, ContextMenuItem}; use dirs::home_dir; +use editor::{scroll::autoscroll::Autoscroll, Editor}; use gpui::{ actions, elements::{AnchorCorner, ChildView, Flex, Label, ParentElement, Stack}, geometry::vector::Vector2F, impl_actions, keymap_matcher::{KeymapContext, Keystroke}, - platform::KeyDownEvent, + platform::{KeyDownEvent, ModifiersChangedEvent}, AnyElement, AnyViewHandle, AppContext, Element, Entity, ModelHandle, Task, View, ViewContext, ViewHandle, WeakViewHandle, }; +use language::Bias; use project::{LocalWorktree, Project}; use serde::Deserialize; use smallvec::{smallvec, SmallVec}; @@ -30,9 +33,9 @@ use terminal::{ index::Point, term::{search::RegexSearch, TermMode}, }, - Event, Terminal, TerminalBlink, WorkingDirectory, + Event, MaybeNavigationTarget, Terminal, TerminalBlink, WorkingDirectory, }; -use util::ResultExt; +use util::{paths::PathLikeWithPosition, ResultExt}; use workspace::{ item::{BreadcrumbText, Item, ItemEvent}, notifications::NotifyResultExt, @@ -90,6 +93,7 @@ pub struct TerminalView { blinking_on: bool, blinking_paused: bool, blink_epoch: usize, + can_navigate_to_selected_word: bool, workspace_id: WorkspaceId, } @@ -117,19 +121,27 @@ impl TerminalView { .notify_err(workspace, cx); if let Some(terminal) = terminal { - let view = cx.add_view(|cx| TerminalView::new(terminal, workspace.database_id(), cx)); + let view = cx.add_view(|cx| { + TerminalView::new( + terminal, + workspace.weak_handle(), + workspace.database_id(), + cx, + ) + }); workspace.add_item(Box::new(view), cx) } } pub fn new( terminal: ModelHandle, + workspace: WeakViewHandle, workspace_id: WorkspaceId, cx: &mut ViewContext, ) -> Self { let view_id = cx.view_id(); cx.observe(&terminal, |_, _, cx| cx.notify()).detach(); - cx.subscribe(&terminal, |this, _, event, cx| match event { + cx.subscribe(&terminal, move |this, _, event, cx| match event { Event::Wakeup => { if !cx.is_self_focused() { this.has_new_content = true; @@ -158,7 +170,82 @@ impl TerminalView { .detach(); } } - _ => cx.emit(*event), + Event::NewNavigationTarget(maybe_navigation_target) => { + this.can_navigate_to_selected_word = match maybe_navigation_target { + Some(MaybeNavigationTarget::Url(_)) => true, + Some(MaybeNavigationTarget::PathLike(maybe_path)) => { + !possible_open_targets(&workspace, maybe_path, cx).is_empty() + } + None => false, + } + } + Event::Open(maybe_navigation_target) => match maybe_navigation_target { + MaybeNavigationTarget::Url(url) => cx.platform().open_url(url), + MaybeNavigationTarget::PathLike(maybe_path) => { + if !this.can_navigate_to_selected_word { + return; + } + let potential_abs_paths = possible_open_targets(&workspace, maybe_path, cx); + if let Some(path) = potential_abs_paths.into_iter().next() { + let is_dir = path.path_like.is_dir(); + let task_workspace = workspace.clone(); + cx.spawn(|_, mut cx| async move { + let opened_items = task_workspace + .update(&mut cx, |workspace, cx| { + workspace.open_paths(vec![path.path_like], is_dir, cx) + }) + .context("workspace update")? + .await; + anyhow::ensure!( + opened_items.len() == 1, + "For a single path open, expected single opened item" + ); + let opened_item = opened_items + .into_iter() + .next() + .unwrap() + .transpose() + .context("path open")?; + if is_dir { + task_workspace.update(&mut cx, |workspace, cx| { + workspace.project().update(cx, |_, cx| { + cx.emit(project::Event::ActivateProjectPanel); + }) + })?; + } else { + if let Some(row) = path.row { + let col = path.column.unwrap_or(0); + if let Some(active_editor) = + opened_item.and_then(|item| item.downcast::()) + { + active_editor + .downgrade() + .update(&mut cx, |editor, cx| { + let snapshot = editor.snapshot(cx).display_snapshot; + let point = snapshot.buffer_snapshot.clip_point( + language::Point::new( + row.saturating_sub(1), + col.saturating_sub(1), + ), + Bias::Left, + ); + editor.change_selections( + Some(Autoscroll::center()), + cx, + |s| s.select_ranges([point..point]), + ); + }) + .log_err(); + } + } + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + } + }, + _ => cx.emit(event.clone()), }) .detach(); @@ -171,6 +258,7 @@ impl TerminalView { blinking_on: false, blinking_paused: false, blink_epoch: 0, + can_navigate_to_selected_word: false, workspace_id, } } @@ -344,6 +432,50 @@ impl TerminalView { } } +fn possible_open_targets( + workspace: &WeakViewHandle, + maybe_path: &String, + cx: &mut ViewContext<'_, '_, TerminalView>, +) -> Vec> { + let path_like = PathLikeWithPosition::parse_str(maybe_path.as_str(), |path_str| { + Ok::<_, std::convert::Infallible>(Path::new(path_str).to_path_buf()) + }) + .expect("infallible"); + let maybe_path = path_like.path_like; + let potential_abs_paths = if maybe_path.is_absolute() { + vec![maybe_path] + } else if maybe_path.starts_with("~") { + if let Some(abs_path) = maybe_path + .strip_prefix("~") + .ok() + .and_then(|maybe_path| Some(dirs::home_dir()?.join(maybe_path))) + { + vec![abs_path] + } else { + Vec::new() + } + } else if let Some(workspace) = workspace.upgrade(cx) { + workspace.update(cx, |workspace, cx| { + workspace + .worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().join(&maybe_path)) + .collect() + }) + } else { + Vec::new() + }; + + potential_abs_paths + .into_iter() + .filter(|path| path.exists()) + .map(|path| PathLikeWithPosition { + path_like: path, + row: path_like.row, + column: path_like.column, + }) + .collect() +} + pub fn regex_search_for_query(query: project::search::SearchQuery) -> Option { let searcher = match query { project::search::SearchQuery::Text { query, .. } => RegexSearch::new(&query), @@ -372,6 +504,7 @@ impl View for TerminalView { terminal_handle, focused, self.should_show_cursor(focused, cx), + self.can_navigate_to_selected_word, ) .contained(), ) @@ -393,6 +526,20 @@ impl View for TerminalView { cx.notify(); } + fn modifiers_changed( + &mut self, + event: &ModifiersChangedEvent, + cx: &mut ViewContext, + ) -> bool { + let handled = self + .terminal() + .update(cx, |term, _| term.try_modifiers_change(&event.modifiers)); + if handled { + cx.notify(); + } + handled + } + fn key_down(&mut self, event: &KeyDownEvent, cx: &mut ViewContext) -> bool { self.clear_bel(cx); self.pause_cursor_blinking(cx); @@ -618,7 +765,7 @@ impl Item for TerminalView { project.create_terminal(cwd, window_id, cx) })?; Ok(pane.update(&mut cx, |_, cx| { - cx.add_view(|cx| TerminalView::new(terminal, workspace_id, cx)) + cx.add_view(|cx| TerminalView::new(terminal, workspace, workspace_id, cx)) })?) }) } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index b7a7408bef..4766f636f3 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -402,6 +402,7 @@ pub struct StatusBar { pub height: f32, pub item_spacing: f32, pub cursor_position: TextStyle, + pub vim_mode_indicator: ContainedText, pub active_language: Interactive, pub auto_update_progress_message: TextStyle, pub auto_update_done_message: TextStyle, @@ -480,8 +481,10 @@ pub struct ProjectPanelEntry { #[serde(flatten)] pub container: ContainerStyle, pub text: TextStyle, - pub icon_color: Color, pub icon_size: f32, + pub icon_color: Color, + pub chevron_color: Color, + pub chevron_size: f32, pub icon_spacing: f32, pub status: EntryStatus, } @@ -689,6 +692,8 @@ pub struct Editor { pub document_highlight_read_background: Color, pub document_highlight_write_background: Color, pub diff: DiffStyle, + pub wrap_guide: Color, + pub active_wrap_guide: Color, pub line_number: Color, pub line_number_active: Color, pub guest_selections: Vec, diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 47a85f4ed3..2d394e3dcf 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -32,6 +32,8 @@ language = { path = "../language" } search = { path = "../search" } settings = { path = "../settings" } workspace = { path = "../workspace" } +theme = { path = "../theme" } +language_selector = { path = "../language_selector"} [dev-dependencies] indoc.workspace = true @@ -44,3 +46,4 @@ project = { path = "../project", features = ["test-support"] } util = { path = "../util", features = ["test-support"] } settings = { path = "../settings" } workspace = { path = "../workspace", features = ["test-support"] } +theme = { path = "../theme", features = ["test-support"] } diff --git a/crates/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs index a11f1cc182..60e63f9823 100644 --- a/crates/vim/src/editor_events.rs +++ b/crates/vim/src/editor_events.rs @@ -13,7 +13,7 @@ fn focused(EditorFocused(editor): &EditorFocused, cx: &mut AppContext) { cx.update_window(previously_active_editor.window_id(), |cx| { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |previously_active_editor, cx| { - Vim::unhook_vim_settings(previously_active_editor, cx); + vim.unhook_vim_settings(previously_active_editor, cx) }); }); }); @@ -35,7 +35,7 @@ fn blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut AppContext) { } } - editor.update(cx, |editor, cx| Vim::unhook_vim_settings(editor, cx)) + editor.update(cx, |editor, cx| vim.unhook_vim_settings(editor, cx)) }); }); } diff --git a/crates/vim/src/mode_indicator.rs b/crates/vim/src/mode_indicator.rs new file mode 100644 index 0000000000..e0d2b65955 --- /dev/null +++ b/crates/vim/src/mode_indicator.rs @@ -0,0 +1,58 @@ +use gpui::{elements::Label, AnyElement, Element, Entity, View, ViewContext}; +use workspace::{item::ItemHandle, StatusItemView}; + +use crate::state::Mode; + +pub struct ModeIndicator { + pub mode: Mode, +} + +impl ModeIndicator { + pub fn new(mode: Mode) -> Self { + Self { mode } + } + + pub fn set_mode(&mut self, mode: Mode, cx: &mut ViewContext) { + if mode != self.mode { + self.mode = mode; + cx.notify(); + } + } +} + +impl Entity for ModeIndicator { + type Event = (); +} + +impl View for ModeIndicator { + fn ui_name() -> &'static str { + "ModeIndicatorView" + } + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + let theme = &theme::current(cx).workspace.status_bar; + // we always choose text to be 12 monospace characters + // so that as the mode indicator changes, the rest of the + // UI stays still. + let text = match self.mode { + Mode::Normal => "-- NORMAL --", + Mode::Insert => "-- INSERT --", + Mode::Visual { line: false } => "-- VISUAL --", + Mode::Visual { line: true } => "VISUAL LINE ", + }; + Label::new(text, theme.vim_mode_indicator.text.clone()) + .contained() + .with_style(theme.vim_mode_indicator.container) + .into_any() + } +} + +impl StatusItemView for ModeIndicator { + fn set_active_pane_item( + &mut self, + _active_pane_item: Option<&dyn ItemHandle>, + _cx: &mut ViewContext, + ) { + // nothing to do. + } +} diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 07b095dd5e..b8bd256d8a 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -62,6 +62,12 @@ struct PreviousWordStart { ignore_punctuation: bool, } +#[derive(Clone, Deserialize, PartialEq)] +struct RepeatFind { + #[serde(default)] + backwards: bool, +} + actions!( vim, [ @@ -82,7 +88,10 @@ actions!( NextLineStart, ] ); -impl_actions!(vim, [NextWordStart, NextWordEnd, PreviousWordStart]); +impl_actions!( + vim, + [NextWordStart, NextWordEnd, PreviousWordStart, RepeatFind] +); pub fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &Left, cx: _| motion(Motion::Left, cx)); @@ -123,13 +132,15 @@ pub fn init(cx: &mut AppContext) { &PreviousWordStart { ignore_punctuation }: &PreviousWordStart, cx: _| { motion(Motion::PreviousWordStart { ignore_punctuation }, cx) }, ); - cx.add_action(|_: &mut Workspace, &NextLineStart, cx: _| motion(Motion::NextLineStart, cx)) + cx.add_action(|_: &mut Workspace, &NextLineStart, cx: _| motion(Motion::NextLineStart, cx)); + cx.add_action(|_: &mut Workspace, action: &RepeatFind, cx: _| { + repeat_motion(action.backwards, cx) + }) } pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) { - if let Some(Operator::Namespace(_)) - | Some(Operator::FindForward { .. }) - | Some(Operator::FindBackward { .. }) = Vim::read(cx).active_operator() + if let Some(Operator::FindForward { .. }) | Some(Operator::FindBackward { .. }) = + Vim::read(cx).active_operator() { Vim::update(cx, |vim, cx| vim.pop_operator(cx)); } @@ -146,6 +157,35 @@ pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| vim.clear_operator(cx)); } +fn repeat_motion(backwards: bool, cx: &mut WindowContext) { + let find = match Vim::read(cx).state.last_find.clone() { + Some(Motion::FindForward { before, text }) => { + if backwards { + Motion::FindBackward { + after: before, + text, + } + } else { + Motion::FindForward { before, text } + } + } + + Some(Motion::FindBackward { after, text }) => { + if backwards { + Motion::FindForward { + before: after, + text, + } + } else { + Motion::FindBackward { after, text } + } + } + _ => return, + }; + + motion(find, cx) +} + // Motion handling is specified here: // https://github.com/vim/vim/blob/master/runtime/doc/motion.txt impl Motion { @@ -743,4 +783,23 @@ mod test { cx.simulate_shared_keystrokes(["%"]).await; cx.assert_shared_state("func boop(ˇ) {\n}").await; } + + #[gpui::test] + async fn test_comma_semicolon(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("ˇone two three four").await; + cx.simulate_shared_keystrokes(["f", "o"]).await; + cx.assert_shared_state("one twˇo three four").await; + cx.simulate_shared_keystrokes([","]).await; + cx.assert_shared_state("ˇone two three four").await; + cx.simulate_shared_keystrokes(["2", ";"]).await; + cx.assert_shared_state("one two three fˇour").await; + cx.simulate_shared_keystrokes(["shift-t", "e"]).await; + cx.assert_shared_state("one two threeˇ four").await; + cx.simulate_shared_keystrokes(["3", ";"]).await; + cx.assert_shared_state("oneˇ two three four").await; + cx.simulate_shared_keystrokes([","]).await; + cx.assert_shared_state("one two thˇree four").await; + } } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 1227afbb85..79c990ffeb 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -2,6 +2,7 @@ mod case; mod change; mod delete; mod scroll; +mod search; mod substitute; mod yank; @@ -57,6 +58,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(insert_line_above); cx.add_action(insert_line_below); cx.add_action(change_case); + search::init(cx); cx.add_action(|_: &mut Workspace, _: &Substitute, cx| { Vim::update(cx, |vim, cx| { let times = vim.pop_number_operator(cx); @@ -105,7 +107,7 @@ pub fn normal_motion( Some(Operator::Delete) => delete_motion(vim, motion, times, cx), Some(Operator::Yank) => yank_motion(vim, motion, times, cx), Some(operator) => { - // Can't do anything for text objects or namespace operators. Ignoring + // Can't do anything for text objects, Ignoring error!("Unexpected normal mode motion operator: {:?}", operator) } } @@ -439,11 +441,8 @@ mod test { use indoc::indoc; use crate::{ - state::{ - Mode::{self, *}, - Namespace, Operator, - }, - test::{ExemptionFeatures, NeovimBackedTestContext, VimTestContext}, + state::Mode::{self}, + test::{ExemptionFeatures, NeovimBackedTestContext}, }; #[gpui::test] @@ -608,22 +607,6 @@ mod test { .await; } - #[gpui::test] - async fn test_g_prefix_and_abort(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true).await; - - // Can abort with escape to get back to normal mode - cx.simulate_keystroke("g"); - assert_eq!(cx.mode(), Normal); - assert_eq!( - cx.active_operator(), - Some(Operator::Namespace(Namespace::G)) - ); - cx.simulate_keystroke("escape"); - assert_eq!(cx.mode(), Normal); - assert_eq!(cx.active_operator(), None); - } - #[gpui::test] async fn test_gg(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs new file mode 100644 index 0000000000..d584c575d2 --- /dev/null +++ b/crates/vim/src/normal/search.rs @@ -0,0 +1,302 @@ +use gpui::{actions, impl_actions, AppContext, ViewContext}; +use search::{buffer_search, BufferSearchBar, SearchOptions}; +use serde_derive::Deserialize; +use workspace::{searchable::Direction, Pane, Workspace}; + +use crate::{state::SearchState, Vim}; + +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MoveToNext { + #[serde(default)] + partial_word: bool, +} + +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MoveToPrev { + #[serde(default)] + partial_word: bool, +} + +#[derive(Clone, Deserialize, PartialEq)] +pub(crate) struct Search { + #[serde(default)] + backwards: bool, +} + +impl_actions!(vim, [MoveToNext, MoveToPrev, Search]); +actions!(vim, [SearchSubmit]); + +pub(crate) fn init(cx: &mut AppContext) { + cx.add_action(move_to_next); + cx.add_action(move_to_prev); + cx.add_action(search); + cx.add_action(search_submit); + cx.add_action(search_deploy); +} + +fn move_to_next(workspace: &mut Workspace, action: &MoveToNext, cx: &mut ViewContext) { + move_to_internal(workspace, Direction::Next, !action.partial_word, cx) +} + +fn move_to_prev(workspace: &mut Workspace, action: &MoveToPrev, cx: &mut ViewContext) { + move_to_internal(workspace, Direction::Prev, !action.partial_word, cx) +} + +fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext) { + let pane = workspace.active_pane().clone(); + let direction = if action.backwards { + Direction::Prev + } else { + Direction::Next + }; + Vim::update(cx, |vim, cx| { + let count = vim.pop_number_operator(cx).unwrap_or(1); + pane.update(cx, |pane, cx| { + if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { + search_bar.update(cx, |search_bar, cx| { + if !search_bar.show(cx) { + return; + } + let query = search_bar.query(cx); + + search_bar.select_query(cx); + cx.focus_self(); + + if query.is_empty() { + search_bar.set_search_options( + SearchOptions::CASE_SENSITIVE | SearchOptions::REGEX, + cx, + ); + } + vim.state.search = SearchState { + direction, + count, + initial_query: query, + }; + }); + } + }) + }) +} + +// hook into the existing to clear out any vim search state on cmd+f or edit -> find. +fn search_deploy(_: &mut Pane, _: &buffer_search::Deploy, cx: &mut ViewContext) { + Vim::update(cx, |vim, _| vim.state.search = Default::default()); + cx.propagate_action(); +} + +fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewContext) { + Vim::update(cx, |vim, cx| { + let pane = workspace.active_pane().clone(); + pane.update(cx, |pane, cx| { + if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { + search_bar.update(cx, |search_bar, cx| { + let mut state = &mut vim.state.search; + let mut count = state.count; + + // in the case that the query has changed, the search bar + // will have selected the next match already. + if (search_bar.query(cx) != state.initial_query) + && state.direction == Direction::Next + { + count = count.saturating_sub(1) + } + search_bar.select_match(state.direction, count, cx); + state.count = 1; + search_bar.focus_editor(&Default::default(), cx); + }); + } + }); + }) +} + +pub fn move_to_internal( + workspace: &mut Workspace, + direction: Direction, + whole_word: bool, + cx: &mut ViewContext, +) { + Vim::update(cx, |vim, cx| { + let pane = workspace.active_pane().clone(); + let count = vim.pop_number_operator(cx).unwrap_or(1); + pane.update(cx, |pane, cx| { + if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { + let search = search_bar.update(cx, |search_bar, cx| { + let mut options = SearchOptions::CASE_SENSITIVE; + options.set(SearchOptions::WHOLE_WORD, whole_word); + if search_bar.show(cx) { + search_bar + .query_suggestion(cx) + .map(|query| search_bar.search(&query, Some(options), cx)) + } else { + None + } + }); + + if let Some(search) = search { + let search_bar = search_bar.downgrade(); + cx.spawn(|_, mut cx| async move { + search.await?; + search_bar.update(&mut cx, |search_bar, cx| { + search_bar.select_match(direction, count, cx) + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + } + }); + vim.clear_operator(cx); + }); +} + +#[cfg(test)] +mod test { + use std::sync::Arc; + + use editor::DisplayPoint; + use search::BufferSearchBar; + + use crate::{state::Mode, test::VimTestContext}; + + #[gpui::test] + async fn test_move_to_next( + cx: &mut gpui::TestAppContext, + deterministic: Arc, + ) { + let mut cx = VimTestContext::new(cx, true).await; + cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal); + + cx.simulate_keystrokes(["*"]); + deterministic.run_until_parked(); + cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal); + + cx.simulate_keystrokes(["*"]); + deterministic.run_until_parked(); + cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal); + + cx.simulate_keystrokes(["#"]); + deterministic.run_until_parked(); + cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal); + + cx.simulate_keystrokes(["#"]); + deterministic.run_until_parked(); + cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal); + + cx.simulate_keystrokes(["2", "*"]); + deterministic.run_until_parked(); + cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal); + + cx.simulate_keystrokes(["g", "*"]); + deterministic.run_until_parked(); + cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal); + + cx.simulate_keystrokes(["n"]); + cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal); + + cx.simulate_keystrokes(["g", "#"]); + deterministic.run_until_parked(); + cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal); + } + + #[gpui::test] + async fn test_search( + cx: &mut gpui::TestAppContext, + deterministic: Arc, + ) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal); + cx.simulate_keystrokes(["/", "c", "c"]); + + let search_bar = cx.workspace(|workspace, cx| { + workspace + .active_pane() + .read(cx) + .toolbar() + .read(cx) + .item_of_type::() + .expect("Buffer search bar should be deployed") + }); + + search_bar.read_with(cx.cx, |bar, cx| { + assert_eq!(bar.query_editor.read(cx).text(cx), "cc"); + }); + + deterministic.run_until_parked(); + + cx.update_editor(|editor, cx| { + let highlights = editor.all_background_highlights(cx); + assert_eq!(3, highlights.len()); + assert_eq!( + DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2), + highlights[0].0 + ) + }); + + cx.simulate_keystrokes(["enter"]); + cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal); + + // n to go to next/N to go to previous + cx.simulate_keystrokes(["n"]); + cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal); + cx.simulate_keystrokes(["shift-n"]); + cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal); + + // ? to go to previous + cx.simulate_keystrokes(["?", "enter"]); + deterministic.run_until_parked(); + cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal); + cx.simulate_keystrokes(["?", "enter"]); + deterministic.run_until_parked(); + cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal); + + // / to go to next + cx.simulate_keystrokes(["/", "enter"]); + deterministic.run_until_parked(); + cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal); + + // ?{search} to search backwards + cx.simulate_keystrokes(["?", "b", "enter"]); + deterministic.run_until_parked(); + cx.assert_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal); + + // works with counts + cx.simulate_keystrokes(["4", "/", "c"]); + deterministic.run_until_parked(); + cx.simulate_keystrokes(["enter"]); + cx.assert_state("aa\nbb\ncc\ncˇc\ncc\n", Mode::Normal); + + // check that searching resumes from cursor, not previous match + cx.set_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal); + cx.simulate_keystrokes(["/", "d"]); + deterministic.run_until_parked(); + cx.simulate_keystrokes(["enter"]); + cx.assert_state("aa\nbb\nˇdd\ncc\nbb\n", Mode::Normal); + cx.update_editor(|editor, cx| editor.move_to_beginning(&Default::default(), cx)); + cx.assert_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal); + cx.simulate_keystrokes(["/", "b"]); + deterministic.run_until_parked(); + cx.simulate_keystrokes(["enter"]); + cx.assert_state("aa\nˇbb\ndd\ncc\nbb\n", Mode::Normal); + } + + #[gpui::test] + async fn test_non_vim_search( + cx: &mut gpui::TestAppContext, + deterministic: Arc, + ) { + let mut cx = VimTestContext::new(cx, false).await; + cx.set_state("ˇone one one one", Mode::Normal); + cx.simulate_keystrokes(["cmd-f"]); + deterministic.run_until_parked(); + + cx.assert_editor_state("«oneˇ» one one one"); + cx.simulate_keystrokes(["enter"]); + cx.assert_editor_state("one «oneˇ» one one"); + cx.simulate_keystrokes(["shift-enter"]); + cx.assert_editor_state("«oneˇ» one one one"); + } +} diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index e1a06fce59..eb52945ced 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1,6 +1,9 @@ use gpui::keymap_matcher::KeymapContext; use language::CursorShape; use serde::{Deserialize, Serialize}; +use workspace::searchable::Direction; + +use crate::motion::Motion; #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] pub enum Mode { @@ -15,16 +18,9 @@ impl Default for Mode { } } -#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)] -pub enum Namespace { - G, - Z, -} - #[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)] pub enum Operator { Number(usize), - Namespace(Namespace), Change, Delete, Yank, @@ -38,6 +34,25 @@ pub enum Operator { pub struct VimState { pub mode: Mode, pub operator_stack: Vec, + pub search: SearchState, + + pub last_find: Option, +} + +pub struct SearchState { + pub direction: Direction, + pub count: usize, + pub initial_query: String, +} + +impl Default for SearchState { + fn default() -> Self { + Self { + direction: Direction::Next, + count: 1, + initial_query: "".to_string(), + } + } } impl VimState { @@ -73,6 +88,7 @@ impl VimState { pub fn keymap_context_layer(&self) -> KeymapContext { let mut context = KeymapContext::default(); + context.add_identifier("VimEnabled"); context.add_key( "vim_mode", match self.mode { @@ -107,8 +123,6 @@ impl Operator { pub fn id(&self) -> &'static str { match self { Operator::Number(_) => "n", - Operator::Namespace(Namespace::G) => "g", - Operator::Namespace(Namespace::Z) => "z", Operator::Object { around: false } => "i", Operator::Object { around: true } => "a", Operator::Change => "c", diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index a6efbd4da1..96d6a2b690 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -4,7 +4,10 @@ mod neovim_connection; mod vim_binding_test_context; mod vim_test_context; +use std::sync::Arc; + use command_palette::CommandPalette; +use editor::DisplayPoint; pub use neovim_backed_binding_test_context::*; pub use neovim_backed_test_context::*; pub use vim_binding_test_context::*; @@ -13,7 +16,7 @@ pub use vim_test_context::*; use indoc::indoc; use search::BufferSearchBar; -use crate::state::Mode; +use crate::{state::Mode, ModeIndicator}; #[gpui::test] async fn test_initially_disabled(cx: &mut gpui::TestAppContext) { @@ -96,7 +99,7 @@ async fn test_buffer_search(cx: &mut gpui::TestAppContext) { }); search_bar.read_with(cx.cx, |bar, cx| { - assert_eq!(bar.query_editor.read(cx).text(cx), "jumps"); + assert_eq!(bar.query_editor.read(cx).text(cx), ""); }) } @@ -137,7 +140,7 @@ async fn test_indent_outdent(cx: &mut gpui::TestAppContext) { cx.assert_editor_state("aa\nbˇb\ncc"); // works in visuial mode - cx.simulate_keystrokes(["shift-v", "down", ">", ">"]); + cx.simulate_keystrokes(["shift-v", "down", ">"]); cx.assert_editor_state("aa\n b«b\n cˇ»c"); } @@ -153,3 +156,98 @@ async fn test_escape_command_palette(cx: &mut gpui::TestAppContext) { assert!(!cx.workspace(|workspace, _| workspace.modal::().is_some())); cx.assert_state("aˇbc\n", Mode::Insert); } + +#[gpui::test] +async fn test_selection_on_search(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state(indoc! {"aa\nbˇb\ncc\ncc\ncc\n"}, Mode::Normal); + cx.simulate_keystrokes(["/", "c", "c"]); + + let search_bar = cx.workspace(|workspace, cx| { + workspace + .active_pane() + .read(cx) + .toolbar() + .read(cx) + .item_of_type::() + .expect("Buffer search bar should be deployed") + }); + + search_bar.read_with(cx.cx, |bar, cx| { + assert_eq!(bar.query_editor.read(cx).text(cx), "cc"); + }); + + // wait for the query editor change event to fire. + search_bar.next_notification(&cx).await; + + cx.update_editor(|editor, cx| { + let highlights = editor.all_background_highlights(cx); + assert_eq!(3, highlights.len()); + assert_eq!( + DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2), + highlights[0].0 + ) + }); + cx.simulate_keystrokes(["enter"]); + + cx.assert_state(indoc! {"aa\nbb\nˇcc\ncc\ncc\n"}, Mode::Normal); + cx.simulate_keystrokes(["n"]); + cx.assert_state(indoc! {"aa\nbb\ncc\nˇcc\ncc\n"}, Mode::Normal); + cx.simulate_keystrokes(["shift-n"]); + cx.assert_state(indoc! {"aa\nbb\nˇcc\ncc\ncc\n"}, Mode::Normal); +} + +#[gpui::test] +async fn test_status_indicator( + cx: &mut gpui::TestAppContext, + deterministic: Arc, +) { + let mut cx = VimTestContext::new(cx, true).await; + deterministic.run_until_parked(); + + let mode_indicator = cx.workspace(|workspace, cx| { + let status_bar = workspace.status_bar().read(cx); + let mode_indicator = status_bar.item_of_type::(); + assert!(mode_indicator.is_some()); + mode_indicator.unwrap() + }); + + assert_eq!( + cx.workspace(|_, cx| mode_indicator.read(cx).mode), + Mode::Normal + ); + + // shows the correct mode + cx.simulate_keystrokes(["i"]); + deterministic.run_until_parked(); + assert_eq!( + cx.workspace(|_, cx| mode_indicator.read(cx).mode), + Mode::Insert + ); + + // shows even in search + cx.simulate_keystrokes(["escape", "v", "/"]); + deterministic.run_until_parked(); + assert_eq!( + cx.workspace(|_, cx| mode_indicator.read(cx).mode), + Mode::Visual { line: false } + ); + + // hides if vim mode is disabled + cx.disable_vim(); + deterministic.run_until_parked(); + cx.workspace(|workspace, cx| { + let status_bar = workspace.status_bar().read(cx); + let mode_indicator = status_bar.item_of_type::(); + assert!(mode_indicator.is_none()); + }); + + cx.enable_vim(); + deterministic.run_until_parked(); + cx.workspace(|workspace, cx| { + let status_bar = workspace.status_bar().read(cx); + let mode_indicator = status_bar.item_of_type::(); + assert!(mode_indicator.is_some()); + }); +} diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index f9ba577231..56ca654644 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -90,6 +90,7 @@ impl<'a> VimTestContext<'a> { self.cx.set_state(text) } + #[track_caller] pub fn assert_state(&mut self, text: &str, mode: Mode) { self.assert_editor_state(text); assert_eq!(self.mode(), mode, "{}", self.assertion_context()); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 2bcc2254ee..d8edf1a667 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -3,6 +3,7 @@ mod test; mod editor_events; mod insert; +mod mode_indicator; mod motion; mod normal; mod object; @@ -14,10 +15,11 @@ use anyhow::Result; use collections::CommandPaletteFilter; use editor::{Bias, Editor, EditorMode, Event}; use gpui::{ - actions, impl_actions, AppContext, Subscription, ViewContext, ViewHandle, WeakViewHandle, - WindowContext, + actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext, + Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext, }; use language::CursorShape; +pub use mode_indicator::ModeIndicator; use motion::Motion; use normal::normal_replace; use serde::Deserialize; @@ -90,7 +92,10 @@ pub fn init(cx: &mut AppContext) { } pub fn observe_keystrokes(cx: &mut WindowContext) { - cx.observe_keystrokes(|_keystroke, _result, handled_by, cx| { + cx.observe_keystrokes(|_keystroke, result, handled_by, cx| { + if result == &MatchResult::Pending { + return true; + } if let Some(handled_by) = handled_by { // Keystroke is handled by the vim system, so continue forward if handled_by.namespace() == "vim" { @@ -116,6 +121,7 @@ pub fn observe_keystrokes(cx: &mut WindowContext) { pub struct Vim { active_editor: Option>, editor_subscription: Option, + mode_indicator: Option>, enabled: bool, state: VimState, @@ -175,6 +181,10 @@ impl Vim { self.state.mode = mode; self.state.operator_stack.clear(); + if let Some(mode_indicator) = &self.mode_indicator { + mode_indicator.update(cx, |mode_indicator, cx| mode_indicator.set_mode(mode, cx)) + } + // Sync editor settings like clip mode self.sync_vim_settings(cx); @@ -243,10 +253,14 @@ impl Vim { match Vim::read(cx).active_operator() { Some(Operator::FindForward { before }) => { - motion::motion(Motion::FindForward { before, text }, cx) + let find = Motion::FindForward { before, text }; + Vim::update(cx, |vim, _| vim.state.last_find = Some(find.clone())); + motion::motion(find, cx) } Some(Operator::FindBackward { after }) => { - motion::motion(Motion::FindBackward { after, text }, cx) + let find = Motion::FindBackward { after, text }; + Vim::update(cx, |vim, _| vim.state.last_find = Some(find.clone())); + motion::motion(find, cx) } Some(Operator::Replace) => match Vim::read(cx).state.mode { Mode::Normal => normal_replace(text, cx), @@ -257,6 +271,44 @@ impl Vim { } } + fn sync_mode_indicator(cx: &mut WindowContext) { + let Some(workspace) = cx.root_view() + .downcast_ref::() + .map(|workspace| workspace.downgrade()) else { + return; + }; + + cx.spawn(|mut cx| async move { + workspace.update(&mut cx, |workspace, cx| { + Vim::update(cx, |vim, cx| { + workspace.status_bar().update(cx, |status_bar, cx| { + let current_position = status_bar.position_of_item::(); + + if vim.enabled && current_position.is_none() { + if vim.mode_indicator.is_none() { + vim.mode_indicator = + Some(cx.add_view(|_| ModeIndicator::new(vim.state.mode))); + }; + let mode_indicator = vim.mode_indicator.as_ref().unwrap(); + let position = status_bar + .position_of_item::(); + if let Some(position) = position { + status_bar.insert_item_after(position, mode_indicator.clone(), cx) + } else { + status_bar.add_left_item(mode_indicator.clone(), cx) + } + } else if !vim.enabled { + if let Some(position) = current_position { + status_bar.remove_item_at(position, cx) + } + } + }) + }) + }) + }) + .detach_and_log_err(cx); + } + fn set_enabled(&mut self, enabled: bool, cx: &mut AppContext) { if self.enabled != enabled { self.enabled = enabled; @@ -295,22 +347,39 @@ impl Vim { if self.enabled && editor.mode() == EditorMode::Full { editor.set_cursor_shape(cursor_shape, cx); editor.set_clip_at_line_ends(state.clip_at_line_end(), cx); + editor.set_collapse_matches(true); editor.set_input_enabled(!state.vim_controlled()); editor.selections.line_mode = matches!(state.mode, Mode::Visual { line: true }); let context_layer = state.keymap_context_layer(); editor.set_keymap_context_layer::(context_layer, cx); } else { - Self::unhook_vim_settings(editor, cx); + // Note: set_collapse_matches is not in unhook_vim_settings, as that method is called on blur, + // but we need collapse_matches to persist when the search bar is focused. + editor.set_collapse_matches(false); + self.unhook_vim_settings(editor, cx); } }); + + Vim::sync_mode_indicator(cx); } - fn unhook_vim_settings(editor: &mut Editor, cx: &mut ViewContext) { + fn unhook_vim_settings(&self, editor: &mut Editor, cx: &mut ViewContext) { editor.set_cursor_shape(CursorShape::Bar, cx); editor.set_clip_at_line_ends(false, cx); editor.set_input_enabled(true); editor.selections.line_mode = false; - editor.remove_keymap_context_layer::(cx); + + // we set the VimEnabled context on all editors so that we + // can distinguish between vim mode and non-vim mode in the BufferSearchBar. + // This is a bit of a hack, but currently the search crate does not depend on vim, + // and it seems nice to keep it that way. + if self.enabled { + let mut context = KeymapContext::default(); + context.add_identifier("VimEnabled"); + editor.set_keymap_context_layer::(context, cx) + } else { + editor.remove_keymap_context_layer::(cx); + } } } diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 5e22e77bf0..d87e4ff974 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -58,7 +58,9 @@ pub fn visual_motion(motion: Motion, times: Option, cx: &mut WindowContex pub fn visual_object(object: Object, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { - if let Operator::Object { around } = vim.pop_operator(cx) { + if let Some(Operator::Object { around }) = vim.active_operator() { + vim.pop_operator(cx); + vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { diff --git a/crates/vim/test_data/test_comma_semicolon.json b/crates/vim/test_data/test_comma_semicolon.json new file mode 100644 index 0000000000..8cde887ed1 --- /dev/null +++ b/crates/vim/test_data/test_comma_semicolon.json @@ -0,0 +1,17 @@ +{"Put":{"state":"ˇone two three four"}} +{"Key":"f"} +{"Key":"o"} +{"Get":{"state":"one twˇo three four","mode":"Normal"}} +{"Key":","} +{"Get":{"state":"ˇone two three four","mode":"Normal"}} +{"Key":"2"} +{"Key":";"} +{"Get":{"state":"one two three fˇour","mode":"Normal"}} +{"Key":"shift-t"} +{"Key":"e"} +{"Get":{"state":"one two threeˇ four","mode":"Normal"}} +{"Key":"3"} +{"Key":";"} +{"Get":{"state":"oneˇ two three four","mode":"Normal"}} +{"Key":","} +{"Get":{"state":"one two thˇree four","mode":"Normal"}} diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 460698efb8..f0af080d4a 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -5,6 +5,7 @@ use crate::{ use crate::{AutosaveSetting, DelayedDebouncedEditAction, WorkspaceSettings}; use anyhow::Result; use client::{proto, Client}; +use gpui::geometry::vector::Vector2F; use gpui::{ fonts::HighlightStyle, AnyElement, AnyViewHandle, AppContext, ModelHandle, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, @@ -203,6 +204,9 @@ pub trait Item: View { fn show_toolbar(&self) -> bool { true } + fn pixel_position_of_cursor(&self) -> Option { + None + } } pub trait ItemHandle: 'static + fmt::Debug { @@ -271,6 +275,7 @@ pub trait ItemHandle: 'static + fmt::Debug { fn breadcrumbs(&self, theme: &Theme, cx: &AppContext) -> Option>; fn serialized_item_kind(&self) -> Option<&'static str>; fn show_toolbar(&self, cx: &AppContext) -> bool; + fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option; } pub trait WeakItemHandle { @@ -615,6 +620,10 @@ impl ItemHandle for ViewHandle { fn show_toolbar(&self, cx: &AppContext) -> bool { self.read(cx).show_toolbar() } + + fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option { + self.read(cx).pixel_position_of_cursor() + } } impl From> for AnyViewHandle { diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index f5b96fd421..2972c307f2 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -542,6 +542,12 @@ impl Pane { self.items.get(self.active_item_index).cloned() } + pub fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option { + self.items + .get(self.active_item_index)? + .pixel_position_of_cursor(cx) + } + pub fn item_for_entry( &self, entry_id: ProjectEntryId, diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 52761b06c8..e60f6deb2f 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -54,6 +54,20 @@ impl PaneGroup { } } + pub fn bounding_box_for_pane(&self, pane: &ViewHandle) -> Option { + match &self.root { + Member::Pane(_) => None, + Member::Axis(axis) => axis.bounding_box_for_pane(pane), + } + } + + pub fn pane_at_pixel_position(&self, coordinate: Vector2F) -> Option<&ViewHandle> { + match &self.root { + Member::Pane(pane) => Some(pane), + Member::Axis(axis) => axis.pane_at_pixel_position(coordinate), + } + } + /// Returns: /// - Ok(true) if it found and removed a pane /// - Ok(false) if it found but did not remove the pane @@ -309,15 +323,18 @@ pub(crate) struct PaneAxis { pub axis: Axis, pub members: Vec, pub flexes: Rc>>, + pub bounding_boxes: Rc>>>, } impl PaneAxis { pub fn new(axis: Axis, members: Vec) -> Self { let flexes = Rc::new(RefCell::new(vec![1.; members.len()])); + let bounding_boxes = Rc::new(RefCell::new(vec![None; members.len()])); Self { axis, members, flexes, + bounding_boxes, } } @@ -326,10 +343,12 @@ impl PaneAxis { debug_assert!(members.len() == flexes.len()); let flexes = Rc::new(RefCell::new(flexes)); + let bounding_boxes = Rc::new(RefCell::new(vec![None; members.len()])); Self { axis, members, flexes, + bounding_boxes, } } @@ -409,6 +428,44 @@ impl PaneAxis { } } + fn bounding_box_for_pane(&self, pane: &ViewHandle) -> Option { + debug_assert!(self.members.len() == self.bounding_boxes.borrow().len()); + + for (idx, member) in self.members.iter().enumerate() { + match member { + Member::Pane(found) => { + if pane == found { + return self.bounding_boxes.borrow()[idx]; + } + } + Member::Axis(axis) => { + if let Some(rect) = axis.bounding_box_for_pane(pane) { + return Some(rect); + } + } + } + } + None + } + + fn pane_at_pixel_position(&self, coordinate: Vector2F) -> Option<&ViewHandle> { + debug_assert!(self.members.len() == self.bounding_boxes.borrow().len()); + + let bounding_boxes = self.bounding_boxes.borrow(); + + for (idx, member) in self.members.iter().enumerate() { + if let Some(coordinates) = bounding_boxes[idx] { + if coordinates.contains_point(coordinate) { + return match member { + Member::Pane(found) => Some(found), + Member::Axis(axis) => axis.pane_at_pixel_position(coordinate), + }; + } + } + } + None + } + fn render( &self, project: &ModelHandle, @@ -423,7 +480,12 @@ impl PaneAxis { ) -> AnyElement { debug_assert!(self.members.len() == self.flexes.borrow().len()); - let mut pane_axis = PaneAxisElement::new(self.axis, basis, self.flexes.clone()); + let mut pane_axis = PaneAxisElement::new( + self.axis, + basis, + self.flexes.clone(), + self.bounding_boxes.clone(), + ); let mut active_pane_ix = None; let mut members = self.members.iter().enumerate().peekable(); @@ -546,14 +608,21 @@ mod element { active_pane_ix: Option, flexes: Rc>>, children: Vec>, + bounding_boxes: Rc>>>, } impl PaneAxisElement { - pub fn new(axis: Axis, basis: usize, flexes: Rc>>) -> Self { + pub fn new( + axis: Axis, + basis: usize, + flexes: Rc>>, + bounding_boxes: Rc>>>, + ) -> Self { Self { axis, basis, flexes, + bounding_boxes, active_pane_ix: None, children: Default::default(), } @@ -708,11 +777,16 @@ mod element { let mut child_origin = bounds.origin(); + let mut bounding_boxes = self.bounding_boxes.borrow_mut(); + bounding_boxes.clear(); + let mut children_iter = self.children.iter_mut().enumerate().peekable(); while let Some((ix, child)) = children_iter.next() { let child_start = child_origin.clone(); child.paint(scene, child_origin, visible_bounds, view, cx); + bounding_boxes.push(Some(RectF::new(child_origin, child.size()))); + match self.axis { Axis::Horizontal => child_origin += vec2f(child.size().x(), 0.0), Axis::Vertical => child_origin += vec2f(0.0, child.size().y()), @@ -752,63 +826,79 @@ mod element { let child_size = child.size(); let next_child_size = next_child.size(); let drag_bounds = visible_bounds.clone(); - let flexes = self.flexes.clone(); - let current_flex = flexes.borrow()[ix]; + let flexes = self.flexes.borrow(); + let current_flex = flexes[ix]; let next_ix = *next_ix; - let next_flex = flexes.borrow()[next_ix]; + let next_flex = flexes[next_ix]; + drop(flexes); enum ResizeHandle {} let mut mouse_region = MouseRegion::new::( cx.view_id(), self.basis + ix, handle_bounds, ); - mouse_region = mouse_region.on_drag( - MouseButton::Left, - move |drag, workspace: &mut Workspace, cx| { - let min_size = match axis { - Axis::Horizontal => HORIZONTAL_MIN_SIZE, - Axis::Vertical => VERTICAL_MIN_SIZE, - }; - // Don't allow resizing to less than the minimum size, if elements are already too small - if min_size - 1. > child_size.along(axis) - || min_size - 1. > next_child_size.along(axis) - { - return; + mouse_region = mouse_region + .on_drag(MouseButton::Left, { + let flexes = self.flexes.clone(); + move |drag, workspace: &mut Workspace, cx| { + let min_size = match axis { + Axis::Horizontal => HORIZONTAL_MIN_SIZE, + Axis::Vertical => VERTICAL_MIN_SIZE, + }; + // Don't allow resizing to less than the minimum size, if elements are already too small + if min_size - 1. > child_size.along(axis) + || min_size - 1. > next_child_size.along(axis) + { + return; + } + + let mut current_target_size = + (drag.position - child_start).along(axis); + + let proposed_current_pixel_change = + current_target_size - child_size.along(axis); + + if proposed_current_pixel_change < 0. { + current_target_size = f32::max(current_target_size, min_size); + } else if proposed_current_pixel_change > 0. { + // TODO: cascade this change to other children if current item is at min size + let next_target_size = f32::max( + next_child_size.along(axis) - proposed_current_pixel_change, + min_size, + ); + current_target_size = f32::min( + current_target_size, + child_size.along(axis) + next_child_size.along(axis) + - next_target_size, + ); + } + + let current_pixel_change = + current_target_size - child_size.along(axis); + let flex_change = + current_pixel_change / drag_bounds.length_along(axis); + let current_target_flex = current_flex + flex_change; + let next_target_flex = next_flex - flex_change; + + let mut borrow = flexes.borrow_mut(); + *borrow.get_mut(ix).unwrap() = current_target_flex; + *borrow.get_mut(next_ix).unwrap() = next_target_flex; + + workspace.schedule_serialize(cx); + cx.notify(); } - - let mut current_target_size = (drag.position - child_start).along(axis); - - let proposed_current_pixel_change = - current_target_size - child_size.along(axis); - - if proposed_current_pixel_change < 0. { - current_target_size = f32::max(current_target_size, min_size); - } else if proposed_current_pixel_change > 0. { - // TODO: cascade this change to other children if current item is at min size - let next_target_size = f32::max( - next_child_size.along(axis) - proposed_current_pixel_change, - min_size, - ); - current_target_size = f32::min( - current_target_size, - child_size.along(axis) + next_child_size.along(axis) - - next_target_size, - ); + }) + .on_click(MouseButton::Left, { + let flexes = self.flexes.clone(); + move |e, v: &mut Workspace, cx| { + if e.click_count >= 2 { + let mut borrow = flexes.borrow_mut(); + *borrow = vec![1.; borrow.len()]; + v.schedule_serialize(cx); + cx.notify(); + } } - - let current_pixel_change = current_target_size - child_size.along(axis); - let flex_change = current_pixel_change / drag_bounds.length_along(axis); - let current_target_flex = current_flex + flex_change; - let next_target_flex = next_flex - flex_change; - - let mut borrow = flexes.borrow_mut(); - *borrow.get_mut(ix).unwrap() = current_target_flex; - *borrow.get_mut(next_ix).unwrap() = next_target_flex; - - workspace.schedule_serialize(cx); - cx.notify(); - }, - ); + }); scene.push_mouse_region(mouse_region); scene.pop_stacking_context(); diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index 3a3ba02e06..ae95838a74 100644 --- a/crates/workspace/src/searchable.rs +++ b/crates/workspace/src/searchable.rs @@ -55,26 +55,21 @@ pub trait SearchableItem: Item { fn match_index_for_direction( &mut self, matches: &Vec, - mut current_index: usize, + current_index: usize, direction: Direction, + count: usize, _: &mut ViewContext, ) -> usize { match direction { Direction::Prev => { - if current_index == 0 { - matches.len() - 1 + let count = count % matches.len(); + if current_index >= count { + current_index - count } else { - current_index - 1 - } - } - Direction::Next => { - current_index += 1; - if current_index == matches.len() { - 0 - } else { - current_index + matches.len() - (count - current_index) } } + Direction::Next => (current_index + count) % matches.len(), } } fn find_matches( @@ -113,6 +108,7 @@ pub trait SearchableItemHandle: ItemHandle { matches: &Vec>, current_index: usize, direction: Direction, + count: usize, cx: &mut WindowContext, ) -> usize; fn find_matches( @@ -183,11 +179,12 @@ impl SearchableItemHandle for ViewHandle { matches: &Vec>, current_index: usize, direction: Direction, + count: usize, cx: &mut WindowContext, ) -> usize { let matches = downcast_matches(matches); self.update(cx, |this, cx| { - this.match_index_for_direction(&matches, current_index, direction, cx) + this.match_index_for_direction(&matches, current_index, direction, count, cx) }) } fn find_matches( diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index 6fc1467566..8726eaf569 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -27,6 +27,7 @@ trait StatusItemViewHandle { active_pane_item: Option<&dyn ItemHandle>, cx: &mut WindowContext, ); + fn ui_name(&self) -> &'static str; } pub struct StatusBar { @@ -57,7 +58,6 @@ impl View for StatusBar { .with_margin_right(theme.item_spacing) })) .into_any(), - right: Flex::row() .with_children(self.right_items.iter().rev().map(|i| { ChildView::new(i.as_any(), cx) @@ -96,6 +96,56 @@ impl StatusBar { cx.notify(); } + pub fn item_of_type(&self) -> Option> { + self.left_items + .iter() + .chain(self.right_items.iter()) + .find_map(|item| item.as_any().clone().downcast()) + } + + pub fn position_of_item(&self) -> Option + where + T: StatusItemView, + { + for (index, item) in self.left_items.iter().enumerate() { + if item.as_ref().ui_name() == T::ui_name() { + return Some(index); + } + } + for (index, item) in self.right_items.iter().enumerate() { + if item.as_ref().ui_name() == T::ui_name() { + return Some(index + self.left_items.len()); + } + } + return None; + } + + pub fn insert_item_after( + &mut self, + position: usize, + item: ViewHandle, + cx: &mut ViewContext, + ) where + T: 'static + StatusItemView, + { + if position < self.left_items.len() { + self.left_items.insert(position + 1, Box::new(item)) + } else { + self.right_items + .insert(position + 1 - self.left_items.len(), Box::new(item)) + } + cx.notify() + } + + pub fn remove_item_at(&mut self, position: usize, cx: &mut ViewContext) { + if position < self.left_items.len() { + self.left_items.remove(position); + } else { + self.right_items.remove(position - self.left_items.len()); + } + cx.notify(); + } + pub fn add_right_item(&mut self, item: ViewHandle, cx: &mut ViewContext) where T: 'static + StatusItemView, @@ -133,6 +183,10 @@ impl StatusItemViewHandle for ViewHandle { this.set_active_pane_item(active_pane_item, cx) }); } + + fn ui_name(&self) -> &'static str { + T::ui_name() + } } impl From<&dyn StatusItemViewHandle> for AnyViewHandle { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 3e62af8ea6..1e9e431f9d 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -141,6 +141,7 @@ actions!( ToggleLeftDock, ToggleRightDock, ToggleBottomDock, + CloseAllDocks, ] ); @@ -152,6 +153,9 @@ pub struct OpenPaths { #[derive(Clone, Deserialize, PartialEq)] pub struct ActivatePane(pub usize); +#[derive(Clone, Deserialize, PartialEq)] +pub struct ActivatePaneInDirection(pub SplitDirection); + #[derive(Deserialize)] pub struct Toast { id: usize, @@ -197,7 +201,7 @@ impl Clone for Toast { } } -impl_actions!(workspace, [ActivatePane, Toast]); +impl_actions!(workspace, [ActivatePane, ActivatePaneInDirection, Toast]); pub type WorkspaceId = i64; @@ -262,6 +266,13 @@ pub fn init(app_state: Arc, cx: &mut AppContext) { cx.add_action(|workspace: &mut Workspace, _: &ActivateNextPane, cx| { workspace.activate_next_pane(cx) }); + + cx.add_action( + |workspace: &mut Workspace, action: &ActivatePaneInDirection, cx| { + workspace.activate_pane_in_direction(action.0, cx) + }, + ); + cx.add_action(|workspace: &mut Workspace, _: &ToggleLeftDock, cx| { workspace.toggle_dock(DockPosition::Left, cx); }); @@ -271,6 +282,9 @@ pub fn init(app_state: Arc, cx: &mut AppContext) { cx.add_action(|workspace: &mut Workspace, _: &ToggleBottomDock, cx| { workspace.toggle_dock(DockPosition::Bottom, cx); }); + cx.add_action(|workspace: &mut Workspace, _: &CloseAllDocks, cx| { + workspace.close_all_docks(cx); + }); cx.add_action(Workspace::activate_pane_at_index); cx.add_action(|workspace: &mut Workspace, _: &ReopenClosedItem, cx| { workspace.reopen_closed_item(cx).detach(); @@ -498,7 +512,7 @@ pub struct Workspace { follower_states_by_leader: FollowerStatesByLeader, last_leaders_by_pane: HashMap, PeerId>, window_edited: bool, - active_call: Option<(ModelHandle, Vec)>, + active_call: Option<(ModelHandle, Vec)>, leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>, database_id: WorkspaceId, app_state: Arc, @@ -884,6 +898,18 @@ impl Workspace { pub fn add_panel(&mut self, panel: ViewHandle, cx: &mut ViewContext) where T::Event: std::fmt::Debug, + { + self.add_panel_with_extra_event_handler(panel, cx, |_, _, _, _| {}) + } + + pub fn add_panel_with_extra_event_handler( + &mut self, + panel: ViewHandle, + cx: &mut ViewContext, + handler: F, + ) where + T::Event: std::fmt::Debug, + F: Fn(&mut Self, &ViewHandle, &T::Event, &mut ViewContext) + 'static, { let dock = match panel.position(cx) { DockPosition::Left => &self.left_dock, @@ -951,6 +977,8 @@ impl Workspace { } this.update_active_view_for_followers(cx); cx.notify(); + } else { + handler(this, &panel, event, cx) } } })); @@ -1403,45 +1431,65 @@ impl Workspace { // Sort the paths to ensure we add worktrees for parents before their children. abs_paths.sort_unstable(); cx.spawn(|this, mut cx| async move { - let mut project_paths = Vec::new(); - for path in &abs_paths { - if let Some(project_path) = this + let mut tasks = Vec::with_capacity(abs_paths.len()); + for abs_path in &abs_paths { + let project_path = match this .update(&mut cx, |this, cx| { - Workspace::project_path_for_path(this.project.clone(), path, visible, cx) + Workspace::project_path_for_path( + this.project.clone(), + abs_path, + visible, + cx, + ) }) .log_err() { - project_paths.push(project_path.await.log_err()); - } else { - project_paths.push(None); - } - } + Some(project_path) => project_path.await.log_err(), + None => None, + }; - let tasks = abs_paths - .iter() - .cloned() - .zip(project_paths.into_iter()) - .map(|(abs_path, project_path)| { - let this = this.clone(); - cx.spawn(|mut cx| { - let fs = fs.clone(); - async move { - let (_worktree, project_path) = project_path?; - if fs.is_file(&abs_path).await { - Some( - this.update(&mut cx, |this, cx| { - this.open_path(project_path, None, true, cx) + let this = this.clone(); + let task = cx.spawn(|mut cx| { + let fs = fs.clone(); + let abs_path = abs_path.clone(); + async move { + let (worktree, project_path) = project_path?; + if fs.is_file(&abs_path).await { + Some( + this.update(&mut cx, |this, cx| { + this.open_path(project_path, None, true, cx) + }) + .log_err()? + .await, + ) + } else { + this.update(&mut cx, |workspace, cx| { + let worktree = worktree.read(cx); + let worktree_abs_path = worktree.abs_path(); + let entry_id = if abs_path == worktree_abs_path.as_ref() { + worktree.root_entry() + } else { + abs_path + .strip_prefix(worktree_abs_path.as_ref()) + .ok() + .and_then(|relative_path| { + worktree.entry_for_path(relative_path) + }) + } + .map(|entry| entry.id); + if let Some(entry_id) = entry_id { + workspace.project().update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some(entry_id))); }) - .log_err()? - .await, - ) - } else { - None - } + } + }) + .log_err()?; + None } - }) - }) - .collect::>(); + } + }); + tasks.push(task); + } futures::future::join_all(tasks).await }) @@ -1660,6 +1708,20 @@ impl Workspace { self.serialize_workspace(cx); } + pub fn close_all_docks(&mut self, cx: &mut ViewContext) { + let docks = [&self.left_dock, &self.bottom_dock, &self.right_dock]; + + for dock in docks { + dock.update(cx, |dock, cx| { + dock.set_open(false, cx); + }); + } + + cx.focus_self(); + cx.notify(); + self.serialize_workspace(cx); + } + /// Transfer focus to the panel of the given type. pub fn focus_panel(&mut self, cx: &mut ViewContext) -> Option> { self.focus_or_unfocus_panel::(cx, |_, _| true)? @@ -2054,6 +2116,37 @@ impl Workspace { } } + pub fn activate_pane_in_direction( + &mut self, + direction: SplitDirection, + cx: &mut ViewContext, + ) { + let bounding_box = match self.center.bounding_box_for_pane(&self.active_pane) { + Some(coordinates) => coordinates, + None => { + return; + } + }; + let cursor = self.active_pane.read(cx).pixel_position_of_cursor(cx); + let center = match cursor { + Some(cursor) if bounding_box.contains_point(cursor) => cursor, + _ => bounding_box.center(), + }; + + let distance_to_next = theme::current(cx).workspace.pane_divider.width + 1.; + + let target = match direction { + SplitDirection::Left => vec2f(bounding_box.origin_x() - distance_to_next, center.y()), + SplitDirection::Right => vec2f(bounding_box.max_x() + distance_to_next, center.y()), + SplitDirection::Up => vec2f(center.x(), bounding_box.origin_y() - distance_to_next), + SplitDirection::Down => vec2f(center.x(), bounding_box.max_y() + distance_to_next), + }; + + if let Some(pane) = self.center.pane_at_pixel_position(target) { + cx.focus(pane); + } + } + fn handle_pane_focused(&mut self, pane: ViewHandle, cx: &mut ViewContext) { if self.active_pane != pane { self.active_pane = pane.clone(); @@ -3030,6 +3123,7 @@ impl Workspace { axis, members, flexes, + bounding_boxes: _, }) => SerializedPaneGroup::Group { axis: *axis, children: members diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 2a64ec08a3..8c423eb51b 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -3,7 +3,7 @@ authors = ["Nathan Sobo "] description = "The fast, collaborative code editor." edition = "2021" name = "zed" -version = "0.96.0" +version = "0.97.0" publish = false [lib] @@ -104,11 +104,14 @@ thiserror.workspace = true tiny_http = "0.8" toml.workspace = true tree-sitter.workspace = true +tree-sitter-bash.workspace = true tree-sitter-c.workspace = true tree-sitter-cpp.workspace = true tree-sitter-css.workspace = true tree-sitter-elixir.workspace = true +tree-sitter-elm.workspace = true tree-sitter-embedded-template.workspace = true +tree-sitter-glsl.workspace = true tree-sitter-go.workspace = true tree-sitter-heex.workspace = true tree-sitter-json.workspace = true diff --git a/crates/zed/src/languages.rs b/crates/zed/src/languages.rs index a523fb5c6c..09f5162c12 100644 --- a/crates/zed/src/languages.rs +++ b/crates/zed/src/languages.rs @@ -40,6 +40,7 @@ pub fn init(languages: Arc, node_runtime: Arc) { languages.register(name, load_config(name), grammar, adapters, load_queries) }; + language("bash", tree_sitter_bash::language(), vec![]); language( "c", tree_sitter_c::language(), @@ -151,6 +152,8 @@ pub fn init(languages: Arc, node_runtime: Arc) { tree_sitter_php::language(), vec![Arc::new(php::IntelephenseLspAdapter::new(node_runtime))], ); + language("elm", tree_sitter_elm::language(), vec![]); + language("glsl", tree_sitter_glsl::language(), vec![]); } #[cfg(any(test, feature = "test-support"))] diff --git a/crates/zed/src/languages/bash/brackets.scm b/crates/zed/src/languages/bash/brackets.scm new file mode 100644 index 0000000000..191fd9c084 --- /dev/null +++ b/crates/zed/src/languages/bash/brackets.scm @@ -0,0 +1,3 @@ +("(" @open ")" @close) +("[" @open "]" @close) +("{" @open "}" @close) diff --git a/crates/zed/src/languages/bash/config.toml b/crates/zed/src/languages/bash/config.toml new file mode 100644 index 0000000000..80b8753d80 --- /dev/null +++ b/crates/zed/src/languages/bash/config.toml @@ -0,0 +1,8 @@ +name = "Shell Script" +path_suffixes = [".sh", ".bash", ".bashrc", ".bash_profile", ".bash_aliases", ".bash_logout", ".profile", ".zsh", ".zshrc", ".zshenv", ".zsh_profile", ".zsh_aliases", ".zsh_histfile", ".zlogin"] +first_line_pattern = "^#!.*\\b(?:ba|z)?sh\\b" +brackets = [ + { start = "[", end = "]", close = true, newline = false }, + { start = "(", end = ")", close = true, newline = false }, + { start = "\"", end = "\"", close = true, newline = false, not_in = ["comment", "string"] }, +] diff --git a/crates/zed/src/languages/bash/highlights.scm b/crates/zed/src/languages/bash/highlights.scm new file mode 100644 index 0000000000..a72c5468ed --- /dev/null +++ b/crates/zed/src/languages/bash/highlights.scm @@ -0,0 +1,58 @@ +[ + (string) + (raw_string) + (heredoc_body) + (heredoc_start) +] @string + +(command_name) @function + +(variable_name) @property + +[ + "case" + "do" + "done" + "elif" + "else" + "esac" + "export" + "fi" + "for" + "function" + "if" + "in" + "select" + "then" + "unset" + "until" + "while" + "local" + "declare" +] @keyword + +(comment) @comment + +(function_definition name: (word) @function) + +(file_descriptor) @number + +[ + (command_substitution) + (process_substitution) + (expansion) +]@embedded + +[ + "$" + "&&" + ">" + ">>" + "<" + "|" +] @operator + +( + (command (_) @constant) + (#match? @constant "^-") +) diff --git a/crates/zed/src/languages/elm/config.toml b/crates/zed/src/languages/elm/config.toml new file mode 100644 index 0000000000..5051427a93 --- /dev/null +++ b/crates/zed/src/languages/elm/config.toml @@ -0,0 +1,11 @@ +name = "Elm" +path_suffixes = ["elm"] +line_comment = "-- " +block_comment = ["{- ", " -}"] +brackets = [ + { start = "{", end = "}", close = true, newline = true }, + { start = "[", end = "]", close = true, newline = true }, + { start = "(", end = ")", close = true, newline = true }, + { start = "\"", end = "\"", close = true, newline = false, not_in = ["string"] }, + { start = "'", end = "'", close = true, newline = false, not_in = ["string", "comment"] }, +] diff --git a/crates/zed/src/languages/elm/highlights.scm b/crates/zed/src/languages/elm/highlights.scm new file mode 100644 index 0000000000..5723c7eecb --- /dev/null +++ b/crates/zed/src/languages/elm/highlights.scm @@ -0,0 +1,72 @@ +[ + "if" + "then" + "else" + "let" + "in" + (case) + (of) + (backslash) + (as) + (port) + (exposing) + (alias) + (import) + (module) + (type) + (arrow) + ] @keyword + +[ + (eq) + (operator_identifier) + (colon) +] @operator + +(type_annotation(lower_case_identifier) @function) +(port_annotation(lower_case_identifier) @function) +(function_declaration_left(lower_case_identifier) @function.definition) + +(function_call_expr + target: (value_expr + name: (value_qid (lower_case_identifier) @function))) + +(exposed_value(lower_case_identifier) @function) +(exposed_type(upper_case_identifier) @type) + +(field_access_expr(value_expr(value_qid)) @identifier) +(lower_pattern) @variable +(record_base_identifier) @identifier + +[ + "(" + ")" +] @punctuation.bracket + +[ + "|" + "," +] @punctuation.delimiter + +(number_constant_expr) @constant + +(type_declaration(upper_case_identifier) @type) +(type_ref) @type +(type_alias_declaration name: (upper_case_identifier) @type) + +(value_expr(upper_case_qid(upper_case_identifier)) @type) + +[ + (line_comment) + (block_comment) +] @comment + +(string_escape) @string.escape + +[ + (open_quote) + (close_quote) + (regular_string_part) + (open_char) + (close_char) +] @string diff --git a/crates/zed/src/languages/elm/injections.scm b/crates/zed/src/languages/elm/injections.scm new file mode 100644 index 0000000000..0567320675 --- /dev/null +++ b/crates/zed/src/languages/elm/injections.scm @@ -0,0 +1,2 @@ +((glsl_content) @content + (#set! "language" "glsl")) diff --git a/crates/zed/src/languages/elm/outline.scm b/crates/zed/src/languages/elm/outline.scm new file mode 100644 index 0000000000..1d7d5a70b0 --- /dev/null +++ b/crates/zed/src/languages/elm/outline.scm @@ -0,0 +1,22 @@ +(type_declaration + (type) @context + (upper_case_identifier) @name) @item + +(type_alias_declaration + (type) @context + (alias) @context + name: (upper_case_identifier) @name) @item + +(type_alias_declaration + typeExpression: + (type_expression + part: (record_type + (field_type + name: (lower_case_identifier) @name) @item))) + +(union_variant + name: (upper_case_identifier) @name) @item + +(value_declaration + functionDeclarationLeft: + (function_declaration_left(lower_case_identifier) @name)) @item diff --git a/crates/zed/src/languages/glsl/config.toml b/crates/zed/src/languages/glsl/config.toml new file mode 100644 index 0000000000..4081a6381f --- /dev/null +++ b/crates/zed/src/languages/glsl/config.toml @@ -0,0 +1,9 @@ +name = "GLSL" +path_suffixes = ["vert", "frag", "tesc", "tese", "geom", "comp"] +line_comment = "// " +block_comment = ["/* ", " */"] +brackets = [ + { start = "{", end = "}", close = true, newline = true }, + { start = "[", end = "]", close = true, newline = true }, + { start = "(", end = ")", close = true, newline = true }, +] diff --git a/crates/zed/src/languages/glsl/highlights.scm b/crates/zed/src/languages/glsl/highlights.scm new file mode 100644 index 0000000000..e4503c6fbb --- /dev/null +++ b/crates/zed/src/languages/glsl/highlights.scm @@ -0,0 +1,118 @@ +"break" @keyword +"case" @keyword +"const" @keyword +"continue" @keyword +"default" @keyword +"do" @keyword +"else" @keyword +"enum" @keyword +"extern" @keyword +"for" @keyword +"if" @keyword +"inline" @keyword +"return" @keyword +"sizeof" @keyword +"static" @keyword +"struct" @keyword +"switch" @keyword +"typedef" @keyword +"union" @keyword +"volatile" @keyword +"while" @keyword + +"#define" @keyword +"#elif" @keyword +"#else" @keyword +"#endif" @keyword +"#if" @keyword +"#ifdef" @keyword +"#ifndef" @keyword +"#include" @keyword +(preproc_directive) @keyword + +"--" @operator +"-" @operator +"-=" @operator +"->" @operator +"=" @operator +"!=" @operator +"*" @operator +"&" @operator +"&&" @operator +"+" @operator +"++" @operator +"+=" @operator +"<" @operator +"==" @operator +">" @operator +"||" @operator + +"." @delimiter +";" @delimiter + +(string_literal) @string +(system_lib_string) @string + +(null) @constant +(number_literal) @number +(char_literal) @number + +(call_expression + function: (identifier) @function) +(call_expression + function: (field_expression + field: (field_identifier) @function)) +(function_declarator + declarator: (identifier) @function) +(preproc_function_def + name: (identifier) @function.special) + +(field_identifier) @property +(statement_identifier) @label +(type_identifier) @type +(primitive_type) @type +(sized_type_specifier) @type + +((identifier) @constant + (#match? @constant "^[A-Z][A-Z\\d_]*$")) + +(identifier) @variable + +(comment) @comment +; inherits: c + +[ + "in" + "out" + "inout" + "uniform" + "shared" + "layout" + "attribute" + "varying" + "buffer" + "coherent" + "readonly" + "writeonly" + "precision" + "highp" + "mediump" + "lowp" + "centroid" + "sample" + "patch" + "smooth" + "flat" + "noperspective" + "invariant" + "precise" +] @type.qualifier + +"subroutine" @keyword.function + +(extension_storage_class) @storageclass + +( + (identifier) @variable.builtin + (#match? @variable.builtin "^gl_") +) diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index ebea266191..df16ea7db9 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -154,7 +154,7 @@ fn main() { file_finder::init(cx); outline::init(cx); project_symbols::init(cx); - project_panel::init(cx); + project_panel::init(Assets, cx); diagnostics::init(cx); search::init(cx); semantic_index::init(fs.clone(), http.clone(), languages.clone(), cx); @@ -166,6 +166,7 @@ fn main() { cx.spawn(|cx| watch_themes(fs.clone(), cx)).detach(); cx.spawn(|_| watch_languages(fs.clone(), languages.clone())) .detach(); + watch_file_types(fs.clone(), cx); languages.set_theme(theme::current(cx).clone()); cx.observe_global::({ @@ -685,6 +686,26 @@ async fn watch_languages(fs: Arc, languages: Arc) -> O Some(()) } +#[cfg(debug_assertions)] +fn watch_file_types(fs: Arc, cx: &mut AppContext) { + cx.spawn(|mut cx| async move { + let mut events = fs + .watch( + "assets/icons/file_icons/file_types.json".as_ref(), + Duration::from_millis(100), + ) + .await; + while (events.next().await).is_some() { + cx.update(|cx| { + cx.update_global(|file_types, _| { + *file_types = project_panel::file_associations::FileAssociations::new(Assets); + }); + }) + } + }) + .detach() +} + #[cfg(not(debug_assertions))] async fn watch_themes(_fs: Arc, _cx: AsyncAppContext) -> Option<()> { None @@ -695,6 +716,9 @@ async fn watch_languages(_: Arc, _: Arc) -> Option<()> None } +#[cfg(not(debug_assertions))] +fn watch_file_types(fs: Arc, cx: &mut AppContext) {} + fn connect_to_cli( server_name: &str, ) -> Result<(mpsc::Receiver, IpcSender)> { @@ -895,7 +919,14 @@ pub fn dock_default_item_factory( }) .notify_err(workspace, cx)?; - let terminal_view = cx.add_view(|cx| TerminalView::new(terminal, workspace.database_id(), cx)); + let terminal_view = cx.add_view(|cx| { + TerminalView::new( + terminal, + workspace.weak_handle(), + workspace.database_id(), + cx, + ) + }); Some(Box::new(terminal_view)) } diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index 9112cd207b..22a260b588 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -93,6 +93,7 @@ pub fn menus() -> Vec> { MenuItem::action("Toggle Left Dock", workspace::ToggleLeftDock), MenuItem::action("Toggle Right Dock", workspace::ToggleRightDock), MenuItem::action("Toggle Bottom Dock", workspace::ToggleBottomDock), + MenuItem::action("Close All Docks", workspace::CloseAllDocks), MenuItem::submenu(Menu { name: "Editor Layout", items: vec![ diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 9dffc644ae..84cef99f81 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -315,6 +315,7 @@ pub fn initialize_workspace( workspace.status_bar().update(cx, |status_bar, cx| { status_bar.add_left_item(diagnostic_summary, cx); status_bar.add_left_item(activity_indicator, cx); + status_bar.add_right_item(feedback_button, cx); status_bar.add_right_item(copilot, cx); status_bar.add_right_item(active_buffer_language, cx); @@ -338,9 +339,22 @@ pub fn initialize_workspace( let assistant_panel = AssistantPanel::load(workspace_handle.clone(), cx.clone()); let (project_panel, terminal_panel, assistant_panel) = futures::try_join!(project_panel, terminal_panel, assistant_panel)?; + workspace_handle.update(&mut cx, |workspace, cx| { let project_panel_position = project_panel.position(cx); - workspace.add_panel(project_panel, cx); + workspace.add_panel_with_extra_event_handler( + project_panel, + cx, + |workspace, _, event, cx| match event { + project_panel::Event::NewSearchInDirectory { dir_entry } => { + search::ProjectSearchView::new_search_in_directory(workspace, dir_entry, cx) + } + project_panel::Event::ActivatePanel => { + workspace.focus_panel::(cx); + } + _ => {} + }, + ); workspace.add_panel(terminal_panel, cx); workspace.add_panel(assistant_panel, cx); @@ -1085,8 +1099,46 @@ mod tests { ) .await; - let project = Project::test(app_state.fs.clone(), ["/dir1".as_ref()], cx).await; - let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); + cx.update(|cx| open_paths(&[PathBuf::from("/dir1/")], &app_state, None, cx)) + .await + .unwrap(); + assert_eq!(cx.window_ids().len(), 1); + let workspace = cx + .read_window(cx.window_ids()[0], |cx| cx.root_view().clone()) + .unwrap() + .downcast::() + .unwrap(); + + #[track_caller] + fn assert_project_panel_selection( + workspace: &Workspace, + expected_worktree_path: &Path, + expected_entry_path: &Path, + cx: &AppContext, + ) { + let project_panel = [ + workspace.left_dock().read(cx).panel::(), + workspace.right_dock().read(cx).panel::(), + workspace.bottom_dock().read(cx).panel::(), + ] + .into_iter() + .find_map(std::convert::identity) + .expect("found no project panels") + .read(cx); + let (selected_worktree, selected_entry) = project_panel + .selected_entry(cx) + .expect("project panel should have a selected entry"); + assert_eq!( + selected_worktree.abs_path().as_ref(), + expected_worktree_path, + "Unexpected project panel selected worktree path" + ); + assert_eq!( + selected_entry.path.as_ref(), + expected_entry_path, + "Unexpected project panel selected entry path" + ); + } // Open a file within an existing worktree. workspace @@ -1095,9 +1147,10 @@ mod tests { }) .await; cx.read(|cx| { + let workspace = workspace.read(cx); + assert_project_panel_selection(workspace, Path::new("/dir1"), Path::new("a.txt"), cx); assert_eq!( workspace - .read(cx) .active_pane() .read(cx) .active_item() @@ -1118,8 +1171,9 @@ mod tests { }) .await; cx.read(|cx| { + let workspace = workspace.read(cx); + assert_project_panel_selection(workspace, Path::new("/dir2/b.txt"), Path::new(""), cx); let worktree_roots = workspace - .read(cx) .worktrees(cx) .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref()) .collect::>(); @@ -1132,7 +1186,6 @@ mod tests { ); assert_eq!( workspace - .read(cx) .active_pane() .read(cx) .active_item() @@ -1153,8 +1206,9 @@ mod tests { }) .await; cx.read(|cx| { + let workspace = workspace.read(cx); + assert_project_panel_selection(workspace, Path::new("/dir3"), Path::new("c.txt"), cx); let worktree_roots = workspace - .read(cx) .worktrees(cx) .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref()) .collect::>(); @@ -1167,7 +1221,6 @@ mod tests { ); assert_eq!( workspace - .read(cx) .active_pane() .read(cx) .active_item() @@ -1188,8 +1241,9 @@ mod tests { }) .await; cx.read(|cx| { + let workspace = workspace.read(cx); + assert_project_panel_selection(workspace, Path::new("/d.txt"), Path::new(""), cx); let worktree_roots = workspace - .read(cx) .worktrees(cx) .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref()) .collect::>(); @@ -1202,7 +1256,6 @@ mod tests { ); let visible_worktree_roots = workspace - .read(cx) .visible_worktrees(cx) .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref()) .collect::>(); @@ -1216,7 +1269,6 @@ mod tests { assert_eq!( workspace - .read(cx) .active_pane() .read(cx) .active_item() @@ -2334,7 +2386,7 @@ mod tests { editor::init(cx); project_panel::init_settings(cx); pane::init(cx); - project_panel::init(cx); + project_panel::init((), cx); terminal_view::init(cx); ai::init(cx); app_state diff --git a/script/generate-licenses b/script/generate-licenses index 14c9d4c79f..9a2fe8921a 100755 --- a/script/generate-licenses +++ b/script/generate-licenses @@ -26,4 +26,4 @@ sed -i '' 's/'/'\''/g' $OUTPUT_FILE # The ` '\'' ` thing ends the string, a sed -i '' 's/=/=/g' $OUTPUT_FILE sed -i '' 's/`/`/g' $OUTPUT_FILE sed -i '' 's/<//g' $OUTPUT_FILE \ No newline at end of file +sed -i '' 's/>/>/g' $OUTPUT_FILE diff --git a/script/zed-with-local-servers b/script/zed-with-local-servers index ed07862d30..f1de38adcf 100755 --- a/script/zed-with-local-servers +++ b/script/zed-with-local-servers @@ -1 +1,3 @@ +#!/bin/bash + ZED_ADMIN_API_TOKEN=secret ZED_SERVER_URL=http://localhost:3000 cargo run $@ diff --git a/styles/src/style_tree/editor.ts b/styles/src/style_tree/editor.ts index 7e20f09b32..acf983e8be 100644 --- a/styles/src/style_tree/editor.ts +++ b/styles/src/style_tree/editor.ts @@ -170,6 +170,8 @@ export default function editor(): any { line_number: with_opacity(foreground(layer), 0.35), line_number_active: foreground(layer), rename_fade: 0.6, + wrap_guide: with_opacity(foreground(layer), 0.1), + active_wrap_guide: with_opacity(foreground(layer), 0.2), unnecessary_code_fade: 0.5, selection: theme.players[0], whitespace: theme.ramps.neutral(0.5).hex(), diff --git a/styles/src/style_tree/project_panel.ts b/styles/src/style_tree/project_panel.ts index af997d0a6e..e239f9a840 100644 --- a/styles/src/style_tree/project_panel.ts +++ b/styles/src/style_tree/project_panel.ts @@ -46,9 +46,11 @@ export default function project_panel(): any { const base_properties = { height: 22, background: background(theme.middle), - icon_color: foreground(theme.middle, "variant"), - icon_size: 7, - icon_spacing: 5, + chevron_color: foreground(theme.middle, "variant"), + icon_color: with_opacity(foreground(theme.middle, "active"), 0.3), + chevron_size: 7, + icon_size: 14, + icon_spacing: 6, text: text(theme.middle, "sans", "variant", { size: "sm" }), status: { ...git_status, @@ -62,17 +64,17 @@ export default function project_panel(): any { const unselected_default_style = merge( base_properties, unselected?.default ?? {}, - {} + {}, ) const unselected_hovered_style = merge( base_properties, { background: background(theme.middle, "hovered") }, - unselected?.hovered ?? {} + unselected?.hovered ?? {}, ) const unselected_clicked_style = merge( base_properties, { background: background(theme.middle, "pressed") }, - unselected?.clicked ?? {} + unselected?.clicked ?? {}, ) const selected_default_style = merge( base_properties, @@ -80,7 +82,7 @@ export default function project_panel(): any { background: background(theme.lowest), text: text(theme.lowest, "sans", { size: "sm" }), }, - selected_style?.default ?? {} + selected_style?.default ?? {}, ) const selected_hovered_style = merge( base_properties, @@ -88,7 +90,7 @@ export default function project_panel(): any { background: background(theme.lowest, "hovered"), text: text(theme.lowest, "sans", { size: "sm" }), }, - selected_style?.hovered ?? {} + selected_style?.hovered ?? {}, ) const selected_clicked_style = merge( base_properties, @@ -96,7 +98,7 @@ export default function project_panel(): any { background: background(theme.lowest, "pressed"), text: text(theme.lowest, "sans", { size: "sm" }), }, - selected_style?.clicked ?? {} + selected_style?.clicked ?? {}, ) return toggleable({ @@ -155,7 +157,7 @@ export default function project_panel(): any { }), background: background(theme.middle), padding: { left: 6, right: 6, top: 0, bottom: 6 }, - indent_width: 12, + indent_width: 20, entry: default_entry, dragged_entry: { ...default_entry.inactive.default, @@ -173,7 +175,7 @@ export default function project_panel(): any { default: { icon_color: foreground(theme.middle, "variant"), }, - } + }, ), cut_entry: entry( { @@ -188,7 +190,7 @@ export default function project_panel(): any { size: "sm", }), }, - } + }, ), filename_editor: { background: background(theme.middle, "on"), diff --git a/styles/src/style_tree/status_bar.ts b/styles/src/style_tree/status_bar.ts index 9aeea866f3..06afc37823 100644 --- a/styles/src/style_tree/status_bar.ts +++ b/styles/src/style_tree/status_bar.ts @@ -1,6 +1,8 @@ import { background, border, foreground, text } from "./components" import { interactive, toggleable } from "../element" import { useTheme } from "../common" +import { text_button } from "../component/text_button" + export default function status_bar(): any { const theme = useTheme() @@ -26,20 +28,16 @@ export default function status_bar(): any { right: 6, }, border: border(layer, { top: true, overlay: true }), - cursor_position: text(layer, "sans", "variant"), - active_language: interactive({ - base: { - padding: { left: 6, right: 6 }, - ...text(layer, "sans", "variant"), - }, - state: { - hovered: { - ...text(layer, "sans", "on"), - }, - }, + cursor_position: text(layer, "sans", "variant", { size: "xs" }), + vim_mode_indicator: { + margin: { left: 6 }, + ...text(layer, "mono", "variant", { size: "xs" }), + }, + active_language: text_button({ + color: "variant" }), - auto_update_progress_message: text(layer, "sans", "variant"), - auto_update_done_message: text(layer, "sans", "variant"), + auto_update_progress_message: text(layer, "sans", "variant", { size: "xs" }), + auto_update_done_message: text(layer, "sans", "variant", { size: "xs" }), lsp_status: interactive({ base: { ...diagnostic_status_container, @@ -59,9 +57,9 @@ export default function status_bar(): any { }), diagnostic_message: interactive({ base: { - ...text(layer, "sans"), + ...text(layer, "sans", { size: "xs" }), }, - state: { hovered: text(layer, "sans", "hovered") }, + state: { hovered: text(layer, "sans", "hovered", { size: "xs" }) }, }), diagnostic_summary: interactive({ base: { @@ -117,7 +115,7 @@ export default function status_bar(): any { icon_color: foreground(layer, "variant"), label: { margin: { left: 6 }, - ...text(layer, "sans", { size: "sm" }), + ...text(layer, "sans", { size: "xs" }), }, }, state: {