diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49881e2e7cb0cc..602808f1b59fa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,12 @@ jobs: script/check-licenses script/generate-licenses /tmp/zed_licenses_output + - name: Check for new vulnerable dependencies + if: github.event_name == 'pull_request' + uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4 + with: + license-check: false + - name: Run tests uses: ./.github/actions/run_tests diff --git a/.github/workflows/community_update_all_top_ranking_issues.yml b/.github/workflows/community_update_all_top_ranking_issues.yml index af69446462e476..9642315bb359b1 100644 --- a/.github/workflows/community_update_all_top_ranking_issues.yml +++ b/.github/workflows/community_update_all_top_ranking_issues.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up uv - uses: astral-sh/setup-uv@2e657c127d5b1635d5a8e3fa40e0ac50a5bf6992 # v3 + uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 with: version: "latest" enable-cache: true diff --git a/.github/workflows/community_update_weekly_top_ranking_issues.yml b/.github/workflows/community_update_weekly_top_ranking_issues.yml index 18f525ab3b590f..53dcfd1d87bd9b 100644 --- a/.github/workflows/community_update_weekly_top_ranking_issues.yml +++ b/.github/workflows/community_update_weekly_top_ranking_issues.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up uv - uses: astral-sh/setup-uv@2e657c127d5b1635d5a8e3fa40e0ac50a5bf6992 # v3 + uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 with: version: "latest" enable-cache: true diff --git a/.gitignore b/.gitignore index d19c5a102aac8a..fc6263eb7e194c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.direnv +.envrc .idea **/target **/cargo-target diff --git a/Cargo.lock b/Cargo.lock index 29936d3ffa2ff9..236f640964b21e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,9 +342,9 @@ dependencies = [ [[package]] name = "ashpd" -version = "0.9.2" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d43c03d9e36dd40cab48435be0b09646da362c278223ca535493877b2c1dee9" +checksum = "e9c39d707614dbcc6bed00015539f488d8e3fe3e66ed60961efc0c90f4b380b3" dependencies = [ "async-fs 2.1.2", "async-net 2.0.0", @@ -355,7 +355,7 @@ dependencies = [ "serde", "serde_repr", "url", - "zbus", + "zbus 5.1.1", ] [[package]] @@ -456,14 +456,19 @@ version = "0.1.0" dependencies = [ "anyhow", "assistant_tool", + "client", "collections", "command_palette_hooks", + "context_server", "editor", "feature_flags", "futures 0.3.31", "gpui", "language_model", "language_model_selector", + "language_models", + "log", + "project", "proto", "serde", "serde_json", @@ -925,20 +930,6 @@ version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" -[[package]] -name = "async-tls" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfeefd0ca297cbbb3bd34fd6b228401c2a5177038257afd751bc29f0a2da4795" -dependencies = [ - "futures-core", - "futures-io", - "rustls 0.20.9", - "rustls-pemfile 1.0.4", - "webpki", - "webpki-roots 0.22.6", -] - [[package]] name = "async-tls" version = "0.13.0" @@ -963,21 +954,6 @@ dependencies = [ "syn 2.0.87", ] -[[package]] -name = "async-tungstenite" -version = "0.22.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce01ac37fdc85f10a43c43bc582cbd566720357011578a935761075f898baf58" -dependencies = [ - "async-std", - "async-tls 0.12.0", - "futures-io", - "futures-util", - "log", - "pin-project-lite", - "tungstenite 0.19.0", -] - [[package]] name = "async-tungstenite" version = "0.28.0" @@ -985,7 +961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90e661b6cb0a6eb34d02c520b052daa3aa9ac0cc02495c9d066bbce13ead132b" dependencies = [ "async-std", - "async-tls 0.13.0", + "async-tls", "futures-io", "futures-util", "log", @@ -1155,7 +1131,7 @@ dependencies = [ "fastrand 2.2.0", "hex", "http 0.2.12", - "ring 0.17.8", + "ring", "time", "tokio", "tracing", @@ -1345,7 +1321,7 @@ dependencies = [ "once_cell", "p256", "percent-encoding", - "ring 0.17.8", + "ring", "sha2", "subtle", "time", @@ -1975,9 +1951,9 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d" +checksum = "8b37c88a63ffd85d15b406896cc343916d7cf57838a847b3a6f2ca5d39a5695a" dependencies = [ "bytemuck_derive", ] @@ -2502,7 +2478,7 @@ dependencies = [ "anyhow", "async-native-tls", "async-recursion 0.3.2", - "async-tungstenite 0.28.0", + "async-tungstenite", "chrono", "clock", "cocoa 0.26.0", @@ -2634,7 +2610,7 @@ dependencies = [ "assistant_tool", "async-stripe", "async-trait", - "async-tungstenite 0.28.0", + "async-tungstenite", "audio", "aws-config", "aws-sdk-kinesis", @@ -3440,9 +3416,9 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.9" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f" dependencies = [ "quote", "syn 2.0.87", @@ -3752,6 +3728,12 @@ dependencies = [ "phf", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dwrote" version = "0.11.2" @@ -3896,9 +3878,9 @@ dependencies = [ [[package]] name = "embed-resource" -version = "2.5.1" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b68b6f9f63a0b6a38bc447d4ce84e2b388f3ec95c99c641c8ff0dd3ef89a6379" +checksum = "4762ce03154ba57ebaeee60cc631901ceae4f18219cbb874e464347471594742" dependencies = [ "cc", "memchr", @@ -4529,7 +4511,7 @@ dependencies = [ "futures-core", "futures-sink", "nanorand", - "spin 0.9.8", + "spin", ] [[package]] @@ -6278,9 +6260,9 @@ dependencies = [ [[package]] name = "ipc-channel" -version = "0.18.3" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f4c80f2df4fc64fb7fc2cff69fc034af26e6e6617ea9f1313131af464b9ca0" +checksum = "6fb8251fb7bcd9ccd3725ed8deae9fe7db8e586495c9eb5b0c52e6233e5e75ea" dependencies = [ "bincode", "crossbeam-channel", @@ -6441,7 +6423,7 @@ dependencies = [ "base64 0.21.7", "js-sys", "pem", - "ring 0.17.8", + "ring", "serde", "serde_json", "simple_asn1", @@ -6449,47 +6431,31 @@ dependencies = [ [[package]] name = "jupyter-protocol" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d4d496ac890e14efc12c5289818b3c39e3026a7bb02d5576b011e1a062d4bcc" +checksum = "503458f8125fd9047ed0a9d95d7a93adc5eaf8bce48757c6d401e09f71ad3407" dependencies = [ "anyhow", "async-trait", "bytes 1.8.0", "chrono", "futures 0.3.31", - "jupyter-serde", - "rand 0.8.5", - "serde", - "serde_json", - "uuid", -] - -[[package]] -name = "jupyter-serde" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32aa595c3912167b7eafcaa822b767ad1fa9605a18127fc9ac741241b796410e" -dependencies = [ - "anyhow", "serde", "serde_json", - "thiserror 1.0.69", "uuid", ] [[package]] name = "jupyter-websocket-client" -version = "0.5.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5850894210a3f033ff730d6f956b0335db38573ce7bb61c6abbf69dcbe284ba7" +checksum = "58d9afa5bc6eeafb78f710a2efc585f69099f8b6a99dc7eb826581e3773a6e31" dependencies = [ "anyhow", "async-trait", - "async-tungstenite 0.22.2", + "async-tungstenite", "futures 0.3.31", "jupyter-protocol", - "jupyter-serde", "serde", "serde_json", "url", @@ -6701,11 +6667,14 @@ version = "0.1.0" dependencies = [ "anyhow", "editor", + "file_finder", + "file_icons", "fuzzy", "gpui", "language", "picker", "project", + "settings", "ui", "util", "workspace", @@ -6801,7 +6770,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin", ] [[package]] @@ -6818,9 +6787,9 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" [[package]] name = "libc" -version = "0.2.164" +version = "0.2.162" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" +checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" [[package]] name = "libdbus-sys" @@ -7523,13 +7492,13 @@ dependencies = [ [[package]] name = "nbformat" -version = "0.7.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa6827a3881aa100bb2241cd2633b3c79474dbc93704f1f2cf5cc85064cda4be" +checksum = "19835ad46507d80d9671e10a1c7c335655f4f3033aeb066fe025f14e070c2e66" dependencies = [ "anyhow", "chrono", - "jupyter-serde", + "jupyter-protocol", "serde", "serde_json", "thiserror 1.0.69", @@ -7980,9 +7949,9 @@ dependencies = [ "serde", "sha2", "subtle", - "zbus", + "zbus 4.4.0", "zeroize", - "zvariant", + "zvariant 4.2.0", ] [[package]] @@ -9213,9 +9182,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.89" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] @@ -9305,6 +9274,7 @@ dependencies = [ "anyhow", "client", "collections", + "command_palette_hooks", "db", "editor", "file_icons", @@ -9554,7 +9524,7 @@ dependencies = [ "bytes 1.8.0", "getrandom 0.2.15", "rand 0.8.5", - "ring 0.17.8", + "ring", "rustc-hash 2.0.0", "rustls 0.23.16", "rustls-pki-types", @@ -10197,21 +10167,6 @@ dependencies = [ "util", ] -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin 0.5.2", - "untrusted 0.7.1", - "web-sys", - "winapi", -] - [[package]] name = "ring" version = "0.17.8" @@ -10222,8 +10177,8 @@ dependencies = [ "cfg-if", "getrandom 0.2.15", "libc", - "spin 0.9.8", - "untrusted 0.9.0", + "spin", + "untrusted", "windows-sys 0.52.0", ] @@ -10279,13 +10234,12 @@ dependencies = [ [[package]] name = "rodio" -version = "0.19.0" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb" +checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1" dependencies = [ "cpal", "hound", - "thiserror 1.0.69", ] [[package]] @@ -10317,7 +10271,7 @@ name = "rpc" version = "0.1.0" dependencies = [ "anyhow", - "async-tungstenite 0.28.0", + "async-tungstenite", "base64 0.22.1", "chrono", "collections", @@ -10359,9 +10313,9 @@ dependencies = [ [[package]] name = "runtimelib" -version = "0.22.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a8ab675beb5cf25c28f9c6ddb8f47bcf73b43872797e6ab6157865f44d1e19" +checksum = "445ff0ee3d5c832cdd27efadd004a741423db1f91bd1de593a14b21211ea084c" dependencies = [ "anyhow", "async-dispatcher", @@ -10374,8 +10328,7 @@ dependencies = [ "futures 0.3.31", "glob", "jupyter-protocol", - "jupyter-serde", - "ring 0.17.8", + "ring", "serde", "serde_json", "shellexpand 3.1.0", @@ -10502,18 +10455,6 @@ dependencies = [ "rustix 0.38.40", ] -[[package]] -name = "rustls" -version = "0.20.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" -dependencies = [ - "log", - "ring 0.16.20", - "sct", - "webpki", -] - [[package]] name = "rustls" version = "0.21.12" @@ -10521,7 +10462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "log", - "ring 0.17.8", + "ring", "rustls-webpki 0.101.7", "sct", ] @@ -10533,7 +10474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eee87ff5d9b36712a58574e12e9f0ea80f915a5b0ac518d322b24a465617925e" dependencies = [ "once_cell", - "ring 0.17.8", + "ring", "rustls-pki-types", "rustls-webpki 0.102.8", "subtle", @@ -10598,8 +10539,8 @@ version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ - "ring 0.17.8", - "untrusted 0.9.0", + "ring", + "untrusted", ] [[package]] @@ -10608,9 +10549,9 @@ version = "0.102.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ - "ring 0.17.8", + "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -10724,8 +10665,8 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ - "ring 0.17.8", - "untrusted 0.9.0", + "ring", + "untrusted", ] [[package]] @@ -11013,9 +10954,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.133" +version = "1.0.132" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" dependencies = [ "indexmap 2.6.0", "itoa", @@ -11487,12 +11428,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "spin" version = "0.9.8" @@ -12416,6 +12351,7 @@ name = "terminal_view" version = "0.1.0" dependencies = [ "anyhow", + "async-recursion 1.1.1", "breadcrumbs", "client", "collections", @@ -13161,9 +13097,9 @@ dependencies = [ [[package]] name = "tree-sitter-c" -version = "0.23.1" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8b3fb515e498e258799a31d78e6603767cd6892770d9e2290ec00af5c3ad80b" +checksum = "db56fadd8c3c6bc880dffcf1177c9d1c54a71a5207716db8660189082e63b587" dependencies = [ "cc", "tree-sitter-language", @@ -13372,25 +13308,6 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" -[[package]] -name = "tungstenite" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15fba1a6d6bb030745759a9a2a588bfe8490fc8b4751a277db3a0be1c9ebbf67" -dependencies = [ - "byteorder", - "bytes 1.8.0", - "data-encoding", - "http 0.2.12", - "httparse", - "log", - "rand 0.8.5", - "sha1", - "thiserror 1.0.69", - "url", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.20.1" @@ -13602,12 +13519,6 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" @@ -13686,6 +13597,7 @@ dependencies = [ "async-fs 1.6.0", "collections", "dirs 4.0.0", + "dunce", "futures 0.3.31", "futures-lite 1.13.0", "git2", @@ -13830,6 +13742,7 @@ dependencies = [ "serde_derive", "serde_json", "settings", + "theme", "tokio", "ui", "util", @@ -14516,8 +14429,8 @@ version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" dependencies = [ - "ring 0.17.8", - "untrusted 0.9.0", + "ring", + "untrusted", ] [[package]] @@ -15580,9 +15493,45 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1162094dc63b1629fcc44150bcceeaa80798cd28bcbe7fa987b65a034c258608" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs 2.1.2", + "async-io 2.4.0", + "async-lock 3.4.0", + "async-process 2.3.0", + "async-recursion 1.1.1", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener 5.3.1", + "futures-core", + "futures-util", + "hex", + "nix", + "ordered-stream", + "serde", + "serde_repr", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.59.0", + "winnow 0.6.20", + "xdg-home", + "zbus_macros 5.1.1", + "zbus_names 4.1.0", + "zvariant 5.1.0", ] [[package]] @@ -15595,7 +15544,22 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.87", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd2dcdce3e2727f7d74b7e33b5a89539b3cc31049562137faf7ae4eb86cd16d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.87", + "zbus_names 4.1.0", + "zvariant 5.1.0", + "zvariant_utils 3.0.2", ] [[package]] @@ -15606,12 +15570,24 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "856b7a38811f71846fd47856ceee8bccaec8399ff53fb370247e66081ace647b" +dependencies = [ + "serde", + "static_assertions", + "winnow 0.6.20", + "zvariant 5.1.0", ] [[package]] name = "zed" -version = "0.164.0" +version = "0.165.0" dependencies = [ "activity_indicator", "anyhow", @@ -16096,13 +16072,28 @@ name = "zvariant" version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1200ee6ac32f1e5a312e455a949a4794855515d34f9909f4a3e082d14e1a56f" dependencies = [ "endi", "enumflags2", "serde", "static_assertions", "url", - "zvariant_derive", + "winnow 0.6.20", + "zvariant_derive 5.1.0", + "zvariant_utils 3.0.2", ] [[package]] @@ -16115,7 +16106,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.87", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687e3b97fae6c9104fbbd36c73d27d149abf04fb874e2efbd84838763daa8916" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.87", + "zvariant_utils 3.0.2", ] [[package]] @@ -16128,3 +16132,17 @@ dependencies = [ "quote", "syn 2.0.87", ] + +[[package]] +name = "zvariant_utils" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20d1d011a38f12360e5fcccceeff5e2c42a8eb7f27f0dcba97a0862ede05c9c6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "static_assertions", + "syn 2.0.87", + "winnow 0.6.20", +] diff --git a/Cargo.toml b/Cargo.toml index 7c141a1b6cf304..0465545990616a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -228,7 +228,9 @@ git = { path = "crates/git" } git_hosting_providers = { path = "crates/git_hosting_providers" } go_to_line = { path = "crates/go_to_line" } google_ai = { path = "crates/google_ai" } -gpui = { path = "crates/gpui", default-features = false, features = ["http_client"]} +gpui = { path = "crates/gpui", default-features = false, features = [ + "http_client", +] } gpui_macros = { path = "crates/gpui_macros" } html_to_markdown = { path = "crates/html_to_markdown" } http_client = { path = "crates/http_client" } @@ -331,7 +333,7 @@ alacritty_terminal = { git = "https://github.com/alacritty/alacritty", rev = "91 any_vec = "0.14" anyhow = "1.0.86" arrayvec = { version = "0.7.4", features = ["serde"] } -ashpd = "0.9.1" +ashpd = { version = "0.10", default-features = false, features = ["async-std"]} async-compat = "0.2.1" async-compression = { version = "0.4", features = ["gzip", "futures-io"] } async-dispatcher = "0.1" @@ -386,14 +388,14 @@ indexmap = { version = "1.6.2", features = ["serde"] } indoc = "2" itertools = "0.13.0" jsonwebtoken = "9.3" -jupyter-protocol = { version = "0.3.0" } -jupyter-websocket-client = { version = "0.5.0" } +jupyter-protocol = { version = "0.5.0" } +jupyter-websocket-client = { version = "0.8.0" } libc = "0.2" linkify = "0.10.0" log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } markup5ever_rcdom = "0.3.0" nanoid = "0.4" -nbformat = { version = "0.7.0" } +nbformat = { version = "0.9.0" } nix = "0.29" num-format = "0.4.4" once_cell = "1.19.0" @@ -403,10 +405,10 @@ parking_lot = "0.12.1" pathdiff = "0.2" pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } -pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } -pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } -pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } -pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } +pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } +pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } +pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } +pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } postage = { version = "0.5", features = ["futures-traits"] } pretty_assertions = { version = "1.3.0", features = ["unstable"] } profiling = "1" @@ -427,7 +429,7 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "fd110f "stream", ] } rsa = "0.9.6" -runtimelib = { version = "0.22.0", default-features = false, features = [ +runtimelib = { version = "0.24.0", default-features = false, features = [ "async-dispatcher-runtime", ] } rustc-demangle = "0.1.23" diff --git a/assets/icons/cursor_i_beam.svg b/assets/icons/cursor_i_beam.svg index 2e7b95b2039455..93ac068fe2a854 100644 --- a/assets/icons/cursor_i_beam.svg +++ b/assets/icons/cursor_i_beam.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/file_icons/audio.svg b/assets/icons/file_icons/audio.svg index 5152efb874e93e..672f736c958662 100644 --- a/assets/icons/file_icons/audio.svg +++ b/assets/icons/file_icons/audio.svg @@ -1,4 +1,8 @@ - - + + + + + + diff --git a/assets/icons/file_icons/diff.svg b/assets/icons/file_icons/diff.svg new file mode 100644 index 00000000000000..07c46f1799604f --- /dev/null +++ b/assets/icons/file_icons/diff.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_icons/file_types.json b/assets/icons/file_icons/file_types.json index fe293256b393cc..89da63dddacd8d 100644 --- a/assets/icons/file_icons/file_types.json +++ b/assets/icons/file_icons/file_types.json @@ -34,6 +34,7 @@ "dat": "storage", "db": "storage", "dbf": "storage", + "diff": "diff", "dll": "storage", "doc": "document", "docx": "document", @@ -112,6 +113,7 @@ "mkv": "video", "ml": "ocaml", "mli": "ocaml", + "mod": "go", "mov": "video", "mp3": "audio", "mp4": "video", @@ -127,6 +129,7 @@ "ogg": "audio", "opus": "audio", "otf": "font", + "pcss": "css", "pdb": "storage", "pdf": "document", "php": "php", @@ -173,6 +176,9 @@ "tsx": "react", "ttf": "font", "txt": "document", + "v": "v", + "vsh": "v", + "vv": "v", "vue": "vue", "wav": "audio", "webm": "video", @@ -181,6 +187,7 @@ "wmv": "video", "woff": "font", "woff2": "font", + "work": "go", "wv": "audio", "xls": "document", "xlsx": "document", @@ -235,6 +242,9 @@ "default": { "icon": "icons/file_icons/file.svg" }, + "diff": { + "icon": "icons/file_icons/diff.svg" + }, "docker": { "icon": "icons/file_icons/docker.svg" }, @@ -379,6 +389,9 @@ "typescript": { "icon": "icons/file_icons/typescript.svg" }, + "v": { + "icon": "icons/file_icons/v.svg" + }, "vcs": { "icon": "icons/file_icons/git.svg" }, diff --git a/assets/icons/file_icons/v.svg b/assets/icons/file_icons/v.svg new file mode 100644 index 00000000000000..485e27a3786e6f --- /dev/null +++ b/assets/icons/file_icons/v.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/globe.svg b/assets/icons/globe.svg new file mode 100644 index 00000000000000..2082a43984a0cc --- /dev/null +++ b/assets/icons/globe.svg @@ -0,0 +1 @@ + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 2eedc1c839a26c..2b792f353fc1e7 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -405,7 +405,7 @@ "ctrl-shift-p": "command_palette::Toggle", "f1": "command_palette::Toggle", "ctrl-shift-m": "diagnostics::Deploy", - "ctrl-shift-e": "project_panel::ToggleFocus", + "ctrl-shift-e": "pane::RevealInProjectPanel", "ctrl-shift-b": "outline_panel::ToggleFocus", "ctrl-?": "assistant::ToggleFocus", "ctrl-alt-s": "workspace::SaveAll", @@ -594,6 +594,7 @@ "ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }], "alt-ctrl-r": "project_panel::RevealInFileManager", "ctrl-shift-enter": "project_panel::OpenWithSystem", + "ctrl-shift-e": "project_panel::ToggleFocus", "ctrl-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrev", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ddbbdd3faf5bb2..71d997d2b1ab20 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -93,8 +93,6 @@ "ctrl-e": "editor::MoveToEndOfLine", "cmd-up": "editor::MoveToBeginning", "cmd-down": "editor::MoveToEnd", - "ctrl-home": "editor::MoveToBeginning", - "ctrl-end": "editor::MoveToEnd", "shift-up": "editor::SelectUp", "ctrl-shift-p": "editor::SelectUp", "shift-down": "editor::SelectDown", @@ -446,7 +444,7 @@ "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], "cmd-shift-p": "command_palette::Toggle", "cmd-shift-m": "diagnostics::Deploy", - "cmd-shift-e": "project_panel::ToggleFocus", + "cmd-shift-e": "pane::RevealInProjectPanel", "cmd-shift-b": "outline_panel::ToggleFocus", "cmd-?": "assistant::ToggleFocus", "cmd-alt-s": "workspace::SaveAll", @@ -616,6 +614,7 @@ "cmd-delete": ["project_panel::Delete", { "skip_prompt": false }], "alt-cmd-r": "project_panel::RevealInFileManager", "ctrl-shift-enter": "project_panel::OpenWithSystem", + "cmd-shift-e": "project_panel::ToggleFocus", "cmd-alt-backspace": ["project_panel::Delete", { "skip_prompt": false }], "cmd-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", @@ -731,7 +730,11 @@ "cmd-end": "terminal::ScrollToBottom", "shift-home": "terminal::ScrollToTop", "shift-end": "terminal::ScrollToBottom", - "ctrl-shift-space": "terminal::ToggleViMode" + "ctrl-shift-space": "terminal::ToggleViMode", + "ctrl-k up": "pane::SplitUp", + "ctrl-k down": "pane::SplitDown", + "ctrl-k left": "pane::SplitLeft", + "ctrl-k right": "pane::SplitRight" } } ] diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index d0c7ae192ba753..5f5933ef63e70f 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -33,6 +33,18 @@ "(": "vim::SentenceBackward", ")": "vim::SentenceForward", "|": "vim::GoToColumn", + "] ]": "vim::NextSectionStart", + "] [": "vim::NextSectionEnd", + "[ [": "vim::PreviousSectionStart", + "[ ]": "vim::PreviousSectionEnd", + "] m": "vim::NextMethodStart", + "] M": "vim::NextMethodEnd", + "[ m": "vim::PreviousMethodStart", + "[ M": "vim::PreviousMethodEnd", + "[ *": "vim::PreviousComment", + "[ /": "vim::PreviousComment", + "] *": "vim::NextComment", + "] /": "vim::NextComment", // Word motions "w": "vim::NextWordStart", "e": "vim::NextWordEnd", @@ -55,6 +67,10 @@ "n": "vim::MoveToNextMatch", "shift-n": "vim::MoveToPrevMatch", "%": "vim::Matching", + "] }": ["vim::UnmatchedForward", { "char": "}" }], + "[ {": ["vim::UnmatchedBackward", { "char": "{" }], + "] )": ["vim::UnmatchedForward", { "char": ")" }], + "[ (": ["vim::UnmatchedBackward", { "char": "(" }], "f": ["vim::PushOperator", { "FindForward": { "before": false } }], "t": ["vim::PushOperator", { "FindForward": { "before": true } }], "shift-f": ["vim::PushOperator", { "FindBackward": { "after": false } }], @@ -205,6 +221,7 @@ "shift-s": "vim::SubstituteLine", ">": ["vim::PushOperator", "Indent"], "<": ["vim::PushOperator", "Outdent"], + "=": ["vim::PushOperator", "AutoIndent"], "g u": ["vim::PushOperator", "Lowercase"], "g shift-u": ["vim::PushOperator", "Uppercase"], "g ~": ["vim::PushOperator", "OppositeCase"], @@ -271,6 +288,7 @@ "ctrl-[": ["vim::SwitchMode", "Normal"], ">": "vim::Indent", "<": "vim::Outdent", + "=": "vim::AutoIndent", "i": ["vim::PushOperator", { "Object": { "around": false } }], "a": ["vim::PushOperator", { "Object": { "around": true } }], "g c": "vim::ToggleComments", @@ -354,7 +372,8 @@ "bindings": { "escape": "vim::ClearOperators", "ctrl-c": "vim::ClearOperators", - "ctrl-[": "vim::ClearOperators" + "ctrl-[": "vim::ClearOperators", + "g c": "vim::Comment" } }, { @@ -383,7 +402,9 @@ ">": "vim::AngleBrackets", "a": "vim::Argument", "i": "vim::IndentObj", - "shift-i": ["vim::IndentObj", { "includeBelow": true }] + "shift-i": ["vim::IndentObj", { "includeBelow": true }], + "f": "vim::Method", + "c": "vim::Class" } }, { @@ -553,6 +574,12 @@ "ctrl-w shift-l": ["workspace::SwapPaneInDirection", "Right"], "ctrl-w shift-k": ["workspace::SwapPaneInDirection", "Up"], "ctrl-w shift-j": ["workspace::SwapPaneInDirection", "Down"], + "ctrl-w >": ["vim::ResizePane", "Widen"], + "ctrl-w <": ["vim::ResizePane", "Narrow"], + "ctrl-w -": ["vim::ResizePane", "Shorten"], + "ctrl-w +": ["vim::ResizePane", "Lengthen"], + "ctrl-w _": "vim::MaximizePane", + "ctrl-w =": "vim::ResetPaneSizes", "ctrl-w g t": "pane::ActivateNextItem", "ctrl-w ctrl-g t": "pane::ActivateNextItem", "ctrl-w g shift-t": "pane::ActivatePrevItem", diff --git a/assets/settings/default.json b/assets/settings/default.json index efb0cc9479197c..59305378563934 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -300,6 +300,8 @@ "scroll_beyond_last_line": "one_page", // The number of lines to keep above/below the cursor when scrolling. "vertical_scroll_margin": 3, + // Whether to scroll when clicking near the edge of the visible text area. + "autoscroll_on_clicks": false, // Scroll sensitivity multiplier. This multiplier is applied // to both the horizontal and vertical delta values while scrolling. "scroll_sensitivity": 1.0, @@ -557,6 +559,8 @@ "close_position": "right", // Whether to show the file icon for a tab. "file_icons": false, + // Whether to always show the close button on tabs. + "always_show_close_button": false, // What to do after closing the current tab. // // 1. Activate the tab that was open previously (default) diff --git a/assets/themes/andromeda/andromeda.json b/assets/themes/andromeda/andromeda.json index 532d013b369c8b..633b5c308f32e9 100644 --- a/assets/themes/andromeda/andromeda.json +++ b/assets/themes/andromeda/andromeda.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Andromeda", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/atelier/atelier.json b/assets/themes/atelier/atelier.json index 1bf4878b5a4060..f72e8e84eedea1 100644 --- a/assets/themes/atelier/atelier.json +++ b/assets/themes/atelier/atelier.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Atelier", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index 00fb6deb913917..d511ebf84af93c 100644 --- a/assets/themes/ayu/ayu.json +++ b/assets/themes/ayu/ayu.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Ayu", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index a56ea7d04685cc..908ce3a28a090b 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Gruvbox", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index 0519ead392b451..daa09f89950bd7 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "One", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/rose_pine/rose_pine.json b/assets/themes/rose_pine/rose_pine.json index 5b66c5ed3441f0..2ff97da11722c5 100644 --- a/assets/themes/rose_pine/rose_pine.json +++ b/assets/themes/rose_pine/rose_pine.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Rosé Pine", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/sandcastle/sandcastle.json b/assets/themes/sandcastle/sandcastle.json index b5239b0a5527d4..ba9e6f50fd2756 100644 --- a/assets/themes/sandcastle/sandcastle.json +++ b/assets/themes/sandcastle/sandcastle.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Sandcastle", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/solarized/solarized.json b/assets/themes/solarized/solarized.json index 7bd0c53f52c949..fe86793cdccfe3 100644 --- a/assets/themes/solarized/solarized.json +++ b/assets/themes/solarized/solarized.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Solarized", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/summercamp/summercamp.json b/assets/themes/summercamp/summercamp.json index 84423a86009aa9..c2206f9aab0877 100644 --- a/assets/themes/summercamp/summercamp.json +++ b/assets/themes/summercamp/summercamp.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Summercamp", "author": "Zed Industries", "themes": [ diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index 7e4e38e3205167..6d619a76b9295b 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -342,8 +342,7 @@ fn register_slash_commands(prompt_builder: Option>, cx: &mut slash_command_registry.register_command(terminal_command::TerminalSlashCommand, true); slash_command_registry.register_command(now_command::NowSlashCommand, false); slash_command_registry.register_command(diagnostics_command::DiagnosticsSlashCommand, true); - slash_command_registry.register_command(fetch_command::FetchSlashCommand, false); - slash_command_registry.register_command(fetch_command::FetchSlashCommand, false); + slash_command_registry.register_command(fetch_command::FetchSlashCommand, true); if let Some(prompt_builder) = prompt_builder { cx.observe_flag::({ diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 7467d5dfd482d2..109c9c32370cf7 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -416,7 +416,6 @@ impl AssistantPanel { ControlFlow::Break(()) }); - pane.set_can_split(false, cx); pane.set_can_navigate(true, cx); pane.display_nav_history_buttons(None); pane.set_should_display_tab_bar(|_| true); @@ -451,6 +450,7 @@ impl AssistantPanel { .gap(DynamicSpacing::Base02.rems(cx)) .child( IconButton::new("new-chat", IconName::Plus) + .icon_size(IconSize::Small) .on_click( cx.listener(|_, _, cx| { cx.dispatch_action(NewContext.boxed_clone()) diff --git a/crates/assistant/src/slash_command/fetch_command.rs b/crates/assistant/src/slash_command/fetch_command.rs index 4d38bb20a7baa7..96ea05c3023132 100644 --- a/crates/assistant/src/slash_command/fetch_command.rs +++ b/crates/assistant/src/slash_command/fetch_command.rs @@ -108,6 +108,10 @@ impl SlashCommand for FetchSlashCommand { "Insert fetched URL contents".into() } + fn icon(&self) -> IconName { + IconName::Globe + } + fn menu_text(&self) -> String { self.description() } @@ -162,7 +166,7 @@ impl SlashCommand for FetchSlashCommand { text, sections: vec![SlashCommandOutputSection { range, - icon: IconName::AtSign, + icon: IconName::Globe, label: format!("fetch {}", url).into(), metadata: None, }], diff --git a/crates/assistant/src/slash_command_picker.rs b/crates/assistant/src/slash_command_picker.rs index 8e797d6184268d..215888540a93c5 100644 --- a/crates/assistant/src/slash_command_picker.rs +++ b/crates/assistant/src/slash_command_picker.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use gpui::{AnyElement, DismissEvent, SharedString, Task, WeakView}; use picker::{Picker, PickerDelegate, PickerEditorPosition}; -use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverTrigger}; +use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverTrigger, Tooltip}; use crate::assistant_panel::ContextEditor; use crate::SlashCommandWorkingSet; @@ -177,11 +177,17 @@ impl PickerDelegate for SlashCommandDelegate { .inset(true) .spacing(ListItemSpacing::Dense) .selected(selected) + .tooltip({ + let description = info.description.clone(); + move |cx| cx.new_view(|_| Tooltip::new(description.clone())).into() + }) .child( v_flex() .group(format!("command-entry-label-{ix}")) .w_full() + .py_0p5() .min_w(px(250.)) + .max_w(px(400.)) .child( h_flex() .gap_1p5() @@ -192,7 +198,7 @@ impl PickerDelegate for SlashCommandDelegate { { label.push_str(&args); } - Label::new(label).size(LabelSize::Small) + Label::new(label).single_line().size(LabelSize::Small) })) .children(info.args.clone().filter(|_| !selected).map( |args| { @@ -200,6 +206,7 @@ impl PickerDelegate for SlashCommandDelegate { .font_buffer(cx) .child( Label::new(args) + .single_line() .size(LabelSize::Small) .color(Color::Muted), ) @@ -210,9 +217,11 @@ impl PickerDelegate for SlashCommandDelegate { )), ) .child( - Label::new(info.description.clone()) - .size(LabelSize::Small) - .color(Color::Muted), + div().overflow_hidden().text_ellipsis().child( + Label::new(info.description.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ), ), ), ), diff --git a/crates/assistant/src/terminal_inline_assistant.rs b/crates/assistant/src/terminal_inline_assistant.rs index a5424a8d7e2d2b..d60a556cf06cf3 100644 --- a/crates/assistant/src/terminal_inline_assistant.rs +++ b/crates/assistant/src/terminal_inline_assistant.rs @@ -32,7 +32,7 @@ use telemetry_events::{AssistantEvent, AssistantKind, AssistantPhase}; use terminal::Terminal; use terminal_view::TerminalView; use theme::ThemeSettings; -use ui::{prelude::*, IconButtonShape, Tooltip}; +use ui::{prelude::*, text_for_action, IconButtonShape, Tooltip}; use util::ResultExt; use workspace::{notifications::NotificationId, Toast, Workspace}; @@ -704,7 +704,7 @@ impl PromptEditor { cx, ); editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); - editor.set_placeholder_text("Add a prompt…", cx); + editor.set_placeholder_text(Self::placeholder_text(cx), cx); editor }); @@ -737,6 +737,14 @@ impl PromptEditor { this } + fn placeholder_text(cx: &WindowContext) -> String { + let context_keybinding = text_for_action(&crate::ToggleFocus, cx) + .map(|keybinding| format!(" • {keybinding} for context")) + .unwrap_or_default(); + + format!("Generate…{context_keybinding} • ↓↑ for history") + } + fn subscribe_to_editor(&mut self, cx: &mut ViewContext) { self.editor_subscriptions.clear(); self.editor_subscriptions diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index ca563b05c8d469..20e8dfbc9a9891 100644 --- a/crates/assistant2/Cargo.toml +++ b/crates/assistant2/Cargo.toml @@ -15,14 +15,19 @@ doctest = false [dependencies] anyhow.workspace = true assistant_tool.workspace = true +client.workspace = true collections.workspace = true command_palette_hooks.workspace = true +context_server.workspace = true editor.workspace = true feature_flags.workspace = true futures.workspace = true gpui.workspace = true language_model.workspace = true language_model_selector.workspace = true +language_models.workspace = true +log.workspace = true +project.workspace = true proto.workspace = true settings.workspace = true serde.workspace = true diff --git a/crates/assistant2/src/assistant.rs b/crates/assistant2/src/assistant.rs index 1b33e27928a609..8ef4a1d9dcf057 100644 --- a/crates/assistant2/src/assistant.rs +++ b/crates/assistant2/src/assistant.rs @@ -1,6 +1,7 @@ mod assistant_panel; mod message_editor; mod thread; +mod thread_store; use command_palette_hooks::CommandPaletteFilter; use feature_flags::{Assistant2FeatureFlag, FeatureFlagAppExt}; diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index bf457d6c71826e..4e6b6ef227c328 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -2,9 +2,11 @@ use std::sync::Arc; use anyhow::Result; use assistant_tool::ToolWorkingSet; +use client::zed_urls; use gpui::{ - prelude::*, px, Action, AppContext, AsyncWindowContext, EventEmitter, FocusHandle, - FocusableView, Model, Pixels, Subscription, Task, View, ViewContext, WeakView, WindowContext, + prelude::*, px, Action, AnyElement, AppContext, AsyncWindowContext, EventEmitter, FocusHandle, + FocusableView, FontWeight, Model, Pixels, Subscription, Task, View, ViewContext, WeakView, + WindowContext, }; use language_model::{LanguageModelRegistry, Role}; use language_model_selector::LanguageModelSelector; @@ -13,7 +15,8 @@ use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::Workspace; use crate::message_editor::MessageEditor; -use crate::thread::{Message, Thread, ThreadEvent}; +use crate::thread::{Message, Thread, ThreadError, ThreadEvent}; +use crate::thread_store::ThreadStore; use crate::{NewThread, ToggleFocus, ToggleModelSelector}; pub fn init(cx: &mut AppContext) { @@ -29,9 +32,12 @@ pub fn init(cx: &mut AppContext) { pub struct AssistantPanel { workspace: WeakView, + #[allow(unused)] + thread_store: Model, thread: Model, message_editor: View, tools: Arc, + last_error: Option, _subscriptions: Vec, } @@ -42,13 +48,25 @@ impl AssistantPanel { ) -> Task>> { cx.spawn(|mut cx| async move { let tools = Arc::new(ToolWorkingSet::default()); + let thread_store = workspace + .update(&mut cx, |workspace, cx| { + let project = workspace.project().clone(); + ThreadStore::new(project, tools.clone(), cx) + })? + .await?; + workspace.update(&mut cx, |workspace, cx| { - cx.new_view(|cx| Self::new(workspace, tools, cx)) + cx.new_view(|cx| Self::new(workspace, thread_store, tools, cx)) }) }) } - fn new(workspace: &Workspace, tools: Arc, cx: &mut ViewContext) -> Self { + fn new( + workspace: &Workspace, + thread_store: Model, + tools: Arc, + cx: &mut ViewContext, + ) -> Self { let thread = cx.new_model(|cx| Thread::new(tools.clone(), cx)); let subscriptions = vec![ cx.observe(&thread, |_, _, cx| cx.notify()), @@ -57,9 +75,11 @@ impl AssistantPanel { Self { workspace: workspace.weak_handle(), + thread_store, thread: thread.clone(), message_editor: cx.new_view(|cx| MessageEditor::new(thread, cx)), tools, + last_error: None, _subscriptions: subscriptions, } } @@ -86,6 +106,9 @@ impl AssistantPanel { cx: &mut ViewContext, ) { match event { + ThreadEvent::ShowError(error) => { + self.last_error = Some(error.clone()); + } ThreadEvent::StreamedCompletion => {} ThreadEvent::UsePendingTools => { let pending_tool_uses = self @@ -304,6 +327,152 @@ impl AssistantPanel { ) .child(v_flex().p_1p5().child(Label::new(message.text.clone()))) } + + fn render_last_error(&self, cx: &mut ViewContext) -> Option { + let last_error = self.last_error.as_ref()?; + + Some( + div() + .absolute() + .right_3() + .bottom_12() + .max_w_96() + .py_2() + .px_3() + .elevation_2(cx) + .occlude() + .child(match last_error { + ThreadError::PaymentRequired => self.render_payment_required_error(cx), + ThreadError::MaxMonthlySpendReached => { + self.render_max_monthly_spend_reached_error(cx) + } + ThreadError::Message(error_message) => { + self.render_error_message(error_message, cx) + } + }) + .into_any(), + ) + } + + fn render_payment_required_error(&self, cx: &mut ViewContext) -> AnyElement { + const ERROR_MESSAGE: &str = "Free tier exceeded. Subscribe and add payment to continue using Zed LLMs. You'll be billed at cost for tokens used."; + + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_1p5() + .items_center() + .child(Icon::new(IconName::XCircle).color(Color::Error)) + .child(Label::new("Free Usage Exceeded").weight(FontWeight::MEDIUM)), + ) + .child( + div() + .id("error-message") + .max_h_24() + .overflow_y_scroll() + .child(Label::new(ERROR_MESSAGE)), + ) + .child( + h_flex() + .justify_end() + .mt_1() + .child(Button::new("subscribe", "Subscribe").on_click(cx.listener( + |this, _, cx| { + this.last_error = None; + cx.open_url(&zed_urls::account_url(cx)); + cx.notify(); + }, + ))) + .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( + |this, _, cx| { + this.last_error = None; + cx.notify(); + }, + ))), + ) + .into_any() + } + + fn render_max_monthly_spend_reached_error(&self, cx: &mut ViewContext) -> AnyElement { + const ERROR_MESSAGE: &str = "You have reached your maximum monthly spend. Increase your spend limit to continue using Zed LLMs."; + + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_1p5() + .items_center() + .child(Icon::new(IconName::XCircle).color(Color::Error)) + .child(Label::new("Max Monthly Spend Reached").weight(FontWeight::MEDIUM)), + ) + .child( + div() + .id("error-message") + .max_h_24() + .overflow_y_scroll() + .child(Label::new(ERROR_MESSAGE)), + ) + .child( + h_flex() + .justify_end() + .mt_1() + .child( + Button::new("subscribe", "Update Monthly Spend Limit").on_click( + cx.listener(|this, _, cx| { + this.last_error = None; + cx.open_url(&zed_urls::account_url(cx)); + cx.notify(); + }), + ), + ) + .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( + |this, _, cx| { + this.last_error = None; + cx.notify(); + }, + ))), + ) + .into_any() + } + + fn render_error_message( + &self, + error_message: &SharedString, + cx: &mut ViewContext, + ) -> AnyElement { + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_1p5() + .items_center() + .child(Icon::new(IconName::XCircle).color(Color::Error)) + .child( + Label::new("Error interacting with language model") + .weight(FontWeight::MEDIUM), + ), + ) + .child( + div() + .id("error-message") + .max_h_32() + .overflow_y_scroll() + .child(Label::new(error_message.clone())), + ) + .child( + h_flex() + .justify_end() + .mt_1() + .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( + |this, _, cx| { + this.last_error = None; + cx.notify(); + }, + ))), + ) + .into_any() + } } impl Render for AssistantPanel { @@ -338,5 +507,6 @@ impl Render for AssistantPanel { .border_color(cx.theme().colors().border_variant) .child(self.message_editor.clone()), ) + .children(self.render_last_error(cx)) } } diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index 0d2aab6905f62d..a5ab415a4d7e10 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -5,12 +5,13 @@ use assistant_tool::ToolWorkingSet; use collections::HashMap; use futures::future::Shared; use futures::{FutureExt as _, StreamExt as _}; -use gpui::{AppContext, EventEmitter, ModelContext, Task}; +use gpui::{AppContext, EventEmitter, ModelContext, SharedString, Task}; use language_model::{ LanguageModel, LanguageModelCompletionEvent, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, StopReason, }; +use language_models::provider::cloud::{MaxMonthlySpendReachedError, PaymentRequiredError}; use serde::{Deserialize, Serialize}; use util::post_inc; @@ -210,29 +211,28 @@ impl Thread { let result = stream_completion.await; thread - .update(&mut cx, |_thread, cx| { - let error_message = if let Some(error) = result.as_ref().err() { - let error_message = error - .chain() - .map(|err| err.to_string()) - .collect::>() - .join("\n"); - Some(error_message) - } else { - None - }; - - if let Some(error_message) = error_message { - eprintln!("Completion failed: {error_message:?}"); - } - - if let Ok(stop_reason) = result { - match stop_reason { - StopReason::ToolUse => { - cx.emit(ThreadEvent::UsePendingTools); - } - StopReason::EndTurn => {} - StopReason::MaxTokens => {} + .update(&mut cx, |_thread, cx| match result.as_ref() { + Ok(stop_reason) => match stop_reason { + StopReason::ToolUse => { + cx.emit(ThreadEvent::UsePendingTools); + } + StopReason::EndTurn => {} + StopReason::MaxTokens => {} + }, + Err(error) => { + if error.is::() { + cx.emit(ThreadEvent::ShowError(ThreadError::PaymentRequired)); + } else if error.is::() { + cx.emit(ThreadEvent::ShowError(ThreadError::MaxMonthlySpendReached)); + } else { + let error_message = error + .chain() + .map(|err| err.to_string()) + .collect::>() + .join("\n"); + cx.emit(ThreadEvent::ShowError(ThreadError::Message( + SharedString::from(error_message.clone()), + ))); } } }) @@ -305,8 +305,16 @@ impl Thread { } } +#[derive(Debug, Clone)] +pub enum ThreadError { + PaymentRequired, + MaxMonthlySpendReached, + Message(SharedString), +} + #[derive(Debug, Clone)] pub enum ThreadEvent { + ShowError(ThreadError), StreamedCompletion, UsePendingTools, ToolFinished { diff --git a/crates/assistant2/src/thread_store.rs b/crates/assistant2/src/thread_store.rs new file mode 100644 index 00000000000000..99f90eace8304e --- /dev/null +++ b/crates/assistant2/src/thread_store.rs @@ -0,0 +1,114 @@ +use std::sync::Arc; + +use anyhow::Result; +use assistant_tool::{ToolId, ToolWorkingSet}; +use collections::HashMap; +use context_server::manager::ContextServerManager; +use context_server::{ContextServerFactoryRegistry, ContextServerTool}; +use gpui::{prelude::*, AppContext, Model, ModelContext, Task}; +use project::Project; +use util::ResultExt as _; + +pub struct ThreadStore { + #[allow(unused)] + project: Model, + tools: Arc, + context_server_manager: Model, + context_server_tool_ids: HashMap, Vec>, +} + +impl ThreadStore { + pub fn new( + project: Model, + tools: Arc, + cx: &mut AppContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + let this = cx.new_model(|cx: &mut ModelContext| { + let context_server_factory_registry = + ContextServerFactoryRegistry::default_global(cx); + let context_server_manager = cx.new_model(|cx| { + ContextServerManager::new(context_server_factory_registry, project.clone(), cx) + }); + + let this = Self { + project, + tools, + context_server_manager, + context_server_tool_ids: HashMap::default(), + }; + this.register_context_server_handlers(cx); + + this + })?; + + Ok(this) + }) + } + + fn register_context_server_handlers(&self, cx: &mut ModelContext) { + cx.subscribe( + &self.context_server_manager.clone(), + Self::handle_context_server_event, + ) + .detach(); + } + + fn handle_context_server_event( + &mut self, + context_server_manager: Model, + event: &context_server::manager::Event, + cx: &mut ModelContext, + ) { + let tool_working_set = self.tools.clone(); + match event { + context_server::manager::Event::ServerStarted { server_id } => { + if let Some(server) = context_server_manager.read(cx).get_server(server_id) { + let context_server_manager = context_server_manager.clone(); + cx.spawn({ + let server = server.clone(); + let server_id = server_id.clone(); + |this, mut cx| async move { + let Some(protocol) = server.client() else { + return; + }; + + if protocol.capable(context_server::protocol::ServerCapability::Tools) { + if let Some(tools) = protocol.list_tools().await.log_err() { + let tool_ids = tools + .tools + .into_iter() + .map(|tool| { + log::info!( + "registering context server tool: {:?}", + tool.name + ); + tool_working_set.insert(Arc::new( + ContextServerTool::new( + context_server_manager.clone(), + server.id(), + tool, + ), + )) + }) + .collect::>(); + + this.update(&mut cx, |this, _cx| { + this.context_server_tool_ids.insert(server_id, tool_ids); + }) + .log_err(); + } + } + } + }) + .detach(); + } + } + context_server::manager::Event::ServerStopped { server_id } => { + if let Some(tool_ids) = self.context_server_tool_ids.remove(server_id) { + tool_working_set.remove(&tool_ids); + } + } + } + } +} diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml index 9502b58f93274e..f3bc1737649818 100644 --- a/crates/audio/Cargo.toml +++ b/crates/audio/Cargo.toml @@ -18,5 +18,5 @@ collections.workspace = true derive_more.workspace = true gpui.workspace = true parking_lot.workspace = true -rodio = { version = "0.19.0", default-features = false, features = ["wav"] } +rodio = { version = "0.20.0", default-features = false, features = ["wav"] } util.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 5dd53b5a09e6c9..fedd6738ed1839 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -16,11 +16,15 @@ doctest = false name = "cli" path = "src/main.rs" +[features] +no-bundled-uninstall = [] +default = [] + [dependencies] anyhow.workspace = true clap.workspace = true collections.workspace = true -ipc-channel = "0.18" +ipc-channel = "0.19" once_cell.workspace = true parking_lot.workspace = true paths.workspace = true diff --git a/crates/cli/build.rs b/crates/cli/build.rs new file mode 100644 index 00000000000000..399755fa28aa11 --- /dev/null +++ b/crates/cli/build.rs @@ -0,0 +1,5 @@ +fn main() { + if std::env::var("ZED_UPDATE_EXPLANATION").is_ok() { + println!(r#"cargo:rustc-cfg=feature="no-bundled-uninstall""#); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 002b0c01731907..c8e1c8d3ed30a7 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -59,6 +59,13 @@ struct Args { /// Run zed in dev-server mode #[arg(long)] dev_server_token: Option, + /// Uninstall Zed from user system + #[cfg(all( + any(target_os = "linux", target_os = "macos"), + not(feature = "no-bundled-uninstall") + ))] + #[arg(long)] + uninstall: bool, } fn parse_path_with_position(argument_str: &str) -> anyhow::Result { @@ -119,6 +126,29 @@ fn main() -> Result<()> { return Ok(()); } + #[cfg(all( + any(target_os = "linux", target_os = "macos"), + not(feature = "no-bundled-uninstall") + ))] + if args.uninstall { + static UNINSTALL_SCRIPT: &[u8] = include_bytes!("../../../script/uninstall.sh"); + + let tmp_dir = tempfile::tempdir()?; + let script_path = tmp_dir.path().join("uninstall.sh"); + fs::write(&script_path, UNINSTALL_SCRIPT)?; + + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?; + + let status = std::process::Command::new("sh") + .arg(&script_path) + .env("ZED_CHANNEL", &*release_channel::RELEASE_CHANNEL_NAME) + .status() + .context("Failed to execute uninstall script")?; + + std::process::exit(status.code().unwrap_or(1)); + } + let (server, server_name) = IpcOneShotServer::::new().context("Handshake before Zed spawn")?; let url = format!("zed-cli://{server_name}"); diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index 075c3b69b1c31a..daddefb579f907 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -197,7 +197,7 @@ pub fn init(fs: Arc, client: Arc, cx: &mut AppContext) { cx.set_global(GlobalCopilotChat(copilot_chat)); } -fn copilot_chat_config_path() -> &'static PathBuf { +fn copilot_chat_config_dir() -> &'static PathBuf { static COPILOT_CHAT_CONFIG_DIR: OnceLock = OnceLock::new(); COPILOT_CHAT_CONFIG_DIR.get_or_init(|| { @@ -207,10 +207,14 @@ fn copilot_chat_config_path() -> &'static PathBuf { home_dir().join(".config") } .join("github-copilot") - .join("hosts.json") }) } +fn copilot_chat_config_paths() -> [PathBuf; 2] { + let base_dir = copilot_chat_config_dir(); + [base_dir.join("hosts.json"), base_dir.join("apps.json")] +} + impl CopilotChat { pub fn global(cx: &AppContext) -> Option> { cx.try_global::() @@ -218,13 +222,24 @@ impl CopilotChat { } pub fn new(fs: Arc, client: Arc, cx: &AppContext) -> Self { - let mut config_file_rx = watch_config_file( - cx.background_executor(), - fs, - copilot_chat_config_path().clone(), - ); + let config_paths = copilot_chat_config_paths(); + + let resolve_config_path = { + let fs = fs.clone(); + async move { + for config_path in config_paths.iter() { + if fs.metadata(config_path).await.is_ok_and(|v| v.is_some()) { + return config_path.clone(); + } + } + config_paths[0].clone() + } + }; cx.spawn(|cx| async move { + let config_file = resolve_config_path.await; + let mut config_file_rx = watch_config_file(cx.background_executor(), fs, config_file); + while let Some(contents) = config_file_rx.next().await { let oauth_token = extract_oauth_token(contents); @@ -318,9 +333,15 @@ async fn request_api_token(oauth_token: &str, client: Arc) -> Re fn extract_oauth_token(contents: String) -> Option { serde_json::from_str::(&contents) .map(|v| { - v["github.com"]["oauth_token"] - .as_str() - .map(|v| v.to_string()) + v.as_object().and_then(|obj| { + obj.iter().find_map(|(key, value)| { + if key.starts_with("github.com") { + value["oauth_token"].as_str().map(|v| v.to_string()) + } else { + None + } + }) + }) }) .ok() .flatten() diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 6db831c1ffddad..48a92d906ea3ba 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -716,7 +716,7 @@ impl Item for ProjectDiagnosticsEditor { fn for_each_project_item( &self, cx: &AppContext, - f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item), + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), ) { self.editor.for_each_project_item(cx, f) } diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 2c580c44def3f7..f102be37fd092a 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -1,6 +1,8 @@ +use std::time::Duration; + use editor::Editor; use gpui::{ - rems, EventEmitter, IntoElement, ParentElement, Render, Styled, Subscription, View, + EventEmitter, IntoElement, ParentElement, Render, Styled, Subscription, Task, View, ViewContext, WeakView, }; use language::Diagnostic; @@ -15,6 +17,7 @@ pub struct DiagnosticIndicator { workspace: WeakView, current_diagnostic: Option, _observe_active_editor: Option, + diagnostics_update: Task<()>, } impl Render for DiagnosticIndicator { @@ -77,8 +80,10 @@ impl Render for DiagnosticIndicator { }; h_flex() - .h(rems(1.375)) .gap_2() + .pl_1() + .border_l_1() + .border_color(cx.theme().colors().border) .child( ButtonLike::new("diagnostic-indicator") .child(diagnostic_indicator) @@ -124,6 +129,7 @@ impl DiagnosticIndicator { workspace: workspace.weak_handle(), current_diagnostic: None, _observe_active_editor: None, + diagnostics_update: Task::ready(()), } } @@ -147,8 +153,17 @@ impl DiagnosticIndicator { .min_by_key(|entry| (entry.diagnostic.severity, entry.range.len())) .map(|entry| entry.diagnostic); if new_diagnostic != self.current_diagnostic { - self.current_diagnostic = new_diagnostic; - cx.notify(); + self.diagnostics_update = cx.spawn(|diagnostics_indicator, mut cx| async move { + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; + diagnostics_indicator + .update(&mut cx, |diagnostics_indicator, cx| { + diagnostics_indicator.current_diagnostic = new_diagnostic; + cx.notify(); + }) + .ok(); + }); } } } diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 5b11b18bc23888..a67dd55055c6b3 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -303,6 +303,7 @@ gpui::actions!( OpenPermalinkToLine, OpenUrl, Outdent, + AutoIndent, PageDown, PageUp, Paste, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8caad5daf4306d..9a4642e27f020c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -125,8 +125,8 @@ use parking_lot::{Mutex, RwLock}; use project::{ lsp_store::{FormatTarget, FormatTrigger}, project_settings::{GitGutterSetting, ProjectSettings}, - CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Item, Location, - LocationLink, Project, ProjectTransaction, TaskSourceKind, + CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Location, LocationLink, + Project, ProjectItem, ProjectTransaction, TaskSourceKind, }; use rand::prelude::*; use rpc::{proto::*, ErrorExt}; @@ -596,7 +596,6 @@ pub struct Editor { auto_signature_help: Option, find_all_references_task_sources: Vec, next_completion_id: CompletionId, - completion_documentation_pre_resolve_debounce: DebouncedDelay, available_code_actions: Option<(Location, Arc<[AvailableCodeAction]>)>, code_actions_task: Option>>, document_highlights_task: Option>, @@ -1007,7 +1006,7 @@ struct CompletionsMenu { matches: Arc<[StringMatch]>, selected_item: usize, scroll_handle: UniformListScrollHandle, - selected_completion_documentation_resolve_debounce: Option>>, + selected_completion_resolve_debounce: Option>>, } impl CompletionsMenu { @@ -1039,9 +1038,7 @@ impl CompletionsMenu { matches: Vec::new().into(), selected_item: 0, scroll_handle: UniformListScrollHandle::new(), - selected_completion_documentation_resolve_debounce: Some(Arc::new(Mutex::new( - DebouncedDelay::new(), - ))), + selected_completion_resolve_debounce: Some(Arc::new(Mutex::new(DebouncedDelay::new()))), } } @@ -1094,15 +1091,12 @@ impl CompletionsMenu { matches, selected_item: 0, scroll_handle: UniformListScrollHandle::new(), - selected_completion_documentation_resolve_debounce: Some(Arc::new(Mutex::new( - DebouncedDelay::new(), - ))), + selected_completion_resolve_debounce: Some(Arc::new(Mutex::new(DebouncedDelay::new()))), } } fn suppress_documentation_resolution(mut self) -> Self { - self.selected_completion_documentation_resolve_debounce - .take(); + self.selected_completion_resolve_debounce.take(); self } @@ -1114,7 +1108,7 @@ impl CompletionsMenu { self.selected_item = 0; self.scroll_handle .scroll_to_item(self.selected_item, ScrollStrategy::Top); - self.attempt_resolve_selected_completion_documentation(provider, cx); + self.resolve_selected_completion(provider, cx); cx.notify(); } @@ -1130,7 +1124,7 @@ impl CompletionsMenu { } self.scroll_handle .scroll_to_item(self.selected_item, ScrollStrategy::Top); - self.attempt_resolve_selected_completion_documentation(provider, cx); + self.resolve_selected_completion(provider, cx); cx.notify(); } @@ -1146,7 +1140,7 @@ impl CompletionsMenu { } self.scroll_handle .scroll_to_item(self.selected_item, ScrollStrategy::Top); - self.attempt_resolve_selected_completion_documentation(provider, cx); + self.resolve_selected_completion(provider, cx); cx.notify(); } @@ -1158,58 +1152,20 @@ impl CompletionsMenu { self.selected_item = self.matches.len() - 1; self.scroll_handle .scroll_to_item(self.selected_item, ScrollStrategy::Top); - self.attempt_resolve_selected_completion_documentation(provider, cx); + self.resolve_selected_completion(provider, cx); cx.notify(); } - fn pre_resolve_completion_documentation( - buffer: Model, - completions: Arc>>, - matches: Arc<[StringMatch]>, - editor: &Editor, - cx: &mut ViewContext, - ) -> Task<()> { - let settings = EditorSettings::get_global(cx); - if !settings.show_completion_documentation { - return Task::ready(()); - } - - let Some(provider) = editor.completion_provider.as_ref() else { - return Task::ready(()); - }; - - let resolve_task = provider.resolve_completions( - buffer, - matches.iter().map(|m| m.candidate_id).collect(), - completions.clone(), - cx, - ); - - cx.spawn(move |this, mut cx| async move { - if let Some(true) = resolve_task.await.log_err() { - this.update(&mut cx, |_, cx| cx.notify()).ok(); - } - }) - } - - fn attempt_resolve_selected_completion_documentation( + fn resolve_selected_completion( &mut self, provider: Option<&dyn CompletionProvider>, cx: &mut ViewContext, ) { - let settings = EditorSettings::get_global(cx); - if !settings.show_completion_documentation { - return; - } - let completion_index = self.matches[self.selected_item].candidate_id; let Some(provider) = provider else { return; }; - let Some(documentation_resolve) = self - .selected_completion_documentation_resolve_debounce - .as_ref() - else { + let Some(completion_resolve) = self.selected_completion_resolve_debounce.as_ref() else { return; }; @@ -1224,7 +1180,7 @@ impl CompletionsMenu { EditorSettings::get_global(cx).completion_documentation_secondary_query_debounce; let delay = Duration::from_millis(delay_ms); - documentation_resolve.lock().fire_new(delay, cx, |_, cx| { + completion_resolve.lock().fire_new(delay, cx, |_, cx| { cx.spawn(move |this, mut cx| async move { if let Some(true) = resolve_task.await.log_err() { this.update(&mut cx, |_, cx| cx.notify()).ok(); @@ -2140,7 +2096,6 @@ impl Editor { auto_signature_help: None, find_all_references_task_sources: Vec::new(), next_completion_id: 0, - completion_documentation_pre_resolve_debounce: DebouncedDelay::new(), next_inlay_id: 0, code_action_providers, available_code_actions: Default::default(), @@ -2999,7 +2954,7 @@ impl Editor { let start; let end; let mode; - let auto_scroll; + let mut auto_scroll; match click_count { 1 => { start = buffer.anchor_before(position.to_point(&display_map)); @@ -3035,6 +2990,7 @@ impl Editor { auto_scroll = false; } } + auto_scroll &= EditorSettings::get_global(cx).autoscroll_on_clicks; let point_to_delete: Option = { let selected_points: Vec> = @@ -4165,8 +4121,10 @@ impl Editor { if buffer.contains_str_at(selection.start, &pair.end) { let pair_start_len = pair.start.len(); - if buffer.contains_str_at(selection.start - pair_start_len, &pair.start) - { + if buffer.contains_str_at( + selection.start.saturating_sub(pair_start_len), + &pair.start, + ) { selection.start -= pair_start_len; selection.end += pair.end.len(); @@ -4546,9 +4504,9 @@ impl Editor { let sort_completions = provider.sort_completions(); let id = post_inc(&mut self.next_completion_id); - let task = cx.spawn(|this, mut cx| { + let task = cx.spawn(|editor, mut cx| { async move { - this.update(&mut cx, |this, _| { + editor.update(&mut cx, |this, _| { this.completion_tasks.retain(|(task_id, _)| *task_id >= id); })?; let completions = completions.await.log_err(); @@ -4566,34 +4524,14 @@ impl Editor { if menu.matches.is_empty() { None } else { - this.update(&mut cx, |editor, cx| { - let completions = menu.completions.clone(); - let matches = menu.matches.clone(); - - let delay_ms = EditorSettings::get_global(cx) - .completion_documentation_secondary_query_debounce; - let delay = Duration::from_millis(delay_ms); - editor - .completion_documentation_pre_resolve_debounce - .fire_new(delay, cx, |editor, cx| { - CompletionsMenu::pre_resolve_completion_documentation( - buffer, - completions, - matches, - editor, - cx, - ) - }); - }) - .ok(); Some(menu) } } else { None }; - this.update(&mut cx, |this, cx| { - let mut context_menu = this.context_menu.write(); + editor.update(&mut cx, |editor, cx| { + let mut context_menu = editor.context_menu.write(); match context_menu.as_ref() { None => {} @@ -4606,19 +4544,20 @@ impl Editor { _ => return, } - if this.focus_handle.is_focused(cx) && menu.is_some() { - let menu = menu.unwrap(); + if editor.focus_handle.is_focused(cx) && menu.is_some() { + let mut menu = menu.unwrap(); + menu.resolve_selected_completion(editor.completion_provider.as_deref(), cx); *context_menu = Some(ContextMenu::Completions(menu)); drop(context_menu); - this.discard_inline_completion(false, cx); + editor.discard_inline_completion(false, cx); cx.notify(); - } else if this.completion_tasks.len() <= 1 { + } else if editor.completion_tasks.len() <= 1 { // If there are no more completion tasks and the last menu was // empty, we should hide it. If it was already hidden, we should // also show the copilot completion when available. drop(context_menu); - if this.hide_context_menu(cx).is_none() { - this.update_visible_inline_completion(cx); + if editor.hide_context_menu(cx).is_none() { + editor.update_visible_inline_completion(cx); } } })?; @@ -6383,6 +6322,25 @@ impl Editor { }); } + pub fn autoindent(&mut self, _: &AutoIndent, cx: &mut ViewContext) { + if self.read_only(cx) { + return; + } + let selections = self + .selections + .all::(cx) + .into_iter() + .map(|s| s.range()); + + self.transact(cx, |this, cx| { + this.buffer.update(cx, |buffer, cx| { + buffer.autoindent_ranges(selections, cx); + }); + let selections = this.selections.all::(cx); + this.change_selections(Some(Autoscroll::fit()), cx, |s| s.select(selections)); + }); + } + pub fn delete_line(&mut self, _: &DeleteLine, cx: &mut ViewContext) { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let selections = self.selections.all::(cx); @@ -11884,6 +11842,10 @@ impl Editor { self.blame.as_ref() } + pub fn show_git_blame_gutter(&self) -> bool { + self.show_git_blame_gutter + } + pub fn render_git_blame_gutter(&mut self, cx: &mut WindowContext) -> bool { self.show_git_blame_gutter && self.has_blame_entries(cx) } @@ -12894,8 +12856,41 @@ impl Editor { }; for (buffer, (ranges, scroll_offset)) in new_selections_by_buffer { - let editor = - workspace.open_project_item::(pane.clone(), buffer, true, true, cx); + let editor = buffer + .read(cx) + .file() + .is_none() + .then(|| { + // Handle file-less buffers separately: those are not really the project items, so won't have a paroject path or entity id, + // so `workspace.open_project_item` will never find them, always opening a new editor. + // Instead, we try to activate the existing editor in the pane first. + let (editor, pane_item_index) = + pane.read(cx).items().enumerate().find_map(|(i, item)| { + let editor = item.downcast::()?; + let singleton_buffer = + editor.read(cx).buffer().read(cx).as_singleton()?; + if singleton_buffer == buffer { + Some((editor, i)) + } else { + None + } + })?; + pane.update(cx, |pane, cx| { + pane.activate_item(pane_item_index, true, true, cx) + }); + Some(editor) + }) + .flatten() + .unwrap_or_else(|| { + workspace.open_project_item::( + pane.clone(), + buffer, + true, + true, + cx, + ) + }); + editor.update(cx, |editor, cx| { let autoscroll = match scroll_offset { Some(scroll_offset) => Autoscroll::top_relative(scroll_offset as usize), @@ -14682,7 +14677,8 @@ impl ViewInputHandler for Editor { let start = OffsetUtf16(range_utf16.start).to_display_point(&snapshot); let x = snapshot.x_for_display_point(start, &text_layout_details) - scroll_left - + self.gutter_dimensions.width; + + self.gutter_dimensions.width + + self.gutter_dimensions.margin; let y = line_height * (start.row().as_f32() - scroll_position.y); Some(Bounds { diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index ff743db9b6d6e3..e669c215544b17 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -18,6 +18,7 @@ pub struct EditorSettings { pub gutter: Gutter, pub scroll_beyond_last_line: ScrollBeyondLastLine, pub vertical_scroll_margin: f32, + pub autoscroll_on_clicks: bool, pub scroll_sensitivity: f32, pub relative_line_numbers: bool, pub seed_search_query_from_cursor: SeedQuerySetting, @@ -222,6 +223,10 @@ pub struct EditorSettingsContent { /// /// Default: 3. pub vertical_scroll_margin: Option, + /// Whether to scroll when clicking near the edge of the visible text area. + /// + /// Default: false + pub autoscroll_on_clicks: Option, /// Scroll sensitivity multiplier. This multiplier is applied /// to both the horizontal and vertical delta values while scrolling. /// diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 181eb520ad763e..b5b5215af15210 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -31,9 +31,10 @@ use project::{ project_settings::{LspSettings, ProjectSettings}, }; use serde_json::{self, json}; -use std::sync::atomic; use std::sync::atomic::AtomicUsize; +use std::sync::atomic::{self, AtomicBool}; use std::{cell::RefCell, future::Future, rc::Rc, time::Instant}; +use test::editor_lsp_test_context::rust_lang; use unindent::Unindent; use util::{ assert_set_eq, @@ -5457,7 +5458,7 @@ async fn test_select_larger_smaller_syntax_node(cx: &mut gpui::TestAppContext) { } #[gpui::test] -async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) { +async fn test_autoindent(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let language = Arc::new( @@ -5519,6 +5520,89 @@ async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + { + let mut cx = EditorLspTestContext::new_rust(Default::default(), cx).await; + cx.set_state(indoc! {" + impl A { + + fn b() {} + + «fn c() { + + }ˇ» + } + "}); + + cx.update_editor(|editor, cx| { + editor.autoindent(&Default::default(), cx); + }); + + cx.assert_editor_state(indoc! {" + impl A { + + fn b() {} + + «fn c() { + + }ˇ» + } + "}); + } + + { + let mut cx = EditorTestContext::new_multibuffer( + cx, + [indoc! { " + impl A { + « + // a + fn b(){} + » + « + } + fn c(){} + » + "}], + ); + + let buffer = cx.update_editor(|editor, cx| { + let buffer = editor.buffer().update(cx, |buffer, _| { + buffer.all_buffers().iter().next().unwrap().clone() + }); + buffer.update(cx, |buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + buffer + }); + + cx.run_until_parked(); + cx.update_editor(|editor, cx| { + editor.select_all(&Default::default(), cx); + editor.autoindent(&Default::default(), cx) + }); + cx.run_until_parked(); + + cx.update(|cx| { + pretty_assertions::assert_eq!( + buffer.read(cx).text(), + indoc! { " + impl A { + + // a + fn b(){} + + + } + fn c(){} + + " } + ) + }); + } +} + #[gpui::test] async fn test_autoclose_and_auto_surround_pairs(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); @@ -10575,6 +10659,94 @@ async fn test_completions_default_resolve_data_handling(cx: &mut gpui::TestAppCo }, }; + let resolve_requests_number = Arc::new(AtomicUsize::new(0)); + let expect_first_item = Arc::new(AtomicBool::new(true)); + cx.lsp + .server + .on_request::({ + let closure_default_data = default_data.clone(); + let closure_resolve_requests_number = resolve_requests_number.clone(); + let closure_expect_first_item = expect_first_item.clone(); + let closure_default_commit_characters = default_commit_characters.clone(); + move |item_to_resolve, _| { + closure_resolve_requests_number.fetch_add(1, atomic::Ordering::Release); + let default_data = closure_default_data.clone(); + let default_commit_characters = closure_default_commit_characters.clone(); + let expect_first_item = closure_expect_first_item.clone(); + async move { + if expect_first_item.load(atomic::Ordering::Acquire) { + assert_eq!( + item_to_resolve.label, "Some(2)", + "Should have selected the first item" + ); + assert_eq!( + item_to_resolve.data, + Some(json!({ "very": "special"})), + "First item should bring its own data for resolving" + ); + assert_eq!( + item_to_resolve.commit_characters, + Some(default_commit_characters), + "First item had no own commit characters and should inherit the default ones" + ); + assert!( + matches!( + item_to_resolve.text_edit, + Some(lsp::CompletionTextEdit::InsertAndReplace { .. }) + ), + "First item should bring its own edit range for resolving" + ); + assert_eq!( + item_to_resolve.insert_text_format, + Some(default_insert_text_format), + "First item had no own insert text format and should inherit the default one" + ); + assert_eq!( + item_to_resolve.insert_text_mode, + Some(lsp::InsertTextMode::ADJUST_INDENTATION), + "First item should bring its own insert text mode for resolving" + ); + Ok(item_to_resolve) + } else { + assert_eq!( + item_to_resolve.label, "vec![2]", + "Should have selected the last item" + ); + assert_eq!( + item_to_resolve.data, + Some(default_data), + "Last item has no own resolve data and should inherit the default one" + ); + assert_eq!( + item_to_resolve.commit_characters, + Some(default_commit_characters), + "Last item had no own commit characters and should inherit the default ones" + ); + assert_eq!( + item_to_resolve.text_edit, + Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: default_edit_range, + new_text: "vec![2]".to_string() + })), + "Last item had no own edit range and should inherit the default one" + ); + assert_eq!( + item_to_resolve.insert_text_format, + Some(lsp::InsertTextFormat::PLAIN_TEXT), + "Last item should bring its own insert text format for resolving" + ); + assert_eq!( + item_to_resolve.insert_text_mode, + Some(default_insert_text_mode), + "Last item had no own insert text mode and should inherit the default one" + ); + + Ok(item_to_resolve) + } + } + } + }).detach(); + let completion_data = default_data.clone(); let completion_characters = default_commit_characters.clone(); cx.handle_request::(move |_, _, _| { @@ -10622,7 +10794,7 @@ async fn test_completions_default_resolve_data_handling(cx: &mut gpui::TestAppCo cx.condition(|editor, _| editor.context_menu_visible()) .await; - + cx.run_until_parked(); cx.update_editor(|editor, _| { let menu = editor.context_menu.read(); match menu.as_ref().expect("should have the completions menu") { @@ -10639,99 +10811,32 @@ async fn test_completions_default_resolve_data_handling(cx: &mut gpui::TestAppCo ContextMenu::CodeActions(_) => panic!("Expected to have the completions menu"), } }); + assert_eq!( + resolve_requests_number.load(atomic::Ordering::Acquire), + 1, + "While there are 2 items in the completion list, only 1 resolve request should have been sent, for the selected item" + ); cx.update_editor(|editor, cx| { editor.context_menu_first(&ContextMenuFirst, cx); }); - let first_item_resolve_characters = default_commit_characters.clone(); - cx.handle_request::(move |_, item_to_resolve, _| { - let default_commit_characters = first_item_resolve_characters.clone(); - - async move { - assert_eq!( - item_to_resolve.label, "Some(2)", - "Should have selected the first item" - ); - assert_eq!( - item_to_resolve.data, - Some(json!({ "very": "special"})), - "First item should bring its own data for resolving" - ); - assert_eq!( - item_to_resolve.commit_characters, - Some(default_commit_characters), - "First item had no own commit characters and should inherit the default ones" - ); - assert!( - matches!( - item_to_resolve.text_edit, - Some(lsp::CompletionTextEdit::InsertAndReplace { .. }) - ), - "First item should bring its own edit range for resolving" - ); - assert_eq!( - item_to_resolve.insert_text_format, - Some(default_insert_text_format), - "First item had no own insert text format and should inherit the default one" - ); - assert_eq!( - item_to_resolve.insert_text_mode, - Some(lsp::InsertTextMode::ADJUST_INDENTATION), - "First item should bring its own insert text mode for resolving" - ); - Ok(item_to_resolve) - } - }) - .next() - .await - .unwrap(); + cx.run_until_parked(); + assert_eq!( + resolve_requests_number.load(atomic::Ordering::Acquire), + 2, + "After re-selecting the first item, another resolve request should have been sent" + ); + expect_first_item.store(false, atomic::Ordering::Release); cx.update_editor(|editor, cx| { editor.context_menu_last(&ContextMenuLast, cx); }); - cx.handle_request::(move |_, item_to_resolve, _| { - let default_data = default_data.clone(); - let default_commit_characters = default_commit_characters.clone(); - async move { - assert_eq!( - item_to_resolve.label, "vec![2]", - "Should have selected the last item" - ); - assert_eq!( - item_to_resolve.data, - Some(default_data), - "Last item has no own resolve data and should inherit the default one" - ); - assert_eq!( - item_to_resolve.commit_characters, - Some(default_commit_characters), - "Last item had no own commit characters and should inherit the default ones" - ); - assert_eq!( - item_to_resolve.text_edit, - Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - range: default_edit_range, - new_text: "vec![2]".to_string() - })), - "Last item had no own edit range and should inherit the default one" - ); - assert_eq!( - item_to_resolve.insert_text_format, - Some(lsp::InsertTextFormat::PLAIN_TEXT), - "Last item should bring its own insert text format for resolving" - ); - assert_eq!( - item_to_resolve.insert_text_mode, - Some(default_insert_text_mode), - "Last item had no own insert text mode and should inherit the default one" - ); - - Ok(item_to_resolve) - } - }) - .next() - .await - .unwrap(); + cx.run_until_parked(); + assert_eq!( + resolve_requests_number.load(atomic::Ordering::Acquire), + 3, + "After selecting the other item, another resolve request should have been sent" + ); } #[gpui::test] @@ -11687,7 +11792,7 @@ async fn test_multibuffer_in_navigation_history(cx: &mut gpui::TestAppContext) { multi_buffer_editor.update(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::Next), cx, |s| { - s.select_ranges(Some(60..70)) + s.select_ranges(Some(70..70)) }); editor.open_excerpts(&OpenExcerpts, cx); }); @@ -13826,20 +13931,6 @@ pub(crate) fn init_test(cx: &mut TestAppContext, f: fn(&mut AllLanguageSettingsC update_test_language_settings(cx, f); } -pub(crate) fn rust_lang() -> Arc { - Arc::new(Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - )) -} - #[track_caller] fn assert_hunk_revert( not_reverted_text_with_selections: &str, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index fa567ed31a0d43..be6b882ad02967 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -189,6 +189,7 @@ impl EditorElement { register_action(view, cx, Editor::tab_prev); register_action(view, cx, Editor::indent); register_action(view, cx, Editor::outdent); + register_action(view, cx, Editor::autoindent); register_action(view, cx, Editor::delete_line); register_action(view, cx, Editor::join_lines); register_action(view, cx, Editor::sort_lines_case_sensitive); diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index 9dfc379ae70eda..c5cfb2e850caba 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -10,7 +10,7 @@ use gpui::{Model, ModelContext, Subscription, Task}; use http_client::HttpClient; use language::{markdown, Bias, Buffer, BufferSnapshot, Edit, LanguageRegistry, ParsedMarkdown}; use multi_buffer::MultiBufferRow; -use project::{Item, Project}; +use project::{Project, ProjectItem}; use smallvec::SmallVec; use sum_tree::SumTree; use url::Url; diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 31be9e93a94807..0973f59babf6d8 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -1,8 +1,9 @@ use crate::{ + editor_settings::MultiCursorModifier, hover_popover::{self, InlayHover}, scroll::ScrollAmount, - Anchor, Editor, EditorSnapshot, FindAllReferences, GoToDefinition, GoToTypeDefinition, - GotoDefinitionKind, InlayId, Navigated, PointForPosition, SelectPhase, + Anchor, Editor, EditorSettings, EditorSnapshot, FindAllReferences, GoToDefinition, + GoToTypeDefinition, GotoDefinitionKind, InlayId, Navigated, PointForPosition, SelectPhase, }; use gpui::{px, AppContext, AsyncWindowContext, Model, Modifiers, Task, ViewContext}; use language::{Bias, ToOffset}; @@ -12,6 +13,7 @@ use project::{ HoverBlock, HoverBlockKind, InlayHintLabelPartTooltip, InlayHintTooltip, LocationLink, Project, ResolveState, ResolvedPath, }; +use settings::Settings; use std::ops::Range; use theme::ActiveTheme as _; use util::{maybe, ResultExt, TryFutureExt as _}; @@ -117,7 +119,12 @@ impl Editor { modifiers: Modifiers, cx: &mut ViewContext, ) { - if !modifiers.secondary() || self.has_pending_selection() { + let multi_cursor_setting = EditorSettings::get_global(cx).multi_cursor_modifier; + let hovered_link_modifier = match multi_cursor_setting { + MultiCursorModifier::Alt => modifiers.secondary(), + MultiCursorModifier::CmdOrCtrl => modifiers.alt, + }; + if !hovered_link_modifier || self.has_pending_selection() { self.hide_hovered_link(cx); return; } @@ -137,7 +144,7 @@ impl Editor { snapshot, point_for_position, self, - modifiers.secondary(), + hovered_link_modifier, modifiers.shift, cx, ); diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 006a42700bb814..c402132bf34e9b 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -593,8 +593,8 @@ async fn parse_blocks( combined_text, markdown_style.clone(), Some(language_registry.clone()), - cx, fallback_language_name, + cx, ) }) .ok(); diff --git a/crates/editor/src/hunk_diff.rs b/crates/editor/src/hunk_diff.rs index 46dc28e0939c98..3edfd72f8c934f 100644 --- a/crates/editor/src/hunk_diff.rs +++ b/crates/editor/src/hunk_diff.rs @@ -524,6 +524,12 @@ impl Editor { } } + fn has_multiple_hunks(&self, cx: &AppContext) -> bool { + let snapshot = self.buffer.read(cx).snapshot(cx); + let mut hunks = snapshot.git_diff_hunks_in_range(MultiBufferRow::MIN..MultiBufferRow::MAX); + hunks.nth(1).is_some() + } + fn hunk_header_block( &self, hunk: &HoveredHunk, @@ -553,6 +559,7 @@ impl Editor { render: Arc::new({ let editor = cx.view().clone(); let hunk = hunk.clone(); + let has_multiple_hunks = self.has_multiple_hunks(cx); move |cx| { let hunk_controls_menu_handle = @@ -596,6 +603,7 @@ impl Editor { IconButton::new("next-hunk", IconName::ArrowDown) .shape(IconButtonShape::Square) .icon_size(IconSize::Small) + .disabled(!has_multiple_hunks) .tooltip({ let focus_handle = editor.focus_handle(cx); move |cx| { @@ -624,6 +632,7 @@ impl Editor { IconButton::new("prev-hunk", IconName::ArrowUp) .shape(IconButtonShape::Square) .icon_size(IconSize::Small) + .disabled(!has_multiple_hunks) .tooltip({ let focus_handle = editor.focus_handle(cx); move |cx| { diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlay_hint_cache.rs index 877f02eefe203d..8b2358c6b49c21 100644 --- a/crates/editor/src/inlay_hint_cache.rs +++ b/crates/editor/src/inlay_hint_cache.rs @@ -1258,6 +1258,7 @@ pub mod tests { use crate::{ scroll::{scroll_amount::ScrollAmount, Autoscroll}, + test::editor_lsp_test_context::rust_lang, ExcerptRange, }; use futures::StreamExt; @@ -2274,7 +2275,7 @@ pub mod tests { let project = Project::test(fs, ["/a".as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(crate::editor_tests::rust_lang()); + language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { @@ -2570,7 +2571,7 @@ pub mod tests { let project = Project::test(fs, ["/a".as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - let language = crate::editor_tests::rust_lang(); + let language = rust_lang(); language_registry.add(language); let mut fake_servers = language_registry.register_fake_lsp( "Rust", @@ -2922,7 +2923,7 @@ pub mod tests { let project = Project::test(fs, ["/a".as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(crate::editor_tests::rust_lang()); + language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { @@ -3153,7 +3154,7 @@ pub mod tests { let project = Project::test(fs, ["/a".as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(crate::editor_tests::rust_lang()); + language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { @@ -3396,7 +3397,7 @@ pub mod tests { let project = Project::test(fs, ["/a".as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(crate::editor_tests::rust_lang()); + language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 6e6d82d638635f..298ef5a3f06092 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -22,8 +22,8 @@ use language::{ use lsp::DiagnosticSeverity; use multi_buffer::AnchorRangeExt; use project::{ - lsp_store::FormatTrigger, project_settings::ProjectSettings, search::SearchQuery, Item as _, - Project, ProjectPath, + lsp_store::FormatTrigger, project_settings::ProjectSettings, search::SearchQuery, Project, + ProjectItem as _, ProjectPath, }; use rpc::proto::{self, update_view, PeerId}; use settings::Settings; @@ -47,7 +47,7 @@ use workspace::item::{BreadcrumbText, FollowEvent}; use workspace::{ item::{FollowableItem, Item, ItemEvent, ProjectItem}, searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle}, - ItemId, ItemNavHistory, Pane, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, + ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, }; pub const MAX_TAB_TITLE_LEN: usize = 24; @@ -665,7 +665,7 @@ impl Item for Editor { fn for_each_project_item( &self, cx: &AppContext, - f: &mut dyn FnMut(EntityId, &dyn project::Item), + f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem), ) { self.buffer .read(cx) @@ -954,7 +954,7 @@ impl SerializableItem for Editor { workspace: WeakView, workspace_id: workspace::WorkspaceId, item_id: ItemId, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Task>> { let serialized_editor = match DB .get_serialized_editor(item_id, workspace_id) @@ -989,7 +989,7 @@ impl SerializableItem for Editor { contents: Some(contents), language, .. - } => cx.spawn(|pane, mut cx| { + } => cx.spawn(|mut cx| { let project = project.clone(); async move { let language = if let Some(language_name) = language { @@ -1019,7 +1019,7 @@ impl SerializableItem for Editor { buffer.set_text(contents, cx); })?; - pane.update(&mut cx, |_, cx| { + cx.update(|cx| { cx.new_view(|cx| { let mut editor = Editor::for_buffer(buffer, Some(project), cx); @@ -1046,7 +1046,7 @@ impl SerializableItem for Editor { match project_item { Some(project_item) => { - cx.spawn(|pane, mut cx| async move { + cx.spawn(|mut cx| async move { let (_, project_item) = project_item.await?; let buffer = project_item.downcast::().map_err(|_| { anyhow!("Project item at stored path was not a buffer") @@ -1073,7 +1073,7 @@ impl SerializableItem for Editor { })?; } - pane.update(&mut cx, |_, cx| { + cx.update(|cx| { cx.new_view(|cx| { let mut editor = Editor::for_buffer(buffer, Some(project), cx); @@ -1087,7 +1087,7 @@ impl SerializableItem for Editor { let open_by_abs_path = workspace.update(cx, |workspace, cx| { workspace.open_abs_path(abs_path.clone(), false, cx) }); - cx.spawn(|_, mut cx| async move { + cx.spawn(|mut cx| async move { let editor = open_by_abs_path?.await?.downcast::().with_context(|| format!("Failed to downcast to Editor after opening abs path {abs_path:?}"))?; editor.update(&mut cx, |editor, cx| { editor.read_scroll_position_from_db(item_id, workspace_id, cx); diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index 0384ed065b9b68..b43d78bc9975a5 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -31,6 +31,47 @@ pub struct EditorLspTestContext { pub buffer_lsp_url: lsp::Url, } +pub(crate) fn rust_lang() -> Arc { + let language = Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + line_comments: vec!["// ".into(), "/// ".into(), "//! ".into()], + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_queries(LanguageQueries { + indents: Some(Cow::from(indoc! {r#" + [ + ((where_clause) _ @end) + (field_expression) + (call_expression) + (assignment_expression) + (let_declaration) + (let_chain) + (await_expression) + ] @indent + + (_ "[" "]" @end) @indent + (_ "<" ">" @end) @indent + (_ "{" "}" @end) @indent + (_ "(" ")" @end) @indent"#})), + brackets: Some(Cow::from(indoc! {r#" + ("(" @open ")" @close) + ("[" @open "]" @close) + ("{" @open "}" @close) + ("<" @open ">" @close) + ("\"" @open "\"" @close) + (closure_parameters "|" @open "|" @close)"#})), + ..Default::default() + }) + .expect("Could not parse queries"); + Arc::new(language) +} impl EditorLspTestContext { pub async fn new( language: Language, @@ -119,46 +160,7 @@ impl EditorLspTestContext { capabilities: lsp::ServerCapabilities, cx: &mut gpui::TestAppContext, ) -> EditorLspTestContext { - let language = Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - line_comments: vec!["// ".into(), "/// ".into(), "//! ".into()], - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_queries(LanguageQueries { - indents: Some(Cow::from(indoc! {r#" - [ - ((where_clause) _ @end) - (field_expression) - (call_expression) - (assignment_expression) - (let_declaration) - (let_chain) - (await_expression) - ] @indent - - (_ "[" "]" @end) @indent - (_ "<" ">" @end) @indent - (_ "{" "}" @end) @indent - (_ "(" ")" @end) @indent"#})), - brackets: Some(Cow::from(indoc! {r#" - ("(" @open ")" @close) - ("[" @open "]" @close) - ("{" @open "}" @close) - ("<" @open ">" @close) - ("\"" @open "\"" @close) - (closure_parameters "|" @open "|" @close)"#})), - ..Default::default() - }) - .expect("Could not parse queries"); - - Self::new(language, capabilities, cx).await + Self::new(Arc::into_inner(rust_lang()).unwrap(), capabilities, cx).await } pub async fn new_typescript( diff --git a/crates/extension/src/extension_host_proxy.rs b/crates/extension/src/extension_host_proxy.rs index 8909a6082dee9c..3fa35597a83113 100644 --- a/crates/extension/src/extension_host_proxy.rs +++ b/crates/extension/src/extension_host_proxy.rs @@ -159,6 +159,7 @@ pub trait ExtensionLanguageProxy: Send + Sync + 'static { language: LanguageName, grammar: Option>, matcher: LanguageMatcher, + hidden: bool, load: Arc Result + Send + Sync + 'static>, ); @@ -175,13 +176,14 @@ impl ExtensionLanguageProxy for ExtensionHostProxy { language: LanguageName, grammar: Option>, matcher: LanguageMatcher, + hidden: bool, load: Arc Result + Send + Sync + 'static>, ) { let Some(proxy) = self.language_proxy.read().clone() else { return; }; - proxy.register_language(language, grammar, matcher, load) + proxy.register_language(language, grammar, matcher, hidden, load) } fn remove_languages( diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index aab5c258f50fda..7ceb1fa7147cee 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -162,6 +162,7 @@ pub struct ExtensionIndexLanguageEntry { pub extension: Arc, pub path: PathBuf, pub matcher: LanguageMatcher, + pub hidden: bool, pub grammar: Option>, } @@ -1097,6 +1098,7 @@ impl ExtensionStore { language_name.clone(), language.grammar.clone(), language.matcher.clone(), + language.hidden, Arc::new(move || { let config = std::fs::read_to_string(language_path.join("config.toml"))?; let config: LanguageConfig = ::toml::from_str(&config)?; @@ -1324,6 +1326,7 @@ impl ExtensionStore { extension: extension_id.clone(), path: relative_path, matcher: config.matcher, + hidden: config.hidden, grammar: config.grammar, }, ); diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index 1359b5b202843c..8b5a2a782149ab 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -203,6 +203,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { extension: "zed-ruby".into(), path: "languages/erb".into(), grammar: Some("embedded_template".into()), + hidden: false, matcher: LanguageMatcher { path_suffixes: vec!["erb".into()], first_line_pattern: None, @@ -215,6 +216,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { extension: "zed-ruby".into(), path: "languages/ruby".into(), grammar: Some("ruby".into()), + hidden: false, matcher: LanguageMatcher { path_suffixes: vec!["rb".into()], first_line_pattern: None, diff --git a/crates/extension_host/src/headless_host.rs b/crates/extension_host/src/headless_host.rs index 19a574b9d4aa5c..687f05db478b04 100644 --- a/crates/extension_host/src/headless_host.rs +++ b/crates/extension_host/src/headless_host.rs @@ -156,6 +156,7 @@ impl HeadlessExtensionStore { config.name.clone(), None, config.matcher.clone(), + config.hidden, Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index eaffdafa41517d..aef99e6167779e 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -14,7 +14,7 @@ use editor::{Editor, EditorElement, EditorStyle}; use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ - actions, uniform_list, Action, AppContext, EventEmitter, Flatten, FocusableView, + actions, uniform_list, Action, AppContext, ClipboardItem, EventEmitter, Flatten, FocusableView, InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, WindowContext, }; @@ -637,13 +637,21 @@ impl ExtensionsPage { cx: &mut WindowContext, ) -> View { let context_menu = ContextMenu::build(cx, |context_menu, cx| { - context_menu.entry( - "Install Another Version...", - None, - cx.handler_for(this, move |this, cx| { - this.show_extension_version_list(extension_id.clone(), cx) - }), - ) + context_menu + .entry( + "Install Another Version...", + None, + cx.handler_for(this, { + let extension_id = extension_id.clone(); + move |this, cx| this.show_extension_version_list(extension_id.clone(), cx) + }), + ) + .entry("Copy Extension ID", None, { + let extension_id = extension_id.clone(); + move |cx| { + cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string())); + } + }) }); context_menu diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 6a758211f8b73d..62e0818b7434f6 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod file_finder_tests; -mod file_finder_settings; +pub mod file_finder_settings; mod new_path_prompt; mod open_path_prompt; diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index fc0fae3fe8fc51..37525db7d933e8 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -452,18 +452,16 @@ impl Fs for RealFs { #[cfg(target_os = "windows")] async fn trash_file(&self, path: &Path, _options: RemoveOptions) -> Result<()> { + use util::paths::SanitizedPath; use windows::{ core::HSTRING, Storage::{StorageDeleteOption, StorageFile}, }; // todo(windows) // When new version of `windows-rs` release, make this operation `async` - let path = path.canonicalize()?.to_string_lossy().to_string(); - let path_str = path.trim_start_matches("\\\\?\\"); - if path_str.is_empty() { - anyhow::bail!("File path is empty!"); - } - let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(path_str))?.get()?; + let path = SanitizedPath::from(path.canonicalize()?); + let path_string = path.to_string(); + let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(path_string))?.get()?; file.DeleteAsync(StorageDeleteOption::Default)?.get()?; Ok(()) } @@ -480,19 +478,17 @@ impl Fs for RealFs { #[cfg(target_os = "windows")] async fn trash_dir(&self, path: &Path, _options: RemoveOptions) -> Result<()> { + use util::paths::SanitizedPath; use windows::{ core::HSTRING, Storage::{StorageDeleteOption, StorageFolder}, }; - let path = path.canonicalize()?.to_string_lossy().to_string(); - let path_str = path.trim_start_matches("\\\\?\\"); - if path_str.is_empty() { - anyhow::bail!("Folder path is empty!"); - } // todo(windows) // When new version of `windows-rs` release, make this operation `async` - let folder = StorageFolder::GetFolderFromPathAsync(&HSTRING::from(path_str))?.get()?; + let path = SanitizedPath::from(path.canonicalize()?); + let path_string = path.to_string(); + let folder = StorageFolder::GetFolderFromPathAsync(&HSTRING::from(path_string))?.get()?; folder.DeleteAsync(StorageDeleteOption::Default)?.get()?; Ok(()) } diff --git a/crates/go_to_line/src/cursor_position.rs b/crates/go_to_line/src/cursor_position.rs index 3931cac2845af7..2dc60475d35e7c 100644 --- a/crates/go_to_line/src/cursor_position.rs +++ b/crates/go_to_line/src/cursor_position.rs @@ -1,5 +1,5 @@ use editor::{Editor, ToPoint}; -use gpui::{AppContext, Subscription, Task, View, WeakView}; +use gpui::{AppContext, FocusHandle, FocusableView, Subscription, Task, View, WeakView}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsSources}; @@ -22,6 +22,7 @@ pub(crate) struct SelectionStats { pub struct CursorPosition { position: Option, selected_count: SelectionStats, + context: Option, workspace: WeakView, update_position: Task<()>, _observe_active_editor: Option, @@ -31,6 +32,7 @@ impl CursorPosition { pub fn new(workspace: &Workspace) -> Self { Self { position: None, + context: None, selected_count: Default::default(), workspace: workspace.weak_handle(), update_position: Task::ready(()), @@ -52,34 +54,46 @@ impl CursorPosition { editor .update(&mut cx, |editor, cx| { - let buffer = editor.buffer().read(cx).snapshot(cx); cursor_position.update(cx, |cursor_position, cx| { cursor_position.selected_count = SelectionStats::default(); cursor_position.selected_count.selections = editor.selections.count(); - let mut last_selection = None::>; - for selection in editor.selections.all::(cx) { - cursor_position.selected_count.characters += buffer - .text_for_range(selection.start..selection.end) - .map(|t| t.chars().count()) - .sum::(); - if last_selection - .as_ref() - .map_or(true, |last_selection| selection.id > last_selection.id) - { - last_selection = Some(selection); + match editor.mode() { + editor::EditorMode::AutoHeight { .. } + | editor::EditorMode::SingleLine { .. } => { + cursor_position.position = None; + cursor_position.context = None; } - } - for selection in editor.selections.all::(cx) { - if selection.end != selection.start { - cursor_position.selected_count.lines += - (selection.end.row - selection.start.row) as usize; - if selection.end.column != 0 { - cursor_position.selected_count.lines += 1; + editor::EditorMode::Full => { + let mut last_selection = None::>; + let buffer = editor.buffer().read(cx).snapshot(cx); + if buffer.excerpts().count() > 0 { + for selection in editor.selections.all::(cx) { + cursor_position.selected_count.characters += buffer + .text_for_range(selection.start..selection.end) + .map(|t| t.chars().count()) + .sum::(); + if last_selection.as_ref().map_or(true, |last_selection| { + selection.id > last_selection.id + }) { + last_selection = Some(selection); + } + } + for selection in editor.selections.all::(cx) { + if selection.end != selection.start { + cursor_position.selected_count.lines += + (selection.end.row - selection.start.row) as usize; + if selection.end.column != 0 { + cursor_position.selected_count.lines += 1; + } + } + } } + cursor_position.position = + last_selection.map(|s| s.head().to_point(&buffer)); + cursor_position.context = Some(editor.focus_handle(cx)); } } - cursor_position.position = - last_selection.map(|s| s.head().to_point(&buffer)); + cx.notify(); }) }) @@ -148,6 +162,8 @@ impl Render for CursorPosition { ); self.write_position(&mut text, cx); + let context = self.context.clone(); + el.child( Button::new("go-to-line-column", text) .label_size(LabelSize::Small) @@ -164,12 +180,18 @@ impl Render for CursorPosition { }); } })) - .tooltip(|cx| { - Tooltip::for_action( + .tooltip(move |cx| match context.as_ref() { + Some(context) => Tooltip::for_action_in( + "Go to Line/Column", + &editor::actions::ToggleGoToLine, + context, + cx, + ), + None => Tooltip::for_action( "Go to Line/Column", &editor::actions::ToggleGoToLine, cx, - ) + ), }), ) }) diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index c848d28eaa55b2..df673ef8233ebf 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/crates/go_to_line/src/go_to_line.rs @@ -9,7 +9,7 @@ use gpui::{ use settings::Settings; use text::{Bias, Point}; use theme::ActiveTheme; -use ui::{h_flex, prelude::*, v_flex, Label}; +use ui::prelude::*; use util::paths::FILE_ROW_COLUMN_DELIMITER; use workspace::ModalView; @@ -73,7 +73,7 @@ impl GoToLine { let last_line = editor.buffer().read(cx).snapshot(cx).max_point().row; let scroll_position = active_editor.update(cx, |editor, cx| editor.scroll_position(cx)); - let current_text = format!("line {} of {} (column {})", line, last_line + 1, column); + let current_text = format!("{} of {} (column {})", line, last_line + 1, column); Self { line_editor, @@ -186,36 +186,27 @@ impl Render for GoToLine { } } - div() + v_flex() + .w(rems(24.)) .elevation_2(cx) .key_context("GoToLine") .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::confirm)) - .w_96() .child( - v_flex() - .px_1() - .pt_0p5() - .gap_px() - .child( - v_flex() - .py_0p5() - .px_1() - .child(div().px_1().py_0p5().child(self.line_editor.clone())), - ) - .child( - div() - .h_px() - .w_full() - .bg(cx.theme().colors().element_background), - ) - .child( - h_flex() - .justify_between() - .px_2() - .py_1() - .child(Label::new(help_text).color(Color::Muted)), - ), + div() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .px_2() + .py_1() + .child(self.line_editor.clone()), + ) + .child( + h_flex() + .px_2() + .py_1() + .gap_1() + .child(Label::new("Current Line:").color(Color::Muted)) + .child(Label::new(help_text).color(Color::Muted)), ) } } diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 347e5502ca39d2..ed523c769ac636 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -119,7 +119,7 @@ http_client = { workspace = true, features = ["test-support"] } unicode-segmentation.workspace = true [build-dependencies] -embed-resource = "2.4" +embed-resource = "3.0" [target.'cfg(target_os = "macos")'.build-dependencies] bindgen = "0.70.0" diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs index 5a015106c722ad..ef29d7cc8222ac 100644 --- a/crates/gpui/build.rs +++ b/crates/gpui/build.rs @@ -18,7 +18,9 @@ fn main() { let rc_file = std::path::Path::new("resources/windows/gpui.rc"); println!("cargo:rerun-if-changed={}", manifest.display()); println!("cargo:rerun-if-changed={}", rc_file.display()); - embed_resource::compile(rc_file, embed_resource::NONE); + embed_resource::compile(rc_file, embed_resource::NONE) + .manifest_required() + .unwrap(); } _ => (), }; diff --git a/crates/gpui/examples/hello_world.rs b/crates/gpui/examples/hello_world.rs index 961212fa62a265..57312c06bb8ceb 100644 --- a/crates/gpui/examples/hello_world.rs +++ b/crates/gpui/examples/hello_world.rs @@ -8,8 +8,10 @@ impl Render for HelloWorld { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { div() .flex() - .bg(rgb(0x2e7d32)) - .size(Length::Definite(Pixels(300.0).into())) + .flex_col() + .gap_3() + .bg(rgb(0x505050)) + .size(Length::Definite(Pixels(500.0).into())) .justify_center() .items_center() .shadow_lg() @@ -18,12 +20,23 @@ impl Render for HelloWorld { .text_xl() .text_color(rgb(0xffffff)) .child(format!("Hello, {}!", &self.text)) + .child( + div() + .flex() + .gap_2() + .child(div().size_8().bg(gpui::red())) + .child(div().size_8().bg(gpui::green())) + .child(div().size_8().bg(gpui::blue())) + .child(div().size_8().bg(gpui::yellow())) + .child(div().size_8().bg(gpui::black())) + .child(div().size_8().bg(gpui::white())), + ) } } fn main() { App::new().run(|cx: &mut AppContext| { - let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); + let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 0776e5c72ef185..87ee3942dd44ab 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1578,7 +1578,7 @@ pub struct AnyDrag { pub view: AnyView, /// The value of the dragged item, to be dropped - pub value: Box, + pub value: Arc, /// This is used to render the dragged item in the same place /// on the original element that the drag was initiated diff --git a/crates/gpui/src/color.rs b/crates/gpui/src/color.rs index 9c831d0875588c..04a35e6886456d 100644 --- a/crates/gpui/src/color.rs +++ b/crates/gpui/src/color.rs @@ -314,7 +314,7 @@ pub fn hsla(h: f32, s: f32, l: f32, a: f32) -> Hsla { } /// Pure black in [`Hsla`] -pub fn black() -> Hsla { +pub const fn black() -> Hsla { Hsla { h: 0., s: 0., @@ -324,7 +324,7 @@ pub fn black() -> Hsla { } /// Transparent black in [`Hsla`] -pub fn transparent_black() -> Hsla { +pub const fn transparent_black() -> Hsla { Hsla { h: 0., s: 0., @@ -334,7 +334,7 @@ pub fn transparent_black() -> Hsla { } /// Transparent black in [`Hsla`] -pub fn transparent_white() -> Hsla { +pub const fn transparent_white() -> Hsla { Hsla { h: 0., s: 0., @@ -354,7 +354,7 @@ pub fn opaque_grey(lightness: f32, opacity: f32) -> Hsla { } /// Pure white in [`Hsla`] -pub fn white() -> Hsla { +pub const fn white() -> Hsla { Hsla { h: 0., s: 0., @@ -364,7 +364,7 @@ pub fn white() -> Hsla { } /// The color red in [`Hsla`] -pub fn red() -> Hsla { +pub const fn red() -> Hsla { Hsla { h: 0., s: 1., @@ -374,9 +374,9 @@ pub fn red() -> Hsla { } /// The color blue in [`Hsla`] -pub fn blue() -> Hsla { +pub const fn blue() -> Hsla { Hsla { - h: 0.6, + h: 0.6666666667, s: 1., l: 0.5, a: 1., @@ -384,19 +384,19 @@ pub fn blue() -> Hsla { } /// The color green in [`Hsla`] -pub fn green() -> Hsla { +pub const fn green() -> Hsla { Hsla { - h: 0.33, + h: 0.3333333333, s: 1., - l: 0.5, + l: 0.25, a: 1., } } /// The color yellow in [`Hsla`] -pub fn yellow() -> Hsla { +pub const fn yellow() -> Hsla { Hsla { - h: 0.16, + h: 0.1666666667, s: 1., l: 0.5, a: 1., @@ -410,32 +410,32 @@ impl Hsla { } /// The color red - pub fn red() -> Self { + pub const fn red() -> Self { red() } /// The color green - pub fn green() -> Self { + pub const fn green() -> Self { green() } /// The color blue - pub fn blue() -> Self { + pub const fn blue() -> Self { blue() } /// The color black - pub fn black() -> Self { + pub const fn black() -> Self { black() } /// The color white - pub fn white() -> Self { + pub const fn white() -> Self { white() } /// The color transparent black - pub fn transparent_black() -> Self { + pub const fn transparent_black() -> Self { transparent_black() } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 6928ca74ee473a..909af004a5d2f4 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -35,6 +35,7 @@ use std::{ mem, ops::DerefMut, rc::Rc, + sync::Arc, time::Duration, }; use taffy::style::Overflow; @@ -61,6 +62,7 @@ pub struct DragMoveEvent { /// The bounds of this element. pub bounds: Bounds, drag: PhantomData, + dragged_item: Arc, } impl DragMoveEvent { @@ -71,6 +73,11 @@ impl DragMoveEvent { .and_then(|drag| drag.value.downcast_ref::()) .expect("DragMoveEvent is only valid when the stored active drag is of the same type.") } + + /// An item that is about to be dropped. + pub fn dragged_item(&self) -> &dyn Any { + self.dragged_item.as_ref() + } } impl Interactivity { @@ -243,20 +250,20 @@ impl Interactivity { { self.mouse_move_listeners .push(Box::new(move |event, phase, hitbox, cx| { - if phase == DispatchPhase::Capture - && cx - .active_drag - .as_ref() - .is_some_and(|drag| drag.value.as_ref().type_id() == TypeId::of::()) - { - (listener)( - &DragMoveEvent { - event: event.clone(), - bounds: hitbox.bounds, - drag: PhantomData, - }, - cx, - ); + if phase == DispatchPhase::Capture { + if let Some(drag) = &cx.active_drag { + if drag.value.as_ref().type_id() == TypeId::of::() { + (listener)( + &DragMoveEvent { + event: event.clone(), + bounds: hitbox.bounds, + drag: PhantomData, + dragged_item: Arc::clone(&drag.value), + }, + cx, + ); + } + } } })); } @@ -454,7 +461,7 @@ impl Interactivity { "calling on_drag more than once on the same element is not supported" ); self.drag_listener = Some(( - Box::new(value), + Arc::new(value), Box::new(move |value, offset, cx| { constructor(value.downcast_ref().unwrap(), offset, cx).into() }), @@ -1292,7 +1299,7 @@ pub struct Interactivity { pub(crate) drop_listeners: Vec<(TypeId, DropListener)>, pub(crate) can_drop_predicate: Option, pub(crate) click_listeners: Vec, - pub(crate) drag_listener: Option<(Box, DragListener)>, + pub(crate) drag_listener: Option<(Arc, DragListener)>, pub(crate) hover_listener: Option>, pub(crate) tooltip_builder: Option, pub(crate) occlude_mouse: bool, diff --git a/crates/gpui/src/platform/linux/wayland/client.rs b/crates/gpui/src/platform/linux/wayland/client.rs index e1932019579186..2cafffa72534de 100644 --- a/crates/gpui/src/platform/linux/wayland/client.rs +++ b/crates/gpui/src/platform/linux/wayland/client.rs @@ -496,7 +496,7 @@ impl WaylandClient { XDPEvent::CursorTheme(theme) => { if let Some(client) = client.0.upgrade() { let mut client = client.borrow_mut(); - client.cursor.set_theme(theme.as_str(), None); + client.cursor.set_theme(theme.as_str()); } } XDPEvent::CursorSize(size) => { @@ -649,15 +649,16 @@ impl LinuxClient for WaylandClient { if let Some(cursor_shape_device) = &state.cursor_shape_device { cursor_shape_device.set_shape(serial, style.to_shape()); - } else if state.mouse_focused_window.is_some() { + } else if let Some(focused_window) = &state.mouse_focused_window { // cursor-shape-v1 isn't supported, set the cursor using a surface. let wl_pointer = state .wl_pointer .clone() .expect("window is focused by pointer"); + let scale = focused_window.primary_output_scale(); state .cursor - .set_icon(&wl_pointer, serial, &style.to_icon_name()); + .set_icon(&wl_pointer, serial, &style.to_icon_name(), scale); } } } @@ -1439,9 +1440,13 @@ impl Dispatch for WaylandClientStatePtr { if let Some(cursor_shape_device) = &state.cursor_shape_device { cursor_shape_device.set_shape(serial, style.to_shape()); } else { - state - .cursor - .set_icon(&wl_pointer, serial, &style.to_icon_name()); + let scale = window.primary_output_scale(); + state.cursor.set_icon( + &wl_pointer, + serial, + &style.to_icon_name(), + scale, + ); } } drop(state); diff --git a/crates/gpui/src/platform/linux/wayland/cursor.rs b/crates/gpui/src/platform/linux/wayland/cursor.rs index 6a527650429a4e..09aa414debcffb 100644 --- a/crates/gpui/src/platform/linux/wayland/cursor.rs +++ b/crates/gpui/src/platform/linux/wayland/cursor.rs @@ -9,6 +9,7 @@ use wayland_cursor::{CursorImageBuffer, CursorTheme}; pub(crate) struct Cursor { theme: Option, theme_name: Option, + theme_size: u32, surface: WlSurface, size: u32, shm: WlShm, @@ -27,6 +28,7 @@ impl Cursor { Self { theme: CursorTheme::load(&connection, globals.shm.clone(), size).log_err(), theme_name: None, + theme_size: size, surface: globals.compositor.create_surface(&globals.qh, ()), shm: globals.shm.clone(), connection: connection.clone(), @@ -34,26 +36,26 @@ impl Cursor { } } - pub fn set_theme(&mut self, theme_name: &str, size: Option) { - if let Some(size) = size { - self.size = size; - } - if let Some(theme) = - CursorTheme::load_from_name(&self.connection, self.shm.clone(), theme_name, self.size) - .log_err() + pub fn set_theme(&mut self, theme_name: &str) { + if let Some(theme) = CursorTheme::load_from_name( + &self.connection, + self.shm.clone(), + theme_name, + self.theme_size, + ) + .log_err() { self.theme = Some(theme); self.theme_name = Some(theme_name.to_string()); } else if let Some(theme) = - CursorTheme::load(&self.connection, self.shm.clone(), self.size).log_err() + CursorTheme::load(&self.connection, self.shm.clone(), self.theme_size).log_err() { self.theme = Some(theme); self.theme_name = None; } } - pub fn set_size(&mut self, size: u32) { - self.size = size; + fn set_theme_size(&mut self, theme_size: u32) { self.theme = self .theme_name .as_ref() @@ -62,14 +64,29 @@ impl Cursor { &self.connection, self.shm.clone(), name.as_str(), - self.size, + theme_size, ) .log_err() }) - .or_else(|| CursorTheme::load(&self.connection, self.shm.clone(), self.size).log_err()); + .or_else(|| { + CursorTheme::load(&self.connection, self.shm.clone(), theme_size).log_err() + }); + } + + pub fn set_size(&mut self, size: u32) { + self.size = size; + self.set_theme_size(size); } - pub fn set_icon(&mut self, wl_pointer: &WlPointer, serial_id: u32, mut cursor_icon_name: &str) { + pub fn set_icon( + &mut self, + wl_pointer: &WlPointer, + serial_id: u32, + mut cursor_icon_name: &str, + scale: i32, + ) { + self.set_theme_size(self.size * scale as u32); + if let Some(theme) = &mut self.theme { let mut buffer: Option<&CursorImageBuffer>; @@ -91,7 +108,15 @@ impl Cursor { let (width, height) = buffer.dimensions(); let (hot_x, hot_y) = buffer.hotspot(); - wl_pointer.set_cursor(serial_id, Some(&self.surface), hot_x as i32, hot_y as i32); + self.surface.set_buffer_scale(scale); + + wl_pointer.set_cursor( + serial_id, + Some(&self.surface), + hot_x as i32 / scale, + hot_y as i32 / scale, + ); + self.surface.attach(Some(&buffer), 0, 0); self.surface.damage(0, 0, width as i32, height as i32); self.surface.commit(); diff --git a/crates/gpui/src/platform/linux/wayland/window.rs b/crates/gpui/src/platform/linux/wayland/window.rs index 55ba4f6004d393..4cdf88e26268e4 100644 --- a/crates/gpui/src/platform/linux/wayland/window.rs +++ b/crates/gpui/src/platform/linux/wayland/window.rs @@ -194,6 +194,23 @@ impl WaylandWindowState { self.decorations == WindowDecorations::Client || self.background_appearance != WindowBackgroundAppearance::Opaque } + + pub fn primary_output_scale(&mut self) -> i32 { + let mut scale = 1; + let mut current_output = self.display.take(); + for (id, output) in self.outputs.iter() { + if let Some((_, output_data)) = ¤t_output { + if output.scale > output_data.scale { + current_output = Some((id.clone(), output.clone())); + } + } else { + current_output = Some((id.clone(), output.clone())); + } + scale = scale.max(output.scale); + } + self.display = current_output; + scale + } } pub(crate) struct WaylandWindow(pub WaylandWindowStatePtr); @@ -560,7 +577,7 @@ impl WaylandWindowStatePtr { state.outputs.insert(id, output.clone()); - let scale = primary_output_scale(&mut state); + let scale = state.primary_output_scale(); // We use `PreferredBufferScale` instead to set the scale if it's available if state.surface.version() < wl_surface::EVT_PREFERRED_BUFFER_SCALE_SINCE { @@ -572,7 +589,7 @@ impl WaylandWindowStatePtr { wl_surface::Event::Leave { output } => { state.outputs.remove(&output.id()); - let scale = primary_output_scale(&mut state); + let scale = state.primary_output_scale(); // We use `PreferredBufferScale` instead to set the scale if it's available if state.surface.version() < wl_surface::EVT_PREFERRED_BUFFER_SCALE_SINCE { @@ -719,6 +736,10 @@ impl WaylandWindowStatePtr { (fun)() } } + + pub fn primary_output_scale(&self) -> i32 { + self.state.borrow_mut().primary_output_scale() + } } fn extract_states<'a, S: TryFrom + 'a>(states: &'a [u8]) -> impl Iterator + 'a @@ -732,23 +753,6 @@ where .flat_map(S::try_from) } -fn primary_output_scale(state: &mut RefMut) -> i32 { - let mut scale = 1; - let mut current_output = state.display.take(); - for (id, output) in state.outputs.iter() { - if let Some((_, output_data)) = ¤t_output { - if output.scale > output_data.scale { - current_output = Some((id.clone(), output.clone())); - } - } else { - current_output = Some((id.clone(), output.clone())); - } - scale = scale.max(output.scale); - } - state.display = current_output; - scale -} - impl rwh::HasWindowHandle for WaylandWindow { fn window_handle(&self) -> Result, rwh::HandleError> { unimplemented!() diff --git a/crates/gpui/src/platform/linux/xdg_desktop_portal.rs b/crates/gpui/src/platform/linux/xdg_desktop_portal.rs index 64aa3975b86afb..722947a299e7b2 100644 --- a/crates/gpui/src/platform/linux/xdg_desktop_portal.rs +++ b/crates/gpui/src/platform/linux/xdg_desktop_portal.rs @@ -42,11 +42,13 @@ impl XDPEventSource { { sender.send(Event::CursorTheme(initial_theme))?; } + + // If u32 is used here, it throws invalid type error if let Ok(initial_size) = settings - .read::("org.gnome.desktop.interface", "cursor-size") + .read::("org.gnome.desktop.interface", "cursor-size") .await { - sender.send(Event::CursorSize(initial_size))?; + sender.send(Event::CursorSize(initial_size as u32))?; } if let Ok(mut cursor_theme_changed) = settings @@ -69,7 +71,7 @@ impl XDPEventSource { } if let Ok(mut cursor_size_changed) = settings - .receive_setting_changed_with_args::( + .receive_setting_changed_with_args::( "org.gnome.desktop.interface", "cursor-size", ) @@ -80,7 +82,7 @@ impl XDPEventSource { .spawn(async move { while let Some(size) = cursor_size_changed.next().await { let size = size?; - sender.send(Event::CursorSize(size))?; + sender.send(Event::CursorSize(size as u32))?; } anyhow::Ok(()) }) diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index ce9a4c05bffa85..9266f81f74a808 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -331,6 +331,7 @@ struct MacWindowState { traffic_light_position: Option>, previous_modifiers_changed_event: Option, keystroke_for_do_command: Option, + do_command_handled: Option, external_files_dragged: bool, // Whether the next left-mouse click is also the focusing click. first_mouse: bool, @@ -609,6 +610,7 @@ impl MacWindow { .and_then(|titlebar| titlebar.traffic_light_position), previous_modifiers_changed_event: None, keystroke_for_do_command: None, + do_command_handled: None, external_files_dragged: false, first_mouse: false, fullscreen_restore_bounds: Bounds::default(), @@ -1251,14 +1253,25 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: // otherwise we only send to the input handler if we don't have a matching binding. // The input handler may call `do_command_by_selector` if it doesn't know how to handle // a key. If it does so, it will return YES so we won't send the key twice. - if is_composing || event.keystroke.key.is_empty() { - window_state.as_ref().lock().keystroke_for_do_command = Some(event.keystroke.clone()); + // We also do this for non-printing keys (like arrow keys and escape) as the IME menu + // may need them even if there is no marked text; + // however we skip keys with control or the input handler adds control-characters to the buffer. + if is_composing || (event.keystroke.key_char.is_none() && !event.keystroke.modifiers.control) { + { + let mut lock = window_state.as_ref().lock(); + lock.keystroke_for_do_command = Some(event.keystroke.clone()); + lock.do_command_handled.take(); + drop(lock); + } + let handled: BOOL = unsafe { let input_context: id = msg_send![this, inputContext]; msg_send![input_context, handleEvent: native_event] }; window_state.as_ref().lock().keystroke_for_do_command.take(); - if handled == YES { + if let Some(handled) = window_state.as_ref().lock().do_command_handled.take() { + return handled as BOOL; + } else if handled == YES { return YES; } @@ -1377,6 +1390,14 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { }; match &event { + PlatformInput::MouseDown(_) => { + drop(lock); + unsafe { + let input_context: id = msg_send![this, inputContext]; + msg_send![input_context, handleEvent: native_event] + } + lock = window_state.as_ref().lock(); + } PlatformInput::MouseMove( event @ MouseMoveEvent { pressed_button: Some(_), @@ -1683,7 +1704,10 @@ extern "C" fn first_rect_for_character_range( let lock = state.lock(); let mut frame = NSWindow::frame(lock.native_window); let content_layout_rect: CGRect = msg_send![lock.native_window, contentLayoutRect]; - frame.origin.y -= frame.size.height - content_layout_rect.size.height; + let style_mask: NSWindowStyleMask = msg_send![lock.native_window, styleMask]; + if !style_mask.contains(NSWindowStyleMask::NSFullSizeContentViewWindowMask) { + frame.origin.y -= frame.size.height - content_layout_rect.size.height; + } frame }; with_input_handler(this, |input_handler| { @@ -1790,10 +1814,11 @@ extern "C" fn do_command_by_selector(this: &Object, _: Sel, _: Sel) { drop(lock); if let Some((keystroke, mut callback)) = keystroke.zip(event_callback.as_mut()) { - (callback)(PlatformInput::KeyDown(KeyDownEvent { + let handled = (callback)(PlatformInput::KeyDown(KeyDownEvent { keystroke, is_held: false, })); + state.as_ref().lock().do_command_handled = Some(!handled.propagate); } state.as_ref().lock().event_callback = event_callback; diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index 5f45d260d94a60..025fbba4ac97ff 100644 --- a/crates/gpui/src/platform/windows/events.rs +++ b/crates/gpui/src/platform/windows/events.rs @@ -7,6 +7,7 @@ use windows::Win32::{ Graphics::Gdi::*, System::SystemServices::*, UI::{ + Controls::*, HiDpi::*, Input::{Ime::*, KeyboardAndMouse::*}, WindowsAndMessaging::*, @@ -43,7 +44,8 @@ pub(crate) fn handle_msg( WM_PAINT => handle_paint_msg(handle, state_ptr), WM_CLOSE => handle_close_msg(state_ptr), WM_DESTROY => handle_destroy_msg(handle, state_ptr), - WM_MOUSEMOVE => handle_mouse_move_msg(lparam, wparam, state_ptr), + WM_MOUSEMOVE => handle_mouse_move_msg(handle, lparam, wparam, state_ptr), + WM_MOUSELEAVE => handle_mouse_leave_msg(state_ptr), WM_NCMOUSEMOVE => handle_nc_mouse_move_msg(handle, lparam, state_ptr), WM_NCLBUTTONDOWN => { handle_nc_mouse_down_msg(handle, MouseButton::Left, wparam, lparam, state_ptr) @@ -234,10 +236,32 @@ fn handle_destroy_msg(handle: HWND, state_ptr: Rc) -> Opt } fn handle_mouse_move_msg( + handle: HWND, lparam: LPARAM, wparam: WPARAM, state_ptr: Rc, ) -> Option { + let mut lock = state_ptr.state.borrow_mut(); + if !lock.hovered { + lock.hovered = true; + unsafe { + TrackMouseEvent(&mut TRACKMOUSEEVENT { + cbSize: std::mem::size_of::() as u32, + dwFlags: TME_LEAVE, + hwndTrack: handle, + dwHoverTime: HOVER_DEFAULT, + }) + .log_err() + }; + if let Some(mut callback) = lock.callbacks.hovered_status_change.take() { + drop(lock); + callback(true); + state_ptr.state.borrow_mut().callbacks.hovered_status_change = Some(callback); + } + } else { + drop(lock); + } + let mut lock = state_ptr.state.borrow_mut(); if let Some(mut callback) = lock.callbacks.input.take() { let scale_factor = lock.scale_factor; @@ -272,6 +296,18 @@ fn handle_mouse_move_msg( Some(1) } +fn handle_mouse_leave_msg(state_ptr: Rc) -> Option { + let mut lock = state_ptr.state.borrow_mut(); + lock.hovered = false; + if let Some(mut callback) = lock.callbacks.hovered_status_change.take() { + drop(lock); + callback(false); + state_ptr.state.borrow_mut().callbacks.hovered_status_change = Some(callback); + } + + Some(0) +} + fn handle_syskeydown_msg( wparam: WPARAM, lparam: LPARAM, diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index 91e9816106fc7e..389b90765df26c 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -6,7 +6,7 @@ use std::{ sync::Arc, }; -use ::util::ResultExt; +use ::util::{paths::SanitizedPath, ResultExt}; use anyhow::{anyhow, Context, Result}; use async_task::Runnable; use futures::channel::oneshot::{self, Receiver}; @@ -645,13 +645,11 @@ fn file_save_dialog(directory: PathBuf) -> Result> { let dialog: IFileSaveDialog = unsafe { CoCreateInstance(&FileSaveDialog, None, CLSCTX_ALL)? }; if !directory.to_string_lossy().is_empty() { if let Some(full_path) = directory.canonicalize().log_err() { - let full_path = full_path.to_string_lossy(); - let full_path_str = full_path.trim_start_matches("\\\\?\\"); - if !full_path_str.is_empty() { - let path_item: IShellItem = - unsafe { SHCreateItemFromParsingName(&HSTRING::from(full_path_str), None)? }; - unsafe { dialog.SetFolder(&path_item).log_err() }; - } + let full_path = SanitizedPath::from(full_path); + let full_path_string = full_path.to_string(); + let path_item: IShellItem = + unsafe { SHCreateItemFromParsingName(&HSTRING::from(full_path_string), None)? }; + unsafe { dialog.SetFolder(&path_item).log_err() }; } } unsafe { diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index f2600d3c6fd611..93671f9b89da75 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/crates/gpui/src/platform/windows/window.rs @@ -42,6 +42,7 @@ pub struct WindowsWindowState { pub callbacks: Callbacks, pub input_handler: Option, pub system_key_handled: bool, + pub hovered: bool, pub renderer: BladeRenderer, @@ -95,6 +96,7 @@ impl WindowsWindowState { let callbacks = Callbacks::default(); let input_handler = None; let system_key_handled = false; + let hovered = false; let click_state = ClickState::new(); let system_settings = WindowsSystemSettings::new(display); let nc_button_pressed = None; @@ -110,6 +112,7 @@ impl WindowsWindowState { callbacks, input_handler, system_key_handled, + hovered, renderer, click_state, system_settings, @@ -326,6 +329,7 @@ pub(crate) struct Callbacks { pub(crate) request_frame: Option>, pub(crate) input: Option DispatchEventResult>>, pub(crate) active_status_change: Option>, + pub(crate) hovered_status_change: Option>, pub(crate) resize: Option, f32)>>, pub(crate) moved: Option>, pub(crate) should_close: Option bool>>, @@ -635,9 +639,8 @@ impl PlatformWindow for WindowsWindow { self.0.hwnd == unsafe { GetActiveWindow() } } - // is_hovered is unused on Windows. See WindowContext::is_window_hovered. fn is_hovered(&self) -> bool { - false + self.0.state.borrow().hovered } fn set_title(&mut self, title: &str) { @@ -728,7 +731,9 @@ impl PlatformWindow for WindowsWindow { self.0.state.borrow_mut().callbacks.active_status_change = Some(callback); } - fn on_hover_status_change(&self, _: Box) {} + fn on_hover_status_change(&self, callback: Box) { + self.0.state.borrow_mut().callbacks.hovered_status_change = Some(callback); + } fn on_resize(&self, callback: Box, f32)>) { self.0.state.borrow_mut().callbacks.resize = Some(callback); diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 9787ec5d87f137..418be6af22b961 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -128,13 +128,15 @@ impl Scene { } pub fn finish(&mut self) { - self.shadows.sort(); - self.quads.sort(); - self.paths.sort(); - self.underlines.sort(); - self.monochrome_sprites.sort(); - self.polychrome_sprites.sort(); - self.surfaces.sort(); + self.shadows.sort_by_key(|shadow| shadow.order); + self.quads.sort_by_key(|quad| quad.order); + self.paths.sort_by_key(|path| path.order); + self.underlines.sort_by_key(|underline| underline.order); + self.monochrome_sprites + .sort_by_key(|sprite| (sprite.order, sprite.tile.tile_id)); + self.polychrome_sprites + .sort_by_key(|sprite| (sprite.order, sprite.tile.tile_id)); + self.surfaces.sort_by_key(|surface| surface.order); } #[cfg_attr( @@ -196,7 +198,7 @@ pub(crate) enum PaintOperation { EndLayer, } -#[derive(Clone, Ord, PartialOrd, Eq, PartialEq)] +#[derive(Clone)] pub(crate) enum Primitive { Shadow(Shadow), Quad(Quad), @@ -449,7 +451,7 @@ pub(crate) enum PrimitiveBatch<'a> { Surfaces(&'a [PaintSurface]), } -#[derive(Default, Debug, Clone, Eq, PartialEq)] +#[derive(Default, Debug, Clone)] #[repr(C)] pub(crate) struct Quad { pub order: DrawOrder, @@ -462,25 +464,13 @@ pub(crate) struct Quad { pub border_widths: Edges, } -impl Ord for Quad { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.order.cmp(&other.order) - } -} - -impl PartialOrd for Quad { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - impl From for Primitive { fn from(quad: Quad) -> Self { Primitive::Quad(quad) } } -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone)] #[repr(C)] pub(crate) struct Underline { pub order: DrawOrder, @@ -492,25 +482,13 @@ pub(crate) struct Underline { pub wavy: bool, } -impl Ord for Underline { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.order.cmp(&other.order) - } -} - -impl PartialOrd for Underline { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - impl From for Primitive { fn from(underline: Underline) -> Self { Primitive::Underline(underline) } } -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone)] #[repr(C)] pub(crate) struct Shadow { pub order: DrawOrder, @@ -521,18 +499,6 @@ pub(crate) struct Shadow { pub color: Hsla, } -impl Ord for Shadow { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.order.cmp(&other.order) - } -} - -impl PartialOrd for Shadow { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - impl From for Primitive { fn from(shadow: Shadow) -> Self { Primitive::Shadow(shadow) @@ -642,7 +608,7 @@ impl Default for TransformationMatrix { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug)] #[repr(C)] pub(crate) struct MonochromeSprite { pub order: DrawOrder, @@ -654,28 +620,13 @@ pub(crate) struct MonochromeSprite { pub transformation: TransformationMatrix, } -impl Ord for MonochromeSprite { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - match self.order.cmp(&other.order) { - std::cmp::Ordering::Equal => self.tile.tile_id.cmp(&other.tile.tile_id), - order => order, - } - } -} - -impl PartialOrd for MonochromeSprite { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - impl From for Primitive { fn from(sprite: MonochromeSprite) -> Self { Primitive::MonochromeSprite(sprite) } } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] #[repr(C)] pub(crate) struct PolychromeSprite { pub order: DrawOrder, @@ -687,22 +638,6 @@ pub(crate) struct PolychromeSprite { pub corner_radii: Corners, pub tile: AtlasTile, } -impl Eq for PolychromeSprite {} - -impl Ord for PolychromeSprite { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - match self.order.cmp(&other.order) { - std::cmp::Ordering::Equal => self.tile.tile_id.cmp(&other.tile.tile_id), - order => order, - } - } -} - -impl PartialOrd for PolychromeSprite { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} impl From for Primitive { fn from(sprite: PolychromeSprite) -> Self { @@ -710,7 +645,7 @@ impl From for Primitive { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug)] pub(crate) struct PaintSurface { pub order: DrawOrder, pub bounds: Bounds, @@ -719,18 +654,6 @@ pub(crate) struct PaintSurface { pub image_buffer: media::core_video::CVImageBuffer, } -impl Ord for PaintSurface { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.order.cmp(&other.order) - } -} - -impl PartialOrd for PaintSurface { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - impl From for Primitive { fn from(surface: PaintSurface) -> Self { Primitive::Surface(surface) @@ -859,26 +782,6 @@ impl Path { } } -impl Eq for Path {} - -impl PartialEq for Path { - fn eq(&self, other: &Self) -> bool { - self.order == other.order - } -} - -impl Ord for Path { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.order.cmp(&other.order) - } -} - -impl PartialOrd for Path { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - impl From> for Primitive { fn from(path: Path) -> Self { Primitive::Path(path) diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index 66eb914a30780d..13a7896a3ffc93 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -385,20 +385,28 @@ impl LineLayoutCache { let mut previous_frame = &mut *self.previous_frame.lock(); let mut current_frame = &mut *self.current_frame.write(); - for key in &previous_frame.used_lines[range.start.lines_index..range.end.lines_index] { - if let Some((key, line)) = previous_frame.lines.remove_entry(key) { - current_frame.lines.insert(key, line); + if let Some(cached_keys) = previous_frame + .used_lines + .get(range.start.lines_index..range.end.lines_index) + { + for key in cached_keys { + if let Some((key, line)) = previous_frame.lines.remove_entry(key) { + current_frame.lines.insert(key, line); + } + current_frame.used_lines.push(key.clone()); } - current_frame.used_lines.push(key.clone()); } - for key in &previous_frame.used_wrapped_lines - [range.start.wrapped_lines_index..range.end.wrapped_lines_index] + if let Some(cached_keys) = previous_frame + .used_wrapped_lines + .get(range.start.wrapped_lines_index..range.end.wrapped_lines_index) { - if let Some((key, line)) = previous_frame.wrapped_lines.remove_entry(key) { - current_frame.wrapped_lines.insert(key, line); + for key in cached_keys { + if let Some((key, line)) = previous_frame.wrapped_lines.remove_entry(key) { + current_frame.wrapped_lines.insert(key, line); + } + current_frame.used_wrapped_lines.push(key.clone()); } - current_frame.used_wrapped_lines.push(key.clone()); } } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index c1c14edba2bfb7..06298a81adb776 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1241,7 +1241,11 @@ impl<'a> WindowContext<'a> { /// that currently owns the mouse cursor. /// On mac, this is equivalent to `is_window_active`. pub fn is_window_hovered(&self) -> bool { - if cfg!(any(target_os = "linux", target_os = "freebsd")) { + if cfg!(any( + target_os = "windows", + target_os = "linux", + target_os = "freebsd" + )) { self.window.hovered.get() } else { self.is_window_active() @@ -1752,12 +1756,18 @@ impl<'a> WindowContext<'a> { .iter_mut() .map(|listener| listener.take()), ); - window.next_frame.accessed_element_states.extend( - window.rendered_frame.accessed_element_states[range.start.accessed_element_states_index - ..range.end.accessed_element_states_index] - .iter() - .map(|(id, type_id)| (GlobalElementId(id.0.clone()), *type_id)), - ); + if let Some(element_states) = window + .rendered_frame + .accessed_element_states + .get(range.start.accessed_element_states_index..range.end.accessed_element_states_index) + { + window.next_frame.accessed_element_states.extend( + element_states + .iter() + .map(|(id, type_id)| (GlobalElementId(id.0.clone()), *type_id)), + ); + } + window .text_system .reuse_layouts(range.start.line_layout_index..range.end.line_layout_index); @@ -3126,7 +3136,7 @@ impl<'a> WindowContext<'a> { self.window.mouse_position = position; if self.active_drag.is_none() { self.active_drag = Some(AnyDrag { - value: Box::new(paths.clone()), + value: Arc::new(paths.clone()), view: self.new_view(|_| paths).into(), cursor_offset: position, }); diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 1d03e77e76c310..c3f264d863b63b 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -16,7 +16,7 @@ use settings::Settings; use util::paths::PathExt; use workspace::{ item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams}, - ItemId, ItemSettings, Pane, ToolbarItemLocation, Workspace, WorkspaceId, + ItemId, ItemSettings, ToolbarItemLocation, Workspace, WorkspaceId, }; const IMAGE_VIEWER_KIND: &str = "ImageView"; @@ -78,7 +78,7 @@ impl Item for ImageView { fn for_each_project_item( &self, cx: &AppContext, - f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item), + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), ) { f(self.image_item.entity_id(), self.image_item.read(cx)) } @@ -172,9 +172,9 @@ impl SerializableItem for ImageView { _workspace: WeakView, workspace_id: WorkspaceId, item_id: ItemId, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Task>> { - cx.spawn(|_pane, mut cx| async move { + cx.spawn(|mut cx| async move { let image_path = IMAGE_VIEWER .get_image_path(item_id, workspace_id)? .ok_or_else(|| anyhow::anyhow!("No image path found"))?; @@ -301,7 +301,8 @@ impl Render for ImageView { img(image) .object_fit(ObjectFit::ScaleDown) .max_w_full() - .max_h_full(), + .max_h_full() + .id("img"), ), ) } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index d3a8d73ba5c65c..61e0a1e5c5dc57 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -14,7 +14,8 @@ use crate::{ SyntaxMapMatches, SyntaxSnapshot, ToTreeSitterPoint, }, task_context::RunnableRange, - LanguageScope, Outline, OutlineConfig, RunnableCapture, RunnableTag, + LanguageScope, Outline, OutlineConfig, RunnableCapture, RunnableTag, TextObject, + TreeSitterOptions, }; use anyhow::{anyhow, Context, Result}; use async_watch as watch; @@ -455,6 +456,7 @@ struct AutoindentRequest { before_edit: BufferSnapshot, entries: Vec, is_block_mode: bool, + ignore_empty_lines: bool, } #[derive(Debug, Clone)] @@ -1279,7 +1281,7 @@ impl Buffer { let autoindent_requests = self.autoindent_requests.clone(); Some(async move { - let mut indent_sizes = BTreeMap::new(); + let mut indent_sizes = BTreeMap::::new(); for request in autoindent_requests { // Resolve each edited range to its row in the current buffer and in the // buffer before this batch of edits. @@ -1373,10 +1375,12 @@ impl Buffer { let suggested_indent = indent_sizes .get(&suggestion.basis_row) .copied() + .map(|e| e.0) .unwrap_or_else(|| { snapshot.indent_size_for_line(suggestion.basis_row) }) .with_delta(suggestion.delta, language_indent_size); + if old_suggestions.get(&new_row).map_or( true, |(old_indentation, was_within_error)| { @@ -1384,7 +1388,10 @@ impl Buffer { && (!suggestion.within_error || *was_within_error) }, ) { - indent_sizes.insert(new_row, suggested_indent); + indent_sizes.insert( + new_row, + (suggested_indent, request.ignore_empty_lines), + ); } } } @@ -1392,10 +1399,12 @@ impl Buffer { if let (true, Some(original_indent_column)) = (request.is_block_mode, original_indent_column) { - let new_indent = indent_sizes - .get(&row_range.start) - .copied() - .unwrap_or_else(|| snapshot.indent_size_for_line(row_range.start)); + let new_indent = + if let Some((indent, _)) = indent_sizes.get(&row_range.start) { + *indent + } else { + snapshot.indent_size_for_line(row_range.start) + }; let delta = new_indent.len as i64 - original_indent_column as i64; if delta != 0 { for row in row_range.skip(1) { @@ -1410,7 +1419,7 @@ impl Buffer { Ordering::Equal => {} } } - size + (size, request.ignore_empty_lines) }); } } @@ -1421,6 +1430,15 @@ impl Buffer { } indent_sizes + .into_iter() + .filter_map(|(row, (indent, ignore_empty_lines))| { + if ignore_empty_lines && snapshot.line_len(row) == 0 { + None + } else { + Some((row, indent)) + } + }) + .collect() }) } @@ -1965,6 +1983,7 @@ impl Buffer { before_edit, entries, is_block_mode: matches!(mode, AutoindentMode::Block { .. }), + ignore_empty_lines: false, })); } @@ -1992,6 +2011,30 @@ impl Buffer { cx.notify(); } + pub fn autoindent_ranges(&mut self, ranges: I, cx: &mut ModelContext) + where + I: IntoIterator>, + T: ToOffset + Copy, + { + let before_edit = self.snapshot(); + let entries = ranges + .into_iter() + .map(|range| AutoindentRequestEntry { + range: before_edit.anchor_before(range.start)..before_edit.anchor_after(range.end), + first_line_is_new: true, + indent_size: before_edit.language_indent_size_at(range.start, cx), + original_indent_column: None, + }) + .collect(); + self.autoindent_requests.push(Arc::new(AutoindentRequest { + before_edit, + entries, + is_block_mode: false, + ignore_empty_lines: true, + })); + self.request_autoindent(cx); + } + // Inserts newlines at the given position to create an empty line, returning the start of the new line. // You can also request the insertion of empty lines above and below the line starting at the returned point. pub fn insert_empty_line( @@ -3268,6 +3311,72 @@ impl BufferSnapshot { }) } + pub fn text_object_ranges( + &self, + range: Range, + options: TreeSitterOptions, + ) -> impl Iterator, TextObject)> + '_ { + let range = range.start.to_offset(self).saturating_sub(1) + ..self.len().min(range.end.to_offset(self) + 1); + + let mut matches = + self.syntax + .matches_with_options(range.clone(), &self.text, options, |grammar| { + grammar.text_object_config.as_ref().map(|c| &c.query) + }); + + let configs = matches + .grammars() + .iter() + .map(|grammar| grammar.text_object_config.as_ref()) + .collect::>(); + + let mut captures = Vec::<(Range, TextObject)>::new(); + + iter::from_fn(move || loop { + while let Some(capture) = captures.pop() { + if capture.0.overlaps(&range) { + return Some(capture); + } + } + + let mat = matches.peek()?; + + let Some(config) = configs[mat.grammar_index].as_ref() else { + matches.advance(); + continue; + }; + + for capture in mat.captures { + let Some(ix) = config + .text_objects_by_capture_ix + .binary_search_by_key(&capture.index, |e| e.0) + .ok() + else { + continue; + }; + let text_object = config.text_objects_by_capture_ix[ix].1; + let byte_range = capture.node.byte_range(); + + let mut found = false; + for (range, existing) in captures.iter_mut() { + if existing == &text_object { + range.start = range.start.min(byte_range.start); + range.end = range.end.max(byte_range.end); + found = true; + break; + } + } + + if !found { + captures.push((byte_range, text_object)); + } + } + + matches.advance(); + }) + } + /// Returns enclosing bracket ranges containing the given range pub fn enclosing_bracket_ranges( &self, diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index e191bae5f9ed69..a1d1a57f13edbd 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -19,6 +19,7 @@ use std::{ sync::LazyLock, time::{Duration, Instant}, }; +use syntax_map::TreeSitterOptions; use text::network::Network; use text::{BufferId, LineEnding, LineIndent}; use text::{Point, ToPoint}; @@ -914,6 +915,39 @@ async fn test_symbols_containing(cx: &mut gpui::TestAppContext) { } } +#[gpui::test] +fn test_text_objects(cx: &mut AppContext) { + let (text, ranges) = marked_text_ranges( + indoc! {r#" + impl Hello { + fn say() -> u8 { return /* ˇhi */ 1 } + }"# + }, + false, + ); + + let buffer = + cx.new_model(|cx| Buffer::local(text.clone(), cx).with_language(Arc::new(rust_lang()), cx)); + let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); + + let matches = snapshot + .text_object_ranges(ranges[0].clone(), TreeSitterOptions::default()) + .map(|(range, text_object)| (&text[range], text_object)) + .collect::>(); + + assert_eq!( + matches, + &[ + ("/* hi */", TextObject::AroundComment), + ("return /* hi */ 1", TextObject::InsideFunction), + ( + "fn say() -> u8 { return /* hi */ 1 }", + TextObject::AroundFunction + ), + ], + ) +} + #[gpui::test] fn test_enclosing_bracket_ranges(cx: &mut AppContext) { let mut assert = |selection_text, range_markers| { @@ -3134,6 +3168,20 @@ fn rust_lang() -> Language { "#, ) .unwrap() + .with_text_object_query( + r#" + (function_item + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + + (line_comment)+ @comment.around + + (block_comment) @comment.around + "#, + ) + .unwrap() .with_outline_query( r#" (line_comment) @annotation diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 58be8a4dc3e9ce..e0cd392131105c 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -30,7 +30,10 @@ use gpui::{AppContext, AsyncAppContext, Model, SharedString, Task}; pub use highlight_map::HighlightMap; use http_client::HttpClient; pub use language_registry::{LanguageName, LoadedLanguage}; -use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerBinaryOptions, LanguageServerName}; +use lsp::{ + CodeActionKind, InitializeParams, LanguageServerBinary, LanguageServerBinaryOptions, + LanguageServerName, +}; use parking_lot::Mutex; use regex::Regex; use schemars::{ @@ -75,7 +78,7 @@ pub use language_registry::{ }; pub use lsp::LanguageServerId; pub use outline::*; -pub use syntax_map::{OwnedSyntaxLayer, SyntaxLayer}; +pub use syntax_map::{OwnedSyntaxLayer, SyntaxLayer, TreeSitterOptions}; pub use text::{AnchorRangeExt, LineEnding}; pub use tree_sitter::{Node, Parser, Tree, TreeCursor}; @@ -126,6 +129,10 @@ pub static PLAIN_TEXT: LazyLock> = LazyLock::new(|| { LanguageConfig { name: "Plain Text".into(), soft_wrap: Some(SoftWrap::EditorWidth), + matcher: LanguageMatcher { + path_suffixes: vec!["txt".to_owned()], + first_line_pattern: None, + }, ..Default::default() }, None, @@ -484,6 +491,11 @@ pub trait LspAdapter: 'static + Send + Sync { fn language_ids(&self) -> HashMap { Default::default() } + + /// Support custom initialize params. + fn prepare_initialize_params(&self, original: InitializeParams) -> Result { + Ok(original) + } } async fn try_fetch_server_binary( @@ -836,6 +848,7 @@ pub struct Grammar { pub(crate) runnable_config: Option, pub(crate) indents_config: Option, pub outline_config: Option, + pub text_object_config: Option, pub embedding_config: Option, pub(crate) injection_config: Option, pub(crate) override_config: Option, @@ -861,6 +874,44 @@ pub struct OutlineConfig { pub annotation_capture_ix: Option, } +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TextObject { + InsideFunction, + AroundFunction, + InsideClass, + AroundClass, + InsideComment, + AroundComment, +} + +impl TextObject { + pub fn from_capture_name(name: &str) -> Option { + match name { + "function.inside" => Some(TextObject::InsideFunction), + "function.around" => Some(TextObject::AroundFunction), + "class.inside" => Some(TextObject::InsideClass), + "class.around" => Some(TextObject::AroundClass), + "comment.inside" => Some(TextObject::InsideComment), + "comment.around" => Some(TextObject::AroundComment), + _ => None, + } + } + + pub fn around(&self) -> Option { + match self { + TextObject::InsideFunction => Some(TextObject::AroundFunction), + TextObject::InsideClass => Some(TextObject::AroundClass), + TextObject::InsideComment => Some(TextObject::AroundComment), + _ => None, + } + } +} + +pub struct TextObjectConfig { + pub query: Query, + pub text_objects_by_capture_ix: Vec<(u32, TextObject)>, +} + #[derive(Debug)] pub struct EmbeddingConfig { pub query: Query, @@ -938,6 +989,7 @@ impl Language { highlights_query: None, brackets_config: None, outline_config: None, + text_object_config: None, embedding_config: None, indents_config: None, injection_config: None, @@ -1008,7 +1060,12 @@ impl Language { if let Some(query) = queries.runnables { self = self .with_runnable_query(query.as_ref()) - .context("Error loading tests query")?; + .context("Error loading runnables query")?; + } + if let Some(query) = queries.text_objects { + self = self + .with_text_object_query(query.as_ref()) + .context("Error loading textobject query")?; } Ok(self) } @@ -1085,6 +1142,26 @@ impl Language { Ok(self) } + pub fn with_text_object_query(mut self, source: &str) -> Result { + let grammar = self + .grammar_mut() + .ok_or_else(|| anyhow!("cannot mutate grammar"))?; + let query = Query::new(&grammar.ts_language, source)?; + + let mut text_objects_by_capture_ix = Vec::new(); + for (ix, name) in query.capture_names().iter().enumerate() { + if let Some(text_object) = TextObject::from_capture_name(name) { + text_objects_by_capture_ix.push((ix as u32, text_object)); + } + } + + grammar.text_object_config = Some(TextObjectConfig { + query, + text_objects_by_capture_ix, + }); + Ok(self) + } + pub fn with_embedding_query(mut self, source: &str) -> Result { let grammar = self .grammar_mut() @@ -1410,6 +1487,10 @@ impl Language { pub fn prettier_parser_name(&self) -> Option<&str> { self.config.prettier_parser_name.as_deref() } + + pub fn config(&self) -> &LanguageConfig { + &self.config + } } impl LanguageScope { diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index d8c2b0d5107816..794ab0784ea3cd 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -130,6 +130,7 @@ pub struct AvailableLanguage { name: LanguageName, grammar: Option>, matcher: LanguageMatcher, + hidden: bool, load: Arc Result + 'static + Send + Sync>, loaded: bool, } @@ -142,6 +143,9 @@ impl AvailableLanguage { pub fn matcher(&self) -> &LanguageMatcher { &self.matcher } + pub fn hidden(&self) -> bool { + self.hidden + } } enum AvailableGrammar { @@ -177,6 +181,7 @@ pub const QUERY_FILENAME_PREFIXES: &[( ("overrides", |q| &mut q.overrides), ("redactions", |q| &mut q.redactions), ("runnables", |q| &mut q.runnables), + ("textobjects", |q| &mut q.text_objects), ]; /// Tree-sitter language queries for a given language. @@ -191,6 +196,7 @@ pub struct LanguageQueries { pub overrides: Option>, pub redactions: Option>, pub runnables: Option>, + pub text_objects: Option>, } #[derive(Clone, Default)] @@ -288,6 +294,7 @@ impl LanguageRegistry { config.name.clone(), config.grammar.clone(), config.matcher.clone(), + config.hidden, Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), @@ -436,6 +443,7 @@ impl LanguageRegistry { name: LanguageName, grammar_name: Option>, matcher: LanguageMatcher, + hidden: bool, load: Arc Result + 'static + Send + Sync>, ) { let state = &mut *self.state.write(); @@ -455,6 +463,7 @@ impl LanguageRegistry { grammar: grammar_name, matcher, load, + hidden, loaded: false, }); state.version += 1; @@ -522,6 +531,7 @@ impl LanguageRegistry { name: language.name(), grammar: language.config.grammar.clone(), matcher: language.config.matcher.clone(), + hidden: language.config.hidden, load: Arc::new(|| Err(anyhow!("already loaded"))), loaded: true, }); @@ -590,15 +600,12 @@ impl LanguageRegistry { async move { rx.await? } } - pub fn available_language_for_name( - self: &Arc, - name: &LanguageName, - ) -> Option { + pub fn available_language_for_name(self: &Arc, name: &str) -> Option { let state = self.state.read(); state .available_languages .iter() - .find(|l| &l.name == name) + .find(|l| l.name.0.as_ref() == name) .cloned() } diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index 12089255420d35..76c6dc75e3093a 100644 --- a/crates/language/src/syntax_map.rs +++ b/crates/language/src/syntax_map.rs @@ -814,6 +814,23 @@ impl SyntaxSnapshot { buffer.as_rope(), self.layers_for_range(range, buffer, true), query, + TreeSitterOptions::default(), + ) + } + + pub fn matches_with_options<'a>( + &'a self, + range: Range, + buffer: &'a BufferSnapshot, + options: TreeSitterOptions, + query: fn(&Grammar) -> Option<&Query>, + ) -> SyntaxMapMatches<'a> { + SyntaxMapMatches::new( + range.clone(), + buffer.as_rope(), + self.layers_for_range(range, buffer, true), + query, + options, ) } @@ -1001,12 +1018,25 @@ impl<'a> SyntaxMapCaptures<'a> { } } +#[derive(Default)] +pub struct TreeSitterOptions { + max_start_depth: Option, +} +impl TreeSitterOptions { + pub fn max_start_depth(max_start_depth: u32) -> Self { + Self { + max_start_depth: Some(max_start_depth), + } + } +} + impl<'a> SyntaxMapMatches<'a> { fn new( range: Range, text: &'a Rope, layers: impl Iterator>, query: fn(&Grammar) -> Option<&Query>, + options: TreeSitterOptions, ) -> Self { let mut result = Self::default(); for layer in layers { @@ -1027,6 +1057,7 @@ impl<'a> SyntaxMapMatches<'a> { query_cursor.deref_mut(), ) }; + cursor.set_max_start_depth(options.max_start_depth); cursor.set_byte_range(range.clone()); let matches = cursor.matches(query, layer.node(), TextProvider(text)); diff --git a/crates/language/src/toolchain.rs b/crates/language/src/toolchain.rs index fe8936db084a63..13703d81a7cd97 100644 --- a/crates/language/src/toolchain.rs +++ b/crates/language/src/toolchain.rs @@ -24,7 +24,7 @@ pub struct Toolchain { pub as_json: serde_json::Value, } -#[async_trait(?Send)] +#[async_trait] pub trait ToolchainLister: Send + Sync { async fn list( &self, diff --git a/crates/language_extension/src/language_extension.rs b/crates/language_extension/src/language_extension.rs index d8ffc71d7c4ab0..59951c87e48293 100644 --- a/crates/language_extension/src/language_extension.rs +++ b/crates/language_extension/src/language_extension.rs @@ -34,10 +34,11 @@ impl ExtensionLanguageProxy for LanguageServerRegistryProxy { language: LanguageName, grammar: Option>, matcher: LanguageMatcher, + hidden: bool, load: Arc Result + Send + Sync + 'static>, ) { self.language_registry - .register_language(language, grammar, matcher, load); + .register_language(language, grammar, matcher, hidden, load); } fn remove_languages( diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 3c5a00bd85e682..83f0b50321c4c0 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -55,7 +55,7 @@ pub enum LanguageModelCompletionEvent { StartMessage { message_id: String }, } -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum StopReason { EndTurn, diff --git a/crates/language_selector/Cargo.toml b/crates/language_selector/Cargo.toml index b864ffc31f3f4c..276e9b0d42bb7c 100644 --- a/crates/language_selector/Cargo.toml +++ b/crates/language_selector/Cargo.toml @@ -15,11 +15,14 @@ doctest = false [dependencies] anyhow.workspace = true editor.workspace = true +file_finder.workspace = true +file_icons.workspace = true fuzzy.workspace = true gpui.workspace = true language.workspace = true picker.workspace = true project.workspace = true +settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/language_selector/src/active_buffer_language.rs b/crates/language_selector/src/active_buffer_language.rs index 1d5f82d285bcd6..eeaa403e200ee2 100644 --- a/crates/language_selector/src/active_buffer_language.rs +++ b/crates/language_selector/src/active_buffer_language.rs @@ -4,7 +4,7 @@ use language::LanguageName; use ui::{Button, ButtonCommon, Clickable, FluentBuilder, LabelSize, Tooltip}; use workspace::{item::ItemHandle, StatusItemView, Workspace}; -use crate::LanguageSelector; +use crate::{LanguageSelector, Toggle}; pub struct ActiveBufferLanguage { active_language: Option>, @@ -54,7 +54,7 @@ impl Render for ActiveBufferLanguage { }); } })) - .tooltip(|cx| Tooltip::text("Select Language", cx)), + .tooltip(|cx| Tooltip::for_action("Select Language", &Toggle, cx)), ) }) } diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index 489f6fd141b927..60da837baab770 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -3,15 +3,18 @@ mod active_buffer_language; pub use active_buffer_language::ActiveBufferLanguage; use anyhow::anyhow; use editor::Editor; +use file_finder::file_finder_settings::FileFinderSettings; +use file_icons::FileIcons; use fuzzy::{match_strings, StringMatch, StringMatchCandidate}; use gpui::{ actions, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model, ParentElement, Render, Styled, View, ViewContext, VisualContext, WeakView, }; -use language::{Buffer, LanguageRegistry}; +use language::{Buffer, LanguageMatcher, LanguageName, LanguageRegistry}; use picker::{Picker, PickerDelegate}; use project::Project; -use std::sync::Arc; +use settings::Settings; +use std::{ops::Not as _, path::Path, sync::Arc}; use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing}; use util::ResultExt; use workspace::{ModalView, Workspace}; @@ -102,7 +105,13 @@ impl LanguageSelectorDelegate { .language_names() .into_iter() .enumerate() - .map(|(candidate_id, name)| StringMatchCandidate::new(candidate_id, name)) + .filter_map(|(candidate_id, name)| { + language_registry + .available_language_for_name(&name)? + .hidden() + .not() + .then(|| StringMatchCandidate::new(candidate_id, name)) + }) .collect::>(); Self { @@ -115,13 +124,64 @@ impl LanguageSelectorDelegate { selected_index: 0, } } + + fn language_data_for_match( + &self, + mat: &StringMatch, + cx: &AppContext, + ) -> (String, Option) { + let mut label = mat.string.clone(); + let buffer_language = self.buffer.read(cx).language(); + let need_icon = FileFinderSettings::get_global(cx).file_icons; + if let Some(buffer_language) = buffer_language { + let buffer_language_name = buffer_language.name(); + if buffer_language_name.0.as_ref() == mat.string.as_str() { + label.push_str(" (current)"); + let icon = need_icon + .then(|| self.language_icon(&buffer_language.config().matcher, cx)) + .flatten(); + return (label, icon); + } + } + + if need_icon { + let language_name = LanguageName::new(mat.string.as_str()); + match self + .language_registry + .available_language_for_name(&language_name.0) + { + Some(available_language) => { + let icon = self.language_icon(available_language.matcher(), cx); + (label, icon) + } + None => (label, None), + } + } else { + (label, None) + } + } + + fn language_icon(&self, matcher: &LanguageMatcher, cx: &AppContext) -> Option { + matcher + .path_suffixes + .iter() + .find_map(|extension| { + if extension.contains('.') { + None + } else { + FileIcons::get_icon(Path::new(&format!("file.{extension}")), cx) + } + }) + .map(Icon::from_path) + .map(|icon| icon.color(Color::Muted)) + } } impl PickerDelegate for LanguageSelectorDelegate { type ListItem = ListItem; fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { - "Select a language...".into() + "Select a language…".into() } fn match_count(&self) -> usize { @@ -215,17 +275,13 @@ impl PickerDelegate for LanguageSelectorDelegate { cx: &mut ViewContext>, ) -> Option { let mat = &self.matches[ix]; - let buffer_language_name = self.buffer.read(cx).language().map(|l| l.name()); - let mut label = mat.string.clone(); - if buffer_language_name.map(|n| n.0).as_deref() == Some(mat.string.as_str()) { - label.push_str(" (current)"); - } - + let (label, language_icon) = self.language_data_for_match(mat, cx); Some( ListItem::new(ix) .inset(true) .spacing(ListItemSpacing::Sparse) .selected(selected) + .start_slot::(language_icon) .child(HighlightedLabel::new(label, mat.positions.clone())), ) } diff --git a/crates/languages/src/bash/textobjects.scm b/crates/languages/src/bash/textobjects.scm new file mode 100644 index 00000000000000..cca2f7d9e9e4a8 --- /dev/null +++ b/crates/languages/src/bash/textobjects.scm @@ -0,0 +1,7 @@ +(function_definition + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + +(comment) @comment.around diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index 8d0369f0e01954..c50a16b3e467d8 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -4,10 +4,11 @@ use futures::StreamExt; use gpui::AsyncAppContext; use http_client::github::{latest_github_release, GitHubLspBinaryVersion}; pub use language::*; -use lsp::{LanguageServerBinary, LanguageServerName}; +use lsp::{InitializeParams, LanguageServerBinary, LanguageServerName}; +use serde_json::json; use smol::fs::{self, File}; use std::{any::Any, env::consts, path::PathBuf, sync::Arc}; -use util::{fs::remove_matching, maybe, ResultExt}; +use util::{fs::remove_matching, maybe, merge_json_value_into, ResultExt}; pub struct CLspAdapter; @@ -257,6 +258,26 @@ impl super::LspAdapter for CLspAdapter { filter_range, }) } + + fn prepare_initialize_params( + &self, + mut original: InitializeParams, + ) -> Result { + // enable clangd's dot-to-arrow feature. + let experimental = json!({ + "textDocument": { + "completion" : { + "editsNearCursor": true + } + } + }); + if let Some(ref mut original_experimental) = original.capabilities.experimental { + merge_json_value_into(experimental, original_experimental); + } else { + original.capabilities.experimental = Some(experimental); + } + Ok(original) + } } async fn get_cached_server_binary(container_dir: PathBuf) -> Option { diff --git a/crates/languages/src/c/textobjects.scm b/crates/languages/src/c/textobjects.scm new file mode 100644 index 00000000000000..832dd62288b40f --- /dev/null +++ b/crates/languages/src/c/textobjects.scm @@ -0,0 +1,25 @@ +(declaration + declarator: (function_declarator)) @function.around + +(function_definition + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + +(preproc_function_def + value: (_) @function.inside) @function.around + +(comment) @comment.around + +(struct_specifier + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(enum_specifier + body: (_ + "{" + [(_) ","?]* @class.inside + "}")) @class.around diff --git a/crates/languages/src/cpp/textobjects.scm b/crates/languages/src/cpp/textobjects.scm new file mode 100644 index 00000000000000..11a27b8d581dd5 --- /dev/null +++ b/crates/languages/src/cpp/textobjects.scm @@ -0,0 +1,31 @@ +(declaration + declarator: (function_declarator)) @function.around + +(function_definition + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + +(preproc_function_def + value: (_) @function.inside) @function.around + +(comment) @comment.around + +(struct_specifier + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(enum_specifier + body: (_ + "{" + [(_) ","?]* @class.inside + "}")) @class.around + +(class_specifier + body: (_ + "{" + [(_) ":"? ";"?]* @class.inside + "}"?)) @class.around diff --git a/crates/languages/src/css/config.toml b/crates/languages/src/css/config.toml index 9b0c9c703c07f1..d6ea2f9c7fe8b7 100644 --- a/crates/languages/src/css/config.toml +++ b/crates/languages/src/css/config.toml @@ -1,6 +1,6 @@ name = "CSS" grammar = "css" -path_suffixes = ["css", "postcss"] +path_suffixes = ["css", "postcss", "pcss"] autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, diff --git a/crates/languages/src/css/textobjects.scm b/crates/languages/src/css/textobjects.scm new file mode 100644 index 00000000000000..c9c6207b851e6b --- /dev/null +++ b/crates/languages/src/css/textobjects.scm @@ -0,0 +1,30 @@ +(comment) @comment.around + +(rule_set + (block ( + "{" + (_)* @function.inside + "}" ))) @function.around +(keyframe_block + (block ( + "{" + (_)* @function.inside + "}" ))) @function.around + +(media_statement + (block ( + "{" + (_)* @class.inside + "}" ))) @class.around + +(supports_statement + (block ( + "{" + (_)* @class.inside + "}" ))) @class.around + +(keyframes_statement + (keyframe_block_list ( + "{" + (_)* @class.inside + "}" ))) @class.around diff --git a/crates/languages/src/go/textobjects.scm b/crates/languages/src/go/textobjects.scm new file mode 100644 index 00000000000000..eb4f3a00501021 --- /dev/null +++ b/crates/languages/src/go/textobjects.scm @@ -0,0 +1,25 @@ +(function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(method_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(type_declaration + (type_spec (struct_type (field_declaration_list ( + "{" + (_)* @class.inside + "}")?)))) @class.around + +(type_declaration + (type_spec (interface_type + (_)* @class.inside))) @class.around + +(type_declaration) @class.around + +(comment)+ @comment.around diff --git a/crates/languages/src/javascript/outline.scm b/crates/languages/src/javascript/outline.scm index c5ec3d36dd36e1..0159d452cc8fd7 100644 --- a/crates/languages/src/javascript/outline.scm +++ b/crates/languages/src/javascript/outline.scm @@ -62,12 +62,20 @@ name: (_) @name) @item ; Add support for (node:test, bun:test and Jest) runnable -(call_expression - function: (_) @context - (#any-of? @context "it" "test" "describe") - arguments: ( - arguments . (string - (string_fragment) @name +( + (call_expression + function: [ + (identifier) @_name + (member_expression + object: [ + (identifier) @_name + (member_expression object: (identifier) @_name) + ] + ) + ] @context + (#any-of? @_name "it" "test" "describe") + arguments: ( + arguments . (string (string_fragment) @name) ) ) ) @item diff --git a/crates/languages/src/javascript/runnables.scm b/crates/languages/src/javascript/runnables.scm index 37f48e1df8f2ea..af619dacb7f19e 100644 --- a/crates/languages/src/javascript/runnables.scm +++ b/crates/languages/src/javascript/runnables.scm @@ -2,13 +2,20 @@ ; Function expression that has `it`, `test` or `describe` as the function name ( (call_expression - function: (_) @_name + function: [ + (identifier) @_name + (member_expression + object: [ + (identifier) @_name + (member_expression object: (identifier) @_name) + ] + ) + ] (#any-of? @_name "it" "test" "describe") arguments: ( - arguments . (string - (string_fragment) @run - ) + arguments . (string (string_fragment) @run) ) ) @_js-test + (#set! tag js-test) ) diff --git a/crates/languages/src/javascript/textobjects.scm b/crates/languages/src/javascript/textobjects.scm new file mode 100644 index 00000000000000..1a273ddb5000ba --- /dev/null +++ b/crates/languages/src/javascript/textobjects.scm @@ -0,0 +1,51 @@ +(comment)+ @comment.around + +(function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(method_definition + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(function_expression + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")) @function.around + +(arrow_function) @function.around + +(generator_function + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(generator_function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(class_declaration + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around + +(class + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around diff --git a/crates/languages/src/jsdoc/config.toml b/crates/languages/src/jsdoc/config.toml index 444e657a3863f4..0aa0d361bdc45b 100644 --- a/crates/languages/src/jsdoc/config.toml +++ b/crates/languages/src/jsdoc/config.toml @@ -5,3 +5,4 @@ brackets = [ { start = "{", end = "}", close = true, newline = false }, { start = "[", end = "]", close = true, newline = false }, ] +hidden = true diff --git a/crates/languages/src/json/textobjects.scm b/crates/languages/src/json/textobjects.scm new file mode 100644 index 00000000000000..81fd20245b93cd --- /dev/null +++ b/crates/languages/src/json/textobjects.scm @@ -0,0 +1 @@ +(comment)+ @comment.around diff --git a/crates/languages/src/jsonc/textobjects.scm b/crates/languages/src/jsonc/textobjects.scm new file mode 100644 index 00000000000000..81fd20245b93cd --- /dev/null +++ b/crates/languages/src/jsonc/textobjects.scm @@ -0,0 +1 @@ +(comment)+ @comment.around diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 776d47a5f775b2..5ba6f5c03439c2 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -62,6 +62,7 @@ pub fn init(languages: Arc, node_runtime: NodeRuntime, cx: &mu config.name.clone(), config.grammar.clone(), config.matcher.clone(), + config.hidden, Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), @@ -83,6 +84,7 @@ pub fn init(languages: Arc, node_runtime: NodeRuntime, cx: &mu config.name.clone(), config.grammar.clone(), config.matcher.clone(), + config.hidden, Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), @@ -104,6 +106,7 @@ pub fn init(languages: Arc, node_runtime: NodeRuntime, cx: &mu config.name.clone(), config.grammar.clone(), config.matcher.clone(), + config.hidden, Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), @@ -125,6 +128,7 @@ pub fn init(languages: Arc, node_runtime: NodeRuntime, cx: &mu config.name.clone(), config.grammar.clone(), config.matcher.clone(), + config.hidden, Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), diff --git a/crates/languages/src/markdown/textobjects.scm b/crates/languages/src/markdown/textobjects.scm new file mode 100644 index 00000000000000..e0f76c53651556 --- /dev/null +++ b/crates/languages/src/markdown/textobjects.scm @@ -0,0 +1,3 @@ +(section + (atx_heading) + (_)* @class.inside) @class.around diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 8736a129424ece..ec7ddde61dbad4 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -536,7 +536,7 @@ fn env_priority(kind: Option) -> usize { } } -#[async_trait(?Send)] +#[async_trait] impl ToolchainLister for PythonToolchainProvider { async fn list( &self, diff --git a/crates/languages/src/python/highlights.scm b/crates/languages/src/python/highlights.scm index 98ed2039695c7e..3b318fe962f13e 100644 --- a/crates/languages/src/python/highlights.scm +++ b/crates/languages/src/python/highlights.scm @@ -18,6 +18,12 @@ (tuple (identifier) @type) ) +; Forward references +(type + (string) @type +) + + ; Function calls (decorator diff --git a/crates/languages/src/python/textobjects.scm b/crates/languages/src/python/textobjects.scm new file mode 100644 index 00000000000000..abd28ab75ab6a5 --- /dev/null +++ b/crates/languages/src/python/textobjects.scm @@ -0,0 +1,7 @@ +(comment)+ @comment.around + +(function_definition + body: (_) @function.inside) @function.around + +(class_definition + body: (_) @class.inside) @class.around diff --git a/crates/languages/src/regex/config.toml b/crates/languages/src/regex/config.toml index d0938024d6e3a7..85f2e370d673f7 100644 --- a/crates/languages/src/regex/config.toml +++ b/crates/languages/src/regex/config.toml @@ -6,3 +6,4 @@ brackets = [ { start = "{", end = "}", close = true, newline = false }, { start = "[", end = "]", close = true, newline = false }, ] +hidden = true diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 25cddae5a65fb4..274d96f5fa9d1a 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -10,6 +10,7 @@ pub use language::*; use lsp::{LanguageServerBinary, LanguageServerName}; use regex::Regex; use smol::fs::{self}; +use std::fmt::Display; use std::{ any::Any, borrow::Cow, @@ -444,6 +445,10 @@ const RUST_PACKAGE_TASK_VARIABLE: VariableName = const RUST_BIN_NAME_TASK_VARIABLE: VariableName = VariableName::Custom(Cow::Borrowed("RUST_BIN_NAME")); +/// The bin kind (bin/example) corresponding to the current file in Cargo.toml +const RUST_BIN_KIND_TASK_VARIABLE: VariableName = + VariableName::Custom(Cow::Borrowed("RUST_BIN_KIND")); + const RUST_MAIN_FUNCTION_TASK_VARIABLE: VariableName = VariableName::Custom(Cow::Borrowed("_rust_main_function_end")); @@ -469,12 +474,16 @@ impl ContextProvider for RustContextProvider { .is_some(); if is_main_function { - if let Some((package_name, bin_name)) = local_abs_path.and_then(|path| { + if let Some(target) = local_abs_path.and_then(|path| { package_name_and_bin_name_from_abs_path(path, project_env.as_ref()) }) { return Task::ready(Ok(TaskVariables::from_iter([ - (RUST_PACKAGE_TASK_VARIABLE.clone(), package_name), - (RUST_BIN_NAME_TASK_VARIABLE.clone(), bin_name), + (RUST_PACKAGE_TASK_VARIABLE.clone(), target.package_name), + (RUST_BIN_NAME_TASK_VARIABLE.clone(), target.target_name), + ( + RUST_BIN_KIND_TASK_VARIABLE.clone(), + target.target_kind.to_string(), + ), ]))); } } @@ -568,8 +577,9 @@ impl ContextProvider for RustContextProvider { }, TaskTemplate { label: format!( - "cargo run -p {} --bin {}", + "cargo run -p {} --{} {}", RUST_PACKAGE_TASK_VARIABLE.template_value(), + RUST_BIN_KIND_TASK_VARIABLE.template_value(), RUST_BIN_NAME_TASK_VARIABLE.template_value(), ), command: "cargo".into(), @@ -577,7 +587,7 @@ impl ContextProvider for RustContextProvider { "run".into(), "-p".into(), RUST_PACKAGE_TASK_VARIABLE.template_value(), - "--bin".into(), + format!("--{}", RUST_BIN_KIND_TASK_VARIABLE.template_value()), RUST_BIN_NAME_TASK_VARIABLE.template_value(), ], cwd: Some("$ZED_DIRNAME".to_owned()), @@ -635,10 +645,42 @@ struct CargoTarget { src_path: String, } +#[derive(Debug, PartialEq)] +enum TargetKind { + Bin, + Example, +} + +impl Display for TargetKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TargetKind::Bin => write!(f, "bin"), + TargetKind::Example => write!(f, "example"), + } + } +} + +impl TryFrom<&str> for TargetKind { + type Error = (); + fn try_from(value: &str) -> Result { + match value { + "bin" => Ok(Self::Bin), + "example" => Ok(Self::Example), + _ => Err(()), + } + } +} +/// Which package and binary target are we in? +struct TargetInfo { + package_name: String, + target_name: String, + target_kind: TargetKind, +} + fn package_name_and_bin_name_from_abs_path( abs_path: &Path, project_env: Option<&HashMap>, -) -> Option<(String, String)> { +) -> Option { let mut command = util::command::new_std_command("cargo"); if let Some(envs) = project_env { command.envs(envs); @@ -656,10 +698,14 @@ fn package_name_and_bin_name_from_abs_path( let metadata: CargoMetadata = serde_json::from_slice(&output).log_err()?; retrieve_package_id_and_bin_name_from_metadata(metadata, abs_path).and_then( - |(package_id, bin_name)| { + |(package_id, bin_name, target_kind)| { let package_name = package_name_from_pkgid(&package_id); - package_name.map(|package_name| (package_name.to_owned(), bin_name)) + package_name.map(|package_name| TargetInfo { + package_name: package_name.to_owned(), + target_name: bin_name, + target_kind, + }) }, ) } @@ -667,13 +713,19 @@ fn package_name_and_bin_name_from_abs_path( fn retrieve_package_id_and_bin_name_from_metadata( metadata: CargoMetadata, abs_path: &Path, -) -> Option<(String, String)> { +) -> Option<(String, String, TargetKind)> { for package in metadata.packages { for target in package.targets { - let is_bin = target.kind.iter().any(|kind| kind == "bin"); + let Some(bin_kind) = target + .kind + .iter() + .find_map(|kind| TargetKind::try_from(kind.as_ref()).ok()) + else { + continue; + }; let target_path = PathBuf::from(target.src_path); - if target_path == abs_path && is_bin { - return Some((package.id, target.name)); + if target_path == abs_path { + return Some((package.id, target.name, bin_kind)); } } } @@ -1066,7 +1118,11 @@ mod tests { ( r#"{"packages":[{"id":"path+file:///path/to/zed/crates/zed#0.131.0","targets":[{"name":"zed","kind":["bin"],"src_path":"/path/to/zed/src/main.rs"}]}]}"#, "/path/to/zed/src/main.rs", - Some(("path+file:///path/to/zed/crates/zed#0.131.0", "zed")), + Some(( + "path+file:///path/to/zed/crates/zed#0.131.0", + "zed", + TargetKind::Bin, + )), ), ( r#"{"packages":[{"id":"path+file:///path/to/custom-package#my-custom-package@0.1.0","targets":[{"name":"my-custom-bin","kind":["bin"],"src_path":"/path/to/custom-package/src/main.rs"}]}]}"#, @@ -1074,6 +1130,16 @@ mod tests { Some(( "path+file:///path/to/custom-package#my-custom-package@0.1.0", "my-custom-bin", + TargetKind::Bin, + )), + ), + ( + r#"{"packages":[{"id":"path+file:///path/to/custom-package#my-custom-package@0.1.0","targets":[{"name":"my-custom-bin","kind":["example"],"src_path":"/path/to/custom-package/src/main.rs"}]}]}"#, + "/path/to/custom-package/src/main.rs", + Some(( + "path+file:///path/to/custom-package#my-custom-package@0.1.0", + "my-custom-bin", + TargetKind::Example, )), ), ( @@ -1088,7 +1154,7 @@ mod tests { assert_eq!( retrieve_package_id_and_bin_name_from_metadata(metadata, absolute_path), - expected.map(|(pkgid, bin)| (pkgid.to_owned(), bin.to_owned())) + expected.map(|(pkgid, name, kind)| (pkgid.to_owned(), name.to_owned(), kind)) ); } } diff --git a/crates/languages/src/rust/outline.scm b/crates/languages/src/rust/outline.scm index 3012995e2a7f23..4299a01f19674e 100644 --- a/crates/languages/src/rust/outline.scm +++ b/crates/languages/src/rust/outline.scm @@ -15,11 +15,7 @@ (visibility_modifier)? @context name: (_) @name) @item -(impl_item - "impl" @context - trait: (_)? @name - "for"? @context - type: (_) @name +(function_item body: (_ "{" @open (_)* "}" @close)) @item (trait_item diff --git a/crates/languages/src/rust/textobjects.scm b/crates/languages/src/rust/textobjects.scm new file mode 100644 index 00000000000000..4e7e7fa0cd1ba4 --- /dev/null +++ b/crates/languages/src/rust/textobjects.scm @@ -0,0 +1,51 @@ +; functions +(function_signature_item) @function.around + +(function_item + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + +; classes +(struct_item + body: (_ + ["{" "("]? + [(_) ","?]* @class.inside + ["}" ")"]? )) @class.around + +(enum_item + body: (_ + "{" + [(_) ","?]* @class.inside + "}" )) @class.around + +(union_item + body: (_ + "{" + [(_) ","?]* @class.inside + "}" )) @class.around + +(trait_item + body: (_ + "{" + [(_) ","?]* @class.inside + "}" )) @class.around + +(impl_item + body: (_ + "{" + [(_) ","?]* @class.inside + "}" )) @class.around + +(mod_item + body: (_ + "{" + [(_) ","?]* @class.inside + "}" )) @class.around + +; comments + +(line_comment)+ @comment.around + +(block_comment) @comment.around diff --git a/crates/languages/src/tsx/outline.scm b/crates/languages/src/tsx/outline.scm index 0c3589071df263..34b80b733bb00b 100644 --- a/crates/languages/src/tsx/outline.scm +++ b/crates/languages/src/tsx/outline.scm @@ -70,12 +70,20 @@ name: (_) @name) @item ; Add support for (node:test, bun:test and Jest) runnable -(call_expression - function: (_) @context - (#any-of? @context "it" "test" "describe") - arguments: ( - arguments . (string - (string_fragment) @name +( + (call_expression + function: [ + (identifier) @_name + (member_expression + object: [ + (identifier) @_name + (member_expression object: (identifier) @_name) + ] + ) + ] @context + (#any-of? @_name "it" "test" "describe") + arguments: ( + arguments . (string (string_fragment) @name) ) ) ) @item diff --git a/crates/languages/src/tsx/runnables.scm b/crates/languages/src/tsx/runnables.scm index 68c81d04c7833d..af619dacb7f19e 100644 --- a/crates/languages/src/tsx/runnables.scm +++ b/crates/languages/src/tsx/runnables.scm @@ -2,13 +2,20 @@ ; Function expression that has `it`, `test` or `describe` as the function name ( (call_expression - function: (_) @_name + function: [ + (identifier) @_name + (member_expression + object: [ + (identifier) @_name + (member_expression object: (identifier) @_name) + ] + ) + ] (#any-of? @_name "it" "test" "describe") arguments: ( - arguments . (string - (string_fragment) @run - ) + arguments . (string (string_fragment) @run) ) - ) @_tsx-test - (#set! tag tsx-test) + ) @_js-test + + (#set! tag js-test) ) diff --git a/crates/languages/src/tsx/textobjects.scm b/crates/languages/src/tsx/textobjects.scm new file mode 100644 index 00000000000000..836fed35ba1c10 --- /dev/null +++ b/crates/languages/src/tsx/textobjects.scm @@ -0,0 +1,79 @@ +(comment)+ @comment.around + +(function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(method_definition + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(function_expression + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")) @function.around + +(arrow_function) @function.around +(function_signature) @function.around + +(generator_function + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(generator_function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(class_declaration + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around + +(class + body: (_ + "{" + (_)* @class.inside + "}" )) @class.around + +(interface_declaration + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around + +(enum_declaration + body: (_ + "{" + [(_) ","?]* @class.inside + "}" )) @class.around + +(ambient_declaration + (module + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" ))) @class.around + +(internal_module + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around + +(type_alias_declaration) @class.around diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index c580575a1ecc9f..076d8d33748931 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -412,7 +412,7 @@ impl LspAdapter for EsLintLspAdapter { _delegate: &dyn LspAdapterDelegate, ) -> Result> { let url = build_asset_url( - "microsoft/vscode-eslint", + "zed-industries/vscode-eslint", Self::CURRENT_VERSION_TAG_NAME, Self::GITHUB_ASSET_KIND, )?; diff --git a/crates/languages/src/typescript/outline.scm b/crates/languages/src/typescript/outline.scm index 0c3589071df263..34b80b733bb00b 100644 --- a/crates/languages/src/typescript/outline.scm +++ b/crates/languages/src/typescript/outline.scm @@ -70,12 +70,20 @@ name: (_) @name) @item ; Add support for (node:test, bun:test and Jest) runnable -(call_expression - function: (_) @context - (#any-of? @context "it" "test" "describe") - arguments: ( - arguments . (string - (string_fragment) @name +( + (call_expression + function: [ + (identifier) @_name + (member_expression + object: [ + (identifier) @_name + (member_expression object: (identifier) @_name) + ] + ) + ] @context + (#any-of? @_name "it" "test" "describe") + arguments: ( + arguments . (string (string_fragment) @name) ) ) ) @item diff --git a/crates/languages/src/typescript/runnables.scm b/crates/languages/src/typescript/runnables.scm index 21a965fd31cbb5..af619dacb7f19e 100644 --- a/crates/languages/src/typescript/runnables.scm +++ b/crates/languages/src/typescript/runnables.scm @@ -2,13 +2,20 @@ ; Function expression that has `it`, `test` or `describe` as the function name ( (call_expression - function: (_) @_name + function: [ + (identifier) @_name + (member_expression + object: [ + (identifier) @_name + (member_expression object: (identifier) @_name) + ] + ) + ] (#any-of? @_name "it" "test" "describe") arguments: ( - arguments . (string - (string_fragment) @run - ) + arguments . (string (string_fragment) @run) ) - ) @_ts-test - (#set! tag ts-test) + ) @_js-test + + (#set! tag js-test) ) diff --git a/crates/languages/src/typescript/textobjects.scm b/crates/languages/src/typescript/textobjects.scm new file mode 100644 index 00000000000000..836fed35ba1c10 --- /dev/null +++ b/crates/languages/src/typescript/textobjects.scm @@ -0,0 +1,79 @@ +(comment)+ @comment.around + +(function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(method_definition + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(function_expression + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")) @function.around + +(arrow_function) @function.around +(function_signature) @function.around + +(generator_function + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(generator_function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(class_declaration + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around + +(class + body: (_ + "{" + (_)* @class.inside + "}" )) @class.around + +(interface_declaration + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around + +(enum_declaration + body: (_ + "{" + [(_) ","?]* @class.inside + "}" )) @class.around + +(ambient_declaration + (module + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" ))) @class.around + +(internal_module + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around + +(type_alias_declaration) @class.around diff --git a/crates/languages/src/yaml/textobjects.scm b/crates/languages/src/yaml/textobjects.scm new file mode 100644 index 00000000000000..5262b7e232edcd --- /dev/null +++ b/crates/languages/src/yaml/textobjects.scm @@ -0,0 +1 @@ +(comment)+ @comment diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 98755583e3ce57..8789f5f2521179 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -599,22 +599,14 @@ impl LanguageServer { Ok(()) } - /// Initializes a language server by sending the `Initialize` request. - /// Note that `options` is used directly to construct [`InitializeParams`], which is why it is owned. - /// - /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize) - pub fn initialize( - mut self, - options: Option, - cx: &AppContext, - ) -> Task>> { + pub fn default_initialize_params(&self, cx: &AppContext) -> InitializeParams { let root_uri = Url::from_file_path(&self.working_dir).unwrap(); #[allow(deprecated)] - let params = InitializeParams { + InitializeParams { process_id: None, root_path: None, root_uri: Some(root_uri.clone()), - initialization_options: options, + initialization_options: None, capabilities: ClientCapabilities { workspace: Some(WorkspaceClientCapabilities { configuration: Some(true), @@ -779,6 +771,22 @@ impl LanguageServer { }), locale: None, ..Default::default() + } + } + + /// Initializes a language server by sending the `Initialize` request. + /// Note that `options` is used directly to construct [`InitializeParams`], which is why it is owned. + /// + /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize) + pub fn initialize( + mut self, + initialize_params: Option, + cx: &AppContext, + ) -> Task>> { + let params = if let Some(params) = initialize_params { + params + } else { + self.default_initialize_params(cx) }; cx.spawn(|_| async move { diff --git a/crates/markdown/examples/markdown.rs b/crates/markdown/examples/markdown.rs index 0514ebcf4e1e57..26b4f833746535 100644 --- a/crates/markdown/examples/markdown.rs +++ b/crates/markdown/examples/markdown.rs @@ -178,7 +178,7 @@ impl MarkdownExample { cx: &mut WindowContext, ) -> Self { let markdown = - cx.new_view(|cx| Markdown::new(text, style, Some(language_registry), cx, None)); + cx.new_view(|cx| Markdown::new(text, style, Some(language_registry), None, cx)); Self { markdown } } } diff --git a/crates/markdown/examples/markdown_as_child.rs b/crates/markdown/examples/markdown_as_child.rs index 3700e64364dfd8..a7be4d28914ec1 100644 --- a/crates/markdown/examples/markdown_as_child.rs +++ b/crates/markdown/examples/markdown_as_child.rs @@ -87,7 +87,7 @@ pub fn main() { heading: Default::default(), }; let markdown = cx.new_view(|cx| { - Markdown::new(MARKDOWN_EXAMPLE.into(), markdown_style, None, cx, None) + Markdown::new(MARKDOWN_EXAMPLE.into(), markdown_style, None, None, cx) }); HelloWorld { markdown } diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index ff67c01a0ecc31..39217b69305ab7 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -71,8 +71,8 @@ impl Markdown { source: String, style: MarkdownStyle, language_registry: Option>, - cx: &ViewContext, fallback_code_block_language: Option, + cx: &ViewContext, ) -> Self { let focus_handle = cx.focus_handle(); let mut this = Self { diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index 6140372e0bd393..39bcd546dff2f3 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -417,6 +417,7 @@ fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) cx.with_common_p(div()) .children(render_markdown_text(parsed, cx)) .flex() + .flex_col() .into_any_element() } diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 4f1e5da6d771ed..73b9e01a639ced 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -323,6 +323,13 @@ struct ExcerptBytes<'a> { reversed: bool, } +struct BufferEdit { + range: Range, + new_text: Arc, + is_insertion: bool, + original_indent_column: u32, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ExpandExcerptDirection { Up, @@ -523,57 +530,146 @@ impl MultiBuffer { pub fn edit( &self, edits: I, - mut autoindent_mode: Option, + autoindent_mode: Option, cx: &mut ModelContext, ) where I: IntoIterator, T)>, S: ToOffset, T: Into>, { - if self.read_only() { - return; - } - if self.buffers.borrow().is_empty() { - return; - } - let snapshot = self.read(cx); - let edits = edits.into_iter().map(|(range, new_text)| { - let mut range = range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot); - if range.start > range.end { - mem::swap(&mut range.start, &mut range.end); + let edits = edits + .into_iter() + .map(|(range, new_text)| { + let mut range = range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot); + if range.start > range.end { + mem::swap(&mut range.start, &mut range.end); + } + (range, new_text.into()) + }) + .collect::>(); + + return edit_internal(self, snapshot, edits, autoindent_mode, cx); + + // Non-generic part of edit, hoisted out to avoid blowing up LLVM IR. + fn edit_internal( + this: &MultiBuffer, + snapshot: Ref, + edits: Vec<(Range, Arc)>, + mut autoindent_mode: Option, + cx: &mut ModelContext, + ) { + if this.read_only() || this.buffers.borrow().is_empty() { + return; + } + + if let Some(buffer) = this.as_singleton() { + buffer.update(cx, |buffer, cx| { + buffer.edit(edits, autoindent_mode, cx); + }); + cx.emit(Event::ExcerptsEdited { + ids: this.excerpt_ids(), + }); + return; + } + + let original_indent_columns = match &mut autoindent_mode { + Some(AutoindentMode::Block { + original_indent_columns, + }) => mem::take(original_indent_columns), + _ => Default::default(), + }; + + let (buffer_edits, edited_excerpt_ids) = + this.convert_edits_to_buffer_edits(edits, &snapshot, &original_indent_columns); + drop(snapshot); + + for (buffer_id, mut edits) in buffer_edits { + edits.sort_unstable_by_key(|edit| edit.range.start); + this.buffers.borrow()[&buffer_id] + .buffer + .update(cx, |buffer, cx| { + let mut edits = edits.into_iter().peekable(); + let mut insertions = Vec::new(); + let mut original_indent_columns = Vec::new(); + let mut deletions = Vec::new(); + let empty_str: Arc = Arc::default(); + while let Some(BufferEdit { + mut range, + new_text, + mut is_insertion, + original_indent_column, + }) = edits.next() + { + while let Some(BufferEdit { + range: next_range, + is_insertion: next_is_insertion, + .. + }) = edits.peek() + { + if range.end >= next_range.start { + range.end = cmp::max(next_range.end, range.end); + is_insertion |= *next_is_insertion; + edits.next(); + } else { + break; + } + } + + if is_insertion { + original_indent_columns.push(original_indent_column); + insertions.push(( + buffer.anchor_before(range.start) + ..buffer.anchor_before(range.end), + new_text.clone(), + )); + } else if !range.is_empty() { + deletions.push(( + buffer.anchor_before(range.start) + ..buffer.anchor_before(range.end), + empty_str.clone(), + )); + } + } + + let deletion_autoindent_mode = + if let Some(AutoindentMode::Block { .. }) = autoindent_mode { + Some(AutoindentMode::Block { + original_indent_columns: Default::default(), + }) + } else { + autoindent_mode.clone() + }; + let insertion_autoindent_mode = + if let Some(AutoindentMode::Block { .. }) = autoindent_mode { + Some(AutoindentMode::Block { + original_indent_columns, + }) + } else { + autoindent_mode.clone() + }; + + buffer.edit(deletions, deletion_autoindent_mode, cx); + buffer.edit(insertions, insertion_autoindent_mode, cx); + }) } - (range, new_text) - }); - if let Some(buffer) = self.as_singleton() { - buffer.update(cx, |buffer, cx| { - buffer.edit(edits, autoindent_mode, cx); - }); cx.emit(Event::ExcerptsEdited { - ids: self.excerpt_ids(), + ids: edited_excerpt_ids, }); - return; } + } - let original_indent_columns = match &mut autoindent_mode { - Some(AutoindentMode::Block { - original_indent_columns, - }) => mem::take(original_indent_columns), - _ => Default::default(), - }; - - struct BufferEdit { - range: Range, - new_text: Arc, - is_insertion: bool, - original_indent_column: u32, - } + fn convert_edits_to_buffer_edits( + &self, + edits: Vec<(Range, Arc)>, + snapshot: &MultiBufferSnapshot, + original_indent_columns: &[u32], + ) -> (HashMap>, Vec) { let mut buffer_edits: HashMap> = Default::default(); let mut edited_excerpt_ids = Vec::new(); let mut cursor = snapshot.excerpts.cursor::(&()); - for (ix, (range, new_text)) in edits.enumerate() { - let new_text: Arc = new_text.into(); + for (ix, (range, new_text)) in edits.into_iter().enumerate() { let original_indent_column = original_indent_columns.get(ix).copied().unwrap_or(0); cursor.seek(&range.start, Bias::Right, &()); if cursor.item().is_none() && range.start == *cursor.start() { @@ -665,84 +761,71 @@ impl MultiBuffer { } } } + (buffer_edits, edited_excerpt_ids) + } - drop(cursor); - drop(snapshot); - // Non-generic part of edit, hoisted out to avoid blowing up LLVM IR. - fn tail( + pub fn autoindent_ranges(&self, ranges: I, cx: &mut ModelContext) + where + I: IntoIterator>, + S: ToOffset, + { + let snapshot = self.read(cx); + let empty = Arc::::from(""); + let edits = ranges + .into_iter() + .map(|range| { + let mut range = range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot); + if range.start > range.end { + mem::swap(&mut range.start, &mut range.end); + } + (range, empty.clone()) + }) + .collect::>(); + + return autoindent_ranges_internal(self, snapshot, edits, cx); + + fn autoindent_ranges_internal( this: &MultiBuffer, - buffer_edits: HashMap>, - autoindent_mode: Option, - edited_excerpt_ids: Vec, + snapshot: Ref, + edits: Vec<(Range, Arc)>, cx: &mut ModelContext, ) { + if this.read_only() || this.buffers.borrow().is_empty() { + return; + } + + if let Some(buffer) = this.as_singleton() { + buffer.update(cx, |buffer, cx| { + buffer.autoindent_ranges(edits.into_iter().map(|e| e.0), cx); + }); + cx.emit(Event::ExcerptsEdited { + ids: this.excerpt_ids(), + }); + return; + } + + let (buffer_edits, edited_excerpt_ids) = + this.convert_edits_to_buffer_edits(edits, &snapshot, &[]); + drop(snapshot); + for (buffer_id, mut edits) in buffer_edits { edits.sort_unstable_by_key(|edit| edit.range.start); - this.buffers.borrow()[&buffer_id] - .buffer - .update(cx, |buffer, cx| { - let mut edits = edits.into_iter().peekable(); - let mut insertions = Vec::new(); - let mut original_indent_columns = Vec::new(); - let mut deletions = Vec::new(); - let empty_str: Arc = Arc::default(); - while let Some(BufferEdit { - mut range, - new_text, - mut is_insertion, - original_indent_column, - }) = edits.next() - { - while let Some(BufferEdit { - range: next_range, - is_insertion: next_is_insertion, - .. - }) = edits.peek() - { - if range.end >= next_range.start { - range.end = cmp::max(next_range.end, range.end); - is_insertion |= *next_is_insertion; - edits.next(); - } else { - break; - } - } - if is_insertion { - original_indent_columns.push(original_indent_column); - insertions.push(( - buffer.anchor_before(range.start) - ..buffer.anchor_before(range.end), - new_text.clone(), - )); - } else if !range.is_empty() { - deletions.push(( - buffer.anchor_before(range.start) - ..buffer.anchor_before(range.end), - empty_str.clone(), - )); - } + let mut ranges: Vec> = Vec::new(); + for edit in edits { + if let Some(last_range) = ranges.last_mut() { + if edit.range.start <= last_range.end { + last_range.end = last_range.end.max(edit.range.end); + continue; } + } + ranges.push(edit.range); + } - let deletion_autoindent_mode = - if let Some(AutoindentMode::Block { .. }) = autoindent_mode { - Some(AutoindentMode::Block { - original_indent_columns: Default::default(), - }) - } else { - autoindent_mode.clone() - }; - let insertion_autoindent_mode = - if let Some(AutoindentMode::Block { .. }) = autoindent_mode { - Some(AutoindentMode::Block { - original_indent_columns, - }) - } else { - autoindent_mode.clone() - }; - - buffer.edit(deletions, deletion_autoindent_mode, cx); - buffer.edit(insertions, insertion_autoindent_mode, cx); + this.buffers.borrow()[&buffer_id] + .buffer + .update(cx, |buffer, cx| { + buffer.autoindent_ranges(ranges, cx); }) } @@ -750,7 +833,6 @@ impl MultiBuffer { ids: edited_excerpt_ids, }); } - tail(self, buffer_edits, autoindent_mode, edited_excerpt_ids, cx); } // Inserts newlines at the given position to create an empty line, returning the start of the new line. @@ -3388,6 +3470,36 @@ impl MultiBufferSnapshot { }) } + pub fn excerpt_before(&self, id: ExcerptId) -> Option> { + let start_locator = self.excerpt_locator_for_id(id); + let mut cursor = self.excerpts.cursor::(&()); + cursor.seek(start_locator, Bias::Left, &()); + cursor.prev(&()); + let excerpt = cursor.item()?; + let excerpt_offset = cursor.start().text.len; + let excerpt_position = cursor.start().text.lines; + Some(MultiBufferExcerpt { + excerpt, + excerpt_offset, + excerpt_position, + }) + } + + pub fn excerpt_after(&self, id: ExcerptId) -> Option> { + let start_locator = self.excerpt_locator_for_id(id); + let mut cursor = self.excerpts.cursor::(&()); + cursor.seek(start_locator, Bias::Left, &()); + cursor.next(&()); + let excerpt = cursor.item()?; + let excerpt_offset = cursor.start().text.len; + let excerpt_position = cursor.start().text.lines; + Some(MultiBufferExcerpt { + excerpt, + excerpt_offset, + excerpt_position, + }) + } + pub fn excerpt_boundaries_in_range( &self, range: R, @@ -4487,6 +4599,26 @@ impl<'a> MultiBufferExcerpt<'a> { } } + pub fn id(&self) -> ExcerptId { + self.excerpt.id + } + + pub fn start_anchor(&self) -> Anchor { + Anchor { + buffer_id: Some(self.excerpt.buffer_id), + excerpt_id: self.excerpt.id, + text_anchor: self.excerpt.range.context.start, + } + } + + pub fn end_anchor(&self) -> Anchor { + Anchor { + buffer_id: Some(self.excerpt.buffer_id), + excerpt_id: self.excerpt.id, + text_anchor: self.excerpt.range.context.end, + } + } + pub fn buffer(&self) -> &'a BufferSnapshot { &self.excerpt.buffer } diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index f878b582d942e1..f36e144c88245e 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -36,7 +36,7 @@ use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrev}; use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings, ShowIndentGuides}; -use project::{File, Fs, Item, Project}; +use project::{File, Fs, Project, ProjectItem}; use search::{BufferSearchBar, ProjectSearchView}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; @@ -811,7 +811,7 @@ impl OutlinePanel { if self.filter_editor.focus_handle(cx).is_focused(cx) { cx.propagate() } else if let Some(selected_entry) = self.selected_entry().cloned() { - self.open_entry(&selected_entry, true, cx); + self.open_entry(&selected_entry, true, false, cx); } } @@ -834,7 +834,7 @@ impl OutlinePanel { } else if let Some((active_editor, selected_entry)) = self.active_editor().zip(self.selected_entry().cloned()) { - self.open_entry(&selected_entry, true, cx); + self.open_entry(&selected_entry, true, true, cx); active_editor.update(cx, |editor, cx| editor.open_excerpts(action, cx)); } } @@ -849,7 +849,7 @@ impl OutlinePanel { } else if let Some((active_editor, selected_entry)) = self.active_editor().zip(self.selected_entry().cloned()) { - self.open_entry(&selected_entry, true, cx); + self.open_entry(&selected_entry, true, true, cx); active_editor.update(cx, |editor, cx| editor.open_excerpts_in_split(action, cx)); } } @@ -857,7 +857,8 @@ impl OutlinePanel { fn open_entry( &mut self, entry: &PanelEntry, - change_selection: bool, + prefer_selection_change: bool, + change_focus: bool, cx: &mut ViewContext, ) { let Some(active_editor) = self.active_editor() else { @@ -871,9 +872,11 @@ impl OutlinePanel { Point::new(0.0, -(active_editor.read(cx).file_header_size() as f32)) }; + let mut change_selection = prefer_selection_change; let scroll_target = match entry { PanelEntry::FoldedDirs(..) | PanelEntry::Fs(FsEntry::Directory(..)) => None, PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => { + change_selection = false; let scroll_target = multi_buffer_snapshot.excerpts().find_map( |(excerpt_id, buffer_snapshot, excerpt_range)| { if &buffer_snapshot.remote_id() == buffer_id { @@ -887,6 +890,7 @@ impl OutlinePanel { Some(offset_from_top).zip(scroll_target) } PanelEntry::Fs(FsEntry::File(_, file_entry, ..)) => { + change_selection = false; let scroll_target = self .project .update(cx, |project, cx| { @@ -929,9 +933,9 @@ impl OutlinePanel { .workspace .update(cx, |workspace, cx| match self.active_item() { Some(active_item) => { - workspace.activate_item(active_item.as_ref(), true, change_selection, cx) + workspace.activate_item(active_item.as_ref(), true, change_focus, cx) } - None => workspace.activate_item(&active_editor, true, change_selection, cx), + None => workspace.activate_item(&active_editor, true, change_focus, cx), }); if activate.is_ok() { @@ -939,16 +943,20 @@ impl OutlinePanel { if change_selection { active_editor.update(cx, |editor, cx| { editor.change_selections( - Some(Autoscroll::Strategy(AutoscrollStrategy::Top)), + Some(Autoscroll::Strategy(AutoscrollStrategy::Center)), cx, |s| s.select_ranges(Some(anchor..anchor)), ); }); - active_editor.focus_handle(cx).focus(cx); } else { active_editor.update(cx, |editor, cx| { editor.set_scroll_anchor(ScrollAnchor { offset, anchor }, cx); }); + } + + if change_focus { + active_editor.focus_handle(cx).focus(cx); + } else { self.focus_handle.focus(cx); } } @@ -969,7 +977,7 @@ impl OutlinePanel { self.select_first(&SelectFirst {}, cx) } if let Some(selected_entry) = self.selected_entry().cloned() { - self.open_entry(&selected_entry, false, cx); + self.open_entry(&selected_entry, true, false, cx); } } @@ -988,7 +996,7 @@ impl OutlinePanel { self.select_last(&SelectLast, cx) } if let Some(selected_entry) = self.selected_entry().cloned() { - self.open_entry(&selected_entry, false, cx); + self.open_entry(&selected_entry, true, false, cx); } } @@ -2027,9 +2035,9 @@ impl OutlinePanel { if event.down.button == MouseButton::Right || event.down.first_mouse { return; } - let change_selection = event.down.click_count > 1; + let change_focus = event.down.click_count > 1; outline_panel.toggle_expanded(&clicked_entry, cx); - outline_panel.open_entry(&clicked_entry, change_selection, cx); + outline_panel.open_entry(&clicked_entry, true, change_focus, cx); }) }) .cursor_pointer() @@ -4863,9 +4871,13 @@ mod tests { ), select_first_in_all_matches(navigated_outline_selection) ); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + outline_panel.update(cx, |_, cx| { assert_eq!( selected_row_text(&active_editor, cx), - initial_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes + navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes "Should still have the initial caret position after SelectNext calls" ); }); @@ -4895,9 +4907,13 @@ mod tests { ), select_first_in_all_matches(next_navigated_outline_selection) ); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + outline_panel.update(cx, |_, cx| { assert_eq!( selected_row_text(&active_editor, cx), - navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes + next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes "Should again preserve the selection after another SelectNext call" ); }); diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index eb50fe238cd983..b74b7fc846eab0 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -1,7 +1,7 @@ use crate::{ search::SearchQuery, worktree_store::{WorktreeStore, WorktreeStoreEvent}, - Item, ProjectPath, + ProjectItem as _, ProjectPath, }; use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry}; use anyhow::{anyhow, Context as _, Result}; diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 9f794d5248c8b9..949e1f484e22d1 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -1,6 +1,6 @@ use crate::{ worktree_store::{WorktreeStore, WorktreeStoreEvent}, - Project, ProjectEntryId, ProjectPath, + Project, ProjectEntryId, ProjectItem, ProjectPath, }; use anyhow::{Context as _, Result}; use collections::{hash_map, HashMap, HashSet}; @@ -114,7 +114,7 @@ impl ImageItem { } } -impl crate::Item for ImageItem { +impl ProjectItem for ImageItem { fn try_open( project: &Model, path: &ProjectPath, @@ -151,6 +151,10 @@ impl crate::Item for ImageItem { fn project_path(&self, cx: &AppContext) -> Option { Some(self.project_path(cx).clone()) } + + fn is_dirty(&self) -> bool { + false + } } trait ImageStoreImpl { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 29a0afcfe59438..41a3ccc0a30490 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -10,7 +10,7 @@ use crate::{ toolchain_store::{EmptyToolchainStore, ToolchainStoreEvent}, worktree_store::{WorktreeStore, WorktreeStoreEvent}, yarn::YarnPathStore, - CodeAction, Completion, CoreCompletion, Hover, InlayHint, Item as _, ProjectPath, + CodeAction, Completion, CoreCompletion, Hover, InlayHint, ProjectItem as _, ProjectPath, ProjectTransaction, ResolveState, Symbol, ToolchainStore, }; use anyhow::{anyhow, Context as _, Result}; @@ -5577,7 +5577,7 @@ impl LspStore { let worktree = worktree_handle.read(cx); let worktree_id = worktree.id(); - let worktree_path = worktree.abs_path(); + let root_path = worktree.abs_path(); let key = (worktree_id, adapter.name.clone()); if self.language_server_ids.contains_key(&key) { @@ -5599,7 +5599,6 @@ impl LspStore { as Arc; let server_id = self.languages.next_language_server_id(); - let root_path = worktree_path.clone(); log::info!( "attempting to start language server {:?}, path: {root_path:?}, id: {server_id}", adapter.name.0 @@ -5674,8 +5673,6 @@ impl LspStore { .initialization_options(&(delegate)) .await?; - Self::setup_lsp_messages(this.clone(), &language_server, delegate, adapter); - match (&mut initialization_options, override_options) { (Some(initialization_options), Some(override_options)) => { merge_json_value_into(override_options, initialization_options); @@ -5684,8 +5681,18 @@ impl LspStore { _ => {} } + let initialization_params = cx.update(|cx| { + let mut params = language_server.default_initialize_params(cx); + params.initialization_options = initialization_options; + adapter.adapter.prepare_initialize_params(params) + })??; + + Self::setup_lsp_messages(this.clone(), &language_server, delegate, adapter); + let language_server = cx - .update(|cx| language_server.initialize(initialization_options, cx))? + .update(|cx| { + language_server.initialize(Some(initialization_params), cx) + })? .await .inspect_err(|_| { if let Some(this) = this.upgrade() { diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index fe5d0e79bc246f..ba025a06d82db1 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -111,7 +111,7 @@ const MAX_PROJECT_SEARCH_HISTORY_SIZE: usize = 500; const MAX_SEARCH_RESULT_FILES: usize = 5_000; const MAX_SEARCH_RESULT_RANGES: usize = 10_000; -pub trait Item { +pub trait ProjectItem { fn try_open( project: &Model, path: &ProjectPath, @@ -121,6 +121,7 @@ pub trait Item { Self: Sized; fn entry_id(&self, cx: &AppContext) -> Option; fn project_path(&self, cx: &AppContext) -> Option; + fn is_dirty(&self) -> bool; } #[derive(Clone)] @@ -4363,7 +4364,7 @@ impl ResolvedPath { } } -impl Item for Buffer { +impl ProjectItem for Buffer { fn try_open( project: &Model, path: &ProjectPath, @@ -4382,6 +4383,10 @@ impl Item for Buffer { path: file.path().clone(), }) } + + fn is_dirty(&self) -> bool { + self.is_dirty() + } } impl Completion { diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index 6a2d5032e413b1..0708f25410d963 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -3,14 +3,14 @@ use anyhow::Result; use client::proto; use fancy_regex::{Captures, Regex, RegexBuilder}; use gpui::Model; -use language::{Buffer, BufferSnapshot}; +use language::{Buffer, BufferSnapshot, CharKind}; use smol::future::yield_now; use std::{ borrow::Cow, io::{BufRead, BufReader, Read}, ops::Range, path::Path, - sync::{Arc, OnceLock}, + sync::{Arc, LazyLock, OnceLock}, }; use text::Anchor; use util::paths::PathMatcher; @@ -76,6 +76,12 @@ pub enum SearchQuery { }, } +static WORD_MATCH_TEST: LazyLock = LazyLock::new(|| { + RegexBuilder::new(r"\B") + .build() + .expect("Failed to create WORD_MATCH_TEST") +}); + impl SearchQuery { pub fn text( query: impl ToString, @@ -119,9 +125,17 @@ impl SearchQuery { let initial_query = Arc::from(query.as_str()); if whole_word { let mut word_query = String::new(); - word_query.push_str("\\b"); + if let Some(first) = query.get(0..1) { + if WORD_MATCH_TEST.is_match(first).is_ok_and(|x| !x) { + word_query.push_str("\\b"); + } + } word_query.push_str(&query); - word_query.push_str("\\b"); + if let Some(last) = query.get(query.len() - 1..) { + if WORD_MATCH_TEST.is_match(last).is_ok_and(|x| !x) { + word_query.push_str("\\b"); + } + } query = word_query } @@ -313,7 +327,9 @@ impl SearchQuery { let end_kind = classifier.kind(rope.reversed_chars_at(mat.end()).next().unwrap()); let next_kind = rope.chars_at(mat.end()).next().map(|c| classifier.kind(c)); - if Some(start_kind) == prev_kind || Some(end_kind) == next_kind { + if (Some(start_kind) == prev_kind && start_kind == CharKind::Word) + || (Some(end_kind) == next_kind && end_kind == CharKind::Word) + { continue; } } diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index 111516c82de4cb..34ef4d8a822d9f 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -1,8 +1,9 @@ use crate::Project; -use anyhow::Context as _; +use anyhow::{Context as _, Result}; use collections::HashMap; -use gpui::{AnyWindowHandle, AppContext, Context, Entity, Model, ModelContext, WeakModel}; +use gpui::{AnyWindowHandle, AppContext, Context, Entity, Model, ModelContext, Task, WeakModel}; use itertools::Itertools; +use language::LanguageName; use settings::{Settings, SettingsLocation}; use smol::channel::bounded; use std::{ @@ -10,10 +11,11 @@ use std::{ env::{self}, iter, path::{Path, PathBuf}, + sync::Arc, }; use task::{Shell, SpawnInTerminal}; use terminal::{ - terminal_settings::{self, TerminalSettings}, + terminal_settings::{self, TerminalSettings, VenvSettings}, TaskState, TaskStatus, Terminal, TerminalBuilder, }; use util::ResultExt; @@ -42,7 +44,7 @@ pub struct SshCommand { } impl Project { - pub fn active_project_directory(&self, cx: &AppContext) -> Option { + pub fn active_project_directory(&self, cx: &AppContext) -> Option> { let worktree = self .active_entry() .and_then(|entry_id| self.worktree_for_entry(entry_id, cx)) @@ -53,7 +55,7 @@ impl Project { worktree .root_entry() .filter(|entry| entry.is_dir()) - .map(|_| worktree.abs_path().to_path_buf()) + .map(|_| worktree.abs_path().clone()) }); worktree } @@ -87,12 +89,12 @@ impl Project { kind: TerminalKind, window: AnyWindowHandle, cx: &mut ModelContext, - ) -> anyhow::Result> { - let path = match &kind { - TerminalKind::Shell(path) => path.as_ref().map(|path| path.to_path_buf()), + ) -> Task>> { + let path: Option> = match &kind { + TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())), TerminalKind::Task(spawn_task) => { if let Some(cwd) = &spawn_task.cwd { - Some(cwd.clone()) + Some(Arc::from(cwd.as_ref())) } else { self.active_project_directory(cx) } @@ -109,7 +111,7 @@ impl Project { }); } } - let settings = TerminalSettings::get(settings_location, cx); + let settings = TerminalSettings::get(settings_location, cx).clone(); let (completion_tx, completion_rx) = bounded(1); @@ -128,160 +130,206 @@ impl Project { } else { None }; - let python_venv_directory = path - .as_ref() - .and_then(|path| self.python_venv_directory(path, settings, cx)); - let mut python_venv_activate_command = None; - - let (spawn_task, shell) = match kind { - TerminalKind::Shell(_) => { - if let Some(python_venv_directory) = python_venv_directory { - python_venv_activate_command = - self.python_activate_command(&python_venv_directory, settings); - } - match &ssh_details { - Some((host, ssh_command)) => { - log::debug!("Connecting to a remote server: {ssh_command:?}"); - - // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed - // to properly display colors. - // We do not have the luxury of assuming the host has it installed, - // so we set it to a default that does not break the highlighting via ssh. - env.entry("TERM".to_string()) - .or_insert_with(|| "xterm-256color".to_string()); - - let (program, args) = - wrap_for_ssh(ssh_command, None, path.as_deref(), env, None); - env = HashMap::default(); - ( - None, - Shell::WithArguments { - program, - args, - title_override: Some(format!("{} — Terminal", host).into()), - }, - ) + cx.spawn(move |this, mut cx| async move { + let python_venv_directory = if let Some(path) = path.clone() { + this.update(&mut cx, |this, cx| { + this.python_venv_directory(path, settings.detect_venv.clone(), cx) + })? + .await + } else { + None + }; + let mut python_venv_activate_command = None; + + let (spawn_task, shell) = match kind { + TerminalKind::Shell(_) => { + if let Some(python_venv_directory) = python_venv_directory { + python_venv_activate_command = this + .update(&mut cx, |this, _| { + this.python_activate_command( + &python_venv_directory, + &settings.detect_venv, + ) + }) + .ok() + .flatten(); } - None => (None, settings.shell.clone()), - } - } - TerminalKind::Task(spawn_task) => { - let task_state = Some(TaskState { - id: spawn_task.id, - full_label: spawn_task.full_label, - label: spawn_task.label, - command_label: spawn_task.command_label, - hide: spawn_task.hide, - status: TaskStatus::Running, - show_summary: spawn_task.show_summary, - show_command: spawn_task.show_command, - completion_rx, - }); - - env.extend(spawn_task.env); - if let Some(venv_path) = &python_venv_directory { - env.insert( - "VIRTUAL_ENV".to_string(), - venv_path.to_string_lossy().to_string(), - ); + match &ssh_details { + Some((host, ssh_command)) => { + log::debug!("Connecting to a remote server: {ssh_command:?}"); + + // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed + // to properly display colors. + // We do not have the luxury of assuming the host has it installed, + // so we set it to a default that does not break the highlighting via ssh. + env.entry("TERM".to_string()) + .or_insert_with(|| "xterm-256color".to_string()); + + let (program, args) = + wrap_for_ssh(ssh_command, None, path.as_deref(), env, None); + env = HashMap::default(); + ( + Option::::None, + Shell::WithArguments { + program, + args, + title_override: Some(format!("{} — Terminal", host).into()), + }, + ) + } + None => (None, settings.shell.clone()), + } } - - match &ssh_details { - Some((host, ssh_command)) => { - log::debug!("Connecting to a remote server: {ssh_command:?}"); - env.entry("TERM".to_string()) - .or_insert_with(|| "xterm-256color".to_string()); - let (program, args) = wrap_for_ssh( - ssh_command, - Some((&spawn_task.command, &spawn_task.args)), - path.as_deref(), - env, - python_venv_directory, + TerminalKind::Task(spawn_task) => { + let task_state = Some(TaskState { + id: spawn_task.id, + full_label: spawn_task.full_label, + label: spawn_task.label, + command_label: spawn_task.command_label, + hide: spawn_task.hide, + status: TaskStatus::Running, + show_summary: spawn_task.show_summary, + show_command: spawn_task.show_command, + completion_rx, + }); + + env.extend(spawn_task.env); + + if let Some(venv_path) = &python_venv_directory { + env.insert( + "VIRTUAL_ENV".to_string(), + venv_path.to_string_lossy().to_string(), ); - env = HashMap::default(); - ( - task_state, - Shell::WithArguments { - program, - args, - title_override: Some(format!("{} — Terminal", host).into()), - }, - ) } - None => { - if let Some(venv_path) = &python_venv_directory { - add_environment_path(&mut env, &venv_path.join("bin")).log_err(); - } - ( - task_state, - Shell::WithArguments { - program: spawn_task.command, - args: spawn_task.args, - title_override: None, - }, - ) + match &ssh_details { + Some((host, ssh_command)) => { + log::debug!("Connecting to a remote server: {ssh_command:?}"); + env.entry("TERM".to_string()) + .or_insert_with(|| "xterm-256color".to_string()); + let (program, args) = wrap_for_ssh( + ssh_command, + Some((&spawn_task.command, &spawn_task.args)), + path.as_deref(), + env, + python_venv_directory, + ); + env = HashMap::default(); + ( + task_state, + Shell::WithArguments { + program, + args, + title_override: Some(format!("{} — Terminal", host).into()), + }, + ) + } + None => { + if let Some(venv_path) = &python_venv_directory { + add_environment_path(&mut env, &venv_path.join("bin")).log_err(); + } + + ( + task_state, + Shell::WithArguments { + program: spawn_task.command, + args: spawn_task.args, + title_override: None, + }, + ) + } } } - } - }; - - let terminal = TerminalBuilder::new( - local_path, - spawn_task, - shell, - env, - settings.cursor_shape.unwrap_or_default(), - settings.alternate_scroll, - settings.max_scroll_history_lines, - ssh_details.is_some(), - window, - completion_tx, - cx, - ) - .map(|builder| { - let terminal_handle = cx.new_model(|cx| builder.subscribe(cx)); - - self.terminals - .local_handles - .push(terminal_handle.downgrade()); - - let id = terminal_handle.entity_id(); - cx.observe_release(&terminal_handle, move |project, _terminal, cx| { - let handles = &mut project.terminals.local_handles; - - if let Some(index) = handles - .iter() - .position(|terminal| terminal.entity_id() == id) - { - handles.remove(index); - cx.notify(); - } - }) - .detach(); + }; + let terminal = this.update(&mut cx, |this, cx| { + TerminalBuilder::new( + local_path.map(|path| path.to_path_buf()), + spawn_task, + shell, + env, + settings.cursor_shape.unwrap_or_default(), + settings.alternate_scroll, + settings.max_scroll_history_lines, + ssh_details.is_some(), + window, + completion_tx, + cx, + ) + .map(|builder| { + let terminal_handle = cx.new_model(|cx| builder.subscribe(cx)); + + this.terminals + .local_handles + .push(terminal_handle.downgrade()); + + let id = terminal_handle.entity_id(); + cx.observe_release(&terminal_handle, move |project, _terminal, cx| { + let handles = &mut project.terminals.local_handles; + + if let Some(index) = handles + .iter() + .position(|terminal| terminal.entity_id() == id) + { + handles.remove(index); + cx.notify(); + } + }) + .detach(); - if let Some(activate_command) = python_venv_activate_command { - self.activate_python_virtual_environment(activate_command, &terminal_handle, cx); - } - terminal_handle - }); + if let Some(activate_command) = python_venv_activate_command { + this.activate_python_virtual_environment( + activate_command, + &terminal_handle, + cx, + ); + } + terminal_handle + }) + })?; - terminal + terminal + }) } - pub fn python_venv_directory( + fn python_venv_directory( &self, - abs_path: &Path, - settings: &TerminalSettings, - cx: &AppContext, - ) -> Option { - let venv_settings = settings.detect_venv.as_option()?; - if let Some(path) = self.find_venv_in_worktree(abs_path, &venv_settings, cx) { - return Some(path); - } - self.find_venv_on_filesystem(abs_path, &venv_settings, cx) + abs_path: Arc, + venv_settings: VenvSettings, + cx: &ModelContext, + ) -> Task> { + cx.spawn(move |this, mut cx| async move { + if let Some((worktree, _)) = this + .update(&mut cx, |this, cx| this.find_worktree(&abs_path, cx)) + .ok()? + { + let toolchain = this + .update(&mut cx, |this, cx| { + this.active_toolchain( + worktree.read(cx).id(), + LanguageName::new("Python"), + cx, + ) + }) + .ok()? + .await; + + if let Some(toolchain) = toolchain { + let toolchain_path = Path::new(toolchain.path.as_ref()); + return Some(toolchain_path.parent()?.parent()?.to_path_buf()); + } + } + let venv_settings = venv_settings.as_option()?; + this.update(&mut cx, move |this, cx| { + if let Some(path) = this.find_venv_in_worktree(&abs_path, &venv_settings, cx) { + return Some(path); + } + this.find_venv_on_filesystem(&abs_path, &venv_settings, cx) + }) + .ok() + .flatten() + }) } fn find_venv_in_worktree( @@ -337,9 +385,9 @@ impl Project { fn python_activate_command( &self, venv_base_directory: &Path, - settings: &TerminalSettings, + venv_settings: &VenvSettings, ) -> Option { - let venv_settings = settings.detect_venv.as_option()?; + let venv_settings = venv_settings.as_option()?; let activate_keyword = match venv_settings.activate_script { terminal_settings::ActivateScript::Default => match std::env::consts::OS { "windows" => ".", @@ -441,7 +489,7 @@ pub fn wrap_for_ssh( (program, args) } -fn add_environment_path(env: &mut HashMap, new_path: &Path) -> anyhow::Result<()> { +fn add_environment_path(env: &mut HashMap, new_path: &Path) -> Result<()> { let mut env_paths = vec![new_path.to_path_buf()]; if let Some(path) = env.get("PATH").or(env::var("PATH").ok().as_ref()) { let mut paths = std::env::split_paths(&path).collect::>(); diff --git a/crates/project/src/toolchain_store.rs b/crates/project/src/toolchain_store.rs index 4d4c32d74550c8..71228d96a43561 100644 --- a/crates/project/src/toolchain_store.rs +++ b/crates/project/src/toolchain_store.rs @@ -311,12 +311,14 @@ impl LocalToolchainStore { }) .ok()? .await; - let language = registry.language_for_name(&language_name.0).await.ok()?; - let toolchains = language - .toolchain_lister()? - .list(root.to_path_buf(), project_env) - .await; - Some(toolchains) + + cx.background_executor() + .spawn(async move { + let language = registry.language_for_name(&language_name.0).await.ok()?; + let toolchains = language.toolchain_lister()?; + Some(toolchains.list(root.to_path_buf(), project_env).await) + }) + .await }) } pub(crate) fn active_toolchain( diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index 2d1473ff4f8d59..d544c17c8e0967 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -23,7 +23,7 @@ use smol::{ stream::StreamExt, }; use text::ReplicaId; -use util::ResultExt; +use util::{paths::SanitizedPath, ResultExt}; use worktree::{Entry, ProjectEntryId, Worktree, WorktreeId, WorktreeSettings}; use crate::{search::SearchQuery, ProjectPath}; @@ -52,7 +52,7 @@ pub struct WorktreeStore { worktrees_reordered: bool, #[allow(clippy::type_complexity)] loading_worktrees: - HashMap, Shared, Arc>>>>, + HashMap, Arc>>>>, state: WorktreeStoreState, } @@ -148,11 +148,12 @@ impl WorktreeStore { pub fn find_worktree( &self, - abs_path: &Path, + abs_path: impl Into, cx: &AppContext, ) -> Option<(Model, PathBuf)> { + let abs_path: SanitizedPath = abs_path.into(); for tree in self.worktrees() { - if let Ok(relative_path) = abs_path.strip_prefix(tree.read(cx).abs_path()) { + if let Ok(relative_path) = abs_path.as_path().strip_prefix(tree.read(cx).abs_path()) { return Some((tree.clone(), relative_path.into())); } } @@ -193,12 +194,12 @@ impl WorktreeStore { pub fn create_worktree( &mut self, - abs_path: impl AsRef, + abs_path: impl Into, visible: bool, cx: &mut ModelContext, ) -> Task>> { - let path: Arc = abs_path.as_ref().into(); - if !self.loading_worktrees.contains_key(&path) { + let abs_path: SanitizedPath = abs_path.into(); + if !self.loading_worktrees.contains_key(&abs_path) { let task = match &self.state { WorktreeStoreState::Remote { upstream_client, .. @@ -206,20 +207,26 @@ impl WorktreeStore { if upstream_client.is_via_collab() { Task::ready(Err(Arc::new(anyhow!("cannot create worktrees via collab")))) } else { - self.create_ssh_worktree(upstream_client.clone(), abs_path, visible, cx) + self.create_ssh_worktree( + upstream_client.clone(), + abs_path.clone(), + visible, + cx, + ) } } WorktreeStoreState::Local { fs } => { - self.create_local_worktree(fs.clone(), abs_path, visible, cx) + self.create_local_worktree(fs.clone(), abs_path.clone(), visible, cx) } }; - self.loading_worktrees.insert(path.clone(), task.shared()); + self.loading_worktrees + .insert(abs_path.clone(), task.shared()); } - let task = self.loading_worktrees.get(&path).unwrap().clone(); + let task = self.loading_worktrees.get(&abs_path).unwrap().clone(); cx.spawn(|this, mut cx| async move { let result = task.await; - this.update(&mut cx, |this, _| this.loading_worktrees.remove(&path)) + this.update(&mut cx, |this, _| this.loading_worktrees.remove(&abs_path)) .ok(); match result { Ok(worktree) => Ok(worktree), @@ -231,12 +238,11 @@ impl WorktreeStore { fn create_ssh_worktree( &mut self, client: AnyProtoClient, - abs_path: impl AsRef, + abs_path: impl Into, visible: bool, cx: &mut ModelContext, ) -> Task, Arc>> { - let path_key: Arc = abs_path.as_ref().into(); - let mut abs_path = path_key.clone().to_string_lossy().to_string(); + let mut abs_path = Into::::into(abs_path).to_string(); // If we start with `/~` that means the ssh path was something like `ssh://user@host/~/home-dir-folder/` // in which case want to strip the leading the `/`. // On the host-side, the `~` will get expanded. @@ -294,12 +300,12 @@ impl WorktreeStore { fn create_local_worktree( &mut self, fs: Arc, - abs_path: impl AsRef, + abs_path: impl Into, visible: bool, cx: &mut ModelContext, ) -> Task, Arc>> { let next_entry_id = self.next_entry_id.clone(); - let path: Arc = abs_path.as_ref().into(); + let path: SanitizedPath = abs_path.into(); cx.spawn(move |this, mut cx| async move { let worktree = Worktree::local(path.clone(), visible, fs, next_entry_id, &mut cx).await; @@ -309,7 +315,7 @@ impl WorktreeStore { if visible { cx.update(|cx| { - cx.add_recent_document(&path); + cx.add_recent_document(path.as_path()); }) .log_err(); } diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index dbcabc9f83e707..af913d9d6b4728 100644 --- a/crates/project_panel/Cargo.toml +++ b/crates/project_panel/Cargo.toml @@ -15,6 +15,7 @@ doctest = false [dependencies] anyhow.workspace = true collections.workspace = true +command_palette_hooks.workspace = true db.workspace = true editor.workspace = true file_icons.workspace = true diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index c7579247276cb1..df78ff1118bc42 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -17,6 +17,7 @@ use file_icons::FileIcons; use anyhow::{anyhow, Context as _, Result}; use collections::{hash_map, BTreeSet, HashMap}; +use command_palette_hooks::CommandPaletteFilter; use git::repository::GitFileStatus; use gpui::{ actions, anchored, deferred, div, impl_actions, point, px, size, uniform_list, Action, @@ -38,6 +39,7 @@ use project_panel_settings::{ }; use serde::{Deserialize, Serialize}; use smallvec::SmallVec; +use std::any::TypeId; use std::{ cell::OnceCell, cmp, @@ -311,6 +313,15 @@ impl ProjectPanel { }) .detach(); + let trash_action = [TypeId::of::()]; + let is_remote = project.read(cx).is_via_collab(); + + if is_remote { + CommandPaletteFilter::update_global(cx, |filter, _cx| { + filter.hide_action_types(&trash_action); + }); + } + let filename_editor = cx.new_view(Editor::single_line); cx.subscribe( @@ -655,9 +666,11 @@ impl ProjectPanel { .action("Copy Relative Path", Box::new(CopyRelativePath)) .separator() .action("Rename", Box::new(Rename)) - .when(!is_root, |menu| { + .when(!is_root & !is_remote, |menu| { menu.action("Trash", Box::new(Trash { skip_prompt: false })) - .action("Delete", Box::new(Delete { skip_prompt: false })) + }) + .when(!is_root, |menu| { + menu.action("Delete", Box::new(Delete { skip_prompt: false })) }) .when(!is_remote & is_root, |menu| { menu.separator() @@ -1185,7 +1198,7 @@ impl ProjectPanel { fn remove(&mut self, trash: bool, skip_prompt: bool, cx: &mut ViewContext<'_, ProjectPanel>) { maybe!({ - let items_to_delete = self.disjoint_entries_for_removal(cx); + let items_to_delete = self.disjoint_entries(cx); if items_to_delete.is_empty() { return None; } @@ -1546,7 +1559,7 @@ impl ProjectPanel { } fn cut(&mut self, _: &Cut, cx: &mut ViewContext) { - let entries = self.marked_entries(); + let entries = self.disjoint_entries(cx); if !entries.is_empty() { self.clipboard = Some(ClipboardEntry::Cut(entries)); cx.notify(); @@ -1554,7 +1567,7 @@ impl ProjectPanel { } fn copy(&mut self, _: &Copy, cx: &mut ViewContext) { - let entries = self.marked_entries(); + let entries = self.disjoint_entries(cx); if !entries.is_empty() { self.clipboard = Some(ClipboardEntry::Copied(entries)); cx.notify(); @@ -1928,7 +1941,7 @@ impl ProjectPanel { None } - fn disjoint_entries_for_removal(&self, cx: &AppContext) -> BTreeSet { + fn disjoint_entries(&self, cx: &AppContext) -> BTreeSet { let marked_entries = self.marked_entries(); let mut sanitized_entries = BTreeSet::new(); if marked_entries.is_empty() { @@ -1976,25 +1989,25 @@ impl ProjectPanel { sanitized_entries } - // Returns list of entries that should be affected by an operation. - // When currently selected entry is not marked, it's treated as the only marked entry. + // Returns the union of the currently selected entry and all marked entries. fn marked_entries(&self) -> BTreeSet { - let Some(mut selection) = self.selection else { - return Default::default(); - }; - if self.marked_entries.contains(&selection) { - self.marked_entries - .iter() - .copied() - .map(|mut entry| { - entry.entry_id = self.resolve_entry(entry.entry_id); - entry - }) - .collect() - } else { - selection.entry_id = self.resolve_entry(selection.entry_id); - BTreeSet::from_iter([selection]) + let mut entries = self + .marked_entries + .iter() + .map(|entry| SelectedEntry { + entry_id: self.resolve_entry(entry.entry_id), + worktree_id: entry.worktree_id, + }) + .collect::>(); + + if let Some(selection) = self.selection { + entries.insert(SelectedEntry { + entry_id: self.resolve_entry(selection.entry_id), + worktree_id: selection.worktree_id, + }); } + + entries } /// Finds the currently selected subentry for a given leaf entry id. If a given entry @@ -2915,6 +2928,7 @@ impl ProjectPanel { this.marked_entries.remove(&selection); } } else if kind.is_dir() { + this.marked_entries.clear(); this.toggle_expanded(entry_id, cx); } else { let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled; @@ -3051,7 +3065,8 @@ impl ProjectPanel { .single_line() .color(filename_text_color) .when( - is_active && index == active_index, + index == active_index + && (is_active || is_marked), |this| this.underline(true), ), ); @@ -5177,6 +5192,163 @@ mod tests { ); } + #[gpui::test] + async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/test", + json!({ + "dir1": { + "a.txt": "", + "b.txt": "", + }, + "dir2": {}, + "c.txt": "", + "d.txt": "", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "test/dir1", cx); + + cx.simulate_modifiers_change(gpui::Modifiers { + control: true, + ..Default::default() + }); + + select_path_with_mark(&panel, "test/dir1", cx); + select_path_with_mark(&panel, "test/c.txt", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v test", + " v dir1 <== marked", + " a.txt", + " b.txt", + " > dir2", + " c.txt <== selected <== marked", + " d.txt", + ], + "Initial state before copying dir1 and c.txt" + ); + + panel.update(cx, |panel, cx| { + panel.copy(&Default::default(), cx); + }); + select_path(&panel, "test/dir2", cx); + panel.update(cx, |panel, cx| { + panel.paste(&Default::default(), cx); + }); + cx.executor().run_until_parked(); + + toggle_expand_dir(&panel, "test/dir2/dir1", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v test", + " v dir1 <== marked", + " a.txt", + " b.txt", + " v dir2", + " v dir1 <== selected", + " a.txt", + " b.txt", + " c.txt", + " c.txt <== marked", + " d.txt", + ], + "Should copy dir1 as well as c.txt into dir2" + ); + } + + #[gpui::test] + async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/test", + json!({ + "dir1": { + "a.txt": "", + "b.txt": "", + }, + "dir2": {}, + "c.txt": "", + "d.txt": "", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "test/dir1", cx); + + cx.simulate_modifiers_change(gpui::Modifiers { + control: true, + ..Default::default() + }); + + select_path_with_mark(&panel, "test/dir1/a.txt", cx); + select_path_with_mark(&panel, "test/dir1", cx); + select_path_with_mark(&panel, "test/c.txt", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v test", + " v dir1 <== marked", + " a.txt <== marked", + " b.txt", + " > dir2", + " c.txt <== selected <== marked", + " d.txt", + ], + "Initial state before copying a.txt, dir1 and c.txt" + ); + + panel.update(cx, |panel, cx| { + panel.copy(&Default::default(), cx); + }); + select_path(&panel, "test/dir2", cx); + panel.update(cx, |panel, cx| { + panel.paste(&Default::default(), cx); + }); + cx.executor().run_until_parked(); + + toggle_expand_dir(&panel, "test/dir2/dir1", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v test", + " v dir1 <== marked", + " a.txt <== marked", + " b.txt", + " v dir2", + " v dir1 <== selected", + " a.txt", + " b.txt", + " c.txt", + " c.txt <== marked", + " d.txt", + ], + "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1." + ); + } + #[gpui::test] async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) { init_test_with_editor(cx); @@ -7339,7 +7511,7 @@ mod tests { path: ProjectPath, } - impl project::Item for TestProjectItem { + impl project::ProjectItem for TestProjectItem { fn try_open( _project: &Model, path: &ProjectPath, @@ -7356,6 +7528,10 @@ mod tests { fn project_path(&self, _: &AppContext) -> Option { Some(self.path.clone()) } + + fn is_dirty(&self) -> bool { + false + } } impl ProjectItem for TestProjectItemView { diff --git a/crates/repl/src/kernels/native_kernel.rs b/crates/repl/src/kernels/native_kernel.rs index 974a721ac52f73..2d796e12c6f812 100644 --- a/crates/repl/src/kernels/native_kernel.rs +++ b/crates/repl/src/kernels/native_kernel.rs @@ -6,9 +6,12 @@ use futures::{ AsyncBufReadExt as _, SinkExt as _, }; use gpui::{EntityId, Task, View, WindowContext}; -use jupyter_protocol::{JupyterKernelspec, JupyterMessage, JupyterMessageContent, KernelInfoReply}; +use jupyter_protocol::{ + connection_info::{ConnectionInfo, Transport}, + ExecutionState, JupyterKernelspec, JupyterMessage, JupyterMessageContent, KernelInfoReply, +}; use project::Fs; -use runtimelib::{dirs, ConnectionInfo, ExecutionState}; +use runtimelib::dirs; use smol::{net::TcpListener, process::Command}; use std::{ env, @@ -119,7 +122,7 @@ impl NativeRunningKernel { let ports = peek_ports(ip).await?; let connection_info = ConnectionInfo { - transport: "tcp".to_string(), + transport: Transport::TCP, ip: ip.to_string(), stdin_port: ports[0], control_port: ports[1], diff --git a/crates/repl/src/notebook/notebook_ui.rs b/crates/repl/src/notebook/notebook_ui.rs index d10da13fd8ac32..435dab2d0c3971 100644 --- a/crates/repl/src/notebook/notebook_ui.rs +++ b/crates/repl/src/notebook/notebook_ui.rs @@ -158,16 +158,6 @@ impl NotebookEditor { }) } - fn is_dirty(&self, cx: &AppContext) -> bool { - self.cell_map.values().any(|cell| { - if let Cell::Code(code_cell) = cell { - code_cell.read(cx).is_dirty(cx) - } else { - false - } - }) - } - fn clear_outputs(&mut self, cx: &mut ViewContext) { for cell in self.cell_map.values() { if let Cell::Code(code_cell) = cell { @@ -500,7 +490,7 @@ pub struct NotebookItem { id: ProjectEntryId, } -impl project::Item for NotebookItem { +impl project::ProjectItem for NotebookItem { fn try_open( project: &Model, path: &ProjectPath, @@ -561,6 +551,10 @@ impl project::Item for NotebookItem { fn project_path(&self, _: &AppContext) -> Option { Some(self.project_path.clone()) } + + fn is_dirty(&self) -> bool { + false + } } impl NotebookItem { @@ -656,7 +650,7 @@ impl Item for NotebookEditor { fn for_each_project_item( &self, cx: &AppContext, - f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item), + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), ) { f(self.notebook_item.entity_id(), self.notebook_item.read(cx)) } @@ -734,8 +728,13 @@ impl Item for NotebookEditor { } fn is_dirty(&self, cx: &AppContext) -> bool { - // self.is_dirty(cx) TODO - false + self.cell_map.values().any(|cell| { + if let Cell::Code(code_cell) = cell { + code_cell.read(cx).is_dirty(cx) + } else { + false + } + }) } } diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index b705a155681922..a1335f2a0d9059 100644 --- a/crates/repl/src/outputs.rs +++ b/crates/repl/src/outputs.rs @@ -334,9 +334,11 @@ impl ExecutionView { result.transient.as_ref().and_then(|t| t.display_id.clone()), cx, ), - JupyterMessageContent::DisplayData(result) => { - Output::new(&result.data, result.transient.display_id.clone(), cx) - } + JupyterMessageContent::DisplayData(result) => Output::new( + &result.data, + result.transient.as_ref().and_then(|t| t.display_id.clone()), + cx, + ), JupyterMessageContent::StreamContent(result) => { // Previous stream data will combine together, handling colors, carriage returns, etc if let Some(new_terminal) = self.apply_terminal_text(&result.text, cx) { diff --git a/crates/repl/src/repl_editor.rs b/crates/repl/src/repl_editor.rs index b032b1804a608e..3c203900da56b9 100644 --- a/crates/repl/src/repl_editor.rs +++ b/crates/repl/src/repl_editor.rs @@ -7,7 +7,7 @@ use anyhow::{Context, Result}; use editor::Editor; use gpui::{prelude::*, Entity, View, WeakView, WindowContext}; use language::{BufferSnapshot, Language, LanguageName, Point}; -use project::{Item as _, WorktreeId}; +use project::{ProjectItem as _, WorktreeId}; use crate::repl_store::ReplStore; use crate::session::SessionEvent; diff --git a/crates/repl/src/repl_sessions_ui.rs b/crates/repl/src/repl_sessions_ui.rs index 32b91ce28c7fed..11db19ef84832f 100644 --- a/crates/repl/src/repl_sessions_ui.rs +++ b/crates/repl/src/repl_sessions_ui.rs @@ -3,7 +3,7 @@ use gpui::{ actions, prelude::*, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, Subscription, View, }; -use project::Item as _; +use project::ProjectItem as _; use ui::{prelude::*, ButtonLike, ElevationIndex, KeyBinding}; use util::ResultExt as _; use workspace::item::ItemEvent; diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 41e5ba28df7adb..5b1a482f5e1f7d 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -27,7 +27,10 @@ use settings::Settings; use std::sync::Arc; use theme::ThemeSettings; -use ui::{h_flex, prelude::*, IconButton, IconButtonShape, IconName, Tooltip, BASE_REM_SIZE_IN_PX}; +use ui::{ + h_flex, prelude::*, utils::SearchInputWidth, IconButton, IconButtonShape, IconName, Tooltip, + BASE_REM_SIZE_IN_PX, +}; use util::ResultExt; use workspace::{ item::ItemHandle, @@ -38,8 +41,6 @@ use workspace::{ pub use registrar::DivRegistrar; use registrar::{ForDeployed, ForDismissed, SearchActionsRegistrar, WithResults}; -const MIN_INPUT_WIDTH_REMS: f32 = 10.; -const MAX_INPUT_WIDTH_REMS: f32 = 30.; const MAX_BUFFER_SEARCH_HISTORY_SIZE: usize = 50; #[derive(PartialEq, Clone, Deserialize)] @@ -160,12 +161,12 @@ impl Render for BufferSearchBar { query_editor.placeholder_text(cx).is_none() }) { self.query_editor.update(cx, |editor, cx| { - editor.set_placeholder_text("Search", cx); + editor.set_placeholder_text("Search…", cx); }); } self.replacement_editor.update(cx, |editor, cx| { - editor.set_placeholder_text("Replace with...", cx); + editor.set_placeholder_text("Replace with…", cx); }); let mut text_color = Color::Default; @@ -203,21 +204,26 @@ impl Render for BufferSearchBar { cx.theme().colors().border }; + let container_width = cx.viewport_size().width; + let input_width = SearchInputWidth::calc_width(container_width); + + let input_base_styles = || { + h_flex() + .w(input_width) + .h_8() + .px_2() + .py_1() + .border_1() + .border_color(editor_border) + .rounded_lg() + }; + let search_line = h_flex() .gap_2() .child( - h_flex() + input_base_styles() .id("editor-scroll") .track_scroll(&self.editor_scroll_handle) - .flex_1() - .h_8() - .px_2() - .py_1() - .border_1() - .border_color(editor_border) - .min_w(rems(MIN_INPUT_WIDTH_REMS)) - .max_w(rems(MAX_INPUT_WIDTH_REMS)) - .rounded_lg() .child(self.render_text_input(&self.query_editor, text_color.color(cx), cx)) .when(!hide_inline_icons, |div| { div.children(supported_options.case.then(|| { @@ -249,8 +255,8 @@ impl Render for BufferSearchBar { ) .child( h_flex() - .flex_none() - .gap_0p5() + .gap_1() + .min_w_64() .when(supported_options.replacement, |this| { this.child( IconButton::new( @@ -323,20 +329,27 @@ impl Render for BufferSearchBar { } }), ) - .child(render_nav_button( - ui::IconName::ChevronLeft, - self.active_match_index.is_some(), - "Select Previous Match", - &SelectPrevMatch, - focus_handle.clone(), - )) - .child(render_nav_button( - ui::IconName::ChevronRight, - self.active_match_index.is_some(), - "Select Next Match", - &SelectNextMatch, - focus_handle.clone(), - )) + .child( + h_flex() + .pl_2() + .ml_2() + .border_l_1() + .border_color(cx.theme().colors().border_variant) + .child(render_nav_button( + ui::IconName::ChevronLeft, + self.active_match_index.is_some(), + "Select Previous Match", + &SelectPrevMatch, + focus_handle.clone(), + )) + .child(render_nav_button( + ui::IconName::ChevronRight, + self.active_match_index.is_some(), + "Select Next Match", + &SelectNextMatch, + focus_handle.clone(), + )), + ) .when(!narrow_mode, |this| { this.child(h_flex().ml_2().min_w(rems_from_px(40.)).child( Label::new(match_text).size(LabelSize::Small).color( @@ -353,30 +366,15 @@ impl Render for BufferSearchBar { let replace_line = should_show_replace_input.then(|| { h_flex() .gap_2() - .flex_1() - .child( - h_flex() - .flex_1() - // We're giving this a fixed height to match the height of the search input, - // which has an icon inside that is increasing its height. - .h_8() - .px_2() - .py_1() - .border_1() - .border_color(cx.theme().colors().border) - .rounded_lg() - .min_w(rems(MIN_INPUT_WIDTH_REMS)) - .max_w(rems(MAX_INPUT_WIDTH_REMS)) - .child(self.render_text_input( - &self.replacement_editor, - cx.theme().colors().text, - cx, - )), - ) + .child(input_base_styles().child(self.render_text_input( + &self.replacement_editor, + cx.theme().colors().text, + cx, + ))) .child( h_flex() - .flex_none() - .gap_0p5() + .min_w_64() + .gap_1() .child( IconButton::new("search-replace-next", ui::IconName::ReplaceNext) .shape(IconButtonShape::Square) @@ -418,6 +416,7 @@ impl Render for BufferSearchBar { v_flex() .id("buffer_search") + .gap_2() .track_scroll(&self.scroll_handle) .key_context(key_context) .capture_action(cx.listener(Self::tab)) @@ -446,20 +445,22 @@ impl Render for BufferSearchBar { .when(self.supported_options().selection, |this| { this.on_action(cx.listener(Self::toggle_selection)) }) - .gap_2() .child( h_flex() + .relative() .child(search_line.w_full()) .when(!narrow_mode, |div| { div.child( - IconButton::new(SharedString::from("Close"), IconName::Close) - .shape(IconButtonShape::Square) - .tooltip(move |cx| { - Tooltip::for_action("Close Search Bar", &Dismiss, cx) - }) - .on_click(cx.listener(|this, _: &ClickEvent, cx| { - this.dismiss(&Dismiss, cx) - })), + h_flex().absolute().right_0().child( + IconButton::new(SharedString::from("Close"), IconName::Close) + .shape(IconButtonShape::Square) + .tooltip(move |cx| { + Tooltip::for_action("Close Search Bar", &Dismiss, cx) + }) + .on_click(cx.listener(|this, _: &ClickEvent, cx| { + this.dismiss(&Dismiss, cx) + })), + ), ) }), ) @@ -1866,6 +1867,86 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn test_search_query_with_match_whole_word(cx: &mut TestAppContext) { + init_globals(cx); + let buffer_text = r#" + self.buffer.update(cx, |buffer, cx| { + buffer.edit( + edits, + Some(AutoindentMode::Block { + original_indent_columns, + }), + cx, + ) + }); + + this.buffer.update(cx, |buffer, cx| { + buffer.edit([(end_of_line..start_of_next_line, replace)], None, cx) + }); + "# + .unindent(); + let buffer = cx.new_model(|cx| Buffer::local(buffer_text, cx)); + let cx = cx.add_empty_window(); + + let editor = cx.new_view(|cx| Editor::for_buffer(buffer.clone(), None, cx)); + + let search_bar = cx.new_view(|cx| { + let mut search_bar = BufferSearchBar::new(cx); + search_bar.set_active_pane_item(Some(&editor), cx); + search_bar.show(cx); + search_bar + }); + + search_bar + .update(cx, |search_bar, cx| { + search_bar.search( + "edit\\(", + Some(SearchOptions::WHOLE_WORD | SearchOptions::REGEX), + cx, + ) + }) + .await + .unwrap(); + + search_bar.update(cx, |search_bar, cx| { + search_bar.select_all_matches(&SelectAllMatches, cx); + }); + search_bar.update(cx, |_, cx| { + let all_selections = + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)); + assert_eq!( + all_selections.len(), + 2, + "Should select all `edit(` in the buffer, but got: {all_selections:?}" + ); + }); + + search_bar + .update(cx, |search_bar, cx| { + search_bar.search( + "edit(", + Some(SearchOptions::WHOLE_WORD | SearchOptions::CASE_SENSITIVE), + cx, + ) + }) + .await + .unwrap(); + + search_bar.update(cx, |search_bar, cx| { + search_bar.select_all_matches(&SelectAllMatches, cx); + }); + search_bar.update(cx, |_, cx| { + let all_selections = + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)); + assert_eq!( + all_selections.len(), + 2, + "Should select all `edit(` in the buffer, but got: {all_selections:?}" + ); + }); + } + #[gpui::test] async fn test_search_query_history(cx: &mut TestAppContext) { init_globals(cx); diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 8430fd1f370a5d..9caec6af34b172 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -34,8 +34,8 @@ use std::{ }; use theme::ThemeSettings; use ui::{ - h_flex, prelude::*, v_flex, Icon, IconButton, IconButtonShape, IconName, KeyBinding, Label, - LabelCommon, LabelSize, Selectable, Tooltip, + h_flex, prelude::*, utils::SearchInputWidth, v_flex, Icon, IconButton, IconButtonShape, + IconName, KeyBinding, Label, LabelCommon, LabelSize, Selectable, Tooltip, }; use util::paths::PathMatcher; use workspace::{ @@ -333,20 +333,20 @@ impl Render for ProjectSearchView { let model = self.model.read(cx); let has_no_results = model.no_results.unwrap_or(false); let is_search_underway = model.pending_search.is_some(); - let major_text = if is_search_underway { - "Searching..." + + let heading_text = if is_search_underway { + "Searching…" } else if has_no_results { - "No results" + "No Results" } else { - "Search all files" + "Search All Files" }; - let major_text = div() + let heading_text = div() .justify_center() - .max_w_96() - .child(Label::new(major_text).size(LabelSize::Large)); + .child(Label::new(heading_text).size(LabelSize::Large)); - let minor_text: Option = if let Some(no_results) = model.no_results { + let page_content: Option = if let Some(no_results) = model.no_results { if model.pending_search.is_none() && no_results { Some( Label::new("No results found in this project for the provided query") @@ -359,20 +359,24 @@ impl Render for ProjectSearchView { } else { Some(self.landing_text_minor(cx).into_any_element()) }; - let minor_text = minor_text.map(|text| div().items_center().max_w_96().child(text)); + + let page_content = page_content.map(|text| div().child(text)); + v_flex() - .flex_1() .size_full() + .items_center() .justify_center() + .overflow_hidden() .bg(cx.theme().colors().editor_background) .track_focus(&self.focus_handle(cx)) .child( - h_flex() - .size_full() - .justify_center() - .child(h_flex().flex_1()) - .child(v_flex().gap_1().child(major_text).children(minor_text)) - .child(h_flex().flex_1()), + v_flex() + .id("project-search-landing-page") + .overflow_y_scroll() + .max_w_80() + .gap_1() + .child(heading_text) + .children(page_content), ) } } @@ -445,7 +449,7 @@ impl Item for ProjectSearchView { fn for_each_project_item( &self, cx: &AppContext, - f: &mut dyn FnMut(EntityId, &dyn project::Item), + f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem), ) { self.results_editor.for_each_project_item(cx, f) } @@ -669,7 +673,7 @@ impl ProjectSearchView { let query_editor = cx.new_view(|cx| { let mut editor = Editor::single_line(cx); - editor.set_placeholder_text("Search all files...", cx); + editor.set_placeholder_text("Search all files…", cx); editor.set_text(query_text, cx); editor }); @@ -692,7 +696,7 @@ impl ProjectSearchView { ); let replacement_editor = cx.new_view(|cx| { let mut editor = Editor::single_line(cx); - editor.set_placeholder_text("Replace in project...", cx); + editor.set_placeholder_text("Replace in project…", cx); if let Some(text) = replacement_text { editor.set_text(text, cx); } @@ -1586,9 +1590,12 @@ impl Render for ProjectSearchBar { let search = search.read(cx); let focus_handle = search.focus_handle(cx); + let container_width = cx.viewport_size().width; + let input_width = SearchInputWidth::calc_width(container_width); + let input_base_styles = || { h_flex() - .w_full() + .w(input_width) .h_8() .px_2() .py_1() @@ -1701,6 +1708,10 @@ impl Render for ProjectSearchBar { .unwrap_or_else(|| "0/0".to_string()); let matches_column = h_flex() + .pl_2() + .ml_2() + .border_l_1() + .border_color(cx.theme().colors().border_variant) .child( IconButton::new("project-search-prev-match", IconName::ChevronLeft) .shape(IconButtonShape::Square) @@ -1751,13 +1762,13 @@ impl Render for ProjectSearchBar { div() .id("matches") .ml_1() - .child( - Label::new(match_text).color(if search.active_match_index.is_some() { + .child(Label::new(match_text).size(LabelSize::Small).color( + if search.active_match_index.is_some() { Color::Default } else { Color::Disabled - }), - ) + }, + )) .when(limit_reached, |el| { el.tooltip(|cx| { Tooltip::text("Search limits reached.\nTry narrowing your search.", cx) @@ -1767,9 +1778,9 @@ impl Render for ProjectSearchBar { let search_line = h_flex() .w_full() - .gap_1p5() + .gap_2() .child(query_column) - .child(h_flex().min_w_40().child(mode_column).child(matches_column)); + .child(h_flex().min_w_64().child(mode_column).child(matches_column)); let replace_line = search.replace_enabled.then(|| { let replace_column = @@ -1779,7 +1790,7 @@ impl Render for ProjectSearchBar { let replace_actions = h_flex() - .min_w_40() + .min_w_64() .gap_1() .when(search.replace_enabled, |this| { this.child( @@ -1830,7 +1841,7 @@ impl Render for ProjectSearchBar { h_flex() .w_full() - .gap_1p5() + .gap_2() .child(replace_column) .child(replace_actions) }); @@ -1838,7 +1849,7 @@ impl Render for ProjectSearchBar { let filter_line = search.filters_enabled.then(|| { h_flex() .w_full() - .gap_1p5() + .gap_2() .child( input_base_styles() .on_action( @@ -1861,12 +1872,11 @@ impl Render for ProjectSearchBar { ) .child( h_flex() - .min_w_40() + .min_w_64() .gap_1() .child( IconButton::new("project-search-opened-only", IconName::FileSearch) .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) .selected(self.is_opened_only_enabled(cx)) .tooltip(|cx| Tooltip::text("Only Search Open Files", cx)) .on_click(cx.listener(|this, _, cx| { diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index 842f00ad9fd121..760eb14b218535 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -24,7 +24,7 @@ pub struct Toolbar { pub breadcrumbs: bool, } -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] pub struct TerminalSettings { pub shell: Shell, pub working_directory: WorkingDirectory, diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index e57d9d1fc68939..7e4a4fe76ff717 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -14,6 +14,7 @@ doctest = false [dependencies] anyhow.workspace = true +async-recursion.workspace = true breadcrumbs.workspace = true collections.workspace = true db.workspace = true diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index b8c31e05b014a2..d410ef6d720ff1 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -1,8 +1,357 @@ use anyhow::Result; -use std::path::PathBuf; +use async_recursion::async_recursion; +use collections::HashSet; +use futures::{stream::FuturesUnordered, StreamExt as _}; +use gpui::{AsyncWindowContext, Axis, Model, Task, View, WeakView}; +use project::{terminals::TerminalKind, Project}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use ui::{Pixels, ViewContext, VisualContext as _, WindowContext}; +use util::ResultExt as _; use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql}; -use workspace::{ItemId, WorkspaceDb, WorkspaceId}; +use workspace::{ + ItemHandle, ItemId, Member, Pane, PaneAxis, PaneGroup, SerializableItem as _, Workspace, + WorkspaceDb, WorkspaceId, +}; + +use crate::{ + default_working_directory, + terminal_panel::{new_terminal_pane, TerminalPanel}, + TerminalView, +}; + +pub(crate) fn serialize_pane_group( + pane_group: &PaneGroup, + active_pane: &View, + cx: &WindowContext, +) -> SerializedPaneGroup { + build_serialized_pane_group(&pane_group.root, active_pane, cx) +} + +fn build_serialized_pane_group( + pane_group: &Member, + active_pane: &View, + cx: &WindowContext, +) -> SerializedPaneGroup { + match pane_group { + Member::Axis(PaneAxis { + axis, + members, + flexes, + bounding_boxes: _, + }) => SerializedPaneGroup::Group { + axis: SerializedAxis(*axis), + children: members + .iter() + .map(|member| build_serialized_pane_group(member, active_pane, cx)) + .collect::>(), + flexes: Some(flexes.lock().clone()), + }, + Member::Pane(pane_handle) => { + SerializedPaneGroup::Pane(serialize_pane(pane_handle, pane_handle == active_pane, cx)) + } + } +} + +fn serialize_pane(pane: &View, active: bool, cx: &WindowContext) -> SerializedPane { + let mut items_to_serialize = HashSet::default(); + let pane = pane.read(cx); + let children = pane + .items() + .filter_map(|item| { + let terminal_view = item.act_as::(cx)?; + if terminal_view.read(cx).terminal().read(cx).task().is_some() { + None + } else { + let id = item.item_id().as_u64(); + items_to_serialize.insert(id); + Some(id) + } + }) + .collect::>(); + let active_item = pane + .active_item() + .map(|item| item.item_id().as_u64()) + .filter(|active_id| items_to_serialize.contains(active_id)); + + SerializedPane { + active, + children, + active_item, + } +} + +pub(crate) fn deserialize_terminal_panel( + workspace: WeakView, + project: Model, + database_id: WorkspaceId, + serialized_panel: SerializedTerminalPanel, + cx: &mut WindowContext, +) -> Task>> { + cx.spawn(move |mut cx| async move { + let terminal_panel = workspace.update(&mut cx, |workspace, cx| { + cx.new_view(|cx| { + let mut panel = TerminalPanel::new(workspace, cx); + panel.height = serialized_panel.height.map(|h| h.round()); + panel.width = serialized_panel.width.map(|w| w.round()); + panel + }) + })?; + match &serialized_panel.items { + SerializedItems::NoSplits(item_ids) => { + let items = deserialize_terminal_views( + database_id, + project, + workspace, + item_ids.as_slice(), + &mut cx, + ) + .await; + let active_item = serialized_panel.active_item_id; + terminal_panel.update(&mut cx, |terminal_panel, cx| { + terminal_panel.active_pane.update(cx, |pane, cx| { + populate_pane_items(pane, items, active_item, cx); + }); + })?; + } + SerializedItems::WithSplits(serialized_pane_group) => { + let center_pane = deserialize_pane_group( + workspace, + project, + terminal_panel.clone(), + database_id, + serialized_pane_group, + &mut cx, + ) + .await; + if let Some((center_group, active_pane)) = center_pane { + terminal_panel.update(&mut cx, |terminal_panel, _| { + terminal_panel.center = PaneGroup::with_root(center_group); + terminal_panel.active_pane = + active_pane.unwrap_or_else(|| terminal_panel.center.first_pane()); + })?; + } + } + } + + Ok(terminal_panel) + }) +} + +fn populate_pane_items( + pane: &mut Pane, + items: Vec>, + active_item: Option, + cx: &mut ViewContext<'_, Pane>, +) { + let mut item_index = pane.items_len(); + for item in items { + let activate_item = Some(item.item_id().as_u64()) == active_item; + pane.add_item(Box::new(item), false, false, None, cx); + item_index += 1; + if activate_item { + pane.activate_item(item_index, false, false, cx); + } + } +} + +#[async_recursion(?Send)] +async fn deserialize_pane_group( + workspace: WeakView, + project: Model, + panel: View, + workspace_id: WorkspaceId, + serialized: &SerializedPaneGroup, + cx: &mut AsyncWindowContext, +) -> Option<(Member, Option>)> { + match serialized { + SerializedPaneGroup::Group { + axis, + flexes, + children, + } => { + let mut current_active_pane = None; + let mut members = Vec::new(); + for child in children { + if let Some((new_member, active_pane)) = deserialize_pane_group( + workspace.clone(), + project.clone(), + panel.clone(), + workspace_id, + child, + cx, + ) + .await + { + members.push(new_member); + current_active_pane = current_active_pane.or(active_pane); + } + } + + if members.is_empty() { + return None; + } + + if members.len() == 1 { + return Some((members.remove(0), current_active_pane)); + } + + Some(( + Member::Axis(PaneAxis::load(axis.0, members, flexes.clone())), + current_active_pane, + )) + } + SerializedPaneGroup::Pane(serialized_pane) => { + let active = serialized_pane.active; + let new_items = deserialize_terminal_views( + workspace_id, + project.clone(), + workspace.clone(), + serialized_pane.children.as_slice(), + cx, + ) + .await; + + let pane = panel + .update(cx, |_, cx| { + new_terminal_pane(workspace.clone(), project.clone(), cx) + }) + .log_err()?; + let active_item = serialized_pane.active_item; + + let terminal = pane + .update(cx, |pane, cx| { + populate_pane_items(pane, new_items, active_item, cx); + // Avoid blank panes in splits + if pane.items_len() == 0 { + let working_directory = workspace + .update(cx, |workspace, cx| default_working_directory(workspace, cx)) + .ok() + .flatten(); + let kind = TerminalKind::Shell( + working_directory.as_deref().map(Path::to_path_buf), + ); + let window = cx.window_handle(); + let terminal = project + .update(cx, |project, cx| project.create_terminal(kind, window, cx)); + Some(Some(terminal)) + } else { + Some(None) + } + }) + .ok() + .flatten()?; + if let Some(terminal) = terminal { + let terminal = terminal.await.ok()?; + pane.update(cx, |pane, cx| { + let terminal_view = Box::new(cx.new_view(|cx| { + TerminalView::new(terminal, workspace.clone(), Some(workspace_id), cx) + })); + pane.add_item(terminal_view, true, false, None, cx); + }) + .ok()?; + } + Some((Member::Pane(pane.clone()), active.then_some(pane))) + } + } +} + +async fn deserialize_terminal_views( + workspace_id: WorkspaceId, + project: Model, + workspace: WeakView, + item_ids: &[u64], + cx: &mut AsyncWindowContext, +) -> Vec> { + let mut items = Vec::with_capacity(item_ids.len()); + let mut deserialized_items = item_ids + .iter() + .map(|item_id| { + cx.update(|cx| { + TerminalView::deserialize( + project.clone(), + workspace.clone(), + workspace_id, + *item_id, + cx, + ) + }) + .unwrap_or_else(|e| Task::ready(Err(e.context("no window present")))) + }) + .collect::>(); + while let Some(item) = deserialized_items.next().await { + if let Some(item) = item.log_err() { + items.push(item); + } + } + items +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct SerializedTerminalPanel { + pub items: SerializedItems, + // A deprecated field, kept for backwards compatibility for the code before terminal splits were introduced. + pub active_item_id: Option, + pub width: Option, + pub height: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub(crate) enum SerializedItems { + // The data stored before terminal splits were introduced. + NoSplits(Vec), + WithSplits(SerializedPaneGroup), +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) enum SerializedPaneGroup { + Pane(SerializedPane), + Group { + axis: SerializedAxis, + flexes: Option>, + children: Vec, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct SerializedPane { + pub active: bool, + pub children: Vec, + pub active_item: Option, +} + +#[derive(Debug)] +pub(crate) struct SerializedAxis(pub Axis); + +impl Serialize for SerializedAxis { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self.0 { + Axis::Horizontal => serializer.serialize_str("horizontal"), + Axis::Vertical => serializer.serialize_str("vertical"), + } + } +} + +impl<'de> Deserialize<'de> for SerializedAxis { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "horizontal" => Ok(SerializedAxis(Axis::Horizontal)), + "vertical" => Ok(SerializedAxis(Axis::Vertical)), + invalid => Err(serde::de::Error::custom(format!( + "Invalid axis value: '{invalid}'" + ))), + } + } +} define_connection! { pub static ref TERMINAL_DB: TerminalDb = diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index ee10e924f4c391..bbe25b8a92f1b2 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1,19 +1,24 @@ -use std::{ops::ControlFlow, path::PathBuf, sync::Arc}; - -use crate::{default_working_directory, TerminalView}; +use std::{cmp, ops::ControlFlow, path::PathBuf, sync::Arc, time::Duration}; + +use crate::{ + default_working_directory, + persistence::{ + deserialize_terminal_panel, serialize_pane_group, SerializedItems, SerializedTerminalPanel, + }, + TerminalView, +}; use breadcrumbs::Breadcrumbs; -use collections::{HashMap, HashSet}; +use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use futures::future::join_all; use gpui::{ - actions, Action, AnchorCorner, AnyView, AppContext, AsyncWindowContext, Entity, EventEmitter, + actions, Action, AnchorCorner, AnyView, AppContext, AsyncWindowContext, EventEmitter, ExternalPaths, FocusHandle, FocusableView, IntoElement, Model, ParentElement, Pixels, Render, - Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext, + Styled, Task, View, ViewContext, VisualContext, WeakView, WindowContext, }; use itertools::Itertools; -use project::{terminals::TerminalKind, Fs, ProjectEntryId}; +use project::{terminals::TerminalKind, Fs, Project, ProjectEntryId}; use search::{buffer_search::DivRegistrar, BufferSearchBar}; -use serde::{Deserialize, Serialize}; use settings::Settings; use task::{RevealStrategy, Shell, SpawnInTerminal, TaskId}; use terminal::{ @@ -21,16 +26,18 @@ use terminal::{ Terminal, }; use ui::{ - h_flex, ButtonCommon, Clickable, ContextMenu, IconButton, IconSize, PopoverMenu, Selectable, + prelude::*, ButtonCommon, Clickable, ContextMenu, FluentBuilder, PopoverMenu, Selectable, Tooltip, }; use util::{ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, item::SerializableItem, - pane, + move_item, pane, ui::IconName, - DraggedTab, ItemId, NewTerminal, Pane, ToggleZoom, Workspace, + ActivateNextPane, ActivatePane, ActivatePaneInDirection, ActivatePreviousPane, DraggedTab, + ItemId, NewTerminal, Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitRight, + SplitUp, SwapPaneInDirection, ToggleZoom, Workspace, }; use anyhow::Result; @@ -60,14 +67,14 @@ pub fn init(cx: &mut AppContext) { } pub struct TerminalPanel { - pane: View, + pub(crate) active_pane: View, + pub(crate) center: PaneGroup, fs: Arc, workspace: WeakView, - width: Option, - height: Option, + pub(crate) width: Option, + pub(crate) height: Option, pending_serialization: Task>, pending_terminals_to_add: usize, - _subscriptions: Vec, deferred_tasks: HashMap>, enabled: bool, assistant_enabled: bool, @@ -75,85 +82,14 @@ pub struct TerminalPanel { } impl TerminalPanel { - fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { - let pane = cx.new_view(|cx| { - let mut pane = Pane::new( - workspace.weak_handle(), - workspace.project().clone(), - Default::default(), - None, - NewTerminal.boxed_clone(), - cx, - ); - pane.set_can_split(false, cx); - pane.set_can_navigate(false, cx); - pane.display_nav_history_buttons(None); - pane.set_should_display_tab_bar(|_| true); - - let is_local = workspace.project().read(cx).is_local(); - let workspace = workspace.weak_handle(); - pane.set_custom_drop_handle(cx, move |pane, dropped_item, cx| { - if let Some(tab) = dropped_item.downcast_ref::() { - let item = if &tab.pane == cx.view() { - pane.item_for_index(tab.ix) - } else { - tab.pane.read(cx).item_for_index(tab.ix) - }; - if let Some(item) = item { - if item.downcast::().is_some() { - return ControlFlow::Continue(()); - } else if let Some(project_path) = item.project_path(cx) { - if let Some(entry_path) = workspace - .update(cx, |workspace, cx| { - workspace - .project() - .read(cx) - .absolute_path(&project_path, cx) - }) - .log_err() - .flatten() - { - add_paths_to_terminal(pane, &[entry_path], cx); - } - } - } - } else if let Some(&entry_id) = dropped_item.downcast_ref::() { - if let Some(entry_path) = workspace - .update(cx, |workspace, cx| { - let project = workspace.project().read(cx); - project - .path_for_entry(entry_id, cx) - .and_then(|project_path| project.absolute_path(&project_path, cx)) - }) - .log_err() - .flatten() - { - add_paths_to_terminal(pane, &[entry_path], cx); - } - } else if is_local { - if let Some(paths) = dropped_item.downcast_ref::() { - add_paths_to_terminal(pane, paths.paths(), cx); - } - } - - ControlFlow::Break(()) - }); - let buffer_search_bar = cx.new_view(search::BufferSearchBar::new); - let breadcrumbs = cx.new_view(|_| Breadcrumbs::new()); - pane.toolbar().update(cx, |toolbar, cx| { - toolbar.add_item(buffer_search_bar, cx); - toolbar.add_item(breadcrumbs, cx); - }); - pane - }); - let subscriptions = vec![ - cx.observe(&pane, |_, _, cx| cx.notify()), - cx.subscribe(&pane, Self::handle_pane_event), - ]; - let project = workspace.project().read(cx); - let enabled = project.supports_terminal(cx); - let this = Self { - pane, + pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { + let project = workspace.project(); + let pane = new_terminal_pane(workspace.weak_handle(), project.clone(), cx); + let center = PaneGroup::new(pane.clone()); + let enabled = project.read(cx).supports_terminal(cx); + let terminal_panel = Self { + center, + active_pane: pane, fs: workspace.app_state().fs.clone(), workspace: workspace.weak_handle(), pending_serialization: Task::ready(None), @@ -161,20 +97,19 @@ impl TerminalPanel { height: None, pending_terminals_to_add: 0, deferred_tasks: HashMap::default(), - _subscriptions: subscriptions, enabled, assistant_enabled: false, assistant_tab_bar_button: None, }; - this.apply_tab_bar_buttons(cx); - this + terminal_panel.apply_tab_bar_buttons(&terminal_panel.active_pane, cx); + terminal_panel } pub fn asssistant_enabled(&mut self, enabled: bool, cx: &mut ViewContext) { self.assistant_enabled = enabled; if enabled { let focus_handle = self - .pane + .active_pane .read(cx) .active_item() .map(|item| item.focus_handle(cx)) @@ -186,26 +121,31 @@ impl TerminalPanel { } else { self.assistant_tab_bar_button = None; } - self.apply_tab_bar_buttons(cx); + for pane in self.center.panes() { + self.apply_tab_bar_buttons(pane, cx); + } } - fn apply_tab_bar_buttons(&self, cx: &mut ViewContext) { + fn apply_tab_bar_buttons(&self, terminal_pane: &View, cx: &mut ViewContext) { let assistant_tab_bar_button = self.assistant_tab_bar_button.clone(); - self.pane.update(cx, |pane, cx| { + terminal_pane.update(cx, |pane, cx| { pane.set_render_tab_bar_buttons(cx, move |pane, cx| { + let split_context = pane + .active_item() + .and_then(|item| item.downcast::()) + .map(|terminal_view| terminal_view.read(cx).focus_handle.clone()); if !pane.has_focus(cx) && !pane.context_menu_focused(cx) { return (None, None); } let focus_handle = pane.focus_handle(cx); let right_children = h_flex() - .gap_2() - .children(assistant_tab_bar_button.clone()) + .gap(DynamicSpacing::Base02.rems(cx)) .child( PopoverMenu::new("terminal-tab-bar-popover-menu") .trigger( IconButton::new("plus", IconName::Plus) .icon_size(IconSize::Small) - .tooltip(|cx| Tooltip::text("New...", cx)), + .tooltip(|cx| Tooltip::text("New…", cx)), ) .anchor(AnchorCorner::TopRight) .with_handle(pane.new_item_context_menu_handle.clone()) @@ -229,6 +169,33 @@ impl TerminalPanel { Some(menu) }), ) + .children(assistant_tab_bar_button.clone()) + .child( + PopoverMenu::new("terminal-pane-tab-bar-split") + .trigger( + IconButton::new("terminal-pane-split", IconName::Split) + .icon_size(IconSize::Small) + .tooltip(|cx| Tooltip::text("Split Pane", cx)), + ) + .anchor(AnchorCorner::TopRight) + .with_handle(pane.split_item_context_menu_handle.clone()) + .menu({ + let split_context = split_context.clone(); + move |cx| { + ContextMenu::build(cx, |menu, _| { + menu.when_some( + split_context.clone(), + |menu, split_context| menu.context(split_context), + ) + .action("Split Right", SplitRight.boxed_clone()) + .action("Split Left", SplitLeft.boxed_clone()) + .action("Split Up", SplitUp.boxed_clone()) + .action("Split Down", SplitDown.boxed_clone()) + }) + .into() + } + }), + ) .child({ let zoomed = pane.is_zoomed(); IconButton::new("toggle_zoom", IconName::Maximize) @@ -268,80 +235,45 @@ impl TerminalPanel { .log_err() .flatten(); - let (panel, pane, items) = workspace.update(&mut cx, |workspace, cx| { - let panel = cx.new_view(|cx| TerminalPanel::new(workspace, cx)); - let items = if let Some((serialized_panel, database_id)) = - serialized_panel.as_ref().zip(workspace.database_id()) - { - panel.update(cx, |panel, cx| { - cx.notify(); - panel.height = serialized_panel.height.map(|h| h.round()); - panel.width = serialized_panel.width.map(|w| w.round()); - panel.pane.update(cx, |_, cx| { - serialized_panel - .items - .iter() - .map(|item_id| { - TerminalView::deserialize( - workspace.project().clone(), - workspace.weak_handle(), - database_id, - *item_id, - cx, - ) - }) - .collect::>() - }) - }) - } else { - Vec::new() - }; - let pane = panel.read(cx).pane.clone(); - (panel, pane, items) - })?; + let terminal_panel = workspace + .update(&mut cx, |workspace, cx| { + match serialized_panel.zip(workspace.database_id()) { + Some((serialized_panel, database_id)) => deserialize_terminal_panel( + workspace.weak_handle(), + workspace.project().clone(), + database_id, + serialized_panel, + cx, + ), + None => Task::ready(Ok(cx.new_view(|cx| TerminalPanel::new(workspace, cx)))), + } + })? + .await?; if let Some(workspace) = workspace.upgrade() { - panel - .update(&mut cx, |panel, cx| { - panel._subscriptions.push(cx.subscribe( - &workspace, - |terminal_panel, _, e, cx| { - if let workspace::Event::SpawnTask(spawn_in_terminal) = e { - terminal_panel.spawn_task(spawn_in_terminal, cx); - }; - }, - )) + terminal_panel + .update(&mut cx, |_, cx| { + cx.subscribe(&workspace, |terminal_panel, _, e, cx| { + if let workspace::Event::SpawnTask(spawn_in_terminal) = e { + terminal_panel.spawn_task(spawn_in_terminal, cx); + }; + }) + .detach(); }) .ok(); } - let pane = pane.downgrade(); - let items = futures::future::join_all(items).await; - let mut alive_item_ids = Vec::new(); - pane.update(&mut cx, |pane, cx| { - let active_item_id = serialized_panel - .as_ref() - .and_then(|panel| panel.active_item_id); - let mut active_ix = None; - for item in items { - if let Some(item) = item.log_err() { - let item_id = item.entity_id().as_u64(); - pane.add_item(Box::new(item), false, false, None, cx); - alive_item_ids.push(item_id as ItemId); - if Some(item_id) == active_item_id { - active_ix = Some(pane.items_len() - 1); - } - } - } - - if let Some(active_ix) = active_ix { - pane.activate_item(active_ix, false, false, cx) - } - })?; - // Since panels/docks are loaded outside from the workspace, we cleanup here, instead of through the workspace. if let Some(workspace) = workspace.upgrade() { let cleanup_task = workspace.update(&mut cx, |workspace, cx| { + let alive_item_ids = terminal_panel + .read(cx) + .center + .panes() + .into_iter() + .flat_map(|pane| pane.read(cx).items()) + .map(|item| item.item_id().as_u64() as ItemId) + .collect(); workspace .database_id() .map(|workspace_id| TerminalView::cleanup(workspace_id, alive_item_ids, cx)) @@ -351,33 +283,121 @@ impl TerminalPanel { } } - Ok(panel) + Ok(terminal_panel) } fn handle_pane_event( &mut self, - _pane: View, + pane: View, event: &pane::Event, cx: &mut ViewContext, ) { match event { pane::Event::ActivateItem { .. } => self.serialize(cx), pane::Event::RemovedItem { .. } => self.serialize(cx), - pane::Event::Remove { .. } => cx.emit(PanelEvent::Close), + pane::Event::Remove { focus_on_pane } => { + let pane_count_before_removal = self.center.panes().len(); + let _removal_result = self.center.remove(&pane); + if pane_count_before_removal == 1 { + cx.emit(PanelEvent::Close); + } else { + if let Some(focus_on_pane) = + focus_on_pane.as_ref().or_else(|| self.center.panes().pop()) + { + focus_on_pane.focus_handle(cx).focus(cx); + } + } + } pane::Event::ZoomIn => cx.emit(PanelEvent::ZoomIn), pane::Event::ZoomOut => cx.emit(PanelEvent::ZoomOut), - pane::Event::AddItem { item } => { if let Some(workspace) = self.workspace.upgrade() { - let pane = self.pane.clone(); - workspace.update(cx, |workspace, cx| item.added_to_pane(workspace, pane, cx)) + workspace.update(cx, |workspace, cx| { + item.added_to_pane(workspace, pane.clone(), cx) + }) } } + pane::Event::Split(direction) => { + let new_pane = self.new_pane_with_cloned_active_terminal(cx); + let pane = pane.clone(); + let direction = *direction; + cx.spawn(move |this, mut cx| async move { + let Some(new_pane) = new_pane.await else { + return; + }; + this.update(&mut cx, |this, _| { + this.center.split(&pane, &new_pane, direction).log_err(); + }) + .ok(); + }) + .detach(); + } + pane::Event::Focus => { + self.active_pane = pane.clone(); + } _ => {} } } + fn new_pane_with_cloned_active_terminal( + &mut self, + cx: &mut ViewContext, + ) -> Task>> { + let Some(workspace) = self.workspace.clone().upgrade() else { + return Task::ready(None); + }; + let database_id = workspace.read(cx).database_id(); + let weak_workspace = self.workspace.clone(); + let project = workspace.read(cx).project().clone(); + let working_directory = self + .active_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .and_then(|terminal_view| { + terminal_view + .read(cx) + .terminal() + .read(cx) + .working_directory() + }) + .or_else(|| default_working_directory(workspace.read(cx), cx)); + let kind = TerminalKind::Shell(working_directory); + let window = cx.window_handle(); + cx.spawn(move |this, mut cx| async move { + let terminal = project + .update(&mut cx, |project, cx| { + project.create_terminal(kind, window, cx) + }) + .log_err()? + .await + .log_err()?; + + let terminal_view = Box::new( + cx.new_view(|cx| { + TerminalView::new(terminal.clone(), weak_workspace.clone(), database_id, cx) + }) + .ok()?, + ); + let pane = this + .update(&mut cx, |this, cx| { + let pane = new_terminal_pane(weak_workspace, project, cx); + this.apply_tab_bar_buttons(&pane, cx); + pane + }) + .ok()?; + + pane.update(&mut cx, |pane, cx| { + pane.add_item(terminal_view, true, true, None, cx); + }) + .ok()?; + cx.focus_view(&pane).ok()?; + + Some(pane) + }) + } + pub fn open_terminal( workspace: &mut Workspace, action: &workspace::OpenTerminal, @@ -494,40 +514,62 @@ impl TerminalPanel { .detach_and_log_err(cx); return; } - let (existing_item_index, existing_terminal) = terminals_for_task + let (existing_item_index, task_pane, existing_terminal) = terminals_for_task .last() .expect("covered no terminals case above") .clone(); - if allow_concurrent_runs { - debug_assert!( - !use_new_terminal, - "Should have handled 'allow_concurrent_runs && use_new_terminal' case above" - ); - self.replace_terminal(spawn_task, existing_item_index, existing_terminal, cx); - } else { - self.deferred_tasks.insert( - spawn_in_terminal.id.clone(), - cx.spawn(|terminal_panel, mut cx| async move { - wait_for_terminals_tasks(terminals_for_task, &mut cx).await; - terminal_panel - .update(&mut cx, |terminal_panel, cx| { - if use_new_terminal { - terminal_panel - .spawn_in_new_terminal(spawn_task, cx) - .detach_and_log_err(cx); - } else { - terminal_panel.replace_terminal( - spawn_task, - existing_item_index, - existing_terminal, - cx, - ); - } - }) - .ok(); - }), - ); - } + let id = spawn_in_terminal.id.clone(); + cx.spawn(move |this, mut cx| async move { + if allow_concurrent_runs { + debug_assert!( + !use_new_terminal, + "Should have handled 'allow_concurrent_runs && use_new_terminal' case above" + ); + this.update(&mut cx, |this, cx| { + this.replace_terminal( + spawn_task, + task_pane, + existing_item_index, + existing_terminal, + cx, + ) + })? + .await; + } else { + this.update(&mut cx, |this, cx| { + this.deferred_tasks.insert( + id, + cx.spawn(|terminal_panel, mut cx| async move { + wait_for_terminals_tasks(terminals_for_task, &mut cx).await; + let Ok(Some(new_terminal_task)) = + terminal_panel.update(&mut cx, |terminal_panel, cx| { + if use_new_terminal { + terminal_panel + .spawn_in_new_terminal(spawn_task, cx) + .detach_and_log_err(cx); + None + } else { + Some(terminal_panel.replace_terminal( + spawn_task, + task_pane, + existing_item_index, + existing_terminal, + cx, + )) + } + }) + else { + return; + }; + new_terminal_task.await; + }), + ); + }) + .ok(); + } + anyhow::Result::<_, anyhow::Error>::Ok(()) + }) + .detach() } pub fn spawn_in_new_terminal( @@ -562,25 +604,36 @@ impl TerminalPanel { &self, label: &str, cx: &mut AppContext, - ) -> Vec<(usize, View)> { - self.pane - .read(cx) - .items() - .enumerate() - .filter_map(|(index, item)| Some((index, item.act_as::(cx)?))) - .filter_map(|(index, terminal_view)| { - let task_state = terminal_view.read(cx).terminal().read(cx).task()?; - if &task_state.full_label == label { - Some((index, terminal_view)) - } else { - None - } + ) -> Vec<(usize, View, View)> { + self.center + .panes() + .into_iter() + .flat_map(|pane| { + pane.read(cx) + .items() + .enumerate() + .filter_map(|(index, item)| Some((index, item.act_as::(cx)?))) + .filter_map(|(index, terminal_view)| { + let task_state = terminal_view.read(cx).terminal().read(cx).task()?; + if &task_state.full_label == label { + Some((index, terminal_view)) + } else { + None + } + }) + .map(|(index, terminal_view)| (index, pane.clone(), terminal_view)) }) .collect() } - fn activate_terminal_view(&self, item_index: usize, focus: bool, cx: &mut WindowContext) { - self.pane.update(cx, |pane, cx| { + fn activate_terminal_view( + &self, + pane: &View, + item_index: usize, + focus: bool, + cx: &mut WindowContext, + ) { + pane.update(cx, |pane, cx| { pane.activate_item(item_index, true, focus, cx) }) } @@ -601,12 +654,15 @@ impl TerminalPanel { self.pending_terminals_to_add += 1; cx.spawn(|terminal_panel, mut cx| async move { - let pane = terminal_panel.update(&mut cx, |this, _| this.pane.clone())?; + let pane = terminal_panel.update(&mut cx, |this, _| this.active_pane.clone())?; + let project = workspace.update(&mut cx, |workspace, _| workspace.project().clone())?; + let window = cx.window_handle(); + let terminal = project + .update(&mut cx, |project, cx| { + project.create_terminal(kind, window, cx) + })? + .await?; let result = workspace.update(&mut cx, |workspace, cx| { - let window = cx.window_handle(); - let terminal = workspace - .project() - .update(cx, |project, cx| project.create_terminal(kind, window, cx))?; let terminal_view = Box::new(cx.new_view(|cx| { TerminalView::new( terminal.clone(), @@ -640,101 +696,114 @@ impl TerminalPanel { } fn serialize(&mut self, cx: &mut ViewContext) { - let mut items_to_serialize = HashSet::default(); - let items = self - .pane - .read(cx) - .items() - .filter_map(|item| { - let terminal_view = item.act_as::(cx)?; - if terminal_view.read(cx).terminal().read(cx).task().is_some() { - None - } else { - let id = item.item_id().as_u64(); - items_to_serialize.insert(id); - Some(id) - } - }) - .collect::>(); - let active_item_id = self - .pane - .read(cx) - .active_item() - .map(|item| item.item_id().as_u64()) - .filter(|active_id| items_to_serialize.contains(active_id)); let height = self.height; let width = self.width; - self.pending_serialization = cx.background_executor().spawn( - async move { - KEY_VALUE_STORE - .write_kvp( - TERMINAL_PANEL_KEY.into(), - serde_json::to_string(&SerializedTerminalPanel { - items, - active_item_id, - height, - width, - })?, - ) - .await?; - anyhow::Ok(()) - } - .log_err(), - ); + self.pending_serialization = cx.spawn(|terminal_panel, mut cx| async move { + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; + let terminal_panel = terminal_panel.upgrade()?; + let items = terminal_panel + .update(&mut cx, |terminal_panel, cx| { + SerializedItems::WithSplits(serialize_pane_group( + &terminal_panel.center, + &terminal_panel.active_pane, + cx, + )) + }) + .ok()?; + cx.background_executor() + .spawn( + async move { + KEY_VALUE_STORE + .write_kvp( + TERMINAL_PANEL_KEY.into(), + serde_json::to_string(&SerializedTerminalPanel { + items, + active_item_id: None, + height, + width, + })?, + ) + .await?; + anyhow::Ok(()) + } + .log_err(), + ) + .await; + Some(()) + }); } fn replace_terminal( &self, spawn_task: SpawnInTerminal, + task_pane: View, terminal_item_index: usize, terminal_to_replace: View, cx: &mut ViewContext<'_, Self>, - ) -> Option<()> { - let project = self - .workspace - .update(cx, |workspace, _| workspace.project().clone()) - .ok()?; - + ) -> Task> { let reveal = spawn_task.reveal; let window = cx.window_handle(); - let new_terminal = project.update(cx, |project, cx| { - project - .create_terminal(TerminalKind::Task(spawn_task), window, cx) - .log_err() - })?; - terminal_to_replace.update(cx, |terminal_to_replace, cx| { - terminal_to_replace.set_terminal(new_terminal, cx); - }); - - match reveal { - RevealStrategy::Always => { - self.activate_terminal_view(terminal_item_index, true, cx); - let task_workspace = self.workspace.clone(); - cx.spawn(|_, mut cx| async move { - task_workspace - .update(&mut cx, |workspace, cx| workspace.focus_panel::(cx)) + let task_workspace = self.workspace.clone(); + cx.spawn(move |this, mut cx| async move { + let project = this + .update(&mut cx, |this, cx| { + this.workspace + .update(cx, |workspace, _| workspace.project().clone()) .ok() }) - .detach(); - } - RevealStrategy::NoFocus => { - self.activate_terminal_view(terminal_item_index, false, cx); - let task_workspace = self.workspace.clone(); - cx.spawn(|_, mut cx| async move { - task_workspace - .update(&mut cx, |workspace, cx| workspace.open_panel::(cx)) - .ok() + .ok() + .flatten()?; + let new_terminal = project + .update(&mut cx, |project, cx| { + project.create_terminal(TerminalKind::Task(spawn_task), window, cx) }) - .detach(); + .ok()? + .await + .log_err()?; + terminal_to_replace + .update(&mut cx, |terminal_to_replace, cx| { + terminal_to_replace.set_terminal(new_terminal, cx); + }) + .ok()?; + + match reveal { + RevealStrategy::Always => { + this.update(&mut cx, |this, cx| { + this.activate_terminal_view(&task_pane, terminal_item_index, true, cx) + }) + .ok()?; + + cx.spawn(|mut cx| async move { + task_workspace + .update(&mut cx, |workspace, cx| workspace.focus_panel::(cx)) + .ok() + }) + .detach(); + } + RevealStrategy::NoFocus => { + this.update(&mut cx, |this, cx| { + this.activate_terminal_view(&task_pane, terminal_item_index, false, cx) + }) + .ok()?; + + cx.spawn(|mut cx| async move { + task_workspace + .update(&mut cx, |workspace, cx| workspace.open_panel::(cx)) + .ok() + }) + .detach(); + } + RevealStrategy::Never => {} } - RevealStrategy::Never => {} - } - Some(()) + Some(()) + }) } fn has_no_terminals(&self, cx: &WindowContext) -> bool { - self.pane.read(cx).items_len() == 0 && self.pending_terminals_to_add == 0 + self.active_pane.read(cx).items_len() == 0 && self.pending_terminals_to_add == 0 } pub fn assistant_enabled(&self) -> bool { @@ -742,11 +811,149 @@ impl TerminalPanel { } } +pub fn new_terminal_pane( + workspace: WeakView, + project: Model, + cx: &mut ViewContext, +) -> View { + let is_local = project.read(cx).is_local(); + let terminal_panel = cx.view().clone(); + let pane = cx.new_view(|cx| { + let mut pane = Pane::new( + workspace.clone(), + project.clone(), + Default::default(), + None, + NewTerminal.boxed_clone(), + cx, + ); + pane.set_can_navigate(false, cx); + pane.display_nav_history_buttons(None); + pane.set_should_display_tab_bar(|_| true); + + let terminal_panel_for_split_check = terminal_panel.clone(); + pane.set_can_split(Some(Arc::new(move |pane, dragged_item, cx| { + if let Some(tab) = dragged_item.downcast_ref::() { + let current_pane = cx.view().clone(); + let can_drag_away = + terminal_panel_for_split_check.update(cx, |terminal_panel, _| { + let current_panes = terminal_panel.center.panes(); + !current_panes.contains(&&tab.pane) + || current_panes.len() > 1 + || (tab.pane != current_pane || pane.items_len() > 1) + }); + if can_drag_away { + let item = if tab.pane == current_pane { + pane.item_for_index(tab.ix) + } else { + tab.pane.read(cx).item_for_index(tab.ix) + }; + if let Some(item) = item { + return item.downcast::().is_some(); + } + } + } + false + }))); + + let buffer_search_bar = cx.new_view(search::BufferSearchBar::new); + let breadcrumbs = cx.new_view(|_| Breadcrumbs::new()); + pane.toolbar().update(cx, |toolbar, cx| { + toolbar.add_item(buffer_search_bar, cx); + toolbar.add_item(breadcrumbs, cx); + }); + + pane.set_custom_drop_handle(cx, move |pane, dropped_item, cx| { + if let Some(tab) = dropped_item.downcast_ref::() { + let this_pane = cx.view().clone(); + let belongs_to_this_pane = tab.pane == this_pane; + let item = if belongs_to_this_pane { + pane.item_for_index(tab.ix) + } else { + tab.pane.read(cx).item_for_index(tab.ix) + }; + if let Some(item) = item { + if item.downcast::().is_some() { + let source = tab.pane.clone(); + let item_id_to_move = item.item_id(); + + let new_pane = pane.drag_split_direction().and_then(|split_direction| { + terminal_panel.update(cx, |terminal_panel, cx| { + let new_pane = + new_terminal_pane(workspace.clone(), project.clone(), cx); + terminal_panel.apply_tab_bar_buttons(&new_pane, cx); + terminal_panel + .center + .split(&this_pane, &new_pane, split_direction) + .log_err()?; + Some(new_pane) + }) + }); + + let destination; + let destination_index; + if let Some(new_pane) = new_pane { + destination_index = new_pane.read(cx).active_item_index(); + destination = new_pane; + } else if belongs_to_this_pane { + return ControlFlow::Break(()); + } else { + destination = cx.view().clone(); + destination_index = pane.active_item_index(); + } + // Destination pane may be the one currently updated, so defer the move. + cx.spawn(|_, mut cx| async move { + cx.update(|cx| { + move_item( + &source, + &destination, + item_id_to_move, + destination_index, + cx, + ); + }) + .ok(); + }) + .detach(); + } else if let Some(project_path) = item.project_path(cx) { + if let Some(entry_path) = project.read(cx).absolute_path(&project_path, cx) + { + add_paths_to_terminal(pane, &[entry_path], cx); + } + } + } + } else if let Some(&entry_id) = dropped_item.downcast_ref::() { + if let Some(entry_path) = project + .read(cx) + .path_for_entry(entry_id, cx) + .and_then(|project_path| project.read(cx).absolute_path(&project_path, cx)) + { + add_paths_to_terminal(pane, &[entry_path], cx); + } + } else if is_local { + if let Some(paths) = dropped_item.downcast_ref::() { + add_paths_to_terminal(pane, paths.paths(), cx); + } + } + + ControlFlow::Break(()) + }); + + pane + }); + + cx.subscribe(&pane, TerminalPanel::handle_pane_event) + .detach(); + cx.observe(&pane, |_, _, cx| cx.notify()).detach(); + + pane +} + async fn wait_for_terminals_tasks( - terminals_for_task: Vec<(usize, View)>, + terminals_for_task: Vec<(usize, View, View)>, cx: &mut AsyncWindowContext, ) { - let pending_tasks = terminals_for_task.iter().filter_map(|(_, terminal)| { + let pending_tasks = terminals_for_task.iter().filter_map(|(_, _, terminal)| { terminal .update(cx, |terminal_view, cx| { terminal_view @@ -781,7 +988,7 @@ impl Render for TerminalPanel { let mut registrar = DivRegistrar::new( |panel, cx| { panel - .pane + .active_pane .read(cx) .toolbar() .read(cx) @@ -790,13 +997,106 @@ impl Render for TerminalPanel { cx, ); BufferSearchBar::register(&mut registrar); - registrar.into_div().size_full().child(self.pane.clone()) + let registrar = registrar.into_div(); + self.workspace + .update(cx, |workspace, cx| { + registrar.size_full().child(self.center.render( + workspace.project(), + &HashMap::default(), + None, + &self.active_pane, + workspace.zoomed_item(), + workspace.app_state(), + cx, + )) + }) + .ok() + .map(|div| { + div.on_action({ + cx.listener(|terminal_panel, action: &ActivatePaneInDirection, cx| { + if let Some(pane) = terminal_panel.center.find_pane_in_direction( + &terminal_panel.active_pane, + action.0, + cx, + ) { + cx.focus_view(&pane); + } else { + terminal_panel + .workspace + .update(cx, |workspace, cx| { + workspace.activate_pane_in_direction(action.0, cx) + }) + .ok(); + } + }) + }) + .on_action( + cx.listener(|terminal_panel, _action: &ActivateNextPane, cx| { + let panes = terminal_panel.center.panes(); + if let Some(ix) = panes + .iter() + .position(|pane| **pane == terminal_panel.active_pane) + { + let next_ix = (ix + 1) % panes.len(); + let next_pane = panes[next_ix].clone(); + cx.focus_view(&next_pane); + } + }), + ) + .on_action( + cx.listener(|terminal_panel, _action: &ActivatePreviousPane, cx| { + let panes = terminal_panel.center.panes(); + if let Some(ix) = panes + .iter() + .position(|pane| **pane == terminal_panel.active_pane) + { + let prev_ix = cmp::min(ix.wrapping_sub(1), panes.len() - 1); + let prev_pane = panes[prev_ix].clone(); + cx.focus_view(&prev_pane); + } + }), + ) + .on_action(cx.listener(|terminal_panel, action: &ActivatePane, cx| { + let panes = terminal_panel.center.panes(); + if let Some(pane) = panes.get(action.0).map(|p| (*p).clone()) { + cx.focus_view(&pane); + } else { + let new_pane = terminal_panel.new_pane_with_cloned_active_terminal(cx); + cx.spawn(|this, mut cx| async move { + if let Some(new_pane) = new_pane.await { + this.update(&mut cx, |this, _| { + this.center + .split(&this.active_pane, &new_pane, SplitDirection::Right) + .log_err(); + }) + .ok(); + } + }) + .detach(); + } + })) + .on_action(cx.listener( + |terminal_panel, action: &SwapPaneInDirection, cx| { + if let Some(to) = terminal_panel + .center + .find_pane_in_direction(&terminal_panel.active_pane, action.0, cx) + .cloned() + { + terminal_panel + .center + .swap(&terminal_panel.active_pane.clone(), &to); + cx.notify(); + } + }, + )) + }) + .unwrap_or_else(|| div()) } } impl FocusableView for TerminalPanel { fn focus_handle(&self, cx: &AppContext) -> FocusHandle { - self.pane.focus_handle(cx) + self.active_pane.focus_handle(cx) } } @@ -848,11 +1148,12 @@ impl Panel for TerminalPanel { } fn is_zoomed(&self, cx: &WindowContext) -> bool { - self.pane.read(cx).is_zoomed() + self.active_pane.read(cx).is_zoomed() } fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext) { - self.pane.update(cx, |pane, cx| pane.set_zoomed(zoomed, cx)); + self.active_pane + .update(cx, |pane, cx| pane.set_zoomed(zoomed, cx)); } fn set_active(&mut self, active: bool, cx: &mut ViewContext) { @@ -872,7 +1173,12 @@ impl Panel for TerminalPanel { } fn icon_label(&self, cx: &WindowContext) -> Option { - let count = self.pane.read(cx).items_len(); + let count = self + .center + .panes() + .into_iter() + .map(|pane| pane.read(cx).items_len()) + .sum::(); if count == 0 { None } else { @@ -901,7 +1207,7 @@ impl Panel for TerminalPanel { } fn pane(&self) -> Option> { - Some(self.pane.clone()) + Some(self.active_pane.clone()) } } @@ -923,14 +1229,6 @@ impl Render for InlineAssistTabBarButton { } } -#[derive(Serialize, Deserialize)] -struct SerializedTerminalPanel { - items: Vec, - active_item_id: Option, - width: Option, - height: Option, -} - fn retrieve_system_shell() -> Option { #[cfg(not(target_os = "windows"))] { diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index ad0c7f520d8dc3..7a83e530feb89a 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -33,8 +33,8 @@ use workspace::{ notifications::NotifyResultExt, register_serializable_item, searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle}, - CloseActiveItem, NewCenterTerminal, NewTerminal, OpenVisible, Pane, ToolbarItemLocation, - Workspace, WorkspaceId, + CloseActiveItem, NewCenterTerminal, NewTerminal, OpenVisible, ToolbarItemLocation, Workspace, + WorkspaceId, }; use anyhow::Context; @@ -136,24 +136,36 @@ impl TerminalView { let working_directory = default_working_directory(workspace, cx); let window = cx.window_handle(); - let terminal = workspace - .project() - .update(cx, |project, cx| { - project.create_terminal(TerminalKind::Shell(working_directory), window, cx) - }) - .notify_err(workspace, cx); - - if let Some(terminal) = terminal { - let view = cx.new_view(|cx| { - TerminalView::new( - terminal, - workspace.weak_handle(), - workspace.database_id(), - cx, - ) - }); - workspace.add_item_to_active_pane(Box::new(view), None, true, cx); - } + let project = workspace.project().downgrade(); + cx.spawn(move |workspace, mut cx| async move { + let terminal = project + .update(&mut cx, |project, cx| { + project.create_terminal(TerminalKind::Shell(working_directory), window, cx) + }) + .ok()? + .await; + let terminal = workspace + .update(&mut cx, |workspace, cx| terminal.notify_err(workspace, cx)) + .ok() + .flatten()?; + + workspace + .update(&mut cx, |workspace, cx| { + let view = cx.new_view(|cx| { + TerminalView::new( + terminal, + workspace.weak_handle(), + workspace.database_id(), + cx, + ) + }); + workspace.add_item_to_active_pane(Box::new(view), None, true, cx); + }) + .ok(); + + Some(()) + }) + .detach() } pub fn new( @@ -798,7 +810,6 @@ fn possible_open_paths_metadata( cx.background_executor().spawn(async move { let mut paths_with_metadata = Vec::with_capacity(potential_paths.len()); - #[cfg(not(target_os = "windows"))] let mut fetch_metadata_tasks = potential_paths .into_iter() .map(|potential_path| async { @@ -814,20 +825,6 @@ fn possible_open_paths_metadata( }) .collect::>(); - #[cfg(target_os = "windows")] - let mut fetch_metadata_tasks = potential_paths - .iter() - .map(|potential_path| async { - let metadata = fs.metadata(potential_path).await.ok().flatten(); - let path = PathBuf::from( - potential_path - .to_string_lossy() - .trim_start_matches("\\\\?\\"), - ); - (PathWithPosition { path, row, column }, metadata) - }) - .collect::>(); - while let Some((path, metadata)) = fetch_metadata_tasks.next().await { if let Some(metadata) = metadata { paths_with_metadata.push((path, metadata)); @@ -1222,10 +1219,10 @@ impl SerializableItem for TerminalView { workspace: WeakView, workspace_id: workspace::WorkspaceId, item_id: workspace::ItemId, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Task>> { let window = cx.window_handle(); - cx.spawn(|pane, mut cx| async move { + cx.spawn(|mut cx| async move { let cwd = cx .update(|cx| { let from_db = TERMINAL_DB @@ -1246,10 +1243,12 @@ impl SerializableItem for TerminalView { .ok() .flatten(); - let terminal = project.update(&mut cx, |project, cx| { - project.create_terminal(TerminalKind::Shell(cwd), window, cx) - })??; - pane.update(&mut cx, |_, cx| { + let terminal = project + .update(&mut cx, |project, cx| { + project.create_terminal(TerminalKind::Shell(cwd), window, cx) + })? + .await?; + cx.update(|cx| { cx.new_view(|cx| TerminalView::new(terminal, workspace, Some(workspace_id), cx)) }) }) @@ -1377,11 +1376,14 @@ impl SearchableItem for TerminalView { ///Gets the working directory for the given workspace, respecting the user's settings. /// None implies "~" on whichever machine we end up on. -pub fn default_working_directory(workspace: &Workspace, cx: &AppContext) -> Option { +pub(crate) fn default_working_directory(workspace: &Workspace, cx: &AppContext) -> Option { match &TerminalSettings::get_global(cx).working_directory { - WorkingDirectory::CurrentProjectDirectory => { - workspace.project().read(cx).active_project_directory(cx) - } + WorkingDirectory::CurrentProjectDirectory => workspace + .project() + .read(cx) + .active_project_directory(cx) + .as_deref() + .map(Path::to_path_buf), WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx), WorkingDirectory::AlwaysHome => None, WorkingDirectory::Always { directory } => { diff --git a/crates/theme_importer/src/main.rs b/crates/theme_importer/src/main.rs index d92966ae2425ac..db287956c5d417 100644 --- a/crates/theme_importer/src/main.rs +++ b/crates/theme_importer/src/main.rs @@ -19,6 +19,8 @@ use theme::{Appearance, AppearanceContent, ThemeFamilyContent}; use crate::vscode::VsCodeTheme; use crate::vscode::VsCodeThemeConverter; +const ZED_THEME_SCHEMA_URL: &str = "https://zed.dev/public/schema/themes/v0.2.0.json"; + #[derive(Debug, Deserialize)] struct FamilyMetadata { pub name: String, @@ -69,34 +71,53 @@ pub struct ThemeMetadata { #[derive(Parser)] #[command(author, version, about, long_about = None)] struct Args { - /// The path to the theme to import. - theme_path: PathBuf, - - /// Whether to warn when values are missing from the theme. - #[arg(long)] - warn_on_missing: bool, - - /// The path to write the output to. - #[arg(long, short)] - output: Option, - #[command(subcommand)] - command: Option, + command: Command, } -#[derive(Subcommand)] +#[derive(PartialEq, Subcommand)] enum Command { /// Prints the JSON schema for a theme. PrintSchema, + /// Converts a VSCode theme to Zed format [default] + Convert { + /// The path to the theme to import. + theme_path: PathBuf, + + /// Whether to warn when values are missing from the theme. + #[arg(long)] + warn_on_missing: bool, + + /// The path to write the output to. + #[arg(long, short)] + output: Option, + }, } fn main() -> Result<()> { let args = Args::parse(); + match args.command { + Command::PrintSchema => { + let theme_family_schema = schema_for!(ThemeFamilyContent); + println!( + "{}", + serde_json::to_string_pretty(&theme_family_schema).unwrap() + ); + Ok(()) + } + Command::Convert { + theme_path, + warn_on_missing, + output, + } => convert(theme_path, output, warn_on_missing), + } +} + +fn convert(theme_file_path: PathBuf, output: Option, warn_on_missing: bool) -> Result<()> { let log_config = { let mut config = simplelog::ConfigBuilder::new(); - - if !args.warn_on_missing { + if !warn_on_missing { config.add_filter_ignore_str("theme_printer"); } @@ -111,28 +132,11 @@ fn main() -> Result<()> { ) .expect("could not initialize logger"); - if let Some(command) = args.command { - match command { - Command::PrintSchema => { - let theme_family_schema = schema_for!(ThemeFamilyContent); - - println!( - "{}", - serde_json::to_string_pretty(&theme_family_schema).unwrap() - ); - - return Ok(()); - } - } - } - - let theme_file_path = args.theme_path; - let theme_file = match File::open(&theme_file_path) { Ok(file) => file, Err(err) => { log::info!("Failed to open file at path: {:?}", theme_file_path); - return Err(err)?; + return Err(err.into()); } }; @@ -148,10 +152,14 @@ fn main() -> Result<()> { let converter = VsCodeThemeConverter::new(vscode_theme, theme_metadata, IndexMap::new()); let theme = converter.convert()?; - + let mut theme = serde_json::to_value(theme).unwrap(); + theme.as_object_mut().unwrap().insert( + "$schema".to_string(), + serde_json::Value::String(ZED_THEME_SCHEMA_URL.to_string()), + ); let theme_json = serde_json::to_string_pretty(&theme).unwrap(); - if let Some(output) = args.output { + if let Some(output) = output { let mut file = File::create(output)?; file.write_all(theme_json.as_bytes())?; } else { diff --git a/crates/theme_importer/src/vscode/converter.rs b/crates/theme_importer/src/vscode/converter.rs index cca4b563211f8f..a1a6c7a27c134b 100644 --- a/crates/theme_importer/src/vscode/converter.rs +++ b/crates/theme_importer/src/vscode/converter.rs @@ -159,7 +159,9 @@ impl VsCodeThemeConverter { .active_background .clone() .or(vscode_tab_inactive_background.clone()), + search_match_background: vscode_colors.editor.find_match_background.clone(), panel_background: vscode_colors.panel.background.clone(), + pane_group_border: vscode_colors.editor_group.border.clone(), scrollbar_thumb_background: vscode_scrollbar_slider_background.clone(), scrollbar_thumb_hover_background: vscode_colors .scrollbar_slider @@ -168,7 +170,6 @@ impl VsCodeThemeConverter { scrollbar_thumb_border: vscode_scrollbar_slider_background.clone(), scrollbar_track_background: vscode_editor_background.clone(), scrollbar_track_border: vscode_colors.editor_overview_ruler.border.clone(), - pane_group_border: vscode_colors.editor_group.border.clone(), editor_foreground: vscode_editor_foreground .clone() .or(vscode_token_colors_foreground.clone()), @@ -179,6 +180,10 @@ impl VsCodeThemeConverter { editor_active_line_number: vscode_colors.editor.foreground.clone(), editor_wrap_guide: vscode_panel_border.clone(), editor_active_wrap_guide: vscode_panel_border.clone(), + editor_document_highlight_bracket_background: vscode_colors + .editor_bracket_match + .background + .clone(), terminal_background: vscode_colors.terminal.background.clone(), terminal_ansi_black: vscode_colors.terminal.ansi_black.clone(), terminal_ansi_bright_black: vscode_colors.terminal.ansi_bright_black.clone(), diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 161f4c60b7e01a..03000f06387aff 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -160,7 +160,6 @@ pub enum IconName { Copy, CountdownTimer, CursorIBeam, - TextSnippet, Dash, DatabaseZap, Delete, @@ -171,8 +170,8 @@ pub enum IconName { EllipsisVertical, Envelope, Escape, - Exit, ExpandVertical, + Exit, ExternalLink, Eye, File, @@ -198,6 +197,7 @@ pub enum IconName { GenericMinimize, GenericRestore, Github, + Globe, Hash, HistoryRerun, Indicator, @@ -223,13 +223,13 @@ pub enum IconName { PageUp, Pencil, Person, + PhoneIncoming, Pin, Play, Plus, PocketKnife, Public, PullRequest, - PhoneIncoming, Quote, RefreshTitle, Regex, @@ -275,6 +275,7 @@ pub enum IconName { SwatchBook, Tab, Terminal, + TextSnippet, Trash, TrashAlt, Triangle, @@ -287,11 +288,11 @@ pub enum IconName { Wand, Warning, WholeWord, + X, XCircle, ZedAssistant, ZedAssistantFilled, ZedXCopilot, - X, } impl From for Icon { diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index f9617139565113..0e6cc26b182729 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -65,6 +65,11 @@ impl LabelCommon for HighlightedLabel { self.base = self.base.underline(underline); self } + + fn single_line(mut self) -> Self { + self.base = self.base.single_line(); + self + } } pub fn highlight_ranges( diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index f65596184191c2..1df33d27403be2 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -56,20 +56,6 @@ impl Label { single_line: false, } } - - /// Make the label display in a single line mode - /// - /// # Examples - /// - /// ``` - /// use ui::prelude::*; - /// - /// let my_label = Label::new("Hello, World!").single_line(); - /// ``` - pub fn single_line(mut self) -> Self { - self.single_line = true; - self - } } // Style methods. @@ -177,6 +163,12 @@ impl LabelCommon for Label { self.base = self.base.underline(underline); self } + + fn single_line(mut self) -> Self { + self.single_line = true; + self.base = self.base.single_line(); + self + } } impl RenderOnce for Label { diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index fd7303082af488..b1c3240f5a7287 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -49,6 +49,9 @@ pub trait LabelCommon { /// Sets the alpha property of the label, overwriting the alpha value of the color. fn alpha(self, alpha: f32) -> Self; + + /// Sets the label to render as a single line. + fn single_line(self) -> Self; } #[derive(IntoElement)] @@ -63,6 +66,7 @@ pub struct LabelLike { children: SmallVec<[AnyElement; 2]>, alpha: Option, underline: bool, + single_line: bool, } impl Default for LabelLike { @@ -84,6 +88,7 @@ impl LabelLike { children: SmallVec::new(), alpha: None, underline: false, + single_line: false, } } } @@ -139,6 +144,11 @@ impl LabelCommon for LabelLike { self.alpha = Some(alpha); self } + + fn single_line(mut self) -> Self { + self.single_line = true; + self + } } impl ParentElement for LabelLike { @@ -178,6 +188,7 @@ impl RenderOnce for LabelLike { this }) .when(self.strikethrough, |this| this.line_through()) + .when(self.single_line, |this| this.whitespace_nowrap()) .text_color(color) .font_weight(self.weight.unwrap_or(settings.ui_font.weight)) .children(self.children) diff --git a/crates/ui/src/utils.rs b/crates/ui/src/utils.rs index 25477194dc363e..e5c591a97041ed 100644 --- a/crates/ui/src/utils.rs +++ b/crates/ui/src/utils.rs @@ -2,8 +2,10 @@ mod color_contrast; mod format_distance; +mod search_input; mod with_rem_size; pub use color_contrast::*; pub use format_distance::*; +pub use search_input::*; pub use with_rem_size::*; diff --git a/crates/ui/src/utils/search_input.rs b/crates/ui/src/utils/search_input.rs new file mode 100644 index 00000000000000..3a507f9a5ae145 --- /dev/null +++ b/crates/ui/src/utils/search_input.rs @@ -0,0 +1,22 @@ +#![allow(missing_docs)] + +use gpui::Pixels; + +pub struct SearchInputWidth; + +impl SearchInputWidth { + /// The containzer size in which the input stops filling the whole width. + pub const THRESHOLD_WIDTH: f32 = 1200.0; + + /// The maximum width for the search input when the container is larger than the threshold. + pub const MAX_WIDTH: f32 = 1200.0; + + /// Calculates the actual width in pixels based on the container width. + pub fn calc_width(container_width: Pixels) -> Pixels { + if container_width.0 < Self::THRESHOLD_WIDTH { + container_width + } else { + Pixels(container_width.0.min(Self::MAX_WIDTH)) + } + } +} diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index 94d580e6431d03..2f841144092383 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -37,6 +37,7 @@ unicase.workspace = true [target.'cfg(windows)'.dependencies] tendril = "0.4.3" +dunce = "1.0" [dev-dependencies] git2.workspace = true diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index f4e494f66e4e82..e3b0af1fdb6593 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -1,5 +1,5 @@ use std::cmp; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; use std::{ ffi::OsStr, path::{Path, PathBuf}, @@ -95,6 +95,46 @@ impl> PathExt for T { } } +/// Due to the issue of UNC paths on Windows, which can cause bugs in various parts of Zed, introducing this `SanitizedPath` +/// leverages Rust's type system to ensure that all paths entering Zed are always "sanitized" by removing the `\\\\?\\` prefix. +/// On non-Windows operating systems, this struct is effectively a no-op. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SanitizedPath(Arc); + +impl SanitizedPath { + pub fn starts_with(&self, prefix: &SanitizedPath) -> bool { + self.0.starts_with(&prefix.0) + } + + pub fn as_path(&self) -> &Arc { + &self.0 + } + + pub fn to_string(&self) -> String { + self.0.to_string_lossy().to_string() + } +} + +impl From for Arc { + fn from(sanitized_path: SanitizedPath) -> Self { + sanitized_path.0 + } +} + +impl> From for SanitizedPath { + #[cfg(not(target_os = "windows"))] + fn from(path: T) -> Self { + let path = path.as_ref(); + SanitizedPath(path.into()) + } + + #[cfg(target_os = "windows")] + fn from(path: T) -> Self { + let path = path.as_ref(); + SanitizedPath(dunce::simplified(path).into()) + } +} + /// A delimiter to use in `path_query:row_number:column_number` strings parsing. pub const FILE_ROW_COLUMN_DELIMITER: char = ':'; @@ -805,4 +845,22 @@ mod tests { "Path matcher should match {path:?}" ); } + + #[test] + #[cfg(target_os = "windows")] + fn test_sanitized_path() { + let path = Path::new("C:\\Users\\someone\\test_file.rs"); + let sanitized_path = SanitizedPath::from(path); + assert_eq!( + sanitized_path.to_string(), + "C:\\Users\\someone\\test_file.rs" + ); + + let path = Path::new("\\\\?\\C:\\Users\\someone\\test_file.rs"); + let sanitized_path = SanitizedPath::from(path); + assert_eq!( + sanitized_path.to_string(), + "C:\\Users\\someone\\test_file.rs" + ); + } } diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index ddf738d067a225..02d4136faa3323 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -36,6 +36,7 @@ serde.workspace = true serde_derive.workspace = true serde_json.workspace = true settings.workspace = true +theme.workspace = true tokio = { version = "1.15", features = ["full"], optional = true } ui.workspace = true util.workspace = true diff --git a/crates/vim/src/change_list.rs b/crates/vim/src/change_list.rs index 69fcdd83192f52..adf553983b9dd1 100644 --- a/crates/vim/src/change_list.rs +++ b/crates/vim/src/change_list.rs @@ -16,7 +16,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { impl Vim { fn move_to_change(&mut self, direction: Direction, cx: &mut ViewContext) { - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); if self.change_list.is_empty() { return; } diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 2fa75c85797353..5a958da0122ae0 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -101,7 +101,7 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext) { let Some(workspace) = vim.workspace(cx) else { return; }; - let count = vim.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); workspace.update(cx, |workspace, cx| { command_palette::CommandPalette::toggle( workspace, diff --git a/crates/vim/src/indent.rs b/crates/vim/src/indent.rs index b6ca2de34c63e9..6d5ce78f5ccc4d 100644 --- a/crates/vim/src/indent.rs +++ b/crates/vim/src/indent.rs @@ -9,14 +9,15 @@ use ui::ViewContext; pub(crate) enum IndentDirection { In, Out, + Auto, } -actions!(vim, [Indent, Outdent,]); +actions!(vim, [Indent, Outdent, AutoIndent]); pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &Indent, cx| { vim.record_current_action(cx); - let count = vim.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); vim.store_visual_marks(cx); vim.update_editor(cx, |vim, editor, cx| { editor.transact(cx, |editor, cx| { @@ -34,7 +35,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &Outdent, cx| { vim.record_current_action(cx); - let count = vim.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); vim.store_visual_marks(cx); vim.update_editor(cx, |vim, editor, cx| { editor.transact(cx, |editor, cx| { @@ -49,6 +50,24 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { vim.switch_mode(Mode::Normal, true, cx) } }); + + Vim::action(editor, cx, |vim, _: &AutoIndent, cx| { + vim.record_current_action(cx); + let count = Vim::take_count(cx).unwrap_or(1); + vim.store_visual_marks(cx); + vim.update_editor(cx, |vim, editor, cx| { + editor.transact(cx, |editor, cx| { + let original_positions = vim.save_selection_starts(editor, cx); + for _ in 0..count { + editor.autoindent(&Default::default(), cx); + } + vim.restore_selection_cursors(editor, cx, original_positions); + }); + }); + if vim.mode.is_visual() { + vim.switch_mode(Mode::Normal, true, cx) + } + }); } impl Vim { @@ -71,10 +90,10 @@ impl Vim { motion.expand_selection(map, selection, times, false, &text_layout_details); }); }); - if dir == IndentDirection::In { - editor.indent(&Default::default(), cx); - } else { - editor.outdent(&Default::default(), cx); + match dir { + IndentDirection::In => editor.indent(&Default::default(), cx), + IndentDirection::Out => editor.outdent(&Default::default(), cx), + IndentDirection::Auto => editor.autoindent(&Default::default(), cx), } editor.change_selections(None, cx, |s| { s.move_with(|map, selection| { @@ -104,10 +123,10 @@ impl Vim { object.expand_selection(map, selection, around); }); }); - if dir == IndentDirection::In { - editor.indent(&Default::default(), cx); - } else { - editor.outdent(&Default::default(), cx); + match dir { + IndentDirection::In => editor.indent(&Default::default(), cx), + IndentDirection::Out => editor.outdent(&Default::default(), cx), + IndentDirection::Auto => editor.autoindent(&Default::default(), cx), } editor.change_selections(None, cx, |s| { s.move_with(|map, selection| { @@ -122,7 +141,11 @@ impl Vim { #[cfg(test)] mod test { - use crate::test::NeovimBackedTestContext; + use crate::{ + state::Mode, + test::{NeovimBackedTestContext, VimTestContext}, + }; + use indoc::indoc; #[gpui::test] async fn test_indent_gv(cx: &mut gpui::TestAppContext) { @@ -135,4 +158,46 @@ mod test { .await .assert_eq("« hello\n ˇ» world\n"); } + + #[gpui::test] + async fn test_autoindent_op(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc!( + " + fn a() { + b(); + c(); + + d(); + ˇe(); + f(); + + g(); + } + " + ), + Mode::Normal, + ); + + cx.simulate_keystrokes("= a p"); + cx.assert_state( + indoc!( + " + fn a() { + b(); + c(); + + d(); + ˇe(); + f(); + + g(); + } + " + ), + Mode::Normal, + ); + } } diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index ba83e2125b8f6f..b1e7af9b105794 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -17,7 +17,7 @@ impl Vim { self.sync_vim_settings(cx); return; } - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); self.stop_recording_immediately(action.boxed_clone(), cx); if count <= 1 || Vim::globals(cx).dot_replaying { self.create_mark("^".into(), false, cx); diff --git a/crates/vim/src/mode_indicator.rs b/crates/vim/src/mode_indicator.rs index 619bb6e1f43cac..8b608fdfe34d14 100644 --- a/crates/vim/src/mode_indicator.rs +++ b/crates/vim/src/mode_indicator.rs @@ -2,7 +2,7 @@ use gpui::{div, Element, Render, Subscription, View, ViewContext, WeakView}; use itertools::Itertools; use workspace::{item::ItemHandle, ui::prelude::*, StatusItemView}; -use crate::{Vim, VimEvent}; +use crate::{Vim, VimEvent, VimGlobals}; /// The ModeIndicator displays the current mode in the status bar. pub struct ModeIndicator { @@ -68,14 +68,22 @@ impl ModeIndicator { let vim = vim.read(cx); recording - .chain(vim.pre_count.map(|count| format!("{}", count))) + .chain( + cx.global::() + .pre_count + .map(|count| format!("{}", count)), + ) .chain(vim.selected_register.map(|reg| format!("\"{reg}"))) .chain( vim.operator_stack .iter() .map(|item| item.status().to_string()), ) - .chain(vim.post_count.map(|count| format!("{}", count))) + .chain( + cx.global::() + .post_count + .map(|count| format!("{}", count)), + ) .collect::>() .join("") } diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 9f7a30afe9a41d..eb6e8464a3894e 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -11,6 +11,7 @@ use language::{CharKind, Point, Selection, SelectionGoal}; use multi_buffer::MultiBufferRow; use serde::Deserialize; use std::ops::Range; +use workspace::searchable::Direction; use crate::{ normal::mark, @@ -72,6 +73,12 @@ pub enum Motion { StartOfDocument, EndOfDocument, Matching, + UnmatchedForward { + char: char, + }, + UnmatchedBackward { + char: char, + }, FindForward { before: bool, char: char, @@ -98,6 +105,16 @@ pub enum Motion { WindowTop, WindowMiddle, WindowBottom, + NextSectionStart, + NextSectionEnd, + PreviousSectionStart, + PreviousSectionEnd, + NextMethodStart, + NextMethodEnd, + PreviousMethodStart, + PreviousMethodEnd, + NextComment, + PreviousComment, // we don't have a good way to run a search synchronously, so // we handle search motions by running the search async and then @@ -203,6 +220,20 @@ pub struct StartOfLine { pub(crate) display_lines: bool, } +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct UnmatchedForward { + #[serde(default)] + char: char, +} + +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct UnmatchedBackward { + #[serde(default)] + char: char, +} + impl_actions!( vim, [ @@ -219,6 +250,8 @@ impl_actions!( NextSubwordEnd, PreviousSubwordStart, PreviousSubwordEnd, + UnmatchedForward, + UnmatchedBackward ] ); @@ -247,6 +280,16 @@ actions!( WindowTop, WindowMiddle, WindowBottom, + NextSectionStart, + NextSectionEnd, + PreviousSectionStart, + PreviousSectionEnd, + NextMethodStart, + NextMethodEnd, + PreviousMethodStart, + PreviousMethodEnd, + NextComment, + PreviousComment, ] ); @@ -326,7 +369,20 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &Matching, cx| { vim.motion(Motion::Matching, cx) }); - + Vim::action( + editor, + cx, + |vim, &UnmatchedForward { char }: &UnmatchedForward, cx| { + vim.motion(Motion::UnmatchedForward { char }, cx) + }, + ); + Vim::action( + editor, + cx, + |vim, &UnmatchedBackward { char }: &UnmatchedBackward, cx| { + vim.motion(Motion::UnmatchedBackward { char }, cx) + }, + ); Vim::action( editor, cx, @@ -419,6 +475,37 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, &WindowBottom, cx| { vim.motion(Motion::WindowBottom, cx) }); + + Vim::action(editor, cx, |vim, &PreviousSectionStart, cx| { + vim.motion(Motion::PreviousSectionStart, cx) + }); + Vim::action(editor, cx, |vim, &NextSectionStart, cx| { + vim.motion(Motion::NextSectionStart, cx) + }); + Vim::action(editor, cx, |vim, &PreviousSectionEnd, cx| { + vim.motion(Motion::PreviousSectionEnd, cx) + }); + Vim::action(editor, cx, |vim, &NextSectionEnd, cx| { + vim.motion(Motion::NextSectionEnd, cx) + }); + Vim::action(editor, cx, |vim, &PreviousMethodStart, cx| { + vim.motion(Motion::PreviousMethodStart, cx) + }); + Vim::action(editor, cx, |vim, &NextMethodStart, cx| { + vim.motion(Motion::NextMethodStart, cx) + }); + Vim::action(editor, cx, |vim, &PreviousMethodEnd, cx| { + vim.motion(Motion::PreviousMethodEnd, cx) + }); + Vim::action(editor, cx, |vim, &NextMethodEnd, cx| { + vim.motion(Motion::NextMethodEnd, cx) + }); + Vim::action(editor, cx, |vim, &NextComment, cx| { + vim.motion(Motion::NextComment, cx) + }); + Vim::action(editor, cx, |vim, &PreviousComment, cx| { + vim.motion(Motion::PreviousComment, cx) + }); } impl Vim { @@ -455,7 +542,7 @@ impl Vim { self.pop_operator(cx); } - let count = self.take_count(cx); + let count = Vim::take_count(cx); let active_operator = self.active_operator(); let mut waiting_operator: Option = None; match self.mode { @@ -475,7 +562,7 @@ impl Vim { self.clear_operator(cx); if let Some(operator) = waiting_operator { self.push_operator(operator, cx); - self.pre_count = count + Vim::globals(cx).pre_count = count } } } @@ -501,9 +588,21 @@ impl Motion { | WindowTop | WindowMiddle | WindowBottom + | NextSectionStart + | NextSectionEnd + | PreviousSectionStart + | PreviousSectionEnd + | NextMethodStart + | NextMethodEnd + | PreviousMethodStart + | PreviousMethodEnd + | NextComment + | PreviousComment | Jump { line: true, .. } => true, EndOfLine { .. } | Matching + | UnmatchedForward { .. } + | UnmatchedBackward { .. } | FindForward { .. } | Left | Backspace @@ -537,6 +636,8 @@ impl Motion { | Up { .. } | EndOfLine { .. } | Matching + | UnmatchedForward { .. } + | UnmatchedBackward { .. } | FindForward { .. } | RepeatFind { .. } | Left @@ -568,6 +669,16 @@ impl Motion { | NextLineStart | PreviousLineStart | ZedSearchResult { .. } + | NextSectionStart + | NextSectionEnd + | PreviousSectionStart + | PreviousSectionEnd + | NextMethodStart + | NextMethodEnd + | PreviousMethodStart + | PreviousMethodEnd + | NextComment + | PreviousComment | Jump { .. } => false, } } @@ -583,6 +694,8 @@ impl Motion { | EndOfLine { .. } | EndOfLineDownward | Matching + | UnmatchedForward { .. } + | UnmatchedBackward { .. } | FindForward { .. } | WindowTop | WindowMiddle @@ -611,6 +724,16 @@ impl Motion { | FirstNonWhitespace { .. } | FindBackward { .. } | Jump { .. } + | NextSectionStart + | NextSectionEnd + | PreviousSectionStart + | PreviousSectionEnd + | NextMethodStart + | NextMethodEnd + | PreviousMethodStart + | PreviousMethodEnd + | NextComment + | PreviousComment | ZedSearchResult { .. } => false, RepeatFind { last_find: motion } | RepeatFindReversed { last_find: motion } => { motion.inclusive() @@ -707,6 +830,14 @@ impl Motion { SelectionGoal::None, ), Matching => (matching(map, point), SelectionGoal::None), + UnmatchedForward { char } => ( + unmatched_forward(map, point, *char, times), + SelectionGoal::None, + ), + UnmatchedBackward { char } => ( + unmatched_backward(map, point, *char, times), + SelectionGoal::None, + ), // t f FindForward { before, @@ -818,6 +949,47 @@ impl Motion { return None; } } + NextSectionStart => ( + section_motion(map, point, times, Direction::Next, true), + SelectionGoal::None, + ), + NextSectionEnd => ( + section_motion(map, point, times, Direction::Next, false), + SelectionGoal::None, + ), + PreviousSectionStart => ( + section_motion(map, point, times, Direction::Prev, true), + SelectionGoal::None, + ), + PreviousSectionEnd => ( + section_motion(map, point, times, Direction::Prev, false), + SelectionGoal::None, + ), + + NextMethodStart => ( + method_motion(map, point, times, Direction::Next, true), + SelectionGoal::None, + ), + NextMethodEnd => ( + method_motion(map, point, times, Direction::Next, false), + SelectionGoal::None, + ), + PreviousMethodStart => ( + method_motion(map, point, times, Direction::Prev, true), + SelectionGoal::None, + ), + PreviousMethodEnd => ( + method_motion(map, point, times, Direction::Prev, false), + SelectionGoal::None, + ), + NextComment => ( + comment_motion(map, point, times, Direction::Next), + SelectionGoal::None, + ), + PreviousComment => ( + comment_motion(map, point, times, Direction::Prev), + SelectionGoal::None, + ), }; (new_point != point || infallible).then_some((new_point, goal)) @@ -1792,6 +1964,92 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint } } +fn unmatched_forward( + map: &DisplaySnapshot, + mut display_point: DisplayPoint, + char: char, + times: usize, +) -> DisplayPoint { + for _ in 0..times { + // https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1245 + let point = display_point.to_point(map); + let offset = point.to_offset(&map.buffer_snapshot); + + let ranges = map.buffer_snapshot.enclosing_bracket_ranges(point..point); + let Some(ranges) = ranges else { break }; + let mut closest_closing_destination = None; + let mut closest_distance = usize::MAX; + + for (_, close_range) in ranges { + if close_range.start > offset { + let mut chars = map.buffer_snapshot.chars_at(close_range.start); + if Some(char) == chars.next() { + let distance = close_range.start - offset; + if distance < closest_distance { + closest_closing_destination = Some(close_range.start); + closest_distance = distance; + continue; + } + } + } + } + + let new_point = closest_closing_destination + .map(|destination| destination.to_display_point(map)) + .unwrap_or(display_point); + if new_point == display_point { + break; + } + display_point = new_point; + } + return display_point; +} + +fn unmatched_backward( + map: &DisplaySnapshot, + mut display_point: DisplayPoint, + char: char, + times: usize, +) -> DisplayPoint { + for _ in 0..times { + // https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1239 + let point = display_point.to_point(map); + let offset = point.to_offset(&map.buffer_snapshot); + + let ranges = map.buffer_snapshot.enclosing_bracket_ranges(point..point); + let Some(ranges) = ranges else { + break; + }; + + let mut closest_starting_destination = None; + let mut closest_distance = usize::MAX; + + for (start_range, _) in ranges { + if start_range.start < offset { + let mut chars = map.buffer_snapshot.chars_at(start_range.start); + if Some(char) == chars.next() { + let distance = offset - start_range.start; + if distance < closest_distance { + closest_starting_destination = Some(start_range.start); + closest_distance = distance; + continue; + } + } + } + } + + let new_point = closest_starting_destination + .map(|destination| destination.to_display_point(map)) + .unwrap_or(display_point); + if new_point == display_point { + break; + } else { + display_point = new_point; + } + } + display_point +} + fn find_forward( map: &DisplaySnapshot, from: DisplayPoint, @@ -1994,6 +2252,231 @@ fn window_bottom( } } +fn method_motion( + map: &DisplaySnapshot, + mut display_point: DisplayPoint, + times: usize, + direction: Direction, + is_start: bool, +) -> DisplayPoint { + let Some((_, _, buffer)) = map.buffer_snapshot.as_singleton() else { + return display_point; + }; + + for _ in 0..times { + let point = map.display_point_to_point(display_point, Bias::Left); + let offset = point.to_offset(&map.buffer_snapshot); + let range = if direction == Direction::Prev { + 0..offset + } else { + offset..buffer.len() + }; + + let possibilities = buffer + .text_object_ranges(range, language::TreeSitterOptions::max_start_depth(4)) + .filter_map(|(range, object)| { + if !matches!(object, language::TextObject::AroundFunction) { + return None; + } + + let relevant = if is_start { range.start } else { range.end }; + if direction == Direction::Prev && relevant < offset { + Some(relevant) + } else if direction == Direction::Next && relevant > offset + 1 { + Some(relevant) + } else { + None + } + }); + + let dest = if direction == Direction::Prev { + possibilities.max().unwrap_or(offset) + } else { + possibilities.min().unwrap_or(offset) + }; + let new_point = map.clip_point(dest.to_display_point(&map), Bias::Left); + if new_point == display_point { + break; + } + display_point = new_point; + } + display_point +} + +fn comment_motion( + map: &DisplaySnapshot, + mut display_point: DisplayPoint, + times: usize, + direction: Direction, +) -> DisplayPoint { + let Some((_, _, buffer)) = map.buffer_snapshot.as_singleton() else { + return display_point; + }; + + for _ in 0..times { + let point = map.display_point_to_point(display_point, Bias::Left); + let offset = point.to_offset(&map.buffer_snapshot); + let range = if direction == Direction::Prev { + 0..offset + } else { + offset..buffer.len() + }; + + let possibilities = buffer + .text_object_ranges(range, language::TreeSitterOptions::max_start_depth(6)) + .filter_map(|(range, object)| { + if !matches!(object, language::TextObject::AroundComment) { + return None; + } + + let relevant = if direction == Direction::Prev { + range.start + } else { + range.end + }; + if direction == Direction::Prev && relevant < offset { + Some(relevant) + } else if direction == Direction::Next && relevant > offset + 1 { + Some(relevant) + } else { + None + } + }); + + let dest = if direction == Direction::Prev { + possibilities.max().unwrap_or(offset) + } else { + possibilities.min().unwrap_or(offset) + }; + let new_point = map.clip_point(dest.to_display_point(&map), Bias::Left); + if new_point == display_point { + break; + } + display_point = new_point; + } + + display_point +} + +fn section_motion( + map: &DisplaySnapshot, + mut display_point: DisplayPoint, + times: usize, + direction: Direction, + is_start: bool, +) -> DisplayPoint { + if let Some((_, _, buffer)) = map.buffer_snapshot.as_singleton() { + for _ in 0..times { + let offset = map + .display_point_to_point(display_point, Bias::Left) + .to_offset(&map.buffer_snapshot); + let range = if direction == Direction::Prev { + 0..offset + } else { + offset..buffer.len() + }; + + // we set a max start depth here because we want a section to only be "top level" + // similar to vim's default of '{' in the first column. + // (and without it, ]] at the start of editor.rs is -very- slow) + let mut possibilities = buffer + .text_object_ranges(range, language::TreeSitterOptions::max_start_depth(3)) + .filter(|(_, object)| { + matches!( + object, + language::TextObject::AroundClass | language::TextObject::AroundFunction + ) + }) + .collect::>(); + possibilities.sort_by_key(|(range_a, _)| range_a.start); + let mut prev_end = None; + let possibilities = possibilities.into_iter().filter_map(|(range, t)| { + if t == language::TextObject::AroundFunction + && prev_end.is_some_and(|prev_end| prev_end > range.start) + { + return None; + } + prev_end = Some(range.end); + + let relevant = if is_start { range.start } else { range.end }; + if direction == Direction::Prev && relevant < offset { + Some(relevant) + } else if direction == Direction::Next && relevant > offset + 1 { + Some(relevant) + } else { + None + } + }); + + let offset = if direction == Direction::Prev { + possibilities.max().unwrap_or(0) + } else { + possibilities.min().unwrap_or(buffer.len()) + }; + + let new_point = map.clip_point(offset.to_display_point(&map), Bias::Left); + if new_point == display_point { + break; + } + display_point = new_point; + } + return display_point; + }; + + for _ in 0..times { + let point = map.display_point_to_point(display_point, Bias::Left); + let Some(excerpt) = map.buffer_snapshot.excerpt_containing(point..point) else { + return display_point; + }; + let next_point = match (direction, is_start) { + (Direction::Prev, true) => { + let mut start = excerpt.start_anchor().to_display_point(&map); + if start >= display_point && start.row() > DisplayRow(0) { + let Some(excerpt) = map.buffer_snapshot.excerpt_before(excerpt.id()) else { + return display_point; + }; + start = excerpt.start_anchor().to_display_point(&map); + } + start + } + (Direction::Prev, false) => { + let mut start = excerpt.start_anchor().to_display_point(&map); + if start.row() > DisplayRow(0) { + *start.row_mut() -= 1; + } + map.clip_point(start, Bias::Left) + } + (Direction::Next, true) => { + let mut end = excerpt.end_anchor().to_display_point(&map); + *end.row_mut() += 1; + map.clip_point(end, Bias::Right) + } + (Direction::Next, false) => { + let mut end = excerpt.end_anchor().to_display_point(&map); + *end.column_mut() = 0; + if end <= display_point { + *end.row_mut() += 1; + let point_end = map.display_point_to_point(end, Bias::Right); + let Some(excerpt) = + map.buffer_snapshot.excerpt_containing(point_end..point_end) + else { + return display_point; + }; + end = excerpt.end_anchor().to_display_point(&map); + *end.column_mut() = 0; + } + end + } + }; + if next_point == display_point { + break; + } + display_point = next_point; + } + + display_point +} + #[cfg(test)] mod test { @@ -2118,6 +2601,103 @@ mod test { cx.shared_state().await.assert_eq("func boop(ˇ) {\n}"); } + #[gpui::test] + async fn test_unmatched_forward(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // test it works with curly braces + cx.set_shared_state(indoc! {r"func (a string) { + do(something(with.anˇd_arrays[0, 2])) + }"}) + .await; + cx.simulate_shared_keystrokes("] }").await; + cx.shared_state() + .await + .assert_eq(indoc! {r"func (a string) { + do(something(with.and_arrays[0, 2])) + ˇ}"}); + + // test it works with brackets + cx.set_shared_state(indoc! {r"func (a string) { + do(somethiˇng(with.and_arrays[0, 2])) + }"}) + .await; + cx.simulate_shared_keystrokes("] )").await; + cx.shared_state() + .await + .assert_eq(indoc! {r"func (a string) { + do(something(with.and_arrays[0, 2])ˇ) + }"}); + + cx.set_shared_state(indoc! {r"func (a string) { a((b, cˇ))}"}) + .await; + cx.simulate_shared_keystrokes("] )").await; + cx.shared_state() + .await + .assert_eq(indoc! {r"func (a string) { a((b, c)ˇ)}"}); + + // test it works on immediate nesting + cx.set_shared_state("{ˇ {}{}}").await; + cx.simulate_shared_keystrokes("] }").await; + cx.shared_state().await.assert_eq("{ {}{}ˇ}"); + cx.set_shared_state("(ˇ ()())").await; + cx.simulate_shared_keystrokes("] )").await; + cx.shared_state().await.assert_eq("( ()()ˇ)"); + + // test it works on immediate nesting inside braces + cx.set_shared_state("{\n ˇ {()}\n}").await; + cx.simulate_shared_keystrokes("] }").await; + cx.shared_state().await.assert_eq("{\n {()}\nˇ}"); + cx.set_shared_state("(\n ˇ {()}\n)").await; + cx.simulate_shared_keystrokes("] )").await; + cx.shared_state().await.assert_eq("(\n {()}\nˇ)"); + } + + #[gpui::test] + async fn test_unmatched_backward(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // test it works with curly braces + cx.set_shared_state(indoc! {r"func (a string) { + do(something(with.anˇd_arrays[0, 2])) + }"}) + .await; + cx.simulate_shared_keystrokes("[ {").await; + cx.shared_state() + .await + .assert_eq(indoc! {r"func (a string) ˇ{ + do(something(with.and_arrays[0, 2])) + }"}); + + // test it works with brackets + cx.set_shared_state(indoc! {r"func (a string) { + do(somethiˇng(with.and_arrays[0, 2])) + }"}) + .await; + cx.simulate_shared_keystrokes("[ (").await; + cx.shared_state() + .await + .assert_eq(indoc! {r"func (a string) { + doˇ(something(with.and_arrays[0, 2])) + }"}); + + // test it works on immediate nesting + cx.set_shared_state("{{}{} ˇ }").await; + cx.simulate_shared_keystrokes("[ {").await; + cx.shared_state().await.assert_eq("ˇ{{}{} }"); + cx.set_shared_state("(()() ˇ )").await; + cx.simulate_shared_keystrokes("[ (").await; + cx.shared_state().await.assert_eq("ˇ(()() )"); + + // test it works on immediate nesting inside braces + cx.set_shared_state("{\n {()} ˇ\n}").await; + cx.simulate_shared_keystrokes("[ {").await; + cx.shared_state().await.assert_eq("ˇ{\n {()} \n}"); + cx.set_shared_state("(\n {()} ˇ\n)").await; + cx.simulate_shared_keystrokes("[ (").await; + cx.shared_state().await.assert_eq("ˇ(\n {()} \n)"); + } + #[gpui::test] async fn test_matching_tags(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new_html(cx).await; diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 37a8115e333756..bde3c12027482f 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -77,17 +77,17 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &DeleteLeft, cx| { vim.record_current_action(cx); - let times = vim.take_count(cx); + let times = Vim::take_count(cx); vim.delete_motion(Motion::Left, times, cx); }); Vim::action(editor, cx, |vim, _: &DeleteRight, cx| { vim.record_current_action(cx); - let times = vim.take_count(cx); + let times = Vim::take_count(cx); vim.delete_motion(Motion::Right, times, cx); }); Vim::action(editor, cx, |vim, _: &ChangeToEndOfLine, cx| { vim.start_recording(cx); - let times = vim.take_count(cx); + let times = Vim::take_count(cx); vim.change_motion( Motion::EndOfLine { display_lines: false, @@ -98,7 +98,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { }); Vim::action(editor, cx, |vim, _: &DeleteToEndOfLine, cx| { vim.record_current_action(cx); - let times = vim.take_count(cx); + let times = Vim::take_count(cx); vim.delete_motion( Motion::EndOfLine { display_lines: false, @@ -109,7 +109,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { }); Vim::action(editor, cx, |vim, _: &JoinLines, cx| { vim.record_current_action(cx); - let mut times = vim.take_count(cx).unwrap_or(1); + let mut times = Vim::take_count(cx).unwrap_or(1); if vim.mode.is_visual() { times = 1; } else if times > 1 { @@ -130,7 +130,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { }); Vim::action(editor, cx, |vim, _: &Undo, cx| { - let times = vim.take_count(cx); + let times = Vim::take_count(cx); vim.update_editor(cx, |_, editor, cx| { for _ in 0..times.unwrap_or(1) { editor.undo(&editor::actions::Undo, cx); @@ -138,7 +138,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { }); }); Vim::action(editor, cx, |vim, _: &Redo, cx| { - let times = vim.take_count(cx); + let times = Vim::take_count(cx); vim.update_editor(cx, |_, editor, cx| { for _ in 0..times.unwrap_or(1) { editor.redo(&editor::actions::Redo, cx); @@ -170,6 +170,9 @@ impl Vim { Some(Operator::Indent) => self.indent_motion(motion, times, IndentDirection::In, cx), Some(Operator::Rewrap) => self.rewrap_motion(motion, times, cx), Some(Operator::Outdent) => self.indent_motion(motion, times, IndentDirection::Out, cx), + Some(Operator::AutoIndent) => { + self.indent_motion(motion, times, IndentDirection::Auto, cx) + } Some(Operator::Lowercase) => { self.change_case_motion(motion, times, CaseTarget::Lowercase, cx) } @@ -202,6 +205,9 @@ impl Vim { Some(Operator::Outdent) => { self.indent_object(object, around, IndentDirection::Out, cx) } + Some(Operator::AutoIndent) => { + self.indent_object(object, around, IndentDirection::Auto, cx) + } Some(Operator::Rewrap) => self.rewrap_object(object, around, cx), Some(Operator::Lowercase) => { self.change_case_object(object, around, CaseTarget::Lowercase, cx) @@ -396,7 +402,7 @@ impl Vim { } fn yank_line(&mut self, _: &YankLine, cx: &mut ViewContext) { - let count = self.take_count(cx); + let count = Vim::take_count(cx); self.yank_motion(motion::Motion::CurrentLine, count, cx) } @@ -416,7 +422,7 @@ impl Vim { } pub(crate) fn normal_replace(&mut self, text: Arc, cx: &mut ViewContext) { - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); self.stop_recording(cx); self.update_editor(cx, |_, editor, cx| { editor.transact(cx, |editor, cx| { diff --git a/crates/vim/src/normal/case.rs b/crates/vim/src/normal/case.rs index 2c591a1f1ff546..0aeb4c7e98b504 100644 --- a/crates/vim/src/normal/case.rs +++ b/crates/vim/src/normal/case.rs @@ -118,7 +118,7 @@ impl Vim { { self.record_current_action(cx); self.store_visual_marks(cx); - let count = self.take_count(cx).unwrap_or(1) as u32; + let count = Vim::take_count(cx).unwrap_or(1) as u32; self.update_editor(cx, |vim, editor, cx| { let mut ranges = Vec::new(); diff --git a/crates/vim/src/normal/increment.rs b/crates/vim/src/normal/increment.rs index ec24064b31adb7..ca300fc1be27d1 100644 --- a/crates/vim/src/normal/increment.rs +++ b/crates/vim/src/normal/increment.rs @@ -26,13 +26,13 @@ impl_actions!(vim, [Increment, Decrement]); pub fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, action: &Increment, cx| { vim.record_current_action(cx); - let count = vim.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); let step = if action.step { 1 } else { 0 }; vim.increment(count as i64, step, cx) }); Vim::action(editor, cx, |vim, action: &Decrement, cx| { vim.record_current_action(cx); - let count = vim.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); let step = if action.step { -1 } else { 0 }; vim.increment(-(count as i64), step, cx) }); diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index feb060d59436ba..8d49a6802c1952 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -25,7 +25,7 @@ impl Vim { pub fn paste(&mut self, action: &Paste, cx: &mut ViewContext) { self.record_current_action(cx); self.store_visual_marks(cx); - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); self.update_editor(cx, |vim, editor, cx| { let text_layout_details = editor.text_layout_details(cx); diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index c89b63ecc6880d..41c89269f10718 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -158,7 +158,7 @@ impl Vim { } pub(crate) fn replay_register(&mut self, mut register: char, cx: &mut ViewContext) { - let mut count = self.take_count(cx).unwrap_or(1); + let mut count = Vim::take_count(cx).unwrap_or(1); self.clear_operator(cx); let globals = Vim::globals(cx); @@ -184,7 +184,7 @@ impl Vim { } pub(crate) fn repeat(&mut self, from_insert_mode: bool, cx: &mut ViewContext) { - let count = self.take_count(cx); + let count = Vim::take_count(cx); let Some((mut actions, selection, mode)) = Vim::update_globals(cx, |globals, _| { let actions = globals.recorded_actions.clone(); if actions.is_empty() { diff --git a/crates/vim/src/normal/scroll.rs b/crates/vim/src/normal/scroll.rs index 8d1443e6339028..3f71401e2edd38 100644 --- a/crates/vim/src/normal/scroll.rs +++ b/crates/vim/src/normal/scroll.rs @@ -53,7 +53,7 @@ impl Vim { cx: &mut ViewContext, by: fn(c: Option) -> ScrollAmount, ) { - let amount = by(self.take_count(cx).map(|c| c as f32)); + let amount = by(Vim::take_count(cx).map(|c| c as f32)); self.update_editor(cx, |_, editor, cx| { scroll_editor(editor, move_cursor, &amount, cx) }); diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 5d78c8937ef590..103d33f8af12ce 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -120,7 +120,7 @@ impl Vim { } else { Direction::Next }; - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); let prior_selections = self.editor_selections(cx); pane.update(cx, |pane, cx| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { @@ -226,7 +226,7 @@ impl Vim { pub fn move_to_match_internal(&mut self, direction: Direction, cx: &mut ViewContext) { let Some(pane) = self.pane(cx) else { return }; - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); let prior_selections = self.editor_selections(cx); let success = pane.update(cx, |pane, cx| { @@ -264,7 +264,7 @@ impl Vim { cx: &mut ViewContext, ) { let Some(pane) = self.pane(cx) else { return }; - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); let prior_selections = self.editor_selections(cx); let vim = cx.view().clone(); diff --git a/crates/vim/src/normal/substitute.rs b/crates/vim/src/normal/substitute.rs index dc27e2b2190d0a..c2b27227ca0698 100644 --- a/crates/vim/src/normal/substitute.rs +++ b/crates/vim/src/normal/substitute.rs @@ -9,7 +9,7 @@ actions!(vim, [Substitute, SubstituteLine]); pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &Substitute, cx| { vim.start_recording(cx); - let count = vim.take_count(cx); + let count = Vim::take_count(cx); vim.substitute(count, vim.mode == Mode::VisualLine, cx); }); @@ -18,7 +18,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { if matches!(vim.mode, Mode::VisualBlock | Mode::Visual) { vim.switch_mode(Mode::VisualLine, false, cx) } - let count = vim.take_count(cx); + let count = Vim::take_count(cx); vim.substitute(count, true, cx) }); } diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 7ed97358ff3e45..380acc896ac8c0 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -1,6 +1,10 @@ use std::ops::Range; -use crate::{motion::right, state::Mode, Vim}; +use crate::{ + motion::right, + state::{Mode, Operator}, + Vim, +}; use editor::{ display_map::{DisplaySnapshot, ToDisplayPoint}, movement::{self, FindRange}, @@ -10,7 +14,7 @@ use editor::{ use itertools::Itertools; use gpui::{actions, impl_actions, ViewContext}; -use language::{BufferSnapshot, CharKind, Point, Selection}; +use language::{BufferSnapshot, CharKind, Point, Selection, TextObject, TreeSitterOptions}; use multi_buffer::MultiBufferRow; use serde::Deserialize; @@ -30,6 +34,9 @@ pub enum Object { Argument, IndentObj { include_below: bool }, Tag, + Method, + Class, + Comment, } #[derive(Clone, Deserialize, PartialEq)] @@ -61,7 +68,10 @@ actions!( CurlyBrackets, AngleBrackets, Argument, - Tag + Tag, + Method, + Class, + Comment ] ); @@ -107,6 +117,18 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &Argument, cx| { vim.object(Object::Argument, cx) }); + Vim::action(editor, cx, |vim, _: &Method, cx| { + vim.object(Object::Method, cx) + }); + Vim::action(editor, cx, |vim, _: &Class, cx| { + vim.object(Object::Class, cx) + }); + Vim::action(editor, cx, |vim, _: &Comment, cx| { + if !matches!(vim.active_operator(), Some(Operator::Object { .. })) { + vim.push_operator(Operator::Object { around: true }, cx); + } + vim.object(Object::Comment, cx) + }); Vim::action( editor, cx, @@ -144,6 +166,9 @@ impl Object { | Object::CurlyBrackets | Object::SquareBrackets | Object::Argument + | Object::Method + | Object::Class + | Object::Comment | Object::IndentObj { .. } => true, } } @@ -162,12 +187,15 @@ impl Object { | Object::Parentheses | Object::SquareBrackets | Object::Tag + | Object::Method + | Object::Class + | Object::Comment | Object::CurlyBrackets | Object::AngleBrackets => true, } } - pub fn target_visual_mode(self, current_mode: Mode) -> Mode { + pub fn target_visual_mode(self, current_mode: Mode, around: bool) -> Mode { match self { Object::Word { .. } | Object::Sentence @@ -186,8 +214,16 @@ impl Object { | Object::AngleBrackets | Object::VerticalBars | Object::Tag + | Object::Comment | Object::Argument | Object::IndentObj { .. } => Mode::Visual, + Object::Method | Object::Class => { + if around { + Mode::VisualLine + } else { + Mode::Visual + } + } Object::Paragraph => Mode::VisualLine, } } @@ -238,6 +274,33 @@ impl Object { Object::AngleBrackets => { surrounding_markers(map, relative_to, around, self.is_multiline(), '<', '>') } + Object::Method => text_object( + map, + relative_to, + if around { + TextObject::AroundFunction + } else { + TextObject::InsideFunction + }, + ), + Object::Comment => text_object( + map, + relative_to, + if around { + TextObject::AroundComment + } else { + TextObject::InsideComment + }, + ), + Object::Class => text_object( + map, + relative_to, + if around { + TextObject::AroundClass + } else { + TextObject::InsideClass + }, + ), Object::Argument => argument(map, relative_to, around), Object::IndentObj { include_below } => indent(map, relative_to, around, include_below), } @@ -441,6 +504,47 @@ fn around_next_word( Some(start..end) } +fn text_object( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + target: TextObject, +) -> Option> { + let snapshot = &map.buffer_snapshot; + let offset = relative_to.to_offset(map, Bias::Left); + + let excerpt = snapshot.excerpt_containing(offset..offset)?; + let buffer = excerpt.buffer(); + + let mut matches: Vec> = buffer + .text_object_ranges(offset..offset, TreeSitterOptions::default()) + .filter_map(|(r, m)| if m == target { Some(r) } else { None }) + .collect(); + matches.sort_by_key(|r| (r.end - r.start)); + if let Some(range) = matches.first() { + return Some(range.start.to_display_point(map)..range.end.to_display_point(map)); + } + + let around = target.around()?; + let mut matches: Vec> = buffer + .text_object_ranges(offset..offset, TreeSitterOptions::default()) + .filter_map(|(r, m)| if m == around { Some(r) } else { None }) + .collect(); + matches.sort_by_key(|r| (r.end - r.start)); + let around_range = matches.first()?; + + let mut matches: Vec> = buffer + .text_object_ranges(around_range.clone(), TreeSitterOptions::default()) + .filter_map(|(r, m)| if m == target { Some(r) } else { None }) + .collect(); + matches.sort_by_key(|r| r.start); + if let Some(range) = matches.first() { + if !range.is_empty() { + return Some(range.start.to_display_point(map)..range.end.to_display_point(map)); + } + } + return Some(around_range.start.to_display_point(map)..around_range.end.to_display_point(map)); +} + fn argument( map: &DisplaySnapshot, relative_to: DisplayPoint, diff --git a/crates/vim/src/replace.rs b/crates/vim/src/replace.rs index 753eec09717104..8b8484904317fd 100644 --- a/crates/vim/src/replace.rs +++ b/crates/vim/src/replace.rs @@ -22,7 +22,7 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext) { if vim.mode != Mode::Replace { return; } - let count = vim.take_count(cx); + let count = Vim::take_count(cx); vim.undo_replace(count, cx) }); } diff --git a/crates/vim/src/rewrap.rs b/crates/vim/src/rewrap.rs index db54c4ed570d5a..1ef4a3fc03c789 100644 --- a/crates/vim/src/rewrap.rs +++ b/crates/vim/src/rewrap.rs @@ -10,7 +10,7 @@ actions!(vim, [Rewrap]); pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &Rewrap, cx| { vim.record_current_action(cx); - vim.take_count(cx); + Vim::take_count(cx); vim.store_visual_marks(cx); vim.update_editor(cx, |vim, editor, cx| { editor.transact(cx, |editor, cx| { diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 510ed6557dcc70..af187381ad48e4 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -72,6 +72,7 @@ pub enum Operator { Jump { line: bool }, Indent, Outdent, + AutoIndent, Rewrap, Lowercase, Uppercase, @@ -150,6 +151,11 @@ pub struct VimGlobals { pub dot_recording: bool, pub dot_replaying: bool, + /// pre_count is the number before an operator is specified (3 in 3d2d) + pub pre_count: Option, + /// post_count is the number after an operator is specified (2 in 3d2d) + pub post_count: Option, + pub stop_recording_after_next_action: bool, pub ignore_current_insertion: bool, pub recorded_count: Option, @@ -460,6 +466,7 @@ impl Operator { Operator::Jump { line: true } => "'", Operator::Jump { line: false } => "`", Operator::Indent => ">", + Operator::AutoIndent => "eq", Operator::Rewrap => "gq", Operator::Outdent => "<", Operator::Uppercase => "gU", @@ -505,6 +512,7 @@ impl Operator { | Operator::Rewrap | Operator::Indent | Operator::Outdent + | Operator::AutoIndent | Operator::Lowercase | Operator::Uppercase | Operator::Object { .. } diff --git a/crates/vim/src/surrounds.rs b/crates/vim/src/surrounds.rs index 88bcb6a2e166dd..719a1470623861 100644 --- a/crates/vim/src/surrounds.rs +++ b/crates/vim/src/surrounds.rs @@ -35,7 +35,7 @@ impl Vim { cx: &mut ViewContext, ) { self.stop_recording(cx); - let count = self.take_count(cx); + let count = Vim::take_count(cx); let mode = self.mode; self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(cx); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index dd3bf297cbe728..db0a7651704a11 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -25,8 +25,8 @@ use editor::{ Anchor, Bias, Editor, EditorEvent, EditorMode, ToPoint, }; use gpui::{ - actions, impl_actions, Action, AppContext, Entity, EventEmitter, KeyContext, KeystrokeEvent, - Render, Subscription, View, ViewContext, WeakView, + actions, impl_actions, Action, AppContext, Axis, Entity, EventEmitter, KeyContext, + KeystrokeEvent, Render, Subscription, View, ViewContext, WeakView, }; use insert::{NormalBefore, TemporaryNormal}; use language::{CursorShape, Point, Selection, SelectionGoal, TransactionId}; @@ -40,12 +40,17 @@ use settings::{update_settings_file, Settings, SettingsSources, SettingsStore}; use state::{Mode, Operator, RecordedSelection, SearchState, VimGlobals}; use std::{mem, ops::Range, sync::Arc}; use surrounds::SurroundsType; -use ui::{IntoElement, VisualContext}; +use theme::ThemeSettings; +use ui::{px, IntoElement, VisualContext}; use vim_mode_setting::VimModeSetting; -use workspace::{self, Pane, Workspace}; +use workspace::{self, Pane, ResizeIntent, Workspace}; use crate::state::ReplayableAction; +/// Used to resize the current pane +#[derive(Clone, Deserialize, PartialEq)] +pub struct ResizePane(pub ResizeIntent); + /// An Action to Switch between modes #[derive(Clone, Deserialize, PartialEq)] pub struct SwitchMode(pub Mode); @@ -74,14 +79,19 @@ actions!( InnerObject, FindForward, FindBackward, - OpenDefaultKeymap + OpenDefaultKeymap, + MaximizePane, + ResetPaneSizes, ] ); // in the workspace namespace so it's not filtered out when vim is disabled. actions!(workspace, [ToggleVimMode]); -impl_actions!(vim, [SwitchMode, PushOperator, Number, SelectRegister]); +impl_actions!( + vim, + [ResizePane, SwitchMode, PushOperator, Number, SelectRegister] +); /// Initializes the `vim` crate. pub fn init(cx: &mut AppContext) { @@ -109,6 +119,51 @@ pub fn init(cx: &mut AppContext) { }); }); + workspace.register_action(|workspace, _: &ResetPaneSizes, cx| { + workspace.reset_pane_sizes(cx); + }); + + workspace.register_action(|workspace, _: &MaximizePane, cx| { + let pane = workspace.active_pane(); + let Some(size) = workspace.bounding_box_for_pane(&pane) else { + return; + }; + + let theme = ThemeSettings::get_global(cx); + let height = theme.buffer_font_size(cx) * theme.buffer_line_height.value(); + + let desired_size = if let Some(count) = Vim::take_count(cx) { + height * count + } else { + px(10000.) + }; + workspace.resize_pane(Axis::Vertical, desired_size - size.size.height, cx) + }); + + workspace.register_action(|workspace, action: &ResizePane, cx| { + let count = Vim::take_count(cx).unwrap_or(1) as f32; + let theme = ThemeSettings::get_global(cx); + let Ok(font_id) = cx.text_system().font_id(&theme.buffer_font) else { + return; + }; + let Ok(width) = cx + .text_system() + .advance(font_id, theme.buffer_font_size(cx), 'm') + else { + return; + }; + let height = theme.buffer_font_size(cx) * theme.buffer_line_height.value(); + + let (axis, amount) = match action.0 { + ResizeIntent::Lengthen => (Axis::Vertical, height), + ResizeIntent::Shorten => (Axis::Vertical, height * -1.), + ResizeIntent::Widen => (Axis::Horizontal, width.width), + ResizeIntent::Narrow => (Axis::Horizontal, width.width * -1.), + }; + + workspace.resize_pane(axis, amount * count, cx); + }); + workspace.register_action(|workspace, _: &SearchSubmit, cx| { let vim = workspace .focused_pane(cx) @@ -131,7 +186,7 @@ pub(crate) struct VimAddon { impl editor::Addon for VimAddon { fn extend_key_context(&self, key_context: &mut KeyContext, cx: &AppContext) { - self.view.read(cx).extend_key_context(key_context) + self.view.read(cx).extend_key_context(key_context, cx) } fn to_any(&self) -> &dyn std::any::Any { @@ -146,11 +201,6 @@ pub(crate) struct Vim { pub temp_mode: bool, pub exit_temporary_mode: bool, - /// pre_count is the number before an operator is specified (3 in 3d2d) - pre_count: Option, - /// post_count is the number after an operator is specified (2 in 3d2d) - post_count: Option, - operator_stack: Vec, pub(crate) replacements: Vec<(Range, String)>, @@ -197,8 +247,6 @@ impl Vim { last_mode: Mode::Normal, temp_mode: false, exit_temporary_mode: false, - pre_count: None, - post_count: None, operator_stack: Vec::new(), replacements: Vec::new(), @@ -422,6 +470,7 @@ impl Vim { | Operator::Replace | Operator::Indent | Operator::Outdent + | Operator::AutoIndent | Operator::Lowercase | Operator::Uppercase | Operator::OppositeCase @@ -471,7 +520,7 @@ impl Vim { self.current_anchor.take(); } if mode != Mode::Insert && mode != Mode::Replace { - self.take_count(cx); + Vim::take_count(cx); } // Sync editor settings like clip mode @@ -551,22 +600,24 @@ impl Vim { }); } - fn take_count(&mut self, cx: &mut ViewContext) -> Option { + pub fn take_count(cx: &mut AppContext) -> Option { let global_state = cx.global_mut::(); if global_state.dot_replaying { return global_state.recorded_count; } - let count = if self.post_count.is_none() && self.pre_count.is_none() { + let count = if global_state.post_count.is_none() && global_state.pre_count.is_none() { return None; } else { - Some(self.post_count.take().unwrap_or(1) * self.pre_count.take().unwrap_or(1)) + Some( + global_state.post_count.take().unwrap_or(1) + * global_state.pre_count.take().unwrap_or(1), + ) }; if global_state.dot_recording { global_state.recorded_count = count; } - self.sync_vim_settings(cx); count } @@ -613,7 +664,7 @@ impl Vim { } } - pub fn extend_key_context(&self, context: &mut KeyContext) { + pub fn extend_key_context(&self, context: &mut KeyContext, cx: &AppContext) { let mut mode = match self.mode { Mode::Normal => "normal", Mode::Visual | Mode::VisualLine | Mode::VisualBlock => "visual", @@ -625,8 +676,8 @@ impl Vim { let mut operator_id = "none"; let active_operator = self.active_operator(); - if active_operator.is_none() && self.pre_count.is_some() - || active_operator.is_some() && self.post_count.is_some() + if active_operator.is_none() && cx.global::().pre_count.is_some() + || active_operator.is_some() && cx.global::().post_count.is_some() { context.add("VimCount"); } @@ -837,18 +888,18 @@ impl Vim { fn push_count_digit(&mut self, number: usize, cx: &mut ViewContext) { if self.active_operator().is_some() { - let post_count = self.post_count.unwrap_or(0); + let post_count = Vim::globals(cx).post_count.unwrap_or(0); - self.post_count = Some( + Vim::globals(cx).post_count = Some( post_count .checked_mul(10) .and_then(|post_count| post_count.checked_add(number)) .unwrap_or(post_count), ) } else { - let pre_count = self.pre_count.unwrap_or(0); + let pre_count = Vim::globals(cx).pre_count.unwrap_or(0); - self.pre_count = Some( + Vim::globals(cx).pre_count = Some( pre_count .checked_mul(10) .and_then(|pre_count| pre_count.checked_add(number)) @@ -880,7 +931,7 @@ impl Vim { } fn clear_operator(&mut self, cx: &mut ViewContext) { - self.take_count(cx); + Vim::take_count(cx); self.selected_register.take(); self.operator_stack.clear(); self.sync_vim_settings(cx); diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 47aa618b5c0398..8d2b31a1de983c 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -308,7 +308,7 @@ impl Vim { if let Some(Operator::Object { around }) = self.active_operator() { self.pop_operator(cx); let current_mode = self.mode; - let target_mode = object.target_visual_mode(current_mode); + let target_mode = object.target_visual_mode(current_mode, around); if target_mode != current_mode { self.switch_mode(target_mode, true, cx); } @@ -538,9 +538,8 @@ impl Vim { } pub fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { - let count = self - .take_count(cx) - .unwrap_or_else(|| if self.mode.is_visual() { 1 } else { 2 }); + let count = + Vim::take_count(cx).unwrap_or_else(|| if self.mode.is_visual() { 1 } else { 2 }); self.update_editor(cx, |_, editor, cx| { editor.set_clip_at_line_ends(false, cx); for _ in 0..count { @@ -556,9 +555,8 @@ impl Vim { } pub fn select_previous(&mut self, _: &SelectPrevious, cx: &mut ViewContext) { - let count = self - .take_count(cx) - .unwrap_or_else(|| if self.mode.is_visual() { 1 } else { 2 }); + let count = + Vim::take_count(cx).unwrap_or_else(|| if self.mode.is_visual() { 1 } else { 2 }); self.update_editor(cx, |_, editor, cx| { for _ in 0..count { if editor @@ -573,7 +571,7 @@ impl Vim { } pub fn select_match(&mut self, direction: Direction, cx: &mut ViewContext) { - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); let Some(pane) = self.pane(cx) else { return; }; diff --git a/crates/vim/test_data/test_unmatched_backward.json b/crates/vim/test_data/test_unmatched_backward.json new file mode 100644 index 00000000000000..bb3825dcd23fa2 --- /dev/null +++ b/crates/vim/test_data/test_unmatched_backward.json @@ -0,0 +1,24 @@ +{"Put":{"state":"func (a string) {\n do(something(with.anˇd_arrays[0, 2]))\n}"}} +{"Key":"["} +{"Key":"{"} +{"Get":{"state":"func (a string) ˇ{\n do(something(with.and_arrays[0, 2]))\n}","mode":"Normal"}} +{"Put":{"state":"func (a string) {\n do(somethiˇng(with.and_arrays[0, 2]))\n}"}} +{"Key":"["} +{"Key":"("} +{"Get":{"state":"func (a string) {\n doˇ(something(with.and_arrays[0, 2]))\n}","mode":"Normal"}} +{"Put":{"state":"{{}{} ˇ }"}} +{"Key":"["} +{"Key":"{"} +{"Get":{"state":"ˇ{{}{} }","mode":"Normal"}} +{"Put":{"state":"(()() ˇ )"}} +{"Key":"["} +{"Key":"("} +{"Get":{"state":"ˇ(()() )","mode":"Normal"}} +{"Put":{"state":"{\n {()} ˇ\n}"}} +{"Key":"["} +{"Key":"{"} +{"Get":{"state":"ˇ{\n {()} \n}","mode":"Normal"}} +{"Put":{"state":"(\n {()} ˇ\n)"}} +{"Key":"["} +{"Key":"("} +{"Get":{"state":"ˇ(\n {()} \n)","mode":"Normal"}} diff --git a/crates/vim/test_data/test_unmatched_forward.json b/crates/vim/test_data/test_unmatched_forward.json new file mode 100644 index 00000000000000..a6b4a38f290037 --- /dev/null +++ b/crates/vim/test_data/test_unmatched_forward.json @@ -0,0 +1,28 @@ +{"Put":{"state":"func (a string) {\n do(something(with.anˇd_arrays[0, 2]))\n}"}} +{"Key":"]"} +{"Key":"}"} +{"Get":{"state":"func (a string) {\n do(something(with.and_arrays[0, 2]))\nˇ}","mode":"Normal"}} +{"Put":{"state":"func (a string) {\n do(somethiˇng(with.and_arrays[0, 2]))\n}"}} +{"Key":"]"} +{"Key":")"} +{"Get":{"state":"func (a string) {\n do(something(with.and_arrays[0, 2])ˇ)\n}","mode":"Normal"}} +{"Put":{"state":"func (a string) { a((b, cˇ))}"}} +{"Key":"]"} +{"Key":")"} +{"Get":{"state":"func (a string) { a((b, c)ˇ)}","mode":"Normal"}} +{"Put":{"state":"{ˇ {}{}}"}} +{"Key":"]"} +{"Key":"}"} +{"Get":{"state":"{ {}{}ˇ}","mode":"Normal"}} +{"Put":{"state":"(ˇ ()())"}} +{"Key":"]"} +{"Key":")"} +{"Get":{"state":"( ()()ˇ)","mode":"Normal"}} +{"Put":{"state":"{\n ˇ {()}\n}"}} +{"Key":"]"} +{"Key":"}"} +{"Get":{"state":"{\n {()}\nˇ}","mode":"Normal"}} +{"Put":{"state":"(\n ˇ {()}\n)"}} +{"Key":"]"} +{"Key":")"} +{"Get":{"state":"(\n {()}\nˇ)","mode":"Normal"}} diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index a7bf90dd174b4e..eab3ddc755c6a3 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -42,6 +42,7 @@ pub struct ItemSettings { pub close_position: ClosePosition, pub activate_on_close: ActivateOnClose, pub file_icons: bool, + pub always_show_close_button: bool, } #[derive(Deserialize)] @@ -85,6 +86,10 @@ pub struct ItemSettingsContent { /// /// Default: history pub activate_on_close: Option, + /// Whether to always show the close button on tabs. + /// + /// Default: false + always_show_close_button: Option, } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] @@ -208,7 +213,7 @@ pub trait Item: FocusableView + EventEmitter { fn for_each_project_item( &self, _: &AppContext, - _: &mut dyn FnMut(EntityId, &dyn project::Item), + _: &mut dyn FnMut(EntityId, &dyn project::ProjectItem), ) { } fn is_singleton(&self, _cx: &AppContext) -> bool { @@ -315,7 +320,7 @@ pub trait SerializableItem: Item { _workspace: WeakView, _workspace_id: WorkspaceId, _item_id: ItemId, - _cx: &mut ViewContext, + _cx: &mut WindowContext, ) -> Task>>; fn serialize( @@ -386,7 +391,7 @@ pub trait ItemHandle: 'static + Send { fn for_each_project_item( &self, _: &AppContext, - _: &mut dyn FnMut(EntityId, &dyn project::Item), + _: &mut dyn FnMut(EntityId, &dyn project::ProjectItem), ); fn is_singleton(&self, cx: &AppContext) -> bool; fn boxed_clone(&self) -> Box; @@ -563,7 +568,7 @@ impl ItemHandle for View { fn for_each_project_item( &self, cx: &AppContext, - f: &mut dyn FnMut(EntityId, &dyn project::Item), + f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem), ) { self.read(cx).for_each_project_item(cx, f) } @@ -891,7 +896,7 @@ impl WeakItemHandle for WeakView { } pub trait ProjectItem: Item { - type Item: project::Item; + type Item: project::ProjectItem; fn for_project_item( project: Model, @@ -1032,7 +1037,7 @@ impl WeakFollowableItemHandle for WeakView { #[cfg(any(test, feature = "test-support"))] pub mod test { use super::{Item, ItemEvent, SerializableItem, TabContentParams}; - use crate::{ItemId, ItemNavHistory, Pane, Workspace, WorkspaceId}; + use crate::{ItemId, ItemNavHistory, Workspace, WorkspaceId}; use gpui::{ AnyElement, AppContext, Context as _, EntityId, EventEmitter, FocusableView, InteractiveElement, IntoElement, Model, Render, SharedString, Task, View, ViewContext, @@ -1040,10 +1045,12 @@ pub mod test { }; use project::{Project, ProjectEntryId, ProjectPath, WorktreeId}; use std::{any::Any, cell::Cell, path::Path}; + use ui::WindowContext; pub struct TestProjectItem { pub entry_id: Option, pub project_path: Option, + pub is_dirty: bool, } pub struct TestItem { @@ -1064,7 +1071,7 @@ pub mod test { focus_handle: gpui::FocusHandle, } - impl project::Item for TestProjectItem { + impl project::ProjectItem for TestProjectItem { fn try_open( _project: &Model, _path: &ProjectPath, @@ -1072,7 +1079,6 @@ pub mod test { ) -> Option>>> { None } - fn entry_id(&self, _: &AppContext) -> Option { self.entry_id } @@ -1080,6 +1086,10 @@ pub mod test { fn project_path(&self, _: &AppContext) -> Option { self.project_path.clone() } + + fn is_dirty(&self) -> bool { + self.is_dirty + } } pub enum TestItemEvent { @@ -1096,6 +1106,7 @@ pub mod test { cx.new_model(|_| Self { entry_id, project_path, + is_dirty: false, }) } @@ -1103,6 +1114,7 @@ pub mod test { cx.new_model(|_| Self { project_path: None, entry_id: None, + is_dirty: false, }) } } @@ -1224,7 +1236,7 @@ pub mod test { fn for_each_project_item( &self, cx: &AppContext, - f: &mut dyn FnMut(EntityId, &dyn project::Item), + f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem), ) { self.project_items .iter() @@ -1339,7 +1351,7 @@ pub mod test { _workspace: WeakView, workspace_id: WorkspaceId, _item_id: ItemId, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Task>> { let view = cx.new_view(|cx| Self::new_deserialized(workspace_id, cx)); Task::ready(Ok(view)) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 4eec2f18d17dc8..a2c63addd8ca4c 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -291,7 +291,7 @@ pub struct Pane { can_drop_predicate: Option bool>>, custom_drop_handle: Option) -> ControlFlow<(), ()>>>, - can_split: bool, + can_split_predicate: Option) -> bool>>, should_display_tab_bar: Rc) -> bool>, render_tab_bar_buttons: Rc) -> (Option, Option)>, @@ -303,7 +303,7 @@ pub struct Pane { double_click_dispatch_action: Box, save_modals_spawned: HashSet, pub new_item_context_menu_handle: PopoverMenuHandle, - split_item_context_menu_handle: PopoverMenuHandle, + pub split_item_context_menu_handle: PopoverMenuHandle, pinned_tab_count: usize, } @@ -411,7 +411,7 @@ impl Pane { project, can_drop_predicate, custom_drop_handle: None, - can_split: true, + can_split_predicate: None, should_display_tab_bar: Rc::new(|cx| TabBarSettings::get_global(cx).show), render_tab_bar_buttons: Rc::new(move |pane, cx| { if !pane.has_focus(cx) && !pane.context_menu_focused(cx) { @@ -623,9 +623,13 @@ impl Pane { self.should_display_tab_bar = Rc::new(should_display_tab_bar); } - pub fn set_can_split(&mut self, can_split: bool, cx: &mut ViewContext) { - self.can_split = can_split; - cx.notify(); + pub fn set_can_split( + &mut self, + can_split_predicate: Option< + Arc) -> bool + 'static>, + >, + ) { + self.can_split_predicate = can_split_predicate; } pub fn set_can_navigate(&mut self, can_navigate: bool, cx: &mut ViewContext) { @@ -824,9 +828,10 @@ impl Pane { pub fn close_current_preview_item(&mut self, cx: &mut ViewContext) -> Option { let item_idx = self.preview_item_idx()?; + let id = self.preview_item_id()?; let prev_active_item_index = self.active_item_index; - self.remove_item(item_idx, false, false, cx); + self.remove_item(id, false, false, cx); self.active_item_index = prev_active_item_index; if item_idx < self.items.len() { @@ -1291,10 +1296,12 @@ impl Pane { ) -> Task> { // Find the items to close. let mut items_to_close = Vec::new(); + let mut item_ids_to_close = HashSet::default(); let mut dirty_items = Vec::new(); for item in &self.items { if should_close(item.item_id()) { items_to_close.push(item.boxed_clone()); + item_ids_to_close.insert(item.item_id()); if item.is_dirty(cx) { dirty_items.push(item.boxed_clone()); } @@ -1335,16 +1342,23 @@ impl Pane { } } let mut saved_project_items_ids = HashSet::default(); - for item in items_to_close.clone() { - // Find the item's current index and its set of project item models. Avoid + for item_to_close in items_to_close { + // Find the item's current index and its set of dirty project item models. Avoid // storing these in advance, in case they have changed since this task // was started. - let (item_ix, mut project_item_ids) = pane.update(&mut cx, |pane, cx| { - (pane.index_for_item(&*item), item.project_item_model_ids(cx)) - })?; - let item_ix = if let Some(ix) = item_ix { - ix - } else { + let mut dirty_project_item_ids = Vec::new(); + let Some(item_ix) = pane.update(&mut cx, |pane, cx| { + item_to_close.for_each_project_item( + cx, + &mut |project_item_id, project_item| { + if project_item.is_dirty() { + dirty_project_item_ids.push(project_item_id); + } + }, + ); + pane.index_for_item(&*item_to_close) + })? + else { continue; }; @@ -1352,27 +1366,34 @@ impl Pane { // in the workspace, AND that the user has not already been prompted to save. // If there are any such project entries, prompt the user to save this item. let project = workspace.update(&mut cx, |workspace, cx| { - for item in workspace.items(cx) { - if !items_to_close - .iter() - .any(|item_to_close| item_to_close.item_id() == item.item_id()) - { - let other_project_item_ids = item.project_item_model_ids(cx); - project_item_ids.retain(|id| !other_project_item_ids.contains(id)); + for open_item in workspace.items(cx) { + let open_item_id = open_item.item_id(); + if !item_ids_to_close.contains(&open_item_id) { + let other_project_item_ids = open_item.project_item_model_ids(cx); + dirty_project_item_ids + .retain(|id| !other_project_item_ids.contains(id)); } } workspace.project().clone() })?; - let should_save = project_item_ids + let should_save = dirty_project_item_ids .iter() - .any(|id| saved_project_items_ids.insert(*id)); + .any(|id| saved_project_items_ids.insert(*id)) + // Always propose to save singleton files without any project paths: those cannot be saved via multibuffer, as require a file path selection modal. + || cx + .update(|cx| { + item_to_close.is_dirty(cx) + && item_to_close.is_singleton(cx) + && item_to_close.project_path(cx).is_none() + }) + .unwrap_or(false); if should_save && !Self::save_item( project.clone(), &pane, item_ix, - &*item, + &*item_to_close, save_intent, &mut cx, ) @@ -1383,13 +1404,7 @@ impl Pane { // Remove the item from the pane. pane.update(&mut cx, |pane, cx| { - if let Some(item_ix) = pane - .items - .iter() - .position(|i| i.item_id() == item.item_id()) - { - pane.remove_item(item_ix, false, true, cx); - } + pane.remove_item(item_to_close.item_id(), false, true, cx); }) .ok(); } @@ -1401,11 +1416,14 @@ impl Pane { pub fn remove_item( &mut self, - item_index: usize, + item_id: EntityId, activate_pane: bool, close_pane_if_empty: bool, cx: &mut ViewContext, ) { + let Some(item_index) = self.index_for_item_id(item_id) else { + return; + }; self._remove_item(item_index, activate_pane, close_pane_if_empty, None, cx) } @@ -1595,7 +1613,9 @@ impl Pane { .await? } Ok(1) => { - pane.update(cx, |pane, cx| pane.remove_item(item_ix, false, false, cx))?; + pane.update(cx, |pane, cx| { + pane.remove_item(item.item_id(), false, false, cx) + })?; } _ => return Ok(false), } @@ -1689,9 +1709,7 @@ impl Pane { if let Some(abs_path) = abs_path.await.ok().flatten() { pane.update(cx, |pane, cx| { if let Some(item) = pane.item_for_path(abs_path.clone(), cx) { - if let Some(idx) = pane.index_for_item(&*item) { - pane.remove_item(idx, false, false, cx); - } + pane.remove_item(item.item_id(), false, false, cx); } item.save_as(project, abs_path, cx) @@ -1757,15 +1775,15 @@ impl Pane { entry_id: ProjectEntryId, cx: &mut ViewContext, ) -> Option<()> { - let (item_index_to_delete, item_id) = self.items().enumerate().find_map(|(i, item)| { + let item_id = self.items().find_map(|item| { if item.is_singleton(cx) && item.project_entry_ids(cx).as_slice() == [entry_id] { - Some((i, item.item_id())) + Some(item.item_id()) } else { None } })?; - self.remove_item(item_index_to_delete, false, true, cx); + self.remove_item(item_id, false, true, cx); self.nav_history.remove_item(item_id); Some(()) @@ -1870,7 +1888,7 @@ impl Pane { fn unpin_tab_at(&mut self, ix: usize, cx: &mut ViewContext<'_, Self>) { maybe!({ let pane = cx.view().clone(); - self.pinned_tab_count = self.pinned_tab_count.checked_sub(1).unwrap(); + self.pinned_tab_count = self.pinned_tab_count.checked_sub(1)?; let destination_index = self.pinned_tab_count; let id = self.item_for_index(ix)?.item_id(); @@ -1931,7 +1949,9 @@ impl Pane { }; let icon = item.tab_icon(cx); - let close_side = &ItemSettings::get_global(cx).close_position; + let settings = ItemSettings::get_global(cx); + let close_side = &settings.close_position; + let always_show_close_button = settings.always_show_close_button; let indicator = render_item_indicator(item.boxed_clone(), cx); let item_id = item.item_id(); let is_first_item = ix == 0; @@ -2026,7 +2046,9 @@ impl Pane { end_slot_action = &CloseActiveItem { save_intent: None }; end_slot_tooltip_text = "Close Tab"; IconButton::new("close tab", IconName::Close) - .visible_on_hover("") + .when(!always_show_close_button, |button| { + button.visible_on_hover("") + }) .shape(IconButtonShape::Square) .icon_color(Color::Muted) .size(ButtonSize::None) @@ -2071,8 +2093,10 @@ impl Pane { let is_pinned = self.is_tab_pinned(ix); let pane = cx.view().downgrade(); + let menu_context = item.focus_handle(cx); right_click_menu(ix).trigger(tab).menu(move |cx| { let pane = pane.clone(); + let menu_context = menu_context.clone(); ContextMenu::build(cx, move |mut menu, cx| { if let Some(pane) = pane.upgrade() { menu = menu @@ -2251,7 +2275,7 @@ impl Pane { } } - menu + menu.context(menu_context) }) }) } @@ -2384,8 +2408,18 @@ impl Pane { self.zoomed } - fn handle_drag_move(&mut self, event: &DragMoveEvent, cx: &mut ViewContext) { - if !self.can_split { + fn handle_drag_move( + &mut self, + event: &DragMoveEvent, + cx: &mut ViewContext, + ) { + let can_split_predicate = self.can_split_predicate.take(); + let can_split = match &can_split_predicate { + Some(can_split_predicate) => can_split_predicate(self, event.dragged_item(), cx), + None => false, + }; + self.can_split_predicate = can_split_predicate; + if !can_split { return; } @@ -2679,6 +2713,10 @@ impl Pane { }) .collect() } + + pub fn drag_split_direction(&self) -> Option { + self.drag_split_direction + } } impl FocusableView for Pane { @@ -3705,11 +3743,41 @@ mod tests { assert_item_labels(&pane, [], cx); + add_labeled_item(&pane, "A", true, cx).update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new(1, "A.txt", cx)) + }); + add_labeled_item(&pane, "B", true, cx).update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new(2, "B.txt", cx)) + }); + add_labeled_item(&pane, "C", true, cx).update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new(3, "C.txt", cx)) + }); + assert_item_labels(&pane, ["A^", "B^", "C*^"], cx); + + let save = pane + .update(cx, |pane, cx| { + pane.close_all_items( + &CloseAllItems { + save_intent: None, + close_pinned: false, + }, + cx, + ) + }) + .unwrap(); + + cx.executor().run_until_parked(); + cx.simulate_prompt_answer(2); + save.await.unwrap(); + assert_item_labels(&pane, [], cx); + add_labeled_item(&pane, "A", true, cx); add_labeled_item(&pane, "B", true, cx); add_labeled_item(&pane, "C", true, cx); assert_item_labels(&pane, ["A^", "B^", "C*^"], cx); - let save = pane .update(cx, |pane, cx| { pane.close_all_items( @@ -3724,8 +3792,11 @@ mod tests { cx.executor().run_until_parked(); cx.simulate_prompt_answer(2); + cx.executor().run_until_parked(); + cx.simulate_prompt_answer(2); + cx.executor().run_until_parked(); save.await.unwrap(); - assert_item_labels(&pane, [], cx); + assert_item_labels(&pane, ["A*^", "B^", "C^"], cx); } #[gpui::test] @@ -3813,14 +3884,14 @@ mod tests { } // Assert the item label, with the active item label suffixed with a '*' + #[track_caller] fn assert_item_labels( pane: &View, expected_states: [&str; COUNT], cx: &mut VisualTestContext, ) { - pane.update(cx, |pane, cx| { - let actual_states = pane - .items + let actual_states = pane.update(cx, |pane, cx| { + pane.items .iter() .enumerate() .map(|(ix, item)| { @@ -3839,12 +3910,11 @@ mod tests { } state }) - .collect::>(); - - assert_eq!( - actual_states, expected_states, - "pane items do not match expectation" - ); - }) + .collect::>() + }); + assert_eq!( + actual_states, expected_states, + "pane items do not match expectation" + ); } } diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 390fa6d174c404..4461e589258628 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -8,8 +8,8 @@ use call::{ActiveCall, ParticipantLocation}; use client::proto::PeerId; use collections::HashMap; use gpui::{ - point, size, AnyView, AnyWeakView, Axis, Bounds, IntoElement, Model, MouseButton, Pixels, - Point, StyleRefinement, View, ViewContext, + point, size, Along, AnyView, AnyWeakView, Axis, Bounds, IntoElement, Model, MouseButton, + Pixels, Point, StyleRefinement, View, ViewContext, }; use parking_lot::Mutex; use project::Project; @@ -27,11 +27,11 @@ const VERTICAL_MIN_SIZE: f32 = 100.; /// Single-pane group is a regular pane. #[derive(Clone)] pub struct PaneGroup { - pub(crate) root: Member, + pub root: Member, } impl PaneGroup { - pub(crate) fn with_root(root: Member) -> Self { + pub fn with_root(root: Member) -> Self { Self { root } } @@ -90,6 +90,30 @@ impl PaneGroup { } } + pub fn resize( + &mut self, + pane: &View, + direction: Axis, + amount: Pixels, + bounds: &Bounds, + ) { + match &mut self.root { + Member::Pane(_) => {} + Member::Axis(axis) => { + let _ = axis.resize(pane, direction, amount, bounds); + } + }; + } + + pub fn reset_pane_sizes(&mut self) { + match &mut self.root { + Member::Pane(_) => {} + Member::Axis(axis) => { + let _ = axis.reset_pane_sizes(); + } + }; + } + pub fn swap(&mut self, from: &View, to: &View) { match &mut self.root { Member::Pane(_) => {} @@ -98,7 +122,7 @@ impl PaneGroup { } #[allow(clippy::too_many_arguments)] - pub(crate) fn render( + pub fn render( &self, project: &Model, follower_states: &HashMap, @@ -120,19 +144,51 @@ impl PaneGroup { ) } - pub(crate) fn panes(&self) -> Vec<&View> { + pub fn panes(&self) -> Vec<&View> { let mut panes = Vec::new(); self.root.collect_panes(&mut panes); panes } - pub(crate) fn first_pane(&self) -> View { + pub fn first_pane(&self) -> View { self.root.first_pane() } + + pub fn find_pane_in_direction( + &mut self, + active_pane: &View, + direction: SplitDirection, + cx: &WindowContext, + ) -> Option<&View> { + let bounding_box = self.bounding_box_for_pane(active_pane)?; + let cursor = active_pane.read(cx).pixel_position_of_cursor(cx); + let center = match cursor { + Some(cursor) if bounding_box.contains(&cursor) => cursor, + _ => bounding_box.center(), + }; + + let distance_to_next = crate::HANDLE_HITBOX_SIZE; + + let target = match direction { + SplitDirection::Left => { + Point::new(bounding_box.left() - distance_to_next.into(), center.y) + } + SplitDirection::Right => { + Point::new(bounding_box.right() + distance_to_next.into(), center.y) + } + SplitDirection::Up => { + Point::new(center.x, bounding_box.top() - distance_to_next.into()) + } + SplitDirection::Down => { + Point::new(center.x, bounding_box.bottom() + distance_to_next.into()) + } + }; + self.pane_at_pixel_position(target) + } } -#[derive(Clone)] -pub(crate) enum Member { +#[derive(Debug, Clone)] +pub enum Member { Axis(PaneAxis), Pane(View), } @@ -335,8 +391,8 @@ impl Member { } } -#[derive(Clone)] -pub(crate) struct PaneAxis { +#[derive(Debug, Clone)] +pub struct PaneAxis { pub axis: Axis, pub members: Vec, pub flexes: Arc>>, @@ -445,6 +501,125 @@ impl PaneAxis { } } + fn reset_pane_sizes(&self) { + *self.flexes.lock() = vec![1.; self.members.len()]; + for member in self.members.iter() { + if let Member::Axis(axis) = member { + axis.reset_pane_sizes(); + } + } + } + + fn resize( + &mut self, + pane: &View, + axis: Axis, + amount: Pixels, + bounds: &Bounds, + ) -> Option { + let container_size = self + .bounding_boxes + .lock() + .iter() + .filter_map(|e| *e) + .reduce(|acc, e| acc.union(&e)) + .unwrap_or(*bounds) + .size; + + let found_pane = self + .members + .iter() + .any(|member| matches!(member, Member::Pane(p) if p == pane)); + + if found_pane && self.axis != axis { + return Some(false); // pane found but this is not the correct axis direction + } + let mut found_axis_index: Option = None; + if !found_pane { + for (i, pa) in self.members.iter_mut().enumerate() { + if let Member::Axis(pa) = pa { + if let Some(done) = pa.resize(pane, axis, amount, bounds) { + if done { + return Some(true); // pane found and operations already done + } else if self.axis != axis { + return Some(false); // pane found but this is not the correct axis direction + } else { + found_axis_index = Some(i); // pane found and this is correct direction + } + } + } + } + found_axis_index?; // no pane found + } + + let min_size = match axis { + Axis::Horizontal => px(HORIZONTAL_MIN_SIZE), + Axis::Vertical => px(VERTICAL_MIN_SIZE), + }; + let mut flexes = self.flexes.lock(); + + let ix = if found_pane { + self.members.iter().position(|m| { + if let Member::Pane(p) = m { + p == pane + } else { + false + } + }) + } else { + found_axis_index + }; + + if ix.is_none() { + return Some(true); + } + + let ix = ix.unwrap_or(0); + + let size = move |ix, flexes: &[f32]| { + container_size.along(axis) * (flexes[ix] / flexes.len() as f32) + }; + + // Don't allow resizing to less than the minimum size, if elements are already too small + if min_size - px(1.) > size(ix, flexes.as_slice()) { + return Some(true); + } + + let flex_changes = |pixel_dx, target_ix, next: isize, flexes: &[f32]| { + let flex_change = flexes.len() as f32 * pixel_dx / container_size.along(axis); + let current_target_flex = flexes[target_ix] + flex_change; + let next_target_flex = flexes[(target_ix as isize + next) as usize] - flex_change; + (current_target_flex, next_target_flex) + }; + + let apply_changes = + |current_ix: usize, proposed_current_pixel_change: Pixels, flexes: &mut [f32]| { + let next_target_size = Pixels::max( + size(current_ix + 1, flexes) - proposed_current_pixel_change, + min_size, + ); + let current_target_size = Pixels::max( + size(current_ix, flexes) + size(current_ix + 1, flexes) - next_target_size, + min_size, + ); + + let current_pixel_change = current_target_size - size(current_ix, flexes); + + let (current_target_flex, next_target_flex) = + flex_changes(current_pixel_change, current_ix, 1, flexes); + + flexes[current_ix] = current_target_flex; + flexes[current_ix + 1] = next_target_flex; + }; + + if ix + 1 == flexes.len() { + apply_changes(ix - 1, -1.0 * amount, flexes.as_mut_slice()); + } else { + apply_changes(ix, amount, flexes.as_mut_slice()); + } + Some(true) + } + fn swap(&mut self, from: &View, to: &View) { for member in self.members.iter_mut() { match member { @@ -625,8 +800,15 @@ impl SplitDirection { } } -mod element { +#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +pub enum ResizeIntent { + Lengthen, + Shorten, + Widen, + Narrow, +} +mod element { use std::mem; use std::{cell::RefCell, iter, rc::Rc, sync::Arc}; diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index a2510b8bec6bf5..7a368ee441180d 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -473,7 +473,7 @@ impl SerializedPane { })?; } pane.update(cx, |pane, _| { - pane.set_pinned_count(self.pinned_count); + pane.set_pinned_count(self.pinned_count.min(items.len())); })?; anyhow::Ok(items) diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index 00a00780329fbf..585b2700b4a237 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -64,14 +64,14 @@ impl Render for StatusBar { impl StatusBar { fn render_left_tools(&self, cx: &mut ViewContext) -> impl IntoElement { h_flex() - .gap(DynamicSpacing::Base08.rems(cx)) + .gap(DynamicSpacing::Base04.rems(cx)) .overflow_x_hidden() .children(self.left_items.iter().map(|item| item.to_any())) } fn render_right_tools(&self, cx: &mut ViewContext) -> impl IntoElement { h_flex() - .gap(DynamicSpacing::Base08.rems(cx)) + .gap(DynamicSpacing::Base04.rems(cx)) .children(self.right_items.iter().rev().map(|item| item.to_any())) } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 42db3183bd8189..c5de8822dccf8e 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -97,7 +97,7 @@ use ui::{ IntoElement, ParentElement as _, Pixels, SharedString, Styled as _, ViewContext, VisualContext as _, WindowContext, }; -use util::{ResultExt, TryFutureExt}; +use util::{paths::SanitizedPath, ResultExt, TryFutureExt}; use uuid::Uuid; pub use workspace_settings::{ AutosaveSetting, RestoreOnStartupBehavior, TabBarSettings, WorkspaceSettings, @@ -391,12 +391,12 @@ impl Global for ProjectItemOpeners {} pub fn register_project_item(cx: &mut AppContext) { let builders = cx.default_global::(); builders.push(|project, project_path, cx| { - let project_item = ::try_open(project, project_path, cx)?; + let project_item = ::try_open(project, project_path, cx)?; let project = project.clone(); Some(cx.spawn(|cx| async move { let project_item = project_item.await?; let project_entry_id: Option = - project_item.read_with(&cx, project::Item::entry_id)?; + project_item.read_with(&cx, project::ProjectItem::entry_id)?; let build_workspace_item = Box::new(|cx: &mut ViewContext| { Box::new(cx.new_view(|cx| I::for_project_item(project, project_item, cx))) as Box @@ -777,7 +777,7 @@ pub struct ViewId { pub id: u64, } -struct FollowerState { +pub struct FollowerState { center_pane: View, dock_pane: Option>, active_view_id: Option, @@ -887,14 +887,16 @@ impl Workspace { let pane_history_timestamp = Arc::new(AtomicUsize::new(0)); let center_pane = cx.new_view(|cx| { - Pane::new( + let mut center_pane = Pane::new( weak_handle.clone(), project.clone(), pane_history_timestamp.clone(), None, NewFile.boxed_clone(), cx, - ) + ); + center_pane.set_can_split(Some(Arc::new(|_, _, _| true))); + center_pane }); cx.subscribe(¢er_pane, Self::handle_pane_event).detach(); @@ -2022,7 +2024,7 @@ impl Workspace { }; let this = this.clone(); - let abs_path = abs_path.clone(); + let abs_path: Arc = SanitizedPath::from(abs_path.clone()).into(); let fs = fs.clone(); let pane = pane.clone(); let task = cx.spawn(move |mut cx| async move { @@ -2031,7 +2033,7 @@ impl Workspace { 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() { + let entry_id = if abs_path.as_ref() == worktree_abs_path.as_ref() { worktree.root_entry() } else { abs_path @@ -2464,14 +2466,16 @@ impl Workspace { fn add_pane(&mut self, cx: &mut ViewContext) -> View { let pane = cx.new_view(|cx| { - Pane::new( + let mut pane = Pane::new( self.weak_handle(), self.project.clone(), self.pane_history_timestamp.clone(), None, NewFile.boxed_clone(), cx, - ) + ); + pane.set_can_split(Some(Arc::new(|_, _, _| true))); + pane }); cx.subscribe(&pane, Self::handle_pane_event).detach(); self.panes.push(pane.clone()); @@ -2717,7 +2721,7 @@ impl Workspace { where T: ProjectItem, { - use project::Item as _; + use project::ProjectItem as _; let project_item = project_item.read(cx); let entry_id = project_item.entry_id(cx); let project_path = project_item.project_path(cx); @@ -2946,35 +2950,18 @@ impl Workspace { } } + pub fn bounding_box_for_pane(&self, pane: &View) -> Option> { + self.center.bounding_box_for_pane(pane) + } + pub fn find_pane_in_direction( &mut self, direction: SplitDirection, cx: &WindowContext, ) -> Option> { - let bounding_box = self.center.bounding_box_for_pane(&self.active_pane)?; - let cursor = self.active_pane.read(cx).pixel_position_of_cursor(cx); - let center = match cursor { - Some(cursor) if bounding_box.contains(&cursor) => cursor, - _ => bounding_box.center(), - }; - - let distance_to_next = pane_group::HANDLE_HITBOX_SIZE; - - let target = match direction { - SplitDirection::Left => { - Point::new(bounding_box.left() - distance_to_next.into(), center.y) - } - SplitDirection::Right => { - Point::new(bounding_box.right() + distance_to_next.into(), center.y) - } - SplitDirection::Up => { - Point::new(center.x, bounding_box.top() - distance_to_next.into()) - } - SplitDirection::Down => { - Point::new(center.x, bounding_box.bottom() + distance_to_next.into()) - } - }; - self.center.pane_at_pixel_position(target).cloned() + self.center + .find_pane_in_direction(&self.active_pane, direction, cx) + .cloned() } pub fn swap_pane_in_direction( @@ -2988,6 +2975,17 @@ impl Workspace { } } + pub fn resize_pane(&mut self, axis: gpui::Axis, amount: Pixels, cx: &mut ViewContext) { + self.center + .resize(&self.active_pane.clone(), axis, amount, &self.bounds); + cx.notify(); + } + + pub fn reset_pane_sizes(&mut self, cx: &mut ViewContext) { + self.center.reset_pane_sizes(); + cx.notify(); + } + fn handle_pane_focused(&mut self, pane: View, cx: &mut ViewContext) { // This is explicitly hoisted out of the following check for pane identity as // terminal panel panes are not registered as a center panes. @@ -3725,7 +3723,7 @@ impl Workspace { let mut new_item = task.await?; pane.update(cx, |pane, cx| { - let mut item_ix_to_remove = None; + let mut item_to_remove = None; for (ix, item) in pane.items().enumerate() { if let Some(item) = item.to_followable_item_handle(cx) { match new_item.dedup(item.as_ref(), cx) { @@ -3735,7 +3733,7 @@ impl Workspace { break; } Some(item::Dedup::ReplaceExisting) => { - item_ix_to_remove = Some(ix); + item_to_remove = Some((ix, item.item_id())); break; } None => {} @@ -3743,8 +3741,8 @@ impl Workspace { } } - if let Some(ix) = item_ix_to_remove { - pane.remove_item(ix, false, false, cx); + if let Some((ix, id)) = item_to_remove { + pane.remove_item(id, false, false, cx); pane.add_item(new_item.boxed_clone(), false, false, Some(ix), cx); } })?; @@ -4146,30 +4144,30 @@ impl Workspace { let left_dock = this.left_dock.read(cx); let left_visible = left_dock.is_open(); let left_active_panel = left_dock - .visible_panel() + .active_panel() .map(|panel| panel.persistent_name().to_string()); let left_dock_zoom = left_dock - .visible_panel() + .active_panel() .map(|panel| panel.is_zoomed(cx)) .unwrap_or(false); let right_dock = this.right_dock.read(cx); let right_visible = right_dock.is_open(); let right_active_panel = right_dock - .visible_panel() + .active_panel() .map(|panel| panel.persistent_name().to_string()); let right_dock_zoom = right_dock - .visible_panel() + .active_panel() .map(|panel| panel.is_zoomed(cx)) .unwrap_or(false); let bottom_dock = this.bottom_dock.read(cx); let bottom_visible = bottom_dock.is_open(); let bottom_active_panel = bottom_dock - .visible_panel() + .active_panel() .map(|panel| panel.persistent_name().to_string()); let bottom_dock_zoom = bottom_dock - .visible_panel() + .active_panel() .map(|panel| panel.is_zoomed(cx)) .unwrap_or(false); @@ -4576,6 +4574,10 @@ impl Workspace { let window = cx.window_handle().downcast::()?; cx.read_window(&window, |workspace, _| workspace).ok() } + + pub fn zoomed_item(&self) -> Option<&AnyWeakView> { + self.zoomed.as_ref() + } } fn leader_border_for_pane( @@ -6420,24 +6422,26 @@ mod tests { let item1 = cx.new_view(|cx| { TestItem::new(cx) .with_dirty(true) - .with_project_items(&[TestProjectItem::new(1, "1.txt", cx)]) + .with_project_items(&[dirty_project_item(1, "1.txt", cx)]) }); let item2 = cx.new_view(|cx| { TestItem::new(cx) .with_dirty(true) .with_conflict(true) - .with_project_items(&[TestProjectItem::new(2, "2.txt", cx)]) + .with_project_items(&[dirty_project_item(2, "2.txt", cx)]) }); let item3 = cx.new_view(|cx| { TestItem::new(cx) .with_dirty(true) .with_conflict(true) - .with_project_items(&[TestProjectItem::new(3, "3.txt", cx)]) + .with_project_items(&[dirty_project_item(3, "3.txt", cx)]) }); let item4 = cx.new_view(|cx| { - TestItem::new(cx) - .with_dirty(true) - .with_project_items(&[TestProjectItem::new_untitled(cx)]) + TestItem::new(cx).with_dirty(true).with_project_items(&[{ + let project_item = TestProjectItem::new_untitled(cx); + project_item.update(cx, |project_item, _| project_item.is_dirty = true); + project_item + }]) }); let pane = workspace.update(cx, |workspace, cx| { workspace.add_item_to_active_pane(Box::new(item1.clone()), None, true, cx); @@ -6529,7 +6533,7 @@ mod tests { cx.new_view(|cx| { TestItem::new(cx) .with_dirty(true) - .with_project_items(&[TestProjectItem::new( + .with_project_items(&[dirty_project_item( project_entry_id, &format!("{project_entry_id}.txt"), cx, @@ -6711,6 +6715,9 @@ mod tests { }) }); item.is_dirty = true; + for project_item in &mut item.project_items { + project_item.update(cx, |project_item, _| project_item.is_dirty = true); + } }); pane.update(cx, |pane, cx| { @@ -7409,6 +7416,434 @@ mod tests { }); } + #[gpui::test] + async fn test_no_save_prompt_when_multi_buffer_dirty_items_closed(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); + let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); + + let dirty_regular_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("1.txt") + .with_project_items(&[dirty_project_item(1, "1.txt", cx)]) + }); + let dirty_regular_buffer_2 = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("2.txt") + .with_project_items(&[dirty_project_item(2, "2.txt", cx)]) + }); + let dirty_multi_buffer_with_both = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_singleton(false) + .with_label("Fake Project Search") + .with_project_items(&[ + dirty_regular_buffer.read(cx).project_items[0].clone(), + dirty_regular_buffer_2.read(cx).project_items[0].clone(), + ]) + }); + let multi_buffer_with_both_files_id = dirty_multi_buffer_with_both.item_id(); + workspace.update(cx, |workspace, cx| { + workspace.add_item( + pane.clone(), + Box::new(dirty_regular_buffer.clone()), + None, + false, + false, + cx, + ); + workspace.add_item( + pane.clone(), + Box::new(dirty_regular_buffer_2.clone()), + None, + false, + false, + cx, + ); + workspace.add_item( + pane.clone(), + Box::new(dirty_multi_buffer_with_both.clone()), + None, + false, + false, + cx, + ); + }); + + pane.update(cx, |pane, cx| { + pane.activate_item(2, true, true, cx); + assert_eq!( + pane.active_item().unwrap().item_id(), + multi_buffer_with_both_files_id, + "Should select the multi buffer in the pane" + ); + }); + let close_all_but_multi_buffer_task = pane + .update(cx, |pane, cx| { + pane.close_inactive_items( + &CloseInactiveItems { + save_intent: Some(SaveIntent::Save), + close_pinned: true, + }, + cx, + ) + }) + .expect("should have inactive files to close"); + cx.background_executor.run_until_parked(); + assert!( + !cx.has_pending_prompt(), + "Multi buffer still has the unsaved buffer inside, so no save prompt should be shown" + ); + close_all_but_multi_buffer_task + .await + .expect("Closing all buffers but the multi buffer failed"); + pane.update(cx, |pane, cx| { + assert_eq!(dirty_regular_buffer.read(cx).save_count, 0); + assert_eq!(dirty_multi_buffer_with_both.read(cx).save_count, 0); + assert_eq!(dirty_regular_buffer_2.read(cx).save_count, 0); + assert_eq!(pane.items_len(), 1); + assert_eq!( + pane.active_item().unwrap().item_id(), + multi_buffer_with_both_files_id, + "Should have only the multi buffer left in the pane" + ); + assert!( + dirty_multi_buffer_with_both.read(cx).is_dirty, + "The multi buffer containing the unsaved buffer should still be dirty" + ); + }); + + let close_multi_buffer_task = pane + .update(cx, |pane, cx| { + pane.close_active_item( + &CloseActiveItem { + save_intent: Some(SaveIntent::Close), + }, + cx, + ) + }) + .expect("should have the multi buffer to close"); + cx.background_executor.run_until_parked(); + assert!( + cx.has_pending_prompt(), + "Dirty multi buffer should prompt a save dialog" + ); + cx.simulate_prompt_answer(0); + cx.background_executor.run_until_parked(); + close_multi_buffer_task + .await + .expect("Closing the multi buffer failed"); + pane.update(cx, |pane, cx| { + assert_eq!( + dirty_multi_buffer_with_both.read(cx).save_count, + 1, + "Multi buffer item should get be saved" + ); + // Test impl does not save inner items, so we do not assert them + assert_eq!( + pane.items_len(), + 0, + "No more items should be left in the pane" + ); + assert!(pane.active_item().is_none()); + }); + } + + #[gpui::test] + async fn test_no_save_prompt_when_dirty_singleton_buffer_closed_with_a_multi_buffer_containing_it_present_in_the_pane( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); + let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); + + let dirty_regular_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("1.txt") + .with_project_items(&[dirty_project_item(1, "1.txt", cx)]) + }); + let dirty_regular_buffer_2 = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("2.txt") + .with_project_items(&[dirty_project_item(2, "2.txt", cx)]) + }); + let clear_regular_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_label("3.txt") + .with_project_items(&[TestProjectItem::new(3, "3.txt", cx)]) + }); + + let dirty_multi_buffer_with_both = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_singleton(false) + .with_label("Fake Project Search") + .with_project_items(&[ + dirty_regular_buffer.read(cx).project_items[0].clone(), + dirty_regular_buffer_2.read(cx).project_items[0].clone(), + clear_regular_buffer.read(cx).project_items[0].clone(), + ]) + }); + workspace.update(cx, |workspace, cx| { + workspace.add_item( + pane.clone(), + Box::new(dirty_regular_buffer.clone()), + None, + false, + false, + cx, + ); + workspace.add_item( + pane.clone(), + Box::new(dirty_multi_buffer_with_both.clone()), + None, + false, + false, + cx, + ); + }); + + pane.update(cx, |pane, cx| { + pane.activate_item(0, true, true, cx); + assert_eq!( + pane.active_item().unwrap().item_id(), + dirty_regular_buffer.item_id(), + "Should select the dirty singleton buffer in the pane" + ); + }); + let close_singleton_buffer_task = pane + .update(cx, |pane, cx| { + pane.close_active_item(&CloseActiveItem { save_intent: None }, cx) + }) + .expect("should have active singleton buffer to close"); + cx.background_executor.run_until_parked(); + assert!( + !cx.has_pending_prompt(), + "Multi buffer is still in the pane and has the unsaved buffer inside, so no save prompt should be shown" + ); + + close_singleton_buffer_task + .await + .expect("Should not fail closing the singleton buffer"); + pane.update(cx, |pane, cx| { + assert_eq!(dirty_regular_buffer.read(cx).save_count, 0); + assert_eq!( + dirty_multi_buffer_with_both.read(cx).save_count, + 0, + "Multi buffer itself should not be saved" + ); + assert_eq!(dirty_regular_buffer_2.read(cx).save_count, 0); + assert_eq!( + pane.items_len(), + 1, + "A dirty multi buffer should be present in the pane" + ); + assert_eq!( + pane.active_item().unwrap().item_id(), + dirty_multi_buffer_with_both.item_id(), + "Should activate the only remaining item in the pane" + ); + }); + } + + #[gpui::test] + async fn test_save_prompt_when_dirty_multi_buffer_closed_with_some_of_its_dirty_items_not_present_in_the_pane( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); + let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); + + let dirty_regular_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("1.txt") + .with_project_items(&[dirty_project_item(1, "1.txt", cx)]) + }); + let dirty_regular_buffer_2 = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("2.txt") + .with_project_items(&[dirty_project_item(2, "2.txt", cx)]) + }); + let clear_regular_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_label("3.txt") + .with_project_items(&[TestProjectItem::new(3, "3.txt", cx)]) + }); + + let dirty_multi_buffer_with_both = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_singleton(false) + .with_label("Fake Project Search") + .with_project_items(&[ + dirty_regular_buffer.read(cx).project_items[0].clone(), + dirty_regular_buffer_2.read(cx).project_items[0].clone(), + clear_regular_buffer.read(cx).project_items[0].clone(), + ]) + }); + let multi_buffer_with_both_files_id = dirty_multi_buffer_with_both.item_id(); + workspace.update(cx, |workspace, cx| { + workspace.add_item( + pane.clone(), + Box::new(dirty_regular_buffer.clone()), + None, + false, + false, + cx, + ); + workspace.add_item( + pane.clone(), + Box::new(dirty_multi_buffer_with_both.clone()), + None, + false, + false, + cx, + ); + }); + + pane.update(cx, |pane, cx| { + pane.activate_item(1, true, true, cx); + assert_eq!( + pane.active_item().unwrap().item_id(), + multi_buffer_with_both_files_id, + "Should select the multi buffer in the pane" + ); + }); + let _close_multi_buffer_task = pane + .update(cx, |pane, cx| { + pane.close_active_item(&CloseActiveItem { save_intent: None }, cx) + }) + .expect("should have active multi buffer to close"); + cx.background_executor.run_until_parked(); + assert!( + cx.has_pending_prompt(), + "With one dirty item from the multi buffer not being in the pane, a save prompt should be shown" + ); + } + + #[gpui::test] + async fn test_no_save_prompt_when_dirty_multi_buffer_closed_with_all_of_its_dirty_items_present_in_the_pane( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); + let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); + + let dirty_regular_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("1.txt") + .with_project_items(&[dirty_project_item(1, "1.txt", cx)]) + }); + let dirty_regular_buffer_2 = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("2.txt") + .with_project_items(&[dirty_project_item(2, "2.txt", cx)]) + }); + let clear_regular_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_label("3.txt") + .with_project_items(&[TestProjectItem::new(3, "3.txt", cx)]) + }); + + let dirty_multi_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_singleton(false) + .with_label("Fake Project Search") + .with_project_items(&[ + dirty_regular_buffer.read(cx).project_items[0].clone(), + dirty_regular_buffer_2.read(cx).project_items[0].clone(), + clear_regular_buffer.read(cx).project_items[0].clone(), + ]) + }); + workspace.update(cx, |workspace, cx| { + workspace.add_item( + pane.clone(), + Box::new(dirty_regular_buffer.clone()), + None, + false, + false, + cx, + ); + workspace.add_item( + pane.clone(), + Box::new(dirty_regular_buffer_2.clone()), + None, + false, + false, + cx, + ); + workspace.add_item( + pane.clone(), + Box::new(dirty_multi_buffer.clone()), + None, + false, + false, + cx, + ); + }); + + pane.update(cx, |pane, cx| { + pane.activate_item(2, true, true, cx); + assert_eq!( + pane.active_item().unwrap().item_id(), + dirty_multi_buffer.item_id(), + "Should select the multi buffer in the pane" + ); + }); + let close_multi_buffer_task = pane + .update(cx, |pane, cx| { + pane.close_active_item(&CloseActiveItem { save_intent: None }, cx) + }) + .expect("should have active multi buffer to close"); + cx.background_executor.run_until_parked(); + assert!( + !cx.has_pending_prompt(), + "All dirty items from the multi buffer are in the pane still, no save prompts should be shown" + ); + close_multi_buffer_task + .await + .expect("Closing multi buffer failed"); + pane.update(cx, |pane, cx| { + assert_eq!(dirty_regular_buffer.read(cx).save_count, 0); + assert_eq!(dirty_multi_buffer.read(cx).save_count, 0); + assert_eq!(dirty_regular_buffer_2.read(cx).save_count, 0); + assert_eq!( + pane.items() + .map(|item| item.item_id()) + .sorted() + .collect::>(), + vec![ + dirty_regular_buffer.item_id(), + dirty_regular_buffer_2.item_id(), + ], + "Should have no multi buffer left in the pane" + ); + assert!(dirty_regular_buffer.read(cx).is_dirty); + assert!(dirty_regular_buffer_2.read(cx).is_dirty); + }); + } + mod register_project_item_tests { use ui::Context as _; @@ -7421,7 +7856,7 @@ mod tests { // Model struct TestPngItem {} - impl project::Item for TestPngItem { + impl project::ProjectItem for TestPngItem { fn try_open( _project: &Model, path: &ProjectPath, @@ -7441,6 +7876,10 @@ mod tests { fn project_path(&self, _: &AppContext) -> Option { None } + + fn is_dirty(&self) -> bool { + false + } } impl Item for TestPngItemView { @@ -7483,7 +7922,7 @@ mod tests { // Model struct TestIpynbItem {} - impl project::Item for TestIpynbItem { + impl project::ProjectItem for TestIpynbItem { fn try_open( _project: &Model, path: &ProjectPath, @@ -7503,6 +7942,10 @@ mod tests { fn project_path(&self, _: &AppContext) -> Option { None } + + fn is_dirty(&self) -> bool { + false + } } impl Item for TestIpynbItemView { @@ -7700,4 +8143,12 @@ mod tests { Project::init_settings(cx); }); } + + fn dirty_project_item(id: u64, path: &str, cx: &mut AppContext) -> Model { + let item = TestProjectItem::new(id, path, cx); + item.update(cx, |item, _| { + item.is_dirty = true; + }); + item + } } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 25f3574016d4a9..586344e67d5862 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -66,7 +66,7 @@ use std::{ use sum_tree::{Bias, Edit, SeekTarget, SumTree, TreeMap, TreeSet}; use text::{LineEnding, Rope}; use util::{ - paths::{home_dir, PathMatcher}, + paths::{home_dir, PathMatcher, SanitizedPath}, ResultExt, }; pub use worktree_settings::WorktreeSettings; @@ -148,7 +148,7 @@ pub struct RemoteWorktree { #[derive(Clone)] pub struct Snapshot { id: WorktreeId, - abs_path: Arc, + abs_path: SanitizedPath, root_name: String, root_char_bag: CharBag, entries_by_path: SumTree, @@ -355,7 +355,7 @@ enum ScanState { scanning: bool, }, RootUpdated { - new_path: Option>, + new_path: Option, }, } @@ -653,8 +653,8 @@ impl Worktree { pub fn abs_path(&self) -> Arc { match self { - Worktree::Local(worktree) => worktree.abs_path.clone(), - Worktree::Remote(worktree) => worktree.abs_path.clone(), + Worktree::Local(worktree) => worktree.abs_path.clone().into(), + Worktree::Remote(worktree) => worktree.abs_path.clone().into(), } } @@ -1064,6 +1064,7 @@ impl LocalWorktree { } pub fn contains_abs_path(&self, path: &Path) -> bool { + let path = SanitizedPath::from(path); path.starts_with(&self.abs_path) } @@ -1104,13 +1105,13 @@ impl LocalWorktree { let (scan_states_tx, mut scan_states_rx) = mpsc::unbounded(); let background_scanner = cx.background_executor().spawn({ let abs_path = &snapshot.abs_path; - let abs_path = if cfg!(target_os = "windows") { - abs_path - .canonicalize() - .unwrap_or_else(|_| abs_path.to_path_buf()) - } else { - abs_path.to_path_buf() - }; + #[cfg(target_os = "windows")] + let abs_path = abs_path + .as_path() + .canonicalize() + .unwrap_or_else(|_| abs_path.as_path().to_path_buf()); + #[cfg(not(target_os = "windows"))] + let abs_path = abs_path.as_path().to_path_buf(); let background = cx.background_executor().clone(); async move { let (events, watcher) = fs.watch(&abs_path, FS_WATCH_LATENCY).await; @@ -1173,6 +1174,7 @@ impl LocalWorktree { this.snapshot.git_repositories = Default::default(); this.snapshot.ignores_by_parent_abs_path = Default::default(); let root_name = new_path + .as_path() .file_name() .map_or(String::new(), |f| f.to_string_lossy().to_string()); this.snapshot.update_abs_path(new_path, root_name); @@ -2122,7 +2124,7 @@ impl Snapshot { pub fn new(id: u64, root_name: String, abs_path: Arc) -> Self { Snapshot { id: WorktreeId::from_usize(id as usize), - abs_path, + abs_path: abs_path.into(), root_char_bag: root_name.chars().map(|c| c.to_ascii_lowercase()).collect(), root_name, always_included_entries: Default::default(), @@ -2138,8 +2140,20 @@ impl Snapshot { self.id } + // TODO: + // Consider the following: + // + // ```rust + // let abs_path: Arc = snapshot.abs_path(); // e.g. "C:\Users\user\Desktop\project" + // let some_non_trimmed_path = Path::new("\\\\?\\C:\\Users\\user\\Desktop\\project\\main.rs"); + // // The caller perform some actions here: + // some_non_trimmed_path.strip_prefix(abs_path); // This fails + // some_non_trimmed_path.starts_with(abs_path); // This fails too + // ``` + // + // This is definitely a bug, but it's not clear if we should handle it here or not. pub fn abs_path(&self) -> &Arc { - &self.abs_path + self.abs_path.as_path() } fn build_initial_update(&self, project_id: u64, worktree_id: u64) -> proto::UpdateWorktree { @@ -2179,9 +2193,9 @@ impl Snapshot { return Err(anyhow!("invalid path")); } if path.file_name().is_some() { - Ok(self.abs_path.join(path)) + Ok(self.abs_path.as_path().join(path)) } else { - Ok(self.abs_path.to_path_buf()) + Ok(self.abs_path.as_path().to_path_buf()) } } @@ -2240,7 +2254,7 @@ impl Snapshot { .and_then(|entry| entry.git_status) } - fn update_abs_path(&mut self, abs_path: Arc, root_name: String) { + fn update_abs_path(&mut self, abs_path: SanitizedPath, root_name: String) { self.abs_path = abs_path; if root_name != self.root_name { self.root_char_bag = root_name.chars().map(|c| c.to_ascii_lowercase()).collect(); @@ -2259,7 +2273,7 @@ impl Snapshot { update.removed_entries.len() ); self.update_abs_path( - Arc::from(PathBuf::from(update.abs_path).as_path()), + SanitizedPath::from(PathBuf::from(update.abs_path)), update.root_name, ); @@ -2679,7 +2693,7 @@ impl LocalSnapshot { fn insert_entry(&mut self, mut entry: Entry, fs: &dyn Fs) -> Entry { if entry.is_file() && entry.path.file_name() == Some(&GITIGNORE) { - let abs_path = self.abs_path.join(&entry.path); + let abs_path = self.abs_path.as_path().join(&entry.path); match smol::block_on(build_gitignore(&abs_path, fs)) { Ok(ignore) => { self.ignores_by_parent_abs_path @@ -2833,8 +2847,9 @@ impl LocalSnapshot { if git_state { for ignore_parent_abs_path in self.ignores_by_parent_abs_path.keys() { - let ignore_parent_path = - ignore_parent_abs_path.strip_prefix(&self.abs_path).unwrap(); + let ignore_parent_path = ignore_parent_abs_path + .strip_prefix(self.abs_path.as_path()) + .unwrap(); assert!(self.entry_for_path(ignore_parent_path).is_some()); assert!(self .entry_for_path(ignore_parent_path.join(*GITIGNORE)) @@ -2988,7 +3003,7 @@ impl BackgroundScannerState { } if let Some(ignore) = ignore { - let abs_parent_path = self.snapshot.abs_path.join(parent_path).into(); + let abs_parent_path = self.snapshot.abs_path.as_path().join(parent_path).into(); self.snapshot .ignores_by_parent_abs_path .insert(abs_parent_path, (ignore, false)); @@ -3051,7 +3066,11 @@ impl BackgroundScannerState { } if entry.path.file_name() == Some(&GITIGNORE) { - let abs_parent_path = self.snapshot.abs_path.join(entry.path.parent().unwrap()); + let abs_parent_path = self + .snapshot + .abs_path + .as_path() + .join(entry.path.parent().unwrap()); if let Some((_, needs_update)) = self .snapshot .ignores_by_parent_abs_path @@ -3132,7 +3151,7 @@ impl BackgroundScannerState { return None; } - let dot_git_abs_path = self.snapshot.abs_path.join(&dot_git_path); + let dot_git_abs_path = self.snapshot.abs_path.as_path().join(&dot_git_path); let t0 = Instant::now(); let repository = fs.open_repo(&dot_git_abs_path)?; @@ -3346,9 +3365,9 @@ impl language::LocalFile for File { fn abs_path(&self, cx: &AppContext) -> PathBuf { let worktree_path = &self.worktree.read(cx).as_local().unwrap().abs_path; if self.path.as_ref() == Path::new("") { - worktree_path.to_path_buf() + worktree_path.as_path().to_path_buf() } else { - worktree_path.join(&self.path) + worktree_path.as_path().join(&self.path) } } @@ -3759,7 +3778,7 @@ impl BackgroundScanner { // the git repository in an ancestor directory. Find any gitignore files // in ancestor directories. let root_abs_path = self.state.lock().snapshot.abs_path.clone(); - for (index, ancestor) in root_abs_path.ancestors().enumerate() { + for (index, ancestor) in root_abs_path.as_path().ancestors().enumerate() { if index != 0 { if let Ok(ignore) = build_gitignore(&ancestor.join(*GITIGNORE), self.fs.as_ref()).await @@ -3791,7 +3810,13 @@ impl BackgroundScanner { self.state.lock().insert_git_repository_for_path( Path::new("").into(), ancestor_dot_git.into(), - Some(root_abs_path.strip_prefix(ancestor).unwrap().into()), + Some( + root_abs_path + .as_path() + .strip_prefix(ancestor) + .unwrap() + .into(), + ), self.fs.as_ref(), self.watcher.as_ref(), ); @@ -3810,12 +3835,12 @@ impl BackgroundScanner { 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_abs_path_ignored(&root_abs_path, true) { + .ignore_stack_for_abs_path(root_abs_path.as_path(), true); + if ignore_stack.is_abs_path_ignored(root_abs_path.as_path(), true) { root_entry.is_ignored = true; state.insert_entry(root_entry.clone(), self.fs.as_ref(), self.watcher.as_ref()); } - state.enqueue_scan_dir(root_abs_path, &root_entry, &scan_job_tx); + state.enqueue_scan_dir(root_abs_path.into(), &root_entry, &scan_job_tx); } }; @@ -3865,7 +3890,7 @@ impl BackgroundScanner { { let mut state = self.state.lock(); state.path_prefixes_to_scan.insert(path_prefix.clone()); - state.snapshot.abs_path.join(&path_prefix) + state.snapshot.abs_path.as_path().join(&path_prefix) }; if let Some(abs_path) = self.fs.canonicalize(&abs_path).await.log_err() { @@ -3892,7 +3917,7 @@ impl BackgroundScanner { self.forcibly_load_paths(&request.relative_paths).await; let root_path = self.state.lock().snapshot.abs_path.clone(); - let root_canonical_path = match self.fs.canonicalize(&root_path).await { + let root_canonical_path = match self.fs.canonicalize(root_path.as_path()).await { Ok(path) => path, Err(err) => { log::error!("failed to canonicalize root path: {}", err); @@ -3921,7 +3946,7 @@ impl BackgroundScanner { } self.reload_entries_for_paths( - root_path, + root_path.into(), root_canonical_path, &request.relative_paths, abs_paths, @@ -3934,7 +3959,7 @@ impl BackgroundScanner { async fn process_events(&self, mut abs_paths: Vec) { let root_path = self.state.lock().snapshot.abs_path.clone(); - let root_canonical_path = match self.fs.canonicalize(&root_path).await { + let root_canonical_path = match self.fs.canonicalize(root_path.as_path()).await { Ok(path) => path, Err(err) => { let new_path = self @@ -3944,21 +3969,20 @@ impl BackgroundScanner { .root_file_handle .clone() .and_then(|handle| handle.current_path(&self.fs).log_err()) - .filter(|new_path| **new_path != *root_path); + .map(SanitizedPath::from) + .filter(|new_path| *new_path != root_path); if let Some(new_path) = new_path.as_ref() { log::info!( "root renamed from {} to {}", - root_path.display(), - new_path.display() + root_path.as_path().display(), + new_path.as_path().display() ) } else { log::warn!("root path could not be canonicalized: {}", err); } self.status_updates_tx - .unbounded_send(ScanState::RootUpdated { - new_path: new_path.map(|p| p.into()), - }) + .unbounded_send(ScanState::RootUpdated { new_path }) .ok(); return; } @@ -4053,7 +4077,7 @@ impl BackgroundScanner { let (scan_job_tx, scan_job_rx) = channel::unbounded(); log::debug!("received fs events {:?}", relative_paths); self.reload_entries_for_paths( - root_path, + root_path.into(), root_canonical_path, &relative_paths, abs_paths, @@ -4091,7 +4115,7 @@ impl BackgroundScanner { for ancestor in path.ancestors() { if let Some(entry) = state.snapshot.entry_for_path(ancestor) { if entry.kind == EntryKind::UnloadedDir { - let abs_path = root_path.join(ancestor); + let abs_path = root_path.as_path().join(ancestor); state.enqueue_scan_dir(abs_path.into(), entry, &scan_job_tx); state.paths_to_scan.insert(path.clone()); break; @@ -4595,7 +4619,7 @@ impl BackgroundScanner { snapshot .ignores_by_parent_abs_path .retain(|parent_abs_path, (_, needs_update)| { - if let Ok(parent_path) = parent_abs_path.strip_prefix(&abs_path) { + if let Ok(parent_path) = parent_abs_path.strip_prefix(abs_path.as_path()) { if *needs_update { *needs_update = false; if snapshot.snapshot.entry_for_path(parent_path).is_some() { @@ -4674,7 +4698,10 @@ impl BackgroundScanner { let mut entries_by_id_edits = Vec::new(); let mut entries_by_path_edits = Vec::new(); - let path = job.abs_path.strip_prefix(&snapshot.abs_path).unwrap(); + let path = job + .abs_path + .strip_prefix(snapshot.abs_path.as_path()) + .unwrap(); let repo = snapshot.repo_for_path(path); for mut entry in snapshot.child_entries(path).cloned() { let was_ignored = entry.is_ignored; diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 5003ca1b81b5e0..24fc0dec8b3573 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition = "2021" name = "zed" -version = "0.164.0" +version = "0.165.0" publish = false license = "GPL-3.0-or-later" authors = ["Zed Team "] diff --git a/crates/zed/resources/flatpak/manifest-template.json b/crates/zed/resources/flatpak/manifest-template.json index 7905058f444596..1560027e9fefaf 100644 --- a/crates/zed/resources/flatpak/manifest-template.json +++ b/crates/zed/resources/flatpak/manifest-template.json @@ -32,7 +32,7 @@ "BRANDING_LIGHT": "$BRANDING_LIGHT", "BRANDING_DARK": "$BRANDING_DARK", "APP_CLI": "zed", - "APP_ARGS": "--foreground", + "APP_ARGS": "--foreground %U", "DO_STARTUP_NOTIFY": "false" } }, diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index cfc11ade3f8e40..c5980543564637 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -1124,10 +1124,7 @@ impl ToString for IdType { fn parse_url_arg(arg: &str, cx: &AppContext) -> Result { match std::fs::canonicalize(Path::new(&arg)) { - Ok(path) => Ok(format!( - "file://{}", - path.to_string_lossy().trim_start_matches(r#"\\?\"#) - )), + Ok(path) => Ok(format!("file://{}", path.display())), Err(error) => { if arg.starts_with("file://") || arg.starts_with("zed-cli://") diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 4e3d05d2fbae28..2adb287b4de98d 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -29,7 +29,7 @@ use gpui::{ pub use open_listener::*; use outline_panel::OutlinePanel; use paths::{local_settings_file_relative_path, local_tasks_file_relative_path}; -use project::{DirectoryLister, Item}; +use project::{DirectoryLister, ProjectItem}; use project_panel::ProjectPanel; use quick_action_bar::QuickActionBar; use recent_projects::open_ssh_project; diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index 85090a1b979fb9..bfcd3fa39122d9 100644 --- a/crates/zed/src/zed/quick_action_bar.rs +++ b/crates/zed/src/zed/quick_action_bar.rs @@ -91,6 +91,7 @@ impl Render for QuickActionBar { inlay_hints_enabled, supports_inlay_hints, git_blame_inline_enabled, + show_git_blame_gutter, auto_signature_help_enabled, ) = { let editor = editor.read(cx); @@ -98,6 +99,7 @@ impl Render for QuickActionBar { let inlay_hints_enabled = editor.inlay_hints_enabled(); let supports_inlay_hints = editor.supports_inlay_hints(cx); let git_blame_inline_enabled = editor.git_blame_inline_enabled(); + let show_git_blame_gutter = editor.show_git_blame_gutter(); let auto_signature_help_enabled = editor.auto_signature_help_enabled(cx); ( @@ -105,6 +107,7 @@ impl Render for QuickActionBar { inlay_hints_enabled, supports_inlay_hints, git_blame_inline_enabled, + show_git_blame_gutter, auto_signature_help_enabled, ) }; @@ -236,17 +239,17 @@ impl Render for QuickActionBar { } menu = menu.toggleable_entry( - "Inline Git Blame", - git_blame_inline_enabled, + "Selection Menu", + selection_menu_enabled, IconPosition::Start, - Some(editor::actions::ToggleGitBlameInline.boxed_clone()), + Some(editor::actions::ToggleSelectionMenu.boxed_clone()), { let editor = editor.clone(); move |cx| { editor .update(cx, |editor, cx| { - editor.toggle_git_blame_inline( - &editor::actions::ToggleGitBlameInline, + editor.toggle_selection_menu( + &editor::actions::ToggleSelectionMenu, cx, ) }) @@ -256,39 +259,59 @@ impl Render for QuickActionBar { ); menu = menu.toggleable_entry( - "Selection Menu", - selection_menu_enabled, + "Auto Signature Help", + auto_signature_help_enabled, IconPosition::Start, - Some(editor::actions::ToggleSelectionMenu.boxed_clone()), + Some(editor::actions::ToggleAutoSignatureHelp.boxed_clone()), { let editor = editor.clone(); move |cx| { editor .update(cx, |editor, cx| { - editor.toggle_selection_menu( - &editor::actions::ToggleSelectionMenu, + editor.toggle_auto_signature_help_menu( + &editor::actions::ToggleAutoSignatureHelp, cx, - ) + ); }) .ok(); } }, ); + menu = menu.separator(); + menu = menu.toggleable_entry( - "Auto Signature Help", - auto_signature_help_enabled, + "Inline Git Blame", + git_blame_inline_enabled, IconPosition::Start, - Some(editor::actions::ToggleAutoSignatureHelp.boxed_clone()), + Some(editor::actions::ToggleGitBlameInline.boxed_clone()), { let editor = editor.clone(); move |cx| { editor .update(cx, |editor, cx| { - editor.toggle_auto_signature_help_menu( - &editor::actions::ToggleAutoSignatureHelp, + editor.toggle_git_blame_inline( + &editor::actions::ToggleGitBlameInline, cx, - ); + ) + }) + .ok(); + } + }, + ); + + menu = menu.toggleable_entry( + "Column Git Blame", + show_git_blame_gutter, + IconPosition::Start, + Some(editor::actions::ToggleGitBlame.boxed_clone()), + { + let editor = editor.clone(); + move |cx| { + editor + .update(cx, |editor, cx| { + editor + .toggle_git_blame(&editor::actions::ToggleGitBlame, cx) }) .ok(); } diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 5eacf4136ddabb..d4f8c40dbdc710 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -133,6 +133,16 @@ Define extensions which should be installed (`true`) or never installed (`false` } ``` +## Autoscroll on Clicks + +- Description: Whether to scroll when clicking near the edge of the visible text area. +- Setting: `autoscroll_on_clicks` +- Default: `false` + +**Options** + +`boolean` values + ## Auto Update - Description: Whether or not to automatically check for updates. @@ -624,7 +634,8 @@ List of `string` values "close_position": "right", "file_icons": false, "git_status": false, - "activate_on_close": "history" + "activate_on_close": "history", + "always_show_close_button": false }, ``` @@ -688,6 +699,12 @@ List of `string` values } ``` +### Always show the close button + +- Description: Whether to always show the close button on tabs. +- Setting: `always_show_close_button` +- Default: `false` + ## Editor Toolbar - Description: Whether or not to show various elements in the editor toolbar. @@ -1318,19 +1335,19 @@ To override settings for a language, add an entry for that languages name to the The following settings can be overridden for each specific language: -- `enable_language_server` -- `ensure_final_newline_on_save` -- `format_on_save` -- `formatter` -- `hard_tabs` -- `preferred_line_length` -- `remove_trailing_whitespace_on_save` -- `show_inline_completions` -- `show_whitespaces` -- `soft_wrap` -- `tab_size` -- `use_autoclose` -- `always_treat_brackets_as_autoclosed` +- [`enable_language_server`](#enable-language-server) +- [`ensure_final_newline_on_save`](#ensure-final-newline-on-save) +- [`format_on_save`](#format-on-save) +- [`formatter`](#formatter) +- [`hard_tabs`](#hard-tabs) +- [`preferred_line_length`](#preferred-line-length) +- [`remove_trailing_whitespace_on_save`](#remove-trailing-whitespace-on-save) +- [`show_inline_completions`](#show-inline-completions) +- [`show_whitespaces`](#show-whitespaces) +- [`soft_wrap`](#soft-wrap) +- [`tab_size`](#tab-size) +- [`use_autoclose`](#use-autoclose) +- [`always_treat_brackets_as_autoclosed`](#always-treat-brackets-as-autoclosed) These values take in the same options as the root-level settings with the same name. diff --git a/docs/src/development/linux.md b/docs/src/development/linux.md index 5dba44d2f07db6..1505f99e880eff 100644 --- a/docs/src/development/linux.md +++ b/docs/src/development/linux.md @@ -6,11 +6,7 @@ Clone down the [Zed repository](https://github.com/zed-industries/zed). ## Dependencies -- Install [Rust](https://www.rust-lang.org/tools/install). If it's already installed, make sure it's up-to-date: - - ```sh - rustup update - ``` +- Install [rustup](https://www.rust-lang.org/tools/install) - Install the necessary system libraries: diff --git a/docs/src/development/macos.md b/docs/src/development/macos.md index 2fd076b0fad71c..fe15e9f56e027a 100644 --- a/docs/src/development/macos.md +++ b/docs/src/development/macos.md @@ -6,7 +6,8 @@ Clone down the [Zed repository](https://github.com/zed-industries/zed). ## Dependencies -- Install [Rust](https://www.rust-lang.org/tools/install) +- Install [rustup](https://www.rust-lang.org/tools/install) + - Install [Xcode](https://apps.apple.com/us/app/xcode/id497799835?mt=12) from the macOS App Store, or from the [Apple Developer](https://developer.apple.com/download/all/) website. Note this requires a developer account. > Ensure you launch Xcode after installing, and install the macOS components, which is the default option. @@ -24,12 +25,6 @@ Clone down the [Zed repository](https://github.com/zed-industries/zed). sudo xcodebuild -license accept ``` -- Install the Rust wasm toolchain: - - ```sh - rustup target add wasm32-wasip1 - ``` - - Install `cmake` (required by [a dependency](https://docs.rs/wasmtime-c-api-impl/latest/wasmtime_c_api/)) ```sh diff --git a/docs/src/development/windows.md b/docs/src/development/windows.md index f95cfb3ed03544..9cb539366d2727 100644 --- a/docs/src/development/windows.md +++ b/docs/src/development/windows.md @@ -8,21 +8,11 @@ Clone down the [Zed repository](https://github.com/zed-industries/zed). ## Dependencies -- Install [Rust](https://www.rust-lang.org/tools/install). If it's already installed, make sure it's up-to-date: - - ```sh - rustup update - ``` - -- Install the Rust wasm toolchain: - - ```sh - rustup target add wasm32-wasip1 - ``` +- Install [rustup](https://www.rust-lang.org/tools/install) - Install [Visual Studio](https://visualstudio.microsoft.com/downloads/) with the optional components `MSVC v*** - VS YYYY C++ x64/x86 build tools` and `MSVC v*** - VS YYYY C++ x64/x86 Spectre-mitigated libs (latest)` (`v***` is your VS version and `YYYY` is year when your VS was released. Pay attention to the architecture and change it to yours if needed.) - Install Windows 11 or 10 SDK depending on your system, but ensure that at least `Windows 10 SDK version 2104 (10.0.20348.0)` is installed on your machine. You can download it from the [Windows SDK Archive](https://developer.microsoft.com/windows/downloads/windows-sdk/) -- Install [CMake](https://cmake.org/download) +- Install [CMake](https://cmake.org/download) (required by [a dependency](https://docs.rs/wasmtime-c-api-impl/latest/wasmtime_c_api/)) ## Backend dependencies diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md index bdfab5fcde6653..c404d260a0c724 100644 --- a/docs/src/extensions/developing-extensions.md +++ b/docs/src/extensions/developing-extensions.md @@ -9,6 +9,16 @@ Extensions can add the following capabilities to Zed: - [Slash Commands](./slash-commands.md) - [Context Servers](./context-servers.md) +## Developing an Extension Locally + +Before starting to develop an extension for Zed, be sure to [install Rust via rustup](https://www.rust-lang.org/tools/install). + +When developing an extension, you can use it in Zed without needing to publish it by installing it as a _dev extension_. + +From the extensions page, click the `Install Dev Extension` button and select the directory containing your extension. + +If you already have a published extension with the same name installed, your dev extension will override it. + ## Directory Structure of a Zed Extension A Zed extension is a Git repository that contains an `extension.toml`. This file must contain some @@ -75,16 +85,6 @@ impl zed::Extension for MyExtension { zed::register_extension!(MyExtension); ``` -## Developing an Extension Locally - -Before starting to develop an extension for Zed, be sure to [install Rust via rustup](https://www.rust-lang.org/tools/install). - -When developing an extension, you can use it in Zed without needing to publish it by installing it as a _dev extension_. - -From the extensions page, click the `Install Dev Extension` button and select the directory containing your extension. - -If you already have a published extension with the same name installed, your dev extension will override it. - ## Publishing your extension To publish an extension, open a PR to [the `zed-industries/extensions` repo](https://github.com/zed-industries/extensions). diff --git a/docs/src/extensions/languages.md b/docs/src/extensions/languages.md index 0995ed97fdc8d3..fc2c42c74aee5b 100644 --- a/docs/src/extensions/languages.md +++ b/docs/src/extensions/languages.md @@ -69,6 +69,7 @@ several features: - Syntax overrides - Text redactions - Runnable code detection +- Selecting classes, functions, etc. The following sections elaborate on how [Tree-sitter queries](https://tree-sitter.github.io/tree-sitter/using-parsers#query-syntax) enable these features in Zed, using [JSON syntax](https://www.json.org/json-en.html) as a guiding example. @@ -259,6 +260,44 @@ For example, in JavaScript, we also disable auto-closing of single quotes within (comment) @comment.inclusive ``` +### Text objects + +The `textobjects.scm` file defines rules for navigating by text objects. This was added in Zed v0.165 and is currently used only in Vim mode. + +Vim provides two levels of granularity for navigating around files. Section-by-section with `[]` etc., and method-by-method with `]m` etc. Even languages that don't support functions and classes can work well by defining similar concepts. For example CSS defines a rule-set as a method, and a media-query as a class. + +For languages with closures, these typically should not count as functions in Zed. This is best-effort however, as languages like Javascript do not syntactically differentiate syntactically between closures and top-level function declarations. + +For languages with declarations like C, provide queries that match `@class.around` or `@function.around`. The `if` and `ic` text objects will default to these if there is no inside. + +If you are not sure what to put in textobjects.scm, both [nvim-treesitter-textobjects](https://github.com/nvim-treesitter/nvim-treesitter-textobjects), and the [Helix editor](https://github.com/helix-editor/helix) have queries for many languages. You can refer to the Zed [built-in languages](https://github.com/zed-industries/zed/tree/main/crates/languages/src) to see how to adapt these. + +| Capture | Description | Vim mode | +| ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------ | +| @function.around | An entire function definition or equivalent small section of a file. | `[m`, `]m`, `[M`,`]M` motions. `af` text object | +| @function.inside | The function body (the stuff within the braces). | `if` text object | +| @class.around | An entire class definition or equivalent large section of a file. | `[[`, `]]`, `[]`, `][` motions. `ac` text object | +| @class.inside | The contents of a class definition. | `ic` text object | +| @comment.around | An entire comment (e.g. all adjacent line comments, or a block comment) | `gc` text object | +| @comment.inside | The contents of a comment | `igc` text object (rarely supported) | + +For example: + +```scheme +; include only the content of the method in the function +(method_definition + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +; match function.around for declarations with no body +(function_signature_item) @function.around + +; join all adjacent comments into one +(comment)+ @comment.around +``` + ### Text redactions The `redactions.scm` file defines text redaction rules. When collaborating and sharing your screen, it makes sure that certain syntax nodes are rendered in a redacted mode to avoid them from leaking. diff --git a/docs/src/extensions/themes.md b/docs/src/extensions/themes.md index 4737a99a3e9f82..ecdbdace591d81 100644 --- a/docs/src/extensions/themes.md +++ b/docs/src/extensions/themes.md @@ -2,13 +2,13 @@ The `themes` directory in an extension should contain one or more theme files. -Each theme file should adhere to the JSON schema specified at [`https://zed.dev/schema/themes/v0.1.0.json`](https://zed.dev/schema/themes/v0.1.0.json). +Each theme file should adhere to the JSON schema specified at [`https://zed.dev/schema/themes/v0.2.0.json`](https://zed.dev/schema/themes/v0.2.0.json). See [this blog post](https://zed.dev/blog/user-themes-now-in-preview) for more details about creating themes. ## Theme JSON Structure -The structure of a Zed theme is defined in the [Zed Theme JSON Schema](https://zed.dev/schema/themes/v0.1.0.json). +The structure of a Zed theme is defined in the [Zed Theme JSON Schema](https://zed.dev/schema/themes/v0.2.0.json). A Zed theme consists of a Theme Family object including: diff --git a/docs/src/key-bindings.md b/docs/src/key-bindings.md index 68db5174805693..660a80ebd49a2f 100644 --- a/docs/src/key-bindings.md +++ b/docs/src/key-bindings.md @@ -130,7 +130,7 @@ When multiple keybindings have the same keystroke and are active at the same tim The other kind of conflict that arises is when you have two bindings, one of which is a prefix of the other. For example if you have `"ctrl-w":"editor::DeleteToNextWordEnd"` and `"ctrl-w left":"editor::DeleteToEndOfLine"`. -When this happens, and both bindings are active in the current context, Zed will wait for 1 second after you tupe `ctrl-w` to se if you're about to type `left`. If you don't type anything, or if you type a different key, then `DeleteToNextWordEnd` will be triggered. If you do, then `DeleteToEndOfLine` will be triggered. +When this happens, and both bindings are active in the current context, Zed will wait for 1 second after you type `ctrl-w` to see if you're about to type `left`. If you don't type anything, or if you type a different key, then `DeleteToNextWordEnd` will be triggered. If you do, then `DeleteToEndOfLine` will be triggered. ### Non-QWERTY keyboards diff --git a/docs/src/vim.md b/docs/src/vim.md index 254c5a09346ce7..c0a7fed2e2575b 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -79,12 +79,41 @@ The following commands use the language server to help you navigate and refactor ### Treesitter -Treesitter is a powerful tool that Zed uses to understand the structure of your code. These commands help you navigate your code semantically. - -| Command | Default Shortcut | -| ---------------------------- | ---------------- | -| Select a smaller syntax node | `] x` | -| Select a larger syntax node | `[ x` | +Treesitter is a powerful tool that Zed uses to understand the structure of your code. Zed provides motions that change the current cursor position, and text objects that can be used as the target of actions. + +| Command | Default Shortcut | +| ------------------------------- | --------------------------- | +| Go to next/previous method | `] m` / `[ m` | +| Go to next/previous method end | `] M` / `[ M` | +| Go to next/previous section | `] ]` / `[ [` | +| Go to next/previous section end | `] [` / `[ ]` | +| Go to next/previous comment | `] /`, `] *` / `[ /`, `[ *` | +| Select a larger syntax node | `[ x` | +| Select a larger syntax node | `[ x` | + +| Text Objects | Default Shortcut | +| ---------------------------------------------------------- | ---------------- | +| Around a class, definition, etc. | `a c` | +| Inside a class, definition, etc. | `i c` | +| Around a function, method etc. | `a f` | +| Inside a function, method, etc. | `i f` | +| A comment | `g c` | +| An argument, or list item, etc. | `i a` | +| An argument, or list item, etc. (including trailing comma) | `a a` | +| Around an HTML-like tag | `i a` | +| Inside an HTML-like tag | `i a` | +| The current indent level, and one line before and after | `a I` | +| The current indent level, and one line before | `a i` | +| The current indent level | `i i` | + +Note that the definitions for the targets of the `[m` family of motions are the same as the +boundaries defined by `af`. The targets of the `[[` are the same as those defined by `ac`, though +if there are no classes, then functions are also used. Similarly `gc` is used to find `[ /`. `g c` + +The definition of functions, classes and comments is language dependent, and support can be added +to extensions by adding a [`textobjects.scm`]. The definition of arguments and tags operates at +the tree-sitter level, but looks for certain patterns in the parse tree and is not currently configurable +per language. ### Multi cursor diff --git a/flake.lock b/flake.lock index 5666e73569f7dc..ae27b51678cdd2 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1727060013, - "narHash": "sha256-/fC5YlJy4IoAW9GhkJiwyzk0K/gQd9Qi4rRcoweyG9E=", + "lastModified": 1732407143, + "narHash": "sha256-qJOGDT6PACoX+GbNH2PPx2ievlmtT1NVeTB80EkRLys=", "owner": "ipetkov", "repo": "crane", - "rev": "6b40cc876c929bfe1e3a24bf538ce3b5622646ba", + "rev": "f2b4b472983817021d9ffb60838b2b36b9376b20", "type": "github" }, "original": { @@ -15,27 +15,6 @@ "type": "github" } }, - "fenix": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src" - }, - "locked": { - "lastModified": 1727073227, - "narHash": "sha256-1kmkEQmFfGVuPBasqSZrNThqyMDV1SzTalQdRZxtDRs=", - "owner": "nix-community", - "repo": "fenix", - "rev": "88cc292eb3c689073c784d6aecc0edbd47e12881", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, "flake-compat": { "locked": { "lastModified": 1696426674, @@ -53,11 +32,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1726937504, - "narHash": "sha256-bvGoiQBvponpZh8ClUcmJ6QnsNKw0EMrCQJARK3bI1c=", + "lastModified": 1732014248, + "narHash": "sha256-y/MEyuJ5oBWrWAic/14LaIr/u5E0wRVzyYsouYY3W6w=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "9357f4f23713673f310988025d9dc261c20e70c6", + "rev": "23e89b7da85c3640bbc2173fe04f4bd114342367", "type": "github" }, "original": { @@ -70,25 +49,28 @@ "root": { "inputs": { "crane": "crane", - "fenix": "fenix", "flake-compat": "flake-compat", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" } }, - "rust-analyzer-src": { - "flake": false, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, "locked": { - "lastModified": 1726443025, - "narHash": "sha256-nCmG4NJpwI0IoIlYlwtDwVA49yuspA2E6OhfCOmiArQ=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "94b526fc86eaa0e90fb4d54a5ba6313aa1e9b269", + "lastModified": 1732242723, + "narHash": "sha256-NWI8csIK0ujFlFuEXKnoc+7hWoCiEtINK9r48LUUMeU=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "a229311fcb45b88a95fdfa5cecd8349c809a272a", "type": "github" }, "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", + "owner": "oxalica", + "repo": "rust-overlay", "type": "github" } } diff --git a/flake.nix b/flake.nix index 2ee86c446685fc..f797227fba2be3 100644 --- a/flake.nix +++ b/flake.nix @@ -3,60 +3,65 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable"; - fenix = { - url = "github:nix-community/fenix"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; inputs.nixpkgs.follows = "nixpkgs"; }; crane.url = "github:ipetkov/crane"; flake-compat.url = "github:edolstra/flake-compat"; }; - outputs = { - nixpkgs, - crane, - fenix, - ... - }: let - systems = ["x86_64-linux" "aarch64-linux"]; - - overlays = { - fenix = fenix.overlays.default; - rust-toolchain = final: prev: { - rustToolchain = final.fenix.stable.toolchain; - }; - zed-editor = final: prev: { - zed-editor = final.callPackage ./nix/build.nix { - craneLib = (crane.mkLib final).overrideToolchain final.rustToolchain; - rustPlatform = final.makeRustPlatform { - inherit (final.rustToolchain) cargo rustc; + outputs = + { + nixpkgs, + rust-overlay, + crane, + ... + }: + let + systems = [ + "x86_64-linux" + "x86_64-darwin" + "aarch64-linux" + "aarch64-darwin" + ]; + + overlays = { + rust-overlay = rust-overlay.overlays.default; + rust-toolchain = final: prev: { + rustToolchain = final.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + }; + zed-editor = final: prev: { + zed-editor = final.callPackage ./nix/build.nix { + crane = crane.mkLib final; + rustToolchain = final.rustToolchain; }; }; }; - }; - mkPkgs = system: - import nixpkgs { - inherit system; - overlays = builtins.attrValues overlays; - }; + mkPkgs = + system: + import nixpkgs { + inherit system; + overlays = builtins.attrValues overlays; + }; - forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f (mkPkgs system)); - in { - packages = forAllSystems (pkgs: { - zed-editor = pkgs.zed-editor; - default = pkgs.zed-editor; - }); + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f (mkPkgs system)); + in + { + packages = forAllSystems (pkgs: { + zed-editor = pkgs.zed-editor; + default = pkgs.zed-editor; + }); - devShells = forAllSystems (pkgs: { - default = import ./nix/shell.nix {inherit pkgs;}; - }); + devShells = forAllSystems (pkgs: { + default = import ./nix/shell.nix { inherit pkgs; }; + }); - formatter = forAllSystems (pkgs: pkgs.alejandra); + formatter = forAllSystems (pkgs: pkgs.nixfmt-rfc-style); - overlays = - overlays - // { + overlays = overlays // { default = nixpkgs.lib.composeManyExtensions (builtins.attrValues overlays); }; - }; + }; } diff --git a/nix/build.nix b/nix/build.nix index 4782c9a56fda21..e78025dffdef2e 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -1,10 +1,10 @@ { lib, - craneLib, - rustPlatform, + crane, + rustToolchain, + fetchpatch, clang, - llvmPackages_18, - mold-wrapped, + cmake, copyDesktopItems, curl, perl, @@ -22,47 +22,64 @@ wayland, libglvnd, xorg, + stdenv, makeFontsConf, vulkan-loader, envsubst, - stdenvAdapters, + cargo-about, + cargo-bundle, + git, + apple-sdk_15, + darwinMinVersionHook, + makeWrapper, + nodejs_22, nix-gitignore, + withGLES ? false, - cmake, -}: let - includeFilter = path: type: let - baseName = baseNameOf (toString path); - parentDir = dirOf path; - inRootDir = type == "directory" && parentDir == ../.; - in - !(inRootDir && (baseName == "docs" || baseName == ".github" || baseName == "script" || baseName == ".git" || baseName == "target")); - - src = lib.cleanSourceWith { - src = nix-gitignore.gitignoreSource [] ../.; +}: + +assert withGLES -> stdenv.hostPlatform.isLinux; + +let + includeFilter = + path: type: + let + baseName = baseNameOf (toString path); + parentDir = dirOf path; + inRootDir = type == "directory" && parentDir == ../.; + in + !( + inRootDir + && (baseName == "docs" || baseName == ".github" || baseName == ".git" || baseName == "target") + ); + craneLib = crane.overrideToolchain rustToolchain; + commonSrc = lib.cleanSourceWith { + src = nix-gitignore.gitignoreSource [ ] ../.; filter = includeFilter; name = "source"; }; + commonArgs = rec { + pname = "zed-editor"; + version = "nightly"; - stdenv = stdenvAdapters.useMoldLinker llvmPackages_18.stdenv; + src = commonSrc; - commonArgs = - craneLib.crateNameFromCargoToml {cargoToml = ../crates/zed/Cargo.toml;} - // { - inherit src stdenv; - - nativeBuildInputs = [ + nativeBuildInputs = + [ clang + cmake copyDesktopItems curl - mold-wrapped perl pkg-config protobuf - rustPlatform.bindgenHook - cmake - ]; + cargo-about + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ makeWrapper ] + ++ lib.optionals stdenv.hostPlatform.isDarwin [ cargo-bundle ]; - buildInputs = [ + buildInputs = + [ curl fontconfig freetype @@ -71,73 +88,161 @@ sqlite zlib zstd - + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ alsa-lib libxkbcommon wayland xorg.libxcb + ] + ++ lib.optionals stdenv.hostPlatform.isDarwin [ + apple-sdk_15 + (darwinMinVersionHook "10.15") ]; + env = { ZSTD_SYS_USE_PKG_CONFIG = true; FONTCONFIG_FILE = makeFontsConf { fontDirectories = [ - "../assets/fonts/zed-mono" - "../assets/fonts/zed-sans" + "${src}/assets/fonts/plex-mono" + "${src}/assets/fonts/plex-sans" ]; }; - ZED_UPDATE_EXPLANATION = "zed has been installed using nix. Auto-updates have thus been disabled."; + ZED_UPDATE_EXPLANATION = "Zed has been installed using Nix. Auto-updates have thus been disabled."; + RELEASE_VERSION = version; }; - + }; cargoArtifacts = craneLib.buildDepsOnly commonArgs; - - gpu-lib = - if withGLES - then libglvnd - else vulkan-loader; - - zed = craneLib.buildPackage (commonArgs - // { - inherit cargoArtifacts; - cargoExtraArgs = "--package=zed --package=cli"; - buildFeatures = ["gpui/runtime_shaders"]; - doCheck = false; - - RUSTFLAGS = - if withGLES - then "--cfg gles" - else ""; - - postFixup = '' - patchelf --add-rpath ${gpu-lib}/lib $out/libexec/* - patchelf --add-rpath ${wayland}/lib $out/libexec/* - ''; - - postInstall = '' - mkdir -p $out/bin $out/libexec - mv $out/bin/zed $out/libexec/zed-editor - mv $out/bin/cli $out/bin/zed - - install -D crates/zed/resources/app-icon@2x.png $out/share/icons/hicolor/1024x1024@2x/apps/zed.png - install -D crates/zed/resources/app-icon.png $out/share/icons/hicolor/512x512/apps/zed.png - - export DO_STARTUP_NOTIFY="true" - export APP_CLI="zed" - export APP_ICON="zed" - export APP_NAME="Zed" - export APP_ARGS="%U" - mkdir -p "$out/share/applications" - ${lib.getExe envsubst} < "crates/zed/resources/zed.desktop.in" > "$out/share/applications/dev.zed.Zed.desktop" - ''; - }); in - zed - // { - meta = with lib; { +craneLib.buildPackage ( + commonArgs + // rec { + inherit cargoArtifacts; + + patches = + [ + # Zed uses cargo-install to install cargo-about during the script execution. + # We provide cargo-about ourselves and can skip this step. + # Until https://github.com/zed-industries/zed/issues/19971 is fixed, + # we also skip any crate for which the license cannot be determined. + (fetchpatch { + url = "https://raw.githubusercontent.com/NixOS/nixpkgs/1fd02d90c6c097f91349df35da62d36c19359ba7/pkgs/by-name/ze/zed-editor/0001-generate-licenses.patch"; + hash = "sha256-cLgqLDXW1JtQ2OQFLd5UolAjfy7bMoTw40lEx2jA2pk="; + }) + ] + ++ lib.optionals stdenv.hostPlatform.isDarwin [ + # Livekit requires Swift 6 + # We need this until livekit-rust sdk is used + (fetchpatch { + url = "https://raw.githubusercontent.com/NixOS/nixpkgs/1fd02d90c6c097f91349df35da62d36c19359ba7/pkgs/by-name/ze/zed-editor/0002-disable-livekit-darwin.patch"; + hash = "sha256-whZ7RaXv8hrVzWAveU3qiBnZSrvGNEHTuyNhxgMIo5w="; + }) + ]; + + cargoExtraArgs = "--package=zed --package=cli --features=gpui/runtime_shaders"; + + dontUseCmakeConfigure = true; + preBuild = '' + bash script/generate-licenses + ''; + + postFixup = lib.optionalString stdenv.hostPlatform.isLinux '' + patchelf --add-rpath ${gpu-lib}/lib $out/libexec/* + patchelf --add-rpath ${wayland}/lib $out/libexec/* + wrapProgram $out/libexec/zed-editor --suffix PATH : ${lib.makeBinPath [ nodejs_22 ]} + ''; + + RUSTFLAGS = if withGLES then "--cfg gles" else ""; + gpu-lib = if withGLES then libglvnd else vulkan-loader; + + preCheck = '' + export HOME=$(mktemp -d); + ''; + + cargoTestExtraArgs = + "-- " + + lib.concatStringsSep " " ( + [ + # Flaky: unreliably fails on certain hosts (including Hydra) + "--skip=zed::tests::test_window_edit_state_restoring_enabled" + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ + # Fails on certain hosts (including Hydra) for unclear reason + "--skip=test_open_paths_action" + ] + ); + + installPhase = + if stdenv.hostPlatform.isDarwin then + '' + runHook preInstall + + # cargo-bundle expects the binary in target/release + mv target/release/zed target/release/zed + + pushd crates/zed + + # Note that this is GNU sed, while Zed's bundle-mac uses BSD sed + sed -i "s/package.metadata.bundle-stable/package.metadata.bundle/" Cargo.toml + export CARGO_BUNDLE_SKIP_BUILD=true + app_path=$(cargo bundle --release | xargs) + + # We're not using the fork of cargo-bundle, so we must manually append plist extensions + # Remove closing tags from Info.plist (last two lines) + head -n -2 $app_path/Contents/Info.plist > Info.plist + # Append extensions + cat resources/info/*.plist >> Info.plist + # Add closing tags + printf "\n\n" >> Info.plist + mv Info.plist $app_path/Contents/Info.plist + + popd + + mkdir -p $out/Applications $out/bin + # Zed expects git next to its own binary + ln -s ${git}/bin/git $app_path/Contents/MacOS/git + mv target/release/cli $app_path/Contents/MacOS/cli + mv $app_path $out/Applications/ + + # Physical location of the CLI must be inside the app bundle as this is used + # to determine which app to start + ln -s $out/Applications/Zed.app/Contents/MacOS/cli $out/bin/zed + + runHook postInstall + '' + else + '' + runHook preInstall + + mkdir -p $out/bin $out/libexec + cp target/release/zed $out/libexec/zed-editor + cp target/release/cli $out/bin/zed + + install -D ${commonSrc}/crates/zed/resources/app-icon@2x.png $out/share/icons/hicolor/1024x1024@2x/apps/zed.png + install -D ${commonSrc}/crates/zed/resources/app-icon.png $out/share/icons/hicolor/512x512/apps/zed.png + + # extracted from https://github.com/zed-industries/zed/blob/v0.141.2/script/bundle-linux (envsubst) + # and https://github.com/zed-industries/zed/blob/v0.141.2/script/install.sh (final desktop file name) + ( + export DO_STARTUP_NOTIFY="true" + export APP_CLI="zed" + export APP_ICON="zed" + export APP_NAME="Zed" + export APP_ARGS="%U" + mkdir -p "$out/share/applications" + ${lib.getExe envsubst} < "crates/zed/resources/zed.desktop.in" > "$out/share/applications/dev.zed.Zed.desktop" + ) + + runHook postInstall + ''; + + meta = { description = "High-performance, multiplayer code editor from the creators of Atom and Tree-sitter"; homepage = "https://zed.dev"; changelog = "https://zed.dev/releases/preview"; - license = licenses.gpl3Only; + license = lib.licenses.gpl3Only; mainProgram = "zed"; - platforms = platforms.linux; + platforms = lib.platforms.linux ++ lib.platforms.darwin; }; } +) diff --git a/nix/shell.nix b/nix/shell.nix index e0b4018778c87d..75ceb0d8e39ec3 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -1,51 +1,57 @@ -{pkgs ? import {}}: let - stdenv = pkgs.stdenvAdapters.useMoldLinker pkgs.llvmPackages_18.stdenv; +{ + pkgs ? import { }, +}: +let + inherit (pkgs) lib; in - if pkgs.stdenv.isDarwin - then - # See https://github.com/NixOS/nixpkgs/issues/320084 - throw "zed: nix dev-shell isn't supported on darwin yet." - else let - buildInputs = with pkgs; [ - curl - fontconfig - freetype - libgit2 - openssl - sqlite - zlib - zstd - alsa-lib - libxkbcommon - wayland - xorg.libxcb - vulkan-loader - rustToolchain - ]; - in - pkgs.mkShell.override {inherit stdenv;} { - nativeBuildInputs = with pkgs; [ - clang - curl - cmake - perl - pkg-config - protobuf - rustPlatform.bindgenHook - ]; +pkgs.mkShell rec { + packages = [ + pkgs.clang + pkgs.curl + pkgs.cmake + pkgs.perl + pkgs.pkg-config + pkgs.protobuf + pkgs.rustPlatform.bindgenHook + pkgs.rust-analyzer + ]; - inherit buildInputs; + buildInputs = + [ + pkgs.curl + pkgs.fontconfig + pkgs.freetype + pkgs.libgit2 + pkgs.openssl + pkgs.sqlite + pkgs.zlib + pkgs.zstd + pkgs.rustToolchain + ] + ++ lib.optionals pkgs.stdenv.hostPlatform.isLinux [ + pkgs.alsa-lib + pkgs.libxkbcommon + ] + ++ lib.optional pkgs.stdenv.hostPlatform.isDarwin pkgs.apple-sdk_15; - shellHook = '' - export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath buildInputs}:$LD_LIBRARY_PATH" - export PROTOC="${pkgs.protobuf}/bin/protoc" - ''; + # We set SDKROOT and DEVELOPER_DIR to the Xcode ones instead of the nixpkgs ones, + # because we need Swift 6.0 and nixpkgs doesn't have it. + # Xcode is required for development anyways + shellHook = + '' + export LD_LIBRARY_PATH="${lib.makeLibraryPath buildInputs}:$LD_LIBRARY_PATH" + export PROTOC="${pkgs.protobuf}/bin/protoc" + '' + + lib.optionalString pkgs.stdenv.hostPlatform.isDarwin '' + export SDKROOT="/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk"; + export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"; + ''; - FONTCONFIG_FILE = pkgs.makeFontsConf { - fontDirectories = [ - "./assets/fonts/zed-mono" - "./assets/fonts/zed-sans" - ]; - }; - ZSTD_SYS_USE_PKG_CONFIG = true; - } + FONTCONFIG_FILE = pkgs.makeFontsConf { + fontDirectories = [ + "./assets/fonts/zed-mono" + "./assets/fonts/zed-sans" + ]; + }; + ZSTD_SYS_USE_PKG_CONFIG = true; +} diff --git a/script/import-themes b/script/import-themes index ce9ce9ef12f7ee..8f07df2ef3746b 100755 --- a/script/import-themes +++ b/script/import-themes @@ -1,3 +1,3 @@ #!/bin/bash -cargo run -p theme_importer +cargo run -p theme_importer -- "$@" diff --git a/script/install.sh b/script/install.sh index 3f2c690779d3a1..9cd21119b7d934 100755 --- a/script/install.sh +++ b/script/install.sh @@ -125,7 +125,7 @@ linux() { desktop_file_path="$HOME/.local/share/applications/${appid}.desktop" cp "$HOME/.local/zed$suffix.app/share/applications/zed$suffix.desktop" "${desktop_file_path}" sed -i "s|Icon=zed|Icon=$HOME/.local/zed$suffix.app/share/icons/hicolor/512x512/apps/zed.png|g" "${desktop_file_path}" - sed -i "s|Exec=zed|Exec=$HOME/.local/zed$suffix.app/libexec/zed-editor|g" "${desktop_file_path}" + sed -i "s|Exec=zed|Exec=$HOME/.local/zed$suffix.app/bin/zed|g" "${desktop_file_path}" } macos() { diff --git a/script/linux b/script/linux index eecf70f90e876a..7457b8de76a580 100755 --- a/script/linux +++ b/script/linux @@ -37,6 +37,7 @@ if [[ -n $apt ]]; then cmake clang jq + netcat-openbsd git curl gettext-base @@ -67,6 +68,7 @@ yum=$(command -v yum || true) if [[ -n $dnf ]] || [[ -n $yum ]]; then pkg_cmd="${dnf:-${yum}}" deps=( + musl-gcc gcc clang cmake @@ -83,12 +85,14 @@ if [[ -n $dnf ]] || [[ -n $yum ]]; then tar ) # perl used for building openssl-sys crate. See: https://docs.rs/openssl/latest/openssl/ + # openbsd-netcat is unavailable in RHEL8/9 (and nmap-ncat doesn't support sockets) if grep -qP '^ID="(fedora)' /etc/os-release; then deps+=( perl-FindBin perl-IPC-Cmd perl-File-Compare perl-File-Copy + netcat mold ) elif grep -qP '^ID="(rhel|rocky|alma|centos|ol)' /etc/os-release; then @@ -119,7 +123,7 @@ if [[ -n $dnf ]] || [[ -n $yum ]]; then fi fi - $maysudo $pkg_cmd install -y "${deps[@]}" + $maysudo "$pkg_cmd" install -y "${deps[@]}" finalize exit 0 fi @@ -144,6 +148,7 @@ if [[ -n $zyp ]]; then libzstd-devel make mold + netcat-openbsd openssl-devel sqlite3-devel tar @@ -168,6 +173,7 @@ if [[ -n $pacman ]]; then wayland libgit2 libxkbcommon-x11 + openbsd-netcat openssl zstd pkgconf @@ -197,6 +203,7 @@ if [[ -n $xbps ]]; then libxcb-devel libxkbcommon-devel libzstd-devel + openbsd-netcat openssl-devel wayland-devel vulkan-loader @@ -221,6 +228,7 @@ if [[ -n $emerge ]]; then media-libs/alsa-lib media-libs/fontconfig media-libs/vulkan-loader + net-analyzer/openbsd-netcat x11-libs/libxcb x11-libs/libxkbcommon sys-devel/mold diff --git a/script/uninstall.sh b/script/uninstall.sh new file mode 100644 index 00000000000000..3e460b81862b7e --- /dev/null +++ b/script/uninstall.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env sh +set -eu + +# Uninstalls Zed that was installed using the install.sh script + +check_remaining_installations() { + platform="$(uname -s)" + if [ "$platform" = "Darwin" ]; then + # Check for any Zed variants in /Applications + remaining=$(ls -d /Applications/Zed*.app 2>/dev/null | wc -l) + [ "$remaining" -eq 0 ] + else + # Check for any Zed variants in ~/.local + remaining=$(ls -d "$HOME/.local/zed"*.app 2>/dev/null | wc -l) + [ "$remaining" -eq 0 ] + fi +} + +prompt_remove_preferences() { + printf "Do you want to keep your Zed preferences? [Y/n] " + read -r response + case "$response" in + [nN]|[nN][oO]) + rm -rf "$HOME/.config/zed" + echo "Preferences removed." + ;; + *) + echo "Preferences kept." + ;; + esac +} + +main() { + platform="$(uname -s)" + channel="${ZED_CHANNEL:-stable}" + + if [ "$platform" = "Darwin" ]; then + platform="macos" + elif [ "$platform" = "Linux" ]; then + platform="linux" + else + echo "Unsupported platform $platform" + exit 1 + fi + + "$platform" + + echo "Zed has been uninstalled" +} + +linux() { + suffix="" + if [ "$channel" != "stable" ]; then + suffix="-$channel" + fi + + appid="" + db_suffix="stable" + case "$channel" in + stable) + appid="dev.zed.Zed" + db_suffix="stable" + ;; + nightly) + appid="dev.zed.Zed-Nightly" + db_suffix="nightly" + ;; + preview) + appid="dev.zed.Zed-Preview" + db_suffix="preview" + ;; + dev) + appid="dev.zed.Zed-Dev" + db_suffix="dev" + ;; + *) + echo "Unknown release channel: ${channel}. Using stable app ID." + appid="dev.zed.Zed" + db_suffix="stable" + ;; + esac + + # Remove the app directory + rm -rf "$HOME/.local/zed$suffix.app" + + # Remove the binary symlink + rm -f "$HOME/.local/bin/zed" + + # Remove the .desktop file + rm -f "$HOME/.local/share/applications/${appid}.desktop" + + # Remove the database directory for this channel + rm -rf "$HOME/.local/share/zed/db/0-$db_suffix" + + # Remove socket file + rm -f "$HOME/.local/share/zed/zed-$db_suffix.sock" + + # Remove the entire Zed directory if no installations remain + if check_remaining_installations; then + rm -rf "$HOME/.local/share/zed" + prompt_remove_preferences + fi + + rm -rf $HOME/.zed_server +} + +macos() { + app="Zed.app" + db_suffix="stable" + app_id="dev.zed.Zed" + case "$channel" in + nightly) + app="Zed Nightly.app" + db_suffix="nightly" + app_id="dev.zed.Zed-Nightly" + ;; + preview) + app="Zed Preview.app" + db_suffix="preview" + app_id="dev.zed.Zed-Preview" + ;; + dev) + app="Zed Dev.app" + db_suffix="dev" + app_id="dev.zed.Zed-Dev" + ;; + esac + + # Remove the app bundle + if [ -d "/Applications/$app" ]; then + rm -rf "/Applications/$app" + fi + + # Remove the binary symlink + rm -f "$HOME/.local/bin/zed" + + # Remove the database directory for this channel + rm -rf "$HOME/Library/Application Support/Zed/db/0-$db_suffix" + + # Remove app-specific files and directories + rm -rf "$HOME/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments/$app_id.sfl"* + rm -rf "$HOME/Library/Caches/$app_id" + rm -rf "$HOME/Library/HTTPStorages/$app_id" + rm -rf "$HOME/Library/Preferences/$app_id.plist" + rm -rf "$HOME/Library/Saved Application State/$app_id.savedState" + + # Remove the entire Zed directory if no installations remain + if check_remaining_installations; then + rm -rf "$HOME/Library/Application Support/Zed" + rm -rf "$HOME/Library/Logs/Zed" + + prompt_remove_preferences + fi + + rm -rf $HOME/.zed_server +} + +main "$@"