diff --git a/.cargo/config.toml b/.cargo/config.toml index a657ae61b9..043adf6b30 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -13,6 +13,12 @@ rustflags = ["-C", "link-arg=-fuse-ld=mold"] linker = "clang" rustflags = ["-C", "link-arg=-fuse-ld=mold"] +[target.aarch64-apple-darwin] +rustflags = ["-C", "link-args=-Objc -all_load"] + +[target.x86_64-apple-darwin] +rustflags = ["-C", "link-args=-Objc -all_load"] + # This cfg will reduce the size of `windows::core::Error` from 16 bytes to 4 bytes [target.'cfg(target_os = "windows")'] rustflags = ["--cfg", "windows_slim_errors"] diff --git a/.cloudflare/docs-proxy/src/worker.js b/.cloudflare/docs-proxy/src/worker.js index b29ddc00f1..f9f441883a 100644 --- a/.cloudflare/docs-proxy/src/worker.js +++ b/.cloudflare/docs-proxy/src/worker.js @@ -3,15 +3,6 @@ export default { const url = new URL(request.url); url.hostname = "docs-anw.pages.dev"; - // These pages were removed, but may still be served due to Cloudflare's - // [asset retention](https://developers.cloudflare.com/pages/configuration/serving-pages/#asset-retention). - if ( - url.pathname === "/docs/assistant/context-servers" || - url.pathname === "/docs/assistant/model-context-protocol" - ) { - return await fetch("https://zed.dev/404"); - } - let res = await fetch(url, request); if (res.status === 404) { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49881e2e7c..8a19130324 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 @@ -123,7 +129,9 @@ jobs: run: | cargo build --workspace --bins --all-features cargo check -p gpui --features "macos-blade" + cargo check -p workspace cargo build -p remote_server + script/check-rust-livekit-macos linux_tests: timeout-minutes: 60 @@ -155,8 +163,10 @@ jobs: - name: Run tests uses: ./.github/actions/run_tests - - name: Build Zed - run: cargo build -p zed + - name: Build other binaries and features + run: | + cargo build -p zed + cargo check -p workspace build_remote_server: timeout-minutes: 60 diff --git a/.github/workflows/community_update_all_top_ranking_issues.yml b/.github/workflows/community_update_all_top_ranking_issues.yml index af69446462..9642315bb3 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 18f525ab3b..53dcfd1d87 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/.github/workflows/deploy_cloudflare.yml b/.github/workflows/deploy_cloudflare.yml index d6daada6e3..6cc4ea0a33 100644 --- a/.github/workflows/deploy_cloudflare.yml +++ b/.github/workflows/deploy_cloudflare.yml @@ -37,28 +37,28 @@ jobs: mdbook build ./docs --dest-dir=../target/deploy/docs/ - name: Deploy Docs - uses: cloudflare/wrangler-action@05f17c4a695b4d94b57b59997562c6a4624c64e4 # v3 + uses: cloudflare/wrangler-action@6d58852c35a27e6034745c5d0bc373d739014f7f # v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: pages deploy target/deploy --project-name=docs - name: Deploy Install - uses: cloudflare/wrangler-action@05f17c4a695b4d94b57b59997562c6a4624c64e4 # v3 + uses: cloudflare/wrangler-action@6d58852c35a27e6034745c5d0bc373d739014f7f # v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: r2 object put -f script/install.sh zed-open-source-website-assets/install.sh - name: Deploy Docs Workers - uses: cloudflare/wrangler-action@05f17c4a695b4d94b57b59997562c6a4624c64e4 # v3 + uses: cloudflare/wrangler-action@6d58852c35a27e6034745c5d0bc373d739014f7f # v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: deploy .cloudflare/docs-proxy/src/worker.js - name: Deploy Install Workers - uses: cloudflare/wrangler-action@05f17c4a695b4d94b57b59997562c6a4624c64e4 # v3 + uses: cloudflare/wrangler-action@6d58852c35a27e6034745c5d0bc373d739014f7f # v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.gitignore b/.gitignore index d19c5a102a..fc6263eb7e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.direnv +.envrc .idea **/target **/cargo-target diff --git a/Cargo.lock b/Cargo.lock index 91205f214f..0993089333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "activity_indicator" @@ -257,9 +257,9 @@ checksum = "34cd60c5e3152cef0a592f1b296f1cc93715d89d2551d85315828c3a09575ff4" [[package]] name = "anyhow" -version = "1.0.93" +version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" +checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" [[package]] name = "approx" @@ -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]] @@ -383,7 +383,7 @@ dependencies = [ "clock", "collections", "command_palette_hooks", - "context_servers", + "context_server", "ctor", "db", "editor", @@ -455,16 +455,36 @@ name = "assistant2" version = "0.1.0" dependencies = [ "anyhow", + "assistant_tool", + "chrono", + "client", + "collections", "command_palette_hooks", + "context_server", "editor", "feature_flags", + "futures 0.3.31", "gpui", + "language", "language_model", "language_model_selector", + "language_models", + "log", + "markdown", + "picker", + "project", "proto", + "serde", + "serde_json", "settings", + "smol", "theme", + "time", + "time_format", "ui", + "unindent", + "util", + "uuid", "workspace", ] @@ -503,6 +523,20 @@ dependencies = [ "workspace", ] +[[package]] +name = "assistant_tools" +version = "0.1.0" +dependencies = [ + "anyhow", + "assistant_tool", + "chrono", + "gpui", + "schemars", + "serde", + "serde_json", + "workspace", +] + [[package]] name = "async-attributes" version = "1.1.2" @@ -904,20 +938,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" @@ -944,17 +964,18 @@ dependencies = [ [[package]] name = "async-tungstenite" -version = "0.22.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce01ac37fdc85f10a43c43bc582cbd566720357011578a935761075f898baf58" +checksum = "2cca750b12e02c389c1694d35c16539f88b8bbaa5945934fdc1b41a776688589" dependencies = [ + "async-native-tls", "async-std", - "async-tls 0.12.0", + "async-tls", "futures-io", "futures-util", "log", "pin-project-lite", - "tungstenite 0.19.0", + "tungstenite 0.21.0", ] [[package]] @@ -964,7 +985,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", @@ -1134,7 +1155,7 @@ dependencies = [ "fastrand 2.2.0", "hex", "http 0.2.12", - "ring 0.17.8", + "ring", "time", "tokio", "tracing", @@ -1324,7 +1345,7 @@ dependencies = [ "once_cell", "p256", "percent-encoding", - "ring 0.17.8", + "ring", "sha2", "subtle", "time", @@ -1817,15 +1838,15 @@ dependencies = [ [[package]] name = "blake3" -version = "1.5.4" +version = "1.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d82033247fd8e890df8f740e407ad4d038debb9eb1f40533fffb32e7d17dc6f7" +checksum = "b8ee0c1824c4dea5b5f81736aff91bae041d2c07ee1192bec91054e10e3e601e" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", - "constant_time_eq", + "constant_time_eq 0.3.1", ] [[package]] @@ -1954,9 +1975,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", ] @@ -2010,6 +2031,27 @@ dependencies = [ "either", ] +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.11+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "call" version = "0.1.0" @@ -2018,12 +2060,14 @@ dependencies = [ "audio", "client", "collections", + "feature_flags", "fs", "futures 0.3.31", "gpui", "http_client", "language", - "live_kit_client", + "livekit_client", + "livekit_client_macos", "log", "postage", "project", @@ -2157,16 +2201,16 @@ dependencies = [ [[package]] name = "cargo_metadata" -version = "0.19.0" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc309ed89476c8957c50fb818f56fe894db857866c3e163335faa91dc34eb85" +checksum = "8769706aad5d996120af43197bf46ef6ad0fda35216b4505f926a365a232d924" dependencies = [ "camino", "cargo-platform", "semver", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.3", ] [[package]] @@ -2610,6 +2654,7 @@ dependencies = [ "anthropic", "anyhow", "assistant", + "assistant_tool", "async-stripe", "async-trait", "async-tungstenite 0.28.0", @@ -2628,7 +2673,7 @@ dependencies = [ "clock", "collab_ui", "collections", - "context_servers", + "context_server", "ctor", "dashmap 6.1.0", "derive_more", @@ -2650,8 +2695,9 @@ dependencies = [ "jsonwebtoken", "language", "language_model", - "live_kit_client", - "live_kit_server", + "livekit_client", + "livekit_client_macos", + "livekit_server", "log", "lsp", "menu", @@ -2664,7 +2710,7 @@ dependencies = [ "pretty_assertions", "project", "prometheus", - "prost", + "prost 0.9.0", "rand 0.8.5", "recent_projects", "release_channel", @@ -2864,6 +2910,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -2871,12 +2923,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" [[package]] -name = "context_servers" +name = "context_server" version = "0.1.0" dependencies = [ "anyhow", + "assistant_tool", "collections", "command_palette_hooks", + "context_server_settings", "extension", "futures 0.3.31", "gpui", @@ -2884,13 +2938,27 @@ dependencies = [ "parking_lot", "postage", "project", - "schemars", "serde", "serde_json", "settings", "smol", + "ui", "url", "util", + "workspace", +] + +[[package]] +name = "context_server_settings" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "gpui", + "schemars", + "serde", + "serde_json", + "settings", ] [[package]] @@ -3055,6 +3123,17 @@ dependencies = [ "coreaudio-sys", ] +[[package]] +name = "coreaudio-rs" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ca07354f6d0640333ef95f48d460a4bcf34812a7e7967f9b44c728a8f37c28" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + [[package]] name = "coreaudio-sys" version = "0.2.16" @@ -3089,12 +3168,11 @@ dependencies = [ [[package]] name = "cpal" version = "0.15.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +source = "git+https://github.com/zed-industries/cpal?rev=fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50#fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50" dependencies = [ "alsa", "core-foundation-sys", - "coreaudio-rs", + "coreaudio-rs 0.11.3", "dasp_sample", "jni", "js-sys", @@ -3426,6 +3504,65 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991" +[[package]] +name = "cxx" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05e1ec88093d2abd9cf1b09ffd979136b8e922bf31cad966a8fe0d73233112ef" +dependencies = [ + "cc", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afa390d956ee7ccb41aeed7ed7856ab3ffb4fc587e7216be7e0f83e949b4e6c" +dependencies = [ + "cc", + "codespan-reporting", + "proc-macro2", + "quote", + "scratch", + "syn 2.0.87", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c23bfff654d6227cbc83de8e059d2f8678ede5fc3a6c5a35d5c379983cc61e6" +dependencies = [ + "clap", + "codespan-reporting", + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c01b36e22051bc6928a78583f1621abaaf7621561c2ada1b00f7878fbe2caa" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e14013136fac689345d17b9a6df55977251f11d333c0a571e8d963b55e1f95" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.87", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -3714,6 +3851,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" @@ -3766,6 +3909,7 @@ dependencies = [ "db", "emojis", "env_logger 0.11.5", + "feature_flags", "file_icons", "fs", "futures 0.3.31", @@ -3799,6 +3943,7 @@ dependencies = [ "snippet", "sum_tree", "task", + "tempfile", "text", "theme", "time", @@ -3812,6 +3957,7 @@ dependencies = [ "unindent", "url", "util", + "uuid", "workspace", ] @@ -3858,9 +4004,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", @@ -4206,7 +4352,7 @@ dependencies = [ "async-trait", "client", "collections", - "context_servers", + "context_server_settings", "ctor", "env_logger 0.11.5", "extension", @@ -4491,7 +4637,7 @@ dependencies = [ "futures-core", "futures-sink", "nanorand", - "spin 0.9.8", + "spin", ] [[package]] @@ -4676,6 +4822,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fsevent" version = "0.1.0" @@ -4968,7 +5124,6 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "clock", "collections", "derive_more", "git2", @@ -4977,6 +5132,7 @@ dependencies = [ "log", "parking_lot", "pretty_assertions", + "regex", "rope", "serde", "serde_json", @@ -5752,7 +5908,7 @@ dependencies = [ "http 1.1.0", "hyper 1.5.0", "hyper-util", - "rustls 0.23.16", + "rustls 0.23.18", "rustls-native-certs 0.8.0", "rustls-pki-types", "tokio", @@ -6241,9 +6397,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", @@ -6309,6 +6465,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -6404,7 +6569,7 @@ dependencies = [ "base64 0.21.7", "js-sys", "pem", - "ring 0.17.8", + "ring", "serde", "serde_json", "simple_asn1", @@ -6412,47 +6577,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 0.28.0", "futures 0.3.31", "jupyter-protocol", - "jupyter-serde", "serde", "serde_json", "url", @@ -6523,7 +6672,6 @@ dependencies = [ "fs", "futures 0.3.31", "fuzzy", - "git", "globset", "gpui", "http_client", @@ -6665,11 +6813,14 @@ version = "0.1.0" dependencies = [ "anyhow", "editor", + "file_finder", + "file_icons", "fuzzy", "gpui", "language", "picker", "project", + "settings", "ui", "util", "workspace", @@ -6765,7 +6916,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin", ] [[package]] @@ -6782,9 +6933,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" @@ -6857,15 +7008,38 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.28.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ "cc", "pkg-config", "vcpkg", ] +[[package]] +name = "libwebrtc" +version = "0.3.7" +source = "git+https://github.com/zed-industries/rust-sdks?rev=799f10133d93ba2a88642cd480d01ec4da53408c#799f10133d93ba2a88642cd480d01ec4da53408c" +dependencies = [ + "cxx", + "jni", + "js-sys", + "lazy_static", + "livekit-protocol", + "livekit-runtime", + "log", + "parking_lot", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webrtc-sys", +] + [[package]] name = "libz-sys" version = "1.1.20" @@ -6878,6 +7052,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "link-cplusplus" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d240c6f7e1ba3a28b0249f774e6a9dd0175054b52dfbb61b16eb8505c3785c9" +dependencies = [ + "cc", +] + [[package]] name = "linkify" version = "0.10.0" @@ -6926,7 +7109,112 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" [[package]] -name = "live_kit_client" +name = "livekit" +version = "0.7.0" +source = "git+https://github.com/zed-industries/rust-sdks?rev=799f10133d93ba2a88642cd480d01ec4da53408c#799f10133d93ba2a88642cd480d01ec4da53408c" +dependencies = [ + "chrono", + "futures-util", + "lazy_static", + "libwebrtc", + "livekit-api", + "livekit-protocol", + "livekit-runtime", + "log", + "parking_lot", + "prost 0.12.6", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "livekit-api" +version = "0.4.1" +source = "git+https://github.com/zed-industries/rust-sdks?rev=799f10133d93ba2a88642cd480d01ec4da53408c#799f10133d93ba2a88642cd480d01ec4da53408c" +dependencies = [ + "async-tungstenite 0.25.1", + "futures-util", + "http 0.2.12", + "jsonwebtoken", + "livekit-protocol", + "livekit-runtime", + "log", + "parking_lot", + "prost 0.12.6", + "reqwest 0.11.27", + "scopeguard", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite 0.20.1", + "url", +] + +[[package]] +name = "livekit-protocol" +version = "0.3.6" +source = "git+https://github.com/zed-industries/rust-sdks?rev=799f10133d93ba2a88642cd480d01ec4da53408c#799f10133d93ba2a88642cd480d01ec4da53408c" +dependencies = [ + "futures-util", + "livekit-runtime", + "parking_lot", + "pbjson", + "pbjson-types", + "prost 0.12.6", + "prost-types 0.12.6", + "serde", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "livekit-runtime" +version = "0.3.1" +source = "git+https://github.com/zed-industries/rust-sdks?rev=799f10133d93ba2a88642cd480d01ec4da53408c#799f10133d93ba2a88642cd480d01ec4da53408c" +dependencies = [ + "async-io 2.4.0", + "async-std", + "async-task", + "futures 0.3.31", +] + +[[package]] +name = "livekit_client" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "collections", + "core-foundation 0.9.4", + "coreaudio-rs 0.12.1", + "cpal", + "futures 0.3.31", + "gpui", + "http 0.2.12", + "http_client", + "image", + "livekit", + "livekit_server", + "log", + "media", + "nanoid", + "parking_lot", + "postage", + "serde", + "serde_json", + "sha2", + "simplelog", + "smallvec", + "util", +] + +[[package]] +name = "livekit_client_macos" version = "0.1.0" dependencies = [ "anyhow", @@ -6936,7 +7224,7 @@ dependencies = [ "core-foundation 0.9.4", "futures 0.3.31", "gpui", - "live_kit_server", + "livekit_server", "log", "media", "nanoid", @@ -6949,16 +7237,16 @@ dependencies = [ ] [[package]] -name = "live_kit_server" +name = "livekit_server" version = "0.1.0" dependencies = [ "anyhow", "async-trait", "jsonwebtoken", "log", - "prost", - "prost-build", - "prost-types", + "prost 0.9.0", + "prost-build 0.9.0", + "prost-types 0.9.0", "reqwest 0.12.8", "serde", ] @@ -7134,6 +7422,7 @@ dependencies = [ "settings", "theme", "ui", + "util", "workspace", ] @@ -7247,6 +7536,7 @@ dependencies = [ "anyhow", "bindgen", "core-foundation 0.9.4", + "ctor", "foreign-types 0.5.0", "metal", "objc", @@ -7487,13 +7777,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", @@ -7939,14 +8229,14 @@ dependencies = [ "md-5", "num", "num-bigint-dig", - "pbkdf2", + "pbkdf2 0.12.2", "rand 0.8.5", "serde", "sha2", "subtle", - "zbus", + "zbus 4.4.0", "zeroize", - "zvariant", + "zvariant 4.2.0", ] [[package]] @@ -8259,6 +8549,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -8278,9 +8579,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pathdiff" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d61c5ce1153ab5b689d0c074c4e7fc613e942dfb7dd9eea5ab202d2ad91fe361" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pathfinder_geometry" @@ -8309,6 +8610,55 @@ dependencies = [ "util", ] +[[package]] +name = "pbjson" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1030c719b0ec2a2d25a5df729d6cff1acf3cc230bf766f4f97833591f7577b90" +dependencies = [ + "base64 0.21.7", + "serde", +] + +[[package]] +name = "pbjson-build" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2580e33f2292d34be285c5bc3dba5259542b083cfad6037b6d70345f24dcb735" +dependencies = [ + "heck 0.4.1", + "itertools 0.11.0", + "prost 0.12.6", + "prost-types 0.12.6", +] + +[[package]] +name = "pbjson-types" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f596653ba4ac51bdecbb4ef6773bc7f56042dc13927910de1684ad3d32aa12" +dependencies = [ + "bytes 1.8.0", + "chrono", + "pbjson", + "pbjson-build", + "prost 0.12.6", + "prost-build 0.12.6", + "serde", +] + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash 0.4.2", + "sha2", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -9177,9 +9527,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", ] @@ -9269,6 +9619,7 @@ dependencies = [ "anyhow", "client", "collections", + "command_palette_hooks", "db", "editor", "file_icons", @@ -9337,7 +9688,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "444879275cb4fd84958b1a1d5420d15e6fcf7c235fe47f053c9c2a80aceb6001" dependencies = [ "bytes 1.8.0", - "prost-derive", + "prost-derive 0.9.0", +] + +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes 1.8.0", + "prost-derive 0.12.6", ] [[package]] @@ -9353,13 +9714,34 @@ dependencies = [ "log", "multimap", "petgraph", - "prost", - "prost-types", + "prost 0.9.0", + "prost-types 0.9.0", "regex", "tempfile", "which 4.4.2", ] +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes 1.8.0", + "heck 0.5.0", + "itertools 0.12.1", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost 0.12.6", + "prost-types 0.12.6", + "regex", + "syn 2.0.87", + "tempfile", +] + [[package]] name = "prost-derive" version = "0.9.0" @@ -9373,6 +9755,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "prost-types" version = "0.9.0" @@ -9380,7 +9775,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "534b7a0e836e3c482d2693070f982e39e7611da9695d4d1f5a4b186b51faef0a" dependencies = [ "bytes 1.8.0", - "prost", + "prost 0.9.0", +] + +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost 0.12.6", ] [[package]] @@ -9389,8 +9793,8 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "prost", - "prost-build", + "prost 0.9.0", + "prost-build 0.9.0", "serde", ] @@ -9502,7 +9906,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.0.0", - "rustls 0.23.16", + "rustls 0.23.18", "socket2 0.5.7", "thiserror 2.0.3", "tokio", @@ -9518,9 +9922,9 @@ 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 0.23.18", "rustls-pki-types", "slab", "thiserror 2.0.3", @@ -9890,7 +10294,7 @@ dependencies = [ "log", "parking_lot", "paths", - "prost", + "prost 0.9.0", "release_channel", "rpc", "serde", @@ -10025,6 +10429,7 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "hyper 0.14.31", + "hyper-rustls 0.24.2", "hyper-tls", "ipnet", "js-sys", @@ -10034,6 +10439,8 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls 0.21.12", + "rustls-native-certs 0.6.3", "rustls-pemfile 1.0.4", "serde", "serde_json", @@ -10042,6 +10449,7 @@ dependencies = [ "system-configuration 0.5.1", "tokio", "tokio-native-tls", + "tokio-rustls 0.24.1", "tower-service", "url", "wasm-bindgen", @@ -10075,7 +10483,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.16", + "rustls 0.23.18", "rustls-native-certs 0.8.0", "rustls-pemfile 2.2.0", "rustls-pki-types", @@ -10161,21 +10569,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" @@ -10186,8 +10579,8 @@ dependencies = [ "cfg-if", "getrandom 0.2.15", "libc", - "spin 0.9.8", - "untrusted 0.9.0", + "spin", + "untrusted", "windows-sys 0.52.0", ] @@ -10243,13 +10636,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]] @@ -10303,9 +10695,9 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.6" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +checksum = "47c75d7c5c6b673e58bf54d8544a9f432e3a925b0e80f7cd3602ab5c50c55519" dependencies = [ "const-oid", "digest", @@ -10323,9 +10715,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", @@ -10338,8 +10730,7 @@ dependencies = [ "futures 0.3.31", "glob", "jupyter-protocol", - "jupyter-serde", - "ring 0.17.8", + "ring", "serde", "serde_json", "shellexpand 3.1.0", @@ -10466,18 +10857,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" @@ -10485,19 +10864,19 @@ 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", ] [[package]] name = "rustls" -version = "0.23.16" +version = "0.23.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee87ff5d9b36712a58574e12e9f0ea80f915a5b0ac518d322b24a465617925e" +checksum = "9c9cc1d47e243d655ace55ed38201c19ae02c148ae56412ab8750e8f0166ab7f" dependencies = [ "once_cell", - "ring 0.17.8", + "ring", "rustls-pki-types", "rustls-webpki 0.102.8", "subtle", @@ -10562,8 +10941,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]] @@ -10572,9 +10951,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]] @@ -10670,14 +11049,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scratch" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cf7c11c38cb994f3d40e8a8cde3bbd1f72a435e4c49e85d6553d8312306152" + [[package]] name = "scrypt" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ - "password-hash", - "pbkdf2", + "password-hash 0.5.0", + "pbkdf2 0.12.2", "salsa20", "sha2", ] @@ -10688,8 +11073,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]] @@ -10977,9 +11362,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", @@ -11451,12 +11836,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" @@ -11539,9 +11918,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27144619c6e5802f1380337a209d2ac1c431002dd74c6e60aebff3c506dc4f0c" +checksum = "fcfa89bea9500db4a0d038513d7a060566bfc51d46d1c014847049a45cce85e8" dependencies = [ "sqlx-core", "sqlx-macros", @@ -11552,9 +11931,9 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a999083c1af5b5d6c071d34a708a19ba3e02106ad82ef7bbd69f5e48266b613b" +checksum = "d06e2f2bd861719b1f3f0c7dbe1d80c30bf59e76cf019f07d9014ed7eefb8e08" dependencies = [ "atoi", "bigdecimal", @@ -11580,8 +11959,8 @@ dependencies = [ "paste", "percent-encoding", "rust_decimal", - "rustls 0.21.12", - "rustls-pemfile 1.0.4", + "rustls 0.23.18", + "rustls-pemfile 2.2.0", "serde", "serde_json", "sha2", @@ -11594,14 +11973,14 @@ dependencies = [ "tracing", "url", "uuid", - "webpki-roots 0.25.4", + "webpki-roots 0.26.7", ] [[package]] name = "sqlx-macros" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23217eb7d86c584b8cbe0337b9eacf12ab76fe7673c513141ec42565698bb88" +checksum = "2f998a9defdbd48ed005a89362bd40dd2117502f15294f61c8d47034107dbbdc" dependencies = [ "proc-macro2", "quote", @@ -11612,9 +11991,9 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a099220ae541c5db479c6424bdf1b200987934033c2584f79a0e1693601e776" +checksum = "3d100558134176a2629d46cec0c8891ba0be8910f7896abfdb75ef4ab6f4e7ce" dependencies = [ "dotenvy", "either", @@ -11638,9 +12017,9 @@ dependencies = [ [[package]] name = "sqlx-mysql" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5afe4c38a9b417b6a9a5eeffe7235d0a106716495536e7727d1c7f4b1ff3eba6" +checksum = "936cac0ab331b14cb3921c62156d913e4c15b74fb6ec0f3146bd4ef6e4fb3c12" dependencies = [ "atoi", "base64 0.22.1", @@ -11685,9 +12064,9 @@ dependencies = [ [[package]] name = "sqlx-postgres" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1dbb157e65f10dbe01f729339c06d239120221c9ad9fa0ba8408c4cc18ecf21" +checksum = "9734dbce698c67ecf67c442f768a5e90a49b2a4d61a9f1d59f73874bd4cf0710" dependencies = [ "atoi", "base64 0.22.1", @@ -11729,9 +12108,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2cdd83c008a622d94499c0006d8ee5f821f36c89b7d625c900e5dc30b5c5ee" +checksum = "a75b419c3c1b1697833dd927bdc4c6545a620bc1bbafabd44e1efbe9afcd337e" dependencies = [ "atoi", "chrono", @@ -12380,6 +12759,7 @@ name = "terminal_view" version = "0.1.0" dependencies = [ "anyhow", + "async-recursion 1.1.1", "breadcrumbs", "client", "collections", @@ -12804,7 +13184,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls 0.23.16", + "rustls 0.23.18", "rustls-pki-types", "tokio", ] @@ -12841,7 +13221,10 @@ checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" dependencies = [ "futures-util", "log", + "rustls 0.21.12", + "rustls-native-certs 0.6.3", "tokio", + "tokio-rustls 0.24.1", "tungstenite 0.20.1", ] @@ -13125,9 +13508,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", @@ -13336,25 +13719,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" @@ -13368,6 +13732,7 @@ dependencies = [ "httparse", "log", "rand 0.8.5", + "rustls 0.21.12", "sha1", "thiserror 1.0.69", "url", @@ -13386,6 +13751,7 @@ dependencies = [ "http 1.1.0", "httparse", "log", + "native-tls", "rand 0.8.5", "sha1", "thiserror 1.0.69", @@ -13566,12 +13932,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" @@ -13650,6 +14010,7 @@ dependencies = [ "async-fs 1.6.0", "collections", "dirs 4.0.0", + "dunce", "futures 0.3.31", "futures-lite 1.13.0", "git2", @@ -13794,6 +14155,7 @@ dependencies = [ "serde_derive", "serde_json", "settings", + "theme", "tokio", "ui", "util", @@ -14480,8 +14842,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]] @@ -14495,9 +14857,38 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.25.4" +version = "0.26.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webrtc-sys" +version = "0.3.5" +source = "git+https://github.com/zed-industries/rust-sdks?rev=799f10133d93ba2a88642cd480d01ec4da53408c#799f10133d93ba2a88642cd480d01ec4da53408c" +dependencies = [ + "cc", + "cxx", + "cxx-build", + "glob", + "log", + "webrtc-sys-build", +] + +[[package]] +name = "webrtc-sys-build" +version = "0.3.5" +source = "git+https://github.com/zed-industries/rust-sdks?rev=799f10133d93ba2a88642cd480d01ec4da53408c#799f10133d93ba2a88642cd480d01ec4da53408c" +dependencies = [ + "fs2", + "regex", + "reqwest 0.11.27", + "scratch", + "semver", + "zip", +] [[package]] name = "weezl" @@ -15236,7 +15627,6 @@ dependencies = [ "env_logger 0.11.5", "fs", "futures 0.3.31", - "git", "gpui", "http_client", "itertools 0.13.0", @@ -15544,9 +15934,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]] @@ -15559,7 +15985,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]] @@ -15570,12 +16011,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.166.0" dependencies = [ "activity_indicator", "anyhow", @@ -15583,6 +16036,7 @@ dependencies = [ "assets", "assistant", "assistant2", + "assistant_tools", "async-watch", "audio", "auto_update", @@ -15631,6 +16085,7 @@ dependencies = [ "languages", "libc", "log", + "markdown", "markdown_preview", "menu", "mimalloc", @@ -15753,7 +16208,7 @@ dependencies = [ [[package]] name = "zed_erlang" -version = "0.1.0" +version = "0.1.1" dependencies = [ "zed_extension_api 0.1.0", ] @@ -15787,7 +16242,7 @@ dependencies = [ [[package]] name = "zed_haskell" -version = "0.1.1" +version = "0.1.2" dependencies = [ "zed_extension_api 0.1.0", ] @@ -15801,28 +16256,28 @@ dependencies = [ [[package]] name = "zed_lua" -version = "0.1.0" +version = "0.1.1" dependencies = [ "zed_extension_api 0.1.0", ] [[package]] name = "zed_php" -version = "0.2.2" +version = "0.2.3" dependencies = [ "zed_extension_api 0.1.0", ] [[package]] name = "zed_prisma" -version = "0.0.3" +version = "0.0.4" dependencies = [ "zed_extension_api 0.1.0", ] [[package]] name = "zed_proto" -version = "0.2.0" +version = "0.2.1" dependencies = [ "zed_extension_api 0.1.0", ] @@ -15865,7 +16320,7 @@ dependencies = [ [[package]] name = "zed_toml" -version = "0.1.1" +version = "0.1.2" dependencies = [ "zed_extension_api 0.1.0", ] @@ -15879,7 +16334,7 @@ dependencies = [ [[package]] name = "zed_zig" -version = "0.3.1" +version = "0.3.2" dependencies = [ "zed_extension_api 0.1.0", ] @@ -16001,6 +16456,26 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq 0.1.5", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2 0.11.0", + "sha1", + "time", + "zstd", +] + [[package]] name = "zstd" version = "0.11.2+zstd.1.5.2" @@ -16059,13 +16534,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]] @@ -16078,7 +16568,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]] @@ -16091,3 +16594,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 2e5111e2ff..7ff0ad6ce3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/assistant2", "crates/assistant_slash_command", "crates/assistant_tool", + "crates/assistant_tools", "crates/audio", "crates/auto_update", "crates/auto_update_ui", @@ -22,7 +23,8 @@ members = [ "crates/collections", "crates/command_palette", "crates/command_palette_hooks", - "crates/context_servers", + "crates/context_server", + "crates/context_server_settings", "crates/copilot", "crates/db", "crates/diagnostics", @@ -63,8 +65,9 @@ members = [ "crates/language_selector", "crates/language_tools", "crates/languages", - "crates/live_kit_client", - "crates/live_kit_server", + "crates/livekit_client", + "crates/livekit_client_macos", + "crates/livekit_server", "crates/lsp", "crates/markdown", "crates/markdown_preview", @@ -191,6 +194,7 @@ assistant = { path = "crates/assistant" } assistant2 = { path = "crates/assistant2" } assistant_slash_command = { path = "crates/assistant_slash_command" } assistant_tool = { path = "crates/assistant_tool" } +assistant_tools = { path = "crates/assistant_tools" } audio = { path = "crates/audio" } auto_update = { path = "crates/auto_update" } auto_update_ui = { path = "crates/auto_update_ui" } @@ -205,7 +209,8 @@ collab_ui = { path = "crates/collab_ui" } collections = { path = "crates/collections" } command_palette = { path = "crates/command_palette" } command_palette_hooks = { path = "crates/command_palette_hooks" } -context_servers = { path = "crates/context_servers" } +context_server = { path = "crates/context_server" } +context_server_settings = { path = "crates/context_server_settings" } copilot = { path = "crates/copilot" } db = { path = "crates/db" } diagnostics = { path = "crates/diagnostics" } @@ -224,7 +229,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" } @@ -242,8 +249,9 @@ language_models = { path = "crates/language_models" } language_selector = { path = "crates/language_selector" } language_tools = { path = "crates/language_tools" } languages = { path = "crates/languages" } -live_kit_client = { path = "crates/live_kit_client" } -live_kit_server = { path = "crates/live_kit_server" } +livekit_client = { path = "crates/livekit_client" } +livekit_client_macos = { path = "crates/livekit_client_macos" } +livekit_server = { path = "crates/livekit_server" } lsp = { path = "crates/lsp" } markdown = { path = "crates/markdown" } markdown_preview = { path = "crates/markdown_preview" } @@ -327,7 +335,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" @@ -376,20 +384,23 @@ heed = { version = "0.20.1", features = ["read-txn-no-tls"] } hex = "0.4.3" html5ever = "0.27.0" hyper = "0.14" +http = "1.1" ignore = "0.4.22" image = "0.25.1" 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" +libsqlite3-sys = { version = "0.30.1", features = ["bundled"] } linkify = "0.10.0" +livekit = { git = "https://github.com/zed-industries/rust-sdks", rev="799f10133d93ba2a88642cd480d01ec4da53408c", features = ["dispatcher", "services-dispatcher", "rustls-tls-native-roots"], default-features = false } 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" @@ -399,10 +410,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" @@ -423,7 +434,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" @@ -495,7 +506,7 @@ unindent = "0.1.7" unicode-segmentation = "1.10" unicode-script = "0.5.7" url = "2.2" -uuid = { version = "1.1.2", features = ["v4", "v5", "serde"] } +uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } wasmparser = "0.215" wasm-encoder = "0.215" wasmtime = { version = "24", default-features = false, features = [ @@ -564,6 +575,10 @@ features = [ "Win32_UI_WindowsAndMessaging", ] +# TODO livekit https://github.com/RustAudio/cpal/pull/891 +[patch.crates-io] +cpal = { git = "https://github.com/zed-industries/cpal", rev = "fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50" } + [profile.dev] split-debuginfo = "unpacked" debug = "limited" @@ -666,6 +681,7 @@ new_ret_no_self = { level = "allow" } # We have a few `next` functions that differ in lifetimes # compared to Iterator::next. Yet, clippy complains about those. should_implement_trait = { level = "allow" } +let_underscore_future = "allow" [workspace.metadata.cargo-machete] ignored = ["bindgen", "cbindgen", "prost_build", "serde"] diff --git a/assets/icons/cursor_i_beam.svg b/assets/icons/cursor_i_beam.svg index 2e7b95b203..93ac068fe2 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 5152efb874..672f736c95 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 0000000000..07c46f1799 --- /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 fe293256b3..89da63ddda 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 0000000000..485e27a378 --- /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 0000000000..2082a43984 --- /dev/null +++ b/assets/icons/globe.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/triangle.svg b/assets/icons/triangle.svg index 8c44b91b78..0ecf071e24 100644 --- a/assets/icons/triangle.svg +++ b/assets/icons/triangle.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/x.svg b/assets/icons/x.svg index d090cb55bf..5d91a9edd9 100644 --- a/assets/icons/x.svg +++ b/assets/icons/x.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 2eedc1c839..3787f97a8d 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -108,7 +108,9 @@ "ctrl-'": "editor::ToggleHunkDiff", "ctrl-\"": "editor::ExpandAllHunkDiffs", "ctrl-i": "editor::ShowSignatureHelp", - "alt-g b": "editor::ToggleGitBlame" + "alt-g b": "editor::ToggleGitBlame", + "menu": "editor::OpenContextMenu", + "shift-f10": "editor::OpenContextMenu" } }, { @@ -405,7 +407,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 +596,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 963d48ba5e..f54216712e 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1,6 +1,7 @@ [ // Standard macOS bindings { + "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrev", "shift-tab": "menu::SelectPrev", @@ -40,6 +41,7 @@ }, { "context": "Editor", + "use_key_equivalents": true, "bindings": { "escape": "editor::Cancel", "backspace": "editor::Backspace", @@ -93,8 +95,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", @@ -133,6 +133,7 @@ }, { "context": "Editor && mode == full", + "use_key_equivalents": true, "bindings": { "enter": "editor::Newline", "shift-enter": "editor::Newline", @@ -150,20 +151,23 @@ }, { "context": "Editor && mode == full && inline_completion", + "use_key_equivalents": true, "bindings": { - "alt-]": "editor::NextInlineCompletion", - "alt-[": "editor::PreviousInlineCompletion", + "alt-tab": "editor::NextInlineCompletion", + "alt-shift-tab": "editor::PreviousInlineCompletion", "ctrl-right": "editor::AcceptPartialInlineCompletion" } }, { "context": "Editor && !inline_completion", + "use_key_equivalents": true, "bindings": { - "alt-\\": "editor::ShowInlineCompletion" + "alt-tab": "editor::ShowInlineCompletion" } }, { "context": "Editor && mode == auto_height", + "use_key_equivalents": true, "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", @@ -172,12 +176,14 @@ }, { "context": "Markdown", + "use_key_equivalents": true, "bindings": { "cmd-c": "markdown::Copy" } }, { "context": "Editor && jupyter && !ContextEditor", + "use_key_equivalents": true, "bindings": { "ctrl-shift-enter": "repl::Run", "ctrl-alt-enter": "repl::RunInPlace" @@ -185,6 +191,7 @@ }, { "context": "AssistantPanel", + "use_key_equivalents": true, "bindings": { "cmd-k c": "assistant::CopyCode", "cmd-g": "search::SelectNextMatch", @@ -197,6 +204,7 @@ }, { "context": "ContextEditor > Editor", + "use_key_equivalents": true, "bindings": { "cmd-enter": "assistant::Assist", "cmd-shift-enter": "assistant::Edit", @@ -209,8 +217,24 @@ "alt-enter": "editor::Newline" } }, + { + "context": "AssistantPanel2", + "use_key_equivalents": true, + "bindings": { + "cmd-n": "assistant2::NewThread", + "cmd-shift-h": "assistant2::OpenHistory" + } + }, + { + "context": "MessageEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "cmd-enter": "assistant2::Chat" + } + }, { "context": "PromptLibrary", + "use_key_equivalents": true, "bindings": { "cmd-n": "prompt_library::NewPrompt", "cmd-shift-s": "prompt_library::ToggleDefaultPrompt", @@ -219,6 +243,7 @@ }, { "context": "BufferSearchBar", + "use_key_equivalents": true, "bindings": { "escape": "buffer_search::Dismiss", "tab": "buffer_search::FocusEditor", @@ -232,6 +257,7 @@ }, { "context": "BufferSearchBar && in_replace > Editor", + "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", "cmd-enter": "search::ReplaceAll" @@ -239,6 +265,7 @@ }, { "context": "BufferSearchBar && !in_replace > Editor", + "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", "down": "search::NextHistoryQuery" @@ -246,6 +273,7 @@ }, { "context": "ProjectSearchBar", + "use_key_equivalents": true, "bindings": { "escape": "project_search::ToggleFocus", "cmd-shift-j": "project_search::ToggleFilters", @@ -257,6 +285,7 @@ }, { "context": "ProjectSearchBar > Editor", + "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", "down": "search::NextHistoryQuery" @@ -264,6 +293,7 @@ }, { "context": "ProjectSearchBar && in_replace > Editor", + "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", "cmd-enter": "search::ReplaceAll" @@ -271,6 +301,7 @@ }, { "context": "ProjectSearchView", + "use_key_equivalents": true, "bindings": { "escape": "project_search::ToggleFocus", "cmd-shift-j": "project_search::ToggleFilters", @@ -281,6 +312,7 @@ }, { "context": "Pane", + "use_key_equivalents": true, "bindings": { "cmd-{": "pane::ActivatePrevItem", "cmd-}": "pane::ActivateNextItem", @@ -309,6 +341,7 @@ // Bindings from VS Code { "context": "Editor", + "use_key_equivalents": true, "bindings": { "cmd-[": "editor::Outdent", "cmd-]": "editor::Indent", @@ -343,7 +376,7 @@ "alt-cmd-f12": "editor::GoToTypeDefinitionSplit", "alt-shift-f12": "editor::FindAllReferences", "ctrl-m": "editor::MoveToEnclosingBracket", - "cmd-shift-\\": "editor::MoveToEnclosingBracket", + "cmd-|": "editor::MoveToEnclosingBracket", "alt-cmd-[": "editor::Fold", "alt-cmd-]": "editor::UnfoldLines", "cmd-k cmd-l": "editor::ToggleFold", @@ -372,6 +405,7 @@ }, { "context": "Editor && mode == full", + "use_key_equivalents": true, "bindings": { "cmd-shift-o": "outline::Toggle", "ctrl-g": "go_to_line::Toggle" @@ -379,6 +413,7 @@ }, { "context": "Pane", + "use_key_equivalents": true, "bindings": { "ctrl-1": ["pane::ActivateItem", 0], "ctrl-2": ["pane::ActivateItem", 1], @@ -398,6 +433,7 @@ }, { "context": "Workspace", + "use_key_equivalents": true, "bindings": { // Change the default action on `menu::Confirm` by setting the parameter // "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }], @@ -434,7 +470,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", @@ -453,6 +489,7 @@ }, { "context": "Workspace && !Terminal", + "use_key_equivalents": true, "bindings": { "cmd-shift-r": "task::Spawn", "cmd-alt-r": "task::Rerun", @@ -463,6 +500,7 @@ // Bindings from Sublime Text { "context": "Editor", + "use_key_equivalents": true, "bindings": { "ctrl-j": "editor::JoinLines", "ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart", @@ -482,6 +520,7 @@ // Bindings from Atom { "context": "Pane", + "use_key_equivalents": true, "bindings": { "cmd-k up": "pane::SplitUp", "cmd-k down": "pane::SplitDown", @@ -492,12 +531,14 @@ // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", + "use_key_equivalents": true, "bindings": { "enter": "editor::ConfirmRename" } }, { "context": "Editor && showing_completions", + "use_key_equivalents": true, "bindings": { "enter": "editor::ConfirmCompletion", "tab": "editor::ComposeCompletion" @@ -505,18 +546,21 @@ }, { "context": "Editor && inline_completion && !showing_completions", + "use_key_equivalents": true, "bindings": { "tab": "editor::AcceptInlineCompletion" } }, { "context": "Editor && showing_code_actions", + "use_key_equivalents": true, "bindings": { "enter": "editor::ConfirmCodeAction" } }, { "context": "Editor && (showing_code_actions || showing_completions)", + "use_key_equivalents": true, "bindings": { "up": "editor::ContextMenuPrev", "ctrl-p": "editor::ContextMenuPrev", @@ -528,6 +572,7 @@ }, // Custom bindings { + "use_key_equivalents": true, "bindings": { "ctrl-alt-cmd-f": "workspace::FollowNextCollaborator", // TODO: Move this to a dock open action @@ -538,6 +583,7 @@ }, { "context": "Editor && mode == full", + "use_key_equivalents": true, "bindings": { "alt-enter": "editor::OpenExcerpts", "shift-enter": "editor::ExpandExcerpts", @@ -549,6 +595,7 @@ }, { "context": "ProposedChangesEditor", + "use_key_equivalents": true, "bindings": { "cmd-shift-y": "editor::ApplyDiffHunk", "cmd-shift-a": "editor::ApplyAllDiffHunks" @@ -556,6 +603,7 @@ }, { "context": "PromptEditor", + "use_key_equivalents": true, "bindings": { "ctrl-[": "assistant::CyclePreviousInlineAssist", "ctrl-]": "assistant::CycleNextInlineAssist" @@ -563,12 +611,14 @@ }, { "context": "ProjectSearchBar && !in_replace", + "use_key_equivalents": true, "bindings": { "cmd-enter": "project_search::SearchInNew" } }, { "context": "OutlinePanel && not_editing", + "use_key_equivalents": true, "bindings": { "escape": "menu::Cancel", "left": "outline_panel::CollapseSelectedEntry", @@ -585,6 +635,7 @@ }, { "context": "ProjectPanel", + "use_key_equivalents": true, "bindings": { "left": "project_panel::CollapseSelectedEntry", "right": "project_panel::ExpandSelectedEntry", @@ -604,6 +655,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", @@ -613,12 +665,14 @@ }, { "context": "ProjectPanel && not_editing", + "use_key_equivalents": true, "bindings": { "space": "project_panel::Open" } }, { "context": "CollabPanel && not_editing", + "use_key_equivalents": true, "bindings": { "ctrl-backspace": "collab_panel::Remove", "space": "menu::Confirm" @@ -626,18 +680,21 @@ }, { "context": "(CollabPanel && editing) > Editor", + "use_key_equivalents": true, "bindings": { "space": "collab_panel::InsertSpace" } }, { "context": "ChannelModal", + "use_key_equivalents": true, "bindings": { "tab": "channel_modal::ToggleMode" } }, { "context": "Picker > Editor", + "use_key_equivalents": true, "bindings": { "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], @@ -646,18 +703,21 @@ }, { "context": "ChannelModal > Picker > Editor", + "use_key_equivalents": true, "bindings": { "tab": "channel_modal::ToggleMode" } }, { "context": "FileFinder", + "use_key_equivalents": true, "bindings": { "cmd": "file_finder::ToggleMenu" } }, { "context": "FileFinder && !menu_open", + "use_key_equivalents": true, "bindings": { "cmd-shift-p": "file_finder::SelectPrev", "cmd-j": "pane::SplitDown", @@ -668,6 +728,7 @@ }, { "context": "FileFinder && menu_open", + "use_key_equivalents": true, "bindings": { "j": "pane::SplitDown", "k": "pane::SplitUp", @@ -677,6 +738,7 @@ }, { "context": "TabSwitcher", + "use_key_equivalents": true, "bindings": { "ctrl-up": "menu::SelectPrev", "ctrl-down": "menu::SelectNext", @@ -686,6 +748,7 @@ }, { "context": "Terminal", + "use_key_equivalents": true, "bindings": { "ctrl-cmd-space": "terminal::ShowCharacterPalette", "cmd-c": "terminal::Copy", @@ -719,7 +782,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 1be3e8c9c1..597388368d 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -1,7 +1,6 @@ [ { "context": "VimControl && !menu", - "use_layout_keys": true, "bindings": { "i": ["vim::PushOperator", { "Object": { "around": false } }], "a": ["vim::PushOperator", { "Object": { "around": true } }], @@ -33,6 +32,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 +66,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 } }], @@ -172,7 +187,6 @@ }, { "context": "vim_mode == normal", - "use_layout_keys": true, "bindings": { "escape": "editor::Cancel", "ctrl-[": "editor::Cancel", @@ -205,6 +219,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"], @@ -226,7 +241,6 @@ }, { "context": "VimControl && VimCount", - "use_layout_keys": true, "bindings": { "0": ["vim::Number", 0], ":": "vim::CountCommand" @@ -234,7 +248,6 @@ }, { "context": "vim_mode == visual", - "use_layout_keys": true, "bindings": { ":": "vim::VisualCommand", "u": "vim::ConvertToLowerCase", @@ -271,6 +284,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", @@ -283,7 +297,6 @@ }, { "context": "vim_mode == insert", - "use_layout_keys": true, "bindings": { "escape": "vim::NormalBefore", "ctrl-c": "vim::NormalBefore", @@ -308,9 +321,25 @@ "ctrl-o": "vim::TemporaryNormal" } }, + { + "context": "vim_mode == helix_normal", + "bindings": { + "i": "vim::InsertBefore", + "a": "vim::InsertAfter", + "d": "vim::HelixDelete", + "w": "vim::NextWordStart", + "e": "vim::NextWordEnd", + "b": "vim::PreviousWordStart", + + "h": "vim::Left", + "j": "vim::Down", + "k": "vim::Up", + "l": "vim::Right" + } + }, + { "context": "vim_mode == insert && !(showing_code_actions || showing_completions)", - "use_layout_keys": true, "bindings": { "ctrl-p": "editor::ShowCompletions", "ctrl-n": "editor::ShowCompletions" @@ -318,7 +347,6 @@ }, { "context": "vim_mode == replace", - "use_layout_keys": true, "bindings": { "escape": "vim::NormalBefore", "ctrl-c": "vim::NormalBefore", @@ -336,7 +364,6 @@ }, { "context": "vim_mode == waiting", - "use_layout_keys": true, "bindings": { "tab": "vim::Tab", "enter": "vim::Enter", @@ -350,16 +377,15 @@ }, { "context": "vim_mode == operator", - "use_layout_keys": true, "bindings": { "escape": "vim::ClearOperators", "ctrl-c": "vim::ClearOperators", - "ctrl-[": "vim::ClearOperators" + "ctrl-[": "vim::ClearOperators", + "g c": "vim::Comment" } }, { "context": "vim_operator == a || vim_operator == i || vim_operator == cs", - "use_layout_keys": true, "bindings": { "w": "vim::Word", "shift-w": ["vim::Word", { "ignorePunctuation": true }], @@ -381,12 +407,15 @@ "shift-b": "vim::CurlyBrackets", "<": "vim::AngleBrackets", ">": "vim::AngleBrackets", - "a": "vim::Argument" + "a": "vim::Argument", + "i": "vim::IndentObj", + "shift-i": ["vim::IndentObj", { "includeBelow": true }], + "f": "vim::Method", + "c": "vim::Class" } }, { "context": "vim_operator == c", - "use_layout_keys": true, "bindings": { "c": "vim::CurrentLine", "d": "editor::Rename", // zed specific @@ -395,7 +424,6 @@ }, { "context": "vim_operator == d", - "use_layout_keys": true, "bindings": { "d": "vim::CurrentLine", "s": ["vim::PushOperator", "DeleteSurrounds"], @@ -405,7 +433,6 @@ }, { "context": "vim_operator == gu", - "use_layout_keys": true, "bindings": { "g u": "vim::CurrentLine", "u": "vim::CurrentLine" @@ -413,7 +440,6 @@ }, { "context": "vim_operator == gU", - "use_layout_keys": true, "bindings": { "g shift-u": "vim::CurrentLine", "shift-u": "vim::CurrentLine" @@ -421,7 +447,6 @@ }, { "context": "vim_operator == g~", - "use_layout_keys": true, "bindings": { "g ~": "vim::CurrentLine", "~": "vim::CurrentLine" @@ -429,7 +454,6 @@ }, { "context": "vim_operator == gq", - "use_layout_keys": true, "bindings": { "g q": "vim::CurrentLine", "q": "vim::CurrentLine", @@ -439,7 +463,6 @@ }, { "context": "vim_operator == y", - "use_layout_keys": true, "bindings": { "y": "vim::CurrentLine", "s": ["vim::PushOperator", { "AddSurrounds": {} }] @@ -447,35 +470,36 @@ }, { "context": "vim_operator == ys", - "use_layout_keys": true, "bindings": { "s": "vim::CurrentLine" } }, { "context": "vim_operator == >", - "use_layout_keys": true, "bindings": { ">": "vim::CurrentLine" } }, { "context": "vim_operator == <", - "use_layout_keys": true, "bindings": { "<": "vim::CurrentLine" } }, + { + "context": "vim_operator == eq", + "bindings": { + "=": "vim::CurrentLine" + } + }, { "context": "vim_operator == gc", - "use_layout_keys": true, "bindings": { "c": "vim::CurrentLine" } }, { "context": "vim_mode == literal", - "use_layout_keys": true, "bindings": { "ctrl-@": ["vim::Literal", ["ctrl-@", "\u0000"]], "ctrl-a": ["vim::Literal", ["ctrl-a", "\u0001"]], @@ -519,7 +543,6 @@ }, { "context": "BufferSearchBar && !in_replace", - "use_layout_keys": true, "bindings": { "enter": "vim::SearchSubmit", "escape": "buffer_search::Dismiss" @@ -527,7 +550,6 @@ }, { "context": "ProjectPanel || CollabPanel || OutlinePanel || ChatPanel || VimControl || EmptyPane || SharedScreen || MarkdownPreview || KeyContextView", - "use_layout_keys": true, "bindings": { // window related commands (ctrl-w X) "ctrl-w": null, @@ -551,6 +573,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", @@ -578,7 +606,6 @@ }, { "context": "EmptyPane || SharedScreen || MarkdownPreview || KeyContextView || Welcome", - "use_layout_keys": true, "bindings": { ":": "command_palette::Toggle", "g /": "pane::DeploySearch" @@ -587,7 +614,6 @@ { // netrw compatibility "context": "ProjectPanel && not_editing", - "use_layout_keys": true, "bindings": { ":": "command_palette::Toggle", "%": "project_panel::NewFile", @@ -607,6 +633,12 @@ "p": "project_panel::Open", "x": "project_panel::RevealInFileManager", "s": "project_panel::OpenWithSystem", + "] c": "project_panel::SelectNextGitEntry", + "[ c": "project_panel::SelectPrevGitEntry", + "] d": "project_panel::SelectNextDiagnostic", + "[ d": "project_panel::SelectPrevDiagnostic", + "}": "project_panel::SelectNextDirectory", + "{": "project_panel::SelectPrevDirectory", "shift-g": "menu::SelectLast", "g g": "menu::SelectFirst", "-": "project_panel::SelectParent", @@ -615,7 +647,6 @@ }, { "context": "OutlinePanel && not_editing", - "use_layout_keys": true, "bindings": { "j": "menu::SelectNext", "k": "menu::SelectPrev", diff --git a/assets/settings/default.json b/assets/settings/default.json index 45a211789f..20819529ff 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, @@ -490,9 +492,6 @@ "version": "2", // Whether the assistant is enabled. "enabled": true, - // Whether to show inline hints showing the keybindings to use the inline assistant and the - // assistant panel. - "show_hints": true, // Whether to show the assistant panel button in the status bar. "button": true, // Where to dock the assistant panel. Can be 'left', 'right' or 'bottom'. @@ -560,13 +559,26 @@ "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) // "History" // 2. Activate the neighbour tab (prefers the right one, if present) // "Neighbour" - "activate_on_close": "history" + "activate_on_close": "history", + /// Which files containing diagnostic errors/warnings to mark in the tabs. + /// Diagnostics are only shown when file icons are also active. + /// This setting only works when can take the following three values: + /// + /// 1. Do not mark any files: + /// "off" + /// 2. Only mark files with errors: + /// "errors" + /// 3. Mark files with errors and warnings: + /// "all" + "show_diagnostics": "off" }, // Settings related to preview tabs. "preview_tabs": { @@ -1128,6 +1140,7 @@ "use_system_clipboard": "always", "use_multiline_find": false, "use_smartcase_find": false, + "highlight_on_yank_duration": 200, "custom_digraphs": {} }, // The server to connect to. If the environment variable @@ -1185,6 +1198,8 @@ // "W": "workspace::Save" // } "command_aliases": {}, + // Whether to show user picture in titlebar. + "show_user_picture": true, // ssh_connections is an array of ssh connections. // You can configure these from `project: Open Remote` in the command palette. // Zed's ssh support will pull configuration from your ~/.ssh too. diff --git a/assets/themes/andromeda/andromeda.json b/assets/themes/andromeda/andromeda.json index 532d013b36..9a9ab5356e 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": [ @@ -46,7 +46,7 @@ "tab.active_background": "#1e2025ff", "search.match_background": "#11a79366", "panel.background": "#21242bff", - "panel.focused_border": null, + "panel.focused_border": "#10a793ff", "pane.focused_border": null, "scrollbar.thumb.background": "#f7f7f84c", "scrollbar.thumb.hover_background": "#252931ff", diff --git a/assets/themes/atelier/atelier.json b/assets/themes/atelier/atelier.json index 1bf4878b5a..cbfb6bea85 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": [ @@ -46,7 +46,7 @@ "tab.active_background": "#19171cff", "search.match_background": "#576dda66", "panel.background": "#221f26ff", - "panel.focused_border": null, + "panel.focused_border": "#566ddaff", "pane.focused_border": null, "scrollbar.thumb.background": "#efecf44c", "scrollbar.thumb.hover_background": "#332f38ff", @@ -431,7 +431,7 @@ "tab.active_background": "#efecf4ff", "search.match_background": "#586dda66", "panel.background": "#e6e3ebff", - "panel.focused_border": null, + "panel.focused_border": "#586cdaff", "pane.focused_border": null, "scrollbar.thumb.background": "#19171c4c", "scrollbar.thumb.hover_background": "#cbc8d1ff", diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index 00fb6deb91..a7c86ef0ba 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": [ @@ -46,7 +46,7 @@ "tab.active_background": "#0d1016ff", "search.match_background": "#5ac2fe66", "panel.background": "#1f2127ff", - "panel.focused_border": null, + "panel.focused_border": "#5ac1feff", "pane.focused_border": null, "scrollbar.thumb.background": "#bfbdb64c", "scrollbar.thumb.hover_background": "#2d2f34ff", @@ -416,7 +416,7 @@ "tab.active_background": "#fcfcfcff", "search.match_background": "#3b9ee566", "panel.background": "#ececedff", - "panel.focused_border": null, + "panel.focused_border": "#3b9ee5ff", "pane.focused_border": null, "scrollbar.thumb.background": "#5c61664c", "scrollbar.thumb.hover_background": "#dfe0e1ff", diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index a56ea7d046..4f599cdfe6 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": [ @@ -55,7 +55,7 @@ "tab.active_background": "#282828ff", "search.match_background": "#83a59866", "panel.background": "#3a3735ff", - "panel.focused_border": null, + "panel.focused_border": "#83a598ff", "pane.focused_border": null, "scrollbar.thumb.background": "#fbf1c74c", "scrollbar.thumb.hover_background": "#494340ff", @@ -439,7 +439,7 @@ "tab.active_background": "#1d2021ff", "search.match_background": "#83a59866", "panel.background": "#393634ff", - "panel.focused_border": null, + "panel.focused_border": "#83a598ff", "pane.focused_border": null, "scrollbar.thumb.background": "#fbf1c74c", "scrollbar.thumb.hover_background": "#494340ff", diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index 0519ead392..daa09f8995 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 5b66c5ed34..b081f5e133 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": [ @@ -46,7 +46,7 @@ "tab.active_background": "#191724ff", "search.match_background": "#57949f66", "panel.background": "#1c1b2aff", - "panel.focused_border": null, + "panel.focused_border": "#9bced6ff", "pane.focused_border": null, "scrollbar.thumb.background": "#e0def44c", "scrollbar.thumb.hover_background": "#232132ff", @@ -426,7 +426,7 @@ "tab.active_background": "#faf4edff", "search.match_background": "#9cced766", "panel.background": "#fef9f2ff", - "panel.focused_border": null, + "panel.focused_border": "#57949fff", "pane.focused_border": null, "scrollbar.thumb.background": "#5752794c", "scrollbar.thumb.hover_background": "#e5e0dfff", @@ -806,7 +806,7 @@ "tab.active_background": "#232136ff", "search.match_background": "#9cced766", "panel.background": "#28253cff", - "panel.focused_border": null, + "panel.focused_border": "#9bced6ff", "pane.focused_border": null, "scrollbar.thumb.background": "#e0def44c", "scrollbar.thumb.hover_background": "#322f48ff", diff --git a/assets/themes/sandcastle/sandcastle.json b/assets/themes/sandcastle/sandcastle.json index b5239b0a55..87030607dc 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": [ @@ -46,7 +46,7 @@ "tab.active_background": "#282c33ff", "search.match_background": "#528b8b66", "panel.background": "#2b3038ff", - "panel.focused_border": null, + "panel.focused_border": "#518b8bff", "pane.focused_border": null, "scrollbar.thumb.background": "#fdf4c14c", "scrollbar.thumb.hover_background": "#313741ff", diff --git a/assets/themes/solarized/solarized.json b/assets/themes/solarized/solarized.json index 7bd0c53f52..42341d6770 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": [ @@ -46,7 +46,7 @@ "tab.active_background": "#002a35ff", "search.match_background": "#288bd166", "panel.background": "#04313bff", - "panel.focused_border": null, + "panel.focused_border": "#278ad1ff", "pane.focused_border": null, "scrollbar.thumb.background": "#fdf6e34c", "scrollbar.thumb.hover_background": "#053541ff", @@ -416,7 +416,7 @@ "tab.active_background": "#fdf6e3ff", "search.match_background": "#298bd166", "panel.background": "#f3eddaff", - "panel.focused_border": null, + "panel.focused_border": "#288bd1ff", "pane.focused_border": null, "scrollbar.thumb.background": "#002a354c", "scrollbar.thumb.hover_background": "#dcdacbff", diff --git a/assets/themes/summercamp/summercamp.json b/assets/themes/summercamp/summercamp.json index 84423a8600..0c5cfa0c6f 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": [ @@ -46,7 +46,7 @@ "tab.active_background": "#1b1810ff", "search.match_background": "#499bef66", "panel.background": "#231f16ff", - "panel.focused_border": null, + "panel.focused_border": "#499befff", "pane.focused_border": null, "scrollbar.thumb.background": "#f8f5de4c", "scrollbar.thumb.hover_background": "#29251bff", diff --git a/crates/assistant/Cargo.toml b/crates/assistant/Cargo.toml index 0799d4bbdb..3b68b5cc9a 100644 --- a/crates/assistant/Cargo.toml +++ b/crates/assistant/Cargo.toml @@ -33,7 +33,7 @@ client.workspace = true clock.workspace = true collections.workspace = true command_palette_hooks.workspace = true -context_servers.workspace = true +context_server.workspace = true db.workspace = true editor.workspace = true feature_flags.workspace = true diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index f6e435bfb8..6d619a76b9 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -14,16 +14,12 @@ pub mod slash_command_settings; mod slash_command_working_set; mod streaming_diff; mod terminal_inline_assistant; -mod tool_working_set; -mod tools; use crate::slash_command::project_command::ProjectSlashCommandFeatureFlag; pub use crate::slash_command_working_set::{SlashCommandId, SlashCommandWorkingSet}; -pub use crate::tool_working_set::{ToolId, ToolWorkingSet}; pub use assistant_panel::{AssistantPanel, AssistantPanelEvent}; use assistant_settings::AssistantSettings; use assistant_slash_command::SlashCommandRegistry; -use assistant_tool::ToolRegistry; use client::{proto, Client}; use command_palette_hooks::CommandPaletteFilter; pub use context::*; @@ -246,7 +242,7 @@ pub fn init( assistant_slash_command::init(cx); assistant_tool::init(cx); assistant_panel::init(cx); - context_servers::init(cx); + context_server::init(cx); let prompt_builder = prompts::PromptBuilder::new(Some(PromptLoadingParams { fs: fs.clone(), @@ -259,7 +255,6 @@ pub fn init( .map(Arc::new) .unwrap_or_else(|| Arc::new(prompts::PromptBuilder::new(None).unwrap())); register_slash_commands(Some(prompt_builder.clone()), cx); - register_tools(cx); inline_assistant::init( fs.clone(), prompt_builder.clone(), @@ -347,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::({ @@ -423,11 +417,6 @@ fn update_slash_commands_from_settings(cx: &mut AppContext) { } } -fn register_tools(cx: &mut AppContext) { - let tool_registry = ToolRegistry::global(cx); - tool_registry.register_tool(tools::now_tool::NowTool); -} - pub fn humanize_token_count(count: usize) -> String { match count { 0..=999 => count.to_string(), diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 9a7beb96d2..109c9c3237 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -1,6 +1,5 @@ use crate::slash_command::file_command::codeblock_fence_for_path; use crate::slash_command_working_set::SlashCommandWorkingSet; -use crate::ToolWorkingSet; use crate::{ assistant_settings::{AssistantDockPosition, AssistantSettings}, humanize_token_count, @@ -23,6 +22,7 @@ use crate::{ }; use anyhow::Result; use assistant_slash_command::{SlashCommand, SlashCommandOutputSection}; +use assistant_tool::ToolWorkingSet; use client::{proto, zed_urls, Client, Status}; use collections::{hash_map, BTreeSet, HashMap, HashSet}; use editor::{ @@ -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()) @@ -1316,7 +1316,7 @@ impl AssistantPanel { fn restart_context_servers( workspace: &mut Workspace, - _action: &context_servers::Restart, + _action: &context_server::Restart, cx: &mut ViewContext, ) { let Some(assistant_panel) = workspace.panel::(cx) else { @@ -1925,7 +1925,7 @@ impl ContextEditor { Content::ToolUse { range: tool_use.source_range.clone(), tool_use: LanguageModelToolUse { - id: tool_use.id.to_string(), + id: tool_use.id.clone(), name: tool_use.name.clone(), input: tool_use.input.clone(), }, diff --git a/crates/assistant/src/assistant_settings.rs b/crates/assistant/src/assistant_settings.rs index a782f05d03..87baf041ff 100644 --- a/crates/assistant/src/assistant_settings.rs +++ b/crates/assistant/src/assistant_settings.rs @@ -59,7 +59,6 @@ pub struct AssistantSettings { pub inline_alternatives: Vec, pub using_outdated_settings_version: bool, pub enable_experimental_live_diffs: bool, - pub show_hints: bool, } impl AssistantSettings { @@ -202,7 +201,6 @@ impl AssistantSettingsContent { AssistantSettingsContent::Versioned(settings) => match settings { VersionedAssistantSettingsContent::V1(settings) => AssistantSettingsContentV2 { enabled: settings.enabled, - show_hints: None, button: settings.button, dock: settings.dock, default_width: settings.default_width, @@ -243,7 +241,6 @@ impl AssistantSettingsContent { }, AssistantSettingsContent::Legacy(settings) => AssistantSettingsContentV2 { enabled: None, - show_hints: None, button: settings.button, dock: settings.dock, default_width: settings.default_width, @@ -356,7 +353,6 @@ impl Default for VersionedAssistantSettingsContent { fn default() -> Self { Self::V2(AssistantSettingsContentV2 { enabled: None, - show_hints: None, button: None, dock: None, default_width: None, @@ -374,11 +370,6 @@ pub struct AssistantSettingsContentV2 { /// /// Default: true enabled: Option, - /// Whether to show inline hints that show keybindings for inline assistant - /// and assistant panel. - /// - /// Default: true - show_hints: Option, /// Whether to show the assistant panel button in the status bar. /// /// Default: true @@ -513,7 +504,6 @@ impl Settings for AssistantSettings { let value = value.upgrade(); merge(&mut settings.enabled, value.enabled); - merge(&mut settings.show_hints, value.show_hints); merge(&mut settings.button, value.button); merge(&mut settings.dock, value.dock); merge( @@ -584,7 +574,6 @@ mod tests { }), inline_alternatives: None, enabled: None, - show_hints: None, button: None, dock: None, default_width: None, diff --git a/crates/assistant/src/context.rs b/crates/assistant/src/context.rs index 570180ed74..032a66b4c7 100644 --- a/crates/assistant/src/context.rs +++ b/crates/assistant/src/context.rs @@ -2,7 +2,6 @@ mod context_tests; use crate::slash_command_working_set::SlashCommandWorkingSet; -use crate::ToolWorkingSet; use crate::{ prompts::PromptBuilder, slash_command::{file_command::FileCommandMetadata, SlashCommandLine}, @@ -12,10 +11,11 @@ use anyhow::{anyhow, Context as _, Result}; use assistant_slash_command::{ SlashCommandContent, SlashCommandEvent, SlashCommandOutputSection, SlashCommandResult, }; +use assistant_tool::ToolWorkingSet; use client::{self, proto, telemetry::Telemetry}; use clock::ReplicaId; use collections::{HashMap, HashSet}; -use feature_flags::{FeatureFlag, FeatureFlagAppExt}; +use feature_flags::{FeatureFlagAppExt, ToolUseFeatureFlag}; use fs::{Fs, RemoveOptions}; use futures::{future::Shared, FutureExt, StreamExt}; use gpui::{ @@ -27,8 +27,8 @@ use language::{AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, P use language_model::{ LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionEvent, LanguageModelImage, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, - LanguageModelRequestTool, LanguageModelToolResult, LanguageModelToolUse, MessageContent, Role, - StopReason, + LanguageModelRequestTool, LanguageModelToolResult, LanguageModelToolUse, + LanguageModelToolUseId, MessageContent, Role, StopReason, }; use language_models::{ provider::cloud::{MaxMonthlySpendReachedError, PaymentRequiredError}, @@ -385,7 +385,7 @@ pub enum ContextEvent { }, UsePendingTools, ToolFinished { - tool_use_id: Arc, + tool_use_id: LanguageModelToolUseId, output_range: Range, }, Operation(ContextOperation), @@ -479,7 +479,7 @@ pub enum Content { }, ToolResult { range: Range, - tool_use_id: Arc, + tool_use_id: LanguageModelToolUseId, }, } @@ -546,7 +546,7 @@ pub struct Context { pub(crate) slash_commands: Arc, pub(crate) tools: Arc, slash_command_output_sections: Vec>, - pending_tool_uses_by_id: HashMap, PendingToolUse>, + pending_tool_uses_by_id: HashMap, message_anchors: Vec, contents: Vec, messages_metadata: HashMap, @@ -1126,7 +1126,7 @@ impl Context { self.pending_tool_uses_by_id.values().collect() } - pub fn get_tool_use_by_id(&self, id: &Arc) -> Option<&PendingToolUse> { + pub fn get_tool_use_by_id(&self, id: &LanguageModelToolUseId) -> Option<&PendingToolUse> { self.pending_tool_uses_by_id.get(id) } @@ -2153,7 +2153,7 @@ impl Context { pub fn insert_tool_output( &mut self, - tool_use_id: Arc, + tool_use_id: LanguageModelToolUseId, output: Task>, cx: &mut ModelContext, ) { @@ -2340,11 +2340,10 @@ impl Context { let source_range = buffer.anchor_after(start_ix) ..buffer.anchor_after(end_ix); - let tool_use_id: Arc = tool_use.id.into(); this.pending_tool_uses_by_id.insert( - tool_use_id.clone(), + tool_use.id.clone(), PendingToolUse { - id: tool_use_id, + id: tool_use.id, name: tool_use.name, input: tool_use.input, status: PendingToolUseStatus::Idle, @@ -3201,19 +3200,9 @@ pub enum PendingSlashCommandStatus { Error(String), } -pub(crate) struct ToolUseFeatureFlag; - -impl FeatureFlag for ToolUseFeatureFlag { - const NAME: &'static str = "assistant-tool-use"; - - fn enabled_for_staff() -> bool { - false - } -} - #[derive(Debug, Clone)] pub struct PendingToolUse { - pub id: Arc, + pub id: LanguageModelToolUseId, pub name: String, pub input: serde_json::Value, pub status: PendingToolUseStatus, diff --git a/crates/assistant/src/context/context_tests.rs b/crates/assistant/src/context/context_tests.rs index 84b94c72c3..7f058cc9e7 100644 --- a/crates/assistant/src/context/context_tests.rs +++ b/crates/assistant/src/context/context_tests.rs @@ -1,6 +1,5 @@ use super::{AssistantEdit, MessageCacheMetadata}; use crate::slash_command_working_set::SlashCommandWorkingSet; -use crate::ToolWorkingSet; use crate::{ assistant_panel, prompt_library, slash_command::file_command, AssistantEditKind, CacheStatus, Context, ContextEvent, ContextId, ContextOperation, InvokedSlashCommandId, MessageId, @@ -11,6 +10,7 @@ use assistant_slash_command::{ ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent, SlashCommandOutput, SlashCommandOutputSection, SlashCommandRegistry, SlashCommandResult, }; +use assistant_tool::ToolWorkingSet; use collections::{HashMap, HashSet}; use fs::FakeFs; use futures::{ diff --git a/crates/assistant/src/context_store.rs b/crates/assistant/src/context_store.rs index 217d59faa4..34d4e5a700 100644 --- a/crates/assistant/src/context_store.rs +++ b/crates/assistant/src/context_store.rs @@ -1,15 +1,16 @@ use crate::slash_command::context_server_command; +use crate::SlashCommandId; use crate::{ prompts::PromptBuilder, slash_command_working_set::SlashCommandWorkingSet, Context, ContextEvent, ContextId, ContextOperation, ContextVersion, SavedContext, SavedContextMetadata, }; -use crate::{tools, SlashCommandId, ToolId, ToolWorkingSet}; use anyhow::{anyhow, Context as _, Result}; +use assistant_tool::{ToolId, ToolWorkingSet}; use client::{proto, telemetry::Telemetry, Client, TypedEnvelope}; use clock::ReplicaId; use collections::HashMap; -use context_servers::manager::ContextServerManager; -use context_servers::ContextServerFactoryRegistry; +use context_server::manager::ContextServerManager; +use context_server::{ContextServerFactoryRegistry, ContextServerTool}; use fs::Fs; use futures::StreamExt; use fuzzy::StringMatchCandidate; @@ -808,13 +809,13 @@ impl ContextStore { fn handle_context_server_event( &mut self, context_server_manager: Model, - event: &context_servers::manager::Event, + event: &context_server::manager::Event, cx: &mut ModelContext, ) { let slash_command_working_set = self.slash_commands.clone(); let tool_working_set = self.tools.clone(); match event { - context_servers::manager::Event::ServerStarted { server_id } => { + 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({ @@ -825,7 +826,7 @@ impl ContextStore { return; }; - if protocol.capable(context_servers::protocol::ServerCapability::Prompts) { + if protocol.capable(context_server::protocol::ServerCapability::Prompts) { if let Some(prompts) = protocol.list_prompts().await.log_err() { let slash_command_ids = prompts .into_iter() @@ -853,12 +854,12 @@ impl ContextStore { } } - if protocol.capable(context_servers::protocol::ServerCapability::Tools) { + 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(tools::context_server_tool::ContextServerTool::new( + Arc::new(ContextServerTool::new( context_server_manager.clone(), server.id(), tool, @@ -880,7 +881,7 @@ impl ContextStore { .detach(); } } - context_servers::manager::Event::ServerStopped { server_id } => { + context_server::manager::Event::ServerStopped { server_id } => { if let Some(slash_command_ids) = self.context_server_slash_command_ids.remove(server_id) { diff --git a/crates/assistant/src/slash_command/context_server_command.rs b/crates/assistant/src/slash_command/context_server_command.rs index 692b4f6ea7..8c53ddb773 100644 --- a/crates/assistant/src/slash_command/context_server_command.rs +++ b/crates/assistant/src/slash_command/context_server_command.rs @@ -4,7 +4,7 @@ use assistant_slash_command::{ SlashCommandOutputSection, SlashCommandResult, }; use collections::HashMap; -use context_servers::{ +use context_server::{ manager::{ContextServer, ContextServerManager}, types::Prompt, }; @@ -95,9 +95,9 @@ impl SlashCommand for ContextServerSlashCommand { let completion_result = protocol .completion( - context_servers::types::CompletionReference::Prompt( - context_servers::types::PromptReference { - r#type: context_servers::types::PromptReferenceType::Prompt, + context_server::types::CompletionReference::Prompt( + context_server::types::PromptReference { + r#type: context_server::types::PromptReferenceType::Prompt, name: prompt_name, }, ), @@ -152,7 +152,7 @@ impl SlashCommand for ContextServerSlashCommand { if result .messages .iter() - .any(|msg| !matches!(msg.role, context_servers::types::Role::User)) + .any(|msg| !matches!(msg.role, context_server::types::Role::User)) { return Err(anyhow!( "Prompt contains non-user roles, which is not supported" @@ -164,7 +164,7 @@ impl SlashCommand for ContextServerSlashCommand { .messages .into_iter() .filter_map(|msg| match msg.content { - context_servers::types::MessageContent::Text { text } => Some(text), + context_server::types::MessageContent::Text { text, .. } => Some(text), _ => None, }) .collect::>() diff --git a/crates/assistant/src/slash_command/fetch_command.rs b/crates/assistant/src/slash_command/fetch_command.rs index 4d38bb20a7..96ea05c302 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 8e797d6184..215888540a 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 a5424a8d7e..d60a556cf0 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/assistant/src/tools.rs b/crates/assistant/src/tools.rs deleted file mode 100644 index 83a396c020..0000000000 --- a/crates/assistant/src/tools.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod context_server_tool; -pub mod now_tool; diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index 9dd605d559..b5f5fe8ecd 100644 --- a/crates/assistant2/Cargo.toml +++ b/crates/assistant2/Cargo.toml @@ -14,14 +14,34 @@ doctest = false [dependencies] anyhow.workspace = true +assistant_tool.workspace = true +chrono.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.workspace = true language_model.workspace = true language_model_selector.workspace = true +language_models.workspace = true +log.workspace = true +markdown.workspace = true +picker.workspace = true +project.workspace = true proto.workspace = true +serde.workspace = true +serde_json.workspace = true settings.workspace = true +smol.workspace = true theme.workspace = true +time.workspace = true +time_format.workspace = true ui.workspace = true +unindent.workspace = true +util.workspace = true +uuid.workspace = true workspace.workspace = true diff --git a/crates/assistant2/src/active_thread.rs b/crates/assistant2/src/active_thread.rs new file mode 100644 index 0000000000..d9cd8fcc46 --- /dev/null +++ b/crates/assistant2/src/active_thread.rs @@ -0,0 +1,242 @@ +use std::sync::Arc; + +use assistant_tool::ToolWorkingSet; +use collections::HashMap; +use gpui::{ + list, AnyElement, AppContext, Empty, ListAlignment, ListState, Model, StyleRefinement, + Subscription, TextStyleRefinement, View, WeakView, +}; +use language::LanguageRegistry; +use language_model::Role; +use markdown::{Markdown, MarkdownStyle}; +use settings::Settings as _; +use theme::ThemeSettings; +use ui::prelude::*; +use workspace::Workspace; + +use crate::thread::{MessageId, Thread, ThreadError, ThreadEvent}; + +pub struct ActiveThread { + workspace: WeakView, + language_registry: Arc, + tools: Arc, + thread: Model, + messages: Vec, + list_state: ListState, + rendered_messages_by_id: HashMap>, + last_error: Option, + _subscriptions: Vec, +} + +impl ActiveThread { + pub fn new( + thread: Model, + workspace: WeakView, + language_registry: Arc, + tools: Arc, + cx: &mut ViewContext, + ) -> Self { + let subscriptions = vec![ + cx.observe(&thread, |_, _, cx| cx.notify()), + cx.subscribe(&thread, Self::handle_thread_event), + ]; + + let mut this = Self { + workspace, + language_registry, + tools, + thread: thread.clone(), + messages: Vec::new(), + rendered_messages_by_id: HashMap::default(), + list_state: ListState::new(0, ListAlignment::Bottom, px(1024.), { + let this = cx.view().downgrade(); + move |ix, cx: &mut WindowContext| { + this.update(cx, |this, cx| this.render_message(ix, cx)) + .unwrap() + } + }), + last_error: None, + _subscriptions: subscriptions, + }; + + for message in thread.read(cx).messages().cloned().collect::>() { + this.push_message(&message.id, message.text.clone(), cx); + } + + this + } + + pub fn is_empty(&self) -> bool { + self.messages.is_empty() + } + + pub fn summary(&self, cx: &AppContext) -> Option { + self.thread.read(cx).summary() + } + + pub fn last_error(&self) -> Option { + self.last_error.clone() + } + + pub fn clear_last_error(&mut self) { + self.last_error.take(); + } + + fn push_message(&mut self, id: &MessageId, text: String, cx: &mut ViewContext) { + let old_len = self.messages.len(); + self.messages.push(*id); + self.list_state.splice(old_len..old_len, 1); + + let theme_settings = ThemeSettings::get_global(cx); + let ui_font_size = TextSize::Default.rems(cx); + let buffer_font_size = theme_settings.buffer_font_size; + + let mut text_style = cx.text_style(); + text_style.refine(&TextStyleRefinement { + font_family: Some(theme_settings.ui_font.family.clone()), + font_size: Some(ui_font_size.into()), + color: Some(cx.theme().colors().text), + ..Default::default() + }); + + let markdown_style = MarkdownStyle { + base_text_style: text_style, + syntax: cx.theme().syntax().clone(), + selection_background_color: cx.theme().players().local().selection, + code_block: StyleRefinement { + text: Some(TextStyleRefinement { + font_family: Some(theme_settings.buffer_font.family.clone()), + font_size: Some(buffer_font_size.into()), + ..Default::default() + }), + ..Default::default() + }, + inline_code: TextStyleRefinement { + font_family: Some(theme_settings.buffer_font.family.clone()), + font_size: Some(ui_font_size.into()), + background_color: Some(cx.theme().colors().editor_background), + ..Default::default() + }, + ..Default::default() + }; + + let markdown = cx.new_view(|cx| { + Markdown::new( + text, + markdown_style, + Some(self.language_registry.clone()), + None, + cx, + ) + }); + self.rendered_messages_by_id.insert(*id, markdown); + } + + fn handle_thread_event( + &mut self, + _: Model, + event: &ThreadEvent, + cx: &mut ViewContext, + ) { + match event { + ThreadEvent::ShowError(error) => { + self.last_error = Some(error.clone()); + } + ThreadEvent::StreamedCompletion => {} + ThreadEvent::SummaryChanged => {} + ThreadEvent::StreamedAssistantText(message_id, text) => { + if let Some(markdown) = self.rendered_messages_by_id.get_mut(&message_id) { + markdown.update(cx, |markdown, cx| { + markdown.append(text, cx); + }); + } + } + ThreadEvent::MessageAdded(message_id) => { + if let Some(message_text) = self + .thread + .read(cx) + .message(*message_id) + .map(|message| message.text.clone()) + { + self.push_message(message_id, message_text, cx); + } + + cx.notify(); + } + ThreadEvent::UsePendingTools => { + let pending_tool_uses = self + .thread + .read(cx) + .pending_tool_uses() + .into_iter() + .filter(|tool_use| tool_use.status.is_idle()) + .cloned() + .collect::>(); + + for tool_use in pending_tool_uses { + if let Some(tool) = self.tools.tool(&tool_use.name, cx) { + let task = tool.run(tool_use.input, self.workspace.clone(), cx); + + self.thread.update(cx, |thread, cx| { + thread.insert_tool_output( + tool_use.assistant_message_id, + tool_use.id.clone(), + task, + cx, + ); + }); + } + } + } + ThreadEvent::ToolFinished { .. } => {} + } + } + + fn render_message(&self, ix: usize, cx: &mut ViewContext) -> AnyElement { + let message_id = self.messages[ix]; + let Some(message) = self.thread.read(cx).message(message_id) else { + return Empty.into_any(); + }; + + let Some(markdown) = self.rendered_messages_by_id.get(&message_id) else { + return Empty.into_any(); + }; + + let (role_icon, role_name) = match message.role { + Role::User => (IconName::Person, "You"), + Role::Assistant => (IconName::ZedAssistant, "Assistant"), + Role::System => (IconName::Settings, "System"), + }; + + div() + .id(("message-container", ix)) + .p_2() + .child( + v_flex() + .border_1() + .border_color(cx.theme().colors().border_variant) + .rounded_md() + .child( + h_flex() + .justify_between() + .p_1p5() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child( + h_flex() + .gap_2() + .child(Icon::new(role_icon).size(IconSize::Small)) + .child(Label::new(role_name).size(LabelSize::Small)), + ), + ) + .child(v_flex().p_1p5().text_ui(cx).child(markdown.clone())), + ) + .into_any() + } +} + +impl Render for ActiveThread { + fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { + list(self.list_state.clone()).flex_1() + } +} diff --git a/crates/assistant2/src/assistant.rs b/crates/assistant2/src/assistant.rs index f8284d9ff5..3c8520680e 100644 --- a/crates/assistant2/src/assistant.rs +++ b/crates/assistant2/src/assistant.rs @@ -1,5 +1,10 @@ +mod active_thread; mod assistant_panel; -mod chat_editor; +mod context_picker; +mod message_editor; +mod thread; +mod thread_history; +mod thread_store; use command_palette_hooks::CommandPaletteFilter; use feature_flags::{Assistant2FeatureFlag, FeatureFlagAppExt}; @@ -7,7 +12,16 @@ use gpui::{actions, AppContext}; pub use crate::assistant_panel::AssistantPanel; -actions!(assistant2, [ToggleFocus, NewChat, ToggleModelSelector]); +actions!( + assistant2, + [ + ToggleFocus, + NewThread, + ToggleModelSelector, + OpenHistory, + Chat + ] +); const NAMESPACE: &str = "assistant2"; diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 2fa08d7f5e..fde3aa02ba 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -1,16 +1,27 @@ +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, Pixels, Task, View, ViewContext, WeakView, WindowContext, + prelude::*, px, svg, Action, AnyElement, AppContext, AsyncWindowContext, EventEmitter, + FocusHandle, FocusableView, FontWeight, Model, Pixels, Task, View, ViewContext, WeakView, + WindowContext, }; +use language::LanguageRegistry; use language_model::LanguageModelRegistry; use language_model_selector::LanguageModelSelector; -use ui::{prelude::*, ButtonLike, Divider, IconButtonShape, Tab, Tooltip}; +use time::UtcOffset; +use ui::{prelude::*, ButtonLike, Divider, IconButtonShape, KeyBinding, Tab, Tooltip}; use workspace::dock::{DockPosition, Panel, PanelEvent}; -use workspace::{Pane, Workspace}; +use workspace::Workspace; -use crate::chat_editor::ChatEditor; -use crate::{NewChat, ToggleFocus, ToggleModelSelector}; +use crate::active_thread::ActiveThread; +use crate::message_editor::MessageEditor; +use crate::thread::{ThreadError, ThreadId}; +use crate::thread_history::{PastThread, ThreadHistory}; +use crate::thread_store::ThreadStore; +use crate::{NewThread, OpenHistory, ToggleFocus, ToggleModelSelector}; pub fn init(cx: &mut AppContext) { cx.observe_new_views( @@ -23,9 +34,21 @@ pub fn init(cx: &mut AppContext) { .detach(); } +enum ActiveView { + Thread, + History, +} + pub struct AssistantPanel { - pane: View, - chat_editor: View, + workspace: WeakView, + language_registry: Arc, + thread_store: Model, + thread: View, + message_editor: View, + tools: Arc, + local_timezone: UtcOffset, + active_view: ActiveView, + history: View, } impl AssistantPanel { @@ -34,38 +57,112 @@ impl AssistantPanel { cx: AsyncWindowContext, ) -> 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, cx)) + cx.new_view(|cx| Self::new(workspace, thread_store, tools, cx)) }) }) } - 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, - NewChat.boxed_clone(), - cx, - ); - pane.set_can_split(false, cx); - pane.set_can_navigate(true, cx); - - pane - }); + fn new( + workspace: &Workspace, + thread_store: Model, + tools: Arc, + cx: &mut ViewContext, + ) -> Self { + let thread = thread_store.update(cx, |this, cx| this.create_thread(cx)); + let language_registry = workspace.project().read(cx).languages().clone(); + let workspace = workspace.weak_handle(); + let weak_self = cx.view().downgrade(); Self { - pane, - chat_editor: cx.new_view(ChatEditor::new), + active_view: ActiveView::Thread, + workspace: workspace.clone(), + language_registry: language_registry.clone(), + thread_store: thread_store.clone(), + thread: cx.new_view(|cx| { + ActiveThread::new( + thread.clone(), + workspace, + language_registry, + tools.clone(), + cx, + ) + }), + message_editor: cx.new_view(|cx| MessageEditor::new(thread.clone(), cx)), + tools, + local_timezone: UtcOffset::from_whole_seconds( + chrono::Local::now().offset().local_minus_utc(), + ) + .unwrap(), + history: cx.new_view(|cx| ThreadHistory::new(weak_self, thread_store, cx)), } } + + pub(crate) fn local_timezone(&self) -> UtcOffset { + self.local_timezone + } + + fn new_thread(&mut self, cx: &mut ViewContext) { + let thread = self + .thread_store + .update(cx, |this, cx| this.create_thread(cx)); + + self.active_view = ActiveView::Thread; + self.thread = cx.new_view(|cx| { + ActiveThread::new( + thread.clone(), + self.workspace.clone(), + self.language_registry.clone(), + self.tools.clone(), + cx, + ) + }); + self.message_editor = cx.new_view(|cx| MessageEditor::new(thread, cx)); + self.message_editor.focus_handle(cx).focus(cx); + } + + pub(crate) fn open_thread(&mut self, thread_id: &ThreadId, cx: &mut ViewContext) { + let Some(thread) = self + .thread_store + .update(cx, |this, cx| this.open_thread(thread_id, cx)) + else { + return; + }; + + self.active_view = ActiveView::Thread; + self.thread = cx.new_view(|cx| { + ActiveThread::new( + thread.clone(), + self.workspace.clone(), + self.language_registry.clone(), + self.tools.clone(), + cx, + ) + }); + self.message_editor = cx.new_view(|cx| MessageEditor::new(thread, cx)); + self.message_editor.focus_handle(cx).focus(cx); + } + + pub(crate) fn delete_thread(&mut self, thread_id: &ThreadId, cx: &mut ViewContext) { + self.thread_store + .update(cx, |this, cx| this.delete_thread(thread_id, cx)); + } } impl FocusableView for AssistantPanel { fn focus_handle(&self, cx: &AppContext) -> FocusHandle { - self.pane.focus_handle(cx) + match self.active_view { + ActiveView::Thread => self.message_editor.focus_handle(cx), + ActiveView::History => self.history.focus_handle(cx), + } } } @@ -92,20 +189,8 @@ impl Panel for AssistantPanel { fn set_size(&mut self, _size: Option, _cx: &mut ViewContext) {} - fn is_zoomed(&self, cx: &WindowContext) -> bool { - self.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)); - } - fn set_active(&mut self, _active: bool, _cx: &mut ViewContext) {} - fn pane(&self) -> Option> { - Some(self.pane.clone()) - } - fn remote_id() -> Option { Some(proto::PanelId::AssistantPanel) } @@ -136,25 +221,30 @@ impl AssistantPanel { .bg(cx.theme().colors().tab_bar_background) .border_b_1() .border_color(cx.theme().colors().border_variant) - .child(h_flex().child(Label::new("Chat Title Goes Here"))) + .child(h_flex().children(self.thread.read(cx).summary(cx).map(Label::new))) .child( h_flex() .gap(DynamicSpacing::Base08.rems(cx)) .child(self.render_language_model_selector(cx)) .child(Divider::vertical()) .child( - IconButton::new("new-chat", IconName::Plus) + IconButton::new("new-thread", IconName::Plus) .shape(IconButtonShape::Square) .icon_size(IconSize::Small) .style(ButtonStyle::Subtle) .tooltip({ let focus_handle = focus_handle.clone(); move |cx| { - Tooltip::for_action_in("New Chat", &NewChat, &focus_handle, cx) + Tooltip::for_action_in( + "New Thread", + &NewThread, + &focus_handle, + cx, + ) } }) - .on_click(move |_event, _cx| { - println!("New Chat"); + .on_click(move |_event, cx| { + cx.dispatch_action(NewThread.boxed_clone()); }), ) .child( @@ -162,9 +252,19 @@ impl AssistantPanel { .shape(IconButtonShape::Square) .icon_size(IconSize::Small) .style(ButtonStyle::Subtle) - .tooltip(move |cx| Tooltip::text("Open History", cx)) - .on_click(move |_event, _cx| { - println!("Open History"); + .tooltip({ + let focus_handle = focus_handle.clone(); + move |cx| { + Tooltip::for_action_in( + "Open History", + &OpenHistory, + &focus_handle, + cx, + ) + } + }) + .on_click(move |_event, cx| { + cx.dispatch_action(OpenHistory.boxed_clone()); }), ) .child( @@ -230,6 +330,267 @@ impl AssistantPanel { .tooltip(move |cx| Tooltip::for_action("Change Model", &ToggleModelSelector, cx)), ) } + + fn render_active_thread_or_empty_state(&self, cx: &mut ViewContext) -> AnyElement { + if self.thread.read(cx).is_empty() { + return self.render_thread_empty_state(cx).into_any_element(); + } + + self.thread.clone().into_any() + } + + fn render_thread_empty_state(&self, cx: &mut ViewContext) -> impl IntoElement { + let recent_threads = self + .thread_store + .update(cx, |this, cx| this.recent_threads(3, cx)); + + v_flex() + .gap_2() + .mx_auto() + .child( + v_flex().w_full().child( + svg() + .path("icons/logo_96.svg") + .text_color(cx.theme().colors().text) + .w(px(40.)) + .h(px(40.)) + .mx_auto() + .mb_4(), + ), + ) + .child(v_flex()) + .child( + h_flex() + .w_full() + .justify_center() + .child(Label::new("Context Examples:").size(LabelSize::Small)), + ) + .child( + h_flex() + .gap_2() + .justify_center() + .child( + h_flex() + .gap_1() + .p_0p5() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border_variant) + .child( + Icon::new(IconName::Terminal) + .size(IconSize::Small) + .color(Color::Disabled), + ) + .child(Label::new("Terminal").size(LabelSize::Small)), + ) + .child( + h_flex() + .gap_1() + .p_0p5() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border_variant) + .child( + Icon::new(IconName::Folder) + .size(IconSize::Small) + .color(Color::Disabled), + ) + .child(Label::new("/src/components").size(LabelSize::Small)), + ), + ) + .when(!recent_threads.is_empty(), |parent| { + parent + .child( + h_flex() + .w_full() + .justify_center() + .child(Label::new("Recent Threads:").size(LabelSize::Small)), + ) + .child( + v_flex().gap_2().children( + recent_threads + .into_iter() + .map(|thread| PastThread::new(thread, cx.view().downgrade())), + ), + ) + .child( + h_flex().w_full().justify_center().child( + Button::new("view-all-past-threads", "View All Past Threads") + .style(ButtonStyle::Subtle) + .label_size(LabelSize::Small) + .key_binding(KeyBinding::for_action_in( + &OpenHistory, + &self.focus_handle(cx), + cx, + )) + .on_click(move |_event, cx| { + cx.dispatch_action(OpenHistory.boxed_clone()); + }), + ), + ) + }) + } + + fn render_last_error(&self, cx: &mut ViewContext) -> Option { + let last_error = self.thread.read(cx).last_error()?; + + 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.thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); + + cx.open_url(&zed_urls::account_url(cx)); + cx.notify(); + }, + ))) + .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( + |this, _, cx| { + this.thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); + + 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.thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); + + cx.open_url(&zed_urls::account_url(cx)); + cx.notify(); + }), + ), + ) + .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( + |this, _, cx| { + this.thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); + + 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.thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); + + cx.notify(); + }, + ))), + ) + .into_any() + } } impl Render for AssistantPanel { @@ -238,16 +599,26 @@ impl Render for AssistantPanel { .key_context("AssistantPanel2") .justify_between() .size_full() - .on_action(cx.listener(|_this, _: &NewChat, _cx| { - println!("Action: New Chat"); + .on_action(cx.listener(|this, _: &NewThread, cx| { + this.new_thread(cx); + })) + .on_action(cx.listener(|this, _: &OpenHistory, cx| { + this.active_view = ActiveView::History; + this.history.focus_handle(cx).focus(cx); + cx.notify(); })) .child(self.render_toolbar(cx)) - .child(v_flex().bg(cx.theme().colors().panel_background)) - .child( - h_flex() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child(self.chat_editor.clone()), - ) + .map(|parent| match self.active_view { + ActiveView::Thread => parent + .child(self.render_active_thread_or_empty_state(cx)) + .child( + h_flex() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(self.message_editor.clone()), + ) + .children(self.render_last_error(cx)), + ActiveView::History => parent.child(self.history.clone()), + }) } } diff --git a/crates/assistant2/src/chat_editor.rs b/crates/assistant2/src/chat_editor.rs deleted file mode 100644 index 9111f57eac..0000000000 --- a/crates/assistant2/src/chat_editor.rs +++ /dev/null @@ -1,76 +0,0 @@ -use editor::{Editor, EditorElement, EditorStyle}; -use gpui::{TextStyle, View}; -use settings::Settings; -use theme::ThemeSettings; -use ui::prelude::*; - -pub struct ChatEditor { - editor: View, -} - -impl ChatEditor { - pub fn new(cx: &mut ViewContext) -> Self { - Self { - editor: cx.new_view(|cx| { - let mut editor = Editor::auto_height(80, cx); - editor.set_placeholder_text("Ask anything…", cx); - - editor - }), - } - } -} - -impl Render for ChatEditor { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let font_size = TextSize::Default.rems(cx); - let line_height = font_size.to_pixels(cx.rem_size()) * 1.3; - - v_flex() - .size_full() - .gap_2() - .p_2() - .bg(cx.theme().colors().editor_background) - .child({ - let settings = ThemeSettings::get_global(cx); - let text_style = TextStyle { - color: cx.theme().colors().editor_foreground, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_size: font_size.into(), - font_weight: settings.ui_font.weight, - line_height: line_height.into(), - ..Default::default() - }; - - EditorElement::new( - &self.editor, - EditorStyle { - background: cx.theme().colors().editor_background, - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) - }) - .child( - h_flex() - .justify_between() - .child( - h_flex().child( - Button::new("add-context", "Add Context") - .style(ButtonStyle::Filled) - .icon(IconName::Plus) - .icon_position(IconPosition::Start), - ), - ) - .child( - h_flex() - .gap_2() - .child(Button::new("codebase", "Codebase").style(ButtonStyle::Filled)) - .child(Label::new("or")) - .child(Button::new("chat", "Chat").style(ButtonStyle::Filled)), - ), - ) - } -} diff --git a/crates/assistant2/src/context_picker.rs b/crates/assistant2/src/context_picker.rs new file mode 100644 index 0000000000..679ba8b9e7 --- /dev/null +++ b/crates/assistant2/src/context_picker.rs @@ -0,0 +1,197 @@ +use std::sync::Arc; + +use gpui::{DismissEvent, SharedString, Task, WeakView}; +use picker::{Picker, PickerDelegate, PickerEditorPosition}; +use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverTrigger, Tooltip}; + +use crate::message_editor::MessageEditor; + +#[derive(IntoElement)] +pub(super) struct ContextPicker { + message_editor: WeakView, + trigger: T, +} + +#[derive(Clone)] +struct ContextPickerEntry { + name: SharedString, + description: SharedString, + icon: IconName, +} + +pub(crate) struct ContextPickerDelegate { + all_entries: Vec, + filtered_entries: Vec, + message_editor: WeakView, + selected_ix: usize, +} + +impl ContextPicker { + pub(crate) fn new(message_editor: WeakView, trigger: T) -> Self { + ContextPicker { + message_editor, + trigger, + } + } +} + +impl PickerDelegate for ContextPickerDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + self.filtered_entries.len() + } + + fn selected_index(&self) -> usize { + self.selected_ix + } + + fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext>) { + self.selected_ix = ix.min(self.filtered_entries.len().saturating_sub(1)); + cx.notify(); + } + + fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { + "Select a context source…".into() + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { + let all_commands = self.all_entries.clone(); + cx.spawn(|this, mut cx| async move { + let filtered_commands = cx + .background_executor() + .spawn(async move { + if query.is_empty() { + all_commands + } else { + all_commands + .into_iter() + .filter(|model_info| { + model_info + .name + .to_lowercase() + .contains(&query.to_lowercase()) + }) + .collect() + } + }) + .await; + + this.update(&mut cx, |this, cx| { + this.delegate.filtered_entries = filtered_commands; + this.delegate.set_selected_index(0, cx); + cx.notify(); + }) + .ok(); + }) + } + + fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { + if let Some(entry) = self.filtered_entries.get(self.selected_ix) { + self.message_editor + .update(cx, |_message_editor, _cx| { + println!("Insert context from {}", entry.name); + }) + .ok(); + cx.emit(DismissEvent); + } + } + + fn dismissed(&mut self, _cx: &mut ViewContext>) {} + + fn editor_position(&self) -> PickerEditorPosition { + PickerEditorPosition::End + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _cx: &mut ViewContext>, + ) -> Option { + let entry = self.filtered_entries.get(ix)?; + + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Dense) + .selected(selected) + .tooltip({ + let description = entry.description.clone(); + move |cx| cx.new_view(|_cx| Tooltip::new(description.clone())).into() + }) + .child( + v_flex() + .group(format!("context-entry-label-{ix}")) + .w_full() + .py_0p5() + .min_w(px(250.)) + .max_w(px(400.)) + .child( + h_flex() + .gap_1p5() + .child(Icon::new(entry.icon).size(IconSize::XSmall)) + .child( + Label::new(entry.name.clone()) + .single_line() + .size(LabelSize::Small), + ), + ) + .child( + div().overflow_hidden().text_ellipsis().child( + Label::new(entry.description.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), + ), + ) + } +} + +impl RenderOnce for ContextPicker { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let entries = vec![ + ContextPickerEntry { + name: "directory".into(), + description: "Insert any directory".into(), + icon: IconName::Folder, + }, + ContextPickerEntry { + name: "file".into(), + description: "Insert any file".into(), + icon: IconName::File, + }, + ContextPickerEntry { + name: "web".into(), + description: "Fetch content from URL".into(), + icon: IconName::Globe, + }, + ]; + + let delegate = ContextPickerDelegate { + all_entries: entries.clone(), + message_editor: self.message_editor.clone(), + filtered_entries: entries, + selected_ix: 0, + }; + + let picker = + cx.new_view(|cx| Picker::uniform_list(delegate, cx).max_height(Some(rems(20.).into()))); + + let handle = self + .message_editor + .update(cx, |this, _| this.context_picker_handle.clone()) + .ok(); + PopoverMenu::new("context-picker") + .menu(move |_cx| Some(picker.clone())) + .trigger(self.trigger) + .attach(gpui::AnchorCorner::TopLeft) + .anchor(gpui::AnchorCorner::BottomLeft) + .offset(gpui::Point { + x: px(0.0), + y: px(-16.0), + }) + .when_some(handle, |this, handle| this.with_handle(handle)) + } +} diff --git a/crates/assistant2/src/message_editor.rs b/crates/assistant2/src/message_editor.rs new file mode 100644 index 0000000000..f3e618067b --- /dev/null +++ b/crates/assistant2/src/message_editor.rs @@ -0,0 +1,173 @@ +use editor::{Editor, EditorElement, EditorStyle}; +use gpui::{AppContext, FocusableView, Model, TextStyle, View}; +use language_model::{LanguageModelRegistry, LanguageModelRequestTool}; +use picker::Picker; +use settings::Settings; +use theme::ThemeSettings; +use ui::{ + prelude::*, ButtonLike, CheckboxWithLabel, ElevationIndex, IconButtonShape, KeyBinding, + PopoverMenuHandle, +}; + +use crate::context_picker::{ContextPicker, ContextPickerDelegate}; +use crate::thread::{RequestKind, Thread}; +use crate::Chat; + +pub struct MessageEditor { + thread: Model, + editor: View, + pub(crate) context_picker_handle: PopoverMenuHandle>, + use_tools: bool, +} + +impl MessageEditor { + pub fn new(thread: Model, cx: &mut ViewContext) -> Self { + Self { + thread, + editor: cx.new_view(|cx| { + let mut editor = Editor::auto_height(80, cx); + editor.set_placeholder_text("Ask anything…", cx); + + editor + }), + context_picker_handle: PopoverMenuHandle::default(), + use_tools: false, + } + } + + fn chat(&mut self, _: &Chat, cx: &mut ViewContext) { + self.send_to_model(RequestKind::Chat, cx); + } + + fn send_to_model( + &mut self, + request_kind: RequestKind, + cx: &mut ViewContext, + ) -> Option<()> { + let provider = LanguageModelRegistry::read_global(cx).active_provider(); + if provider + .as_ref() + .map_or(false, |provider| provider.must_accept_terms(cx)) + { + cx.notify(); + return None; + } + + let model_registry = LanguageModelRegistry::read_global(cx); + let model = model_registry.active_model()?; + + let user_message = self.editor.update(cx, |editor, cx| { + let text = editor.text(cx); + editor.clear(cx); + text + }); + + self.thread.update(cx, |thread, cx| { + thread.insert_user_message(user_message, cx); + let mut request = thread.to_completion_request(request_kind, cx); + + if self.use_tools { + request.tools = thread + .tools() + .tools(cx) + .into_iter() + .map(|tool| LanguageModelRequestTool { + name: tool.name(), + description: tool.description(), + input_schema: tool.input_schema(), + }) + .collect(); + } + + thread.stream_completion(request, model, cx) + }); + + None + } +} + +impl FocusableView for MessageEditor { + fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl Render for MessageEditor { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let font_size = TextSize::Default.rems(cx); + let line_height = font_size.to_pixels(cx.rem_size()) * 1.3; + let focus_handle = self.editor.focus_handle(cx); + + v_flex() + .key_context("MessageEditor") + .on_action(cx.listener(Self::chat)) + .size_full() + .gap_2() + .p_2() + .bg(cx.theme().colors().editor_background) + .child( + h_flex().gap_2().child(ContextPicker::new( + cx.view().downgrade(), + IconButton::new("add-context", IconName::Plus) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small), + )), + ) + .child({ + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: cx.theme().colors().editor_foreground, + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features.clone(), + font_size: font_size.into(), + font_weight: settings.ui_font.weight, + line_height: line_height.into(), + ..Default::default() + }; + + EditorElement::new( + &self.editor, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + }) + .child( + h_flex() + .justify_between() + .child(h_flex().gap_2().child(CheckboxWithLabel::new( + "use-tools", + Label::new("Tools"), + self.use_tools.into(), + cx.listener(|this, selection, _cx| { + this.use_tools = match selection { + Selection::Selected => true, + Selection::Unselected | Selection::Indeterminate => false, + }; + }), + ))) + .child( + h_flex() + .gap_2() + .child(Button::new("codebase", "Codebase").style(ButtonStyle::Filled)) + .child(Label::new("or")) + .child( + ButtonLike::new("chat") + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ModalSurface) + .child(Label::new("Chat")) + .children( + KeyBinding::for_action_in(&Chat, &focus_handle, cx) + .map(|binding| binding.into_any_element()), + ) + .on_click(move |_event, cx| { + focus_handle.dispatch_action(&Chat, cx); + }), + ), + ), + ) + } +} diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs new file mode 100644 index 0000000000..833f8c9b03 --- /dev/null +++ b/crates/assistant2/src/thread.rs @@ -0,0 +1,481 @@ +use std::sync::Arc; + +use anyhow::Result; +use assistant_tool::ToolWorkingSet; +use chrono::{DateTime, Utc}; +use collections::HashMap; +use futures::future::Shared; +use futures::{FutureExt as _, StreamExt as _}; +use gpui::{AppContext, EventEmitter, ModelContext, SharedString, Task}; +use language_model::{ + LanguageModel, LanguageModelCompletionEvent, LanguageModelRegistry, LanguageModelRequest, + LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelToolUse, + LanguageModelToolUseId, MessageContent, Role, StopReason, +}; +use language_models::provider::cloud::{MaxMonthlySpendReachedError, PaymentRequiredError}; +use serde::{Deserialize, Serialize}; +use util::{post_inc, TryFutureExt as _}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy)] +pub enum RequestKind { + Chat, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)] +pub struct ThreadId(Arc); + +impl ThreadId { + pub fn new() -> Self { + Self(Uuid::new_v4().to_string().into()) + } +} + +impl std::fmt::Display for ThreadId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] +pub struct MessageId(usize); + +impl MessageId { + fn post_inc(&mut self) -> Self { + Self(post_inc(&mut self.0)) + } +} + +/// A message in a [`Thread`]. +#[derive(Debug, Clone)] +pub struct Message { + pub id: MessageId, + pub role: Role, + pub text: String, +} + +/// A thread of conversation with the LLM. +pub struct Thread { + id: ThreadId, + updated_at: DateTime, + summary: Option, + pending_summary: Task>, + messages: Vec, + next_message_id: MessageId, + completion_count: usize, + pending_completions: Vec, + tools: Arc, + tool_uses_by_message: HashMap>, + tool_results_by_message: HashMap>, + pending_tool_uses_by_id: HashMap, +} + +impl Thread { + pub fn new(tools: Arc, _cx: &mut ModelContext) -> Self { + Self { + id: ThreadId::new(), + updated_at: Utc::now(), + summary: None, + pending_summary: Task::ready(None), + messages: Vec::new(), + next_message_id: MessageId(0), + completion_count: 0, + pending_completions: Vec::new(), + tools, + tool_uses_by_message: HashMap::default(), + tool_results_by_message: HashMap::default(), + pending_tool_uses_by_id: HashMap::default(), + } + } + + pub fn id(&self) -> &ThreadId { + &self.id + } + + pub fn is_empty(&self) -> bool { + self.messages.is_empty() + } + + pub fn updated_at(&self) -> DateTime { + self.updated_at + } + + pub fn touch_updated_at(&mut self) { + self.updated_at = Utc::now(); + } + + pub fn summary(&self) -> Option { + self.summary.clone() + } + + pub fn set_summary(&mut self, summary: impl Into, cx: &mut ModelContext) { + self.summary = Some(summary.into()); + cx.emit(ThreadEvent::SummaryChanged); + } + + pub fn message(&self, id: MessageId) -> Option<&Message> { + self.messages.iter().find(|message| message.id == id) + } + + pub fn messages(&self) -> impl Iterator { + self.messages.iter() + } + + pub fn tools(&self) -> &Arc { + &self.tools + } + + pub fn pending_tool_uses(&self) -> Vec<&PendingToolUse> { + self.pending_tool_uses_by_id.values().collect() + } + + pub fn insert_user_message(&mut self, text: impl Into, cx: &mut ModelContext) { + self.insert_message(Role::User, text, cx) + } + + pub fn insert_message( + &mut self, + role: Role, + text: impl Into, + cx: &mut ModelContext, + ) { + let id = self.next_message_id.post_inc(); + self.messages.push(Message { + id, + role, + text: text.into(), + }); + self.touch_updated_at(); + cx.emit(ThreadEvent::MessageAdded(id)); + } + + pub fn to_completion_request( + &self, + _request_kind: RequestKind, + _cx: &AppContext, + ) -> LanguageModelRequest { + let mut request = LanguageModelRequest { + messages: vec![], + tools: Vec::new(), + stop: Vec::new(), + temperature: None, + }; + + for message in &self.messages { + let mut request_message = LanguageModelRequestMessage { + role: message.role, + content: Vec::new(), + cache: false, + }; + + if let Some(tool_results) = self.tool_results_by_message.get(&message.id) { + for tool_result in tool_results { + request_message + .content + .push(MessageContent::ToolResult(tool_result.clone())); + } + } + + if !message.text.is_empty() { + request_message + .content + .push(MessageContent::Text(message.text.clone())); + } + + if let Some(tool_uses) = self.tool_uses_by_message.get(&message.id) { + for tool_use in tool_uses { + request_message + .content + .push(MessageContent::ToolUse(tool_use.clone())); + } + } + + request.messages.push(request_message); + } + + request + } + + pub fn stream_completion( + &mut self, + request: LanguageModelRequest, + model: Arc, + cx: &mut ModelContext, + ) { + let pending_completion_id = post_inc(&mut self.completion_count); + + let task = cx.spawn(|thread, mut cx| async move { + let stream = model.stream_completion(request, &cx); + let stream_completion = async { + let mut events = stream.await?; + let mut stop_reason = StopReason::EndTurn; + + while let Some(event) = events.next().await { + let event = event?; + + thread.update(&mut cx, |thread, cx| { + match event { + LanguageModelCompletionEvent::StartMessage { .. } => { + thread.insert_message(Role::Assistant, String::new(), cx); + } + LanguageModelCompletionEvent::Stop(reason) => { + stop_reason = reason; + } + LanguageModelCompletionEvent::Text(chunk) => { + if let Some(last_message) = thread.messages.last_mut() { + if last_message.role == Role::Assistant { + last_message.text.push_str(&chunk); + cx.emit(ThreadEvent::StreamedAssistantText( + last_message.id, + chunk, + )); + } + } + } + LanguageModelCompletionEvent::ToolUse(tool_use) => { + if let Some(last_assistant_message) = thread + .messages + .iter() + .rfind(|message| message.role == Role::Assistant) + { + thread + .tool_uses_by_message + .entry(last_assistant_message.id) + .or_default() + .push(tool_use.clone()); + + thread.pending_tool_uses_by_id.insert( + tool_use.id.clone(), + PendingToolUse { + assistant_message_id: last_assistant_message.id, + id: tool_use.id, + name: tool_use.name, + input: tool_use.input, + status: PendingToolUseStatus::Idle, + }, + ); + } + } + } + + thread.touch_updated_at(); + cx.emit(ThreadEvent::StreamedCompletion); + cx.notify(); + })?; + + smol::future::yield_now().await; + } + + thread.update(&mut cx, |thread, cx| { + thread + .pending_completions + .retain(|completion| completion.id != pending_completion_id); + + if thread.summary.is_none() && thread.messages.len() >= 2 { + thread.summarize(cx); + } + })?; + + anyhow::Ok(stop_reason) + }; + + let result = stream_completion.await; + + thread + .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()), + ))); + } + } + }) + .ok(); + }); + + self.pending_completions.push(PendingCompletion { + id: pending_completion_id, + _task: task, + }); + } + + pub fn summarize(&mut self, cx: &mut ModelContext) { + let Some(provider) = LanguageModelRegistry::read_global(cx).active_provider() else { + return; + }; + let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else { + return; + }; + + if !provider.is_authenticated(cx) { + return; + } + + let mut request = self.to_completion_request(RequestKind::Chat, cx); + request.messages.push(LanguageModelRequestMessage { + role: Role::User, + content: vec![ + "Generate a concise 3-7 word title for this conversation, omitting punctuation. Go straight to the title, without any preamble and prefix like `Here's a concise suggestion:...` or `Title:`" + .into(), + ], + cache: false, + }); + + self.pending_summary = cx.spawn(|this, mut cx| { + async move { + let stream = model.stream_completion_text(request, &cx); + let mut messages = stream.await?; + + let mut new_summary = String::new(); + while let Some(message) = messages.stream.next().await { + let text = message?; + let mut lines = text.lines(); + new_summary.extend(lines.next()); + + // Stop if the LLM generated multiple lines. + if lines.next().is_some() { + break; + } + } + + this.update(&mut cx, |this, cx| { + if !new_summary.is_empty() { + this.summary = Some(new_summary.into()); + } + + cx.emit(ThreadEvent::SummaryChanged); + })?; + + anyhow::Ok(()) + } + .log_err() + }); + } + + pub fn insert_tool_output( + &mut self, + assistant_message_id: MessageId, + tool_use_id: LanguageModelToolUseId, + output: Task>, + cx: &mut ModelContext, + ) { + let insert_output_task = cx.spawn(|thread, mut cx| { + let tool_use_id = tool_use_id.clone(); + async move { + let output = output.await; + thread + .update(&mut cx, |thread, cx| { + // The tool use was requested by an Assistant message, + // so we want to attach the tool results to the next + // user message. + let next_user_message = MessageId(assistant_message_id.0 + 1); + + let tool_results = thread + .tool_results_by_message + .entry(next_user_message) + .or_default(); + + match output { + Ok(output) => { + tool_results.push(LanguageModelToolResult { + tool_use_id: tool_use_id.to_string(), + content: output, + is_error: false, + }); + + cx.emit(ThreadEvent::ToolFinished { tool_use_id }); + } + Err(err) => { + tool_results.push(LanguageModelToolResult { + tool_use_id: tool_use_id.to_string(), + content: err.to_string(), + is_error: true, + }); + + if let Some(tool_use) = + thread.pending_tool_uses_by_id.get_mut(&tool_use_id) + { + tool_use.status = PendingToolUseStatus::Error(err.to_string()); + } + } + } + }) + .ok(); + } + }); + + if let Some(tool_use) = self.pending_tool_uses_by_id.get_mut(&tool_use_id) { + tool_use.status = PendingToolUseStatus::Running { + _task: insert_output_task.shared(), + }; + } + } +} + +#[derive(Debug, Clone)] +pub enum ThreadError { + PaymentRequired, + MaxMonthlySpendReached, + Message(SharedString), +} + +#[derive(Debug, Clone)] +pub enum ThreadEvent { + ShowError(ThreadError), + StreamedCompletion, + StreamedAssistantText(MessageId, String), + MessageAdded(MessageId), + SummaryChanged, + UsePendingTools, + ToolFinished { + #[allow(unused)] + tool_use_id: LanguageModelToolUseId, + }, +} + +impl EventEmitter for Thread {} + +struct PendingCompletion { + id: usize, + _task: Task<()>, +} + +#[derive(Debug, Clone)] +pub struct PendingToolUse { + pub id: LanguageModelToolUseId, + /// The ID of the Assistant message in which the tool use was requested. + pub assistant_message_id: MessageId, + pub name: String, + pub input: serde_json::Value, + pub status: PendingToolUseStatus, +} + +#[derive(Debug, Clone)] +pub enum PendingToolUseStatus { + Idle, + Running { _task: Shared> }, + Error(#[allow(unused)] String), +} + +impl PendingToolUseStatus { + pub fn is_idle(&self) -> bool { + matches!(self, PendingToolUseStatus::Idle) + } +} diff --git a/crates/assistant2/src/thread_history.rs b/crates/assistant2/src/thread_history.rs new file mode 100644 index 0000000000..f183276f7b --- /dev/null +++ b/crates/assistant2/src/thread_history.rs @@ -0,0 +1,156 @@ +use gpui::{ + uniform_list, AppContext, FocusHandle, FocusableView, Model, UniformListScrollHandle, WeakView, +}; +use time::{OffsetDateTime, UtcOffset}; +use ui::{prelude::*, IconButtonShape, ListItem}; + +use crate::thread::Thread; +use crate::thread_store::ThreadStore; +use crate::AssistantPanel; + +pub struct ThreadHistory { + focus_handle: FocusHandle, + assistant_panel: WeakView, + thread_store: Model, + scroll_handle: UniformListScrollHandle, +} + +impl ThreadHistory { + pub(crate) fn new( + assistant_panel: WeakView, + thread_store: Model, + cx: &mut ViewContext, + ) -> Self { + Self { + focus_handle: cx.focus_handle(), + assistant_panel, + thread_store, + scroll_handle: UniformListScrollHandle::default(), + } + } +} + +impl FocusableView for ThreadHistory { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ThreadHistory { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let threads = self.thread_store.update(cx, |this, cx| this.threads(cx)); + + v_flex() + .id("thread-history-container") + .track_focus(&self.focus_handle) + .overflow_y_scroll() + .size_full() + .p_1() + .map(|history| { + if threads.is_empty() { + history + .justify_center() + .child( + h_flex().w_full().justify_center().child( + Label::new("You don't have any past threads yet.") + .size(LabelSize::Small), + ), + ) + } else { + history.child( + uniform_list( + cx.view().clone(), + "thread-history", + threads.len(), + move |history, range, _cx| { + threads[range] + .iter() + .map(|thread| { + PastThread::new( + thread.clone(), + history.assistant_panel.clone(), + ) + }) + .collect() + }, + ) + .track_scroll(self.scroll_handle.clone()) + .flex_grow(), + ) + } + }) + } +} + +#[derive(IntoElement)] +pub struct PastThread { + thread: Model, + assistant_panel: WeakView, +} + +impl PastThread { + pub fn new(thread: Model, assistant_panel: WeakView) -> Self { + Self { + thread, + assistant_panel, + } + } +} + +impl RenderOnce for PastThread { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let (id, summary) = { + const DEFAULT_SUMMARY: SharedString = SharedString::new_static("New Thread"); + let thread = self.thread.read(cx); + ( + thread.id().clone(), + thread.summary().unwrap_or(DEFAULT_SUMMARY), + ) + }; + + let thread_timestamp = time_format::format_localized_timestamp( + OffsetDateTime::from_unix_timestamp(self.thread.read(cx).updated_at().timestamp()) + .unwrap(), + OffsetDateTime::now_utc(), + self.assistant_panel + .update(cx, |this, _cx| this.local_timezone()) + .unwrap_or(UtcOffset::UTC), + time_format::TimestampFormat::EnhancedAbsolute, + ); + ListItem::new(("past-thread", self.thread.entity_id())) + .start_slot(Icon::new(IconName::MessageBubbles)) + .child(Label::new(summary)) + .end_slot( + h_flex() + .gap_2() + .child(Label::new(thread_timestamp).color(Color::Disabled)) + .child( + IconButton::new("delete", IconName::TrashAlt) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) + .on_click({ + let assistant_panel = self.assistant_panel.clone(); + let id = id.clone(); + move |_event, cx| { + assistant_panel + .update(cx, |this, cx| { + this.delete_thread(&id, cx); + }) + .ok(); + } + }), + ), + ) + .on_click({ + let assistant_panel = self.assistant_panel.clone(); + let id = id.clone(); + move |_event, cx| { + assistant_panel + .update(cx, |this, cx| { + this.open_thread(&id, cx); + }) + .ok(); + } + }) + } +} diff --git a/crates/assistant2/src/thread_store.rs b/crates/assistant2/src/thread_store.rs new file mode 100644 index 0000000000..94cb72ce43 --- /dev/null +++ b/crates/assistant2/src/thread_store.rs @@ -0,0 +1,242 @@ +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 unindent::Unindent; +use util::ResultExt as _; + +use crate::thread::{Thread, ThreadId}; + +pub struct ThreadStore { + #[allow(unused)] + project: Model, + tools: Arc, + context_server_manager: Model, + context_server_tool_ids: HashMap, Vec>, + threads: 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 mut this = Self { + project, + tools, + context_server_manager, + context_server_tool_ids: HashMap::default(), + threads: Vec::new(), + }; + this.mock_recent_threads(cx); + this.register_context_server_handlers(cx); + + this + })?; + + Ok(this) + }) + } + + pub fn threads(&self, cx: &ModelContext) -> Vec> { + let mut threads = self + .threads + .iter() + .filter(|thread| !thread.read(cx).is_empty()) + .cloned() + .collect::>(); + threads.sort_unstable_by_key(|thread| std::cmp::Reverse(thread.read(cx).updated_at())); + threads + } + + pub fn recent_threads(&self, limit: usize, cx: &ModelContext) -> Vec> { + self.threads(cx).into_iter().take(limit).collect() + } + + pub fn create_thread(&mut self, cx: &mut ModelContext) -> Model { + let thread = cx.new_model(|cx| Thread::new(self.tools.clone(), cx)); + self.threads.push(thread.clone()); + thread + } + + pub fn open_thread(&self, id: &ThreadId, cx: &mut ModelContext) -> Option> { + self.threads + .iter() + .find(|thread| thread.read(cx).id() == id) + .cloned() + } + + pub fn delete_thread(&mut self, id: &ThreadId, cx: &mut ModelContext) { + self.threads.retain(|thread| thread.read(cx).id() != id); + } + + 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); + } + } + } + } +} + +impl ThreadStore { + /// Creates some mocked recent threads for testing purposes. + fn mock_recent_threads(&mut self, cx: &mut ModelContext) { + use language_model::Role; + + self.threads.push(cx.new_model(|cx| { + let mut thread = Thread::new(self.tools.clone(), cx); + thread.set_summary("Introduction to quantum computing", cx); + thread.insert_user_message("Hello! Can you help me understand quantum computing?", cx); + thread.insert_message(Role::Assistant, "Of course! I'd be happy to help you understand quantum computing. Quantum computing is a fascinating field that uses the principles of quantum mechanics to process information. Unlike classical computers that use bits (0s and 1s), quantum computers use quantum bits or 'qubits'. These qubits can exist in multiple states simultaneously, a property called superposition. This allows quantum computers to perform certain calculations much faster than classical computers. What specific aspect of quantum computing would you like to know more about?", cx); + thread.insert_user_message("That's interesting! Can you explain how quantum entanglement is used in quantum computing?", cx); + thread.insert_message(Role::Assistant, "Certainly! Quantum entanglement is a key principle used in quantum computing. When two qubits become entangled, the state of one qubit is directly related to the state of the other, regardless of the distance between them. This property is used in quantum computing to create complex quantum states and to perform operations on multiple qubits simultaneously. Entanglement allows quantum computers to process information in ways that classical computers cannot, potentially solving certain problems much more efficiently. For example, it's crucial in quantum error correction and in algorithms like quantum teleportation, which is important for quantum communication.", cx); + thread + })); + + self.threads.push(cx.new_model(|cx| { + let mut thread = Thread::new(self.tools.clone(), cx); + thread.set_summary("Rust web development and async programming", cx); + thread.insert_user_message("Can you show me an example of Rust code for a simple web server?", cx); + thread.insert_message(Role::Assistant, "Certainly! Here's an example of a simple web server in Rust using the `actix-web` framework: + + ```rust + use actix_web::{web, App, HttpResponse, HttpServer, Responder}; + + async fn hello() -> impl Responder { + HttpResponse::Ok().body(\"Hello, World!\") + } + + #[actix_web::main] + async fn main() -> std::io::Result<()> { + HttpServer::new(|| { + App::new() + .route(\"/\", web::get().to(hello)) + }) + .bind(\"127.0.0.1:8080\")? + .run() + .await + } + ``` + + This code creates a basic web server that responds with 'Hello, World!' when you access the root URL. Here's a breakdown of what's happening: + + 1. We import necessary items from the `actix-web` crate. + 2. We define an async `hello` function that returns a simple HTTP response. + 3. In the `main` function, we set up the server to listen on `127.0.0.1:8080`. + 4. We configure the app to respond to GET requests on the root path with our `hello` function. + + To run this, you'd need to add `actix-web` to your `Cargo.toml` dependencies: + + ```toml + [dependencies] + actix-web = \"4.0\" + ``` + + Then you can run the server with `cargo run` and access it at `http://localhost:8080`.".unindent(), cx); + thread.insert_user_message("That's great! Can you explain more about async functions in Rust?", cx); + thread.insert_message(Role::Assistant, "Certainly! Async functions are a key feature in Rust for writing efficient, non-blocking code, especially for I/O-bound operations. Here's an overview: + + 1. **Syntax**: Async functions are declared using the `async` keyword: + + ```rust + async fn my_async_function() -> Result<(), Error> { + // Asynchronous code here + } + ``` + + 2. **Futures**: Async functions return a `Future`. A `Future` represents a value that may not be available yet but will be at some point. + + 3. **Await**: Inside an async function, you can use the `.await` syntax to wait for other async operations to complete: + + ```rust + async fn fetch_data() -> Result { + let response = make_http_request().await?; + let data = process_response(response).await?; + Ok(data) + } + ``` + + 4. **Non-blocking**: Async functions allow the runtime to work on other tasks while waiting for I/O or other operations to complete, making efficient use of system resources. + + 5. **Runtime**: To execute async code, you need a runtime like `tokio` or `async-std`. Actix-web, which we used in the previous example, includes its own runtime. + + 6. **Error Handling**: Async functions work well with Rust's `?` operator for error handling. + + Async programming in Rust provides a powerful way to write concurrent code that's both safe and efficient. It's particularly useful for servers, network programming, and any application that deals with many concurrent operations.".unindent(), cx); + thread + })); + } +} diff --git a/crates/assistant_tool/src/assistant_tool.rs b/crates/assistant_tool/src/assistant_tool.rs index 179bfe8dd1..c993494495 100644 --- a/crates/assistant_tool/src/assistant_tool.rs +++ b/crates/assistant_tool/src/assistant_tool.rs @@ -1,4 +1,5 @@ mod tool_registry; +mod tool_working_set; use std::sync::Arc; @@ -6,7 +7,8 @@ use anyhow::Result; use gpui::{AppContext, Task, WeakView, WindowContext}; use workspace::Workspace; -pub use tool_registry::*; +pub use crate::tool_registry::*; +pub use crate::tool_working_set::*; pub fn init(cx: &mut AppContext) { ToolRegistry::default_global(cx); diff --git a/crates/assistant/src/tool_working_set.rs b/crates/assistant_tool/src/tool_working_set.rs similarity index 98% rename from crates/assistant/src/tool_working_set.rs rename to crates/assistant_tool/src/tool_working_set.rs index aa2bb7a530..f22f0c7881 100644 --- a/crates/assistant/src/tool_working_set.rs +++ b/crates/assistant_tool/src/tool_working_set.rs @@ -1,8 +1,10 @@ -use assistant_tool::{Tool, ToolRegistry}; +use std::sync::Arc; + use collections::HashMap; use gpui::AppContext; use parking_lot::Mutex; -use std::sync::Arc; + +use crate::{Tool, ToolRegistry}; #[derive(Copy, Clone, PartialEq, Eq, Hash, Default)] pub struct ToolId(usize); diff --git a/crates/assistant_tools/Cargo.toml b/crates/assistant_tools/Cargo.toml new file mode 100644 index 0000000000..4e92d67299 --- /dev/null +++ b/crates/assistant_tools/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "assistant_tools" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/assistant_tools.rs" + +[dependencies] +anyhow.workspace = true +assistant_tool.workspace = true +chrono.workspace = true +gpui.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +workspace.workspace = true diff --git a/crates/context_servers/LICENSE-GPL b/crates/assistant_tools/LICENSE-GPL similarity index 100% rename from crates/context_servers/LICENSE-GPL rename to crates/assistant_tools/LICENSE-GPL diff --git a/crates/assistant_tools/src/assistant_tools.rs b/crates/assistant_tools/src/assistant_tools.rs new file mode 100644 index 0000000000..7d145c61b7 --- /dev/null +++ b/crates/assistant_tools/src/assistant_tools.rs @@ -0,0 +1,13 @@ +mod now_tool; + +use assistant_tool::ToolRegistry; +use gpui::AppContext; + +use crate::now_tool::NowTool; + +pub fn init(cx: &mut AppContext) { + assistant_tool::init(cx); + + let registry = ToolRegistry::global(cx); + registry.register_tool(NowTool); +} diff --git a/crates/assistant/src/tools/now_tool.rs b/crates/assistant_tools/src/now_tool.rs similarity index 88% rename from crates/assistant/src/tools/now_tool.rs rename to crates/assistant_tools/src/now_tool.rs index 99034321b1..707f2be2bd 100644 --- a/crates/assistant/src/tools/now_tool.rs +++ b/crates/assistant_tools/src/now_tool.rs @@ -30,7 +30,7 @@ impl Tool for NowTool { } fn description(&self) -> String { - "Returns the current datetime in RFC 3339 format.".into() + "Returns the current datetime in RFC 3339 format. Only use this tool when the user specifically asks for it or the current task would benefit from knowing the current datetime.".into() } fn input_schema(&self) -> serde_json::Value { diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml index 9502b58f93..f3bc173764 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/call/Cargo.toml b/crates/call/Cargo.toml index 974c860c08..9ba10e56ba 100644 --- a/crates/call/Cargo.toml +++ b/crates/call/Cargo.toml @@ -17,7 +17,7 @@ test-support = [ "client/test-support", "collections/test-support", "gpui/test-support", - "live_kit_client/test-support", + "livekit_client/test-support", "project/test-support", "util/test-support" ] @@ -27,11 +27,11 @@ anyhow.workspace = true audio.workspace = true client.workspace = true collections.workspace = true +feature_flags.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true -live_kit_client.workspace = true log.workspace = true postage.workspace = true project.workspace = true @@ -41,13 +41,24 @@ serde_derive.workspace = true settings.workspace = true util.workspace = true +[target.'cfg(target_os = "macos")'.dependencies] +livekit_client_macos = { workspace = true } + +[target.'cfg(not(target_os = "macos"))'.dependencies] +livekit_client = { workspace = true } + [dev-dependencies] client = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } -live_kit_client = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } http_client = { workspace = true, features = ["test-support"] } + +[target.'cfg(target_os = "macos")'.dev-dependencies] +livekit_client_macos = { workspace = true, features = ["test-support"] } + +[target.'cfg(not(target_os = "macos"))'.dev-dependencies] +livekit_client = { workspace = true, features = ["test-support"] } diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index c7993f3658..5e212d35b7 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -1,546 +1,13 @@ pub mod call_settings; -pub mod participant; -pub mod room; -use anyhow::{anyhow, Result}; -use audio::Audio; -use call_settings::CallSettings; -use client::{proto, ChannelId, Client, TypedEnvelope, User, UserStore, ZED_ALWAYS_ACTIVE}; -use collections::HashSet; -use futures::{channel::oneshot, future::Shared, Future, FutureExt}; -use gpui::{ - AppContext, AsyncAppContext, Context, EventEmitter, Global, Model, ModelContext, Subscription, - Task, WeakModel, -}; -use postage::watch; -use project::Project; -use room::Event; -use settings::Settings; -use std::sync::Arc; +#[cfg(target_os = "macos")] +mod macos; -pub use participant::ParticipantLocation; -pub use room::Room; +#[cfg(target_os = "macos")] +pub use macos::*; -struct GlobalActiveCall(Model); +#[cfg(not(target_os = "macos"))] +mod cross_platform; -impl Global for GlobalActiveCall {} - -pub fn init(client: Arc, user_store: Model, cx: &mut AppContext) { - CallSettings::register(cx); - - let active_call = cx.new_model(|cx| ActiveCall::new(client, user_store, cx)); - cx.set_global(GlobalActiveCall(active_call)); -} - -pub struct OneAtATime { - cancel: Option>, -} - -impl OneAtATime { - /// spawn a task in the given context. - /// if another task is spawned before that resolves, or if the OneAtATime itself is dropped, the first task will be cancelled and return Ok(None) - /// otherwise you'll see the result of the task. - fn spawn(&mut self, cx: &mut AppContext, f: F) -> Task>> - where - F: 'static + FnOnce(AsyncAppContext) -> Fut, - Fut: Future>, - R: 'static, - { - let (tx, rx) = oneshot::channel(); - self.cancel.replace(tx); - cx.spawn(|cx| async move { - futures::select_biased! { - _ = rx.fuse() => Ok(None), - result = f(cx).fuse() => result.map(Some), - } - }) - } - - fn running(&self) -> bool { - self.cancel - .as_ref() - .is_some_and(|cancel| !cancel.is_canceled()) - } -} - -#[derive(Clone)] -pub struct IncomingCall { - pub room_id: u64, - pub calling_user: Arc, - pub participants: Vec>, - pub initial_project: Option, -} - -/// Singleton global maintaining the user's participation in a room across workspaces. -pub struct ActiveCall { - room: Option<(Model, Vec)>, - pending_room_creation: Option, Arc>>>>, - location: Option>, - _join_debouncer: OneAtATime, - pending_invites: HashSet, - incoming_call: ( - watch::Sender>, - watch::Receiver>, - ), - client: Arc, - user_store: Model, - _subscriptions: Vec, -} - -impl EventEmitter for ActiveCall {} - -impl ActiveCall { - fn new(client: Arc, user_store: Model, cx: &mut ModelContext) -> Self { - Self { - room: None, - pending_room_creation: None, - location: None, - pending_invites: Default::default(), - incoming_call: watch::channel(), - _join_debouncer: OneAtATime { cancel: None }, - _subscriptions: vec![ - client.add_request_handler(cx.weak_model(), Self::handle_incoming_call), - client.add_message_handler(cx.weak_model(), Self::handle_call_canceled), - ], - client, - user_store, - } - } - - pub fn channel_id(&self, cx: &AppContext) -> Option { - self.room()?.read(cx).channel_id() - } - - async fn handle_incoming_call( - this: Model, - envelope: TypedEnvelope, - mut cx: AsyncAppContext, - ) -> Result { - let user_store = this.update(&mut cx, |this, _| this.user_store.clone())?; - let call = IncomingCall { - room_id: envelope.payload.room_id, - participants: user_store - .update(&mut cx, |user_store, cx| { - user_store.get_users(envelope.payload.participant_user_ids, cx) - })? - .await?, - calling_user: user_store - .update(&mut cx, |user_store, cx| { - user_store.get_user(envelope.payload.calling_user_id, cx) - })? - .await?, - initial_project: envelope.payload.initial_project, - }; - this.update(&mut cx, |this, _| { - *this.incoming_call.0.borrow_mut() = Some(call); - })?; - - Ok(proto::Ack {}) - } - - async fn handle_call_canceled( - this: Model, - envelope: TypedEnvelope, - mut cx: AsyncAppContext, - ) -> Result<()> { - this.update(&mut cx, |this, _| { - let mut incoming_call = this.incoming_call.0.borrow_mut(); - if incoming_call - .as_ref() - .map_or(false, |call| call.room_id == envelope.payload.room_id) - { - incoming_call.take(); - } - })?; - Ok(()) - } - - pub fn global(cx: &AppContext) -> Model { - cx.global::().0.clone() - } - - pub fn try_global(cx: &AppContext) -> Option> { - cx.try_global::() - .map(|call| call.0.clone()) - } - - pub fn invite( - &mut self, - called_user_id: u64, - initial_project: Option>, - cx: &mut ModelContext, - ) -> Task> { - if !self.pending_invites.insert(called_user_id) { - return Task::ready(Err(anyhow!("user was already invited"))); - } - cx.notify(); - - if self._join_debouncer.running() { - return Task::ready(Ok(())); - } - - let room = if let Some(room) = self.room().cloned() { - Some(Task::ready(Ok(room)).shared()) - } else { - self.pending_room_creation.clone() - }; - - let invite = if let Some(room) = room { - cx.spawn(move |_, mut cx| async move { - let room = room.await.map_err(|err| anyhow!("{:?}", err))?; - - let initial_project_id = if let Some(initial_project) = initial_project { - Some( - room.update(&mut cx, |room, cx| room.share_project(initial_project, cx))? - .await?, - ) - } else { - None - }; - - room.update(&mut cx, move |room, cx| { - room.call(called_user_id, initial_project_id, cx) - })? - .await?; - - anyhow::Ok(()) - }) - } else { - let client = self.client.clone(); - let user_store = self.user_store.clone(); - let room = cx - .spawn(move |this, mut cx| async move { - let create_room = async { - let room = cx - .update(|cx| { - Room::create( - called_user_id, - initial_project, - client, - user_store, - cx, - ) - })? - .await?; - - this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx))? - .await?; - - anyhow::Ok(room) - }; - - let room = create_room.await; - this.update(&mut cx, |this, _| this.pending_room_creation = None)?; - room.map_err(Arc::new) - }) - .shared(); - self.pending_room_creation = Some(room.clone()); - cx.background_executor().spawn(async move { - room.await.map_err(|err| anyhow!("{:?}", err))?; - anyhow::Ok(()) - }) - }; - - cx.spawn(move |this, mut cx| async move { - let result = invite.await; - if result.is_ok() { - this.update(&mut cx, |this, cx| this.report_call_event("invite", cx))?; - } else { - //TODO: report collaboration error - log::error!("invite failed: {:?}", result); - } - - this.update(&mut cx, |this, cx| { - this.pending_invites.remove(&called_user_id); - cx.notify(); - })?; - result - }) - } - - pub fn cancel_invite( - &mut self, - called_user_id: u64, - cx: &mut ModelContext, - ) -> Task> { - let room_id = if let Some(room) = self.room() { - room.read(cx).id() - } else { - return Task::ready(Err(anyhow!("no active call"))); - }; - - let client = self.client.clone(); - cx.background_executor().spawn(async move { - client - .request(proto::CancelCall { - room_id, - called_user_id, - }) - .await?; - anyhow::Ok(()) - }) - } - - pub fn incoming(&self) -> watch::Receiver> { - self.incoming_call.1.clone() - } - - pub fn accept_incoming(&mut self, cx: &mut ModelContext) -> Task> { - if self.room.is_some() { - return Task::ready(Err(anyhow!("cannot join while on another call"))); - } - - let call = if let Some(call) = self.incoming_call.0.borrow_mut().take() { - call - } else { - return Task::ready(Err(anyhow!("no incoming call"))); - }; - - if self.pending_room_creation.is_some() { - return Task::ready(Ok(())); - } - - let room_id = call.room_id; - let client = self.client.clone(); - let user_store = self.user_store.clone(); - let join = self - ._join_debouncer - .spawn(cx, move |cx| Room::join(room_id, client, user_store, cx)); - - cx.spawn(|this, mut cx| async move { - let room = join.await?; - this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))? - .await?; - this.update(&mut cx, |this, cx| { - this.report_call_event("accept incoming", cx) - })?; - Ok(()) - }) - } - - pub fn decline_incoming(&mut self, _: &mut ModelContext) -> Result<()> { - let call = self - .incoming_call - .0 - .borrow_mut() - .take() - .ok_or_else(|| anyhow!("no incoming call"))?; - report_call_event_for_room("decline incoming", call.room_id, None, &self.client); - self.client.send(proto::DeclineCall { - room_id: call.room_id, - })?; - Ok(()) - } - - pub fn join_channel( - &mut self, - channel_id: ChannelId, - cx: &mut ModelContext, - ) -> Task>>> { - if let Some(room) = self.room().cloned() { - if room.read(cx).channel_id() == Some(channel_id) { - return Task::ready(Ok(Some(room))); - } else { - room.update(cx, |room, cx| room.clear_state(cx)); - } - } - - if self.pending_room_creation.is_some() { - return Task::ready(Ok(None)); - } - - let client = self.client.clone(); - let user_store = self.user_store.clone(); - let join = self._join_debouncer.spawn(cx, move |cx| async move { - Room::join_channel(channel_id, client, user_store, cx).await - }); - - cx.spawn(|this, mut cx| async move { - let room = join.await?; - this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))? - .await?; - this.update(&mut cx, |this, cx| { - this.report_call_event("join channel", cx) - })?; - Ok(room) - }) - } - - pub fn hang_up(&mut self, cx: &mut ModelContext) -> Task> { - cx.notify(); - self.report_call_event("hang up", cx); - - Audio::end_call(cx); - - let channel_id = self.channel_id(cx); - if let Some((room, _)) = self.room.take() { - cx.emit(Event::RoomLeft { channel_id }); - room.update(cx, |room, cx| room.leave(cx)) - } else { - Task::ready(Ok(())) - } - } - - pub fn share_project( - &mut self, - project: Model, - cx: &mut ModelContext, - ) -> Task> { - if let Some((room, _)) = self.room.as_ref() { - self.report_call_event("share project", cx); - room.update(cx, |room, cx| room.share_project(project, cx)) - } else { - Task::ready(Err(anyhow!("no active call"))) - } - } - - pub fn unshare_project( - &mut self, - project: Model, - cx: &mut ModelContext, - ) -> Result<()> { - if let Some((room, _)) = self.room.as_ref() { - self.report_call_event("unshare project", cx); - room.update(cx, |room, cx| room.unshare_project(project, cx)) - } else { - Err(anyhow!("no active call")) - } - } - - pub fn location(&self) -> Option<&WeakModel> { - self.location.as_ref() - } - - pub fn set_location( - &mut self, - project: Option<&Model>, - cx: &mut ModelContext, - ) -> Task> { - if project.is_some() || !*ZED_ALWAYS_ACTIVE { - self.location = project.map(|project| project.downgrade()); - if let Some((room, _)) = self.room.as_ref() { - return room.update(cx, |room, cx| room.set_location(project, cx)); - } - } - Task::ready(Ok(())) - } - - fn set_room( - &mut self, - room: Option>, - cx: &mut ModelContext, - ) -> Task> { - if room.as_ref() == self.room.as_ref().map(|room| &room.0) { - Task::ready(Ok(())) - } else { - cx.notify(); - if let Some(room) = room { - if room.read(cx).status().is_offline() { - self.room = None; - Task::ready(Ok(())) - } else { - let subscriptions = vec![ - cx.observe(&room, |this, room, cx| { - if room.read(cx).status().is_offline() { - this.set_room(None, cx).detach_and_log_err(cx); - } - - cx.notify(); - }), - cx.subscribe(&room, |_, _, event, cx| cx.emit(event.clone())), - ]; - self.room = Some((room.clone(), subscriptions)); - let location = self - .location - .as_ref() - .and_then(|location| location.upgrade()); - let channel_id = room.read(cx).channel_id(); - cx.emit(Event::RoomJoined { channel_id }); - room.update(cx, |room, cx| room.set_location(location.as_ref(), cx)) - } - } else { - self.room = None; - Task::ready(Ok(())) - } - } - } - - pub fn room(&self) -> Option<&Model> { - self.room.as_ref().map(|(room, _)| room) - } - - pub fn client(&self) -> Arc { - self.client.clone() - } - - pub fn pending_invites(&self) -> &HashSet { - &self.pending_invites - } - - pub fn report_call_event(&self, operation: &'static str, cx: &mut AppContext) { - if let Some(room) = self.room() { - let room = room.read(cx); - report_call_event_for_room(operation, room.id(), room.channel_id(), &self.client); - } - } -} - -pub fn report_call_event_for_room( - operation: &'static str, - room_id: u64, - channel_id: Option, - client: &Arc, -) { - let telemetry = client.telemetry(); - - telemetry.report_call_event(operation, Some(room_id), channel_id) -} - -pub fn report_call_event_for_channel( - operation: &'static str, - channel_id: ChannelId, - client: &Arc, - cx: &AppContext, -) { - let room = ActiveCall::global(cx).read(cx).room(); - - let telemetry = client.telemetry(); - - telemetry.report_call_event(operation, room.map(|r| r.read(cx).id()), Some(channel_id)) -} - -#[cfg(test)] -mod test { - use gpui::TestAppContext; - - use crate::OneAtATime; - - #[gpui::test] - async fn test_one_at_a_time(cx: &mut TestAppContext) { - let mut one_at_a_time = OneAtATime { cancel: None }; - - assert_eq!( - cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(1) })) - .await - .unwrap(), - Some(1) - ); - - let (a, b) = cx.update(|cx| { - ( - one_at_a_time.spawn(cx, |_| async { - panic!(""); - }), - one_at_a_time.spawn(cx, |_| async { Ok(3) }), - ) - }); - - assert_eq!(a.await.unwrap(), None::); - assert_eq!(b.await.unwrap(), Some(3)); - - let promise = cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(4) })); - drop(one_at_a_time); - - assert_eq!(promise.await.unwrap(), None); - } -} +#[cfg(not(target_os = "macos"))] +pub use cross_platform::*; diff --git a/crates/call/src/cross_platform/mod.rs b/crates/call/src/cross_platform/mod.rs new file mode 100644 index 0000000000..4a95af1525 --- /dev/null +++ b/crates/call/src/cross_platform/mod.rs @@ -0,0 +1,552 @@ +pub mod participant; +pub mod room; + +use crate::call_settings::CallSettings; +use anyhow::{anyhow, Result}; +use audio::Audio; +use client::{proto, ChannelId, Client, TypedEnvelope, User, UserStore, ZED_ALWAYS_ACTIVE}; +use collections::HashSet; +use futures::{channel::oneshot, future::Shared, Future, FutureExt}; +use gpui::{ + AppContext, AsyncAppContext, Context, EventEmitter, Global, Model, ModelContext, Subscription, + Task, WeakModel, +}; +use postage::watch; +use project::Project; +use room::Event; +use settings::Settings; +use std::sync::Arc; + +pub use livekit_client::{ + track::RemoteVideoTrack, RemoteVideoTrackView, RemoteVideoTrackViewEvent, +}; +pub use participant::ParticipantLocation; +pub use room::Room; + +struct GlobalActiveCall(Model); + +impl Global for GlobalActiveCall {} + +pub fn init(client: Arc, user_store: Model, cx: &mut AppContext) { + livekit_client::init( + cx.background_executor().dispatcher.clone(), + cx.http_client(), + ); + CallSettings::register(cx); + + let active_call = cx.new_model(|cx| ActiveCall::new(client, user_store, cx)); + cx.set_global(GlobalActiveCall(active_call)); +} + +pub struct OneAtATime { + cancel: Option>, +} + +impl OneAtATime { + /// spawn a task in the given context. + /// if another task is spawned before that resolves, or if the OneAtATime itself is dropped, the first task will be cancelled and return Ok(None) + /// otherwise you'll see the result of the task. + fn spawn(&mut self, cx: &mut AppContext, f: F) -> Task>> + where + F: 'static + FnOnce(AsyncAppContext) -> Fut, + Fut: Future>, + R: 'static, + { + let (tx, rx) = oneshot::channel(); + self.cancel.replace(tx); + cx.spawn(|cx| async move { + futures::select_biased! { + _ = rx.fuse() => Ok(None), + result = f(cx).fuse() => result.map(Some), + } + }) + } + + fn running(&self) -> bool { + self.cancel + .as_ref() + .is_some_and(|cancel| !cancel.is_canceled()) + } +} + +#[derive(Clone)] +pub struct IncomingCall { + pub room_id: u64, + pub calling_user: Arc, + pub participants: Vec>, + pub initial_project: Option, +} + +/// Singleton global maintaining the user's participation in a room across workspaces. +pub struct ActiveCall { + room: Option<(Model, Vec)>, + pending_room_creation: Option, Arc>>>>, + location: Option>, + _join_debouncer: OneAtATime, + pending_invites: HashSet, + incoming_call: ( + watch::Sender>, + watch::Receiver>, + ), + client: Arc, + user_store: Model, + _subscriptions: Vec, +} + +impl EventEmitter for ActiveCall {} + +impl ActiveCall { + fn new(client: Arc, user_store: Model, cx: &mut ModelContext) -> Self { + Self { + room: None, + pending_room_creation: None, + location: None, + pending_invites: Default::default(), + incoming_call: watch::channel(), + _join_debouncer: OneAtATime { cancel: None }, + _subscriptions: vec![ + client.add_request_handler(cx.weak_model(), Self::handle_incoming_call), + client.add_message_handler(cx.weak_model(), Self::handle_call_canceled), + ], + client, + user_store, + } + } + + pub fn channel_id(&self, cx: &AppContext) -> Option { + self.room()?.read(cx).channel_id() + } + + async fn handle_incoming_call( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result { + let user_store = this.update(&mut cx, |this, _| this.user_store.clone())?; + let call = IncomingCall { + room_id: envelope.payload.room_id, + participants: user_store + .update(&mut cx, |user_store, cx| { + user_store.get_users(envelope.payload.participant_user_ids, cx) + })? + .await?, + calling_user: user_store + .update(&mut cx, |user_store, cx| { + user_store.get_user(envelope.payload.calling_user_id, cx) + })? + .await?, + initial_project: envelope.payload.initial_project, + }; + this.update(&mut cx, |this, _| { + *this.incoming_call.0.borrow_mut() = Some(call); + })?; + + Ok(proto::Ack {}) + } + + async fn handle_call_canceled( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, _| { + let mut incoming_call = this.incoming_call.0.borrow_mut(); + if incoming_call + .as_ref() + .map_or(false, |call| call.room_id == envelope.payload.room_id) + { + incoming_call.take(); + } + })?; + Ok(()) + } + + pub fn global(cx: &AppContext) -> Model { + cx.global::().0.clone() + } + + pub fn try_global(cx: &AppContext) -> Option> { + cx.try_global::() + .map(|call| call.0.clone()) + } + + pub fn invite( + &mut self, + called_user_id: u64, + initial_project: Option>, + cx: &mut ModelContext, + ) -> Task> { + if !self.pending_invites.insert(called_user_id) { + return Task::ready(Err(anyhow!("user was already invited"))); + } + cx.notify(); + + if self._join_debouncer.running() { + return Task::ready(Ok(())); + } + + let room = if let Some(room) = self.room().cloned() { + Some(Task::ready(Ok(room)).shared()) + } else { + self.pending_room_creation.clone() + }; + + let invite = if let Some(room) = room { + cx.spawn(move |_, mut cx| async move { + let room = room.await.map_err(|err| anyhow!("{:?}", err))?; + + let initial_project_id = if let Some(initial_project) = initial_project { + Some( + room.update(&mut cx, |room, cx| room.share_project(initial_project, cx))? + .await?, + ) + } else { + None + }; + + room.update(&mut cx, move |room, cx| { + room.call(called_user_id, initial_project_id, cx) + })? + .await?; + + anyhow::Ok(()) + }) + } else { + let client = self.client.clone(); + let user_store = self.user_store.clone(); + let room = cx + .spawn(move |this, mut cx| async move { + let create_room = async { + let room = cx + .update(|cx| { + Room::create( + called_user_id, + initial_project, + client, + user_store, + cx, + ) + })? + .await?; + + this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx))? + .await?; + + anyhow::Ok(room) + }; + + let room = create_room.await; + this.update(&mut cx, |this, _| this.pending_room_creation = None)?; + room.map_err(Arc::new) + }) + .shared(); + self.pending_room_creation = Some(room.clone()); + cx.background_executor().spawn(async move { + room.await.map_err(|err| anyhow!("{:?}", err))?; + anyhow::Ok(()) + }) + }; + + cx.spawn(move |this, mut cx| async move { + let result = invite.await; + if result.is_ok() { + this.update(&mut cx, |this, cx| this.report_call_event("invite", cx))?; + } else { + //TODO: report collaboration error + log::error!("invite failed: {:?}", result); + } + + this.update(&mut cx, |this, cx| { + this.pending_invites.remove(&called_user_id); + cx.notify(); + })?; + result + }) + } + + pub fn cancel_invite( + &mut self, + called_user_id: u64, + cx: &mut ModelContext, + ) -> Task> { + let room_id = if let Some(room) = self.room() { + room.read(cx).id() + } else { + return Task::ready(Err(anyhow!("no active call"))); + }; + + let client = self.client.clone(); + cx.background_executor().spawn(async move { + client + .request(proto::CancelCall { + room_id, + called_user_id, + }) + .await?; + anyhow::Ok(()) + }) + } + + pub fn incoming(&self) -> watch::Receiver> { + self.incoming_call.1.clone() + } + + pub fn accept_incoming(&mut self, cx: &mut ModelContext) -> Task> { + if self.room.is_some() { + return Task::ready(Err(anyhow!("cannot join while on another call"))); + } + + let call = if let Some(call) = self.incoming_call.0.borrow_mut().take() { + call + } else { + return Task::ready(Err(anyhow!("no incoming call"))); + }; + + if self.pending_room_creation.is_some() { + return Task::ready(Ok(())); + } + + let room_id = call.room_id; + let client = self.client.clone(); + let user_store = self.user_store.clone(); + let join = self + ._join_debouncer + .spawn(cx, move |cx| Room::join(room_id, client, user_store, cx)); + + cx.spawn(|this, mut cx| async move { + let room = join.await?; + this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))? + .await?; + this.update(&mut cx, |this, cx| { + this.report_call_event("accept incoming", cx) + })?; + Ok(()) + }) + } + + pub fn decline_incoming(&mut self, _: &mut ModelContext) -> Result<()> { + let call = self + .incoming_call + .0 + .borrow_mut() + .take() + .ok_or_else(|| anyhow!("no incoming call"))?; + report_call_event_for_room("decline incoming", call.room_id, None, &self.client); + self.client.send(proto::DeclineCall { + room_id: call.room_id, + })?; + Ok(()) + } + + pub fn join_channel( + &mut self, + channel_id: ChannelId, + cx: &mut ModelContext, + ) -> Task>>> { + if let Some(room) = self.room().cloned() { + if room.read(cx).channel_id() == Some(channel_id) { + return Task::ready(Ok(Some(room))); + } else { + room.update(cx, |room, cx| room.clear_state(cx)); + } + } + + if self.pending_room_creation.is_some() { + return Task::ready(Ok(None)); + } + + let client = self.client.clone(); + let user_store = self.user_store.clone(); + let join = self._join_debouncer.spawn(cx, move |cx| async move { + Room::join_channel(channel_id, client, user_store, cx).await + }); + + cx.spawn(|this, mut cx| async move { + let room = join.await?; + this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))? + .await?; + this.update(&mut cx, |this, cx| { + this.report_call_event("join channel", cx) + })?; + Ok(room) + }) + } + + pub fn hang_up(&mut self, cx: &mut ModelContext) -> Task> { + cx.notify(); + self.report_call_event("hang up", cx); + + Audio::end_call(cx); + + let channel_id = self.channel_id(cx); + if let Some((room, _)) = self.room.take() { + cx.emit(Event::RoomLeft { channel_id }); + room.update(cx, |room, cx| room.leave(cx)) + } else { + Task::ready(Ok(())) + } + } + + pub fn share_project( + &mut self, + project: Model, + cx: &mut ModelContext, + ) -> Task> { + if let Some((room, _)) = self.room.as_ref() { + self.report_call_event("share project", cx); + room.update(cx, |room, cx| room.share_project(project, cx)) + } else { + Task::ready(Err(anyhow!("no active call"))) + } + } + + pub fn unshare_project( + &mut self, + project: Model, + cx: &mut ModelContext, + ) -> Result<()> { + if let Some((room, _)) = self.room.as_ref() { + self.report_call_event("unshare project", cx); + room.update(cx, |room, cx| room.unshare_project(project, cx)) + } else { + Err(anyhow!("no active call")) + } + } + + pub fn location(&self) -> Option<&WeakModel> { + self.location.as_ref() + } + + pub fn set_location( + &mut self, + project: Option<&Model>, + cx: &mut ModelContext, + ) -> Task> { + if project.is_some() || !*ZED_ALWAYS_ACTIVE { + self.location = project.map(|project| project.downgrade()); + if let Some((room, _)) = self.room.as_ref() { + return room.update(cx, |room, cx| room.set_location(project, cx)); + } + } + Task::ready(Ok(())) + } + + fn set_room( + &mut self, + room: Option>, + cx: &mut ModelContext, + ) -> Task> { + if room.as_ref() == self.room.as_ref().map(|room| &room.0) { + Task::ready(Ok(())) + } else { + cx.notify(); + if let Some(room) = room { + if room.read(cx).status().is_offline() { + self.room = None; + Task::ready(Ok(())) + } else { + let subscriptions = vec![ + cx.observe(&room, |this, room, cx| { + if room.read(cx).status().is_offline() { + this.set_room(None, cx).detach_and_log_err(cx); + } + + cx.notify(); + }), + cx.subscribe(&room, |_, _, event, cx| cx.emit(event.clone())), + ]; + self.room = Some((room.clone(), subscriptions)); + let location = self + .location + .as_ref() + .and_then(|location| location.upgrade()); + let channel_id = room.read(cx).channel_id(); + cx.emit(Event::RoomJoined { channel_id }); + room.update(cx, |room, cx| room.set_location(location.as_ref(), cx)) + } + } else { + self.room = None; + Task::ready(Ok(())) + } + } + } + + pub fn room(&self) -> Option<&Model> { + self.room.as_ref().map(|(room, _)| room) + } + + pub fn client(&self) -> Arc { + self.client.clone() + } + + pub fn pending_invites(&self) -> &HashSet { + &self.pending_invites + } + + pub fn report_call_event(&self, operation: &'static str, cx: &mut AppContext) { + if let Some(room) = self.room() { + let room = room.read(cx); + report_call_event_for_room(operation, room.id(), room.channel_id(), &self.client); + } + } +} + +pub fn report_call_event_for_room( + operation: &'static str, + room_id: u64, + channel_id: Option, + client: &Arc, +) { + let telemetry = client.telemetry(); + + telemetry.report_call_event(operation, Some(room_id), channel_id) +} + +pub fn report_call_event_for_channel( + operation: &'static str, + channel_id: ChannelId, + client: &Arc, + cx: &AppContext, +) { + let room = ActiveCall::global(cx).read(cx).room(); + + let telemetry = client.telemetry(); + + telemetry.report_call_event(operation, room.map(|r| r.read(cx).id()), Some(channel_id)) +} + +#[cfg(test)] +mod test { + use gpui::TestAppContext; + + use crate::OneAtATime; + + #[gpui::test] + async fn test_one_at_a_time(cx: &mut TestAppContext) { + let mut one_at_a_time = OneAtATime { cancel: None }; + + assert_eq!( + cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(1) })) + .await + .unwrap(), + Some(1) + ); + + let (a, b) = cx.update(|cx| { + ( + one_at_a_time.spawn(cx, |_| async { + panic!(""); + }), + one_at_a_time.spawn(cx, |_| async { Ok(3) }), + ) + }); + + assert_eq!(a.await.unwrap(), None::); + assert_eq!(b.await.unwrap(), Some(3)); + + let promise = cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(4) })); + drop(one_at_a_time); + + assert_eq!(promise.await.unwrap(), None); + } +} diff --git a/crates/call/src/cross_platform/participant.rs b/crates/call/src/cross_platform/participant.rs new file mode 100644 index 0000000000..2ca33be728 --- /dev/null +++ b/crates/call/src/cross_platform/participant.rs @@ -0,0 +1,68 @@ +#![cfg_attr(target_os = "windows", allow(unused))] + +use anyhow::{anyhow, Result}; +use client::{proto, ParticipantIndex, User}; +use collections::HashMap; +use gpui::WeakModel; +use livekit_client::AudioStream; +use project::Project; +use std::sync::Arc; + +#[cfg(not(target_os = "windows"))] +pub use livekit_client::id::TrackSid; +pub use livekit_client::track::{RemoteAudioTrack, RemoteVideoTrack}; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ParticipantLocation { + SharedProject { project_id: u64 }, + UnsharedProject, + External, +} + +impl ParticipantLocation { + pub fn from_proto(location: Option) -> Result { + match location.and_then(|l| l.variant) { + Some(proto::participant_location::Variant::SharedProject(project)) => { + Ok(Self::SharedProject { + project_id: project.id, + }) + } + Some(proto::participant_location::Variant::UnsharedProject(_)) => { + Ok(Self::UnsharedProject) + } + Some(proto::participant_location::Variant::External(_)) => Ok(Self::External), + None => Err(anyhow!("participant location was not provided")), + } + } +} + +#[derive(Clone, Default)] +pub struct LocalParticipant { + pub projects: Vec, + pub active_project: Option>, + pub role: proto::ChannelRole, +} + +pub struct RemoteParticipant { + pub user: Arc, + pub peer_id: proto::PeerId, + pub role: proto::ChannelRole, + pub projects: Vec, + pub location: ParticipantLocation, + pub participant_index: ParticipantIndex, + pub muted: bool, + pub speaking: bool, + #[cfg(not(target_os = "windows"))] + pub video_tracks: HashMap, + #[cfg(not(target_os = "windows"))] + pub audio_tracks: HashMap, +} + +impl RemoteParticipant { + pub fn has_video_tracks(&self) -> bool { + #[cfg(not(target_os = "windows"))] + return !self.video_tracks.is_empty(); + #[cfg(target_os = "windows")] + return false; + } +} diff --git a/crates/call/src/cross_platform/room.rs b/crates/call/src/cross_platform/room.rs new file mode 100644 index 0000000000..11033098f7 --- /dev/null +++ b/crates/call/src/cross_platform/room.rs @@ -0,0 +1,1771 @@ +#![cfg_attr(target_os = "windows", allow(unused))] + +use crate::{ + call_settings::CallSettings, + participant::{LocalParticipant, ParticipantLocation, RemoteParticipant}, +}; +use anyhow::{anyhow, Result}; +use audio::{Audio, Sound}; +use client::{ + proto::{self, PeerId}, + ChannelId, Client, ParticipantIndex, TypedEnvelope, User, UserStore, +}; +use collections::{BTreeMap, HashMap, HashSet}; +use fs::Fs; +use futures::{FutureExt, StreamExt}; +use gpui::{ + AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, Task, WeakModel, +}; +use language::LanguageRegistry; +#[cfg(not(target_os = "windows"))] +use livekit::{ + capture_local_audio_track, capture_local_video_track, + id::ParticipantIdentity, + options::{TrackPublishOptions, VideoCodec}, + play_remote_audio_track, + publication::LocalTrackPublication, + track::{TrackKind, TrackSource}, + RoomEvent, RoomOptions, +}; +#[cfg(target_os = "windows")] +use livekit::{publication::LocalTrackPublication, RoomEvent}; +use livekit_client as livekit; +use postage::{sink::Sink, stream::Stream, watch}; +use project::Project; +use settings::Settings as _; +use std::{any::Any, future::Future, mem, sync::Arc, time::Duration}; +use util::{post_inc, ResultExt, TryFutureExt}; + +pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Event { + RoomJoined { + channel_id: Option, + }, + ParticipantLocationChanged { + participant_id: proto::PeerId, + }, + RemoteVideoTracksChanged { + participant_id: proto::PeerId, + }, + RemoteAudioTracksChanged { + participant_id: proto::PeerId, + }, + RemoteProjectShared { + owner: Arc, + project_id: u64, + worktree_root_names: Vec, + }, + RemoteProjectUnshared { + project_id: u64, + }, + RemoteProjectJoined { + project_id: u64, + }, + RemoteProjectInvitationDiscarded { + project_id: u64, + }, + RoomLeft { + channel_id: Option, + }, +} + +pub struct Room { + id: u64, + channel_id: Option, + live_kit: Option, + status: RoomStatus, + shared_projects: HashSet>, + joined_projects: HashSet>, + local_participant: LocalParticipant, + remote_participants: BTreeMap, + pending_participants: Vec>, + participant_user_ids: HashSet, + pending_call_count: usize, + leave_when_empty: bool, + client: Arc, + user_store: Model, + follows_by_leader_id_project_id: HashMap<(PeerId, u64), Vec>, + client_subscriptions: Vec, + _subscriptions: Vec, + room_update_completed_tx: watch::Sender>, + room_update_completed_rx: watch::Receiver>, + pending_room_update: Option>, + maintain_connection: Option>>, +} + +impl EventEmitter for Room {} + +impl Room { + pub fn channel_id(&self) -> Option { + self.channel_id + } + + pub fn is_sharing_project(&self) -> bool { + !self.shared_projects.is_empty() + } + + #[cfg(all(any(test, feature = "test-support"), not(target_os = "windows")))] + pub fn is_connected(&self) -> bool { + if let Some(live_kit) = self.live_kit.as_ref() { + live_kit.room.connection_state() == livekit::ConnectionState::Connected + } else { + false + } + } + + fn new( + id: u64, + channel_id: Option, + livekit_connection_info: Option, + client: Arc, + user_store: Model, + cx: &mut ModelContext, + ) -> Self { + spawn_room_connection(livekit_connection_info, cx); + + let maintain_connection = cx.spawn({ + let client = client.clone(); + move |this, cx| Self::maintain_connection(this, client.clone(), cx).log_err() + }); + + Audio::play_sound(Sound::Joined, cx); + + let (room_update_completed_tx, room_update_completed_rx) = watch::channel(); + + Self { + id, + channel_id, + live_kit: None, + status: RoomStatus::Online, + shared_projects: Default::default(), + joined_projects: Default::default(), + participant_user_ids: Default::default(), + local_participant: Default::default(), + remote_participants: Default::default(), + pending_participants: Default::default(), + pending_call_count: 0, + client_subscriptions: vec![ + client.add_message_handler(cx.weak_model(), Self::handle_room_updated) + ], + _subscriptions: vec![ + cx.on_release(Self::released), + cx.on_app_quit(Self::app_will_quit), + ], + leave_when_empty: false, + pending_room_update: None, + client, + user_store, + follows_by_leader_id_project_id: Default::default(), + maintain_connection: Some(maintain_connection), + room_update_completed_tx, + room_update_completed_rx, + } + } + + pub(crate) fn create( + called_user_id: u64, + initial_project: Option>, + client: Arc, + user_store: Model, + cx: &mut AppContext, + ) -> Task>> { + cx.spawn(move |mut cx| async move { + let response = client.request(proto::CreateRoom {}).await?; + let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?; + let room = cx.new_model(|cx| { + let mut room = Self::new( + room_proto.id, + None, + response.live_kit_connection_info, + client, + user_store, + cx, + ); + if let Some(participant) = room_proto.participants.first() { + room.local_participant.role = participant.role() + } + room + })?; + + let initial_project_id = if let Some(initial_project) = initial_project { + let initial_project_id = room + .update(&mut cx, |room, cx| { + room.share_project(initial_project.clone(), cx) + })? + .await?; + Some(initial_project_id) + } else { + None + }; + + let did_join = room + .update(&mut cx, |room, cx| { + room.leave_when_empty = true; + room.call(called_user_id, initial_project_id, cx) + })? + .await; + match did_join { + Ok(()) => Ok(room), + Err(error) => Err(error.context("room creation failed")), + } + }) + } + + pub(crate) async fn join_channel( + channel_id: ChannelId, + client: Arc, + user_store: Model, + cx: AsyncAppContext, + ) -> Result> { + Self::from_join_response( + client + .request(proto::JoinChannel { + channel_id: channel_id.0, + }) + .await?, + client, + user_store, + cx, + ) + } + + pub(crate) async fn join( + room_id: u64, + client: Arc, + user_store: Model, + cx: AsyncAppContext, + ) -> Result> { + Self::from_join_response( + client.request(proto::JoinRoom { id: room_id }).await?, + client, + user_store, + cx, + ) + } + + fn released(&mut self, cx: &mut AppContext) { + if self.status.is_online() { + self.leave_internal(cx).detach_and_log_err(cx); + } + } + + fn app_will_quit(&mut self, cx: &mut ModelContext) -> impl Future { + let task = if self.status.is_online() { + let leave = self.leave_internal(cx); + Some(cx.background_executor().spawn(async move { + leave.await.log_err(); + })) + } else { + None + }; + + async move { + if let Some(task) = task { + task.await; + } + } + } + + pub fn mute_on_join(cx: &AppContext) -> bool { + CallSettings::get_global(cx).mute_on_join || client::IMPERSONATE_LOGIN.is_some() + } + + fn from_join_response( + response: proto::JoinRoomResponse, + client: Arc, + user_store: Model, + mut cx: AsyncAppContext, + ) -> Result> { + let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?; + let room = cx.new_model(|cx| { + Self::new( + room_proto.id, + response.channel_id.map(ChannelId), + response.live_kit_connection_info, + client, + user_store, + cx, + ) + })?; + room.update(&mut cx, |room, cx| { + room.leave_when_empty = room.channel_id.is_none(); + room.apply_room_update(room_proto, cx)?; + anyhow::Ok(()) + })??; + Ok(room) + } + + fn should_leave(&self) -> bool { + self.leave_when_empty + && self.pending_room_update.is_none() + && self.pending_participants.is_empty() + && self.remote_participants.is_empty() + && self.pending_call_count == 0 + } + + pub(crate) fn leave(&mut self, cx: &mut ModelContext) -> Task> { + cx.notify(); + self.leave_internal(cx) + } + + fn leave_internal(&mut self, cx: &mut AppContext) -> Task> { + if self.status.is_offline() { + return Task::ready(Err(anyhow!("room is offline"))); + } + + log::info!("leaving room"); + Audio::play_sound(Sound::Leave, cx); + + self.clear_state(cx); + + let leave_room = self.client.request(proto::LeaveRoom {}); + cx.background_executor().spawn(async move { + leave_room.await?; + anyhow::Ok(()) + }) + } + + pub(crate) fn clear_state(&mut self, cx: &mut AppContext) { + for project in self.shared_projects.drain() { + if let Some(project) = project.upgrade() { + project.update(cx, |project, cx| { + project.unshare(cx).log_err(); + }); + } + } + for project in self.joined_projects.drain() { + if let Some(project) = project.upgrade() { + project.update(cx, |project, cx| { + project.disconnected_from_host(cx); + project.close(cx); + }); + } + } + + self.status = RoomStatus::Offline; + self.remote_participants.clear(); + self.pending_participants.clear(); + self.participant_user_ids.clear(); + self.client_subscriptions.clear(); + self.live_kit.take(); + self.pending_room_update.take(); + self.maintain_connection.take(); + } + + async fn maintain_connection( + this: WeakModel, + client: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + let mut client_status = client.status(); + loop { + let _ = client_status.try_recv(); + let is_connected = client_status.borrow().is_connected(); + // Even if we're initially connected, any future change of the status means we momentarily disconnected. + if !is_connected || client_status.next().await.is_some() { + log::info!("detected client disconnection"); + + this.upgrade() + .ok_or_else(|| anyhow!("room was dropped"))? + .update(&mut cx, |this, cx| { + this.status = RoomStatus::Rejoining; + cx.notify(); + })?; + + // Wait for client to re-establish a connection to the server. + { + let mut reconnection_timeout = + cx.background_executor().timer(RECONNECT_TIMEOUT).fuse(); + let client_reconnection = async { + let mut remaining_attempts = 3; + while remaining_attempts > 0 { + if client_status.borrow().is_connected() { + log::info!("client reconnected, attempting to rejoin room"); + + let Some(this) = this.upgrade() else { break }; + match this.update(&mut cx, |this, cx| this.rejoin(cx)) { + Ok(task) => { + if task.await.log_err().is_some() { + return true; + } else { + remaining_attempts -= 1; + } + } + Err(_app_dropped) => return false, + } + } else if client_status.borrow().is_signed_out() { + return false; + } + + log::info!( + "waiting for client status change, remaining attempts {}", + remaining_attempts + ); + client_status.next().await; + } + false + } + .fuse(); + futures::pin_mut!(client_reconnection); + + futures::select_biased! { + reconnected = client_reconnection => { + if reconnected { + log::info!("successfully reconnected to room"); + // If we successfully joined the room, go back around the loop + // waiting for future connection status changes. + continue; + } + } + _ = reconnection_timeout => { + log::info!("room reconnection timeout expired"); + } + } + } + + break; + } + } + + // The client failed to re-establish a connection to the server + // or an error occurred while trying to re-join the room. Either way + // we leave the room and return an error. + if let Some(this) = this.upgrade() { + log::info!("reconnection failed, leaving room"); + this.update(&mut cx, |this, cx| this.leave(cx))?.await?; + } + Err(anyhow!( + "can't reconnect to room: client failed to re-establish connection" + )) + } + + fn rejoin(&mut self, cx: &mut ModelContext) -> Task> { + let mut projects = HashMap::default(); + let mut reshared_projects = Vec::new(); + let mut rejoined_projects = Vec::new(); + self.shared_projects.retain(|project| { + if let Some(handle) = project.upgrade() { + let project = handle.read(cx); + if let Some(project_id) = project.remote_id() { + projects.insert(project_id, handle.clone()); + reshared_projects.push(proto::UpdateProject { + project_id, + worktrees: project.worktree_metadata_protos(cx), + }); + return true; + } + } + false + }); + self.joined_projects.retain(|project| { + if let Some(handle) = project.upgrade() { + let project = handle.read(cx); + if let Some(project_id) = project.remote_id() { + projects.insert(project_id, handle.clone()); + rejoined_projects.push(proto::RejoinProject { + id: project_id, + worktrees: project + .worktrees(cx) + .map(|worktree| { + let worktree = worktree.read(cx); + proto::RejoinWorktree { + id: worktree.id().to_proto(), + scan_id: worktree.completed_scan_id() as u64, + } + }) + .collect(), + }); + } + return true; + } + false + }); + + let response = self.client.request_envelope(proto::RejoinRoom { + id: self.id, + reshared_projects, + rejoined_projects, + }); + + cx.spawn(|this, mut cx| async move { + let response = response.await?; + let message_id = response.message_id; + let response = response.payload; + let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?; + this.update(&mut cx, |this, cx| { + this.status = RoomStatus::Online; + this.apply_room_update(room_proto, cx)?; + + for reshared_project in response.reshared_projects { + if let Some(project) = projects.get(&reshared_project.id) { + project.update(cx, |project, cx| { + project.reshared(reshared_project, cx).log_err(); + }); + } + } + + for rejoined_project in response.rejoined_projects { + if let Some(project) = projects.get(&rejoined_project.id) { + project.update(cx, |project, cx| { + project.rejoined(rejoined_project, message_id, cx).log_err(); + }); + } + } + + anyhow::Ok(()) + })? + }) + } + + pub fn id(&self) -> u64 { + self.id + } + + pub fn status(&self) -> RoomStatus { + self.status + } + + pub fn local_participant(&self) -> &LocalParticipant { + &self.local_participant + } + + pub fn remote_participants(&self) -> &BTreeMap { + &self.remote_participants + } + + pub fn remote_participant_for_peer_id(&self, peer_id: PeerId) -> Option<&RemoteParticipant> { + self.remote_participants + .values() + .find(|p| p.peer_id == peer_id) + } + + pub fn role_for_user(&self, user_id: u64) -> Option { + self.remote_participants + .get(&user_id) + .map(|participant| participant.role) + } + + pub fn contains_guests(&self) -> bool { + self.local_participant.role == proto::ChannelRole::Guest + || self + .remote_participants + .values() + .any(|p| p.role == proto::ChannelRole::Guest) + } + + pub fn local_participant_is_admin(&self) -> bool { + self.local_participant.role == proto::ChannelRole::Admin + } + + pub fn local_participant_is_guest(&self) -> bool { + self.local_participant.role == proto::ChannelRole::Guest + } + + pub fn set_participant_role( + &mut self, + user_id: u64, + role: proto::ChannelRole, + cx: &ModelContext, + ) -> Task> { + let client = self.client.clone(); + let room_id = self.id; + let role = role.into(); + cx.spawn(|_, _| async move { + client + .request(proto::SetRoomParticipantRole { + room_id, + user_id, + role, + }) + .await + .map(|_| ()) + }) + } + + pub fn pending_participants(&self) -> &[Arc] { + &self.pending_participants + } + + pub fn contains_participant(&self, user_id: u64) -> bool { + self.participant_user_ids.contains(&user_id) + } + + pub fn followers_for(&self, leader_id: PeerId, project_id: u64) -> &[PeerId] { + self.follows_by_leader_id_project_id + .get(&(leader_id, project_id)) + .map_or(&[], |v| v.as_slice()) + } + + /// Returns the most 'active' projects, defined as most people in the project + pub fn most_active_project(&self, cx: &AppContext) -> Option<(u64, u64)> { + let mut project_hosts_and_guest_counts = HashMap::, u32)>::default(); + for participant in self.remote_participants.values() { + match participant.location { + ParticipantLocation::SharedProject { project_id } => { + project_hosts_and_guest_counts + .entry(project_id) + .or_default() + .1 += 1; + } + ParticipantLocation::External | ParticipantLocation::UnsharedProject => {} + } + for project in &participant.projects { + project_hosts_and_guest_counts + .entry(project.id) + .or_default() + .0 = Some(participant.user.id); + } + } + + if let Some(user) = self.user_store.read(cx).current_user() { + for project in &self.local_participant.projects { + project_hosts_and_guest_counts + .entry(project.id) + .or_default() + .0 = Some(user.id); + } + } + + project_hosts_and_guest_counts + .into_iter() + .filter_map(|(id, (host, guest_count))| Some((id, host?, guest_count))) + .max_by_key(|(_, _, guest_count)| *guest_count) + .map(|(id, host, _)| (id, host)) + } + + async fn handle_room_updated( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result<()> { + let room = envelope + .payload + .room + .ok_or_else(|| anyhow!("invalid room"))?; + this.update(&mut cx, |this, cx| this.apply_room_update(room, cx))? + } + + fn apply_room_update(&mut self, room: proto::Room, cx: &mut ModelContext) -> Result<()> { + log::trace!( + "client {:?}. room update: {:?}", + self.client.user_id(), + &room + ); + + self.pending_room_update = Some(self.start_room_connection(room, cx)); + + cx.notify(); + Ok(()) + } + + pub fn room_update_completed(&mut self) -> impl Future { + let mut done_rx = self.room_update_completed_rx.clone(); + async move { + while let Some(result) = done_rx.next().await { + if result.is_some() { + break; + } + } + } + } + + #[cfg(target_os = "windows")] + fn start_room_connection( + &self, + mut room: proto::Room, + cx: &mut ModelContext, + ) -> Task<()> { + Task::ready(()) + } + + #[cfg(not(target_os = "windows"))] + fn start_room_connection( + &self, + mut room: proto::Room, + cx: &mut ModelContext, + ) -> Task<()> { + // Filter ourselves out from the room's participants. + let local_participant_ix = room + .participants + .iter() + .position(|participant| Some(participant.user_id) == self.client.user_id()); + let local_participant = local_participant_ix.map(|ix| room.participants.swap_remove(ix)); + + let pending_participant_user_ids = room + .pending_participants + .iter() + .map(|p| p.user_id) + .collect::>(); + + let remote_participant_user_ids = room + .participants + .iter() + .map(|p| p.user_id) + .collect::>(); + + let (remote_participants, pending_participants) = + self.user_store.update(cx, move |user_store, cx| { + ( + user_store.get_users(remote_participant_user_ids, cx), + user_store.get_users(pending_participant_user_ids, cx), + ) + }); + cx.spawn(|this, mut cx| async move { + let (remote_participants, pending_participants) = + futures::join!(remote_participants, pending_participants); + + this.update(&mut cx, |this, cx| { + this.participant_user_ids.clear(); + + if let Some(participant) = local_participant { + let role = participant.role(); + this.local_participant.projects = participant.projects; + if this.local_participant.role != role { + this.local_participant.role = role; + + if role == proto::ChannelRole::Guest { + for project in mem::take(&mut this.shared_projects) { + if let Some(project) = project.upgrade() { + this.unshare_project(project, cx).log_err(); + } + } + this.local_participant.projects.clear(); + if let Some(livekit_room) = &mut this.live_kit { + livekit_room.stop_publishing(cx); + } + } + + this.joined_projects.retain(|project| { + if let Some(project) = project.upgrade() { + project.update(cx, |project, cx| project.set_role(role, cx)); + true + } else { + false + } + }); + } + } else { + this.local_participant.projects.clear(); + } + + let livekit_participants = this + .live_kit + .as_ref() + .map(|live_kit| live_kit.room.remote_participants()); + + if let Some(participants) = remote_participants.log_err() { + for (participant, user) in room.participants.into_iter().zip(participants) { + let Some(peer_id) = participant.peer_id else { + continue; + }; + let participant_index = ParticipantIndex(participant.participant_index); + this.participant_user_ids.insert(participant.user_id); + + let old_projects = this + .remote_participants + .get(&participant.user_id) + .into_iter() + .flat_map(|existing| &existing.projects) + .map(|project| project.id) + .collect::>(); + let new_projects = participant + .projects + .iter() + .map(|project| project.id) + .collect::>(); + + for project in &participant.projects { + if !old_projects.contains(&project.id) { + cx.emit(Event::RemoteProjectShared { + owner: user.clone(), + project_id: project.id, + worktree_root_names: project.worktree_root_names.clone(), + }); + } + } + + for unshared_project_id in old_projects.difference(&new_projects) { + this.joined_projects.retain(|project| { + if let Some(project) = project.upgrade() { + project.update(cx, |project, cx| { + if project.remote_id() == Some(*unshared_project_id) { + project.disconnected_from_host(cx); + false + } else { + true + } + }) + } else { + false + } + }); + cx.emit(Event::RemoteProjectUnshared { + project_id: *unshared_project_id, + }); + } + + let role = participant.role(); + let location = ParticipantLocation::from_proto(participant.location) + .unwrap_or(ParticipantLocation::External); + if let Some(remote_participant) = + this.remote_participants.get_mut(&participant.user_id) + { + remote_participant.peer_id = peer_id; + remote_participant.projects = participant.projects; + remote_participant.participant_index = participant_index; + if location != remote_participant.location + || role != remote_participant.role + { + remote_participant.location = location; + remote_participant.role = role; + cx.emit(Event::ParticipantLocationChanged { + participant_id: peer_id, + }); + } + } else { + this.remote_participants.insert( + participant.user_id, + RemoteParticipant { + user: user.clone(), + participant_index, + peer_id, + projects: participant.projects, + location, + role, + muted: true, + speaking: false, + video_tracks: Default::default(), + #[cfg(not(target_os = "windows"))] + audio_tracks: Default::default(), + }, + ); + + Audio::play_sound(Sound::Joined, cx); + if let Some(livekit_participants) = &livekit_participants { + if let Some(livekit_participant) = livekit_participants + .get(&ParticipantIdentity(user.id.to_string())) + { + for publication in + livekit_participant.track_publications().into_values() + { + if let Some(track) = publication.track() { + this.livekit_room_updated( + RoomEvent::TrackSubscribed { + track, + publication, + participant: livekit_participant.clone(), + }, + cx, + ) + .warn_on_err(); + } + } + } + } + } + } + + this.remote_participants.retain(|user_id, participant| { + if this.participant_user_ids.contains(user_id) { + true + } else { + for project in &participant.projects { + cx.emit(Event::RemoteProjectUnshared { + project_id: project.id, + }); + } + false + } + }); + } + + if let Some(pending_participants) = pending_participants.log_err() { + this.pending_participants = pending_participants; + for participant in &this.pending_participants { + this.participant_user_ids.insert(participant.id); + } + } + + this.follows_by_leader_id_project_id.clear(); + for follower in room.followers { + let project_id = follower.project_id; + let (leader, follower) = match (follower.leader_id, follower.follower_id) { + (Some(leader), Some(follower)) => (leader, follower), + + _ => { + log::error!("Follower message {follower:?} missing some state"); + continue; + } + }; + + let list = this + .follows_by_leader_id_project_id + .entry((leader, project_id)) + .or_default(); + if !list.contains(&follower) { + list.push(follower); + } + } + + this.pending_room_update.take(); + if this.should_leave() { + log::info!("room is empty, leaving"); + this.leave(cx).detach(); + } + + this.user_store.update(cx, |user_store, cx| { + let participant_indices_by_user_id = this + .remote_participants + .iter() + .map(|(user_id, participant)| (*user_id, participant.participant_index)) + .collect(); + user_store.set_participant_indices(participant_indices_by_user_id, cx); + }); + + this.check_invariants(); + this.room_update_completed_tx.try_send(Some(())).ok(); + cx.notify(); + }) + .ok(); + }) + } + + fn livekit_room_updated( + &mut self, + event: RoomEvent, + cx: &mut ModelContext, + ) -> Result<()> { + log::trace!( + "client {:?}. livekit event: {:?}", + self.client.user_id(), + &event + ); + + match event { + #[cfg(not(target_os = "windows"))] + RoomEvent::TrackSubscribed { + track, + participant, + publication, + } => { + let user_id = participant.identity().0.parse()?; + let track_id = track.sid(); + let participant = self.remote_participants.get_mut(&user_id).ok_or_else(|| { + anyhow!( + "{:?} subscribed to track by unknown participant {user_id}", + self.client.user_id() + ) + })?; + if self.live_kit.as_ref().map_or(true, |kit| kit.deafened) { + track.rtc_track().set_enabled(false); + } + match track { + livekit::track::RemoteTrack::Audio(track) => { + cx.emit(Event::RemoteAudioTracksChanged { + participant_id: participant.peer_id, + }); + let stream = play_remote_audio_track(&track, cx.background_executor())?; + participant.audio_tracks.insert(track_id, (track, stream)); + participant.muted = publication.is_muted(); + } + livekit::track::RemoteTrack::Video(track) => { + cx.emit(Event::RemoteVideoTracksChanged { + participant_id: participant.peer_id, + }); + participant.video_tracks.insert(track_id, track); + } + } + } + + #[cfg(not(target_os = "windows"))] + RoomEvent::TrackUnsubscribed { + track, participant, .. + } => { + let user_id = participant.identity().0.parse()?; + let participant = self.remote_participants.get_mut(&user_id).ok_or_else(|| { + anyhow!( + "{:?}, unsubscribed from track by unknown participant {user_id}", + self.client.user_id() + ) + })?; + match track { + livekit::track::RemoteTrack::Audio(track) => { + participant.audio_tracks.remove(&track.sid()); + participant.muted = true; + cx.emit(Event::RemoteAudioTracksChanged { + participant_id: participant.peer_id, + }); + } + livekit::track::RemoteTrack::Video(track) => { + participant.video_tracks.remove(&track.sid()); + cx.emit(Event::RemoteVideoTracksChanged { + participant_id: participant.peer_id, + }); + } + } + } + + #[cfg(not(target_os = "windows"))] + RoomEvent::ActiveSpeakersChanged { speakers } => { + let mut speaker_ids = speakers + .into_iter() + .filter_map(|speaker| speaker.identity().0.parse().ok()) + .collect::>(); + speaker_ids.sort_unstable(); + for (sid, participant) in &mut self.remote_participants { + participant.speaking = speaker_ids.binary_search(sid).is_ok(); + } + if let Some(id) = self.client.user_id() { + if let Some(room) = &mut self.live_kit { + room.speaking = speaker_ids.binary_search(&id).is_ok(); + } + } + } + + #[cfg(not(target_os = "windows"))] + RoomEvent::TrackMuted { + participant, + publication, + } + | RoomEvent::TrackUnmuted { + participant, + publication, + } => { + let mut found = false; + let user_id = participant.identity().0.parse()?; + let track_id = publication.sid(); + if let Some(participant) = self.remote_participants.get_mut(&user_id) { + for (track, _) in participant.audio_tracks.values() { + if track.sid() == track_id { + found = true; + break; + } + } + if found { + participant.muted = publication.is_muted(); + } + } + } + + #[cfg(not(target_os = "windows"))] + RoomEvent::LocalTrackUnpublished { publication, .. } => { + log::info!("unpublished track {}", publication.sid()); + if let Some(room) = &mut self.live_kit { + if let LocalTrack::Published { + track_publication, .. + } = &room.microphone_track + { + if track_publication.sid() == publication.sid() { + room.microphone_track = LocalTrack::None; + } + } + if let LocalTrack::Published { + track_publication, .. + } = &room.screen_track + { + if track_publication.sid() == publication.sid() { + room.screen_track = LocalTrack::None; + } + } + } + } + + #[cfg(not(target_os = "windows"))] + RoomEvent::LocalTrackPublished { publication, .. } => { + log::info!("published track {:?}", publication.sid()); + } + + #[cfg(not(target_os = "windows"))] + RoomEvent::Disconnected { reason } => { + log::info!("disconnected from room: {reason:?}"); + self.leave(cx).detach_and_log_err(cx); + } + _ => {} + } + + cx.notify(); + Ok(()) + } + + fn check_invariants(&self) { + #[cfg(any(test, feature = "test-support"))] + { + for participant in self.remote_participants.values() { + assert!(self.participant_user_ids.contains(&participant.user.id)); + assert_ne!(participant.user.id, self.client.user_id().unwrap()); + } + + for participant in &self.pending_participants { + assert!(self.participant_user_ids.contains(&participant.id)); + assert_ne!(participant.id, self.client.user_id().unwrap()); + } + + assert_eq!( + self.participant_user_ids.len(), + self.remote_participants.len() + self.pending_participants.len() + ); + } + } + + pub(crate) fn call( + &mut self, + called_user_id: u64, + initial_project_id: Option, + cx: &mut ModelContext, + ) -> Task> { + if self.status.is_offline() { + return Task::ready(Err(anyhow!("room is offline"))); + } + + cx.notify(); + let client = self.client.clone(); + let room_id = self.id; + self.pending_call_count += 1; + cx.spawn(move |this, mut cx| async move { + let result = client + .request(proto::Call { + room_id, + called_user_id, + initial_project_id, + }) + .await; + this.update(&mut cx, |this, cx| { + this.pending_call_count -= 1; + if this.should_leave() { + this.leave(cx).detach_and_log_err(cx); + } + })?; + result?; + Ok(()) + }) + } + + pub fn join_project( + &mut self, + id: u64, + language_registry: Arc, + fs: Arc, + cx: &mut ModelContext, + ) -> Task>> { + let client = self.client.clone(); + let user_store = self.user_store.clone(); + cx.emit(Event::RemoteProjectJoined { project_id: id }); + cx.spawn(move |this, mut cx| async move { + let project = + Project::in_room(id, client, user_store, language_registry, fs, cx.clone()).await?; + + this.update(&mut cx, |this, cx| { + this.joined_projects.retain(|project| { + if let Some(project) = project.upgrade() { + !project.read(cx).is_disconnected(cx) + } else { + false + } + }); + this.joined_projects.insert(project.downgrade()); + })?; + Ok(project) + }) + } + + pub fn share_project( + &mut self, + project: Model, + cx: &mut ModelContext, + ) -> Task> { + if let Some(project_id) = project.read(cx).remote_id() { + return Task::ready(Ok(project_id)); + } + + let request = self.client.request(proto::ShareProject { + room_id: self.id(), + worktrees: project.read(cx).worktree_metadata_protos(cx), + is_ssh_project: project.read(cx).is_via_ssh(), + }); + + cx.spawn(|this, mut cx| async move { + let response = request.await?; + + project.update(&mut cx, |project, cx| { + project.shared(response.project_id, cx) + })??; + + // If the user's location is in this project, it changes from UnsharedProject to SharedProject. + this.update(&mut cx, |this, cx| { + this.shared_projects.insert(project.downgrade()); + let active_project = this.local_participant.active_project.as_ref(); + if active_project.map_or(false, |location| *location == project) { + this.set_location(Some(&project), cx) + } else { + Task::ready(Ok(())) + } + })? + .await?; + + Ok(response.project_id) + }) + } + + pub(crate) fn unshare_project( + &mut self, + project: Model, + cx: &mut ModelContext, + ) -> Result<()> { + let project_id = match project.read(cx).remote_id() { + Some(project_id) => project_id, + None => return Ok(()), + }; + + self.client.send(proto::UnshareProject { project_id })?; + project.update(cx, |this, cx| this.unshare(cx))?; + + if self.local_participant.active_project == Some(project.downgrade()) { + self.set_location(Some(&project), cx).detach_and_log_err(cx); + } + Ok(()) + } + + pub(crate) fn set_location( + &mut self, + project: Option<&Model>, + cx: &mut ModelContext, + ) -> Task> { + if self.status.is_offline() { + return Task::ready(Err(anyhow!("room is offline"))); + } + + let client = self.client.clone(); + let room_id = self.id; + let location = if let Some(project) = project { + self.local_participant.active_project = Some(project.downgrade()); + if let Some(project_id) = project.read(cx).remote_id() { + proto::participant_location::Variant::SharedProject( + proto::participant_location::SharedProject { id: project_id }, + ) + } else { + proto::participant_location::Variant::UnsharedProject( + proto::participant_location::UnsharedProject {}, + ) + } + } else { + self.local_participant.active_project = None; + proto::participant_location::Variant::External(proto::participant_location::External {}) + }; + + cx.notify(); + cx.background_executor().spawn(async move { + client + .request(proto::UpdateParticipantLocation { + room_id, + location: Some(proto::ParticipantLocation { + variant: Some(location), + }), + }) + .await?; + Ok(()) + }) + } + + pub fn is_screen_sharing(&self) -> bool { + self.live_kit.as_ref().map_or(false, |live_kit| { + !matches!(live_kit.screen_track, LocalTrack::None) + }) + } + + pub fn is_sharing_mic(&self) -> bool { + self.live_kit.as_ref().map_or(false, |live_kit| { + !matches!(live_kit.microphone_track, LocalTrack::None) + }) + } + + pub fn is_muted(&self) -> bool { + self.live_kit.as_ref().map_or(false, |live_kit| { + matches!(live_kit.microphone_track, LocalTrack::None) + || live_kit.muted_by_user + || live_kit.deafened + }) + } + + pub fn is_speaking(&self) -> bool { + self.live_kit + .as_ref() + .map_or(false, |live_kit| live_kit.speaking) + } + + pub fn is_deafened(&self) -> Option { + self.live_kit.as_ref().map(|live_kit| live_kit.deafened) + } + + pub fn can_use_microphone(&self, _cx: &AppContext) -> bool { + use proto::ChannelRole::*; + + #[cfg(not(any(test, feature = "test-support")))] + { + use feature_flags::FeatureFlagAppExt as _; + if cfg!(target_os = "windows") || (cfg!(target_os = "linux") && !_cx.is_staff()) { + return false; + } + } + + match self.local_participant.role { + Admin | Member | Talker => true, + Guest | Banned => false, + } + } + + pub fn can_share_projects(&self) -> bool { + use proto::ChannelRole::*; + match self.local_participant.role { + Admin | Member => true, + Guest | Banned | Talker => false, + } + } + + #[cfg(target_os = "windows")] + pub fn share_microphone(&mut self, cx: &mut ModelContext) -> Task> { + Task::ready(Err(anyhow!("Windows is not supported yet"))) + } + + #[cfg(not(target_os = "windows"))] + #[track_caller] + pub fn share_microphone(&mut self, cx: &mut ModelContext) -> Task> { + if self.status.is_offline() { + return Task::ready(Err(anyhow!("room is offline"))); + } + + let (participant, publish_id) = if let Some(live_kit) = self.live_kit.as_mut() { + let publish_id = post_inc(&mut live_kit.next_publish_id); + live_kit.microphone_track = LocalTrack::Pending { publish_id }; + cx.notify(); + (live_kit.room.local_participant(), publish_id) + } else { + return Task::ready(Err(anyhow!("live-kit was not initialized"))); + }; + + cx.spawn(move |this, mut cx| async move { + let (track, stream) = capture_local_audio_track(cx.background_executor())?.await; + + let publication = participant + .publish_track( + livekit::track::LocalTrack::Audio(track), + TrackPublishOptions { + source: TrackSource::Microphone, + ..Default::default() + }, + ) + .await + .map_err(|error| anyhow!("failed to publish track: {error}")); + this.update(&mut cx, |this, cx| { + let live_kit = this + .live_kit + .as_mut() + .ok_or_else(|| anyhow!("live-kit was not initialized"))?; + + let canceled = if let LocalTrack::Pending { + publish_id: cur_publish_id, + } = &live_kit.microphone_track + { + *cur_publish_id != publish_id + } else { + true + }; + + match publication { + Ok(publication) => { + if canceled { + cx.background_executor() + .spawn(async move { + participant.unpublish_track(&publication.sid()).await + }) + .detach_and_log_err(cx) + } else { + if live_kit.muted_by_user || live_kit.deafened { + publication.mute(); + } + live_kit.microphone_track = LocalTrack::Published { + track_publication: publication, + _stream: Box::new(stream), + }; + cx.notify(); + } + Ok(()) + } + Err(error) => { + if canceled { + Ok(()) + } else { + live_kit.microphone_track = LocalTrack::None; + cx.notify(); + Err(error) + } + } + } + })? + }) + } + + #[cfg(target_os = "windows")] + pub fn share_screen(&mut self, cx: &mut ModelContext) -> Task> { + Task::ready(Err(anyhow!("Windows is not supported yet"))) + } + + #[cfg(not(target_os = "windows"))] + pub fn share_screen(&mut self, cx: &mut ModelContext) -> Task> { + if self.status.is_offline() { + return Task::ready(Err(anyhow!("room is offline"))); + } + if self.is_screen_sharing() { + return Task::ready(Err(anyhow!("screen was already shared"))); + } + + let (participant, publish_id) = if let Some(live_kit) = self.live_kit.as_mut() { + let publish_id = post_inc(&mut live_kit.next_publish_id); + live_kit.screen_track = LocalTrack::Pending { publish_id }; + cx.notify(); + (live_kit.room.local_participant(), publish_id) + } else { + return Task::ready(Err(anyhow!("live-kit was not initialized"))); + }; + + let sources = cx.screen_capture_sources(); + + cx.spawn(move |this, mut cx| async move { + let sources = sources.await??; + let source = sources.first().ok_or_else(|| anyhow!("no display found"))?; + + let (track, stream) = capture_local_video_track(&**source).await?; + + let publication = participant + .publish_track( + livekit::track::LocalTrack::Video(track), + TrackPublishOptions { + source: TrackSource::Screenshare, + video_codec: VideoCodec::H264, + ..Default::default() + }, + ) + .await + .map_err(|error| anyhow!("error publishing screen track {error:?}")); + + this.update(&mut cx, |this, cx| { + let live_kit = this + .live_kit + .as_mut() + .ok_or_else(|| anyhow!("live-kit was not initialized"))?; + + let canceled = if let LocalTrack::Pending { + publish_id: cur_publish_id, + } = &live_kit.screen_track + { + *cur_publish_id != publish_id + } else { + true + }; + + match publication { + Ok(publication) => { + if canceled { + cx.background_executor() + .spawn(async move { + participant.unpublish_track(&publication.sid()).await + }) + .detach() + } else { + live_kit.screen_track = LocalTrack::Published { + track_publication: publication, + _stream: Box::new(stream), + }; + cx.notify(); + } + + Audio::play_sound(Sound::StartScreenshare, cx); + Ok(()) + } + Err(error) => { + if canceled { + Ok(()) + } else { + live_kit.screen_track = LocalTrack::None; + cx.notify(); + Err(error) + } + } + } + })? + }) + } + + pub fn toggle_mute(&mut self, cx: &mut ModelContext) { + if let Some(live_kit) = self.live_kit.as_mut() { + // When unmuting, undeafen if the user was deafened before. + let was_deafened = live_kit.deafened; + if live_kit.muted_by_user + || live_kit.deafened + || matches!(live_kit.microphone_track, LocalTrack::None) + { + live_kit.muted_by_user = false; + live_kit.deafened = false; + } else { + live_kit.muted_by_user = true; + } + let muted = live_kit.muted_by_user; + let should_undeafen = was_deafened && !live_kit.deafened; + + if let Some(task) = self.set_mute(muted, cx) { + task.detach_and_log_err(cx); + } + + if should_undeafen { + self.set_deafened(false, cx); + } + } + } + + pub fn toggle_deafen(&mut self, cx: &mut ModelContext) { + if let Some(live_kit) = self.live_kit.as_mut() { + // When deafening, mute the microphone if it was not already muted. + // When un-deafening, unmute the microphone, unless it was explicitly muted. + let deafened = !live_kit.deafened; + live_kit.deafened = deafened; + let should_change_mute = !live_kit.muted_by_user; + + self.set_deafened(deafened, cx); + + if should_change_mute { + if let Some(task) = self.set_mute(deafened, cx) { + task.detach_and_log_err(cx); + } + } + } + } + + pub fn unshare_screen(&mut self, cx: &mut ModelContext) -> Result<()> { + if self.status.is_offline() { + return Err(anyhow!("room is offline")); + } + + let live_kit = self + .live_kit + .as_mut() + .ok_or_else(|| anyhow!("live-kit was not initialized"))?; + match mem::take(&mut live_kit.screen_track) { + LocalTrack::None => Err(anyhow!("screen was not shared")), + LocalTrack::Pending { .. } => { + cx.notify(); + Ok(()) + } + LocalTrack::Published { + track_publication, .. + } => { + #[cfg(not(target_os = "windows"))] + { + let local_participant = live_kit.room.local_participant(); + let sid = track_publication.sid(); + cx.background_executor() + .spawn(async move { local_participant.unpublish_track(&sid).await }) + .detach_and_log_err(cx); + cx.notify(); + } + Audio::play_sound(Sound::StopScreenshare, cx); + Ok(()) + } + } + } + + fn set_deafened(&mut self, deafened: bool, cx: &mut ModelContext) -> Option<()> { + #[cfg(not(target_os = "windows"))] + { + let live_kit = self.live_kit.as_mut()?; + cx.notify(); + for (_, participant) in live_kit.room.remote_participants() { + for (_, publication) in participant.track_publications() { + if publication.kind() == TrackKind::Audio { + publication.set_enabled(!deafened); + } + } + } + } + + None + } + + fn set_mute( + &mut self, + should_mute: bool, + cx: &mut ModelContext, + ) -> Option>> { + let live_kit = self.live_kit.as_mut()?; + cx.notify(); + + if should_mute { + Audio::play_sound(Sound::Mute, cx); + } else { + Audio::play_sound(Sound::Unmute, cx); + } + + match &mut live_kit.microphone_track { + LocalTrack::None => { + if should_mute { + None + } else { + Some(self.share_microphone(cx)) + } + } + LocalTrack::Pending { .. } => None, + LocalTrack::Published { + track_publication, .. + } => { + #[cfg(not(target_os = "windows"))] + { + if should_mute { + track_publication.mute() + } else { + track_publication.unmute() + } + } + None + } + } + } +} + +#[cfg(target_os = "windows")] +fn spawn_room_connection( + livekit_connection_info: Option, + cx: &mut ModelContext<'_, Room>, +) { +} + +#[cfg(not(target_os = "windows"))] +fn spawn_room_connection( + livekit_connection_info: Option, + cx: &mut ModelContext<'_, Room>, +) { + if let Some(connection_info) = livekit_connection_info { + cx.spawn(|this, mut cx| async move { + let (room, mut events) = livekit::Room::connect( + &connection_info.server_url, + &connection_info.token, + RoomOptions::default(), + ) + .await?; + + this.update(&mut cx, |this, cx| { + let _handle_updates = cx.spawn(|this, mut cx| async move { + while let Some(event) = events.recv().await { + if this + .update(&mut cx, |this, cx| { + this.livekit_room_updated(event, cx).warn_on_err(); + }) + .is_err() + { + break; + } + } + }); + + let muted_by_user = Room::mute_on_join(cx); + this.live_kit = Some(LiveKitRoom { + room: Arc::new(room), + screen_track: LocalTrack::None, + microphone_track: LocalTrack::None, + next_publish_id: 0, + muted_by_user, + deafened: false, + speaking: false, + _handle_updates, + }); + + if !muted_by_user && this.can_use_microphone(cx) { + this.share_microphone(cx) + } else { + Task::ready(Ok(())) + } + })? + .await + }) + .detach_and_log_err(cx); + } +} + +struct LiveKitRoom { + room: Arc, + screen_track: LocalTrack, + microphone_track: LocalTrack, + /// Tracks whether we're currently in a muted state due to auto-mute from deafening or manual mute performed by user. + muted_by_user: bool, + deafened: bool, + speaking: bool, + next_publish_id: usize, + _handle_updates: Task<()>, +} + +impl LiveKitRoom { + #[cfg(target_os = "windows")] + fn stop_publishing(&mut self, _cx: &mut ModelContext) {} + + #[cfg(not(target_os = "windows"))] + fn stop_publishing(&mut self, cx: &mut ModelContext) { + let mut tracks_to_unpublish = Vec::new(); + if let LocalTrack::Published { + track_publication, .. + } = mem::replace(&mut self.microphone_track, LocalTrack::None) + { + tracks_to_unpublish.push(track_publication.sid()); + cx.notify(); + } + + if let LocalTrack::Published { + track_publication, .. + } = mem::replace(&mut self.screen_track, LocalTrack::None) + { + tracks_to_unpublish.push(track_publication.sid()); + cx.notify(); + } + + let participant = self.room.local_participant(); + cx.background_executor() + .spawn(async move { + for sid in tracks_to_unpublish { + participant.unpublish_track(&sid).await.log_err(); + } + }) + .detach(); + } +} + +enum LocalTrack { + None, + Pending { + publish_id: usize, + }, + Published { + track_publication: LocalTrackPublication, + _stream: Box, + }, +} + +impl Default for LocalTrack { + fn default() -> Self { + Self::None + } +} + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum RoomStatus { + Online, + Rejoining, + Offline, +} + +impl RoomStatus { + pub fn is_offline(&self) -> bool { + matches!(self, RoomStatus::Offline) + } + + pub fn is_online(&self) -> bool { + matches!(self, RoomStatus::Online) + } +} diff --git a/crates/call/src/macos/mod.rs b/crates/call/src/macos/mod.rs new file mode 100644 index 0000000000..24472bd1fb --- /dev/null +++ b/crates/call/src/macos/mod.rs @@ -0,0 +1,545 @@ +pub mod participant; +pub mod room; + +use crate::call_settings::CallSettings; +use anyhow::{anyhow, Result}; +use audio::Audio; +use client::{proto, ChannelId, Client, TypedEnvelope, User, UserStore, ZED_ALWAYS_ACTIVE}; +use collections::HashSet; +use futures::{channel::oneshot, future::Shared, Future, FutureExt}; +use gpui::{ + AppContext, AsyncAppContext, Context, EventEmitter, Global, Model, ModelContext, Subscription, + Task, WeakModel, +}; +use postage::watch; +use project::Project; +use room::Event; +use settings::Settings; +use std::sync::Arc; + +pub use participant::ParticipantLocation; +pub use room::Room; + +struct GlobalActiveCall(Model); + +impl Global for GlobalActiveCall {} + +pub fn init(client: Arc, user_store: Model, cx: &mut AppContext) { + CallSettings::register(cx); + + let active_call = cx.new_model(|cx| ActiveCall::new(client, user_store, cx)); + cx.set_global(GlobalActiveCall(active_call)); +} + +pub struct OneAtATime { + cancel: Option>, +} + +impl OneAtATime { + /// spawn a task in the given context. + /// if another task is spawned before that resolves, or if the OneAtATime itself is dropped, the first task will be cancelled and return Ok(None) + /// otherwise you'll see the result of the task. + fn spawn(&mut self, cx: &mut AppContext, f: F) -> Task>> + where + F: 'static + FnOnce(AsyncAppContext) -> Fut, + Fut: Future>, + R: 'static, + { + let (tx, rx) = oneshot::channel(); + self.cancel.replace(tx); + cx.spawn(|cx| async move { + futures::select_biased! { + _ = rx.fuse() => Ok(None), + result = f(cx).fuse() => result.map(Some), + } + }) + } + + fn running(&self) -> bool { + self.cancel + .as_ref() + .is_some_and(|cancel| !cancel.is_canceled()) + } +} + +#[derive(Clone)] +pub struct IncomingCall { + pub room_id: u64, + pub calling_user: Arc, + pub participants: Vec>, + pub initial_project: Option, +} + +/// Singleton global maintaining the user's participation in a room across workspaces. +pub struct ActiveCall { + room: Option<(Model, Vec)>, + pending_room_creation: Option, Arc>>>>, + location: Option>, + _join_debouncer: OneAtATime, + pending_invites: HashSet, + incoming_call: ( + watch::Sender>, + watch::Receiver>, + ), + client: Arc, + user_store: Model, + _subscriptions: Vec, +} + +impl EventEmitter for ActiveCall {} + +impl ActiveCall { + fn new(client: Arc, user_store: Model, cx: &mut ModelContext) -> Self { + Self { + room: None, + pending_room_creation: None, + location: None, + pending_invites: Default::default(), + incoming_call: watch::channel(), + _join_debouncer: OneAtATime { cancel: None }, + _subscriptions: vec![ + client.add_request_handler(cx.weak_model(), Self::handle_incoming_call), + client.add_message_handler(cx.weak_model(), Self::handle_call_canceled), + ], + client, + user_store, + } + } + + pub fn channel_id(&self, cx: &AppContext) -> Option { + self.room()?.read(cx).channel_id() + } + + async fn handle_incoming_call( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result { + let user_store = this.update(&mut cx, |this, _| this.user_store.clone())?; + let call = IncomingCall { + room_id: envelope.payload.room_id, + participants: user_store + .update(&mut cx, |user_store, cx| { + user_store.get_users(envelope.payload.participant_user_ids, cx) + })? + .await?, + calling_user: user_store + .update(&mut cx, |user_store, cx| { + user_store.get_user(envelope.payload.calling_user_id, cx) + })? + .await?, + initial_project: envelope.payload.initial_project, + }; + this.update(&mut cx, |this, _| { + *this.incoming_call.0.borrow_mut() = Some(call); + })?; + + Ok(proto::Ack {}) + } + + async fn handle_call_canceled( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, _| { + let mut incoming_call = this.incoming_call.0.borrow_mut(); + if incoming_call + .as_ref() + .map_or(false, |call| call.room_id == envelope.payload.room_id) + { + incoming_call.take(); + } + })?; + Ok(()) + } + + pub fn global(cx: &AppContext) -> Model { + cx.global::().0.clone() + } + + pub fn try_global(cx: &AppContext) -> Option> { + cx.try_global::() + .map(|call| call.0.clone()) + } + + pub fn invite( + &mut self, + called_user_id: u64, + initial_project: Option>, + cx: &mut ModelContext, + ) -> Task> { + if !self.pending_invites.insert(called_user_id) { + return Task::ready(Err(anyhow!("user was already invited"))); + } + cx.notify(); + + if self._join_debouncer.running() { + return Task::ready(Ok(())); + } + + let room = if let Some(room) = self.room().cloned() { + Some(Task::ready(Ok(room)).shared()) + } else { + self.pending_room_creation.clone() + }; + + let invite = if let Some(room) = room { + cx.spawn(move |_, mut cx| async move { + let room = room.await.map_err(|err| anyhow!("{:?}", err))?; + + let initial_project_id = if let Some(initial_project) = initial_project { + Some( + room.update(&mut cx, |room, cx| room.share_project(initial_project, cx))? + .await?, + ) + } else { + None + }; + + room.update(&mut cx, move |room, cx| { + room.call(called_user_id, initial_project_id, cx) + })? + .await?; + + anyhow::Ok(()) + }) + } else { + let client = self.client.clone(); + let user_store = self.user_store.clone(); + let room = cx + .spawn(move |this, mut cx| async move { + let create_room = async { + let room = cx + .update(|cx| { + Room::create( + called_user_id, + initial_project, + client, + user_store, + cx, + ) + })? + .await?; + + this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx))? + .await?; + + anyhow::Ok(room) + }; + + let room = create_room.await; + this.update(&mut cx, |this, _| this.pending_room_creation = None)?; + room.map_err(Arc::new) + }) + .shared(); + self.pending_room_creation = Some(room.clone()); + cx.background_executor().spawn(async move { + room.await.map_err(|err| anyhow!("{:?}", err))?; + anyhow::Ok(()) + }) + }; + + cx.spawn(move |this, mut cx| async move { + let result = invite.await; + if result.is_ok() { + this.update(&mut cx, |this, cx| this.report_call_event("invite", cx))?; + } else { + //TODO: report collaboration error + log::error!("invite failed: {:?}", result); + } + + this.update(&mut cx, |this, cx| { + this.pending_invites.remove(&called_user_id); + cx.notify(); + })?; + result + }) + } + + pub fn cancel_invite( + &mut self, + called_user_id: u64, + cx: &mut ModelContext, + ) -> Task> { + let room_id = if let Some(room) = self.room() { + room.read(cx).id() + } else { + return Task::ready(Err(anyhow!("no active call"))); + }; + + let client = self.client.clone(); + cx.background_executor().spawn(async move { + client + .request(proto::CancelCall { + room_id, + called_user_id, + }) + .await?; + anyhow::Ok(()) + }) + } + + pub fn incoming(&self) -> watch::Receiver> { + self.incoming_call.1.clone() + } + + pub fn accept_incoming(&mut self, cx: &mut ModelContext) -> Task> { + if self.room.is_some() { + return Task::ready(Err(anyhow!("cannot join while on another call"))); + } + + let call = if let Some(call) = self.incoming_call.0.borrow_mut().take() { + call + } else { + return Task::ready(Err(anyhow!("no incoming call"))); + }; + + if self.pending_room_creation.is_some() { + return Task::ready(Ok(())); + } + + let room_id = call.room_id; + let client = self.client.clone(); + let user_store = self.user_store.clone(); + let join = self + ._join_debouncer + .spawn(cx, move |cx| Room::join(room_id, client, user_store, cx)); + + cx.spawn(|this, mut cx| async move { + let room = join.await?; + this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))? + .await?; + this.update(&mut cx, |this, cx| { + this.report_call_event("accept incoming", cx) + })?; + Ok(()) + }) + } + + pub fn decline_incoming(&mut self, _: &mut ModelContext) -> Result<()> { + let call = self + .incoming_call + .0 + .borrow_mut() + .take() + .ok_or_else(|| anyhow!("no incoming call"))?; + report_call_event_for_room("decline incoming", call.room_id, None, &self.client); + self.client.send(proto::DeclineCall { + room_id: call.room_id, + })?; + Ok(()) + } + + pub fn join_channel( + &mut self, + channel_id: ChannelId, + cx: &mut ModelContext, + ) -> Task>>> { + if let Some(room) = self.room().cloned() { + if room.read(cx).channel_id() == Some(channel_id) { + return Task::ready(Ok(Some(room))); + } else { + room.update(cx, |room, cx| room.clear_state(cx)); + } + } + + if self.pending_room_creation.is_some() { + return Task::ready(Ok(None)); + } + + let client = self.client.clone(); + let user_store = self.user_store.clone(); + let join = self._join_debouncer.spawn(cx, move |cx| async move { + Room::join_channel(channel_id, client, user_store, cx).await + }); + + cx.spawn(|this, mut cx| async move { + let room = join.await?; + this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))? + .await?; + this.update(&mut cx, |this, cx| { + this.report_call_event("join channel", cx) + })?; + Ok(room) + }) + } + + pub fn hang_up(&mut self, cx: &mut ModelContext) -> Task> { + cx.notify(); + self.report_call_event("hang up", cx); + + Audio::end_call(cx); + + let channel_id = self.channel_id(cx); + if let Some((room, _)) = self.room.take() { + cx.emit(Event::RoomLeft { channel_id }); + room.update(cx, |room, cx| room.leave(cx)) + } else { + Task::ready(Ok(())) + } + } + + pub fn share_project( + &mut self, + project: Model, + cx: &mut ModelContext, + ) -> Task> { + if let Some((room, _)) = self.room.as_ref() { + self.report_call_event("share project", cx); + room.update(cx, |room, cx| room.share_project(project, cx)) + } else { + Task::ready(Err(anyhow!("no active call"))) + } + } + + pub fn unshare_project( + &mut self, + project: Model, + cx: &mut ModelContext, + ) -> Result<()> { + if let Some((room, _)) = self.room.as_ref() { + self.report_call_event("unshare project", cx); + room.update(cx, |room, cx| room.unshare_project(project, cx)) + } else { + Err(anyhow!("no active call")) + } + } + + pub fn location(&self) -> Option<&WeakModel> { + self.location.as_ref() + } + + pub fn set_location( + &mut self, + project: Option<&Model>, + cx: &mut ModelContext, + ) -> Task> { + if project.is_some() || !*ZED_ALWAYS_ACTIVE { + self.location = project.map(|project| project.downgrade()); + if let Some((room, _)) = self.room.as_ref() { + return room.update(cx, |room, cx| room.set_location(project, cx)); + } + } + Task::ready(Ok(())) + } + + fn set_room( + &mut self, + room: Option>, + cx: &mut ModelContext, + ) -> Task> { + if room.as_ref() == self.room.as_ref().map(|room| &room.0) { + Task::ready(Ok(())) + } else { + cx.notify(); + if let Some(room) = room { + if room.read(cx).status().is_offline() { + self.room = None; + Task::ready(Ok(())) + } else { + let subscriptions = vec![ + cx.observe(&room, |this, room, cx| { + if room.read(cx).status().is_offline() { + this.set_room(None, cx).detach_and_log_err(cx); + } + + cx.notify(); + }), + cx.subscribe(&room, |_, _, event, cx| cx.emit(event.clone())), + ]; + self.room = Some((room.clone(), subscriptions)); + let location = self + .location + .as_ref() + .and_then(|location| location.upgrade()); + let channel_id = room.read(cx).channel_id(); + cx.emit(Event::RoomJoined { channel_id }); + room.update(cx, |room, cx| room.set_location(location.as_ref(), cx)) + } + } else { + self.room = None; + Task::ready(Ok(())) + } + } + } + + pub fn room(&self) -> Option<&Model> { + self.room.as_ref().map(|(room, _)| room) + } + + pub fn client(&self) -> Arc { + self.client.clone() + } + + pub fn pending_invites(&self) -> &HashSet { + &self.pending_invites + } + + pub fn report_call_event(&self, operation: &'static str, cx: &mut AppContext) { + if let Some(room) = self.room() { + let room = room.read(cx); + report_call_event_for_room(operation, room.id(), room.channel_id(), &self.client); + } + } +} + +pub fn report_call_event_for_room( + operation: &'static str, + room_id: u64, + channel_id: Option, + client: &Arc, +) { + let telemetry = client.telemetry(); + + telemetry.report_call_event(operation, Some(room_id), channel_id) +} + +pub fn report_call_event_for_channel( + operation: &'static str, + channel_id: ChannelId, + client: &Arc, + cx: &AppContext, +) { + let room = ActiveCall::global(cx).read(cx).room(); + + let telemetry = client.telemetry(); + + telemetry.report_call_event(operation, room.map(|r| r.read(cx).id()), Some(channel_id)) +} + +#[cfg(test)] +mod test { + use gpui::TestAppContext; + + use crate::OneAtATime; + + #[gpui::test] + async fn test_one_at_a_time(cx: &mut TestAppContext) { + let mut one_at_a_time = OneAtATime { cancel: None }; + + assert_eq!( + cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(1) })) + .await + .unwrap(), + Some(1) + ); + + let (a, b) = cx.update(|cx| { + ( + one_at_a_time.spawn(cx, |_| async { + panic!(""); + }), + one_at_a_time.spawn(cx, |_| async { Ok(3) }), + ) + }); + + assert_eq!(a.await.unwrap(), None::); + assert_eq!(b.await.unwrap(), Some(3)); + + let promise = cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(4) })); + drop(one_at_a_time); + + assert_eq!(promise.await.unwrap(), None); + } +} diff --git a/crates/call/src/participant.rs b/crates/call/src/macos/participant.rs similarity index 80% rename from crates/call/src/participant.rs rename to crates/call/src/macos/participant.rs index 9faefc63c3..82d946a928 100644 --- a/crates/call/src/participant.rs +++ b/crates/call/src/macos/participant.rs @@ -3,8 +3,8 @@ use client::ParticipantIndex; use client::{proto, User}; use collections::HashMap; use gpui::WeakModel; -pub use live_kit_client::Frame; -pub use live_kit_client::{RemoteAudioTrack, RemoteVideoTrack}; +pub use livekit_client_macos::Frame; +pub use livekit_client_macos::{RemoteAudioTrack, RemoteVideoTrack}; use project::Project; use std::sync::Arc; @@ -49,6 +49,12 @@ pub struct RemoteParticipant { pub participant_index: ParticipantIndex, pub muted: bool, pub speaking: bool, - pub video_tracks: HashMap>, - pub audio_tracks: HashMap>, + pub video_tracks: HashMap>, + pub audio_tracks: HashMap>, +} + +impl RemoteParticipant { + pub fn has_video_tracks(&self) -> bool { + !self.video_tracks.is_empty() + } } diff --git a/crates/call/src/room.rs b/crates/call/src/macos/room.rs similarity index 99% rename from crates/call/src/room.rs rename to crates/call/src/macos/room.rs index 3eb98f3109..6fd78570d8 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/macos/room.rs @@ -15,7 +15,7 @@ use gpui::{ AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, Task, WeakModel, }; use language::LanguageRegistry; -use live_kit_client::{LocalAudioTrack, LocalTrackPublication, LocalVideoTrack, RoomUpdate}; +use livekit_client_macos::{LocalAudioTrack, LocalTrackPublication, LocalVideoTrack, RoomUpdate}; use postage::{sink::Sink, stream::Stream, watch}; use project::Project; use settings::Settings as _; @@ -97,7 +97,7 @@ impl Room { if let Some(live_kit) = self.live_kit.as_ref() { matches!( *live_kit.room.status().borrow(), - live_kit_client::ConnectionState::Connected { .. } + livekit_client_macos::ConnectionState::Connected { .. } ) } else { false @@ -113,7 +113,7 @@ impl Room { cx: &mut ModelContext, ) -> Self { let live_kit_room = if let Some(connection_info) = live_kit_connection_info { - let room = live_kit_client::Room::new(); + let room = livekit_client_macos::Room::new(); let mut status = room.status(); // Consume the initial status of the room. let _ = status.try_recv(); @@ -125,7 +125,7 @@ impl Room { break; }; - if status == live_kit_client::ConnectionState::Disconnected { + if status == livekit_client_macos::ConnectionState::Disconnected { this.update(&mut cx, |this, cx| this.leave(cx).log_err()) .ok(); break; @@ -156,7 +156,7 @@ impl Room { cx.spawn(|this, mut cx| async move { connect.await?; this.update(&mut cx, |this, cx| { - if this.can_use_microphone() { + if this.can_use_microphone(cx) { if let Some(live_kit) = &this.live_kit { if !live_kit.muted_by_user && !live_kit.deafened { return this.share_microphone(cx); @@ -1317,7 +1317,7 @@ impl Room { self.live_kit.as_ref().map(|live_kit| live_kit.deafened) } - pub fn can_use_microphone(&self) -> bool { + pub fn can_use_microphone(&self, _cx: &AppContext) -> bool { use proto::ChannelRole::*; match self.local_participant.role { Admin | Member | Talker => true, @@ -1631,7 +1631,7 @@ impl Room { } #[cfg(any(test, feature = "test-support"))] - pub fn set_display_sources(&self, sources: Vec) { + pub fn set_display_sources(&self, sources: Vec) { self.live_kit .as_ref() .unwrap() @@ -1641,7 +1641,7 @@ impl Room { } struct LiveKitRoom { - room: Arc, + room: Arc, screen_track: LocalTrack, microphone_track: LocalTrack, /// Tracks whether we're currently in a muted state due to auto-mute from deafening or manual mute performed by user. diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 5dd53b5a09..fedd6738ed 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 0000000000..399755fa28 --- /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 002b0c0173..c8e1c8d3ed 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/collab/.env.toml b/crates/collab/.env.toml index f542422e95..5d292387cb 100644 --- a/crates/collab/.env.toml +++ b/crates/collab/.env.toml @@ -5,9 +5,9 @@ HTTP_PORT = 8080 API_TOKEN = "secret" INVITE_LINK_PREFIX = "http://localhost:3000/invites/" ZED_ENVIRONMENT = "development" -LIVE_KIT_SERVER = "http://localhost:7880" -LIVE_KIT_KEY = "devkey" -LIVE_KIT_SECRET = "secret" +LIVEKIT_SERVER = "http://localhost:7880" +LIVEKIT_KEY = "devkey" +LIVEKIT_SECRET = "secret" BLOB_STORE_ACCESS_KEY = "the-blob-store-access-key" BLOB_STORE_SECRET_KEY = "the-blob-store-secret-key" BLOB_STORE_BUCKET = "the-extensions-bucket" diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index d3da1c2816..9c7f09bcf5 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -40,7 +40,7 @@ google_ai.workspace = true hex.workspace = true http_client.workspace = true jsonwebtoken.workspace = true -live_kit_server.workspace = true +livekit_server.workspace = true log.workspace = true nanoid.workspace = true open_ai.workspace = true @@ -77,9 +77,16 @@ tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json", "re util.workspace = true uuid.workspace = true +[target.'cfg(target_os = "macos")'.dependencies] +livekit_client_macos = { workspace = true, features = ["test-support"] } + +[target.'cfg(not(target_os = "macos"))'.dependencies] +livekit_client = { workspace = true, features = ["test-support"] } + [dev-dependencies] assistant = { workspace = true, features = ["test-support"] } -context_servers.workspace = true +assistant_tool.workspace = true +context_server.workspace = true async-trait.workspace = true audio.workspace = true call = { workspace = true, features = ["test-support"] } @@ -100,7 +107,6 @@ hyper.workspace = true indoc.workspace = true language = { workspace = true, features = ["test-support"] } language_model = { workspace = true, features = ["test-support"] } -live_kit_client = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } menu.workspace = true multi_buffer = { workspace = true, features = ["test-support"] } @@ -124,5 +130,11 @@ util.workspace = true workspace = { workspace = true, features = ["test-support"] } worktree = { workspace = true, features = ["test-support"] } +[target.'cfg(target_os = "macos")'.dev-dependencies] +livekit_client_macos = { workspace = true, features = ["test-support"] } + +[target.'cfg(not(target_os = "macos"))'.dev-dependencies] +livekit_client = {workspace = true, features = ["test-support"] } + [package.metadata.cargo-machete] ignored = ["async-stripe"] diff --git a/crates/collab/k8s/collab.template.yml b/crates/collab/k8s/collab.template.yml index fb5d4ed6ec..a2f89e5646 100644 --- a/crates/collab/k8s/collab.template.yml +++ b/crates/collab/k8s/collab.template.yml @@ -109,17 +109,17 @@ spec: secretKeyRef: name: zed-client key: checksum-seed - - name: LIVE_KIT_SERVER + - name: LIVEKIT_SERVER valueFrom: secretKeyRef: name: livekit key: server - - name: LIVE_KIT_KEY + - name: LIVEKIT_KEY valueFrom: secretKeyRef: name: livekit key: key - - name: LIVE_KIT_SECRET + - name: LIVEKIT_SECRET valueFrom: secretKeyRef: name: livekit diff --git a/crates/collab/src/api/billing.rs b/crates/collab/src/api/billing.rs index d431e4c043..88201bb5cc 100644 --- a/crates/collab/src/api/billing.rs +++ b/crates/collab/src/api/billing.rs @@ -9,6 +9,7 @@ use collections::HashSet; use reqwest::StatusCode; use sea_orm::ActiveValue; use serde::{Deserialize, Serialize}; +use serde_json::json; use std::{str::FromStr, sync::Arc, time::Duration}; use stripe::{ BillingPortalSession, CreateBillingPortalSession, CreateBillingPortalSessionFlowData, @@ -19,6 +20,7 @@ use stripe::{ }; use util::ResultExt; +use crate::api::events::SnowflakeRow; use crate::llm::{DEFAULT_MAX_MONTHLY_SPEND, FREE_TIER_MONTHLY_SPENDING_LIMIT}; use crate::rpc::{ResultExt as _, Server}; use crate::{ @@ -124,6 +126,20 @@ async fn update_billing_preferences( .await? }; + SnowflakeRow::new( + "Spend Limit Updated", + Some(user.metrics_id), + user.admin, + None, + json!({ + "user_id": user.id, + "max_monthly_llm_usage_spending_in_cents": billing_preferences.max_monthly_llm_usage_spending_in_cents, + }), + ) + .write(&app.kinesis_client, &app.config.kinesis_stream) + .await + .log_err(); + rpc_server.refresh_llm_tokens_for_user(user.id).await; Ok(Json(BillingPreferencesResponse { diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 10120ea814..b107358eff 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -154,9 +154,9 @@ impl Database { } let role = role.unwrap(); - let live_kit_room = format!("channel-{}", nanoid::nanoid!(30)); + let livekit_room = format!("channel-{}", nanoid::nanoid!(30)); let room_id = self - .get_or_create_channel_room(channel_id, &live_kit_room, &tx) + .get_or_create_channel_room(channel_id, &livekit_room, &tx) .await?; self.join_channel_room_internal(room_id, user_id, connection, role, &tx) @@ -896,7 +896,7 @@ impl Database { pub(crate) async fn get_or_create_channel_room( &self, channel_id: ChannelId, - live_kit_room: &str, + livekit_room: &str, tx: &DatabaseTransaction, ) -> Result { let room = room::Entity::find() @@ -909,7 +909,7 @@ impl Database { } else { let result = room::Entity::insert(room::ActiveModel { channel_id: ActiveValue::Set(Some(channel_id)), - live_kit_room: ActiveValue::Set(live_kit_room.to_string()), + live_kit_room: ActiveValue::Set(livekit_room.to_string()), ..Default::default() }) .exec(tx) diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 682c4ed389..a3a99bee71 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -103,11 +103,11 @@ impl Database { &self, user_id: UserId, connection: ConnectionId, - live_kit_room: &str, + livekit_room: &str, ) -> Result { self.transaction(|tx| async move { let room = room::ActiveModel { - live_kit_room: ActiveValue::set(live_kit_room.into()), + live_kit_room: ActiveValue::set(livekit_room.into()), ..Default::default() } .insert(&*tx) @@ -1316,7 +1316,7 @@ impl Database { channel, proto::Room { id: db_room.id.to_proto(), - live_kit_room: db_room.live_kit_room, + livekit_room: db_room.live_kit_room, participants: participants.into_values().collect(), pending_participants, followers, diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index f595cff890..cfa0e1631e 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -156,9 +156,9 @@ pub struct Config { pub clickhouse_password: Option, pub clickhouse_database: Option, pub invite_link_prefix: String, - pub live_kit_server: Option, - pub live_kit_key: Option, - pub live_kit_secret: Option, + pub livekit_server: Option, + pub livekit_key: Option, + pub livekit_secret: Option, pub llm_database_url: Option, pub llm_database_max_connections: Option, pub llm_database_migrations_path: Option, @@ -210,9 +210,9 @@ impl Config { database_max_connections: 0, api_token: "".into(), invite_link_prefix: "".into(), - live_kit_server: None, - live_kit_key: None, - live_kit_secret: None, + livekit_server: None, + livekit_key: None, + livekit_secret: None, llm_database_url: None, llm_database_max_connections: None, llm_database_migrations_path: None, @@ -277,7 +277,7 @@ impl ServiceMode { pub struct AppState { pub db: Arc, pub llm_db: Option>, - pub live_kit_client: Option>, + pub livekit_client: Option>, pub blob_store_client: Option, pub stripe_client: Option>, pub stripe_billing: Option>, @@ -309,17 +309,17 @@ impl AppState { None }; - let live_kit_client = if let Some(((server, key), secret)) = config - .live_kit_server + let livekit_client = if let Some(((server, key), secret)) = config + .livekit_server .as_ref() - .zip(config.live_kit_key.as_ref()) - .zip(config.live_kit_secret.as_ref()) + .zip(config.livekit_key.as_ref()) + .zip(config.livekit_secret.as_ref()) { - Some(Arc::new(live_kit_server::api::LiveKitClient::new( + Some(Arc::new(livekit_server::api::LiveKitClient::new( server.clone(), key.clone(), secret.clone(), - )) as Arc) + )) as Arc) } else { None }; @@ -329,7 +329,7 @@ impl AppState { let this = Self { db: db.clone(), llm_db, - live_kit_client, + livekit_client, blob_store_client: build_blob_store_client(&config).await.log_err(), stripe_billing: stripe_client .clone() diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index a17d4924b7..8fa627d9e1 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -309,6 +309,7 @@ impl Server { .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) + .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler( @@ -418,7 +419,7 @@ impl Server { let peer = self.peer.clone(); let timeout = self.app_state.executor.sleep(CLEANUP_TIMEOUT); let pool = self.connection_pool.clone(); - let live_kit_client = self.app_state.live_kit_client.clone(); + let livekit_client = self.app_state.livekit_client.clone(); let span = info_span!("start server"); self.app_state.executor.spawn_detached( @@ -463,8 +464,8 @@ impl Server { for room_id in room_ids { let mut contacts_to_update = HashSet::default(); let mut canceled_calls_to_user_ids = Vec::new(); - let mut live_kit_room = String::new(); - let mut delete_live_kit_room = false; + let mut livekit_room = String::new(); + let mut delete_livekit_room = false; if let Some(mut refreshed_room) = app_state .db @@ -487,8 +488,8 @@ impl Server { .extend(refreshed_room.canceled_calls_to_user_ids.iter().copied()); canceled_calls_to_user_ids = mem::take(&mut refreshed_room.canceled_calls_to_user_ids); - live_kit_room = mem::take(&mut refreshed_room.room.live_kit_room); - delete_live_kit_room = refreshed_room.room.participants.is_empty(); + livekit_room = mem::take(&mut refreshed_room.room.livekit_room); + delete_livekit_room = refreshed_room.room.participants.is_empty(); } { @@ -539,9 +540,9 @@ impl Server { } } - if let Some(live_kit) = live_kit_client.as_ref() { - if delete_live_kit_room { - live_kit.delete_room(live_kit_room).await.trace_err(); + if let Some(live_kit) = livekit_client.as_ref() { + if delete_livekit_room { + live_kit.delete_room(livekit_room).await.trace_err(); } } } @@ -1210,15 +1211,15 @@ async fn create_room( response: Response, session: Session, ) -> Result<()> { - let live_kit_room = nanoid::nanoid!(30); + let livekit_room = nanoid::nanoid!(30); let live_kit_connection_info = util::maybe!(async { - let live_kit = session.app_state.live_kit_client.as_ref(); + let live_kit = session.app_state.livekit_client.as_ref(); let live_kit = live_kit?; let user_id = session.user_id().to_string(); let token = live_kit - .room_token(&live_kit_room, &user_id.to_string()) + .room_token(&livekit_room, &user_id.to_string()) .trace_err()?; Some(proto::LiveKitConnectionInfo { @@ -1232,7 +1233,7 @@ async fn create_room( let room = session .db() .await - .create_room(session.user_id(), session.connection_id, &live_kit_room) + .create_room(session.user_id(), session.connection_id, &livekit_room) .await?; response.send(proto::CreateRoomResponse { @@ -1284,22 +1285,22 @@ async fn join_room( .trace_err(); } - let live_kit_connection_info = - if let Some(live_kit) = session.app_state.live_kit_client.as_ref() { - live_kit - .room_token( - &joined_room.room.live_kit_room, - &session.user_id().to_string(), - ) - .trace_err() - .map(|token| proto::LiveKitConnectionInfo { - server_url: live_kit.url().into(), - token, - can_publish: true, - }) - } else { - None - }; + let live_kit_connection_info = if let Some(live_kit) = session.app_state.livekit_client.as_ref() + { + live_kit + .room_token( + &joined_room.room.livekit_room, + &session.user_id().to_string(), + ) + .trace_err() + .map(|token| proto::LiveKitConnectionInfo { + server_url: live_kit.url().into(), + token, + can_publish: true, + }) + } else { + None + }; response.send(proto::JoinRoomResponse { room: Some(joined_room.room), @@ -1506,7 +1507,7 @@ async fn set_room_participant_role( let user_id = UserId::from_proto(request.user_id); let role = ChannelRole::from(request.role()); - let (live_kit_room, can_publish) = { + let (livekit_room, can_publish) = { let room = session .db() .await @@ -1518,18 +1519,18 @@ async fn set_room_participant_role( ) .await?; - let live_kit_room = room.live_kit_room.clone(); + let livekit_room = room.livekit_room.clone(); let can_publish = ChannelRole::from(request.role()).can_use_microphone(); room_updated(&room, &session.peer); - (live_kit_room, can_publish) + (livekit_room, can_publish) }; - if let Some(live_kit) = session.app_state.live_kit_client.as_ref() { + if let Some(live_kit) = session.app_state.livekit_client.as_ref() { live_kit .update_participant( - live_kit_room.clone(), + livekit_room.clone(), request.user_id.to_string(), - live_kit_server::proto::ParticipantPermission { + livekit_server::proto::ParticipantPermission { can_subscribe: true, can_publish, can_publish_data: can_publish, @@ -3091,7 +3092,7 @@ async fn join_channel_internal( let live_kit_connection_info = session .app_state - .live_kit_client + .livekit_client .as_ref() .and_then(|live_kit| { let (can_publish, token) = if role == ChannelRole::Guest { @@ -3099,7 +3100,7 @@ async fn join_channel_internal( false, live_kit .guest_token( - &joined_room.room.live_kit_room, + &joined_room.room.livekit_room, &session.user_id().to_string(), ) .trace_err()?, @@ -3109,7 +3110,7 @@ async fn join_channel_internal( true, live_kit .room_token( - &joined_room.room.live_kit_room, + &joined_room.room.livekit_room, &session.user_id().to_string(), ) .trace_err()?, @@ -4313,8 +4314,8 @@ async fn leave_room_for_session(session: &Session, connection_id: ConnectionId) let room_id; let canceled_calls_to_user_ids; - let live_kit_room; - let delete_live_kit_room; + let livekit_room; + let delete_livekit_room; let room; let channel; @@ -4327,8 +4328,8 @@ async fn leave_room_for_session(session: &Session, connection_id: ConnectionId) room_id = RoomId::from_proto(left_room.room.id); canceled_calls_to_user_ids = mem::take(&mut left_room.canceled_calls_to_user_ids); - live_kit_room = mem::take(&mut left_room.room.live_kit_room); - delete_live_kit_room = left_room.deleted; + livekit_room = mem::take(&mut left_room.room.livekit_room); + delete_livekit_room = left_room.deleted; room = mem::take(&mut left_room.room); channel = mem::take(&mut left_room.channel); @@ -4368,14 +4369,14 @@ async fn leave_room_for_session(session: &Session, connection_id: ConnectionId) update_user_contacts(contact_user_id, session).await?; } - if let Some(live_kit) = session.app_state.live_kit_client.as_ref() { + if let Some(live_kit) = session.app_state.livekit_client.as_ref() { live_kit - .remove_participant(live_kit_room.clone(), session.user_id().to_string()) + .remove_participant(livekit_room.clone(), session.user_id().to_string()) .await .trace_err(); - if delete_live_kit_room { - live_kit.delete_room(live_kit_room).await.trace_err(); + if delete_livekit_room { + live_kit.delete_room(livekit_room).await.trace_err(); } } diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index 29373bc6ea..2ce69efc9b 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -1,3 +1,6 @@ +// todo(windows): Actually run the tests +#![cfg(not(target_os = "windows"))] + use std::sync::Arc; use call::Room; diff --git a/crates/collab/src/tests/channel_guest_tests.rs b/crates/collab/src/tests/channel_guest_tests.rs index 5a091fe308..006a3e5d1c 100644 --- a/crates/collab/src/tests/channel_guest_tests.rs +++ b/crates/collab/src/tests/channel_guest_tests.rs @@ -107,7 +107,9 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test }); assert!(project_b.read_with(cx_b, |project, cx| project.is_read_only(cx))); assert!(editor_b.update(cx_b, |e, cx| e.read_only(cx))); - assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone())); + cx_b.update(|cx_b| { + assert!(room_b.read_with(cx_b, |room, cx| !room.can_use_microphone(cx))); + }); assert!(room_b .update(cx_b, |room, cx| room.share_microphone(cx)) .await @@ -133,7 +135,9 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test assert!(editor_b.update(cx_b, |editor, cx| !editor.read_only(cx))); // B sees themselves as muted, and can unmute. - assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone())); + cx_b.update(|cx_b| { + assert!(room_b.read_with(cx_b, |room, cx| room.can_use_microphone(cx))); + }); room_b.read_with(cx_b, |room, _| assert!(room.is_muted())); room_b.update(cx_b, |room, cx| room.toggle_mute(cx)); cx_a.run_until_parked(); @@ -226,7 +230,9 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes let room_b = cx_b .read(ActiveCall::global) .update(cx_b, |call, _| call.room().unwrap().clone()); - assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone())); + cx_b.update(|cx_b| { + assert!(room_b.read_with(cx_b, |room, cx| !room.can_use_microphone(cx))); + }); // A tries to grant write access to B, but cannot because B has not // yet signed the zed CLA. @@ -244,7 +250,9 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes .unwrap_err(); cx_a.run_until_parked(); assert!(room_b.read_with(cx_b, |room, _| !room.can_share_projects())); - assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone())); + cx_b.update(|cx_b| { + assert!(room_b.read_with(cx_b, |room, cx| !room.can_use_microphone(cx))); + }); // A tries to grant write access to B, but cannot because B has not // yet signed the zed CLA. @@ -262,7 +270,9 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes .unwrap(); cx_a.run_until_parked(); assert!(room_b.read_with(cx_b, |room, _| !room.can_share_projects())); - assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone())); + cx_b.update(|cx_b| { + assert!(room_b.read_with(cx_b, |room, cx| room.can_use_microphone(cx))); + }); // User B signs the zed CLA. server @@ -287,5 +297,7 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes .unwrap(); cx_a.run_until_parked(); assert!(room_b.read_with(cx_b, |room, _| room.can_share_projects())); - assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone())); + cx_b.update(|cx_b| { + assert!(room_b.read_with(cx_b, |room, cx| room.can_use_microphone(cx))); + }); } diff --git a/crates/collab/src/tests/following_tests.rs b/crates/collab/src/tests/following_tests.rs index d708194f58..4de368d2ea 100644 --- a/crates/collab/src/tests/following_tests.rs +++ b/crates/collab/src/tests/following_tests.rs @@ -1,5 +1,5 @@ #![allow(clippy::reversed_empty_ranges)] -use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; +use crate::tests::TestServer; use call::{ActiveCall, ParticipantLocation}; use client::ChannelId; use collab_ui::{ @@ -12,17 +12,11 @@ use gpui::{ View, VisualContext, VisualTestContext, }; use language::Capability; -use live_kit_client::MacOSDisplay; use project::WorktreeSettings; use rpc::proto::PeerId; use serde_json::json; use settings::SettingsStore; -use workspace::{ - dock::{test::TestPanel, DockPosition}, - item::{test::TestItem, ItemHandle as _}, - shared_screen::SharedScreen, - SplitDirection, Workspace, -}; +use workspace::{item::ItemHandle as _, SplitDirection, Workspace}; use super::TestClient; @@ -428,106 +422,118 @@ async fn test_basic_following( editor_a1.item_id() ); - // Client B activates an external window, which causes a new screen-sharing item to be added to the pane. - let display = MacOSDisplay::new(); - active_call_b - .update(cx_b, |call, cx| call.set_location(None, cx)) - .await - .unwrap(); - active_call_b - .update(cx_b, |call, cx| { - call.room().unwrap().update(cx, |room, cx| { - room.set_display_sources(vec![display.clone()]); - room.share_screen(cx) + // TODO: Re-enable this test once we can replace our swift Livekit SDK with the rust SDK + #[cfg(not(target_os = "macos"))] + { + use crate::rpc::RECONNECT_TIMEOUT; + use gpui::TestScreenCaptureSource; + use workspace::{ + dock::{test::TestPanel, DockPosition}, + item::test::TestItem, + shared_screen::SharedScreen, + }; + + // Client B activates an external window, which causes a new screen-sharing item to be added to the pane. + let display = TestScreenCaptureSource::new(); + active_call_b + .update(cx_b, |call, cx| call.set_location(None, cx)) + .await + .unwrap(); + cx_b.set_screen_capture_sources(vec![display]); + active_call_b + .update(cx_b, |call, cx| { + call.room() + .unwrap() + .update(cx, |room, cx| room.share_screen(cx)) }) - }) - .await - .unwrap(); - executor.run_until_parked(); - let shared_screen = workspace_a.update(cx_a, |workspace, cx| { - workspace - .active_item(cx) - .expect("no active item") - .downcast::() - .expect("active item isn't a shared screen") - }); + .await + .unwrap(); // This is what breaks + executor.run_until_parked(); + let shared_screen = workspace_a.update(cx_a, |workspace, cx| { + workspace + .active_item(cx) + .expect("no active item") + .downcast::() + .expect("active item isn't a shared screen") + }); - // Client B activates Zed again, which causes the previous editor to become focused again. - active_call_b - .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) - .await - .unwrap(); - executor.run_until_parked(); - workspace_a.update(cx_a, |workspace, cx| { + // Client B activates Zed again, which causes the previous editor to become focused again. + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + executor.run_until_parked(); + workspace_a.update(cx_a, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).unwrap().item_id(), + editor_a1.item_id() + ) + }); + + // Client B activates a multibuffer that was created by following client A. Client A returns to that multibuffer. + workspace_b.update(cx_b, |workspace, cx| { + workspace.activate_item(&multibuffer_editor_b, true, true, cx) + }); + executor.run_until_parked(); + workspace_a.update(cx_a, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).unwrap().item_id(), + multibuffer_editor_a.item_id() + ) + }); + + // Client B activates a panel, and the previously-opened screen-sharing item gets activated. + let panel = cx_b.new_view(|cx| TestPanel::new(DockPosition::Left, cx)); + workspace_b.update(cx_b, |workspace, cx| { + workspace.add_panel(panel, cx); + workspace.toggle_panel_focus::(cx); + }); + executor.run_until_parked(); assert_eq!( - workspace.active_item(cx).unwrap().item_id(), - editor_a1.item_id() - ) - }); + workspace_a.update(cx_a, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .item_id()), + shared_screen.item_id() + ); - // Client B activates a multibuffer that was created by following client A. Client A returns to that multibuffer. - workspace_b.update(cx_b, |workspace, cx| { - workspace.activate_item(&multibuffer_editor_b, true, true, cx) - }); - executor.run_until_parked(); - workspace_a.update(cx_a, |workspace, cx| { + // Toggling the focus back to the pane causes client A to return to the multibuffer. + workspace_b.update(cx_b, |workspace, cx| { + workspace.toggle_panel_focus::(cx); + }); + executor.run_until_parked(); + workspace_a.update(cx_a, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).unwrap().item_id(), + multibuffer_editor_a.item_id() + ) + }); + + // Client B activates an item that doesn't implement following, + // so the previously-opened screen-sharing item gets activated. + let unfollowable_item = cx_b.new_view(TestItem::new); + workspace_b.update(cx_b, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item(Box::new(unfollowable_item), true, true, None, cx) + }) + }); + executor.run_until_parked(); assert_eq!( - workspace.active_item(cx).unwrap().item_id(), - multibuffer_editor_a.item_id() - ) - }); + workspace_a.update(cx_a, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .item_id()), + shared_screen.item_id() + ); - // Client B activates a panel, and the previously-opened screen-sharing item gets activated. - let panel = cx_b.new_view(|cx| TestPanel::new(DockPosition::Left, cx)); - workspace_b.update(cx_b, |workspace, cx| { - workspace.add_panel(panel, cx); - workspace.toggle_panel_focus::(cx); - }); - executor.run_until_parked(); - assert_eq!( - workspace_a.update(cx_a, |workspace, cx| workspace - .active_item(cx) - .unwrap() - .item_id()), - shared_screen.item_id() - ); - - // Toggling the focus back to the pane causes client A to return to the multibuffer. - workspace_b.update(cx_b, |workspace, cx| { - workspace.toggle_panel_focus::(cx); - }); - executor.run_until_parked(); - workspace_a.update(cx_a, |workspace, cx| { + // Following interrupts when client B disconnects. + client_b.disconnect(&cx_b.to_async()); + executor.advance_clock(RECONNECT_TIMEOUT); assert_eq!( - workspace.active_item(cx).unwrap().item_id(), - multibuffer_editor_a.item_id() - ) - }); - - // Client B activates an item that doesn't implement following, - // so the previously-opened screen-sharing item gets activated. - let unfollowable_item = cx_b.new_view(TestItem::new); - workspace_b.update(cx_b, |workspace, cx| { - workspace.active_pane().update(cx, |pane, cx| { - pane.add_item(Box::new(unfollowable_item), true, true, None, cx) - }) - }); - executor.run_until_parked(); - assert_eq!( - workspace_a.update(cx_a, |workspace, cx| workspace - .active_item(cx) - .unwrap() - .item_id()), - shared_screen.item_id() - ); - - // Following interrupts when client B disconnects. - client_b.disconnect(&cx_b.to_async()); - executor.advance_clock(RECONNECT_TIMEOUT); - assert_eq!( - workspace_a.update(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), - None - ); + workspace_a.update(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), + None + ); + } } #[gpui::test] diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 5ec9a574a1..a0b36ce5cc 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -6,7 +6,8 @@ use crate::{ }, }; use anyhow::{anyhow, Result}; -use assistant::{ContextStore, PromptBuilder, SlashCommandWorkingSet, ToolWorkingSet}; +use assistant::{ContextStore, PromptBuilder, SlashCommandWorkingSet}; +use assistant_tool::ToolWorkingSet; use call::{room, ActiveCall, ParticipantLocation, Room}; use client::{User, RECEIVE_TIMEOUT}; use collections::{HashMap, HashSet}; @@ -24,7 +25,6 @@ use language::{ tree_sitter_rust, tree_sitter_typescript, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LineEnding, OffsetRangeExt, Point, Rope, }; -use live_kit_client::MacOSDisplay; use lsp::LanguageServerId; use parking_lot::Mutex; use project::lsp_store::FormatTarget; @@ -240,56 +240,60 @@ async fn test_basic_calls( } ); - // User A shares their screen - let display = MacOSDisplay::new(); - let events_b = active_call_events(cx_b); - let events_c = active_call_events(cx_c); - active_call_a - .update(cx_a, |call, cx| { - call.room().unwrap().update(cx, |room, cx| { - room.set_display_sources(vec![display.clone()]); - room.share_screen(cx) + // TODO: Re-enable this test once we can replace our swift Livekit SDK with the rust SDK + #[cfg(not(target_os = "macos"))] + { + // User A shares their screen + let display = gpui::TestScreenCaptureSource::new(); + let events_b = active_call_events(cx_b); + let events_c = active_call_events(cx_c); + cx_a.set_screen_capture_sources(vec![display]); + active_call_a + .update(cx_a, |call, cx| { + call.room() + .unwrap() + .update(cx, |room, cx| room.share_screen(cx)) }) - }) - .await - .unwrap(); + .await + .unwrap(); - executor.run_until_parked(); + executor.run_until_parked(); - // User B observes the remote screen sharing track. - assert_eq!(events_b.borrow().len(), 1); - let event_b = events_b.borrow().first().unwrap().clone(); - if let call::room::Event::RemoteVideoTracksChanged { participant_id } = event_b { - assert_eq!(participant_id, client_a.peer_id().unwrap()); + // User B observes the remote screen sharing track. + assert_eq!(events_b.borrow().len(), 1); + let event_b = events_b.borrow().first().unwrap().clone(); + if let call::room::Event::RemoteVideoTracksChanged { participant_id } = event_b { + assert_eq!(participant_id, client_a.peer_id().unwrap()); - room_b.read_with(cx_b, |room, _| { - assert_eq!( - room.remote_participants()[&client_a.user_id().unwrap()] - .video_tracks - .len(), - 1 - ); - }); - } else { - panic!("unexpected event") - } + room_b.read_with(cx_b, |room, _| { + assert_eq!( + room.remote_participants()[&client_a.user_id().unwrap()] + .video_tracks + .len(), + 1 + ); + }); + } else { + panic!("unexpected event") + } - // User C observes the remote screen sharing track. - assert_eq!(events_c.borrow().len(), 1); - let event_c = events_c.borrow().first().unwrap().clone(); - if let call::room::Event::RemoteVideoTracksChanged { participant_id } = event_c { - assert_eq!(participant_id, client_a.peer_id().unwrap()); + // User C observes the remote screen sharing track. + assert_eq!(events_c.borrow().len(), 1); + let event_c = events_c.borrow().first().unwrap().clone(); + if let call::room::Event::RemoteVideoTracksChanged { participant_id } = event_c { + assert_eq!(participant_id, client_a.peer_id().unwrap()); - room_c.read_with(cx_c, |room, _| { - assert_eq!( - room.remote_participants()[&client_a.user_id().unwrap()] - .video_tracks - .len(), - 1 - ); - }); - } else { - panic!("unexpected event") + room_c.read_with(cx_c, |room, _| { + assert_eq!( + room.remote_participants()[&client_a.user_id().unwrap()] + .video_tracks + .len(), + 1 + ); + }); + } else { + panic!("unexpected event") + } } // User A leaves the room. @@ -328,7 +332,7 @@ async fn test_basic_calls( // to automatically leave the room. User C leaves the room as well because // nobody else is in there. server - .test_live_kit_server + .test_livekit_server .disconnect_client(client_b.user_id().unwrap().to_string()) .await; executor.run_until_parked(); @@ -843,7 +847,7 @@ async fn test_client_disconnecting_from_room( // User B gets disconnected from the LiveKit server, which causes it // to automatically leave the room. server - .test_live_kit_server + .test_livekit_server .disconnect_client(client_b.user_id().unwrap().to_string()) .await; executor.run_until_parked(); @@ -1942,7 +1946,7 @@ async fn test_mute_deafen( room_a.read_with(cx_a, |room, _| assert!(!room.is_muted())); room_b.read_with(cx_b, |room, _| assert!(!room.is_muted())); - // Users A and B are both muted. + // Users A and B are both unmuted. assert_eq!( participant_audio_state(&room_a, cx_a), &[ParticipantAudioState { @@ -2074,7 +2078,17 @@ async fn test_mute_deafen( audio_tracks_playing: participant .audio_tracks .values() - .map(|track| track.is_playing()) + .map({ + #[cfg(target_os = "macos")] + { + |track| track.is_playing() + } + + #[cfg(not(target_os = "macos"))] + { + |(track, _)| track.rtc_track().enabled() + } + }) .collect(), }) .collect::>() @@ -2560,19 +2574,23 @@ async fn test_git_diff_base_change( .update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) .await .unwrap(); + let change_set_local_a = project_local + .update(cx_a, |p, cx| { + p.open_unstaged_changes(buffer_local_a.clone(), cx) + }) + .await + .unwrap(); // Wait for it to catch up to the new diff executor.run_until_parked(); - - // Smoke test diffing - - buffer_local_a.read_with(cx_a, |buffer, _| { + change_set_local_a.read_with(cx_a, |change_set, cx| { + let buffer = buffer_local_a.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(diff_base.as_str()) ); git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, &diff_base, &[(1..2, "", "two\n")], @@ -2584,25 +2602,30 @@ async fn test_git_diff_base_change( .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) .await .unwrap(); + let change_set_remote_a = project_remote + .update(cx_b, |p, cx| { + p.open_unstaged_changes(buffer_remote_a.clone(), cx) + }) + .await + .unwrap(); // Wait remote buffer to catch up to the new diff executor.run_until_parked(); - - // Smoke test diffing - - buffer_remote_a.read_with(cx_b, |buffer, _| { + change_set_remote_a.read_with(cx_b, |change_set, cx| { + let buffer = buffer_remote_a.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(diff_base.as_str()) ); git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, &diff_base, &[(1..2, "", "two\n")], ); }); + // Update the staged text of the open buffer client_a.fs().set_index_for_repo( Path::new("/dir/.git"), &[(Path::new("a.txt"), new_diff_base.clone())], @@ -2610,40 +2633,35 @@ async fn test_git_diff_base_change( // Wait for buffer_local_a to receive it executor.run_until_parked(); - - // Smoke test new diffing - - buffer_local_a.read_with(cx_a, |buffer, _| { + change_set_local_a.read_with(cx_a, |change_set, cx| { + let buffer = buffer_local_a.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(new_diff_base.as_str()) ); - git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, - &diff_base, + &new_diff_base, &[(2..3, "", "three\n")], ); }); - // Smoke test B - - buffer_remote_a.read_with(cx_b, |buffer, _| { + change_set_remote_a.read_with(cx_b, |change_set, cx| { + let buffer = buffer_remote_a.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(new_diff_base.as_str()) ); git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, - &diff_base, + &new_diff_base, &[(2..3, "", "three\n")], ); }); - //Nested git dir - + // Nested git dir let diff_base = " one three @@ -2666,19 +2684,23 @@ async fn test_git_diff_base_change( .update(cx_a, |p, cx| p.open_buffer((worktree_id, "sub/b.txt"), cx)) .await .unwrap(); + let change_set_local_b = project_local + .update(cx_a, |p, cx| { + p.open_unstaged_changes(buffer_local_b.clone(), cx) + }) + .await + .unwrap(); // Wait for it to catch up to the new diff executor.run_until_parked(); - - // Smoke test diffing - - buffer_local_b.read_with(cx_a, |buffer, _| { + change_set_local_b.read_with(cx_a, |change_set, cx| { + let buffer = buffer_local_b.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(diff_base.as_str()) ); git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, &diff_base, &[(1..2, "", "two\n")], @@ -2690,25 +2712,29 @@ async fn test_git_diff_base_change( .update(cx_b, |p, cx| p.open_buffer((worktree_id, "sub/b.txt"), cx)) .await .unwrap(); + let change_set_remote_b = project_remote + .update(cx_b, |p, cx| { + p.open_unstaged_changes(buffer_remote_b.clone(), cx) + }) + .await + .unwrap(); - // Wait remote buffer to catch up to the new diff executor.run_until_parked(); - - // Smoke test diffing - - buffer_remote_b.read_with(cx_b, |buffer, _| { + change_set_remote_b.read_with(cx_b, |change_set, cx| { + let buffer = buffer_remote_b.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(diff_base.as_str()) ); git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, &diff_base, &[(1..2, "", "two\n")], ); }); + // Update the staged text client_a.fs().set_index_for_repo( Path::new("/dir/sub/.git"), &[(Path::new("b.txt"), new_diff_base.clone())], @@ -2716,43 +2742,30 @@ async fn test_git_diff_base_change( // Wait for buffer_local_b to receive it executor.run_until_parked(); - - // Smoke test new diffing - - buffer_local_b.read_with(cx_a, |buffer, _| { + change_set_local_b.read_with(cx_a, |change_set, cx| { + let buffer = buffer_local_b.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(new_diff_base.as_str()) ); - println!("{:?}", buffer.as_rope().to_string()); - println!("{:?}", buffer.diff_base()); - println!( - "{:?}", - buffer - .snapshot() - .git_diff_hunks_in_row_range(0..4) - .collect::>() - ); - git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, - &diff_base, + &new_diff_base, &[(2..3, "", "three\n")], ); }); - // Smoke test B - - buffer_remote_b.read_with(cx_b, |buffer, _| { + change_set_remote_b.read_with(cx_b, |change_set, cx| { + let buffer = buffer_remote_b.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(new_diff_base.as_str()) ); git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, - &diff_base, + &new_diff_base, &[(2..3, "", "three\n")], ); }); @@ -6015,6 +6028,8 @@ async fn test_contact_requests( } } +// TODO: Re-enable this test once we can replace our swift Livekit SDK with the rust SDK +#[cfg(not(target_os = "macos"))] #[gpui::test(iterations = 10)] async fn test_join_call_after_screen_was_shared( executor: BackgroundExecutor, @@ -6057,13 +6072,13 @@ async fn test_join_call_after_screen_was_shared( assert_eq!(call_b.calling_user.github_login, "user_a"); // User A shares their screen - let display = MacOSDisplay::new(); + let display = gpui::TestScreenCaptureSource::new(); + cx_a.set_screen_capture_sources(vec![display]); active_call_a .update(cx_a, |call, cx| { - call.room().unwrap().update(cx, |room, cx| { - room.set_display_sources(vec![display.clone()]); - room.share_screen(cx) - }) + call.room() + .unwrap() + .update(cx, |room, cx| room.share_screen(cx)) }) .await .unwrap(); @@ -6486,8 +6501,8 @@ async fn test_context_collaboration_with_reconnect( assert_eq!(project.collaborators().len(), 1); }); - cx_a.update(context_servers::init); - cx_b.update(context_servers::init); + cx_a.update(context_server::init); + cx_b.update(context_server::init); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); let context_store_a = cx_a .update(|cx| { diff --git a/crates/collab/src/tests/random_project_collaboration_tests.rs b/crates/collab/src/tests/random_project_collaboration_tests.rs index 1f39190d75..351ae0cbe6 100644 --- a/crates/collab/src/tests/random_project_collaboration_tests.rs +++ b/crates/collab/src/tests/random_project_collaboration_tests.rs @@ -1336,10 +1336,24 @@ impl RandomizedTest for ProjectCollaborationTest { (_, None) => panic!("guest's file is None, hosts's isn't"), } - let host_diff_base = host_buffer - .read_with(host_cx, |b, _| b.diff_base().map(ToString::to_string)); - let guest_diff_base = guest_buffer - .read_with(client_cx, |b, _| b.diff_base().map(ToString::to_string)); + let host_diff_base = host_project.read_with(host_cx, |project, cx| { + project + .buffer_store() + .read(cx) + .get_unstaged_changes(host_buffer.read(cx).remote_id()) + .unwrap() + .read(cx) + .base_text_string(cx) + }); + let guest_diff_base = guest_project.read_with(client_cx, |project, cx| { + project + .buffer_store() + .read(cx) + .get_unstaged_changes(guest_buffer.read(cx).remote_id()) + .unwrap() + .read(cx) + .base_text_string(cx) + }); assert_eq!( guest_diff_base, host_diff_base, "guest {} diff base does not match host's for path {path:?} in project {project_id}", diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index c93cce9770..e66a828a77 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -45,9 +45,15 @@ use std::{ }; use workspace::{Workspace, WorkspaceStore}; +#[cfg(not(target_os = "macos"))] +use livekit_client::test::TestServer as LivekitTestServer; + +#[cfg(target_os = "macos")] +use livekit_client_macos::TestServer as LivekitTestServer; + pub struct TestServer { pub app_state: Arc, - pub test_live_kit_server: Arc, + pub test_livekit_server: Arc, server: Arc, next_github_user_id: i32, connection_killers: Arc>>>, @@ -79,7 +85,7 @@ pub struct ContactsSummary { impl TestServer { pub async fn start(deterministic: BackgroundExecutor) -> Self { - static NEXT_LIVE_KIT_SERVER_ID: AtomicUsize = AtomicUsize::new(0); + static NEXT_LIVEKIT_SERVER_ID: AtomicUsize = AtomicUsize::new(0); let use_postgres = env::var("USE_POSTGRES").ok(); let use_postgres = use_postgres.as_deref(); @@ -88,16 +94,16 @@ impl TestServer { } else { TestDb::sqlite(deterministic.clone()) }; - let live_kit_server_id = NEXT_LIVE_KIT_SERVER_ID.fetch_add(1, SeqCst); - let live_kit_server = live_kit_client::TestServer::create( - format!("http://livekit.{}.test", live_kit_server_id), - format!("devkey-{}", live_kit_server_id), - format!("secret-{}", live_kit_server_id), + let livekit_server_id = NEXT_LIVEKIT_SERVER_ID.fetch_add(1, SeqCst); + let livekit_server = LivekitTestServer::create( + format!("http://livekit.{}.test", livekit_server_id), + format!("devkey-{}", livekit_server_id), + format!("secret-{}", livekit_server_id), deterministic.clone(), ) .unwrap(); let executor = Executor::Deterministic(deterministic.clone()); - let app_state = Self::build_app_state(&test_db, &live_kit_server, executor.clone()).await; + let app_state = Self::build_app_state(&test_db, &livekit_server, executor.clone()).await; let epoch = app_state .db .create_server(&app_state.config.zed_environment) @@ -114,7 +120,7 @@ impl TestServer { forbid_connections: Default::default(), next_github_user_id: 0, _test_db: test_db, - test_live_kit_server: live_kit_server, + test_livekit_server: livekit_server, } } @@ -500,13 +506,13 @@ impl TestServer { pub async fn build_app_state( test_db: &TestDb, - live_kit_test_server: &live_kit_client::TestServer, + livekit_test_server: &LivekitTestServer, executor: Executor, ) -> Arc { Arc::new(AppState { db: test_db.db().clone(), llm_db: None, - live_kit_client: Some(Arc::new(live_kit_test_server.create_api_client())), + livekit_client: Some(Arc::new(livekit_test_server.create_api_client())), blob_store_client: None, stripe_client: None, stripe_billing: None, @@ -520,9 +526,9 @@ impl TestServer { database_max_connections: 0, api_token: "".into(), invite_link_prefix: "".into(), - live_kit_server: None, - live_kit_key: None, - live_kit_secret: None, + livekit_server: None, + livekit_key: None, + livekit_secret: None, llm_database_url: None, llm_database_max_connections: None, llm_database_migrations_path: None, @@ -572,7 +578,7 @@ impl Deref for TestServer { impl Drop for TestServer { fn drop(&mut self) { self.server.teardown(); - self.test_live_kit_server.teardown().unwrap(); + self.test_livekit_server.teardown().unwrap(); } } @@ -585,7 +591,7 @@ impl Deref for TestClient { } impl TestClient { - pub fn fs(&self) -> &FakeFs { + pub fn fs(&self) -> Arc { self.app_state.fs.as_fake() } diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index c93a48096a..fa3ab0219b 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -474,11 +474,10 @@ impl CollabPanel { project_id: project.id, worktree_root_names: project.worktree_root_names.clone(), host_user_id: participant.user.id, - is_last: projects.peek().is_none() - && participant.video_tracks.is_empty(), + is_last: projects.peek().is_none() && !participant.has_video_tracks(), }); } - if !participant.video_tracks.is_empty() { + if participant.has_video_tracks() { self.entries.push(ListEntry::ParticipantScreen { peer_id: Some(participant.peer_id), is_last: true, diff --git a/crates/context_servers/Cargo.toml b/crates/context_server/Cargo.toml similarity index 76% rename from crates/context_servers/Cargo.toml rename to crates/context_server/Cargo.toml index cbd762c8c4..410b897f28 100644 --- a/crates/context_servers/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "context_servers" +name = "context_server" version = "0.1.0" edition = "2021" publish = false @@ -9,12 +9,14 @@ license = "GPL-3.0-or-later" workspace = true [lib] -path = "src/context_servers.rs" +path = "src/context_server.rs" [dependencies] anyhow.workspace = true +assistant_tool.workspace = true collections.workspace = true command_palette_hooks.workspace = true +context_server_settings.workspace = true extension.workspace = true futures.workspace = true gpui.workspace = true @@ -22,10 +24,11 @@ log.workspace = true parking_lot.workspace = true postage.workspace = true project.workspace = true -schemars.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true smol.workspace = true +ui.workspace = true url = { workspace = true, features = ["serde"] } util.workspace = true +workspace.workspace = true diff --git a/crates/live_kit_client/LICENSE-GPL b/crates/context_server/LICENSE-GPL similarity index 100% rename from crates/live_kit_client/LICENSE-GPL rename to crates/context_server/LICENSE-GPL diff --git a/crates/context_servers/src/client.rs b/crates/context_server/src/client.rs similarity index 100% rename from crates/context_servers/src/client.rs rename to crates/context_server/src/client.rs diff --git a/crates/context_servers/src/context_servers.rs b/crates/context_server/src/context_server.rs similarity index 76% rename from crates/context_servers/src/context_servers.rs rename to crates/context_server/src/context_server.rs index e6b52aaee2..84c08d7b2a 100644 --- a/crates/context_servers/src/context_servers.rs +++ b/crates/context_server/src/context_server.rs @@ -1,4 +1,5 @@ pub mod client; +mod context_server_tool; mod extension_context_server; pub mod manager; pub mod protocol; @@ -6,10 +7,10 @@ mod registry; pub mod types; use command_palette_hooks::CommandPaletteFilter; +pub use context_server_settings::{ContextServerSettings, ServerCommand, ServerConfig}; use gpui::{actions, AppContext}; -use settings::Settings; -use crate::manager::ContextServerSettings; +pub use crate::context_server_tool::ContextServerTool; pub use crate::registry::ContextServerFactoryRegistry; actions!(context_servers, [Restart]); @@ -18,7 +19,7 @@ actions!(context_servers, [Restart]); pub const CONTEXT_SERVERS_NAMESPACE: &'static str = "context_servers"; pub fn init(cx: &mut AppContext) { - ContextServerSettings::register(cx); + context_server_settings::init(cx); ContextServerFactoryRegistry::default_global(cx); extension_context_server::init(cx); diff --git a/crates/assistant/src/tools/context_server_tool.rs b/crates/context_server/src/context_server_tool.rs similarity index 97% rename from crates/assistant/src/tools/context_server_tool.rs rename to crates/context_server/src/context_server_tool.rs index 8015d94df9..70740f710a 100644 --- a/crates/assistant/src/tools/context_server_tool.rs +++ b/crates/context_server/src/context_server_tool.rs @@ -2,10 +2,11 @@ use std::sync::Arc; use anyhow::{anyhow, bail}; use assistant_tool::Tool; -use context_servers::manager::ContextServerManager; -use context_servers::types; use gpui::{Model, Task}; +use crate::manager::ContextServerManager; +use crate::types; + pub struct ContextServerTool { server_manager: Model, server_id: Arc, diff --git a/crates/context_servers/src/extension_context_server.rs b/crates/context_server/src/extension_context_server.rs similarity index 97% rename from crates/context_servers/src/extension_context_server.rs rename to crates/context_server/src/extension_context_server.rs index 092816b5e6..36fecd2af3 100644 --- a/crates/context_servers/src/extension_context_server.rs +++ b/crates/context_server/src/extension_context_server.rs @@ -3,8 +3,7 @@ use std::sync::Arc; use extension::{Extension, ExtensionContextServerProxy, ExtensionHostProxy, ProjectDelegate}; use gpui::{AppContext, Model}; -use crate::manager::ServerCommand; -use crate::ContextServerFactoryRegistry; +use crate::{ContextServerFactoryRegistry, ServerCommand}; struct ExtensionProject { worktree_ids: Vec, diff --git a/crates/context_servers/src/manager.rs b/crates/context_server/src/manager.rs similarity index 84% rename from crates/context_servers/src/manager.rs rename to crates/context_server/src/manager.rs index c95fcd239d..febbee1cdf 100644 --- a/crates/context_servers/src/manager.rs +++ b/crates/context_server/src/manager.rs @@ -24,66 +24,16 @@ use gpui::{AsyncAppContext, EventEmitter, Model, ModelContext, Subscription, Tas use log; use parking_lot::RwLock; use project::Project; -use schemars::gen::SchemaGenerator; -use schemars::schema::{InstanceType, Schema, SchemaObject}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources, SettingsStore}; +use settings::{Settings, SettingsStore}; use util::ResultExt as _; +use crate::{ContextServerSettings, ServerConfig}; + use crate::{ client::{self, Client}, types, ContextServerFactoryRegistry, CONTEXT_SERVERS_NAMESPACE, }; -#[derive(Deserialize, Serialize, Default, Clone, PartialEq, Eq, JsonSchema, Debug)] -pub struct ContextServerSettings { - /// Settings for context servers used in the Assistant. - #[serde(default)] - pub context_servers: HashMap, ServerConfig>, -} - -#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug, Default)] -pub struct ServerConfig { - /// The command to run this context server. - /// - /// This will override the command set by an extension. - pub command: Option, - /// The settings for this context server. - /// - /// Consult the documentation for the context server to see what settings - /// are supported. - #[schemars(schema_with = "server_config_settings_json_schema")] - pub settings: Option, -} - -fn server_config_settings_json_schema(_generator: &mut SchemaGenerator) -> Schema { - Schema::Object(SchemaObject { - instance_type: Some(InstanceType::Object.into()), - ..Default::default() - }) -} - -#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug)] -pub struct ServerCommand { - pub path: String, - pub args: Vec, - pub env: Option>, -} - -impl Settings for ContextServerSettings { - const KEY: Option<&'static str> = None; - - type FileContent = Self; - - fn load( - sources: SettingsSources, - _: &mut gpui::AppContext, - ) -> anyhow::Result { - sources.json_merge() - } -} - pub struct ContextServer { pub id: Arc, pub config: Arc, diff --git a/crates/context_servers/src/protocol.rs b/crates/context_server/src/protocol.rs similarity index 100% rename from crates/context_servers/src/protocol.rs rename to crates/context_server/src/protocol.rs diff --git a/crates/context_servers/src/registry.rs b/crates/context_server/src/registry.rs similarity index 98% rename from crates/context_servers/src/registry.rs rename to crates/context_server/src/registry.rs index c17c65370a..a4d0f9a804 100644 --- a/crates/context_servers/src/registry.rs +++ b/crates/context_server/src/registry.rs @@ -5,7 +5,7 @@ use collections::HashMap; use gpui::{AppContext, AsyncAppContext, Context, Global, Model, ReadGlobal, Task}; use project::Project; -use crate::manager::ServerCommand; +use crate::ServerCommand; pub type ContextServerFactory = Arc< dyn Fn(Model, &AsyncAppContext) -> Task> + Send + Sync + 'static, diff --git a/crates/context_servers/src/types.rs b/crates/context_server/src/types.rs similarity index 86% rename from crates/context_servers/src/types.rs rename to crates/context_server/src/types.rs index 851ebbf08b..f3c6e1c5e2 100644 --- a/crates/context_servers/src/types.rs +++ b/crates/context_server/src/types.rs @@ -167,11 +167,18 @@ pub struct InitializeResponse { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResourcesReadResponse { - pub contents: Vec, + pub contents: Vec, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option>, } +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum ResourceContentsType { + Text(TextResourceContents), + Blob(BlobResourceContents), +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResourcesListResponse { @@ -181,6 +188,7 @@ pub struct ResourcesListResponse { #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option>, } + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SamplingMessage { @@ -188,6 +196,35 @@ pub struct SamplingMessage { pub content: MessageContent, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateMessageRequest { + pub messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_preferences: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_context: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + pub max_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_sequences: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateMessageResult { + pub role: Role, + pub content: MessageContent, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PromptMessage { @@ -206,11 +243,33 @@ pub enum Role { #[serde(tag = "type")] pub enum MessageContent { #[serde(rename = "text")] - Text { text: String }, + Text { + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + annotations: Option, + }, #[serde(rename = "image")] - Image { data: String, mime_type: String }, + Image { + data: String, + mime_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + annotations: Option, + }, #[serde(rename = "resource")] - Resource { resource: ResourceContents }, + Resource { + resource: ResourceContents, + #[serde(skip_serializing_if = "Option::is_none")] + annotations: Option, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageAnnotations { + #[serde(skip_serializing_if = "Option::is_none")] + pub audience: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, } #[derive(Debug, Deserialize)] @@ -460,6 +519,11 @@ pub enum ClientNotification { Initialized, Progress(ProgressParams), RootsListChanged, + Cancelled { + request_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + }, } #[derive(Debug, Serialize, Deserialize)] @@ -532,6 +596,16 @@ pub struct ListToolsResponse { pub meta: Option>, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListResourceTemplatesResponse { + pub resource_templates: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option>, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListRootsResponse { diff --git a/crates/context_server_settings/Cargo.toml b/crates/context_server_settings/Cargo.toml new file mode 100644 index 0000000000..ad0d1d9dc0 --- /dev/null +++ b/crates/context_server_settings/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "context_server_settings" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/context_server_settings.rs" + +[dependencies] +anyhow.workspace = true +collections.workspace = true +gpui.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true diff --git a/crates/context_server_settings/LICENSE-GPL b/crates/context_server_settings/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/context_server_settings/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/context_server_settings/src/context_server_settings.rs b/crates/context_server_settings/src/context_server_settings.rs new file mode 100644 index 0000000000..68969ca795 --- /dev/null +++ b/crates/context_server_settings/src/context_server_settings.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; + +use collections::HashMap; +use gpui::AppContext; +use schemars::gen::SchemaGenerator; +use schemars::schema::{InstanceType, Schema, SchemaObject}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsSources}; + +pub fn init(cx: &mut AppContext) { + ContextServerSettings::register(cx); +} + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug, Default)] +pub struct ServerConfig { + /// The command to run this context server. + /// + /// This will override the command set by an extension. + pub command: Option, + /// The settings for this context server. + /// + /// Consult the documentation for the context server to see what settings + /// are supported. + #[schemars(schema_with = "server_config_settings_json_schema")] + pub settings: Option, +} + +fn server_config_settings_json_schema(_generator: &mut SchemaGenerator) -> Schema { + Schema::Object(SchemaObject { + instance_type: Some(InstanceType::Object.into()), + ..Default::default() + }) +} + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug)] +pub struct ServerCommand { + pub path: String, + pub args: Vec, + pub env: Option>, +} + +#[derive(Deserialize, Serialize, Default, Clone, PartialEq, Eq, JsonSchema, Debug)] +pub struct ContextServerSettings { + /// Settings for context servers used in the Assistant. + #[serde(default)] + pub context_servers: HashMap, ServerConfig>, +} + +impl Settings for ContextServerSettings { + const KEY: Option<&'static str> = None; + + type FileContent = Self; + + fn load( + sources: SettingsSources, + _: &mut gpui::AppContext, + ) -> anyhow::Result { + sources.json_merge() + } +} diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index 075c3b69b1..daddefb579 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 bd0af230ab..9f02033237 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -16,8 +16,8 @@ use editor::{ }; use gpui::{ actions, div, svg, AnyElement, AnyView, AppContext, Context, EventEmitter, FocusHandle, - FocusableView, HighlightStyle, InteractiveElement, IntoElement, Model, ParentElement, Render, - SharedString, Styled, StyledText, Subscription, Task, View, ViewContext, VisualContext, + FocusableView, Global, HighlightStyle, InteractiveElement, IntoElement, Model, ParentElement, + Render, SharedString, Styled, StyledText, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext, }; use language::{ @@ -33,6 +33,7 @@ use std::{ mem, ops::Range, sync::Arc, + time::Duration, }; use theme::ActiveTheme; pub use toolbar_controls::ToolbarControls; @@ -45,6 +46,9 @@ use workspace::{ actions!(diagnostics, [Deploy, ToggleWarnings]); +struct IncludeWarnings(bool); +impl Global for IncludeWarnings {} + pub fn init(cx: &mut AppContext) { ProjectDiagnosticsSettings::register(cx); cx.observe_new_views(ProjectDiagnosticsEditor::register) @@ -82,6 +86,8 @@ struct DiagnosticGroupState { impl EventEmitter for ProjectDiagnosticsEditor {} +const DIAGNOSTICS_UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); + impl Render for ProjectDiagnosticsEditor { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let child = if self.path_states.is_empty() { @@ -114,6 +120,7 @@ impl ProjectDiagnosticsEditor { fn new_with_context( context: u32, + include_warnings: bool, project_handle: Model, workspace: WeakView, cx: &mut ViewContext, @@ -131,16 +138,27 @@ impl ProjectDiagnosticsEditor { language_server_id, path, } => { - this.paths_to_update - .insert((path.clone(), Some(*language_server_id))); - this.summary = project.read(cx).diagnostic_summary(false, cx); - cx.emit(EditorEvent::TitleChanged); + let max_severity = this.max_severity(); + let has_diagnostics_to_display = project.read(cx).lsp_store().read(cx).diagnostics_for_buffer(path) + .into_iter().flatten() + .filter(|(server_id, _)| language_server_id == server_id) + .flat_map(|(_, diagnostics)| diagnostics) + .any(|diagnostic| diagnostic.diagnostic.severity <= max_severity); - if this.editor.focus_handle(cx).contains_focused(cx) || this.focus_handle.contains_focused(cx) { - log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. recording change"); + if has_diagnostics_to_display { + this.paths_to_update + .insert((path.clone(), Some(*language_server_id))); + this.summary = project.read(cx).diagnostic_summary(false, cx); + cx.emit(EditorEvent::TitleChanged); + + if this.editor.focus_handle(cx).contains_focused(cx) || this.focus_handle.contains_focused(cx) { + log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. recording change"); + } else { + log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. updating excerpts"); + this.update_stale_excerpts(cx); + } } else { - log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. updating excerpts"); - this.update_stale_excerpts(cx); + log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. no diagnostics to display"); } } _ => {} @@ -172,19 +190,24 @@ impl ProjectDiagnosticsEditor { } }) .detach(); + cx.observe_global::(|this, cx| { + this.include_warnings = cx.global::().0; + this.update_all_excerpts(cx); + }) + .detach(); let project = project_handle.read(cx); let mut this = Self { project: project_handle.clone(), context, summary: project.diagnostic_summary(false, cx), + include_warnings, workspace, excerpts, focus_handle, editor, path_states: Default::default(), paths_to_update: Default::default(), - include_warnings: ProjectDiagnosticsSettings::get_global(cx).include_warnings, update_excerpts_task: None, _subscription: project_event_subscription, }; @@ -198,6 +221,9 @@ impl ProjectDiagnosticsEditor { } let project_handle = self.project.clone(); self.update_excerpts_task = Some(cx.spawn(|this, mut cx| async move { + cx.background_executor() + .timer(DIAGNOSTICS_UPDATE_DEBOUNCE) + .await; loop { let Some((path, language_server_id)) = this.update(&mut cx, |this, _| { let Some((path, language_server_id)) = this.paths_to_update.pop_first() else { @@ -226,11 +252,13 @@ impl ProjectDiagnosticsEditor { fn new( project_handle: Model, + include_warnings: bool, workspace: WeakView, cx: &mut ViewContext, ) -> Self { Self::new_with_context( editor::DEFAULT_MULTIBUFFER_CONTEXT, + include_warnings, project_handle, workspace, cx, @@ -242,8 +270,19 @@ impl ProjectDiagnosticsEditor { workspace.activate_item(&existing, true, true, cx); } else { let workspace_handle = cx.view().downgrade(); + + let include_warnings = match cx.try_global::() { + Some(include_warnings) => include_warnings.0, + None => ProjectDiagnosticsSettings::get_global(cx).include_warnings, + }; + let diagnostics = cx.new_view(|cx| { - ProjectDiagnosticsEditor::new(workspace.project().clone(), workspace_handle, cx) + ProjectDiagnosticsEditor::new( + workspace.project().clone(), + include_warnings, + workspace_handle, + cx, + ) }); workspace.add_item_to_active_pane(Box::new(diagnostics), None, true, cx); } @@ -251,6 +290,7 @@ impl ProjectDiagnosticsEditor { fn toggle_warnings(&mut self, _: &ToggleWarnings, cx: &mut ViewContext) { self.include_warnings = !self.include_warnings; + cx.set_global(IncludeWarnings(self.include_warnings)); self.update_all_excerpts(cx); cx.notify(); } @@ -323,16 +363,12 @@ impl ProjectDiagnosticsEditor { ExcerptId::min() }; + let max_severity = self.max_severity(); let path_state = &mut self.path_states[path_ix]; let mut new_group_ixs = Vec::new(); let mut blocks_to_add = Vec::new(); let mut blocks_to_remove = HashSet::default(); let mut first_excerpt_id = None; - let max_severity = if self.include_warnings { - DiagnosticSeverity::WARNING - } else { - DiagnosticSeverity::ERROR - }; let excerpts_snapshot = self.excerpts.update(cx, |excerpts, cx| { let mut old_groups = mem::take(&mut path_state.diagnostic_groups) .into_iter() @@ -621,6 +657,14 @@ impl ProjectDiagnosticsEditor { prev_path = Some(path); } } + + fn max_severity(&self) -> DiagnosticSeverity { + if self.include_warnings { + DiagnosticSeverity::WARNING + } else { + DiagnosticSeverity::ERROR + } + } } impl FocusableView for ProjectDiagnosticsEditor { @@ -695,7 +739,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) } @@ -719,7 +763,12 @@ impl Item for ProjectDiagnosticsEditor { Self: Sized, { Some(cx.new_view(|cx| { - ProjectDiagnosticsEditor::new(self.project.clone(), self.workspace.clone(), cx) + ProjectDiagnosticsEditor::new( + self.project.clone(), + self.include_warnings, + self.workspace.clone(), + cx, + ) })) } diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index c5ae29ff2e..6ee1a90511 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -151,11 +151,18 @@ async fn test_diagnostics(cx: &mut TestAppContext) { // Open the project diagnostics view while there are already diagnostics. let view = window.build_view(cx, |cx| { - ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx) + ProjectDiagnosticsEditor::new_with_context( + 1, + true, + project.clone(), + workspace.downgrade(), + cx, + ) }); let editor = view.update(cx, |view, _| view.editor.clone()); - view.next_notification(cx).await; + view.next_notification(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10), cx) + .await; assert_eq!( editor_blocks(&editor, cx), [ @@ -240,7 +247,8 @@ async fn test_diagnostics(cx: &mut TestAppContext) { lsp_store.disk_based_diagnostics_finished(language_server_id, cx); }); - view.next_notification(cx).await; + view.next_notification(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10), cx) + .await; assert_eq!( editor_blocks(&editor, cx), [ @@ -352,7 +360,8 @@ async fn test_diagnostics(cx: &mut TestAppContext) { lsp_store.disk_based_diagnostics_finished(language_server_id, cx); }); - view.next_notification(cx).await; + view.next_notification(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10), cx) + .await; assert_eq!( editor_blocks(&editor, cx), [ @@ -456,7 +465,13 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { let workspace = window.root(cx).unwrap(); let view = window.build_view(cx, |cx| { - ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx) + ProjectDiagnosticsEditor::new_with_context( + 1, + true, + project.clone(), + workspace.downgrade(), + cx, + ) }); let editor = view.update(cx, |view, _| view.editor.clone()); @@ -491,6 +506,8 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { }); // Only the first language server's diagnostics are shown. + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.executor().run_until_parked(); assert_eq!( editor_blocks(&editor, cx), @@ -537,6 +554,8 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { }); // Both language server's diagnostics are shown. + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.executor().run_until_parked(); assert_eq!( editor_blocks(&editor, cx), @@ -603,6 +622,8 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { }); // Only the first language server's diagnostics are updated. + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.executor().run_until_parked(); assert_eq!( editor_blocks(&editor, cx), @@ -659,6 +680,8 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { }); // Both language servers' diagnostics are updated. + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.executor().run_until_parked(); assert_eq!( editor_blocks(&editor, cx), @@ -709,7 +732,13 @@ async fn test_random_diagnostics(cx: &mut TestAppContext, mut rng: StdRng) { let workspace = window.root(cx).unwrap(); let mutated_view = window.build_view(cx, |cx| { - ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx) + ProjectDiagnosticsEditor::new_with_context( + 1, + true, + project.clone(), + workspace.downgrade(), + cx, + ) }); workspace.update(cx, |workspace, cx| { @@ -805,7 +834,13 @@ async fn test_random_diagnostics(cx: &mut TestAppContext, mut rng: StdRng) { log::info!("constructing reference diagnostics view"); let reference_view = window.build_view(cx, |cx| { - ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx) + ProjectDiagnosticsEditor::new_with_context( + 1, + true, + project.clone(), + workspace.downgrade(), + cx, + ) }); cx.run_until_parked(); diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 2c580c44de..f102be37fd 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/Cargo.toml b/crates/editor/Cargo.toml index f1f1b34981..a728ea86a2 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -39,6 +39,7 @@ collections.workspace = true convert_case.workspace = true db.workspace = true emojis.workspace = true +feature_flags.workspace = true file_icons.workspace = true futures.workspace = true fuzzy.workspace = true @@ -84,6 +85,7 @@ unindent = { workspace = true, optional = true } ui.workspace = true url.workspace = true util.workspace = true +uuid.workspace = true workspace.workspace = true [dev-dependencies] @@ -97,6 +99,7 @@ project = { workspace = true, features = ["test-support"] } release_channel.workspace = true rand.workspace = true settings = { workspace = true, features = ["test-support"] } +tempfile.workspace = true text = { workspace = true, features = ["test-support"] } theme = { workspace = true, features = ["test-support"] } tree-sitter-html.workspace = true diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 5b11b18bc2..eb0fcaa1e5 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -105,6 +105,7 @@ pub struct MoveDownByLines { #[serde(default)] pub(super) lines: u32, } + #[derive(PartialEq, Clone, Deserialize, Default)] pub struct SelectUpByLines { #[serde(default)] @@ -166,6 +167,13 @@ pub struct SpawnNearestTask { pub reveal: task::RevealStrategy, } +#[derive(Debug, PartialEq, Eq, Clone, Copy, Deserialize, Default)] +pub enum UuidVersion { + #[default] + V4, + V7, +} + impl_actions!( editor, [ @@ -248,6 +256,7 @@ gpui::actions!( FindAllReferences, Fold, FoldAll, + FoldFunctionBodies, FoldRecursive, FoldSelectedRanges, ToggleFold, @@ -270,6 +279,8 @@ gpui::actions!( HalfPageUp, Hover, Indent, + InsertUuidV4, + InsertUuidV7, JoinLines, KillRingCut, KillRingYank, @@ -295,6 +306,7 @@ gpui::actions!( NewlineBelow, NextInlineCompletion, NextScreen, + OpenContextMenu, OpenExcerpts, OpenExcerptsSplit, OpenProposedChangesEditor, @@ -303,6 +315,7 @@ gpui::actions!( OpenPermalinkToLine, OpenUrl, Outdent, + AutoIndent, PageDown, PageUp, Paste, diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index b95c9312c5..a75c2ce9fa 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -684,8 +684,8 @@ impl DisplaySnapshot { .map(|row| row.map(MultiBufferRow)) } - pub fn max_buffer_row(&self) -> MultiBufferRow { - self.buffer_snapshot.max_buffer_row() + pub fn widest_line_number(&self) -> u32 { + self.buffer_snapshot.widest_line_number() } pub fn prev_line_boundary(&self, mut point: MultiBufferPoint) -> (Point, DisplayPoint) { @@ -726,11 +726,10 @@ impl DisplaySnapshot { // used by line_mode selections and tries to match vim behavior pub fn expand_to_line(&self, range: Range) -> Range { + let max_row = self.buffer_snapshot.max_row().0; let new_start = if range.start.row == 0 { MultiBufferPoint::new(0, 0) - } else if range.start.row == self.max_buffer_row().0 - || (range.end.column > 0 && range.end.row == self.max_buffer_row().0) - { + } else if range.start.row == max_row || (range.end.column > 0 && range.end.row == max_row) { MultiBufferPoint::new( range.start.row - 1, self.buffer_snapshot @@ -742,7 +741,7 @@ impl DisplaySnapshot { let new_end = if range.end.column == 0 { range.end - } else if range.end.row < self.max_buffer_row().0 { + } else if range.end.row < max_row { self.buffer_snapshot .clip_point(MultiBufferPoint::new(range.end.row + 1, 0), Bias::Left) } else { @@ -1127,7 +1126,7 @@ impl DisplaySnapshot { } pub fn starts_indent(&self, buffer_row: MultiBufferRow) -> bool { - let max_row = self.buffer_snapshot.max_buffer_row(); + let max_row = self.buffer_snapshot.max_row(); if buffer_row >= max_row { return false; } diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index 673b9383bc..4598a5c015 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -1019,7 +1019,7 @@ impl InlaySnapshot { let inlay_point = InlayPoint::new(row, 0); cursor.seek(&inlay_point, Bias::Left, &()); - let max_buffer_row = MultiBufferRow(self.buffer.max_point().row); + let max_buffer_row = self.buffer.max_row(); let mut buffer_point = cursor.start().1; let buffer_row = if row == 0 { MultiBufferRow(0) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 401462795e..b2abe8db80 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -83,7 +83,7 @@ use gpui::{ use highlight_matching_bracket::refresh_matching_bracket_highlights; use hover_popover::{hide_hover, HoverState}; pub(crate) use hunk_diff::HoveredHunk; -use hunk_diff::{diff_hunk_to_display, ExpandedHunks}; +use hunk_diff::{diff_hunk_to_display, DiffMap, DiffMapSnapshot}; use indent_guides::ActiveIndentGuidesState; use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy}; pub use inline_completion::Direction; @@ -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}; @@ -141,7 +141,7 @@ use snippet::Snippet; use std::{ any::TypeId, borrow::Cow, - cell::RefCell, + cell::{Cell, RefCell}, cmp::{self, Ordering, Reverse}, mem, num::NonZeroU32, @@ -327,6 +327,7 @@ pub fn init(cx: &mut AppContext) { .detach(); } }); + git::project_diff::init(cx); } pub struct SearchWithinRange; @@ -534,15 +535,6 @@ pub enum IsVimMode { No, } -pub trait ActiveLineTrailerProvider { - fn render_active_line_trailer( - &mut self, - style: &EditorStyle, - focus_handle: &FocusHandle, - cx: &mut WindowContext, - ) -> Option; -} - /// Zed's primary text input `View`, allowing users to edit a [`MultiBuffer`] /// /// See the [module level documentation](self) for more information. @@ -605,7 +597,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>, @@ -634,7 +625,7 @@ pub struct Editor { enable_inline_completions: bool, show_inline_completions_override: Option, inlay_hint_cache: InlayHintCache, - expanded_hunks: ExpandedHunks, + diff_map: DiffMap, next_inlay_id: usize, _subscriptions: Vec, pixel_position_of_newest_cursor: Option>, @@ -670,7 +661,6 @@ pub struct Editor { next_scroll_position: NextScrollCursorCenterTopBottom, addons: HashMap>, _scroll_cursor_center_top_bottom_task: Task<()>, - active_line_trailer_provider: Option>, } #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] @@ -702,6 +692,7 @@ pub struct EditorSnapshot { git_blame_gutter_max_author_length: Option, pub display_snapshot: DisplaySnapshot, pub placeholder_text: Option>, + diff_map: DiffMapSnapshot, is_focused: bool, scroll_anchor: ScrollAnchor, ongoing_scroll: OngoingScroll, @@ -1016,7 +1007,8 @@ struct CompletionsMenu { matches: Arc<[StringMatch]>, selected_item: usize, scroll_handle: UniformListScrollHandle, - selected_completion_documentation_resolve_debounce: Option>>, + selected_completion_resolve_debounce: Option>>, + aside_was_displayed: Cell, } impl CompletionsMenu { @@ -1048,9 +1040,8 @@ 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()))), + aside_was_displayed: Cell::new(false), } } @@ -1103,15 +1094,13 @@ 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()))), + aside_was_displayed: Cell::new(false), } } fn suppress_documentation_resolution(mut self) -> Self { - self.selected_completion_documentation_resolve_debounce - .take(); + self.selected_completion_resolve_debounce.take(); self } @@ -1123,7 +1112,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(); } @@ -1139,7 +1128,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(); } @@ -1155,7 +1144,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(); } @@ -1167,58 +1156,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; }; @@ -1233,10 +1184,10 @@ 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| { - cx.spawn(move |this, mut cx| async move { + completion_resolve.lock().fire_new(delay, cx, |_, cx| { + cx.spawn(move |editor, mut cx| async move { if let Some(true) = resolve_task.await.log_err() { - this.update(&mut cx, |_, cx| cx.notify()).ok(); + editor.update(&mut cx, |_, cx| cx.notify()).ok(); } }) }); @@ -1283,7 +1234,7 @@ impl CompletionsMenu { let multiline_docs = if show_completion_documentation { let mat = &self.matches[selected_item]; - let multiline_docs = match &self.completions.read()[mat.candidate_id].documentation { + match &self.completions.read()[mat.candidate_id].documentation { Some(Documentation::MultiLinePlainText(text)) => { Some(div().child(SharedString::from(text.clone()))) } @@ -1296,24 +1247,37 @@ impl CompletionsMenu { cx, ))) } + Some(Documentation::Undocumented) if self.aside_was_displayed.get() => { + Some(div().child("No documentation")) + } _ => None, - }; - multiline_docs.map(|div| { - div.id("multiline_docs") - .max_h(max_height) - .flex_1() - .px_1p5() - .py_1() - .min_w(px(260.)) - .max_w(px(640.)) - .w(px(500.)) - .overflow_y_scroll() - .occlude() - }) + } } else { None }; + let aside_contents = if let Some(multiline_docs) = multiline_docs { + Some(multiline_docs) + } else if self.aside_was_displayed.get() { + Some(div().child("Fetching documentation...")) + } else { + None + }; + self.aside_was_displayed.set(aside_contents.is_some()); + + let aside_contents = aside_contents.map(|div| { + div.id("multiline_docs") + .max_h(max_height) + .flex_1() + .px_1p5() + .py_1() + .min_w(px(260.)) + .max_w(px(640.)) + .w(px(500.)) + .overflow_y_scroll() + .occlude() + }); + let list = uniform_list( cx.view().clone(), "completions", @@ -1409,8 +1373,8 @@ impl CompletionsMenu { Popover::new() .child(list) - .when_some(multiline_docs, |popover, multiline_docs| { - popover.aside(multiline_docs) + .when_some(aside_contents, |popover, aside_contents| { + popover.aside(aside_contents) }) .into_any_element() } @@ -1744,7 +1708,9 @@ impl CodeActionsMenu { }), ) // TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here. - .child(SharedString::from(action.lsp_action.title.clone())) + .child(SharedString::from( + action.lsp_action.title.replace("\n", ""), + )) }) .when_some(action.as_task(), |this, task| { this.on_mouse_down( @@ -1761,7 +1727,7 @@ impl CodeActionsMenu { } }), ) - .child(SharedString::from(task.resolved_label.clone())) + .child(SharedString::from(task.resolved_label.replace("\n", ""))) }) }) .collect() @@ -2053,11 +2019,10 @@ impl Editor { } } - let inlay_hint_settings = inlay_hint_settings( - selections.newest_anchor().head(), - &buffer.read(cx).snapshot(cx), - cx, - ); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + + let inlay_hint_settings = + inlay_hint_settings(selections.newest_anchor().head(), &buffer_snapshot, cx); let focus_handle = cx.focus_handle(); cx.on_focus(&focus_handle, Self::handle_focus).detach(); cx.on_focus_in(&focus_handle, Self::handle_focus_in) @@ -2074,6 +2039,7 @@ impl Editor { let mut code_action_providers = Vec::new(); if let Some(project) = project.clone() { + get_unstaged_changes_for_buffers(&project, buffer.read(cx).all_buffers(), cx); code_action_providers.push(Arc::new(project) as Arc<_>); } @@ -2128,7 +2094,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(), @@ -2157,7 +2122,7 @@ impl Editor { inline_completion_provider: None, active_inline_completion: None, inlay_hint_cache: InlayHintCache::new(inlay_hint_settings), - expanded_hunks: ExpandedHunks::default(), + diff_map: DiffMap::default(), gutter_hovered: false, pixel_position_of_newest_cursor: None, last_bounds: None, @@ -2209,7 +2174,6 @@ impl Editor { addons: HashMap::default(), _scroll_cursor_center_top_bottom_task: Task::ready(()), text_style_refinement: None, - active_line_trailer_provider: None, }; this.tasks_update_task = Some(this.refresh_runnables(cx)); this._subscriptions.extend(project_subscriptions); @@ -2418,6 +2382,7 @@ impl Editor { scroll_anchor: self.scroll_manager.anchor(), ongoing_scroll: self.scroll_manager.ongoing_scroll(), placeholder_text: self.placeholder_text.clone(), + diff_map: self.diff_map.snapshot(), is_focused: self.focus_handle.is_focused(cx), current_line_highlight: self .current_line_highlight @@ -2498,16 +2463,6 @@ impl Editor { self.refresh_inline_completion(false, false, cx); } - pub fn set_active_line_trailer_provider( - &mut self, - provider: Option, - _cx: &mut ViewContext, - ) where - T: ActiveLineTrailerProvider + 'static, - { - self.active_line_trailer_provider = provider.map(|provider| Box::new(provider) as Box<_>); - } - pub fn placeholder_text(&self, _cx: &WindowContext) -> Option<&str> { self.placeholder_text.as_deref() } @@ -2997,7 +2952,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)); @@ -3033,6 +2988,7 @@ impl Editor { auto_scroll = false; } } + auto_scroll &= EditorSettings::get_global(cx).autoscroll_on_clicks; let point_to_delete: Option = { let selected_points: Vec> = @@ -4163,8 +4119,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(); @@ -4544,9 +4502,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(); @@ -4564,34 +4522,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 => {} @@ -4604,19 +4542,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); } } })?; @@ -6381,6 +6320,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); @@ -6563,12 +6521,12 @@ impl Editor { pub fn revert_file(&mut self, _: &RevertFile, cx: &mut ViewContext) { let mut revert_changes = HashMap::default(); - let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); - for hunk in hunks_for_rows( - Some(MultiBufferRow(0)..multi_buffer_snapshot.max_buffer_row()).into_iter(), - &multi_buffer_snapshot, + let snapshot = self.snapshot(cx); + for hunk in hunks_for_ranges( + Some(Point::zero()..snapshot.buffer_snapshot.max_point()).into_iter(), + &snapshot, ) { - Self::prepare_revert_change(&mut revert_changes, self.buffer(), &hunk, cx); + self.prepare_revert_change(&mut revert_changes, &hunk, cx); } if !revert_changes.is_empty() { self.transact(cx, |editor, cx| { @@ -6585,7 +6543,7 @@ impl Editor { } pub fn revert_selected_hunks(&mut self, _: &RevertSelectedHunks, cx: &mut ViewContext) { - let revert_changes = self.gather_revert_changes(&self.selections.disjoint_anchors(), cx); + let revert_changes = self.gather_revert_changes(&self.selections.all(cx), cx); if !revert_changes.is_empty() { self.transact(cx, |editor, cx| { editor.revert(revert_changes, cx); @@ -6593,6 +6551,18 @@ impl Editor { } } + fn revert_hunk(&mut self, hunk: HoveredHunk, cx: &mut ViewContext) { + let snapshot = self.buffer.read(cx).read(cx); + if let Some(hunk) = crate::hunk_diff::to_diff_hunk(&hunk, &snapshot) { + drop(snapshot); + let mut revert_changes = HashMap::default(); + self.prepare_revert_change(&mut revert_changes, &hunk, cx); + if !revert_changes.is_empty() { + self.revert(revert_changes, cx) + } + } + } + pub fn open_active_item_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext) { if let Some(working_directory) = self.active_excerpt(cx).and_then(|(_, buffer, _)| { let project_path = buffer.read(cx).project_path(cx)?; @@ -6612,26 +6582,33 @@ impl Editor { fn gather_revert_changes( &mut self, - selections: &[Selection], + selections: &[Selection], cx: &mut ViewContext<'_, Editor>, ) -> HashMap, Rope)>> { let mut revert_changes = HashMap::default(); - let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); - for hunk in hunks_for_selections(&multi_buffer_snapshot, selections) { - Self::prepare_revert_change(&mut revert_changes, self.buffer(), &hunk, cx); + let snapshot = self.snapshot(cx); + for hunk in hunks_for_selections(&snapshot, selections) { + self.prepare_revert_change(&mut revert_changes, &hunk, cx); } revert_changes } pub fn prepare_revert_change( + &mut self, revert_changes: &mut HashMap, Rope)>>, - multi_buffer: &Model, hunk: &MultiBufferDiffHunk, cx: &AppContext, ) -> Option<()> { - let buffer = multi_buffer.read(cx).buffer(hunk.buffer_id)?; + let buffer = self.buffer.read(cx).buffer(hunk.buffer_id)?; let buffer = buffer.read(cx); - let original_text = buffer.diff_base()?.slice(hunk.diff_base_byte_range.clone()); + let change_set = &self.diff_map.diff_bases.get(&hunk.buffer_id)?.change_set; + let original_text = change_set + .read(cx) + .base_text + .as_ref()? + .read(cx) + .as_rope() + .slice(hunk.diff_base_byte_range.clone()); let buffer_snapshot = buffer.snapshot(); let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default(); if let Err(i) = buffer_revert_changes.binary_search_by(|probe| { @@ -9812,80 +9789,63 @@ impl Editor { } fn go_to_next_hunk(&mut self, _: &GoToHunk, cx: &mut ViewContext) { - let snapshot = self - .display_map - .update(cx, |display_map, cx| display_map.snapshot(cx)); + let snapshot = self.snapshot(cx); let selection = self.selections.newest::(cx); self.go_to_hunk_after_position(&snapshot, selection.head(), cx); } fn go_to_hunk_after_position( &mut self, - snapshot: &DisplaySnapshot, + snapshot: &EditorSnapshot, position: Point, cx: &mut ViewContext<'_, Editor>, ) -> Option { - if let Some(hunk) = self.go_to_next_hunk_in_direction( - snapshot, - position, - false, - snapshot - .buffer_snapshot - .git_diff_hunks_in_range(MultiBufferRow(position.row + 1)..MultiBufferRow::MAX), - cx, - ) { - return Some(hunk); + for (ix, position) in [position, Point::zero()].into_iter().enumerate() { + if let Some(hunk) = self.go_to_next_hunk_in_direction( + snapshot, + position, + ix > 0, + snapshot.diff_map.diff_hunks_in_range( + position + Point::new(1, 0)..snapshot.buffer_snapshot.max_point(), + &snapshot.buffer_snapshot, + ), + cx, + ) { + return Some(hunk); + } } - - let wrapped_point = Point::zero(); - self.go_to_next_hunk_in_direction( - snapshot, - wrapped_point, - true, - snapshot.buffer_snapshot.git_diff_hunks_in_range( - MultiBufferRow(wrapped_point.row + 1)..MultiBufferRow::MAX, - ), - cx, - ) + None } fn go_to_prev_hunk(&mut self, _: &GoToPrevHunk, cx: &mut ViewContext) { - let snapshot = self - .display_map - .update(cx, |display_map, cx| display_map.snapshot(cx)); + let snapshot = self.snapshot(cx); let selection = self.selections.newest::(cx); - self.go_to_hunk_before_position(&snapshot, selection.head(), cx); } fn go_to_hunk_before_position( &mut self, - snapshot: &DisplaySnapshot, + snapshot: &EditorSnapshot, position: Point, cx: &mut ViewContext<'_, Editor>, ) -> Option { - if let Some(hunk) = self.go_to_next_hunk_in_direction( - snapshot, - position, - false, - snapshot - .buffer_snapshot - .git_diff_hunks_in_range_rev(MultiBufferRow(0)..MultiBufferRow(position.row)), - cx, - ) { - return Some(hunk); + for (ix, position) in [position, snapshot.buffer_snapshot.max_point()] + .into_iter() + .enumerate() + { + if let Some(hunk) = self.go_to_next_hunk_in_direction( + snapshot, + position, + ix > 0, + snapshot + .diff_map + .diff_hunks_in_range_rev(Point::zero()..position, &snapshot.buffer_snapshot), + cx, + ) { + return Some(hunk); + } } - - let wrapped_point = snapshot.buffer_snapshot.max_point(); - self.go_to_next_hunk_in_direction( - snapshot, - wrapped_point, - true, - snapshot - .buffer_snapshot - .git_diff_hunks_in_range_rev(MultiBufferRow(0)..MultiBufferRow(wrapped_point.row)), - cx, - ) + None } fn go_to_next_hunk_in_direction( @@ -11114,10 +11074,14 @@ impl Editor { } fn fold_at_level(&mut self, fold_at: &FoldAtLevel, cx: &mut ViewContext) { + if !self.buffer.read(cx).is_singleton() { + return; + } + let fold_at_level = fold_at.level; let snapshot = self.buffer.read(cx).snapshot(cx); let mut to_fold = Vec::new(); - let mut stack = vec![(0, snapshot.max_buffer_row().0, 1)]; + let mut stack = vec![(0, snapshot.max_row().0, 1)]; while let Some((mut start_row, end_row, current_level)) = stack.pop() { while start_row < end_row { @@ -11146,10 +11110,14 @@ impl Editor { } pub fn fold_all(&mut self, _: &actions::FoldAll, cx: &mut ViewContext) { + if !self.buffer.read(cx).is_singleton() { + return; + } + let mut fold_ranges = Vec::new(); let snapshot = self.buffer.read(cx).snapshot(cx); - for row in 0..snapshot.max_buffer_row().0 { + for row in 0..snapshot.max_row().0 { if let Some(foldable_range) = self.snapshot(cx).crease_for_buffer_row(MultiBufferRow(row)) { @@ -11160,6 +11128,23 @@ impl Editor { self.fold_creases(fold_ranges, true, cx); } + pub fn fold_function_bodies( + &mut self, + _: &actions::FoldFunctionBodies, + cx: &mut ViewContext, + ) { + let snapshot = self.buffer.read(cx).snapshot(cx); + let Some((_, _, buffer)) = snapshot.as_singleton() else { + return; + }; + let creases = buffer + .function_body_fold_ranges(0..buffer.len()) + .map(|range| Crease::simple(range, self.display_map.read(cx).fold_placeholder.clone())) + .collect(); + + self.fold_creases(creases, true, cx); + } + pub fn fold_recursive(&mut self, _: &actions::FoldRecursive, cx: &mut ViewContext) { let mut to_fold = Vec::new(); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); @@ -11305,13 +11290,13 @@ impl Editor { return; } - let mut buffers_affected = HashMap::default(); + let mut buffers_affected = HashSet::default(); let multi_buffer = self.buffer().read(cx); for crease in &creases { if let Some((_, buffer, _)) = multi_buffer.excerpt_containing(crease.range().start.clone(), cx) { - buffers_affected.insert(buffer.read(cx).remote_id(), buffer); + buffers_affected.insert(buffer.read(cx).remote_id()); }; } @@ -11321,8 +11306,8 @@ impl Editor { self.request_autoscroll(Autoscroll::fit(), cx); } - for buffer in buffers_affected.into_values() { - self.sync_expanded_diff_hunks(buffer, cx); + for buffer_id in buffers_affected { + Self::sync_expanded_diff_hunks(&mut self.diff_map, buffer_id, cx); } cx.notify(); @@ -11379,11 +11364,11 @@ impl Editor { return; } - let mut buffers_affected = HashMap::default(); + let mut buffers_affected = HashSet::default(); let multi_buffer = self.buffer().read(cx); for range in ranges { if let Some((_, buffer, _)) = multi_buffer.excerpt_containing(range.start.clone(), cx) { - buffers_affected.insert(buffer.read(cx).remote_id(), buffer); + buffers_affected.insert(buffer.read(cx).remote_id()); }; } @@ -11393,8 +11378,8 @@ impl Editor { self.request_autoscroll(Autoscroll::fit(), cx); } - for buffer in buffers_affected.into_values() { - self.sync_expanded_diff_hunks(buffer, cx); + for buffer_id in buffers_affected { + Self::sync_expanded_diff_hunks(&mut self.diff_map, buffer_id, cx); } cx.notify(); @@ -11880,6 +11865,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) } @@ -11891,29 +11880,6 @@ impl Editor { && self.has_blame_entries(cx) } - pub fn render_active_line_trailer( - &mut self, - style: &EditorStyle, - cx: &mut WindowContext, - ) -> Option { - let selection = self.selections.newest::(cx); - if !selection.is_empty() { - return None; - }; - - let snapshot = self.buffer.read(cx).snapshot(cx); - let buffer_row = MultiBufferRow(selection.head().row); - - if snapshot.line_len(buffer_row) != 0 || self.has_active_inline_completion(cx) { - return None; - } - - let focus_handle = self.focus_handle.clone(); - self.active_line_trailer_provider - .as_mut()? - .render_active_line_trailer(style, &focus_handle, cx) - } - fn has_blame_entries(&self, cx: &mut WindowContext) -> bool { self.blame() .map_or(false, |blame| blame.read(cx).has_generated_entries()) @@ -12054,6 +12020,33 @@ impl Editor { .detach(); } + pub fn insert_uuid_v4(&mut self, _: &InsertUuidV4, cx: &mut ViewContext) { + self.insert_uuid(UuidVersion::V4, cx); + } + + pub fn insert_uuid_v7(&mut self, _: &InsertUuidV7, cx: &mut ViewContext) { + self.insert_uuid(UuidVersion::V7, cx); + } + + fn insert_uuid(&mut self, version: UuidVersion, cx: &mut ViewContext) { + self.transact(cx, |this, cx| { + let edits = this + .selections + .all::(cx) + .into_iter() + .map(|selection| { + let uuid = match version { + UuidVersion::V4 => uuid::Uuid::new_v4(), + UuidVersion::V7 => uuid::Uuid::now_v7(), + }; + + (selection.range(), uuid.to_string()) + }); + this.edit(edits, cx); + this.refresh_inline_completion(true, false, cx); + }); + } + /// Adds a row highlight for the given range. If a row has multiple highlights, the /// last highlight added will be used. /// @@ -12675,6 +12668,12 @@ impl Editor { excerpts, } => { self.tasks_update_task = Some(self.refresh_runnables(cx)); + let buffer_id = buffer.read(cx).remote_id(); + if !self.diff_map.diff_bases.contains_key(&buffer_id) { + if let Some(project) = &self.project { + get_unstaged_changes_for_buffers(project, [buffer.clone()], cx); + } + } cx.emit(EditorEvent::ExcerptsAdded { buffer: buffer.clone(), predecessor: *predecessor, @@ -12707,15 +12706,11 @@ impl Editor { multi_buffer::Event::FileHandleChanged | multi_buffer::Event::Reloaded => { cx.emit(EditorEvent::TitleChanged) } - multi_buffer::Event::DiffBaseChanged => { - self.scrollbar_marker_state.dirty = true; - cx.emit(EditorEvent::DiffBaseChanged); - cx.notify(); - } - multi_buffer::Event::DiffUpdated { buffer } => { - self.sync_expanded_diff_hunks(buffer.clone(), cx); - cx.notify(); - } + // multi_buffer::Event::DiffBaseChanged => { + // self.scrollbar_marker_state.dirty = true; + // cx.emit(EditorEvent::DiffBaseChanged); + // cx.notify(); + // } multi_buffer::Event::Closed => cx.emit(EditorEvent::Closed), multi_buffer::Event::DiagnosticsUpdated => { self.refresh_active_diagnostics(cx); @@ -12883,7 +12878,7 @@ impl Editor { // When editing branch buffers, jump to the corresponding location // in their base buffer. let buffer = buffer_handle.read(cx); - if let Some(base_buffer) = buffer.diff_base_buffer() { + if let Some(base_buffer) = buffer.base_buffer() { range = buffer.range_to_version(range, &base_buffer.read(cx).version()); buffer_handle = base_buffer; } @@ -12917,8 +12912,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), @@ -13096,6 +13124,12 @@ impl Editor { cx.write_to_clipboard(ClipboardItem::new_string(lines)); } + pub fn open_context_menu(&mut self, _: &OpenContextMenu, cx: &mut ViewContext) { + self.request_autoscroll(Autoscroll::newest(), cx); + let position = self.selections.newest_display(cx).start; + mouse_context_menu::deploy_context_menu(self, None, position, cx); + } + pub fn inlay_hint_cache(&self) -> &InlayHintCache { &self.inlay_hint_cache } @@ -13317,6 +13351,48 @@ impl Editor { .get(&type_id) .and_then(|item| item.to_any().downcast_ref::()) } + + fn character_size(&self, cx: &mut ViewContext) -> gpui::Point { + let text_layout_details = self.text_layout_details(cx); + let style = &text_layout_details.editor_style; + let font_id = cx.text_system().resolve_font(&style.text.font()); + let font_size = style.text.font_size.to_pixels(cx.rem_size()); + let line_height = style.text.line_height_in_pixels(cx.rem_size()); + + let em_width = cx + .text_system() + .typographic_bounds(font_id, font_size, 'm') + .unwrap() + .size + .width; + + gpui::Point::new(em_width, line_height) + } +} + +fn get_unstaged_changes_for_buffers( + project: &Model, + buffers: impl IntoIterator>, + cx: &mut ViewContext, +) { + let mut tasks = Vec::new(); + project.update(cx, |project, cx| { + for buffer in buffers { + tasks.push(project.open_unstaged_changes(buffer.clone(), cx)) + } + }); + cx.spawn(|this, mut cx| async move { + let change_sets = futures::future::join_all(tasks).await; + this.update(&mut cx, |this, cx| { + for change_set in change_sets { + if let Some(change_set) = change_set.log_err() { + this.diff_map.add_change_set(change_set, cx); + } + } + }) + .ok(); + }) + .detach(); } fn char_len_with_expanded_tabs(offset: usize, text: &str, tab_size: NonZeroU32) -> usize { @@ -13603,35 +13679,29 @@ fn test_wrap_with_prefix() { } fn hunks_for_selections( - multi_buffer_snapshot: &MultiBufferSnapshot, - selections: &[Selection], + snapshot: &EditorSnapshot, + selections: &[Selection], ) -> Vec { - let buffer_rows_for_selections = selections.iter().map(|selection| { - let head = selection.head(); - let tail = selection.tail(); - let start = MultiBufferRow(tail.to_point(multi_buffer_snapshot).row); - let end = MultiBufferRow(head.to_point(multi_buffer_snapshot).row); - if start > end { - end..start - } else { - start..end - } - }); - - hunks_for_rows(buffer_rows_for_selections, multi_buffer_snapshot) + hunks_for_ranges( + selections.iter().map(|selection| selection.range()), + snapshot, + ) } -pub fn hunks_for_rows( - rows: impl Iterator>, - multi_buffer_snapshot: &MultiBufferSnapshot, +pub fn hunks_for_ranges( + ranges: impl Iterator>, + snapshot: &EditorSnapshot, ) -> Vec { let mut hunks = Vec::new(); let mut processed_buffer_rows: HashMap>> = HashMap::default(); - for selected_multi_buffer_rows in rows { + for query_range in ranges { let query_rows = - selected_multi_buffer_rows.start..selected_multi_buffer_rows.end.next_row(); - for hunk in multi_buffer_snapshot.git_diff_hunks_in_range(query_rows.clone()) { + MultiBufferRow(query_range.start.row)..MultiBufferRow(query_range.end.row + 1); + for hunk in snapshot.diff_map.diff_hunks_in_range( + Point::new(query_rows.start.0, 0)..Point::new(query_rows.end.0, 0), + &snapshot.buffer_snapshot, + ) { // Deleted hunk is an empty row range, no caret can be placed there and Zed allows to revert it // when the caret is just above or just below the deleted hunk. let allow_adjacent = hunk_status(&hunk) == DiffHunkStatus::Removed; @@ -13640,10 +13710,7 @@ pub fn hunks_for_rows( || hunk.row_range.start == query_rows.end || hunk.row_range.end == query_rows.start } else { - // `selected_multi_buffer_rows` are inclusive (e.g. [2..2] means 2nd row is selected) - // `hunk.row_range` is exclusive (e.g. [2..3] means 2nd row is selected) - hunk.row_range.overlaps(&selected_multi_buffer_rows) - || selected_multi_buffer_rows.end == hunk.row_range.start + hunk.row_range.overlaps(&query_rows) }; if related_to_selection { if !processed_buffer_rows @@ -13835,80 +13902,135 @@ fn snippet_completions( buffer: &Model, buffer_position: text::Anchor, cx: &mut AppContext, -) -> Vec { +) -> Task>> { let language = buffer.read(cx).language_at(buffer_position); let language_name = language.as_ref().map(|language| language.lsp_id()); let snippet_store = project.snippets().read(cx); let snippets = snippet_store.snippets_for(language_name, cx); if snippets.is_empty() { - return vec![]; + return Task::ready(Ok(vec![])); } let snapshot = buffer.read(cx).text_snapshot(); - let chars = snapshot.reversed_chars_for_range(text::Anchor::MIN..buffer_position); + let chars: String = snapshot + .reversed_chars_for_range(text::Anchor::MIN..buffer_position) + .collect(); let scope = language.map(|language| language.default_scope()); - let classifier = CharClassifier::new(scope).for_completion(true); - let mut last_word = chars - .take_while(|c| classifier.is_word(*c)) - .collect::(); - last_word = last_word.chars().rev().collect(); - let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot); - let to_lsp = |point: &text::Anchor| { - let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); - point_to_lsp(end) - }; - let lsp_end = to_lsp(&buffer_position); - snippets - .into_iter() - .filter_map(|snippet| { - let matching_prefix = snippet - .prefix - .iter() - .find(|prefix| prefix.starts_with(&last_word))?; - let start = as_offset - last_word.len(); - let start = snapshot.anchor_before(start); - let range = start..buffer_position; - let lsp_start = to_lsp(&start); - let lsp_range = lsp::Range { - start: lsp_start, - end: lsp_end, - }; - Some(Completion { - old_range: range, - new_text: snippet.body.clone(), - label: CodeLabel { - text: matching_prefix.clone(), - runs: vec![], - filter_range: 0..matching_prefix.len(), - }, - server_id: LanguageServerId(usize::MAX), - documentation: snippet.description.clone().map(Documentation::SingleLine), - lsp_completion: lsp::CompletionItem { - label: snippet.prefix.first().unwrap().clone(), - kind: Some(CompletionItemKind::SNIPPET), - label_details: snippet.description.as_ref().map(|description| { - lsp::CompletionItemLabelDetails { - detail: Some(description.clone()), - description: None, - } - }), - insert_text_format: Some(InsertTextFormat::SNIPPET), - text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( - lsp::InsertReplaceEdit { - new_text: snippet.body.clone(), - insert: lsp_range, - replace: lsp_range, - }, - )), - filter_text: Some(snippet.body.clone()), - sort_text: Some(char::MAX.to_string()), - ..Default::default() - }, - confirm: None, + let executor = cx.background_executor().clone(); + + cx.background_executor().spawn(async move { + let classifier = CharClassifier::new(scope).for_completion(true); + let mut last_word = chars + .chars() + .take_while(|c| classifier.is_word(*c)) + .collect::(); + last_word = last_word.chars().rev().collect(); + + if last_word.is_empty() { + return Ok(vec![]); + } + + let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot); + let to_lsp = |point: &text::Anchor| { + let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); + point_to_lsp(end) + }; + let lsp_end = to_lsp(&buffer_position); + + let candidates = snippets + .iter() + .enumerate() + .flat_map(|(ix, snippet)| { + snippet + .prefix + .iter() + .map(move |prefix| StringMatchCandidate::new(ix, prefix.clone())) }) - }) - .collect() + .collect::>(); + + let mut matches = fuzzy::match_strings( + &candidates, + &last_word, + last_word.chars().any(|c| c.is_uppercase()), + 100, + &Default::default(), + executor, + ) + .await; + + // Remove all candidates where the query's start does not match the start of any word in the candidate + if let Some(query_start) = last_word.chars().next() { + matches.retain(|string_match| { + split_words(&string_match.string).any(|word| { + // Check that the first codepoint of the word as lowercase matches the first + // codepoint of the query as lowercase + word.chars() + .flat_map(|codepoint| codepoint.to_lowercase()) + .zip(query_start.to_lowercase()) + .all(|(word_cp, query_cp)| word_cp == query_cp) + }) + }); + } + + let matched_strings = matches + .into_iter() + .map(|m| m.string) + .collect::>(); + + let result: Vec = snippets + .into_iter() + .filter_map(|snippet| { + let matching_prefix = snippet + .prefix + .iter() + .find(|prefix| matched_strings.contains(*prefix))?; + let start = as_offset - last_word.len(); + let start = snapshot.anchor_before(start); + let range = start..buffer_position; + let lsp_start = to_lsp(&start); + let lsp_range = lsp::Range { + start: lsp_start, + end: lsp_end, + }; + Some(Completion { + old_range: range, + new_text: snippet.body.clone(), + label: CodeLabel { + text: matching_prefix.clone(), + runs: vec![], + filter_range: 0..matching_prefix.len(), + }, + server_id: LanguageServerId(usize::MAX), + documentation: snippet.description.clone().map(Documentation::SingleLine), + lsp_completion: lsp::CompletionItem { + label: snippet.prefix.first().unwrap().clone(), + kind: Some(CompletionItemKind::SNIPPET), + label_details: snippet.description.as_ref().map(|description| { + lsp::CompletionItemLabelDetails { + detail: Some(description.clone()), + description: None, + } + }), + insert_text_format: Some(InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: snippet.body.clone(), + insert: lsp_range, + replace: lsp_range, + }, + )), + filter_text: Some(snippet.body.clone()), + sort_text: Some(char::MAX.to_string()), + ..Default::default() + }, + confirm: None, + }) + }) + .collect(); + + Ok(result) + }) } impl CompletionProvider for Model { @@ -13924,8 +14046,8 @@ impl CompletionProvider for Model { let project_completions = project.completions(buffer, buffer_position, options, cx); cx.background_executor().spawn(async move { let mut completions = project_completions.await?; - //let snippets = snippets.into_iter().; - completions.extend(snippets); + let snippets_completions = snippets.await?; + completions.extend(snippets_completions); Ok(completions) }) }) @@ -14696,17 +14818,10 @@ impl ViewInputHandler for Editor { cx: &mut ViewContext, ) -> Option> { let text_layout_details = self.text_layout_details(cx); - let style = &text_layout_details.editor_style; - let font_id = cx.text_system().resolve_font(&style.text.font()); - let font_size = style.text.font_size.to_pixels(cx.rem_size()); - let line_height = style.text.line_height_in_pixels(cx.rem_size()); - - let em_width = cx - .text_system() - .typographic_bounds(font_id, font_size, 'm') - .unwrap() - .size - .width; + let gpui::Point { + x: em_width, + y: line_height, + } = self.character_size(cx); let snapshot = self.snapshot(cx); let scroll_position = snapshot.scroll_position(); @@ -14714,7 +14829,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 ff743db9b6..e669c21554 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 01507c4e31..7561c31f13 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -25,15 +25,16 @@ use language::{ use language_settings::{Formatter, FormatterList, IndentGuideSettings}; use multi_buffer::MultiBufferIndentGuide; use parking_lot::Mutex; -use project::FakeFs; +use project::{buffer_store::BufferChangeSet, FakeFs}; use project::{ lsp_command::SIGNATURE_HELP_HIGHLIGHT_CURRENT, 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, @@ -3312,7 +3313,7 @@ async fn test_join_lines_with_git_diff_base( .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); // Join lines @@ -3352,16 +3353,15 @@ async fn test_custom_newlines_cause_no_false_positive_diffs( init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("Line 0\r\nLine 1\rˇ\nLine 2\r\nLine 3"); - cx.set_diff_base(Some("Line 0\r\nLine 1\r\nLine 2\r\nLine 3")); + cx.set_diff_base("Line 0\r\nLine 1\r\nLine 2\r\nLine 3"); executor.run_until_parked(); cx.update_editor(|editor, cx| { + let snapshot = editor.snapshot(cx); assert_eq!( - editor - .buffer() - .read(cx) - .snapshot(cx) - .git_diff_hunks_in_range(MultiBufferRow::MIN..MultiBufferRow::MAX) + snapshot + .diff_map + .diff_hunks_in_range(0..snapshot.buffer_snapshot.len(), &snapshot.buffer_snapshot) .collect::>(), Vec::new(), "Should not have any diffs for files with custom newlines" @@ -5458,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( @@ -5520,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, |_| {}); @@ -10004,7 +10087,7 @@ async fn go_to_hunk(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, cx| { @@ -10541,6 +10624,312 @@ async fn test_completions_with_additional_edits(cx: &mut gpui::TestAppContext) { cx.assert_editor_state(indoc! {"fn main() { let a = Some(2)ˇ; }"}); } +#[gpui::test] +async fn test_completions_resolve_updates_labels(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {"fn main() { let a = 2ˇ; }"}); + cx.simulate_keystroke("."); + + let completion_item = lsp::CompletionItem { + label: "unresolved".to_string(), + detail: None, + documentation: None, + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), + new_text: ".unresolved".to_string(), + })), + ..lsp::CompletionItem::default() + }; + + cx.handle_request::(move |_, _, _| { + let item = completion_item.clone(); + async move { Ok(Some(lsp::CompletionResponse::Array(vec![item]))) } + }) + .next() + .await; + + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, _| { + let context_menu = editor.context_menu.read(); + let context_menu = context_menu + .as_ref() + .expect("Should have the context menu deployed"); + match context_menu { + ContextMenu::Completions(completions_menu) => { + let completions = completions_menu.completions.read(); + assert_eq!(completions.len(), 1, "Should have one completion"); + assert_eq!(completions.get(0).unwrap().label.text, "unresolved"); + } + ContextMenu::CodeActions(_) => panic!("Should show the completions menu"), + } + }); + + cx.handle_request::(move |_, _, _| async move { + Ok(lsp::CompletionItem { + label: "resolved".to_string(), + detail: Some("Now resolved!".to_string()), + documentation: Some(lsp::Documentation::String("Docs".to_string())), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), + new_text: ".resolved".to_string(), + })), + ..lsp::CompletionItem::default() + }) + }) + .next() + .await; + cx.run_until_parked(); + + cx.update_editor(|editor, _| { + let context_menu = editor.context_menu.read(); + let context_menu = context_menu + .as_ref() + .expect("Should have the context menu deployed"); + match context_menu { + ContextMenu::Completions(completions_menu) => { + let completions = completions_menu.completions.read(); + assert_eq!(completions.len(), 1, "Should have one completion"); + assert_eq!( + completions.get(0).unwrap().label.text, + "resolved", + "Should update the completion label after resolving" + ); + } + ContextMenu::CodeActions(_) => panic!("Should show the completions menu"), + } + }); +} + +#[gpui::test] +async fn test_completions_default_resolve_data_handling(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {"fn main() { let a = 2ˇ; }"}); + cx.simulate_keystroke("."); + + let default_commit_characters = vec!["?".to_string()]; + let default_data = json!({ "very": "special"}); + let default_insert_text_format = lsp::InsertTextFormat::SNIPPET; + let default_insert_text_mode = lsp::InsertTextMode::AS_IS; + let default_edit_range = lsp::Range { + start: lsp::Position { + line: 0, + character: 5, + }, + end: lsp::Position { + line: 0, + character: 5, + }, + }; + + 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 |_, _, _| { + let default_data = completion_data.clone(); + let default_commit_characters = completion_characters.clone(); + async move { + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + items: vec![ + lsp::CompletionItem { + label: "Some(2)".into(), + insert_text: Some("Some(2)".into()), + data: Some(json!({ "very": "special"})), + insert_text_mode: Some(lsp::InsertTextMode::ADJUST_INDENTATION), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: "Some(2)".to_string(), + insert: lsp::Range::default(), + replace: lsp::Range::default(), + }, + )), + ..lsp::CompletionItem::default() + }, + lsp::CompletionItem { + label: "vec![2]".into(), + insert_text: Some("vec![2]".into()), + insert_text_format: Some(lsp::InsertTextFormat::PLAIN_TEXT), + ..lsp::CompletionItem::default() + }, + ], + item_defaults: Some(lsp::CompletionListItemDefaults { + data: Some(default_data.clone()), + commit_characters: Some(default_commit_characters.clone()), + edit_range: Some(lsp::CompletionListItemDefaultsEditRange::Range( + default_edit_range, + )), + insert_text_format: Some(default_insert_text_format), + insert_text_mode: Some(default_insert_text_mode), + }), + ..lsp::CompletionList::default() + }))) + } + }) + .next() + .await; + + 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") { + ContextMenu::Completions(completions_menu) => { + assert_eq!( + completions_menu + .matches + .iter() + .map(|c| c.string.as_str()) + .collect::>(), + vec!["Some(2)", "vec![2]"] + ); + } + 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); + }); + 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.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] async fn test_completions_in_languages_with_extra_word_characters(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); @@ -10735,17 +11124,18 @@ async fn test_document_format_with_prettier(cx: &mut gpui::TestAppContext) { async fn test_addition_reverts(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; - let base_text = indoc! {r#"struct Row; -struct Row1; -struct Row2; + let base_text = indoc! {r#" + struct Row; + struct Row1; + struct Row2; -struct Row4; -struct Row5; -struct Row6; + struct Row4; + struct Row5; + struct Row6; -struct Row8; -struct Row9; -struct Row10;"#}; + struct Row8; + struct Row9; + struct Row10;"#}; // When addition hunks are not adjacent to carets, no hunk revert is performed assert_hunk_revert( @@ -10876,17 +11266,18 @@ struct Row10;"#}; async fn test_modification_reverts(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; - let base_text = indoc! {r#"struct Row; -struct Row1; -struct Row2; + let base_text = indoc! {r#" + struct Row; + struct Row1; + struct Row2; -struct Row4; -struct Row5; -struct Row6; + struct Row4; + struct Row5; + struct Row6; -struct Row8; -struct Row9; -struct Row10;"#}; + struct Row8; + struct Row9; + struct Row10;"#}; // Modification hunks behave the same as the addition ones. assert_hunk_revert( @@ -11104,54 +11495,18 @@ struct Row10;"#}; async fn test_multibuffer_reverts(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); - let cols = 4; - let rows = 10; - let sample_text_1 = sample_text(rows, cols, 'a'); - assert_eq!( - sample_text_1, - "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj" - ); - let sample_text_2 = sample_text(rows, cols, 'l'); - assert_eq!( - sample_text_2, - "llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu" - ); - let sample_text_3 = sample_text(rows, cols, 'v'); - assert_eq!( - sample_text_3, - "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}" - ); + let base_text_1 = "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj"; + let base_text_2 = "llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu"; + let base_text_3 = + "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}"; - fn diff_every_buffer_row( - buffer: &Model, - sample_text: String, - cols: usize, - cx: &mut gpui::TestAppContext, - ) { - // revert first character in each row, creating one large diff hunk per buffer - let is_first_char = |offset: usize| offset % cols == 0; - buffer.update(cx, |buffer, cx| { - buffer.set_text( - sample_text - .chars() - .enumerate() - .map(|(offset, c)| if is_first_char(offset) { 'X' } else { c }) - .collect::(), - cx, - ); - buffer.set_diff_base(Some(sample_text), cx); - }); - cx.executor().run_until_parked(); - } + let text_1 = edit_first_char_of_every_line(base_text_1); + let text_2 = edit_first_char_of_every_line(base_text_2); + let text_3 = edit_first_char_of_every_line(base_text_3); - let buffer_1 = cx.new_model(|cx| Buffer::local(sample_text_1.clone(), cx)); - diff_every_buffer_row(&buffer_1, sample_text_1.clone(), cols, cx); - - let buffer_2 = cx.new_model(|cx| Buffer::local(sample_text_2.clone(), cx)); - diff_every_buffer_row(&buffer_2, sample_text_2.clone(), cols, cx); - - let buffer_3 = cx.new_model(|cx| Buffer::local(sample_text_3.clone(), cx)); - diff_every_buffer_row(&buffer_3, sample_text_3.clone(), cols, cx); + let buffer_1 = cx.new_model(|cx| Buffer::local(text_1.clone(), cx)); + let buffer_2 = cx.new_model(|cx| Buffer::local(text_2.clone(), cx)); + let buffer_3 = cx.new_model(|cx| Buffer::local(text_3.clone(), cx)); let multibuffer = cx.new_model(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); @@ -11214,57 +11569,85 @@ async fn test_multibuffer_reverts(cx: &mut gpui::TestAppContext) { let (editor, cx) = cx.add_window_view(|cx| build_editor(multibuffer, cx)); editor.update(cx, |editor, cx| { - assert_eq!(editor.text(cx), "XaaaXbbbX\nccXc\ndXdd\n\nhXhh\nXiiiXjjjX\n\nXlllXmmmX\nnnXn\noXoo\n\nsXss\nXtttXuuuX\n\nXvvvXwwwX\nxxXx\nyXyy\n\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X\n"); + for (buffer, diff_base) in [ + (buffer_1.clone(), base_text_1), + (buffer_2.clone(), base_text_2), + (buffer_3.clone(), base_text_3), + ] { + let change_set = cx.new_model(|cx| { + BufferChangeSet::new_with_base_text( + diff_base.to_string(), + buffer.read(cx).text_snapshot(), + cx, + ) + }); + editor.diff_map.add_change_set(change_set, cx) + } + }); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "Xaaa\nXbbb\nXccc\n\nXfff\nXggg\n\nXjjj\nXlll\nXmmm\nXnnn\n\nXqqq\nXrrr\n\nXuuu\nXvvv\nXwww\nXxxx\n\nX{{{\nX|||\n\nX\u{7f}\u{7f}\u{7f}"); editor.select_all(&SelectAll, cx); editor.revert_selected_hunks(&RevertSelectedHunks, cx); }); cx.executor().run_until_parked(); + // When all ranges are selected, all buffer hunks are reverted. editor.update(cx, |editor, cx| { assert_eq!(editor.text(cx), "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\nllll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu\n\n\nvvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}\n\n"); }); buffer_1.update(cx, |buffer, _| { - assert_eq!(buffer.text(), sample_text_1); + assert_eq!(buffer.text(), base_text_1); }); buffer_2.update(cx, |buffer, _| { - assert_eq!(buffer.text(), sample_text_2); + assert_eq!(buffer.text(), base_text_2); }); buffer_3.update(cx, |buffer, _| { - assert_eq!(buffer.text(), sample_text_3); + assert_eq!(buffer.text(), base_text_3); + }); + + editor.update(cx, |editor, cx| { + editor.undo(&Default::default(), cx); }); - diff_every_buffer_row(&buffer_1, sample_text_1.clone(), cols, cx); - diff_every_buffer_row(&buffer_2, sample_text_2.clone(), cols, cx); - diff_every_buffer_row(&buffer_3, sample_text_3.clone(), cols, cx); editor.update(cx, |editor, cx| { editor.change_selections(None, cx, |s| { s.select_ranges(Some(Point::new(0, 0)..Point::new(6, 0))); }); editor.revert_selected_hunks(&RevertSelectedHunks, cx); }); + // Now, when all ranges selected belong to buffer_1, the revert should succeed, // but not affect buffer_2 and its related excerpts. editor.update(cx, |editor, cx| { assert_eq!( editor.text(cx), - "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\nXlllXmmmX\nnnXn\noXoo\nXpppXqqqX\nrrXr\nsXss\nXtttXuuuX\n\n\nXvvvXwwwX\nxxXx\nyXyy\nXzzzX{{{X\n||X|\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X\n\n" + "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\nXlll\nXmmm\nXnnn\n\nXqqq\nXrrr\n\nXuuu\nXvvv\nXwww\nXxxx\n\nX{{{\nX|||\n\nX\u{7f}\u{7f}\u{7f}" ); }); buffer_1.update(cx, |buffer, _| { - assert_eq!(buffer.text(), sample_text_1); + assert_eq!(buffer.text(), base_text_1); }); buffer_2.update(cx, |buffer, _| { assert_eq!( buffer.text(), - "XlllXmmmX\nnnXn\noXoo\nXpppXqqqX\nrrXr\nsXss\nXtttXuuuX" + "Xlll\nXmmm\nXnnn\nXooo\nXppp\nXqqq\nXrrr\nXsss\nXttt\nXuuu" ); }); buffer_3.update(cx, |buffer, _| { assert_eq!( buffer.text(), - "XvvvXwwwX\nxxXx\nyXyy\nXzzzX{{{X\n||X|\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X" + "Xvvv\nXwww\nXxxx\nXyyy\nXzzz\nX{{{\nX|||\nX}}}\nX~~~\nX\u{7f}\u{7f}\u{7f}" ); }); + + fn edit_first_char_of_every_line(text: &str) -> String { + text.split('\n') + .map(|line| format!("X{}", &line[1..])) + .collect::>() + .join("\n") + } } #[gpui::test] @@ -11506,7 +11889,7 @@ async fn test_mutlibuffer_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); }); @@ -11591,7 +11974,7 @@ async fn test_toggle_hunk_diff(executor: BackgroundExecutor, cx: &mut gpui::Test .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, cx| { @@ -11599,14 +11982,14 @@ async fn test_toggle_hunk_diff(executor: BackgroundExecutor, cx: &mut gpui::Test editor.toggle_hunk_diff(&ToggleHunkDiff, cx); }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::modified; fn main() { - println!("hello"); - + println!("hello there"); + + ˇ println!("hello there"); println!("around the"); println!("world"); @@ -11622,28 +12005,13 @@ async fn test_toggle_hunk_diff(executor: BackgroundExecutor, cx: &mut gpui::Test } }); executor.run_until_parked(); - cx.assert_editor_state( - &r#" - use some::modified; - - ˇ - fn main() { - println!("hello there"); - - println!("around the"); - println!("world"); - } - "# - .unindent(), - ); - - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" - use some::mod; + use some::modified; - const A: u32 = 42; - + ˇ fn main() { - println!("hello"); + println!("hello there"); @@ -11659,11 +12027,11 @@ async fn test_toggle_hunk_diff(executor: BackgroundExecutor, cx: &mut gpui::Test editor.cancel(&Cancel, cx); }); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::modified; - + ˇ fn main() { println!("hello there"); @@ -11718,14 +12086,14 @@ async fn test_diff_base_change_with_expanded_diff_hunks( .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, cx| { editor.expand_all_hunk_diffs(&ExpandAllHunkDiffs, cx); }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" - use some::mod1; use some::mod2; @@ -11734,7 +12102,7 @@ async fn test_diff_base_change_with_expanded_diff_hunks( - const B: u32 = 42; const C: u32 = 42; - fn main() { + fn main(ˇ) { - println!("hello"); + //println!("hello"); @@ -11746,16 +12114,16 @@ async fn test_diff_base_change_with_expanded_diff_hunks( .unindent(), ); - cx.set_diff_base(Some("new diff base!")); + cx.set_diff_base("new diff base!"); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod2; const A: u32 = 42; const C: u32 = 42; - fn main() { + fn main(ˇ) { //println!("hello"); println!("world"); @@ -11770,7 +12138,7 @@ async fn test_diff_base_change_with_expanded_diff_hunks( editor.expand_all_hunk_diffs(&ExpandAllHunkDiffs, cx); }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" - new diff base! + use some::mod2; @@ -11778,7 +12146,7 @@ async fn test_diff_base_change_with_expanded_diff_hunks( + const A: u32 = 42; + const C: u32 = 42; + - + fn main() { + + fn main(ˇ) { + //println!("hello"); + + println!("world"); @@ -11846,7 +12214,7 @@ async fn test_fold_unfold_diff_hunk(executor: BackgroundExecutor, cx: &mut gpui: .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, cx| { @@ -11854,10 +12222,10 @@ async fn test_fold_unfold_diff_hunk(executor: BackgroundExecutor, cx: &mut gpui: }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" - use some::mod1; - use some::mod2; + «use some::mod2; const A: u32 = 42; - const B: u32 = 42; @@ -11869,7 +12237,7 @@ async fn test_fold_unfold_diff_hunk(executor: BackgroundExecutor, cx: &mut gpui: println!("world"); + // - + // + + //ˇ» } fn another() { @@ -11889,9 +12257,9 @@ async fn test_fold_unfold_diff_hunk(executor: BackgroundExecutor, cx: &mut gpui: cx.executor().run_until_parked(); // Hunks are not shown if their position is within a fold - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" - use some::mod2; + «use some::mod2; const A: u32 = 42; const C: u32 = 42; @@ -11901,7 +12269,7 @@ async fn test_fold_unfold_diff_hunk(executor: BackgroundExecutor, cx: &mut gpui: println!("world"); // - // + //ˇ» } fn another() { @@ -11923,10 +12291,10 @@ async fn test_fold_unfold_diff_hunk(executor: BackgroundExecutor, cx: &mut gpui: cx.executor().run_until_parked(); // The deletions reappear when unfolding. - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" - use some::mod1; - use some::mod2; + «use some::mod2; const A: u32 = 42; - const B: u32 = 42; @@ -11949,7 +12317,7 @@ async fn test_fold_unfold_diff_hunk(executor: BackgroundExecutor, cx: &mut gpui: - fn another2() { println!("another2"); } - "# + ˇ»"# .unindent(), ); } @@ -11965,21 +12333,9 @@ async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut gpui::TestAppContext) let file_3_old = "111\n222\n333\n444\n555\n777\n888\n999\n000\n!!!"; let file_3_new = "111\n222\n333\n444\n555\n666\n777\n888\n999\n000\n!!!"; - let buffer_1 = cx.new_model(|cx| { - let mut buffer = Buffer::local(file_1_new.to_string(), cx); - buffer.set_diff_base(Some(file_1_old.into()), cx); - buffer - }); - let buffer_2 = cx.new_model(|cx| { - let mut buffer = Buffer::local(file_2_new.to_string(), cx); - buffer.set_diff_base(Some(file_2_old.into()), cx); - buffer - }); - let buffer_3 = cx.new_model(|cx| { - let mut buffer = Buffer::local(file_3_new.to_string(), cx); - buffer.set_diff_base(Some(file_3_old.into()), cx); - buffer - }); + let buffer_1 = cx.new_model(|cx| Buffer::local(file_1_new.to_string(), cx)); + let buffer_2 = cx.new_model(|cx| Buffer::local(file_2_new.to_string(), cx)); + let buffer_3 = cx.new_model(|cx| Buffer::local(file_3_new.to_string(), cx)); let multi_buffer = cx.new_model(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); @@ -12041,6 +12397,25 @@ async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut gpui::TestAppContext) }); let editor = cx.add_window(|cx| Editor::new(EditorMode::Full, multi_buffer, None, true, cx)); + editor + .update(cx, |editor, cx| { + for (buffer, diff_base) in [ + (buffer_1.clone(), file_1_old), + (buffer_2.clone(), file_2_old), + (buffer_3.clone(), file_3_old), + ] { + let change_set = cx.new_model(|cx| { + BufferChangeSet::new_with_base_text( + diff_base.to_string(), + buffer.read(cx).text_snapshot(), + cx, + ) + }); + editor.diff_map.add_change_set(change_set, cx) + } + }) + .unwrap(); + let mut cx = EditorTestContext::for_editor(editor, cx).await; cx.run_until_parked(); @@ -12080,9 +12455,9 @@ async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut gpui::TestAppContext) }); cx.executor().run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( " - aaa + «aaa - bbb ccc ddd @@ -12108,8 +12483,8 @@ async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut gpui::TestAppContext) 777 000 - !!!" - .unindent(), + !!!ˇ»" + .unindent(), ); } @@ -12120,12 +12495,7 @@ async fn test_expand_diff_hunk_at_excerpt_boundary(cx: &mut gpui::TestAppContext let base = "aaa\nbbb\nccc\nddd\neee\nfff\nggg\n"; let text = "aaa\nBBB\nBB2\nccc\nDDD\nEEE\nfff\nggg\n"; - let buffer = cx.new_model(|cx| { - let mut buffer = Buffer::local(text.to_string(), cx); - buffer.set_diff_base(Some(base.into()), cx); - buffer - }); - + let buffer = cx.new_model(|cx| Buffer::local(text.to_string(), cx)); let multi_buffer = cx.new_model(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.push_excerpts( @@ -12146,15 +12516,24 @@ async fn test_expand_diff_hunk_at_excerpt_boundary(cx: &mut gpui::TestAppContext }); let editor = cx.add_window(|cx| Editor::new(EditorMode::Full, multi_buffer, None, true, cx)); + editor + .update(cx, |editor, cx| { + let buffer = buffer.read(cx).text_snapshot(); + let change_set = cx + .new_model(|cx| BufferChangeSet::new_with_base_text(base.to_string(), buffer, cx)); + editor.diff_map.add_change_set(change_set, cx) + }) + .unwrap(); + let mut cx = EditorTestContext::for_editor(editor, cx).await; cx.run_until_parked(); cx.update_editor(|editor, cx| editor.expand_all_hunk_diffs(&Default::default(), cx)); cx.executor().run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( " - aaa + ˇaaa - bbb + BBB @@ -12209,7 +12588,7 @@ async fn test_edits_around_expanded_insertion_hunks( .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, cx| { @@ -12217,7 +12596,7 @@ async fn test_edits_around_expanded_insertion_hunks( }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -12225,7 +12604,7 @@ async fn test_edits_around_expanded_insertion_hunks( const A: u32 = 42; + const B: u32 = 42; + const C: u32 = 42; - + + + ˇ fn main() { println!("hello"); @@ -12239,7 +12618,7 @@ async fn test_edits_around_expanded_insertion_hunks( cx.update_editor(|editor, cx| editor.handle_input("const D: u32 = 42;\n", cx)); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -12248,7 +12627,7 @@ async fn test_edits_around_expanded_insertion_hunks( + const B: u32 = 42; + const C: u32 = 42; + const D: u32 = 42; - + + + ˇ fn main() { println!("hello"); @@ -12262,7 +12641,7 @@ async fn test_edits_around_expanded_insertion_hunks( cx.update_editor(|editor, cx| editor.handle_input("const E: u32 = 42;\n", cx)); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -12272,7 +12651,7 @@ async fn test_edits_around_expanded_insertion_hunks( + const C: u32 = 42; + const D: u32 = 42; + const E: u32 = 42; - + + + ˇ fn main() { println!("hello"); @@ -12288,7 +12667,7 @@ async fn test_edits_around_expanded_insertion_hunks( }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -12298,32 +12677,6 @@ async fn test_edits_around_expanded_insertion_hunks( + const C: u32 = 42; + const D: u32 = 42; + const E: u32 = 42; - - fn main() { - println!("hello"); - - println!("world"); - } - "# - .unindent(), - ); - - cx.update_editor(|editor, cx| { - editor.move_up(&MoveUp, cx); - editor.delete_line(&DeleteLine, cx); - editor.move_up(&MoveUp, cx); - editor.delete_line(&DeleteLine, cx); - editor.move_up(&MoveUp, cx); - editor.delete_line(&DeleteLine, cx); - }); - executor.run_until_parked(); - cx.assert_editor_state( - &r#" - use some::mod1; - use some::mod2; - - const A: u32 = 42; - const B: u32 = 42; ˇ fn main() { println!("hello"); @@ -12334,14 +12687,23 @@ async fn test_edits_around_expanded_insertion_hunks( .unindent(), ); - cx.assert_diff_hunks( + cx.update_editor(|editor, cx| { + editor.move_up(&MoveUp, cx); + editor.delete_line(&DeleteLine, cx); + editor.move_up(&MoveUp, cx); + editor.delete_line(&DeleteLine, cx); + editor.move_up(&MoveUp, cx); + editor.delete_line(&DeleteLine, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; const A: u32 = 42; + const B: u32 = 42; - + ˇ fn main() { println!("hello"); @@ -12356,13 +12718,13 @@ async fn test_edits_around_expanded_insertion_hunks( editor.delete_line(&DeleteLine, cx); }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; - use some::mod2; - - const A: u32 = 42; - + ˇ fn main() { println!("hello"); @@ -12417,7 +12779,7 @@ async fn test_edits_around_expanded_deletion_hunks( .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, cx| { @@ -12425,13 +12787,13 @@ async fn test_edits_around_expanded_deletion_hunks( }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; - const A: u32 = 42; - const B: u32 = 42; + ˇconst B: u32 = 42; const C: u32 = 42; @@ -12448,32 +12810,16 @@ async fn test_edits_around_expanded_deletion_hunks( editor.delete_line(&DeleteLine, cx); }); executor.run_until_parked(); - cx.assert_editor_state( - &r#" + cx.assert_state_with_diff( + r#" use some::mod1; use some::mod2; + - const A: u32 = 42; + - const B: u32 = 42; ˇconst C: u32 = 42; - fn main() { - println!("hello"); - - println!("world"); - } - "# - .unindent(), - ); - cx.assert_diff_hunks( - r#" - use some::mod1; - use some::mod2; - - - const A: u32 = 42; - - const B: u32 = 42; - const C: u32 = 42; - - fn main() { println!("hello"); @@ -12487,22 +12833,7 @@ async fn test_edits_around_expanded_deletion_hunks( editor.delete_line(&DeleteLine, cx); }); executor.run_until_parked(); - cx.assert_editor_state( - &r#" - use some::mod1; - use some::mod2; - - ˇ - - fn main() { - println!("hello"); - - println!("world"); - } - "# - .unindent(), - ); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -12510,7 +12841,7 @@ async fn test_edits_around_expanded_deletion_hunks( - const A: u32 = 42; - const B: u32 = 42; - const C: u32 = 42; - + ˇ fn main() { println!("hello"); @@ -12525,22 +12856,7 @@ async fn test_edits_around_expanded_deletion_hunks( editor.handle_input("replacement", cx); }); executor.run_until_parked(); - cx.assert_editor_state( - &r#" - use some::mod1; - use some::mod2; - - replacementˇ - - fn main() { - println!("hello"); - - println!("world"); - } - "# - .unindent(), - ); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -12549,7 +12865,7 @@ async fn test_edits_around_expanded_deletion_hunks( - const B: u32 = 42; - const C: u32 = 42; - - + replacement + + replacementˇ fn main() { println!("hello"); @@ -12606,14 +12922,14 @@ async fn test_edit_after_expanded_modification_hunk( .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, cx| { editor.expand_all_hunk_diffs(&ExpandAllHunkDiffs, cx); }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -12621,7 +12937,7 @@ async fn test_edit_after_expanded_modification_hunk( const A: u32 = 42; const B: u32 = 42; - const C: u32 = 42; - + const C: u32 = 43 + + const C: u32 = 43ˇ const D: u32 = 42; @@ -12638,7 +12954,7 @@ async fn test_edit_after_expanded_modification_hunk( }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -12648,7 +12964,7 @@ async fn test_edit_after_expanded_modification_hunk( - const C: u32 = 42; + const C: u32 = 43 + new_line - + + + ˇ const D: u32 = 42; @@ -13718,20 +14034,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, @@ -13741,22 +14043,14 @@ fn assert_hunk_revert( cx: &mut EditorLspTestContext, ) { cx.set_state(not_reverted_text_with_selections); - cx.update_editor(|editor, cx| { - editor - .buffer() - .read(cx) - .as_singleton() - .unwrap() - .update(cx, |buffer, cx| { - buffer.set_diff_base(Some(base_text.into()), cx); - }); - }); + cx.set_diff_base(base_text); cx.executor().run_until_parked(); let reverted_hunk_statuses = cx.update_editor(|editor, cx| { - let snapshot = editor.buffer().read(cx).snapshot(cx); + let snapshot = editor.snapshot(cx); let reverted_hunk_statuses = snapshot - .git_diff_hunks_in_range(MultiBufferRow::MIN..MultiBufferRow::MAX) + .diff_map + .diff_hunks_in_range(0..snapshot.buffer_snapshot.len(), &snapshot.buffer_snapshot) .map(|hunk| hunk_status(&hunk)) .collect::>(); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 0c403022a3..2df6d66b6a 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -169,6 +169,7 @@ impl EditorElement { crate::rust_analyzer_ext::apply_related_actions(view, cx); crate::clangd_ext::apply_related_actions(view, cx); + register_action(view, cx, Editor::open_context_menu); register_action(view, cx, Editor::move_left); register_action(view, cx, Editor::move_right); register_action(view, cx, Editor::move_down); @@ -189,6 +190,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); @@ -341,6 +343,7 @@ impl EditorElement { register_action(view, cx, Editor::fold); register_action(view, cx, Editor::fold_at_level); register_action(view, cx, Editor::fold_all); + register_action(view, cx, Editor::fold_function_bodies); register_action(view, cx, Editor::fold_at); register_action(view, cx, Editor::fold_recursive); register_action(view, cx, Editor::toggle_fold); @@ -453,6 +456,8 @@ impl EditorElement { register_action(view, cx, Editor::open_active_item_in_terminal); register_action(view, cx, Editor::reload_file); register_action(view, cx, Editor::spawn_nearest_task); + register_action(view, cx, Editor::insert_uuid_v4); + register_action(view, cx, Editor::insert_uuid_v7); } fn register_key_listeners(&self, cx: &mut WindowContext, layout: &EditorLayout) { @@ -593,7 +598,7 @@ impl EditorElement { position_map.point_for_position(text_hitbox.bounds, event.position); mouse_context_menu::deploy_context_menu( editor, - event.position, + Some(event.position), point_for_position.previous_valid, cx, ); @@ -1166,7 +1171,7 @@ impl EditorElement { let editor = self.editor.read(cx); let is_singleton = editor.is_singleton(cx); // Git - (is_singleton && scrollbar_settings.git_diff && snapshot.buffer_snapshot.has_git_diffs()) + (is_singleton && scrollbar_settings.git_diff && !snapshot.diff_map.is_empty()) || // Buffer Search Results (is_singleton && scrollbar_settings.search_results && editor.has_background_highlights::()) @@ -1317,17 +1322,8 @@ impl EditorElement { cx: &mut WindowContext, ) -> Vec<(DisplayDiffHunk, Option)> { let buffer_snapshot = &snapshot.buffer_snapshot; - - let buffer_start_row = MultiBufferRow( - DisplayPoint::new(display_rows.start, 0) - .to_point(snapshot) - .row, - ); - let buffer_end_row = MultiBufferRow( - DisplayPoint::new(display_rows.end, 0) - .to_point(snapshot) - .row, - ); + let buffer_start = DisplayPoint::new(display_rows.start, 0).to_point(snapshot); + let buffer_end = DisplayPoint::new(display_rows.end, 0).to_point(snapshot); let git_gutter_setting = ProjectSettings::get_global(cx) .git @@ -1335,7 +1331,7 @@ impl EditorElement { .unwrap_or_default(); self.editor.update(cx, |editor, cx| { - let expanded_hunks = &editor.expanded_hunks.hunks; + let expanded_hunks = &editor.diff_map.hunks; let expanded_hunks_start_ix = expanded_hunks .binary_search_by(|hunk| { hunk.hunk_range @@ -1346,8 +1342,10 @@ impl EditorElement { .unwrap_err(); let mut expanded_hunks = expanded_hunks[expanded_hunks_start_ix..].iter().peekable(); - let display_hunks = buffer_snapshot - .git_diff_hunks_in_range(buffer_start_row..buffer_end_row) + let mut display_hunks: Vec<(DisplayDiffHunk, Option)> = editor + .diff_map + .snapshot + .diff_hunks_in_range(buffer_start..buffer_end, &buffer_snapshot) .filter_map(|hunk| { let display_hunk = diff_hunk_to_display(&hunk, snapshot); @@ -1390,31 +1388,29 @@ impl EditorElement { Some(display_hunk) }) .dedup() - .map(|hunk| match git_gutter_setting { - GitGutterSetting::TrackedFiles => { - let hitbox = match hunk { - DisplayDiffHunk::Unfolded { .. } => { - let hunk_bounds = Self::diff_hunk_bounds( - snapshot, - line_height, - gutter_hitbox.bounds, - &hunk, - ); - Some(cx.insert_hitbox(hunk_bounds, true)) - } - DisplayDiffHunk::Folded { .. } => None, - }; - (hunk, hitbox) - } - GitGutterSetting::Hide => (hunk, None), - }) + .map(|hunk| (hunk, None)) .collect(); + + if let GitGutterSetting::TrackedFiles = git_gutter_setting { + for (hunk, hitbox) in &mut display_hunks { + if let DisplayDiffHunk::Unfolded { .. } = hunk { + let hunk_bounds = Self::diff_hunk_bounds( + snapshot, + line_height, + gutter_hitbox.bounds, + &hunk, + ); + *hitbox = Some(cx.insert_hitbox(hunk_bounds, true)); + }; + } + } + display_hunks }) } #[allow(clippy::too_many_arguments)] - fn layout_active_line_trailer( + fn layout_inline_blame( &self, display_row: DisplayRow, display_snapshot: &DisplaySnapshot, @@ -1426,71 +1422,61 @@ impl EditorElement { line_height: Pixels, cx: &mut WindowContext, ) -> Option { - let render_inline_blame = self + if !self .editor - .update(cx, |editor, cx| editor.render_git_blame_inline(cx)); - if render_inline_blame { - let workspace = self - .editor - .read(cx) - .workspace - .as_ref() - .map(|(w, _)| w.clone()); - - let display_point = DisplayPoint::new(display_row, 0); - let buffer_row = MultiBufferRow(display_point.to_point(display_snapshot).row); - - let blame = self.editor.read(cx).blame.clone()?; - let blame_entry = blame - .update(cx, |blame, cx| { - blame.blame_for_rows([Some(buffer_row)], cx).next() - }) - .flatten()?; - - let mut element = - render_inline_blame_entry(&blame, blame_entry, &self.style, workspace, cx); - - let start_y = content_origin.y - + line_height * (display_row.as_f32() - scroll_pixel_position.y / line_height); - - let start_x = { - const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 6.; - - let line_end = if let Some(crease_trailer) = crease_trailer { - crease_trailer.bounds.right() - } else { - content_origin.x - scroll_pixel_position.x + line_layout.width - }; - let padded_line_end = line_end + em_width * INLINE_BLAME_PADDING_EM_WIDTHS; - - let min_column_in_pixels = ProjectSettings::get_global(cx) - .git - .inline_blame - .and_then(|settings| settings.min_column) - .map(|col| self.column_pixels(col as usize, cx)) - .unwrap_or(px(0.)); - let min_start = content_origin.x - scroll_pixel_position.x + min_column_in_pixels; - - cmp::max(padded_line_end, min_start) - }; - - let absolute_offset = point(start_x, start_y); - element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), cx); - - Some(element) - } else if let Some(mut element) = self.editor.update(cx, |editor, cx| { - editor.render_active_line_trailer(&self.style, cx) - }) { - let start_y = content_origin.y - + line_height * (display_row.as_f32() - scroll_pixel_position.y / line_height); - let start_x = content_origin.x - scroll_pixel_position.x + em_width; - let absolute_offset = point(start_x, start_y); - element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), cx); - - Some(element) - } else { - None + .update(cx, |editor, cx| editor.render_git_blame_inline(cx)) + { + return None; } + + let workspace = self + .editor + .read(cx) + .workspace + .as_ref() + .map(|(w, _)| w.clone()); + + let display_point = DisplayPoint::new(display_row, 0); + let buffer_row = MultiBufferRow(display_point.to_point(display_snapshot).row); + + let blame = self.editor.read(cx).blame.clone()?; + let blame_entry = blame + .update(cx, |blame, cx| { + blame.blame_for_rows([Some(buffer_row)], cx).next() + }) + .flatten()?; + + let mut element = + render_inline_blame_entry(&blame, blame_entry, &self.style, workspace, cx); + + let start_y = content_origin.y + + line_height * (display_row.as_f32() - scroll_pixel_position.y / line_height); + + let start_x = { + const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 6.; + + let line_end = if let Some(crease_trailer) = crease_trailer { + crease_trailer.bounds.right() + } else { + content_origin.x - scroll_pixel_position.x + line_layout.width + }; + let padded_line_end = line_end + em_width * INLINE_BLAME_PADDING_EM_WIDTHS; + + let min_column_in_pixels = ProjectSettings::get_global(cx) + .git + .inline_blame + .and_then(|settings| settings.min_column) + .map(|col| self.column_pixels(col as usize, cx)) + .unwrap_or(px(0.)); + let min_start = content_origin.x - scroll_pixel_position.x + min_column_in_pixels; + + cmp::max(padded_line_end, min_start) + }; + + let absolute_offset = point(start_x, start_y); + element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), cx); + + Some(element) } #[allow(clippy::too_many_arguments)] @@ -2738,6 +2724,7 @@ impl EditorElement { &self, editor_snapshot: &EditorSnapshot, visible_range: Range, + content_origin: gpui::Point, cx: &mut WindowContext, ) -> Option { let position = self.editor.update(cx, |editor, cx| { @@ -2755,16 +2742,11 @@ impl EditorElement { let mouse_context_menu = editor.mouse_context_menu.as_ref()?; let (source_display_point, position) = match mouse_context_menu.position { MenuPosition::PinnedToScreen(point) => (None, point), - MenuPosition::PinnedToEditor { - source, - offset_x, - offset_y, - } => { + MenuPosition::PinnedToEditor { source, offset } => { let source_display_point = source.to_display_point(editor_snapshot); - let mut source_point = editor.to_pixel_point(source, editor_snapshot, cx)?; - source_point.x += offset_x; - source_point.y += offset_y; - (Some(source_display_point), source_point) + let source_point = editor.to_pixel_point(source, editor_snapshot, cx)?; + let position = content_origin + source_point + offset; + (Some(source_display_point), position) } }; @@ -3466,7 +3448,7 @@ impl EditorElement { self.paint_lines(&invisible_display_ranges, layout, cx); self.paint_redactions(layout, cx); self.paint_cursors(layout, cx); - self.paint_active_line_trailer(layout, cx); + self.paint_inline_blame(layout, cx); cx.with_element_namespace("crease_trailers", |cx| { for trailer in layout.crease_trailers.iter_mut().flatten() { trailer.element.paint(cx); @@ -3766,10 +3748,8 @@ impl EditorElement { let mut marker_quads = Vec::new(); if scrollbar_settings.git_diff { let marker_row_ranges = snapshot - .buffer_snapshot - .git_diff_hunks_in_range( - MultiBufferRow::MIN..MultiBufferRow::MAX, - ) + .diff_map + .diff_hunks(&snapshot.buffer_snapshot) .map(|hunk| { let start_display_row = MultiBufferPoint::new(hunk.row_range.start.0, 0) @@ -3948,10 +3928,10 @@ impl EditorElement { } } - fn paint_active_line_trailer(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { - if let Some(mut element) = layout.active_line_trailer.take() { + fn paint_inline_blame(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { + if let Some(mut inline_blame) = layout.inline_blame.take() { cx.paint_layer(layout.text_hitbox.bounds, |cx| { - element.paint(cx); + inline_blame.paint(cx); }) } } @@ -4149,13 +4129,7 @@ impl EditorElement { } fn max_line_number_width(&self, snapshot: &EditorSnapshot, cx: &WindowContext) -> Pixels { - let digit_count = snapshot - .max_buffer_row() - .next_row() - .as_f32() - .log10() - .floor() as usize - + 1; + let digit_count = (snapshot.widest_line_number() as f32).log10().floor() as usize + 1; self.column_pixels(digit_count, cx) } } @@ -4339,8 +4313,8 @@ fn deploy_blame_entry_context_menu( }); editor.update(cx, move |editor, cx| { - editor.mouse_context_menu = Some(MouseContextMenu::pinned_to_screen( - position, + editor.mouse_context_menu = Some(MouseContextMenu::new( + MenuPosition::PinnedToScreen(position), context_menu, cx, )); @@ -5343,14 +5317,14 @@ impl Element for EditorElement { ) }); - let mut active_line_trailer = None; + let mut inline_blame = None; if let Some(newest_selection_head) = newest_selection_head { let display_row = newest_selection_head.row(); if (start_row..end_row).contains(&display_row) { let line_ix = display_row.minus(start_row) as usize; let line_layout = &line_layouts[line_ix]; let crease_trailer_layout = crease_trailers[line_ix].as_ref(); - active_line_trailer = self.layout_active_line_trailer( + inline_blame = self.layout_inline_blame( display_row, &snapshot.display_snapshot, line_layout, @@ -5457,7 +5431,7 @@ impl Element for EditorElement { let expanded_add_hunks_by_rows = self.editor.update(cx, |editor, _| { editor - .expanded_hunks + .diff_map .hunks(false) .filter(|hunk| hunk.status == DiffHunkStatus::Added) .map(|expanded_hunk| { @@ -5592,8 +5566,12 @@ impl Element for EditorElement { ); } - let mouse_context_menu = - self.layout_mouse_context_menu(&snapshot, start_row..end_row, cx); + let mouse_context_menu = self.layout_mouse_context_menu( + &snapshot, + start_row..end_row, + content_origin, + cx, + ); cx.with_element_namespace("crease_toggles", |cx| { self.prepaint_crease_toggles( @@ -5669,7 +5647,7 @@ impl Element for EditorElement { line_elements, line_numbers, blamed_display_rows, - active_line_trailer, + inline_blame, blocks, cursors, visible_cursors, @@ -5806,7 +5784,7 @@ pub struct EditorLayout { line_numbers: Vec>, display_hunks: Vec<(DisplayDiffHunk, Option)>, blamed_display_rows: Option>, - active_line_trailer: Option, + inline_blame: Option, blocks: Vec, highlighted_ranges: Vec<(Range, Hsla)>, highlighted_gutter_ranges: Vec<(Range, Hsla)>, diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index 080babe4c6..97ca80ea29 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -1 +1,2 @@ pub mod blame; +pub mod project_diff; diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index 9dfc379ae7..b4fe2efec6 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; @@ -154,7 +154,7 @@ impl GitBlame { this.generate(cx); } } - project::Event::WorktreeUpdatedGitRepositories => { + project::Event::WorktreeUpdatedGitRepositories(_) => { log::debug!("Status of git repositories updated. Regenerating blame data...",); this.generate(cx); } diff --git a/crates/editor/src/git/project_diff.rs b/crates/editor/src/git/project_diff.rs new file mode 100644 index 0000000000..8fb600c52c --- /dev/null +++ b/crates/editor/src/git/project_diff.rs @@ -0,0 +1,1254 @@ +use std::{ + any::{Any, TypeId}, + cmp::Ordering, + collections::HashSet, + ops::Range, + time::Duration, +}; + +use anyhow::{anyhow, Context as _}; +use collections::{BTreeMap, HashMap}; +use feature_flags::FeatureFlagAppExt; +use git::{ + diff::{BufferDiff, DiffHunk}, + repository::GitFileStatus, +}; +use gpui::{ + actions, AnyElement, AnyView, AppContext, EventEmitter, FocusHandle, FocusableView, + InteractiveElement, Model, Render, Subscription, Task, View, WeakView, +}; +use language::{Buffer, BufferRow}; +use multi_buffer::{ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer}; +use project::{Project, ProjectEntryId, ProjectPath, WorktreeId}; +use text::{OffsetRangeExt, ToPoint}; +use theme::ActiveTheme; +use ui::{ + div, h_flex, Color, Context, FluentBuilder, Icon, IconName, IntoElement, Label, LabelCommon, + ParentElement, SharedString, Styled, ViewContext, VisualContext, WindowContext, +}; +use util::{paths::compare_paths, ResultExt}; +use workspace::{ + item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams}, + ItemNavHistory, ToolbarItemLocation, Workspace, +}; + +use crate::{Editor, EditorEvent, DEFAULT_MULTIBUFFER_CONTEXT}; + +actions!(project_diff, [Deploy]); + +pub fn init(cx: &mut AppContext) { + cx.observe_new_views(ProjectDiffEditor::register).detach(); +} + +const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); + +struct ProjectDiffEditor { + buffer_changes: BTreeMap>, + entry_order: HashMap>, + excerpts: Model, + editor: View, + + project: Model, + workspace: WeakView, + focus_handle: FocusHandle, + worktree_rescans: HashMap>, + _subscriptions: Vec, +} + +#[derive(Debug)] +struct Changes { + _status: GitFileStatus, + buffer: Model, + hunks: Vec, +} + +impl ProjectDiffEditor { + fn register(workspace: &mut Workspace, _: &mut ViewContext) { + workspace.register_action(Self::deploy); + } + + fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext) { + if !cx.is_staff() { + return; + } + + if let Some(existing) = workspace.item_of_type::(cx) { + workspace.activate_item(&existing, true, true, cx); + } else { + let workspace_handle = cx.view().downgrade(); + let project_diff = + cx.new_view(|cx| Self::new(workspace.project().clone(), workspace_handle, cx)); + workspace.add_item_to_active_pane(Box::new(project_diff), None, true, cx); + } + } + + fn new( + project: Model, + workspace: WeakView, + cx: &mut ViewContext, + ) -> Self { + // TODO diff change subscriptions. For that, needed: + // * `-20/+50` stats retrieval: some background process that reacts on file changes + let focus_handle = cx.focus_handle(); + let changed_entries_subscription = + cx.subscribe(&project, |project_diff_editor, _, e, cx| { + let mut worktree_to_rescan = None; + match e { + project::Event::WorktreeAdded(id) => { + worktree_to_rescan = Some(*id); + // project_diff_editor + // .buffer_changes + // .insert(*id, HashMap::default()); + } + project::Event::WorktreeRemoved(id) => { + project_diff_editor.buffer_changes.remove(id); + } + project::Event::WorktreeUpdatedEntries(id, _updated_entries) => { + // TODO cannot invalidate buffer entries without invalidating the corresponding excerpts and order entries. + worktree_to_rescan = Some(*id); + // let entry_changes = + // project_diff_editor.buffer_changes.entry(*id).or_default(); + // for (_, entry_id, change) in updated_entries.iter() { + // let changes = entry_changes.entry(*entry_id); + // match change { + // project::PathChange::Removed => { + // if let hash_map::Entry::Occupied(entry) = changes { + // entry.remove(); + // } + // } + // // TODO understand the invalidation case better: now, we do that but still rescan the entire worktree + // // What if we already have the buffer loaded inside the diff multi buffer and it was edited there? We should not do anything. + // _ => match changes { + // hash_map::Entry::Occupied(mut o) => o.get_mut().invalidate(), + // hash_map::Entry::Vacant(v) => { + // v.insert(None); + // } + // }, + // } + // } + } + project::Event::WorktreeUpdatedGitRepositories(id) => { + worktree_to_rescan = Some(*id); + // project_diff_editor.buffer_changes.clear(); + } + project::Event::DeletedEntry(id, _entry_id) => { + worktree_to_rescan = Some(*id); + // if let Some(entries) = project_diff_editor.buffer_changes.get_mut(id) { + // entries.remove(entry_id); + // } + } + project::Event::Closed => { + project_diff_editor.buffer_changes.clear(); + } + _ => {} + } + + if let Some(worktree_to_rescan) = worktree_to_rescan { + project_diff_editor.schedule_worktree_rescan(worktree_to_rescan, cx); + } + }); + + let excerpts = cx.new_model(|cx| MultiBuffer::new(project.read(cx).capability())); + + let editor = cx.new_view(|cx| { + let mut diff_display_editor = + Editor::for_multibuffer(excerpts.clone(), Some(project.clone()), true, cx); + diff_display_editor.set_expand_all_diff_hunks(); + diff_display_editor + }); + + let mut new_self = Self { + project, + workspace, + buffer_changes: BTreeMap::default(), + entry_order: HashMap::default(), + worktree_rescans: HashMap::default(), + focus_handle, + editor, + excerpts, + _subscriptions: vec![changed_entries_subscription], + }; + new_self.schedule_rescan_all(cx); + new_self + } + + fn schedule_rescan_all(&mut self, cx: &mut ViewContext) { + let mut current_worktrees = HashSet::::default(); + for worktree in self.project.read(cx).worktrees(cx).collect::>() { + let worktree_id = worktree.read(cx).id(); + current_worktrees.insert(worktree_id); + self.schedule_worktree_rescan(worktree_id, cx); + } + + self.worktree_rescans + .retain(|worktree_id, _| current_worktrees.contains(worktree_id)); + self.buffer_changes + .retain(|worktree_id, _| current_worktrees.contains(worktree_id)); + self.entry_order + .retain(|worktree_id, _| current_worktrees.contains(worktree_id)); + } + + fn schedule_worktree_rescan(&mut self, id: WorktreeId, cx: &mut ViewContext) { + let project = self.project.clone(); + self.worktree_rescans.insert( + id, + cx.spawn(|project_diff_editor, mut cx| async move { + cx.background_executor().timer(UPDATE_DEBOUNCE).await; + let open_tasks = project + .update(&mut cx, |project, cx| { + let worktree = project.worktree_for_id(id, cx)?; + let applicable_entries = worktree + .read(cx) + .entries(false, 0) + .filter(|entry| !entry.is_external) + .filter(|entry| entry.is_file()) + .filter_map(|entry| Some((entry.git_status?, entry))) + .filter_map(|(git_status, entry)| { + Some((git_status, entry.id, project.path_for_entry(entry.id, cx)?)) + }) + .collect::>(); + Some( + applicable_entries + .into_iter() + .map(|(status, entry_id, entry_path)| { + let open_task = project.open_path(entry_path.clone(), cx); + (status, entry_id, entry_path, open_task) + }) + .collect::>(), + ) + }) + .ok() + .flatten() + .unwrap_or_default(); + + let Some((buffers, mut new_entries, change_sets)) = cx + .spawn(|mut cx| async move { + let mut new_entries = Vec::new(); + let mut buffers = HashMap::< + ProjectEntryId, + ( + GitFileStatus, + text::BufferSnapshot, + Model, + BufferDiff, + ), + >::default(); + let mut change_sets = Vec::new(); + for (status, entry_id, entry_path, open_task) in open_tasks { + let Some(buffer) = open_task + .await + .and_then(|(_, opened_model)| { + opened_model + .downcast::() + .map_err(|_| anyhow!("Unexpected non-buffer")) + }) + .with_context(|| { + format!("loading {} for git diff", entry_path.path.display()) + }) + .log_err() + else { + continue; + }; + + let Some(change_set) = project + .update(&mut cx, |project, cx| { + project.open_unstaged_changes(buffer.clone(), cx) + })? + .await + .log_err() + else { + continue; + }; + + cx.update(|cx| { + buffers.insert( + entry_id, + ( + status, + buffer.read(cx).text_snapshot(), + buffer, + change_set.read(cx).diff_to_buffer.clone(), + ), + ); + })?; + change_sets.push(change_set); + new_entries.push((entry_path, entry_id)); + } + + anyhow::Ok((buffers, new_entries, change_sets)) + }) + .await + .log_err() + else { + return; + }; + + let (new_changes, new_entry_order) = cx + .background_executor() + .spawn(async move { + let mut new_changes = HashMap::::default(); + for (entry_id, (status, buffer_snapshot, buffer, buffer_diff)) in buffers { + new_changes.insert( + entry_id, + Changes { + _status: status, + buffer, + hunks: buffer_diff + .hunks_in_row_range(0..BufferRow::MAX, &buffer_snapshot) + .collect::>(), + }, + ); + } + + new_entries.sort_by(|(project_path_a, _), (project_path_b, _)| { + compare_paths( + (project_path_a.path.as_ref(), true), + (project_path_b.path.as_ref(), true), + ) + }); + (new_changes, new_entries) + }) + .await; + + project_diff_editor + .update(&mut cx, |project_diff_editor, cx| { + project_diff_editor.update_excerpts(id, new_changes, new_entry_order, cx); + for change_set in change_sets { + project_diff_editor.editor.update(cx, |editor, cx| { + editor.diff_map.add_change_set(change_set, cx) + }); + } + }) + .ok(); + }), + ); + } + + fn update_excerpts( + &mut self, + worktree_id: WorktreeId, + new_changes: HashMap, + new_entry_order: Vec<(ProjectPath, ProjectEntryId)>, + cx: &mut ViewContext, + ) { + if let Some(current_order) = self.entry_order.get(&worktree_id) { + let current_entries = self.buffer_changes.entry(worktree_id).or_default(); + let mut new_order_entries = new_entry_order.iter().fuse().peekable(); + let mut excerpts_to_remove = Vec::new(); + let mut new_excerpt_hunks = BTreeMap::< + ExcerptId, + Vec<(ProjectPath, Model, Vec>)>, + >::new(); + let mut excerpt_to_expand = + HashMap::<(u32, ExpandExcerptDirection), Vec>::default(); + let mut latest_excerpt_id = ExcerptId::min(); + + for (current_path, current_entry_id) in current_order { + let current_changes = match current_entries.get(current_entry_id) { + Some(current_changes) => { + if current_changes.hunks.is_empty() { + continue; + } + current_changes + } + None => continue, + }; + let buffer_excerpts = self + .excerpts + .read(cx) + .excerpts_for_buffer(¤t_changes.buffer, cx); + let last_current_excerpt_id = + buffer_excerpts.last().map(|(excerpt_id, _)| *excerpt_id); + let mut current_excerpts = buffer_excerpts.into_iter().fuse().peekable(); + loop { + match new_order_entries.peek() { + Some((new_path, new_entry)) => { + match compare_paths( + (current_path.path.as_ref(), true), + (new_path.path.as_ref(), true), + ) { + Ordering::Less => { + excerpts_to_remove + .extend(current_excerpts.map(|(excerpt_id, _)| excerpt_id)); + break; + } + Ordering::Greater => { + if let Some(new_changes) = new_changes.get(new_entry) { + if !new_changes.hunks.is_empty() { + let hunks = new_excerpt_hunks + .entry(latest_excerpt_id) + .or_default(); + match hunks.binary_search_by(|(probe, ..)| { + compare_paths( + (new_path.path.as_ref(), true), + (probe.path.as_ref(), true), + ) + }) { + Ok(i) => hunks[i].2.extend( + new_changes + .hunks + .iter() + .map(|hunk| hunk.buffer_range.clone()), + ), + Err(i) => hunks.insert( + i, + ( + new_path.clone(), + new_changes.buffer.clone(), + new_changes + .hunks + .iter() + .map(|hunk| hunk.buffer_range.clone()) + .collect(), + ), + ), + } + } + }; + let _ = new_order_entries.next(); + } + Ordering::Equal => { + match new_changes.get(new_entry) { + Some(new_changes) => { + let buffer_snapshot = + new_changes.buffer.read(cx).snapshot(); + let mut current_hunks = + current_changes.hunks.iter().fuse().peekable(); + let mut new_hunks_unchanged = + Vec::with_capacity(new_changes.hunks.len()); + let mut new_hunks_with_updates = + Vec::with_capacity(new_changes.hunks.len()); + 'new_changes: for new_hunk in &new_changes.hunks { + loop { + match current_hunks.peek() { + Some(current_hunk) => { + match ( + current_hunk + .buffer_range + .start + .cmp( + &new_hunk + .buffer_range + .start, + &buffer_snapshot, + ), + current_hunk.buffer_range.end.cmp( + &new_hunk.buffer_range.end, + &buffer_snapshot, + ), + ) { + ( + Ordering::Equal, + Ordering::Equal, + ) => { + new_hunks_unchanged + .push(new_hunk); + let _ = current_hunks.next(); + continue 'new_changes; + } + (Ordering::Equal, _) + | (_, Ordering::Equal) => { + new_hunks_with_updates + .push(new_hunk); + continue 'new_changes; + } + ( + Ordering::Less, + Ordering::Greater, + ) + | ( + Ordering::Greater, + Ordering::Less, + ) => { + new_hunks_with_updates + .push(new_hunk); + continue 'new_changes; + } + ( + Ordering::Less, + Ordering::Less, + ) => { + if current_hunk + .buffer_range + .start + .cmp( + &new_hunk + .buffer_range + .end, + &buffer_snapshot, + ) + .is_le() + { + new_hunks_with_updates + .push(new_hunk); + continue 'new_changes; + } else { + let _ = + current_hunks.next(); + } + } + ( + Ordering::Greater, + Ordering::Greater, + ) => { + if current_hunk + .buffer_range + .end + .cmp( + &new_hunk + .buffer_range + .start, + &buffer_snapshot, + ) + .is_ge() + { + new_hunks_with_updates + .push(new_hunk); + continue 'new_changes; + } else { + let _ = + current_hunks.next(); + } + } + } + } + None => { + new_hunks_with_updates.push(new_hunk); + continue 'new_changes; + } + } + } + } + + let mut excerpts_with_new_changes = + HashSet::::default(); + 'new_hunks: for new_hunk in new_hunks_with_updates { + loop { + match current_excerpts.peek() { + Some(( + current_excerpt_id, + current_excerpt_range, + )) => { + match ( + current_excerpt_range + .context + .start + .cmp( + &new_hunk + .buffer_range + .start, + &buffer_snapshot, + ), + current_excerpt_range + .context + .end + .cmp( + &new_hunk.buffer_range.end, + &buffer_snapshot, + ), + ) { + ( + Ordering::Less + | Ordering::Equal, + Ordering::Greater + | Ordering::Equal, + ) => { + excerpts_with_new_changes + .insert( + *current_excerpt_id, + ); + continue 'new_hunks; + } + ( + Ordering::Greater + | Ordering::Equal, + Ordering::Less + | Ordering::Equal, + ) => { + let expand_up = current_excerpt_range + .context + .start + .to_point(&buffer_snapshot) + .row + .saturating_sub( + new_hunk + .buffer_range + .start + .to_point(&buffer_snapshot) + .row, + ); + let expand_down = new_hunk + .buffer_range + .end + .to_point(&buffer_snapshot) + .row + .saturating_sub( + current_excerpt_range + .context + .end + .to_point( + &buffer_snapshot, + ) + .row, + ); + excerpt_to_expand.entry((expand_up.max(expand_down).max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::UpAndDown)).or_default().push(*current_excerpt_id); + excerpts_with_new_changes + .insert( + *current_excerpt_id, + ); + continue 'new_hunks; + } + ( + Ordering::Less, + Ordering::Less, + ) => { + if current_excerpt_range + .context + .start + .cmp( + &new_hunk + .buffer_range + .end, + &buffer_snapshot, + ) + .is_le() + { + let expand_up = current_excerpt_range + .context + .start + .to_point(&buffer_snapshot) + .row + .saturating_sub( + new_hunk.buffer_range + .start + .to_point( + &buffer_snapshot, + ) + .row, + ); + excerpt_to_expand.entry((expand_up.max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::Up)).or_default().push(*current_excerpt_id); + excerpts_with_new_changes + .insert( + *current_excerpt_id, + ); + continue 'new_hunks; + } else { + if !new_changes + .hunks + .is_empty() + { + let hunks = new_excerpt_hunks + .entry(latest_excerpt_id) + .or_default(); + match hunks.binary_search_by(|(probe, ..)| { + compare_paths( + (new_path.path.as_ref(), true), + (probe.path.as_ref(), true), + ) + }) { + Ok(i) => hunks[i].2.extend( + new_changes + .hunks + .iter() + .map(|hunk| hunk.buffer_range.clone()), + ), + Err(i) => hunks.insert( + i, + ( + new_path.clone(), + new_changes.buffer.clone(), + new_changes + .hunks + .iter() + .map(|hunk| hunk.buffer_range.clone()) + .collect(), + ), + ), + } + } + continue 'new_hunks; + } + } + /* TODO remove or leave? + [ ><<<<<<<--]----<-- + cur_s > cur_e < + > < + new_s>>>>>>>>< + */ + ( + Ordering::Greater, + Ordering::Greater, + ) => { + if current_excerpt_range + .context + .end + .cmp( + &new_hunk + .buffer_range + .start, + &buffer_snapshot, + ) + .is_ge() + { + let expand_down = new_hunk + .buffer_range + .end + .to_point(&buffer_snapshot) + .row + .saturating_sub( + current_excerpt_range + .context + .end + .to_point( + &buffer_snapshot, + ) + .row, + ); + excerpt_to_expand.entry((expand_down.max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::Down)).or_default().push(*current_excerpt_id); + excerpts_with_new_changes + .insert( + *current_excerpt_id, + ); + continue 'new_hunks; + } else { + latest_excerpt_id = + *current_excerpt_id; + let _ = + current_excerpts.next(); + } + } + } + } + None => { + let hunks = new_excerpt_hunks + .entry(latest_excerpt_id) + .or_default(); + match hunks.binary_search_by( + |(probe, ..)| { + compare_paths( + ( + new_path.path.as_ref(), + true, + ), + (probe.path.as_ref(), true), + ) + }, + ) { + Ok(i) => hunks[i].2.extend( + new_changes.hunks.iter().map( + |hunk| { + hunk.buffer_range + .clone() + }, + ), + ), + Err(i) => hunks.insert( + i, + ( + new_path.clone(), + new_changes.buffer.clone(), + new_changes + .hunks + .iter() + .map(|hunk| { + hunk.buffer_range + .clone() + }) + .collect(), + ), + ), + } + continue 'new_hunks; + } + } + } + } + + for (excerpt_id, excerpt_range) in current_excerpts { + if !excerpts_with_new_changes.contains(&excerpt_id) + && !new_hunks_unchanged.iter().any(|hunk| { + excerpt_range + .context + .start + .cmp( + &hunk.buffer_range.end, + &buffer_snapshot, + ) + .is_le() + && excerpt_range + .context + .end + .cmp( + &hunk.buffer_range.start, + &buffer_snapshot, + ) + .is_ge() + }) + { + excerpts_to_remove.push(excerpt_id); + } + latest_excerpt_id = excerpt_id; + } + } + None => excerpts_to_remove.extend( + current_excerpts.map(|(excerpt_id, _)| excerpt_id), + ), + } + let _ = new_order_entries.next(); + break; + } + } + } + None => { + excerpts_to_remove + .extend(current_excerpts.map(|(excerpt_id, _)| excerpt_id)); + break; + } + } + } + latest_excerpt_id = last_current_excerpt_id.unwrap_or(latest_excerpt_id); + } + + for (path, project_entry_id) in new_order_entries { + if let Some(changes) = new_changes.get(project_entry_id) { + if !changes.hunks.is_empty() { + let hunks = new_excerpt_hunks.entry(latest_excerpt_id).or_default(); + match hunks.binary_search_by(|(probe, ..)| { + compare_paths((path.path.as_ref(), true), (probe.path.as_ref(), true)) + }) { + Ok(i) => hunks[i] + .2 + .extend(changes.hunks.iter().map(|hunk| hunk.buffer_range.clone())), + Err(i) => hunks.insert( + i, + ( + path.clone(), + changes.buffer.clone(), + changes + .hunks + .iter() + .map(|hunk| hunk.buffer_range.clone()) + .collect(), + ), + ), + } + } + } + } + + self.excerpts.update(cx, |multi_buffer, cx| { + for (mut after_excerpt_id, excerpts_to_add) in new_excerpt_hunks { + for (_, buffer, hunk_ranges) in excerpts_to_add { + let buffer_snapshot = buffer.read(cx).snapshot(); + let max_point = buffer_snapshot.max_point(); + let new_excerpts = multi_buffer.insert_excerpts_after( + after_excerpt_id, + buffer, + hunk_ranges.into_iter().map(|range| { + let mut extended_point_range = range.to_point(&buffer_snapshot); + extended_point_range.start.row = extended_point_range + .start + .row + .saturating_sub(DEFAULT_MULTIBUFFER_CONTEXT); + extended_point_range.end.row = (extended_point_range.end.row + + DEFAULT_MULTIBUFFER_CONTEXT) + .min(max_point.row); + ExcerptRange { + context: extended_point_range, + primary: None, + } + }), + cx, + ); + after_excerpt_id = new_excerpts.last().copied().unwrap_or(after_excerpt_id); + } + } + multi_buffer.remove_excerpts(excerpts_to_remove, cx); + for ((line_count, direction), excerpts) in excerpt_to_expand { + multi_buffer.expand_excerpts(excerpts, line_count, direction, cx); + } + }); + } else { + self.excerpts.update(cx, |multi_buffer, cx| { + for new_changes in new_entry_order + .iter() + .filter_map(|(_, entry_id)| new_changes.get(entry_id)) + { + multi_buffer.push_excerpts_with_context_lines( + new_changes.buffer.clone(), + new_changes + .hunks + .iter() + .map(|hunk| hunk.buffer_range.clone()) + .collect(), + DEFAULT_MULTIBUFFER_CONTEXT, + cx, + ); + } + }); + }; + + let mut new_changes = new_changes; + let mut new_entry_order = new_entry_order; + std::mem::swap( + self.buffer_changes.entry(worktree_id).or_default(), + &mut new_changes, + ); + std::mem::swap( + self.entry_order.entry(worktree_id).or_default(), + &mut new_entry_order, + ); + } +} + +impl EventEmitter for ProjectDiffEditor {} + +impl FocusableView for ProjectDiffEditor { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Item for ProjectDiffEditor { + type Event = EditorEvent; + + fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn deactivated(&mut self, cx: &mut ViewContext) { + self.editor.update(cx, |editor, cx| editor.deactivated(cx)); + } + + fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { + self.editor + .update(cx, |editor, cx| editor.navigate(data, cx)) + } + + fn tab_tooltip_text(&self, _: &AppContext) -> Option { + Some("Project Diff".into()) + } + + fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement { + if self.buffer_changes.is_empty() { + Label::new("No changes") + .color(if params.selected { + Color::Default + } else { + Color::Muted + }) + .into_any_element() + } else { + h_flex() + .gap_1() + .when(true, |then| { + then.child( + h_flex() + .gap_1() + .child(Icon::new(IconName::XCircle).color(Color::Error)) + .child(Label::new(self.buffer_changes.len().to_string()).color( + if params.selected { + Color::Default + } else { + Color::Muted + }, + )), + ) + }) + .when(true, |then| { + then.child( + h_flex() + .gap_1() + .child(Icon::new(IconName::Indicator).color(Color::Warning)) + .child(Label::new(self.buffer_changes.len().to_string()).color( + if params.selected { + Color::Default + } else { + Color::Muted + }, + )), + ) + }) + .into_any_element() + } + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("project diagnostics") + } + + fn for_each_project_item( + &self, + cx: &AppContext, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.editor.for_each_project_item(cx, f) + } + + fn is_singleton(&self, _: &AppContext) -> bool { + false + } + + fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext) { + self.editor.update(cx, |editor, _| { + editor.set_nav_history(Some(nav_history)); + }); + } + + fn clone_on_split( + &self, + _workspace_id: Option, + cx: &mut ViewContext, + ) -> Option> + where + Self: Sized, + { + Some(cx.new_view(|cx| { + ProjectDiffEditor::new(self.project.clone(), self.workspace.clone(), cx) + })) + } + + fn is_dirty(&self, cx: &AppContext) -> bool { + self.excerpts.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &AppContext) -> bool { + self.excerpts.read(cx).has_conflict(cx) + } + + fn can_save(&self, _: &AppContext) -> bool { + true + } + + fn save( + &mut self, + format: bool, + project: Model, + cx: &mut ViewContext, + ) -> Task> { + self.editor.save(format, project, cx) + } + + fn save_as( + &mut self, + _: Model, + _: ProjectPath, + _: &mut ViewContext, + ) -> Task> { + unreachable!() + } + + fn reload( + &mut self, + project: Model, + cx: &mut ViewContext, + ) -> Task> { + self.editor.reload(project, cx) + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a View, + _: &'a AppContext, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.to_any()) + } else if type_id == TypeId::of::() { + Some(self.editor.to_any()) + } else { + None + } + } + + fn breadcrumb_location(&self, _: &AppContext) -> ToolbarItemLocation { + ToolbarItemLocation::PrimaryLeft + } + + fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option> { + self.editor.breadcrumbs(theme, cx) + } + + fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { + self.editor + .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx)); + } +} + +impl Render for ProjectDiffEditor { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let child = if self.buffer_changes.is_empty() { + div() + .bg(cx.theme().colors().editor_background) + .flex() + .items_center() + .justify_center() + .size_full() + .child(Label::new("No changes in the workspace")) + } else { + div().size_full().child(self.editor.clone()) + }; + + div() + .track_focus(&self.focus_handle) + .size_full() + .child(child) + } +} + +#[cfg(test)] +mod tests { + use gpui::{SemanticVersion, TestAppContext, VisualTestContext}; + use project::buffer_store::BufferChangeSet; + use serde_json::json; + use settings::SettingsStore; + use std::{ + ops::Deref as _, + path::{Path, PathBuf}, + }; + + use super::*; + + // TODO finish + // #[gpui::test] + // async fn randomized_tests(cx: &mut TestAppContext) { + // // Create a new project (how?? temp fs?), + // let fs = FakeFs::new(cx.executor()); + // let project = Project::test(fs, [], cx).await; + + // // create random files with random content + + // // Commit it into git somehow (technically can do with "real" fs in a temp dir) + // // + // // Apply randomized changes to the project: select a random file, random change and apply to buffers + // } + + #[gpui::test(iterations = 30)] + async fn simple_edit_test(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + init_test(cx); + + let fs = fs::FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root", + json!({ + ".git": {}, + "file_a": "This is file_a", + "file_b": "This is file_b", + }), + ) + .await; + + let project = Project::test(fs.clone(), [Path::new("/root")], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + + let file_a_editor = workspace + .update(cx, |workspace, cx| { + let file_a_editor = + workspace.open_abs_path(PathBuf::from("/root/file_a"), true, cx); + ProjectDiffEditor::deploy(workspace, &Deploy, cx); + file_a_editor + }) + .unwrap() + .await + .expect("did not open an item at all") + .downcast::() + .expect("did not open an editor for file_a"); + let project_diff_editor = workspace + .update(cx, |workspace, cx| { + workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()) + }) + .unwrap() + .expect("did not find a ProjectDiffEditor"); + project_diff_editor.update(cx, |project_diff_editor, cx| { + assert!( + project_diff_editor.editor.read(cx).text(cx).is_empty(), + "Should have no changes after opening the diff on no git changes" + ); + }); + + let old_text = file_a_editor.update(cx, |editor, cx| editor.text(cx)); + let change = "an edit after git add"; + file_a_editor + .update(cx, |file_a_editor, cx| { + file_a_editor.insert(change, cx); + file_a_editor.save(false, project.clone(), cx) + }) + .await + .expect("failed to save a file"); + file_a_editor.update(cx, |file_a_editor, cx| { + let change_set = cx.new_model(|cx| { + BufferChangeSet::new_with_base_text( + old_text.clone(), + file_a_editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .read(cx) + .text_snapshot(), + cx, + ) + }); + file_a_editor + .diff_map + .add_change_set(change_set.clone(), cx); + project.update(cx, |project, cx| { + project.buffer_store().update(cx, |buffer_store, cx| { + buffer_store.set_change_set( + file_a_editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .read(cx) + .remote_id(), + change_set, + ); + }); + }); + }); + fs.set_status_for_repo_via_git_operation( + Path::new("/root/.git"), + &[(Path::new("file_a"), GitFileStatus::Modified)], + ); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + project_diff_editor.update(cx, |project_diff_editor, cx| { + assert_eq!( + // TODO assert it better: extract added text (based on the background changes) and deleted text (based on the deleted blocks added) + project_diff_editor.editor.read(cx).text(cx), + format!("{change}{old_text}"), + "Should have a new change shown in the beginning, and the old text shown as deleted text afterwards" + ); + }); + } + + fn init_test(cx: &mut gpui::TestAppContext) { + if std::env::var("RUST_LOG").is_ok() { + env_logger::try_init().ok(); + } + + cx.update(|cx| { + assets::Assets.load_test_fonts(cx); + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + release_channel::init(SemanticVersion::default(), cx); + client::init_settings(cx); + language::init(cx); + Project::init_settings(cx); + workspace::init_settings(cx); + crate::init(cx); + cx.set_staff(true); + }); + } +} diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 31be9e93a9..0973f59bab 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 006a42700b..9cac7dc713 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -378,7 +378,7 @@ fn show_hover( }, ..Default::default() }; - Markdown::new_text(text, markdown_style.clone(), None, cx, None) + Markdown::new_text(text, markdown_style.clone(), None, None, cx) }) .ok(); @@ -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 27bb8ac557..3f798eaa58 100644 --- a/crates/editor/src/hunk_diff.rs +++ b/crates/editor/src/hunk_diff.rs @@ -1,12 +1,17 @@ -use collections::{hash_map, HashMap, HashSet}; +use collections::{HashMap, HashSet}; use git::diff::DiffHunkStatus; -use gpui::{Action, AnchorCorner, AppContext, CursorStyle, Hsla, Model, MouseButton, Task, View}; +use gpui::{ + Action, AnchorCorner, AppContext, CursorStyle, Hsla, Model, MouseButton, Subscription, Task, + View, +}; use language::{Buffer, BufferId, Point}; use multi_buffer::{ Anchor, AnchorRangeExt, ExcerptRange, MultiBuffer, MultiBufferDiffHunk, MultiBufferRow, - MultiBufferSnapshot, ToPoint, + MultiBufferSnapshot, ToOffset, ToPoint, }; +use project::buffer_store::BufferChangeSet; use std::{ops::Range, sync::Arc}; +use sum_tree::TreeMap; use text::OffsetRangeExt; use ui::{ prelude::*, ActiveTheme, ContextMenu, IconButtonShape, InteractiveElement, IntoElement, @@ -29,10 +34,11 @@ pub(super) struct HoveredHunk { pub diff_base_byte_range: Range, } -#[derive(Debug, Default)] -pub(super) struct ExpandedHunks { +#[derive(Default)] +pub(super) struct DiffMap { pub(crate) hunks: Vec, - diff_base: HashMap, + pub(crate) diff_bases: HashMap, + pub(crate) snapshot: DiffMapSnapshot, hunk_update_tasks: HashMap, Task<()>>, expand_all: bool, } @@ -46,10 +52,13 @@ pub(super) struct ExpandedHunk { pub folded: bool, } -#[derive(Debug)] -struct DiffBaseBuffer { - buffer: Model, - diff_base_version: usize, +#[derive(Clone, Debug, Default)] +pub(crate) struct DiffMapSnapshot(TreeMap); + +pub(crate) struct DiffBaseState { + pub(crate) change_set: Model, + pub(crate) last_version: Option, + _subscription: Subscription, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -66,7 +75,38 @@ pub enum DisplayDiffHunk { }, } -impl ExpandedHunks { +impl DiffMap { + pub fn snapshot(&self) -> DiffMapSnapshot { + self.snapshot.clone() + } + + pub fn add_change_set( + &mut self, + change_set: Model, + cx: &mut ViewContext, + ) { + let buffer_id = change_set.read(cx).buffer_id; + self.snapshot + .0 + .insert(buffer_id, change_set.read(cx).diff_to_buffer.clone()); + Editor::sync_expanded_diff_hunks(self, buffer_id, cx); + self.diff_bases.insert( + buffer_id, + DiffBaseState { + last_version: None, + _subscription: cx.observe(&change_set, move |editor, change_set, cx| { + editor + .diff_map + .snapshot + .0 + .insert(buffer_id, change_set.read(cx).diff_to_buffer.clone()); + Editor::sync_expanded_diff_hunks(&mut editor.diff_map, buffer_id, cx); + }), + change_set, + }, + ); + } + pub fn hunks(&self, include_folded: bool) -> impl Iterator { self.hunks .iter() @@ -74,9 +114,92 @@ impl ExpandedHunks { } } +impl DiffMapSnapshot { + pub fn is_empty(&self) -> bool { + self.0.values().all(|diff| diff.is_empty()) + } + + pub fn diff_hunks<'a>( + &'a self, + buffer_snapshot: &'a MultiBufferSnapshot, + ) -> impl Iterator + 'a { + self.diff_hunks_in_range(0..buffer_snapshot.len(), buffer_snapshot) + } + + pub fn diff_hunks_in_range<'a, T: ToOffset>( + &'a self, + range: Range, + buffer_snapshot: &'a MultiBufferSnapshot, + ) -> impl Iterator + 'a { + let range = range.start.to_offset(buffer_snapshot)..range.end.to_offset(buffer_snapshot); + buffer_snapshot + .excerpts_for_range(range.clone()) + .filter_map(move |excerpt| { + let buffer = excerpt.buffer(); + let buffer_id = buffer.remote_id(); + let diff = self.0.get(&buffer_id)?; + let buffer_range = excerpt.map_range_to_buffer(range.clone()); + let buffer_range = + buffer.anchor_before(buffer_range.start)..buffer.anchor_after(buffer_range.end); + Some( + diff.hunks_intersecting_range(buffer_range, excerpt.buffer()) + .map(move |hunk| { + let start = + excerpt.map_point_from_buffer(Point::new(hunk.row_range.start, 0)); + let end = + excerpt.map_point_from_buffer(Point::new(hunk.row_range.end, 0)); + MultiBufferDiffHunk { + row_range: MultiBufferRow(start.row)..MultiBufferRow(end.row), + buffer_id, + buffer_range: hunk.buffer_range.clone(), + diff_base_byte_range: hunk.diff_base_byte_range.clone(), + } + }), + ) + }) + .flatten() + } + + pub fn diff_hunks_in_range_rev<'a, T: ToOffset>( + &'a self, + range: Range, + buffer_snapshot: &'a MultiBufferSnapshot, + ) -> impl Iterator + 'a { + let range = range.start.to_offset(buffer_snapshot)..range.end.to_offset(buffer_snapshot); + buffer_snapshot + .excerpts_for_range_rev(range.clone()) + .filter_map(move |excerpt| { + let buffer = excerpt.buffer(); + let buffer_id = buffer.remote_id(); + let diff = self.0.get(&buffer_id)?; + let buffer_range = excerpt.map_range_to_buffer(range.clone()); + let buffer_range = + buffer.anchor_before(buffer_range.start)..buffer.anchor_after(buffer_range.end); + Some( + diff.hunks_intersecting_range_rev(buffer_range, excerpt.buffer()) + .map(move |hunk| { + let start_row = excerpt + .map_point_from_buffer(Point::new(hunk.row_range.start, 0)) + .row; + let end_row = excerpt + .map_point_from_buffer(Point::new(hunk.row_range.end, 0)) + .row; + MultiBufferDiffHunk { + row_range: MultiBufferRow(start_row)..MultiBufferRow(end_row), + buffer_id, + buffer_range: hunk.buffer_range.clone(), + diff_base_byte_range: hunk.diff_base_byte_range.clone(), + } + }), + ) + }) + .flatten() + } +} + impl Editor { pub fn set_expand_all_diff_hunks(&mut self) { - self.expanded_hunks.expand_all = true; + self.diff_map.expand_all = true; } pub(super) fn toggle_hovered_hunk( @@ -92,18 +215,15 @@ impl Editor { } pub fn toggle_hunk_diff(&mut self, _: &ToggleHunkDiff, cx: &mut ViewContext) { - let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); - let selections = self.selections.disjoint_anchors(); - self.toggle_hunks_expanded( - hunks_for_selections(&multi_buffer_snapshot, &selections), - cx, - ); + let snapshot = self.snapshot(cx); + let selections = self.selections.all(cx); + self.toggle_hunks_expanded(hunks_for_selections(&snapshot, &selections), cx); } pub fn expand_all_hunk_diffs(&mut self, _: &ExpandAllHunkDiffs, cx: &mut ViewContext) { let snapshot = self.snapshot(cx); let display_rows_with_expanded_hunks = self - .expanded_hunks + .diff_map .hunks(false) .map(|hunk| &hunk.hunk_range) .map(|anchor_range| { @@ -119,10 +239,10 @@ impl Editor { ) }) .collect::>(); - let hunks = snapshot - .display_snapshot - .buffer_snapshot - .git_diff_hunks_in_range(MultiBufferRow::MIN..MultiBufferRow::MAX) + let hunks = self + .diff_map + .snapshot + .diff_hunks(&snapshot.display_snapshot.buffer_snapshot) .filter(|hunk| { let hunk_display_row_range = Point::new(hunk.row_range.start.0, 0) .to_display_point(&snapshot.display_snapshot) @@ -140,11 +260,11 @@ impl Editor { hunks_to_toggle: Vec, cx: &mut ViewContext, ) { - if self.expanded_hunks.expand_all { + if self.diff_map.expand_all { return; } - let previous_toggle_task = self.expanded_hunks.hunk_update_tasks.remove(&None); + let previous_toggle_task = self.diff_map.hunk_update_tasks.remove(&None); let new_toggle_task = cx.spawn(move |editor, mut cx| async move { if let Some(task) = previous_toggle_task { task.await; @@ -154,11 +274,10 @@ impl Editor { .update(&mut cx, |editor, cx| { let snapshot = editor.snapshot(cx); let mut hunks_to_toggle = hunks_to_toggle.into_iter().fuse().peekable(); - let mut highlights_to_remove = - Vec::with_capacity(editor.expanded_hunks.hunks.len()); + let mut highlights_to_remove = Vec::with_capacity(editor.diff_map.hunks.len()); let mut blocks_to_remove = HashSet::default(); let mut hunks_to_expand = Vec::new(); - editor.expanded_hunks.hunks.retain(|expanded_hunk| { + editor.diff_map.hunks.retain(|expanded_hunk| { if expanded_hunk.folded { return true; } @@ -238,7 +357,7 @@ impl Editor { .ok(); }); - self.expanded_hunks + self.diff_map .hunk_update_tasks .insert(None, cx.background_executor().spawn(new_toggle_task)); } @@ -252,30 +371,34 @@ impl Editor { let buffer = self.buffer.clone(); let multi_buffer_snapshot = buffer.read(cx).snapshot(cx); let hunk_range = hunk.multi_buffer_range.clone(); - let (diff_base_buffer, deleted_text_lines) = buffer.update(cx, |buffer, cx| { - let buffer = buffer.buffer(hunk_range.start.buffer_id?)?; - let diff_base_buffer = diff_base_buffer - .or_else(|| self.current_diff_base_buffer(&buffer, cx)) - .or_else(|| create_diff_base_buffer(&buffer, cx))?; - let deleted_text_lines = buffer.read(cx).diff_base().map(|diff_base| { - let diff_start_row = diff_base - .offset_to_point(hunk.diff_base_byte_range.start) - .row; - let diff_end_row = diff_base.offset_to_point(hunk.diff_base_byte_range.end).row; - diff_end_row - diff_start_row - })?; - Some((diff_base_buffer, deleted_text_lines)) + let buffer_id = hunk_range.start.buffer_id?; + let diff_base_buffer = diff_base_buffer.or_else(|| { + self.diff_map + .diff_bases + .get(&buffer_id)? + .change_set + .read(cx) + .base_text + .clone() })?; - let block_insert_index = match self.expanded_hunks.hunks.binary_search_by(|probe| { - probe - .hunk_range - .start - .cmp(&hunk_range.start, &multi_buffer_snapshot) - }) { - Ok(_already_present) => return None, - Err(ix) => ix, - }; + let diff_base = diff_base_buffer.read(cx); + let diff_start_row = diff_base + .offset_to_point(hunk.diff_base_byte_range.start) + .row; + let diff_end_row = diff_base.offset_to_point(hunk.diff_base_byte_range.end).row; + let deleted_text_lines = diff_end_row - diff_start_row; + + let block_insert_index = self + .diff_map + .hunks + .binary_search_by(|probe| { + probe + .hunk_range + .start + .cmp(&hunk_range.start, &multi_buffer_snapshot) + }) + .err()?; let blocks; match hunk.status { @@ -315,7 +438,7 @@ impl Editor { ); } }; - self.expanded_hunks.hunks.insert( + self.diff_map.hunks.insert( block_insert_index, ExpandedHunk { blocks, @@ -374,8 +497,8 @@ impl Editor { _: &ApplyDiffHunk, cx: &mut ViewContext, ) { - let snapshot = self.buffer.read(cx).snapshot(cx); - let hunks = hunks_for_selections(&snapshot, &self.selections.disjoint_anchors()); + let snapshot = self.snapshot(cx); + let hunks = hunks_for_selections(&snapshot, &self.selections.all(cx)); let mut ranges_by_buffer = HashMap::default(); self.transact(cx, |editor, cx| { for hunk in hunks { @@ -399,6 +522,12 @@ impl Editor { } } + fn has_multiple_hunks(&self, cx: &AppContext) -> bool { + let snapshot = self.buffer.read(cx).snapshot(cx); + let mut hunks = self.diff_map.snapshot.diff_hunks(&snapshot); + hunks.nth(1).is_some() + } + fn hunk_header_block( &self, hunk: &HoveredHunk, @@ -409,7 +538,7 @@ impl Editor { .read(cx) .point_to_buffer_offset(hunk.multi_buffer_range.start, cx) .map_or(false, |(buffer, _, _)| { - buffer.read(cx).diff_base_buffer().is_some() + buffer.read(cx).base_buffer().is_some() }); let border_color = cx.theme().colors().border_variant; @@ -428,6 +557,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 = @@ -471,6 +601,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| { @@ -499,6 +630,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| { @@ -543,29 +675,9 @@ impl Editor { let editor = editor.clone(); let hunk = hunk.clone(); move |_event, cx| { - let multi_buffer = - editor.read(cx).buffer().clone(); - let multi_buffer_snapshot = - multi_buffer.read(cx).snapshot(cx); - let mut revert_changes = HashMap::default(); - if let Some(hunk) = - crate::hunk_diff::to_diff_hunk( - &hunk, - &multi_buffer_snapshot, - ) - { - Editor::prepare_revert_change( - &mut revert_changes, - &multi_buffer, - &hunk, - cx, - ); - } - if !revert_changes.is_empty() { - editor.update(cx, |editor, cx| { - editor.revert(revert_changes, cx) - }); - } + editor.update(cx, |editor, cx| { + editor.revert_hunk(hunk.clone(), cx); + }); } }), ) @@ -754,13 +866,13 @@ impl Editor { } pub(super) fn clear_expanded_diff_hunks(&mut self, cx: &mut ViewContext<'_, Editor>) -> bool { - if self.expanded_hunks.expand_all { + if self.diff_map.expand_all { return false; } - self.expanded_hunks.hunk_update_tasks.clear(); + self.diff_map.hunk_update_tasks.clear(); self.clear_row_highlights::(); let to_remove = self - .expanded_hunks + .diff_map .hunks .drain(..) .flat_map(|expanded_hunk| expanded_hunk.blocks.into_iter()) @@ -774,48 +886,39 @@ impl Editor { } pub(super) fn sync_expanded_diff_hunks( - &mut self, - buffer: Model, + diff_map: &mut DiffMap, + buffer_id: BufferId, cx: &mut ViewContext<'_, Self>, ) { - let buffer_id = buffer.read(cx).remote_id(); - let buffer_diff_base_version = buffer.read(cx).diff_base_version(); - self.expanded_hunks - .hunk_update_tasks - .remove(&Some(buffer_id)); - let diff_base_buffer = self.current_diff_base_buffer(&buffer, cx); + let diff_base_state = diff_map.diff_bases.get_mut(&buffer_id); + let mut diff_base_buffer = None; + let mut diff_base_buffer_unchanged = true; + if let Some(diff_base_state) = diff_base_state { + diff_base_state.change_set.update(cx, |change_set, _| { + if diff_base_state.last_version != Some(change_set.base_text_version) { + diff_base_state.last_version = Some(change_set.base_text_version); + diff_base_buffer_unchanged = false; + } + diff_base_buffer = change_set.base_text.clone(); + }) + } + + diff_map.hunk_update_tasks.remove(&Some(buffer_id)); + let new_sync_task = cx.spawn(move |editor, mut cx| async move { - let diff_base_buffer_unchanged = diff_base_buffer.is_some(); - let Ok(diff_base_buffer) = - cx.update(|cx| diff_base_buffer.or_else(|| create_diff_base_buffer(&buffer, cx))) - else { - return; - }; editor .update(&mut cx, |editor, cx| { - if let Some(diff_base_buffer) = &diff_base_buffer { - editor.expanded_hunks.diff_base.insert( - buffer_id, - DiffBaseBuffer { - buffer: diff_base_buffer.clone(), - diff_base_version: buffer_diff_base_version, - }, - ); - } - let snapshot = editor.snapshot(cx); let mut recalculated_hunks = snapshot - .buffer_snapshot - .git_diff_hunks_in_range(MultiBufferRow::MIN..MultiBufferRow::MAX) + .diff_map + .diff_hunks(&snapshot.buffer_snapshot) .filter(|hunk| hunk.buffer_id == buffer_id) .fuse() .peekable(); - let mut highlights_to_remove = - Vec::with_capacity(editor.expanded_hunks.hunks.len()); + let mut highlights_to_remove = Vec::with_capacity(editor.diff_map.hunks.len()); let mut blocks_to_remove = HashSet::default(); - let mut hunks_to_reexpand = - Vec::with_capacity(editor.expanded_hunks.hunks.len()); - editor.expanded_hunks.hunks.retain_mut(|expanded_hunk| { + let mut hunks_to_reexpand = Vec::with_capacity(editor.diff_map.hunks.len()); + editor.diff_map.hunks.retain_mut(|expanded_hunk| { if expanded_hunk.hunk_range.start.buffer_id != Some(buffer_id) { return true; }; @@ -865,7 +968,7 @@ impl Editor { > hunk_display_range.end { recalculated_hunks.next(); - if editor.expanded_hunks.expand_all { + if editor.diff_map.expand_all { hunks_to_reexpand.push(HoveredHunk { status, multi_buffer_range, @@ -908,7 +1011,7 @@ impl Editor { retain }); - if editor.expanded_hunks.expand_all { + if editor.diff_map.expand_all { for hunk in recalculated_hunks { match diff_hunk_to_display(&hunk, &snapshot) { DisplayDiffHunk::Folded { .. } => {} @@ -926,6 +1029,8 @@ impl Editor { } } } + } else { + drop(recalculated_hunks); } editor.remove_highlighted_rows::(highlights_to_remove, cx); @@ -940,32 +1045,12 @@ impl Editor { .ok(); }); - self.expanded_hunks.hunk_update_tasks.insert( + diff_map.hunk_update_tasks.insert( Some(buffer_id), cx.background_executor().spawn(new_sync_task), ); } - fn current_diff_base_buffer( - &mut self, - buffer: &Model, - cx: &mut AppContext, - ) -> Option> { - buffer.update(cx, |buffer, _| { - match self.expanded_hunks.diff_base.entry(buffer.remote_id()) { - hash_map::Entry::Occupied(o) => { - if o.get().diff_base_version != buffer.diff_base_version() { - o.remove(); - None - } else { - Some(o.get().buffer.clone()) - } - } - hash_map::Entry::Vacant(_) => None, - } - }) - } - fn go_to_subsequent_hunk(&mut self, position: Anchor, cx: &mut ViewContext) { let snapshot = self.snapshot(cx); let position = position.to_point(&snapshot.buffer_snapshot); @@ -1012,7 +1097,7 @@ impl Editor { } } -fn to_diff_hunk( +pub(crate) fn to_diff_hunk( hovered_hunk: &HoveredHunk, multi_buffer_snapshot: &MultiBufferSnapshot, ) -> Option { @@ -1034,24 +1119,6 @@ fn to_diff_hunk( }) } -fn create_diff_base_buffer(buffer: &Model, cx: &mut AppContext) -> Option> { - buffer - .update(cx, |buffer, _| { - let language = buffer.language().cloned(); - let diff_base = buffer.diff_base()?.clone(); - Some((buffer.line_ending(), diff_base, language)) - }) - .map(|(line_ending, diff_base, language)| { - cx.new_model(|cx| { - let buffer = Buffer::local_normalized(diff_base, line_ending, cx); - match language { - Some(language) => buffer.with_language(language, cx), - None => buffer, - } - }) - }) -} - fn added_hunk_color(cx: &AppContext) -> Hsla { let mut created_color = cx.theme().status().git().created; created_color.fade_out(0.7); @@ -1109,51 +1176,27 @@ fn editor_with_deleted_text( }); })]); - let original_multi_buffer_range = hunk.multi_buffer_range.clone(); - let diff_base_range = hunk.diff_base_byte_range.clone(); editor .register_action::({ + let hunk = hunk.clone(); let parent_editor = parent_editor.clone(); move |_, cx| { parent_editor - .update(cx, |editor, cx| { - let Some((buffer, original_text)) = - editor.buffer().update(cx, |buffer, cx| { - let (_, buffer, _) = buffer.excerpt_containing( - original_multi_buffer_range.start, - cx, - )?; - let original_text = - buffer.read(cx).diff_base()?.slice(diff_base_range.clone()); - Some((buffer, Arc::from(original_text.to_string()))) - }) - else { - return; - }; - buffer.update(cx, |buffer, cx| { - buffer.edit( - Some(( - original_multi_buffer_range.start.text_anchor - ..original_multi_buffer_range.end.text_anchor, - original_text, - )), - None, - cx, - ) - }); - }) + .update(cx, |editor, cx| editor.revert_hunk(hunk.clone(), cx)) .ok(); } }) .detach(); - let hunk = hunk.clone(); editor - .register_action::(move |_, cx| { - parent_editor - .update(cx, |editor, cx| { - editor.toggle_hovered_hunk(&hunk, cx); - }) - .ok(); + .register_action::({ + let hunk = hunk.clone(); + move |_, cx| { + parent_editor + .update(cx, |editor, cx| { + editor.toggle_hovered_hunk(&hunk, cx); + }) + .ok(); + } }) .detach(); editor @@ -1263,78 +1306,57 @@ mod tests { let project = Project::test(fs, [], cx).await; // buffer has two modified hunks with two rows each - let buffer_1 = project.update(cx, |project, cx| { - project.create_local_buffer( - " - 1.zero - 1.ONE - 1.TWO - 1.three - 1.FOUR - 1.FIVE - 1.six - " - .unindent() - .as_str(), - None, - cx, - ) - }); - buffer_1.update(cx, |buffer, cx| { - buffer.set_diff_base( - Some( - " - 1.zero - 1.one - 1.two - 1.three - 1.four - 1.five - 1.six - " - .unindent(), - ), - cx, - ); - }); + let diff_base_1 = " + 1.zero + 1.one + 1.two + 1.three + 1.four + 1.five + 1.six + " + .unindent(); + + let text_1 = " + 1.zero + 1.ONE + 1.TWO + 1.three + 1.FOUR + 1.FIVE + 1.six + " + .unindent(); // buffer has a deletion hunk and an insertion hunk - let buffer_2 = project.update(cx, |project, cx| { - project.create_local_buffer( - " - 2.zero - 2.one - 2.two - 2.three - 2.four - 2.five - 2.six - " - .unindent() - .as_str(), - None, - cx, - ) - }); - buffer_2.update(cx, |buffer, cx| { - buffer.set_diff_base( - Some( - " - 2.zero - 2.one - 2.one-and-a-half - 2.two - 2.three - 2.four - 2.six - " - .unindent(), - ), - cx, - ); - }); + let diff_base_2 = " + 2.zero + 2.one + 2.one-and-a-half + 2.two + 2.three + 2.four + 2.six + " + .unindent(); - cx.background_executor.run_until_parked(); + let text_2 = " + 2.zero + 2.one + 2.two + 2.three + 2.four + 2.five + 2.six + " + .unindent(); + + let buffer_1 = project.update(cx, |project, cx| { + project.create_local_buffer(text_1.as_str(), None, cx) + }); + let buffer_2 = project.update(cx, |project, cx| { + project.create_local_buffer(text_2.as_str(), None, cx) + }); let multibuffer = cx.new_model(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); @@ -1383,10 +1405,30 @@ mod tests { multibuffer }); - let snapshot = multibuffer.read_with(cx, |b, cx| b.snapshot(cx)); + let editor = cx.add_window(|cx| Editor::for_multibuffer(multibuffer, None, false, cx)); + editor + .update(cx, |editor, cx| { + for (buffer, diff_base) in [ + (buffer_1.clone(), diff_base_1), + (buffer_2.clone(), diff_base_2), + ] { + let change_set = cx.new_model(|cx| { + BufferChangeSet::new_with_base_text( + diff_base.to_string(), + buffer.read(cx).text_snapshot(), + cx, + ) + }); + editor.diff_map.add_change_set(change_set, cx) + } + }) + .unwrap(); + cx.background_executor.run_until_parked(); + + let snapshot = editor.update(cx, |editor, cx| editor.snapshot(cx)).unwrap(); assert_eq!( - snapshot.text(), + snapshot.buffer_snapshot.text(), " 1.zero 1.ONE @@ -1429,7 +1471,8 @@ mod tests { assert_eq!( snapshot - .git_diff_hunks_in_range(MultiBufferRow(0)..MultiBufferRow(12)) + .diff_map + .diff_hunks_in_range(Point::zero()..Point::new(12, 0), &snapshot.buffer_snapshot) .map(|hunk| (hunk_status(&hunk), hunk.row_range)) .collect::>(), &expected, @@ -1437,7 +1480,11 @@ mod tests { assert_eq!( snapshot - .git_diff_hunks_in_range_rev(MultiBufferRow(0)..MultiBufferRow(12)) + .diff_map + .diff_hunks_in_range_rev( + Point::zero()..Point::new(12, 0), + &snapshot.buffer_snapshot + ) .map(|hunk| (hunk_status(&hunk), hunk.row_range)) .collect::>(), expected diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlay_hint_cache.rs index 877f02eefe..8b2358c6b4 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 51ad9b9dec..298ef5a3f0 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) @@ -737,7 +737,7 @@ impl Item for Editor { let buffers = self.buffer().clone().read(cx).all_buffers(); let buffers = buffers .into_iter() - .map(|handle| handle.read(cx).diff_base_buffer().unwrap_or(handle.clone())) + .map(|handle| handle.read(cx).base_buffer().unwrap_or(handle.clone())) .collect::>(); cx.spawn(|this, mut cx| async move { if format { @@ -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/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 9abf4d990c..6861d424ec 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -20,8 +20,7 @@ pub enum MenuPosition { /// Disappears when the position is no longer visible. PinnedToEditor { source: multi_buffer::Anchor, - offset_x: Pixels, - offset_y: Pixels, + offset: Point, }, } @@ -48,36 +47,22 @@ impl MouseContextMenu { context_menu: View, cx: &mut ViewContext, ) -> Option { - let context_menu_focus = context_menu.focus_handle(cx); - cx.focus(&context_menu_focus); - - let _subscription = cx.subscribe( - &context_menu, - move |editor, _, _event: &DismissEvent, cx| { - editor.mouse_context_menu.take(); - if context_menu_focus.contains_focused(cx) { - editor.focus(cx); - } - }, - ); - let editor_snapshot = editor.snapshot(cx); - let source_point = editor.to_pixel_point(source, &editor_snapshot, cx)?; - let offset = position - source_point; - - Some(Self { - position: MenuPosition::PinnedToEditor { - source, - offset_x: offset.x, - offset_y: offset.y, - }, - context_menu, - _subscription, - }) + let content_origin = editor.last_bounds?.origin + + Point { + x: editor.gutter_dimensions.width, + y: Pixels(0.0), + }; + let source_position = editor.to_pixel_point(source, &editor_snapshot, cx)?; + let menu_position = MenuPosition::PinnedToEditor { + source, + offset: position - (source_position + content_origin), + }; + return Some(MouseContextMenu::new(menu_position, context_menu, cx)); } - pub(crate) fn pinned_to_screen( - position: Point, + pub(crate) fn new( + position: MenuPosition, context_menu: View, cx: &mut ViewContext, ) -> Self { @@ -95,7 +80,7 @@ impl MouseContextMenu { ); Self { - position: MenuPosition::PinnedToScreen(position), + position, context_menu, _subscription, } @@ -119,7 +104,7 @@ fn display_ranges<'a>( pub fn deploy_context_menu( editor: &mut Editor, - position: Point, + position: Option>, point: DisplayPoint, cx: &mut ViewContext, ) { @@ -213,8 +198,18 @@ pub fn deploy_context_menu( }) }; - editor.mouse_context_menu = - MouseContextMenu::pinned_to_editor(editor, source_anchor, position, context_menu, cx); + editor.mouse_context_menu = match position { + Some(position) => { + MouseContextMenu::pinned_to_editor(editor, source_anchor, position, context_menu, cx) + } + None => { + let menu_position = MenuPosition::PinnedToEditor { + source: source_anchor, + offset: editor.character_size(cx), + }; + Some(MouseContextMenu::new(menu_position, context_menu, cx)) + } + }; cx.notify(); } @@ -248,7 +243,9 @@ mod tests { } "}); cx.editor(|editor, _app| assert!(editor.mouse_context_menu.is_none())); - cx.update_editor(|editor, cx| deploy_context_menu(editor, Default::default(), point, cx)); + cx.update_editor(|editor, cx| { + deploy_context_menu(editor, Some(Default::default()), point, cx) + }); cx.assert_editor_state(indoc! {" fn test() { diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 52bedde2e3..8fbf0d16f1 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -2,7 +2,7 @@ //! in editor given a given motion (e.g. it handles converting a "move left" command into coordinates in editor). It is exposed mostly for use by vim crate. use super::{Bias, DisplayPoint, DisplaySnapshot, SelectionGoal, ToDisplayPoint}; -use crate::{scroll::ScrollAnchor, CharKind, DisplayRow, EditorStyle, RowExt, ToOffset, ToPoint}; +use crate::{scroll::ScrollAnchor, CharKind, DisplayRow, EditorStyle, ToOffset, ToPoint}; use gpui::{Pixels, WindowTextSystem}; use language::Point; use multi_buffer::{MultiBufferRow, MultiBufferSnapshot}; @@ -382,12 +382,12 @@ pub fn end_of_paragraph( mut count: usize, ) -> DisplayPoint { let point = display_point.to_point(map); - if point.row == map.max_buffer_row().0 { + if point.row == map.buffer_snapshot.max_row().0 { return map.max_point(); } let mut found_non_blank_line = false; - for row in point.row..map.max_buffer_row().next_row().0 { + for row in point.row..=map.buffer_snapshot.max_row().0 { let blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(row)); if found_non_blank_line && blank { if count <= 1 { @@ -488,6 +488,101 @@ pub fn find_boundary_point( map.clip_point(offset.to_display_point(map), Bias::Right) } +pub fn find_preceding_boundary_trail( + map: &DisplaySnapshot, + head: DisplayPoint, + mut is_boundary: impl FnMut(char, char) -> bool, +) -> (Option, DisplayPoint) { + let mut offset = head.to_offset(map, Bias::Left); + let mut trail_offset = None; + + let mut prev_ch = map.buffer_snapshot.chars_at(offset).next(); + let mut forward = map.buffer_snapshot.reversed_chars_at(offset).peekable(); + + // Skip newlines + while let Some(&ch) = forward.peek() { + if ch == '\n' { + prev_ch = forward.next(); + offset -= ch.len_utf8(); + trail_offset = Some(offset); + } else { + break; + } + } + + // Find the boundary + let start_offset = offset; + for ch in forward { + if let Some(prev_ch) = prev_ch { + if is_boundary(prev_ch, ch) { + if start_offset == offset { + trail_offset = Some(offset); + } else { + break; + } + } + } + offset -= ch.len_utf8(); + prev_ch = Some(ch); + } + + let trail = trail_offset + .map(|trail_offset: usize| map.clip_point(trail_offset.to_display_point(map), Bias::Left)); + + ( + trail, + map.clip_point(offset.to_display_point(map), Bias::Left), + ) +} + +/// Finds the location of a boundary +pub fn find_boundary_trail( + map: &DisplaySnapshot, + head: DisplayPoint, + mut is_boundary: impl FnMut(char, char) -> bool, +) -> (Option, DisplayPoint) { + let mut offset = head.to_offset(map, Bias::Right); + let mut trail_offset = None; + + let mut prev_ch = map.buffer_snapshot.reversed_chars_at(offset).next(); + let mut forward = map.buffer_snapshot.chars_at(offset).peekable(); + + // Skip newlines + while let Some(&ch) = forward.peek() { + if ch == '\n' { + prev_ch = forward.next(); + offset += ch.len_utf8(); + trail_offset = Some(offset); + } else { + break; + } + } + + // Find the boundary + let start_offset = offset; + for ch in forward { + if let Some(prev_ch) = prev_ch { + if is_boundary(prev_ch, ch) { + if start_offset == offset { + trail_offset = Some(offset); + } else { + break; + } + } + } + offset += ch.len_utf8(); + prev_ch = Some(ch); + } + + let trail = trail_offset + .map(|trail_offset: usize| map.clip_point(trail_offset.to_display_point(map), Bias::Right)); + + ( + trail, + map.clip_point(offset.to_display_point(map), Bias::Right), + ) +} + pub fn find_boundary( map: &DisplaySnapshot, from: DisplayPoint, diff --git a/crates/editor/src/proposed_changes_editor.rs b/crates/editor/src/proposed_changes_editor.rs index ac97fe18da..f4934c32b0 100644 --- a/crates/editor/src/proposed_changes_editor.rs +++ b/crates/editor/src/proposed_changes_editor.rs @@ -4,7 +4,7 @@ use futures::{channel::mpsc, future::join_all}; use gpui::{AppContext, EventEmitter, FocusableView, Model, Render, Subscription, Task, View}; use language::{Buffer, BufferEvent, Capability}; use multi_buffer::{ExcerptRange, MultiBuffer}; -use project::Project; +use project::{buffer_store::BufferChangeSet, Project}; use smol::stream::StreamExt; use std::{any::TypeId, ops::Range, rc::Rc, time::Duration}; use text::ToOffset; @@ -75,7 +75,7 @@ impl ProposedChangesEditor { title: title.into(), buffer_entries: Vec::new(), recalculate_diffs_tx, - _recalculate_diffs_task: cx.spawn(|_, mut cx| async move { + _recalculate_diffs_task: cx.spawn(|this, mut cx| async move { let mut buffers_to_diff = HashSet::default(); while let Some(mut recalculate_diff) = recalculate_diffs_rx.next().await { buffers_to_diff.insert(recalculate_diff.buffer); @@ -96,12 +96,37 @@ impl ProposedChangesEditor { } } - join_all(buffers_to_diff.drain().filter_map(|buffer| { - buffer - .update(&mut cx, |buffer, cx| buffer.recalculate_diff(cx)) - .ok()? - })) - .await; + let recalculate_diff_futures = this + .update(&mut cx, |this, cx| { + buffers_to_diff + .drain() + .filter_map(|buffer| { + let buffer = buffer.read(cx); + let base_buffer = buffer.base_buffer()?; + let buffer = buffer.text_snapshot(); + let change_set = this.editor.update(cx, |editor, _| { + Some( + editor + .diff_map + .diff_bases + .get(&buffer.remote_id())? + .change_set + .clone(), + ) + })?; + Some(change_set.update(cx, |change_set, cx| { + change_set.set_base_text( + base_buffer.read(cx).text(), + buffer, + cx, + ) + })) + }) + .collect::>() + }) + .ok()?; + + join_all(recalculate_diff_futures).await; } None }), @@ -154,6 +179,7 @@ impl ProposedChangesEditor { }); let mut buffer_entries = Vec::new(); + let mut new_change_sets = Vec::new(); for location in locations { let branch_buffer; if let Some(ix) = self @@ -166,6 +192,15 @@ impl ProposedChangesEditor { buffer_entries.push(entry); } else { branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx)); + new_change_sets.push(cx.new_model(|cx| { + let mut change_set = BufferChangeSet::new(branch_buffer.read(cx)); + let _ = change_set.set_base_text( + location.buffer.read(cx).text(), + branch_buffer.read(cx).text_snapshot(), + cx, + ); + change_set + })); buffer_entries.push(BufferEntry { branch: branch_buffer.clone(), base: location.buffer.clone(), @@ -187,7 +222,10 @@ impl ProposedChangesEditor { self.buffer_entries = buffer_entries; self.editor.update(cx, |editor, cx| { - editor.change_selections(None, cx, |selections| selections.refresh()) + editor.change_selections(None, cx, |selections| selections.refresh()); + for change_set in new_change_sets { + editor.diff_map.add_change_set(change_set, cx) + } }); } @@ -217,14 +255,14 @@ impl ProposedChangesEditor { }) .ok(); } - BufferEvent::DiffBaseChanged => { - self.recalculate_diffs_tx - .unbounded_send(RecalculateDiff { - buffer, - debounce: false, - }) - .ok(); - } + // BufferEvent::DiffBaseChanged => { + // self.recalculate_diffs_tx + // .unbounded_send(RecalculateDiff { + // buffer, + // debounce: false, + // }) + // .ok(); + // } _ => (), } } @@ -373,7 +411,7 @@ impl BranchBufferSemanticsProvider { positions: &[text::Anchor], cx: &AppContext, ) -> Option> { - let base_buffer = buffer.read(cx).diff_base_buffer()?; + let base_buffer = buffer.read(cx).base_buffer()?; let version = base_buffer.read(cx).version(); if positions .iter() diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index 0384ed065b..fd890b839d 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, @@ -72,7 +113,15 @@ impl EditorLspTestContext { app_state .fs .as_fake() - .insert_tree(root, json!({ "dir": { file_name.clone(): "" }})) + .insert_tree( + root, + json!({ + ".git": {}, + "dir": { + file_name.clone(): "" + } + }), + ) .await; let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); @@ -119,46 +168,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/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index de5065d265..11b14e8122 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -42,16 +42,16 @@ pub struct EditorTestContext { impl EditorTestContext { pub async fn new(cx: &mut gpui::TestAppContext) -> EditorTestContext { let fs = FakeFs::new(cx.executor()); - // fs.insert_file("/file", "".to_owned()).await; let root = Self::root_path(); fs.insert_tree( root, serde_json::json!({ + ".git": {}, "file": "", }), ) .await; - let project = Project::test(fs, [root], cx).await; + let project = Project::test(fs.clone(), [root], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(root.join("file"), cx) @@ -65,6 +65,8 @@ impl EditorTestContext { editor }); let editor_view = editor.root_view(cx).unwrap(); + + cx.run_until_parked(); Self { cx: VisualTestContext::from_window(*editor.deref(), cx), window: editor.into(), @@ -276,8 +278,16 @@ impl EditorTestContext { snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end) } - pub fn set_diff_base(&mut self, diff_base: Option<&str>) { - self.update_buffer(|buffer, cx| buffer.set_diff_base(diff_base.map(ToOwned::to_owned), cx)); + pub fn set_diff_base(&mut self, diff_base: &str) { + self.cx.run_until_parked(); + let fs = self + .update_editor(|editor, cx| editor.project.as_ref().unwrap().read(cx).fs().as_fake()); + let path = self.update_buffer(|buffer, _| buffer.file().unwrap().path().clone()); + fs.set_index_for_repo( + &Self::root_path().join(".git"), + &[(path.as_ref(), diff_base.to_string())], + ); + self.cx.run_until_parked(); } /// Change the editor's text and selections using a string containing @@ -319,10 +329,12 @@ impl EditorTestContext { state_context } + /// Assert about the text of the editor, the selections, and the expanded + /// diff hunks. + /// + /// Diff hunks are indicated by lines starting with `+` and `-`. #[track_caller] - pub fn assert_diff_hunks(&mut self, expected_diff: String) { - // Normalize the expected diff. If it has no diff markers, then insert blank markers - // before each line. Strip any whitespace-only lines. + pub fn assert_state_with_diff(&mut self, expected_diff: String) { let has_diff_markers = expected_diff .lines() .any(|line| line.starts_with("+") || line.starts_with("-")); @@ -340,11 +352,14 @@ impl EditorTestContext { }) .join("\n"); + let actual_selections = self.editor_selections(); + let actual_marked_text = + generate_marked_text(&self.buffer_text(), &actual_selections, true); + // Read the actual diff from the editor's row highlights and block // decorations. let actual_diff = self.editor.update(&mut self.cx, |editor, cx| { let snapshot = editor.snapshot(cx); - let text = editor.text(cx); let insertions = editor .highlighted_rows::() .map(|(range, _)| { @@ -354,7 +369,7 @@ impl EditorTestContext { }) .collect::>(); let deletions = editor - .expanded_hunks + .diff_map .hunks .iter() .filter_map(|hunk| { @@ -371,10 +386,20 @@ impl EditorTestContext { .read(cx) .excerpt_containing(hunk.hunk_range.start, cx) .expect("no excerpt for expanded buffer's hunk start"); - let deleted_text = buffer - .read(cx) - .diff_base() + let buffer_id = buffer.read(cx).remote_id(); + let change_set = &editor + .diff_map + .diff_bases + .get(&buffer_id) .expect("should have a diff base for expanded hunk") + .change_set; + let deleted_text = change_set + .read(cx) + .base_text + .as_ref() + .expect("no base text for expanded hunk") + .read(cx) + .as_rope() .slice(hunk.diff_base_byte_range.clone()) .to_string(); if let DiffHunkStatus::Modified | DiffHunkStatus::Removed = hunk.status { @@ -384,7 +409,7 @@ impl EditorTestContext { } }) .collect::>(); - format_diff(text, deletions, insertions) + format_diff(actual_marked_text, deletions, insertions) }); pretty_assertions::assert_eq!(actual_diff, expected_diff_text, "unexpected diff state"); diff --git a/crates/extension/src/extension_host_proxy.rs b/crates/extension/src/extension_host_proxy.rs index 8909a6082d..3fa35597a8 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/Cargo.toml b/crates/extension_host/Cargo.toml index 6e78654b7e..53971ade0a 100644 --- a/crates/extension_host/Cargo.toml +++ b/crates/extension_host/Cargo.toml @@ -22,7 +22,7 @@ async-tar.workspace = true async-trait.workspace = true client.workspace = true collections.workspace = true -context_servers.workspace = true +context_server_settings.workspace = true extension.workspace = true fs.workspace = true futures.workspace = true diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index aab5c258f5..7ceb1fa714 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 1359b5b202..8b5a2a7821 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 19a574b9d4..687f05db47 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/extension_host/src/wasm_host/wit/since_v0_2_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs index f7e11e1032..b722d7b235 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs @@ -7,7 +7,7 @@ use anyhow::{anyhow, bail, Context, Result}; use async_compression::futures::bufread::GzipDecoder; use async_tar::Archive; use async_trait::async_trait; -use context_servers::manager::ContextServerSettings; +use context_server_settings::ContextServerSettings; use extension::{ ExtensionLanguageServerProxy, KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate, }; diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index eaffdafa41..aef99e6167 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/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index 416971b36e..48e3cc95b2 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -49,6 +49,16 @@ impl FeatureFlag for Assistant2FeatureFlag { } } +pub struct ToolUseFeatureFlag; + +impl FeatureFlag for ToolUseFeatureFlag { + const NAME: &'static str = "assistant-tool-use"; + + fn enabled_for_staff() -> bool { + false + } +} + pub struct Remoting {} impl FeatureFlag for Remoting { const NAME: &'static str = "remoting"; diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 6a758211f8..10cde076e1 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; @@ -648,7 +648,7 @@ impl FileFinderDelegate { cx.subscribe(project, |file_finder, _, event, cx| { match event { project::Event::WorktreeUpdatedEntries(_, _) - | project::Event::WorktreeAdded + | project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => file_finder .picker .update(cx, |picker, cx| picker.refresh(cx)), diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index fc0fae3fe8..17571de76b 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -132,7 +132,7 @@ pub trait Fs: Send + Sync { async fn is_case_sensitive(&self) -> Result; #[cfg(any(test, feature = "test-support"))] - fn as_fake(&self) -> &FakeFs { + fn as_fake(&self) -> Arc { panic!("called as_fake on a real fs"); } } @@ -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(()) } @@ -844,6 +840,7 @@ impl Watcher for RealWatcher { #[cfg(any(test, feature = "test-support"))] pub struct FakeFs { + this: std::sync::Weak, // Use an unfair lock to ensure tests are deterministic. state: Mutex, executor: gpui::BackgroundExecutor, @@ -1026,7 +1023,8 @@ impl FakeFs { pub fn new(executor: gpui::BackgroundExecutor) -> Arc { let (tx, mut rx) = smol::channel::bounded::(10); - let this = Arc::new(Self { + let this = Arc::new_cyclic(|this| Self { + this: this.clone(), executor: executor.clone(), state: Mutex::new(FakeFsState { root: Arc::new(Mutex::new(FakeFsEntry::Dir { @@ -1478,7 +1476,8 @@ struct FakeHandle { #[cfg(any(test, feature = "test-support"))] impl FileHandle for FakeHandle { fn current_path(&self, fs: &Arc) -> Result { - let state = fs.as_fake().state.lock(); + let fs = fs.as_fake(); + let state = fs.state.lock(); let Some(target) = state.moves.get(&self.inode) else { anyhow::bail!("fake fd not moved") }; @@ -1974,8 +1973,8 @@ impl Fs for FakeFs { } #[cfg(any(test, feature = "test-support"))] - fn as_fake(&self) -> &FakeFs { - self + fn as_fake(&self) -> Arc { + self.this.upgrade().unwrap() } } diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 8723e41ce4..d31538353e 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -14,7 +14,6 @@ path = "src/git.rs" [dependencies] anyhow.workspace = true async-trait.workspace = true -clock.workspace = true collections.workspace = true derive_more.workspace = true git2.workspace = true @@ -22,6 +21,7 @@ gpui.workspace = true http_client.workspace = true log.workspace = true parking_lot.workspace = true +regex.workspace = true rope.workspace = true serde.workspace = true smol.workspace = true diff --git a/crates/git/src/diff.rs b/crates/git/src/diff.rs index baad824577..d468603663 100644 --- a/crates/git/src/diff.rs +++ b/crates/git/src/diff.rs @@ -64,23 +64,37 @@ impl sum_tree::Summary for DiffHunkSummary { #[derive(Debug, Clone)] pub struct BufferDiff { - last_buffer_version: Option, tree: SumTree, } impl BufferDiff { pub fn new(buffer: &BufferSnapshot) -> BufferDiff { BufferDiff { - last_buffer_version: None, tree: SumTree::new(buffer), } } + pub async fn build(diff_base: &str, buffer: &text::BufferSnapshot) -> Self { + let mut tree = SumTree::new(buffer); + + let buffer_text = buffer.as_rope().to_string(); + let patch = Self::diff(diff_base, &buffer_text); + + if let Some(patch) = patch { + let mut divergence = 0; + for hunk_index in 0..patch.num_hunks() { + let hunk = Self::process_patch_hunk(&patch, hunk_index, buffer, &mut divergence); + tree.push(hunk, buffer); + } + } + + Self { tree } + } + pub fn is_empty(&self) -> bool { self.tree.is_empty() } - #[cfg(any(test, feature = "test-support"))] pub fn hunks_in_row_range<'a>( &'a self, range: Range, @@ -169,27 +183,11 @@ impl BufferDiff { #[cfg(test)] fn clear(&mut self, buffer: &text::BufferSnapshot) { - self.last_buffer_version = Some(buffer.version().clone()); self.tree = SumTree::new(buffer); } pub async fn update(&mut self, diff_base: &Rope, buffer: &text::BufferSnapshot) { - let mut tree = SumTree::new(buffer); - - let diff_base_text = diff_base.to_string(); - let buffer_text = buffer.as_rope().to_string(); - let patch = Self::diff(&diff_base_text, &buffer_text); - - if let Some(patch) = patch { - let mut divergence = 0; - for hunk_index in 0..patch.num_hunks() { - let hunk = Self::process_patch_hunk(&patch, hunk_index, buffer, &mut divergence); - tree.push(hunk, buffer); - } - } - - self.tree = tree; - self.last_buffer_version = Some(buffer.version().clone()); + *self = Self::build(&diff_base.to_string(), buffer).await; } #[cfg(test)] diff --git a/crates/git/src/remote.rs b/crates/git/src/remote.rs index 430836fcf3..e9814afc51 100644 --- a/crates/git/src/remote.rs +++ b/crates/git/src/remote.rs @@ -1,17 +1,23 @@ +use std::sync::LazyLock; + use derive_more::Deref; +use regex::Regex; use url::Url; /// The URL to a Git remote. #[derive(Debug, PartialEq, Eq, Clone, Deref)] pub struct RemoteUrl(Url); +static USERNAME_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"^[0-9a-zA-Z\-_]+@").expect("Failed to create USERNAME_REGEX")); + impl std::str::FromStr for RemoteUrl { type Err = url::ParseError; fn from_str(input: &str) -> Result { - if input.starts_with("git@") { + if USERNAME_REGEX.is_match(input) { // Rewrite remote URLs like `git@github.com:user/repo.git` to `ssh://git@github.com/user/repo.git` - let ssh_url = input.replacen(':', "/", 1).replace("git@", "ssh://git@"); + let ssh_url = format!("ssh://{}", input.replacen(':', "/", 1)); Ok(RemoteUrl(Url::parse(&ssh_url)?)) } else { Ok(RemoteUrl(Url::parse(input)?)) @@ -40,6 +46,12 @@ mod tests { "github.com", "/octocat/zed.git", ), + ( + "org-000000@github.com:octocat/zed.git", + "ssh", + "github.com", + "/octocat/zed.git", + ), ( "ssh://git@github.com/octocat/zed.git", "ssh", diff --git a/crates/go_to_line/src/cursor_position.rs b/crates/go_to_line/src/cursor_position.rs index 3931cac284..2dc60475d3 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 c848d28eaa..df673ef823 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 347e5502ca..ed523c769a 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 5a015106c7..045372b73c 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(); } _ => (), }; @@ -48,6 +50,7 @@ mod macos { fn generate_dispatch_bindings() { println!("cargo:rustc-link-lib=framework=System"); + println!("cargo:rustc-link-lib=framework=ScreenCaptureKit"); println!("cargo:rerun-if-changed=src/platform/mac/dispatch.h"); let bindings = bindgen::Builder::default() diff --git a/crates/gpui/examples/gif_viewer.rs b/crates/gpui/examples/gif_viewer.rs index 939317cb59..003c0b536b 100644 --- a/crates/gpui/examples/gif_viewer.rs +++ b/crates/gpui/examples/gif_viewer.rs @@ -1,4 +1,4 @@ -use gpui::{div, img, prelude::*, App, AppContext, Window, WindowOptions}; +use gpui::{div, img, prelude::*, App, AppContext, Model, Window, WindowOptions}; struct GifViewerExample { gif_path: std::path::PathBuf, diff --git a/crates/gpui/examples/hello_world.rs b/crates/gpui/examples/hello_world.rs index afd6951a74..415f88edfc 100644 --- a/crates/gpui/examples/hello_world.rs +++ b/crates/gpui/examples/hello_world.rs @@ -1,38 +1,55 @@ use gpui::*; -fn main() { - struct HelloWorld; +struct HelloWorld { + text: SharedString, +} - impl Render for HelloWorld { - fn render( - &mut self, - _model: &Model, - _window: &mut Window, - _cx: &mut AppContext, - ) -> impl IntoElement { - div() - .flex() - .bg(rgb(0x2e7d32)) - .size(Length::Definite(Pixels(300.0).into())) - .justify_center() - .items_center() - .shadow_lg() - .border_1() - .border_color(rgb(0x0000ff)) - .text_xl() - .text_color(rgb(0xffffff)) - .child("Hello, World!") - } +impl Render for HelloWorld { + fn render( + &mut self, + _model: &Model, + _window: &mut Window, + _cx: &mut AppContext, + ) -> impl IntoElement { + div() + .flex() + .flex_col() + .gap_3() + .bg(rgb(0x505050)) + .size(Length::Definite(Pixels(500.0).into())) + .justify_center() + .items_center() + .shadow_lg() + .border_1() + .border_color(rgb(0x0000ff)) + .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)), ..Default::default() }, - |_, _, _| HelloWorld, + |_, _, _| HelloWorld { + text: "World".into(), + }, ) .unwrap(); diff --git a/crates/gpui/examples/image_loading.rs b/crates/gpui/examples/image_loading.rs index 258df6449d..ffeeb6c75b 100644 --- a/crates/gpui/examples/image_loading.rs +++ b/crates/gpui/examples/image_loading.rs @@ -4,8 +4,8 @@ use anyhow::anyhow; use gpui::{ black, div, img, prelude::*, pulsating_between, px, red, size, Animation, AnimationExt, App, AppContext, Asset, AssetLogger, AssetSource, Bounds, Hsla, ImageAssetLoader, ImageCacheError, - ImgResourceLoader, Length, Pixels, RenderImage, Resource, SharedString, Window, WindowBounds, - WindowOptions, LOADING_DELAY, + ImgResourceLoader, Length, Model, Pixels, RenderImage, Resource, SharedString, Window, + WindowBounds, WindowOptions, LOADING_DELAY, }; struct Assets {} diff --git a/crates/gpui/examples/painting.rs b/crates/gpui/examples/painting.rs index 394578f944..ed47c83a57 100644 --- a/crates/gpui/examples/painting.rs +++ b/crates/gpui/examples/painting.rs @@ -1,6 +1,6 @@ use gpui::{ - canvas, div, point, prelude::*, px, size, App, AppContext, Bounds, MouseDownEvent, Path, - Pixels, Point, WindowOptions, + canvas, div, point, prelude::*, px, size, App, AppContext, Bounds, Model, MouseDownEvent, Path, + Pixels, Point, Window, WindowOptions, }; struct PaintingViewer { default_lines: Vec>, diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 24988b9a61..191d3a60be 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -27,13 +27,13 @@ pub use test_context::*; use util::ResultExt; use crate::{ - current_platform, hash, init_app_menus, Action, ActionRegistry, Any, AnyElement, + current_platform, hash, init_app_menus, Action, ActionRegistry, Any, AnyElement, AnyView, AnyWindowHandle, Asset, AssetSource, BackgroundExecutor, ClipboardItem, Context, DispatchPhase, DisplayId, Entity, EventEmitter, FocusHandle, ForegroundExecutor, Global, IntoElement, KeyBinding, Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay, Point, PromptBuilder, PromptLevel, Render, Reservation, - SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextSystem, Window, - WindowAppearance, WindowHandle, WindowId, + ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextSystem, + Window, WindowAppearance, WindowHandle, WindowId, }; mod async_context; @@ -615,6 +615,13 @@ impl AppContext { self.platform.primary_display() } + /// Returns a list of available screen capture sources. + pub fn screen_capture_sources( + &self, + ) -> oneshot::Receiver>>> { + self.platform.screen_capture_sources() + } + /// Returns the display with the given ID, if one exists. pub fn find_display(&self, id: DisplayId) -> Option> { self.displays() @@ -1562,10 +1569,10 @@ impl DerefMut for GlobalLease { /// within the window or by dragging into the app from the underlying platform. pub struct AnyDrag { /// How this drag is displayed on screen - pub render: Box AnyElement>, + 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/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 6bb3be7a42..cc34660f76 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -3,8 +3,9 @@ use crate::{ BackgroundExecutor, BorrowAppContext, Bounds, ClipboardItem, Context, DrawPhase, Drawable, Element, Empty, Entity, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, Model, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - Pixels, Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform, TestWindow, - TextSystem, Window, WindowBounds, WindowHandle, WindowOptions, + Pixels, Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform, + TestScreenCaptureSource, TestWindow, TextSystem, Window, WindowBounds, WindowHandle, + WindowOptions, }; use anyhow::{anyhow, bail}; use futures::{channel::oneshot, Stream, StreamExt}; @@ -282,6 +283,12 @@ impl TestAppContext { self.test_window(window_handle).simulate_resize(size); } + /// Causes the given sources to be returned if the application queries for screen + /// capture sources. + pub fn set_screen_capture_sources(&self, sources: Vec) { + self.test_platform.set_screen_capture_sources(sources); + } + /// Returns all windows open in the test. pub fn windows(&self) -> Vec { self.app.borrow().windows().clone() @@ -509,13 +516,43 @@ impl Model { } } - /// Returns a future that resolves when the model notifies. - pub fn next_notification(&self, cx: &TestAppContext) -> impl Future { + // /// Returns a future that resolves when the model notifies. + // pub fn next_notification(&self, cx: &TestAppContext) -> impl Future { + // use postage::prelude::{Sink as _, Stream as _}; + + // let (mut tx, mut rx) = postage::mpsc::channel(1); + // let mut cx = cx.app.app.borrow_mut(); + // let subscription = cx.observe(self, move |_, _| { + // tx.try_send(()).ok(); + // }); + + // let duration = if std::env::var("CI").is_ok() { + // Duration::from_secs(5) + // } else { + // Duration::from_secs(1) + // }; + + // async move { + // let notification = crate::util::timeout(duration, rx.recv()) + // .await + // .expect("next notification timed out"); + // drop(subscription); + // notification.expect("model dropped while test was waiting for its next notification") + // } + // } +} + +impl Model { + /// Returns a future that resolves when the view is next updated. + pub fn next_notification( + &self, + advance_clock_by: Duration, + cx: &TestAppContext, + ) -> impl Future { use postage::prelude::{Sink as _, Stream as _}; let (mut tx, mut rx) = postage::mpsc::channel(1); - let mut cx = cx.app.app.borrow_mut(); - let subscription = cx.observe(self, move |_, _| { + let subscription = cx.app.app.borrow_mut().observe(self, move |_, _| { tx.try_send(()).ok(); }); @@ -525,6 +562,8 @@ impl Model { Duration::from_secs(1) }; + cx.executor().advance_clock(advance_clock_by); + async move { let notification = crate::util::timeout(duration, rx.recv()) .await @@ -535,33 +574,6 @@ impl Model { } } -// impl View { -// /// Returns a future that resolves when the view is next updated. -// pub fn next_notification(&self, cx: &TestAppContext) -> impl Future { -// use postage::prelude::{Sink as _, Stream as _}; - -// let (mut tx, mut rx) = postage::mpsc::channel(1); -// let mut cx = cx.app.app.borrow_mut(); -// let subscription = cx.observe(self, move |_, _| { -// tx.try_send(()).ok(); -// }); - -// let duration = if std::env::var("CI").is_ok() { -// Duration::from_secs(5) -// } else { -// Duration::from_secs(1) -// }; - -// async move { -// let notification = crate::util::timeout(duration, rx.recv()) -// .await -// .expect("next notification timed out"); -// drop(subscription); -// notification.expect("model dropped while test was waiting for its next notification") -// } -// } -// } - // impl View { // /// Returns a future that resolves when the condition becomes true. // pub fn condition( diff --git a/crates/gpui/src/color.rs b/crates/gpui/src/color.rs index 9c831d0875..04a35e6886 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/element.rs b/crates/gpui/src/element.rs index 2eae168f59..a2831b5757 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -139,7 +139,7 @@ impl Render for Empty { /// A dynamically typed function that renders an AnyElement in a Window. pub type AnyView = Rc AnyElement>; -impl Into AnyElement>> for Model { +impl Into for Model { fn into(self) -> Rc AnyElement> { let this = self.clone(); Rc::new(move |_window, _cx| this.clone().into_any_element()) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 60dd817ef2..ebab765e5a 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -16,12 +16,12 @@ //! constructed by combining these two systems into an all-in-one element. use crate::{ - point, px, size, Action, AnyDrag, AnyElement, AnyTooltip, AppContext, Bounds, ClickEvent, - DispatchPhase, Element, ElementId, FocusHandle, Global, GlobalElementId, Hitbox, HitboxId, - IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, LayoutId, ModifiersChangedEvent, - MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, - ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId, - Visibility, Window, + point, px, size, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, AppContext, Bounds, + ClickEvent, DispatchPhase, Element, ElementId, FocusHandle, Global, GlobalElementId, Hitbox, + HitboxId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, LayoutId, + ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, + ParentElement, Pixels, Point, ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, + Styled, Task, TooltipId, Visibility, Window, }; use collections::HashMap; use refineable::Refineable; @@ -34,6 +34,7 @@ use std::{ marker::PhantomData, mem, rc::Rc, + sync::Arc, time::Duration, }; use taffy::style::Overflow; @@ -60,6 +61,7 @@ pub struct DragMoveEvent { /// The bounds of this element. pub bounds: Bounds, drag: PhantomData, + dragged_item: Arc, } impl DragMoveEvent { @@ -70,6 +72,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 { @@ -246,21 +253,21 @@ impl Interactivity { { self.mouse_move_listeners .push(Box::new(move |event, phase, hitbox, window, 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, - }, - window, - 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), + }, + window, + cx, + ); + } + } } })); } @@ -470,27 +477,22 @@ impl Interactivity { /// The imperative API equivalent to [`StatefulInteractiveElement::on_drag`] /// /// See [`ViewContext::listener`](crate::ViewContext::listener) to get access to a view's state from this callback. - pub fn on_drag( + pub fn on_drag( &mut self, value: T, - listener: impl 'static + Fn(&T, Point, &mut Window, &mut AppContext) -> F, + constructor: impl 'static + Fn(&T, Point, &mut Window, &mut AppContext) -> AnyView, ) where Self: Sized, T: 'static, - F: 'static + Fn(&T, Point, &mut Window, &mut AppContext) -> E, - E: IntoElement, { debug_assert!( self.drag_listener.is_none(), "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, window, cx| { - let renderer = listener(value.downcast_ref().unwrap(), offset, window, cx); - Box::new(move |value, offset, window, cx| { - renderer(value.downcast_ref().unwrap(), offset, window, cx).into_any_element() - }) + constructor(value.downcast_ref().unwrap(), offset, window, cx).into() }), )); } @@ -1032,16 +1034,14 @@ pub trait StatefulInteractiveElement: InteractiveElement { /// The fluent API equivalent to [`Interactivity::on_drag`] /// /// See [`ViewContext::listener`](crate::ViewContext::listener) to get access to a view's state from this callback. - fn on_drag( + fn on_drag( mut self, value: T, - listener: impl Fn(&T, Point, &mut Window, &mut AppContext) -> F + 'static, + listener: impl 'static + Fn(&T, Point, &mut Window, &mut AppContext) -> AnyView, ) -> Self where Self: Sized, T: 'static, - F: Fn(&T, Point, &mut Window, &mut AppContext) -> E + 'static, - E: IntoElement, { self.interactivity().on_drag(value, listener); self @@ -1126,11 +1126,8 @@ pub(crate) type ScrollWheelListener = pub(crate) type ClickListener = Box; -pub(crate) type DragRenderer = - Box, &mut Window, &mut AppContext) -> AnyElement>; - pub(crate) type DragListener = - Box, &mut Window, &mut AppContext) -> DragRenderer>; + Box, &mut Window, &mut AppContext) -> AnyView>; type DropListener = Box; @@ -1378,7 +1375,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, @@ -1924,16 +1921,14 @@ impl Interactivity { if let Some((drag_value, drag_listener)) = drag_listener.take() { *clicked_state.borrow_mut() = ElementClickedState::default(); let cursor_offset = event.position - hitbox.origin; - let render_drag = (drag_listener)( + let view = (drag_listener)( drag_value.as_ref(), cursor_offset, window, cx, ); cx.active_drag = Some(AnyDrag { - render: Box::new(move |value, window, cx| { - render_drag(value, cursor_offset, window, cx) - }), + view, value: drag_value, cursor_offset, }); diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index 504b43fe0f..9477844f83 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -8,7 +8,8 @@ use anyhow::{anyhow, Result}; use futures::{AsyncReadExt, Future}; use image::{ - codecs::gif::GifDecoder, AnimationDecoder, Frame, ImageBuffer, ImageError, ImageFormat, + codecs::{gif::GifDecoder, webp::WebPDecoder}, + AnimationDecoder, DynamicImage, Frame, ImageBuffer, ImageError, ImageFormat, Rgba, }; use smallvec::SmallVec; use std::{ @@ -572,6 +573,34 @@ impl Asset for ImageAssetLoader { frames } + ImageFormat::WebP => { + let mut decoder = WebPDecoder::new(Cursor::new(&bytes))?; + + if decoder.has_animation() { + let _ = decoder.set_background_color(Rgba([0, 0, 0, 0])); + let mut frames = SmallVec::new(); + + for frame in decoder.into_frames() { + let mut frame = frame?; + // Convert from RGBA to BGRA. + for pixel in frame.buffer_mut().chunks_exact_mut(4) { + pixel.swap(0, 2); + } + frames.push(frame); + } + + frames + } else { + let mut data = DynamicImage::from_decoder(decoder)?.into_rgba8(); + + // Convert from RGBA to BGRA. + for pixel in data.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + + SmallVec::from_elem(Frame::new(data), 1) + } + } _ => { let mut data = image::load_from_memory_with_format(&bytes, format)?.into_rgba8(); diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index 9e0b9b9014..b636c95a61 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -704,6 +704,11 @@ pub struct Bounds { pub size: Size, } +/// Create a bounds with the given origin and size +pub fn bounds(origin: Point, size: Size) -> Bounds { + Bounds { origin, size } +} + impl Bounds { /// Generate a centered bounds for the given display or primary display if none is provided pub fn centered(display_id: Option, size: Size, cx: &AppContext) -> Self { diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index ac0a2c18a9..0d4e4a3ead 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -71,6 +71,9 @@ pub(crate) use test::*; #[cfg(target_os = "windows")] pub(crate) use windows::*; +#[cfg(any(test, feature = "test-support"))] +pub use test::TestScreenCaptureSource; + #[cfg(target_os = "macos")] pub(crate) fn current_platform(headless: bool) -> Rc { Rc::new(MacPlatform::new(headless)) @@ -150,6 +153,10 @@ pub(crate) trait Platform: 'static { None } + fn screen_capture_sources( + &self, + ) -> oneshot::Receiver>>>; + fn open_window( &self, handle: AnyWindowHandle, @@ -229,6 +236,25 @@ pub trait PlatformDisplay: Send + Sync + Debug { } } +/// A source of on-screen video content that can be captured. +pub trait ScreenCaptureSource { + /// Returns the video resolution of this source. + fn resolution(&self) -> Result>; + + /// Start capture video from this source, invoking the given callback + /// with each frame. + fn stream( + &self, + frame_callback: Box, + ) -> oneshot::Receiver>>; +} + +/// A video stream captured from a screen. +pub trait ScreenCaptureStream {} + +/// A frame of video captured from a screen. +pub struct ScreenCaptureFrame(pub PlatformScreenCaptureFrame); + /// An opaque identifier for a hardware display #[derive(PartialEq, Eq, Hash, Copy, Clone)] pub struct DisplayId(pub(crate) u32); diff --git a/crates/gpui/src/platform/linux.rs b/crates/gpui/src/platform/linux.rs index 0499869361..089b52cf1e 100644 --- a/crates/gpui/src/platform/linux.rs +++ b/crates/gpui/src/platform/linux.rs @@ -20,3 +20,5 @@ pub(crate) use text_system::*; pub(crate) use wayland::*; #[cfg(feature = "x11")] pub(crate) use x11::*; + +pub(crate) type PlatformScreenCaptureFrame = (); diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index 650ed70af8..d0c0f1768e 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -8,7 +8,7 @@ use std::fs::File; use std::io::Read; use std::ops::{Deref, DerefMut}; use std::os::fd::{AsFd, AsRawFd, FromRawFd}; -use std::panic::Location; +use std::panic::{AssertUnwindSafe, Location}; use std::rc::Weak; use std::{ path::{Path, PathBuf}, @@ -18,12 +18,12 @@ use std::{ time::Duration, }; -use anyhow::anyhow; +use anyhow::{anyhow, Context as _}; use async_task::Runnable; use calloop::channel::Channel; use calloop::{EventLoop, LoopHandle, LoopSignal}; use flume::{Receiver, Sender}; -use futures::channel::oneshot; +use futures::{channel::oneshot, future::FutureExt}; use parking_lot::Mutex; use util::ResultExt; @@ -35,8 +35,8 @@ use crate::{ px, Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, Keymap, Keystroke, LinuxDispatcher, Menu, MenuItem, Modifiers, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay, PlatformInputHandler, PlatformTextSystem, - PlatformWindow, Point, PromptLevel, Result, SemanticVersion, SharedString, Size, Task, - WindowAppearance, WindowOptions, WindowParams, + PlatformWindow, Point, PromptLevel, Result, ScreenCaptureSource, SemanticVersion, SharedString, + Size, Task, WindowAppearance, WindowOptions, WindowParams, }; pub(crate) const SCROLL_LINES: f32 = 3.0; @@ -242,6 +242,14 @@ impl Platform for P { self.displays() } + fn screen_capture_sources( + &self, + ) -> oneshot::Receiver>>> { + let (mut tx, rx) = oneshot::channel(); + tx.send(Err(anyhow!("screen capture not implemented"))).ok(); + rx + } + fn active_window(&self) -> Option { self.active_window() } @@ -374,14 +382,14 @@ impl Platform for P { } fn open_with_system(&self, path: &Path) { - let executor = self.background_executor().clone(); let path = path.to_owned(); - executor + self.background_executor() .spawn(async move { let _ = std::process::Command::new("xdg-open") .arg(path) .spawn() - .expect("Failed to open file with xdg-open"); + .context("invoking xdg-open") + .log_err(); }) .detach(); } @@ -481,7 +489,12 @@ impl Platform for P { let username = attributes .get("username") .ok_or_else(|| anyhow!("Cannot find username in stored credentials"))?; - let secret = item.secret().await?; + // oo7 panics if the retrieved secret can't be decrypted due to + // unexpected padding. + let secret = AssertUnwindSafe(item.secret()) + .catch_unwind() + .await + .map_err(|_| anyhow!("oo7 panicked while trying to read credentials"))??; // we lose the zeroizing capabilities at this boundary, // a current limitation GPUI's credentials api diff --git a/crates/gpui/src/platform/linux/wayland/client.rs b/crates/gpui/src/platform/linux/wayland/client.rs index e193201957..2cafffa725 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 6a52765042..09aa414deb 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_icon(&mut self, wl_pointer: &WlPointer, serial_id: u32, mut cursor_icon_name: &str) { + 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, + 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 55ba4f6004..4cdf88e262 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/x11/client.rs b/crates/gpui/src/platform/linux/x11/client.rs index 1fd0e9aa66..a0c9ab4794 100644 --- a/crates/gpui/src/platform/linux/x11/client.rs +++ b/crates/gpui/src/platform/linux/x11/client.rs @@ -9,6 +9,7 @@ use std::time::{Duration, Instant}; use calloop::generic::{FdWrapper, Generic}; use calloop::{EventLoop, LoopHandle, RegistrationToken}; +use anyhow::Context as _; use collections::HashMap; use http_client::Url; use smallvec::SmallVec; @@ -1417,9 +1418,10 @@ impl LinuxClient for X11Client { ..Default::default() }, ) - .expect("failed to change window cursor") - .check() - .unwrap(); + .anyhow() + .and_then(|cookie| cookie.check().anyhow()) + .context("setting cursor style") + .log_err(); } fn open_uri(&self, uri: &str) { diff --git a/crates/gpui/src/platform/linux/xdg_desktop_portal.rs b/crates/gpui/src/platform/linux/xdg_desktop_portal.rs index 64aa3975b8..722947a299 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.rs b/crates/gpui/src/platform/mac.rs index 396fd49d04..bd3d8f35ac 100644 --- a/crates/gpui/src/platform/mac.rs +++ b/crates/gpui/src/platform/mac.rs @@ -4,12 +4,14 @@ mod dispatcher; mod display; mod display_link; mod events; +mod screen_capture; #[cfg(not(feature = "macos-blade"))] mod metal_atlas; #[cfg(not(feature = "macos-blade"))] pub mod metal_renderer; +use media::core_video::CVImageBuffer; #[cfg(not(feature = "macos-blade"))] use metal_renderer as renderer; @@ -49,6 +51,9 @@ pub(crate) use window::*; #[cfg(feature = "font-kit")] pub(crate) use text_system::*; +/// A frame of video captured from a screen. +pub(crate) type PlatformScreenCaptureFrame = CVImageBuffer; + trait BoolExt { fn to_objc(self) -> BOOL; } diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 28f427af1b..096bf860a6 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -1,16 +1,16 @@ use super::{ attributed_string::{NSAttributedString, NSMutableAttributedString}, events::key_to_native, - BoolExt, + renderer, screen_capture, BoolExt, }; use crate::{ hash, Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString, CursorStyle, ForegroundExecutor, Image, ImageFormat, Keymap, MacDispatcher, MacDisplay, MacWindow, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, - PlatformTextSystem, PlatformWindow, Result, SemanticVersion, Task, WindowAppearance, - WindowParams, + PlatformTextSystem, PlatformWindow, Result, ScreenCaptureSource, SemanticVersion, Task, + WindowAppearance, WindowParams, }; -use anyhow::anyhow; +use anyhow::{anyhow, Context as _}; use block::ConcreteBlock; use cocoa::{ appkit::{ @@ -57,8 +57,7 @@ use std::{ sync::Arc, }; use strum::IntoEnumIterator; - -use super::renderer; +use util::ResultExt; #[allow(non_upper_case_globals)] const NSUTF8StringEncoding: NSUInteger = 4; @@ -552,6 +551,12 @@ impl Platform for MacPlatform { .collect() } + fn screen_capture_sources( + &self, + ) -> oneshot::Receiver>>> { + screen_capture::get_sources() + } + fn active_window(&self) -> Option { MacWindow::active_window() } @@ -775,15 +780,16 @@ impl Platform for MacPlatform { } fn open_with_system(&self, path: &Path) { - let path = path.to_path_buf(); + let path = path.to_owned(); self.0 .lock() .background_executor .spawn(async move { - std::process::Command::new("open") + let _ = std::process::Command::new("open") .arg(path) .spawn() - .expect("Failed to open file"); + .context("invoking open command") + .log_err(); }) .detach(); } diff --git a/crates/gpui/src/platform/mac/screen_capture.rs b/crates/gpui/src/platform/mac/screen_capture.rs new file mode 100644 index 0000000000..a2b535996f --- /dev/null +++ b/crates/gpui/src/platform/mac/screen_capture.rs @@ -0,0 +1,239 @@ +use crate::{ + platform::{ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream}, + px, size, Pixels, Size, +}; +use anyhow::{anyhow, Result}; +use block::ConcreteBlock; +use cocoa::{ + base::{id, nil, YES}, + foundation::NSArray, +}; +use core_foundation::base::TCFType; +use ctor::ctor; +use futures::channel::oneshot; +use media::core_media::{CMSampleBuffer, CMSampleBufferRef}; +use metal::NSInteger; +use objc::{ + class, + declare::ClassDecl, + msg_send, + runtime::{Class, Object, Sel}, + sel, sel_impl, +}; +use std::{cell::RefCell, ffi::c_void, mem, ptr, rc::Rc}; + +#[derive(Clone)] +pub struct MacScreenCaptureSource { + sc_display: id, +} + +pub struct MacScreenCaptureStream { + sc_stream: id, + sc_stream_output: id, +} + +#[link(name = "ScreenCaptureKit", kind = "framework")] +extern "C" {} + +static mut DELEGATE_CLASS: *const Class = ptr::null(); +static mut OUTPUT_CLASS: *const Class = ptr::null(); +const FRAME_CALLBACK_IVAR: &str = "frame_callback"; + +#[allow(non_upper_case_globals)] +const SCStreamOutputTypeScreen: NSInteger = 0; + +impl ScreenCaptureSource for MacScreenCaptureSource { + fn resolution(&self) -> Result> { + unsafe { + let width: i64 = msg_send![self.sc_display, width]; + let height: i64 = msg_send![self.sc_display, height]; + Ok(size(px(width as f32), px(height as f32))) + } + } + + fn stream( + &self, + frame_callback: Box, + ) -> oneshot::Receiver>> { + unsafe { + let stream: id = msg_send![class!(SCStream), alloc]; + let filter: id = msg_send![class!(SCContentFilter), alloc]; + let configuration: id = msg_send![class!(SCStreamConfiguration), alloc]; + let delegate: id = msg_send![DELEGATE_CLASS, alloc]; + let output: id = msg_send![OUTPUT_CLASS, alloc]; + + let excluded_windows = NSArray::array(nil); + let filter: id = msg_send![filter, initWithDisplay:self.sc_display excludingWindows:excluded_windows]; + let configuration: id = msg_send![configuration, init]; + let delegate: id = msg_send![delegate, init]; + let output: id = msg_send![output, init]; + + output.as_mut().unwrap().set_ivar( + FRAME_CALLBACK_IVAR, + Box::into_raw(Box::new(frame_callback)) as *mut c_void, + ); + + let stream: id = msg_send![stream, initWithFilter:filter configuration:configuration delegate:delegate]; + + let (mut tx, rx) = oneshot::channel(); + + let mut error: id = nil; + let _: () = msg_send![stream, addStreamOutput:output type:SCStreamOutputTypeScreen sampleHandlerQueue:0 error:&mut error as *mut id]; + if error != nil { + let message: id = msg_send![error, localizedDescription]; + tx.send(Err(anyhow!("failed to add stream output {message:?}"))) + .ok(); + return rx; + } + + let tx = Rc::new(RefCell::new(Some(tx))); + let handler = ConcreteBlock::new({ + move |error: id| { + let result = if error == nil { + let stream = MacScreenCaptureStream { + sc_stream: stream, + sc_stream_output: output, + }; + Ok(Box::new(stream) as Box) + } else { + let message: id = msg_send![error, localizedDescription]; + Err(anyhow!("failed to stop screen capture stream {message:?}")) + }; + if let Some(tx) = tx.borrow_mut().take() { + tx.send(result).ok(); + } + } + }); + let handler = handler.copy(); + let _: () = msg_send![stream, startCaptureWithCompletionHandler:handler]; + rx + } + } +} + +impl Drop for MacScreenCaptureSource { + fn drop(&mut self) { + unsafe { + let _: () = msg_send![self.sc_display, release]; + } + } +} + +impl ScreenCaptureStream for MacScreenCaptureStream {} + +impl Drop for MacScreenCaptureStream { + fn drop(&mut self) { + unsafe { + let mut error: id = nil; + let _: () = msg_send![self.sc_stream, removeStreamOutput:self.sc_stream_output type:SCStreamOutputTypeScreen error:&mut error as *mut _]; + if error != nil { + let message: id = msg_send![error, localizedDescription]; + log::error!("failed to add stream output {message:?}"); + } + + let handler = ConcreteBlock::new(move |error: id| { + if error != nil { + let message: id = msg_send![error, localizedDescription]; + log::error!("failed to stop screen capture stream {message:?}"); + } + }); + let block = handler.copy(); + let _: () = msg_send![self.sc_stream, stopCaptureWithCompletionHandler:block]; + let _: () = msg_send![self.sc_stream, release]; + let _: () = msg_send![self.sc_stream_output, release]; + } + } +} + +pub(crate) fn get_sources() -> oneshot::Receiver>>> { + unsafe { + let (mut tx, rx) = oneshot::channel(); + let tx = Rc::new(RefCell::new(Some(tx))); + + let block = ConcreteBlock::new(move |shareable_content: id, error: id| { + let Some(mut tx) = tx.borrow_mut().take() else { + return; + }; + let result = if error == nil { + let displays: id = msg_send![shareable_content, displays]; + let mut result = Vec::new(); + for i in 0..displays.count() { + let display = displays.objectAtIndex(i); + let source = MacScreenCaptureSource { + sc_display: msg_send![display, retain], + }; + result.push(Box::new(source) as Box); + } + Ok(result) + } else { + let msg: id = msg_send![error, localizedDescription]; + Err(anyhow!("Failed to register: {:?}", msg)) + }; + tx.send(result).ok(); + }); + let block = block.copy(); + + let _: () = msg_send![ + class!(SCShareableContent), + getShareableContentExcludingDesktopWindows:YES + onScreenWindowsOnly:YES + completionHandler:block]; + rx + } +} + +#[ctor] +unsafe fn build_classes() { + let mut decl = ClassDecl::new("GPUIStreamDelegate", class!(NSObject)).unwrap(); + decl.add_method( + sel!(outputVideoEffectDidStartForStream:), + output_video_effect_did_start_for_stream as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(outputVideoEffectDidStopForStream:), + output_video_effect_did_stop_for_stream as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(stream:didStopWithError:), + stream_did_stop_with_error as extern "C" fn(&Object, Sel, id, id), + ); + DELEGATE_CLASS = decl.register(); + + let mut decl = ClassDecl::new("GPUIStreamOutput", class!(NSObject)).unwrap(); + decl.add_method( + sel!(stream:didOutputSampleBuffer:ofType:), + stream_did_output_sample_buffer_of_type as extern "C" fn(&Object, Sel, id, id, NSInteger), + ); + decl.add_ivar::<*mut c_void>(FRAME_CALLBACK_IVAR); + + OUTPUT_CLASS = decl.register(); +} + +extern "C" fn output_video_effect_did_start_for_stream(_this: &Object, _: Sel, _stream: id) {} + +extern "C" fn output_video_effect_did_stop_for_stream(_this: &Object, _: Sel, _stream: id) {} + +extern "C" fn stream_did_stop_with_error(_this: &Object, _: Sel, _stream: id, _error: id) {} + +extern "C" fn stream_did_output_sample_buffer_of_type( + this: &Object, + _: Sel, + _stream: id, + sample_buffer: id, + buffer_type: NSInteger, +) { + if buffer_type != SCStreamOutputTypeScreen { + return; + } + + unsafe { + let sample_buffer = sample_buffer as CMSampleBufferRef; + let sample_buffer = CMSampleBuffer::wrap_under_get_rule(sample_buffer); + if let Some(buffer) = sample_buffer.image_buffer() { + let callback: Box> = + Box::from_raw(*this.get_ivar::<*mut c_void>(FRAME_CALLBACK_IVAR) as *mut _); + callback(ScreenCaptureFrame(buffer)); + mem::forget(callback); + } + } +} diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index ce9a4c05bf..1779767dca 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -152,10 +152,6 @@ unsafe fn build_classes() { sel!(flagsChanged:), handle_view_event as extern "C" fn(&Object, Sel, id), ); - decl.add_method( - sel!(cancelOperation:), - cancel_operation as extern "C" fn(&Object, Sel, id), - ); decl.add_method( sel!(makeBackingLayer), @@ -331,6 +327,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 +606,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(), @@ -1109,10 +1107,16 @@ impl PlatformWindow for MacWindow { } fn update_ime_position(&self, _bounds: Bounds) { - unsafe { - let input_context: id = msg_send![class!(NSTextInputContext), currentInputContext]; - let _: () = msg_send![input_context, invalidateCharacterCoordinates]; - } + let executor = self.0.lock().executor.clone(); + executor + .spawn(async move { + unsafe { + let input_context: id = + msg_send![class!(NSTextInputContext), currentInputContext]; + let _: () = msg_send![input_context, invalidateCharacterCoordinates]; + } + }) + .detach() } } @@ -1251,14 +1255,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 +1392,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(_), @@ -1428,29 +1451,6 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { } } -// Allows us to receive `cmd-.` (the shortcut for closing a dialog) -// https://bugs.eclipse.org/bugs/show_bug.cgi?id=300620#c6 -extern "C" fn cancel_operation(this: &Object, _sel: Sel, _sender: id) { - let window_state = unsafe { get_window_state(this) }; - let mut lock = window_state.as_ref().lock(); - - let keystroke = Keystroke { - modifiers: Default::default(), - key: ".".into(), - key_char: None, - }; - let event = PlatformInput::KeyDown(KeyDownEvent { - keystroke: keystroke.clone(), - is_held: false, - }); - - if let Some(mut callback) = lock.event_callback.take() { - drop(lock); - callback(event); - window_state.lock().event_callback = Some(callback); - } -} - extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; let lock = &mut *window_state.lock(); @@ -1683,7 +1683,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 +1793,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/test.rs b/crates/gpui/src/platform/test.rs index d17739239e..70462cb5e2 100644 --- a/crates/gpui/src/platform/test.rs +++ b/crates/gpui/src/platform/test.rs @@ -7,3 +7,5 @@ pub(crate) use dispatcher::*; pub(crate) use display::*; pub(crate) use platform::*; pub(crate) use window::*; + +pub use platform::TestScreenCaptureSource; diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index aadbe9b595..67227b60fe 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -1,7 +1,7 @@ use crate::{ - AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, Keymap, - Platform, PlatformDisplay, PlatformTextSystem, Task, TestDisplay, TestWindow, WindowAppearance, - WindowParams, + px, size, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, + Keymap, Platform, PlatformDisplay, PlatformTextSystem, ScreenCaptureFrame, ScreenCaptureSource, + ScreenCaptureStream, Task, TestDisplay, TestWindow, WindowAppearance, WindowParams, }; use anyhow::Result; use collections::VecDeque; @@ -31,6 +31,7 @@ pub(crate) struct TestPlatform { #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex>, pub(crate) prompts: RefCell, + screen_capture_sources: RefCell>, pub opened_url: RefCell>, pub text_system: Arc, #[cfg(target_os = "windows")] @@ -38,6 +39,31 @@ pub(crate) struct TestPlatform { weak: Weak, } +#[derive(Clone)] +/// A fake screen capture source, used for testing. +pub struct TestScreenCaptureSource {} + +pub struct TestScreenCaptureStream {} + +impl ScreenCaptureSource for TestScreenCaptureSource { + fn resolution(&self) -> Result> { + Ok(size(px(1.), px(1.))) + } + + fn stream( + &self, + _frame_callback: Box, + ) -> oneshot::Receiver>> { + let (mut tx, rx) = oneshot::channel(); + let stream = TestScreenCaptureStream {}; + tx.send(Ok(Box::new(stream) as Box)) + .ok(); + rx + } +} + +impl ScreenCaptureStream for TestScreenCaptureStream {} + #[derive(Default)] pub(crate) struct TestPrompts { multiple_choice: VecDeque>, @@ -72,6 +98,7 @@ impl TestPlatform { background_executor: executor, foreground_executor, prompts: Default::default(), + screen_capture_sources: Default::default(), active_cursor: Default::default(), active_display: Rc::new(TestDisplay::new()), active_window: Default::default(), @@ -114,6 +141,10 @@ impl TestPlatform { !self.prompts.borrow().multiple_choice.is_empty() } + pub(crate) fn set_screen_capture_sources(&self, sources: Vec) { + *self.screen_capture_sources.borrow_mut() = sources; + } + pub(crate) fn prompt(&self, msg: &str, detail: Option<&str>) -> oneshot::Receiver { let (tx, rx) = oneshot::channel(); self.background_executor() @@ -202,6 +233,20 @@ impl Platform for TestPlatform { Some(self.active_display.clone()) } + fn screen_capture_sources( + &self, + ) -> oneshot::Receiver>>> { + let (mut tx, rx) = oneshot::channel(); + tx.send(Ok(self + .screen_capture_sources + .borrow() + .iter() + .map(|source| Box::new(source.clone()) as Box) + .collect())) + .ok(); + rx + } + fn active_window(&self) -> Option { self.active_window .borrow() @@ -330,6 +375,13 @@ impl Platform for TestPlatform { } } +impl TestScreenCaptureSource { + /// Create a fake screen capture source, for testing. + pub fn new() -> Self { + Self {} + } +} + #[cfg(target_os = "windows")] impl Drop for TestPlatform { fn drop(&mut self) { diff --git a/crates/gpui/src/platform/windows.rs b/crates/gpui/src/platform/windows.rs index 84cf107c70..51d09f0013 100644 --- a/crates/gpui/src/platform/windows.rs +++ b/crates/gpui/src/platform/windows.rs @@ -21,3 +21,5 @@ pub(crate) use window::*; pub(crate) use wrapper::*; pub(crate) use windows::Win32::Foundation::HWND; + +pub(crate) type PlatformScreenCaptureFrame = (); diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index 5f45d260d9..27235d5d40 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::*, @@ -32,7 +33,7 @@ pub(crate) fn handle_msg( WM_ACTIVATE => handle_activate_msg(handle, wparam, state_ptr), WM_CREATE => handle_create_msg(handle, state_ptr), WM_MOVE => handle_move_msg(handle, lparam, state_ptr), - WM_SIZE => handle_size_msg(lparam, state_ptr), + WM_SIZE => handle_size_msg(wparam, lparam, state_ptr), WM_ENTERSIZEMOVE | WM_ENTERMENULOOP => handle_size_move_loop(handle), WM_EXITSIZEMOVE | WM_EXITMENULOOP => handle_size_move_loop_exit(handle), WM_TIMER => handle_timer_msg(handle, wparam, state_ptr), @@ -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) @@ -134,7 +136,15 @@ fn handle_move_msg( Some(0) } -fn handle_size_msg(lparam: LPARAM, state_ptr: Rc) -> Option { +fn handle_size_msg( + wparam: WPARAM, + lparam: LPARAM, + state_ptr: Rc, +) -> Option { + if wparam.0 == SIZE_MINIMIZED as usize { + return Some(0); + } + let width = lparam.loword().max(1) as i32; let height = lparam.hiword().max(1) as i32; let mut lock = state_ptr.state.borrow_mut(); @@ -234,10 +244,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 +304,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 91e9816106..0c23a4ef7a 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}; @@ -325,6 +325,14 @@ impl Platform for WindowsPlatform { WindowsDisplay::primary_monitor().map(|display| Rc::new(display) as Rc) } + fn screen_capture_sources( + &self, + ) -> oneshot::Receiver>>> { + let (mut tx, rx) = oneshot::channel(); + tx.send(Err(anyhow!("screen capture not implemented"))).ok(); + rx + } + fn active_window(&self) -> Option { let active_window_hwnd = unsafe { GetActiveWindow() }; self.try_get_windows_inner_from_hwnd(active_window_hwnd) @@ -645,13 +653,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 f2600d3c6f..93671f9b89 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 9787ec5d87..418be6af22 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 66eb914a30..13a7896a3f 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/view.rs b/crates/gpui/src/view.rs index 7f10eb25c3..4f35413a27 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -7,6 +7,7 @@ use crate::{ }; use anyhow::{Context, Result}; use refineable::Refineable; +use std::mem; use std::{ any::{type_name, TypeId}, fmt, @@ -341,11 +342,13 @@ impl Element for AnyView { } } + let refreshing = mem::replace(&mut cx.window.refreshing, true); let prepaint_start = cx.prepaint_index(); let mut element = (self.render)(self, cx); element.layout_as_root(bounds.size.into(), cx); element.prepaint_at(bounds.origin, cx); let prepaint_end = cx.prepaint_index(); + cx.window.refreshing = refreshing; ( Some(element), @@ -382,7 +385,9 @@ impl Element for AnyView { let paint_start = cx.paint_index(); if let Some(element) = element { + let refreshing = mem::replace(&mut cx.window.refreshing, true); element.paint(cx); + cx.window.refreshing = refreshing; } else { cx.reuse_paint(element_state.paint_range.clone()); } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index b8481a478e..9ae1342633 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1010,9 +1010,12 @@ impl Window { /// Returns whether this window is considered to be the window /// that currently owns the mouse cursor. - /// On mac, this is equivalent to `is_active`. pub fn is_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.hovered.get() } else { self.is_active() @@ -1277,7 +1280,7 @@ impl Window { prompt_element = Some(element); self.prompt = Some(prompt); } else if let Some(mut active_drag) = cx.active_drag.take() { - let mut element = (active_drag.render)(active_drag.value.as_mut(), self, cx); + let mut element = (active_drag.view)(self, cx); let offset = self.mouse_position() - active_drag.cursor_offset; element.prepaint_as_root(offset, AvailableSpace::min_size(), self, cx); active_drag_element = Some(element); @@ -1500,12 +1503,19 @@ impl Window { .iter_mut() .map(|listener| listener.take()), ); - self.next_frame.accessed_element_states.extend( - self.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) = self + .rendered_frame + .accessed_element_states + .get(range.start.accessed_element_states_index..range.end.accessed_element_states_index) + { + self.next_frame.accessed_element_states.extend( + element_states + .iter() + .map(|(id, type_id)| (GlobalElementId(id.0.clone()), *type_id)), + ); + } + self.text_system .reuse_layouts(range.start.line_layout_index..range.end.line_layout_index); self.next_frame.scene.replay( @@ -2780,7 +2790,7 @@ impl Window { } /// Represent this action as a key binding string, to display in the UI. - pub fn keystroke_text_for_action(&self, action: &dyn Action) -> String { + pub fn keystroke_text_for(&self, action: &dyn Action) -> String { self.bindings_for_action(action) .into_iter() .next() @@ -2795,26 +2805,6 @@ impl Window { .unwrap_or_else(|| action.name().to_string()) } - /// Represent this action as a key binding string, to display in the UI. - pub fn keystroke_text_for_action_in( - &self, - action: &dyn Action, - focus_handle: &FocusHandle, - ) -> String { - self.bindings_for_action_in(action, focus_handle) - .into_iter() - .next() - .map(|binding| { - binding - .keystrokes() - .iter() - .map(ToString::to_string) - .collect::>() - .join(" ") - }) - .unwrap_or_else(|| action.name().to_string()) - } - /// Dispatch a mouse or keyboard event on the window. #[profiling::function] pub fn dispatch_event( @@ -2866,8 +2856,8 @@ impl Window { self.mouse_position = position; if cx.active_drag.is_none() { cx.active_drag = Some(AnyDrag { - value: Box::new(paths.clone()), - render: Box::new(move |_, _, _| Empty.into_any_element()), + value: Arc::new(paths.clone()), + view: Rc::new(move |_, _| Empty.into_any_element()), cursor_offset: position, }); } diff --git a/crates/http_client/Cargo.toml b/crates/http_client/Cargo.toml index ac8e254b84..a4f10cff18 100644 --- a/crates/http_client/Cargo.toml +++ b/crates/http_client/Cargo.toml @@ -20,7 +20,7 @@ bytes.workspace = true anyhow.workspace = true derive_more.workspace = true futures.workspace = true -http = "1.1" +http.workspace = true log.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 1d03e77e76..c3f264d863 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/Cargo.toml b/crates/language/Cargo.toml index 8b97d4a95f..d3cb1cfda2 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -34,7 +34,6 @@ ec4rs.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true -git.workspace = true globset.workspace = true gpui.workspace = true http_client.workspace = true diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 2479eafd7a..833a71c899 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; @@ -89,22 +90,11 @@ pub enum Capability { pub type BufferRow = u32; -#[derive(Clone)] -enum BufferDiffBase { - Git(Rope), - PastBufferVersion { - buffer: Model, - rope: Rope, - merged_operations: Vec, - }, -} - /// An in-memory representation of a source code file, including its text, /// syntax trees, git status, and diagnostics. pub struct Buffer { text: TextBuffer, - diff_base: Option, - git_diff: git::diff::BufferDiff, + branch_state: Option, /// Filesystem state, `None` when there is no path. file: Option>, /// The mtime of the file when this buffer was last loaded from @@ -134,7 +124,6 @@ pub struct Buffer { deferred_ops: OperationQueue, capability: Capability, has_conflict: bool, - diff_base_version: usize, /// Memoize calls to has_changes_since(saved_version). /// The contents of a cell are (self.version, has_changes) at the time of a last call. has_unsaved_edits: Cell<(clock::Global, bool)>, @@ -147,11 +136,15 @@ pub enum ParseStatus { Parsing, } +struct BufferBranchState { + base_buffer: Model, + merged_operations: Vec, +} + /// An immutable, cheaply cloneable representation of a fixed /// state of a buffer. pub struct BufferSnapshot { text: text::BufferSnapshot, - git_diff: git::diff::BufferDiff, pub(crate) syntax: SyntaxSnapshot, file: Option>, diagnostics: SmallVec<[(LanguageServerId, DiagnosticSet); 2]>, @@ -344,10 +337,6 @@ pub enum BufferEvent { Reloaded, /// The buffer is in need of a reload ReloadNeeded, - /// The buffer's diff_base changed. - DiffBaseChanged, - /// Buffer's excerpts for a certain diff base were recalculated. - DiffUpdated, /// The buffer's language was changed. LanguageChanged, /// The buffer's syntax trees were updated. @@ -467,6 +456,7 @@ struct AutoindentRequest { before_edit: BufferSnapshot, entries: Vec, is_block_mode: bool, + ignore_empty_lines: bool, } #[derive(Debug, Clone)] @@ -624,7 +614,6 @@ impl Buffer { Self::build( TextBuffer::new(0, cx.entity_id().as_non_zero_u64().into(), base_text.into()), None, - None, Capability::ReadWrite, ) } @@ -643,7 +632,6 @@ impl Buffer { base_text_normalized, ), None, - None, Capability::ReadWrite, ) } @@ -658,7 +646,6 @@ impl Buffer { Self::build( TextBuffer::new(replica_id, remote_id, base_text.into()), None, - None, capability, ) } @@ -674,7 +661,7 @@ impl Buffer { let buffer_id = BufferId::new(message.id) .with_context(|| anyhow!("Could not deserialize buffer_id"))?; let buffer = TextBuffer::new(replica_id, buffer_id, message.base_text); - let mut this = Self::build(buffer, message.diff_base, file, capability); + let mut this = Self::build(buffer, file, capability); this.text.set_line_ending(proto::deserialize_line_ending( rpc::proto::LineEnding::from_i32(message.line_ending) .ok_or_else(|| anyhow!("missing line_ending"))?, @@ -690,7 +677,6 @@ impl Buffer { id: self.remote_id().into(), file: self.file.as_ref().map(|f| f.to_proto(cx)), base_text: self.base_text().to_string(), - diff_base: self.diff_base().as_ref().map(|h| h.to_string()), line_ending: proto::serialize_line_ending(self.line_ending()) as i32, saved_version: proto::serialize_version(&self.saved_version), saved_mtime: self.saved_mtime.map(|time| time.into()), @@ -764,15 +750,9 @@ impl Buffer { } /// Builds a [`Buffer`] with the given underlying [`TextBuffer`], diff base, [`File`] and [`Capability`]. - pub fn build( - buffer: TextBuffer, - diff_base: Option, - file: Option>, - capability: Capability, - ) -> Self { + pub fn build(buffer: TextBuffer, file: Option>, capability: Capability) -> Self { let saved_mtime = file.as_ref().and_then(|file| file.disk_state().mtime()); let snapshot = buffer.snapshot(); - let git_diff = git::diff::BufferDiff::new(&snapshot); let syntax_map = Mutex::new(SyntaxMap::new(&snapshot)); Self { saved_mtime, @@ -783,12 +763,7 @@ impl Buffer { was_dirty_before_starting_transaction: None, has_unsaved_edits: Cell::new((buffer.version(), false)), text: buffer, - diff_base: diff_base.map(|mut raw_diff_base| { - LineEnding::normalize(&mut raw_diff_base); - BufferDiffBase::Git(Rope::from(raw_diff_base)) - }), - diff_base_version: 0, - git_diff, + branch_state: None, file, capability, syntax_map, @@ -822,7 +797,6 @@ impl Buffer { BufferSnapshot { text, syntax, - git_diff: self.git_diff.clone(), file: self.file.clone(), remote_selections: self.remote_selections.clone(), diagnostics: self.diagnostics.clone(), @@ -835,21 +809,15 @@ impl Buffer { let this = cx.handle(); cx.new_model(|cx| { let mut branch = Self { - diff_base: Some(BufferDiffBase::PastBufferVersion { - buffer: this.clone(), - rope: self.as_rope().clone(), + branch_state: Some(BufferBranchState { + base_buffer: this.clone(), merged_operations: Default::default(), }), language: self.language.clone(), has_conflict: self.has_conflict, has_unsaved_edits: Cell::new(self.has_unsaved_edits.get_mut().clone()), _subscriptions: vec![cx.subscribe(&this, Self::on_base_buffer_event)], - ..Self::build( - self.text.branch(), - None, - self.file.clone(), - self.capability(), - ) + ..Self::build(self.text.branch(), self.file.clone(), self.capability()) }; if let Some(language_registry) = self.language_registry() { branch.set_language_registry(language_registry); @@ -868,7 +836,7 @@ impl Buffer { /// If `ranges` is empty, then all changes will be applied. This buffer must /// be a branch buffer to call this method. pub fn merge_into_base(&mut self, ranges: Vec>, cx: &mut ModelContext) { - let Some(base_buffer) = self.diff_base_buffer() else { + let Some(base_buffer) = self.base_buffer() else { debug_panic!("not a branch buffer"); return; }; @@ -904,14 +872,14 @@ impl Buffer { } let operation = base_buffer.update(cx, |base_buffer, cx| { - cx.emit(BufferEvent::DiffBaseChanged); + // cx.emit(BufferEvent::DiffBaseChanged); base_buffer.edit(edits, None, cx) }); if let Some(operation) = operation { - if let Some(BufferDiffBase::PastBufferVersion { + if let Some(BufferBranchState { merged_operations, .. - }) = &mut self.diff_base + }) = &mut self.branch_state { merged_operations.push(operation); } @@ -927,9 +895,9 @@ impl Buffer { let BufferEvent::Operation { operation, .. } = event else { return; }; - let Some(BufferDiffBase::PastBufferVersion { + let Some(BufferBranchState { merged_operations, .. - }) = &mut self.diff_base + }) = &mut self.branch_state else { return; }; @@ -948,8 +916,6 @@ impl Buffer { let counts = [(timestamp, u32::MAX)].into_iter().collect(); self.undo_operations(counts, cx); } - - self.diff_base_version += 1; } #[cfg(test)] @@ -1121,74 +1087,8 @@ impl Buffer { } } - /// Returns the current diff base, see [`Buffer::set_diff_base`]. - pub fn diff_base(&self) -> Option<&Rope> { - match self.diff_base.as_ref()? { - BufferDiffBase::Git(rope) | BufferDiffBase::PastBufferVersion { rope, .. } => { - Some(rope) - } - } - } - - /// Sets the text that will be used to compute a Git diff - /// against the buffer text. - pub fn set_diff_base(&mut self, diff_base: Option, cx: &ModelContext) { - self.diff_base = diff_base.map(|mut raw_diff_base| { - LineEnding::normalize(&mut raw_diff_base); - BufferDiffBase::Git(Rope::from(raw_diff_base)) - }); - self.diff_base_version += 1; - if let Some(recalc_task) = self.recalculate_diff(cx) { - cx.spawn(|buffer, mut cx| async move { - recalc_task.await; - buffer - .update(&mut cx, |_, cx| { - cx.emit(BufferEvent::DiffBaseChanged); - }) - .ok(); - }) - .detach(); - } - } - - /// Returns a number, unique per diff base set to the buffer. - pub fn diff_base_version(&self) -> usize { - self.diff_base_version - } - - pub fn diff_base_buffer(&self) -> Option> { - match self.diff_base.as_ref()? { - BufferDiffBase::Git(_) => None, - BufferDiffBase::PastBufferVersion { buffer, .. } => Some(buffer.clone()), - } - } - - /// Recomputes the diff. - pub fn recalculate_diff(&self, cx: &ModelContext) -> Option> { - let diff_base_rope = match self.diff_base.as_ref()? { - BufferDiffBase::Git(rope) => rope.clone(), - BufferDiffBase::PastBufferVersion { buffer, .. } => buffer.read(cx).as_rope().clone(), - }; - - let snapshot = self.snapshot(); - let mut diff = self.git_diff.clone(); - let diff = cx.background_executor().spawn(async move { - diff.update(&diff_base_rope, &snapshot).await; - (diff, diff_base_rope) - }); - - Some(cx.spawn(|this, mut cx| async move { - let (buffer_diff, diff_base_rope) = diff.await; - this.update(&mut cx, |this, cx| { - this.git_diff = buffer_diff; - this.non_text_state_update_count += 1; - if let Some(BufferDiffBase::PastBufferVersion { rope, .. }) = &mut this.diff_base { - *rope = diff_base_rope; - } - cx.emit(BufferEvent::DiffUpdated); - }) - .ok(); - })) + pub fn base_buffer(&self) -> Option> { + Some(self.branch_state.as_ref()?.base_buffer.clone()) } /// Returns the primary [`Language`] assigned to this [`Buffer`]. @@ -1381,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. @@ -1475,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)| { @@ -1486,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), + ); } } } @@ -1494,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) { @@ -1512,7 +1419,7 @@ impl Buffer { Ordering::Equal => {} } } - size + (size, request.ignore_empty_lines) }); } } @@ -1523,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() }) } @@ -2067,6 +1983,7 @@ impl Buffer { before_edit, entries, is_block_mode: matches!(mode, AutoindentMode::Block { .. }), + ignore_empty_lines: false, })); } @@ -2094,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( @@ -3312,6 +3253,14 @@ impl BufferSnapshot { }) } + pub fn function_body_fold_ranges( + &self, + within: Range, + ) -> impl Iterator> + '_ { + self.text_object_ranges(within, TreeSitterOptions::default()) + .filter_map(|(range, obj)| (obj == TextObject::InsideFunction).then_some(range)) + } + /// For each grammar in the language, runs the provided /// [`tree_sitter::Query`] against the given range. pub fn matches( @@ -3370,6 +3319,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, @@ -3875,38 +3890,6 @@ impl BufferSnapshot { }) } - /// Whether the buffer contains any Git changes. - pub fn has_git_diff(&self) -> bool { - !self.git_diff.is_empty() - } - - /// Returns all the Git diff hunks intersecting the given row range. - #[cfg(any(test, feature = "test-support"))] - pub fn git_diff_hunks_in_row_range( - &self, - range: Range, - ) -> impl '_ + Iterator { - self.git_diff.hunks_in_row_range(range, self) - } - - /// Returns all the Git diff hunks intersecting the given - /// range. - pub fn git_diff_hunks_intersecting_range( - &self, - range: Range, - ) -> impl '_ + Iterator { - self.git_diff.hunks_intersecting_range(range, self) - } - - /// Returns all the Git diff hunks intersecting the given - /// range, in reverse order. - pub fn git_diff_hunks_intersecting_range_rev( - &self, - range: Range, - ) -> impl '_ + Iterator { - self.git_diff.hunks_intersecting_range_rev(range, self) - } - /// Returns if the buffer contains any diagnostics. pub fn has_diagnostics(&self) -> bool { !self.diagnostics.is_empty() @@ -4051,7 +4034,6 @@ impl Clone for BufferSnapshot { fn clone(&self) -> Self { Self { text: self.text.clone(), - git_diff: self.git_diff.clone(), syntax: self.syntax.clone(), file: self.file.clone(), remote_selections: self.remote_selections.clone(), @@ -4515,7 +4497,7 @@ impl CharClassifier { self.kind(c) == CharKind::Punctuation } - pub fn kind(&self, c: char) -> CharKind { + pub fn kind_with(&self, c: char, ignore_punctuation: bool) -> CharKind { if c.is_whitespace() { return CharKind::Whitespace; } else if c.is_alphanumeric() || c == '_' { @@ -4525,7 +4507,7 @@ impl CharClassifier { if let Some(scope) = &self.scope { if let Some(characters) = scope.word_characters() { if characters.contains(&c) { - if c == '-' && !self.for_completion && !self.ignore_punctuation { + if c == '-' && !self.for_completion && !ignore_punctuation { return CharKind::Punctuation; } return CharKind::Word; @@ -4533,12 +4515,16 @@ impl CharClassifier { } } - if self.ignore_punctuation { + if ignore_punctuation { CharKind::Word } else { CharKind::Punctuation } } + + pub fn kind(&self, c: char) -> CharKind { + self.kind_with(c, self.ignore_punctuation) + } } /// Find all of the ranges of whitespace that occur at the ends of lines diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index a33a21cb0f..a1d1a57f13 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -6,7 +6,6 @@ use crate::Buffer; use clock::ReplicaId; use collections::BTreeMap; use futures::FutureExt as _; -use git::diff::assert_hunks; use gpui::{AppContext, BorrowAppContext, Model}; use gpui::{Context, TestAppContext}; use indoc::indoc; @@ -20,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}; @@ -915,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| { @@ -2574,15 +2607,6 @@ fn test_branch_and_merge(cx: &mut TestAppContext) { ); }); - // The branch buffer maintains a diff with respect to its base buffer. - start_recalculating_diff(&branch, cx); - cx.run_until_parked(); - assert_diff_hunks( - &branch, - cx, - &[(1..2, "", "1.5\n"), (3..4, "three\n", "THREE\n")], - ); - // Edits to the base are applied to the branch. base.update(cx, |buffer, cx| { buffer.edit([(Point::new(0, 0)..Point::new(0, 0), "ZERO\n")], None, cx) @@ -2592,21 +2616,6 @@ fn test_branch_and_merge(cx: &mut TestAppContext) { assert_eq!(buffer.text(), "ZERO\none\n1.5\ntwo\nTHREE\n"); }); - // Until the git diff recalculation is complete, the git diff references - // the previous content of the base buffer, so that it stays in sync. - start_recalculating_diff(&branch, cx); - assert_diff_hunks( - &branch, - cx, - &[(2..3, "", "1.5\n"), (4..5, "three\n", "THREE\n")], - ); - cx.run_until_parked(); - assert_diff_hunks( - &branch, - cx, - &[(2..3, "", "1.5\n"), (4..5, "three\n", "THREE\n")], - ); - // Edits to any replica of the base are applied to the branch. base_replica.update(cx, |buffer, cx| { buffer.edit([(Point::new(2, 0)..Point::new(2, 0), "2.5\n")], None, cx) @@ -2697,29 +2706,6 @@ fn test_undo_after_merge_into_base(cx: &mut TestAppContext) { branch.read_with(cx, |branch, _| assert_eq!(branch.text(), "ABCdefgHIjk")); } -fn start_recalculating_diff(buffer: &Model, cx: &mut TestAppContext) { - buffer - .update(cx, |buffer, cx| buffer.recalculate_diff(cx).unwrap()) - .detach(); -} - -#[track_caller] -fn assert_diff_hunks( - buffer: &Model, - cx: &mut TestAppContext, - expected_hunks: &[(Range, &str, &str)], -) { - let (snapshot, diff_base) = buffer.read_with(cx, |buffer, _| { - (buffer.snapshot(), buffer.diff_base().unwrap().to_string()) - }); - assert_hunks( - snapshot.git_diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX), - &snapshot, - &diff_base, - expected_hunks, - ); -} - #[gpui::test(iterations = 100)] fn test_random_collaboration(cx: &mut AppContext, mut rng: StdRng) { let min_peers = env::var("MIN_PEERS") @@ -3182,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 580955a98b..e0cd392131 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, @@ -201,13 +208,14 @@ impl CachedLspAdapter { pub async fn get_language_server_command( self: Arc, delegate: Arc, + toolchains: Arc, binary_options: LanguageServerBinaryOptions, cx: &mut AsyncAppContext, ) -> Result { let cached_binary = self.cached_binary.lock().await; self.adapter .clone() - .get_language_server_command(delegate, binary_options, cached_binary, cx) + .get_language_server_command(delegate, toolchains, binary_options, cached_binary, cx) .await } @@ -281,6 +289,7 @@ pub trait LspAdapter: 'static + Send + Sync { fn get_language_server_command<'a>( self: Arc, delegate: Arc, + toolchains: Arc, binary_options: LanguageServerBinaryOptions, mut cached_binary: futures::lock::MutexGuard<'a, Option>, cx: &'a mut AsyncAppContext, @@ -298,7 +307,7 @@ pub trait LspAdapter: 'static + Send + Sync { // because we don't want to download and overwrite our global one // for each worktree we might have open. if binary_options.allow_path_lookup { - if let Some(binary) = self.check_if_user_installed(delegate.as_ref(), cx).await { + if let Some(binary) = self.check_if_user_installed(delegate.as_ref(), toolchains, cx).await { log::info!( "found user-installed language server for {}. path: {:?}, arguments: {:?}", self.name().0, @@ -357,6 +366,7 @@ pub trait LspAdapter: 'static + Send + Sync { async fn check_if_user_installed( &self, _: &dyn LspAdapterDelegate, + _: Arc, _: &AsyncAppContext, ) -> Option { None @@ -481,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( @@ -833,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, @@ -858,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, @@ -935,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, @@ -1005,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) } @@ -1082,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() @@ -1407,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 { @@ -1665,6 +1749,7 @@ impl LspAdapter for FakeLspAdapter { async fn check_if_user_installed( &self, _: &dyn LspAdapterDelegate, + _: Arc, _: &AsyncAppContext, ) -> Option { Some(self.language_server_binary.clone()) @@ -1673,6 +1758,7 @@ impl LspAdapter for FakeLspAdapter { fn get_language_server_command<'a>( self: Arc, _: Arc, + _: Arc, _: LanguageServerBinaryOptions, _: futures::lock::MutexGuard<'a, Option>, _: &'a mut AsyncAppContext, diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index d8c2b0d510..794ab0784e 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 1208925542..76c6dc75e3 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 fe8936db08..5b48157f0f 100644 --- a/crates/language/src/toolchain.rs +++ b/crates/language/src/toolchain.rs @@ -14,7 +14,7 @@ use settings::WorktreeId; use crate::LanguageName; /// Represents a single toolchain. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] pub struct Toolchain { /// User-facing label pub name: SharedString, @@ -24,7 +24,19 @@ pub struct Toolchain { pub as_json: serde_json::Value, } -#[async_trait(?Send)] +impl PartialEq for Toolchain { + fn eq(&self, other: &Self) -> bool { + // Do not use as_json for comparisons; it shouldn't impact equality, as it's not user-surfaced. + // Thus, there could be multiple entries that look the same in the UI. + (&self.name, &self.path, &self.language_name).eq(&( + &other.name, + &other.path, + &other.language_name, + )) + } +} + +#[async_trait] pub trait ToolchainLister: Send + Sync { async fn list( &self, diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index eab9529fe0..3286e09e2d 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -115,6 +115,7 @@ impl LspAdapter for ExtensionLspAdapter { fn get_language_server_command<'a>( self: Arc, delegate: Arc, + _: Arc, _: LanguageServerBinaryOptions, _: futures::lock::MutexGuard<'a, Option>, _: &'a mut AsyncAppContext, diff --git a/crates/language_extension/src/language_extension.rs b/crates/language_extension/src/language_extension.rs index d8ffc71d7c..59951c87e4 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 f9df34a2d1..83f0b50321 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, @@ -63,9 +63,27 @@ pub enum StopReason { ToolUse, } +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] +pub struct LanguageModelToolUseId(Arc); + +impl fmt::Display for LanguageModelToolUseId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for LanguageModelToolUseId +where + T: Into>, +{ + fn from(value: T) -> Self { + Self(value.into()) + } +} + #[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] pub struct LanguageModelToolUse { - pub id: String, + pub id: LanguageModelToolUseId, pub name: String, pub input: serde_json::Value, } diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index 06dde1862a..e6f7f210c7 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -347,7 +347,7 @@ impl LanguageModelRequest { } MessageContent::ToolUse(tool_use) => { Some(anthropic::RequestContent::ToolUse { - id: tool_use.id, + id: tool_use.id.to_string(), name: tool_use.name, input: tool_use.input, cache_control, diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 87460b824e..e882bb900d 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -498,7 +498,7 @@ pub fn map_to_language_model_completion_events( Some(maybe!({ Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { - id: tool_use.id, + id: tool_use.id.into(), name: tool_use.name, input: if tool_use.input_json.is_empty() { serde_json::Value::Null diff --git a/crates/language_selector/Cargo.toml b/crates/language_selector/Cargo.toml index b864ffc31f..276e9b0d42 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 1d5f82d285..eeaa403e20 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 489f6fd141..760a94000d 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}; @@ -101,6 +104,13 @@ impl LanguageSelectorDelegate { let candidates = language_registry .language_names() .into_iter() + .filter_map(|name| { + language_registry + .available_language_for_name(&name)? + .hidden() + .not() + .then_some(name) + }) .enumerate() .map(|(candidate_id, name)| StringMatchCandidate::new(candidate_id, name)) .collect::>(); @@ -115,13 +125,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 +276,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 0000000000..cca2f7d9e9 --- /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 5bfb7f0bc2..c50a16b3e4 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; @@ -24,6 +25,7 @@ impl super::LspAdapter for CLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, + _: Arc, _: &AsyncAppContext, ) -> Option { let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; @@ -256,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 0000000000..832dd62288 --- /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 0000000000..11a27b8d58 --- /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 9b0c9c703c..d6ea2f9c7f 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 0000000000..c9c6207b85 --- /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.rs b/crates/languages/src/go.rs index b3073d7eaa..6e2b5d464e 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -67,6 +67,7 @@ impl super::LspAdapter for GoLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, + _: Arc, _: &AsyncAppContext, ) -> Option { let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; diff --git a/crates/languages/src/go/textobjects.scm b/crates/languages/src/go/textobjects.scm new file mode 100644 index 0000000000..eb4f3a0050 --- /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 c5ec3d36dd..0159d452cc 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 37f48e1df8..af619dacb7 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 0000000000..1a273ddb50 --- /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 444e657a38..0aa0d361bd 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 0000000000..81fd20245b --- /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 0000000000..81fd20245b --- /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 776d47a5f7..5ba6f5c034 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/injections.scm b/crates/languages/src/markdown/injections.scm index 5972a43eb1..b2c35642e5 100644 --- a/crates/languages/src/markdown/injections.scm +++ b/crates/languages/src/markdown/injections.scm @@ -8,3 +8,7 @@ ((html_block) @content (#set! "language" "html")) + +((minus_metadata) @content (#set! "language" "yaml")) + +((plus_metadata) @content (#set! "language" "toml")) diff --git a/crates/languages/src/markdown/textobjects.scm b/crates/languages/src/markdown/textobjects.scm new file mode 100644 index 0000000000..e0f76c5365 --- /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 2cedd704cf..ec7ddde61d 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -79,6 +79,7 @@ impl LspAdapter for PythonLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, + _: Arc, _: &AsyncAppContext, ) -> Option { let node = delegate.which("node".as_ref()).await?; @@ -535,7 +536,7 @@ fn env_priority(kind: Option) -> usize { } } -#[async_trait(?Send)] +#[async_trait] impl ToolchainLister for PythonToolchainProvider { async fn list( &self, @@ -753,33 +754,29 @@ impl LspAdapter for PyLspAdapter { async fn check_if_user_installed( &self, - _: &dyn LspAdapterDelegate, - _: &AsyncAppContext, + delegate: &dyn LspAdapterDelegate, + toolchains: Arc, + cx: &AsyncAppContext, ) -> Option { - // We don't support user-provided pylsp, as global packages are discouraged in Python ecosystem. - None + let venv = toolchains + .active_toolchain( + delegate.worktree_id(), + LanguageName::new("Python"), + &mut cx.clone(), + ) + .await?; + let pylsp_path = Path::new(venv.path.as_ref()).parent()?.join("pylsp"); + pylsp_path.exists().then(|| LanguageServerBinary { + path: venv.path.to_string().into(), + arguments: vec![pylsp_path.into()], + env: None, + }) } async fn fetch_latest_server_version( &self, _: &dyn LspAdapterDelegate, ) -> Result> { - // let uri = "https://pypi.org/pypi/python-lsp-server/json"; - // let mut root_manifest = delegate - // .http_client() - // .get(&uri, Default::default(), true) - // .await?; - // let mut body = Vec::new(); - // root_manifest.body_mut().read_to_end(&mut body).await?; - // let as_str = String::from_utf8(body)?; - // let json = serde_json::Value::from_str(&as_str)?; - // let latest_version = json - // .get("info") - // .and_then(|info| info.get("version")) - // .and_then(|version| version.as_str().map(ToOwned::to_owned)) - // .ok_or_else(|| { - // anyhow!("PyPI response did not contain version info for python-language-server") - // })?; Ok(Box::new(()) as Box<_>) } diff --git a/crates/languages/src/python/highlights.scm b/crates/languages/src/python/highlights.scm index 6c3f027c19..3b318fe962 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 @@ -96,7 +102,16 @@ "def" name: (_) (parameters)? - body: (block (expression_statement (string) @string.doc))) + body: (block . (expression_statement (string) @string.doc))) + +(class_definition + body: (block + . (comment) @comment* + . (expression_statement (string) @string.doc))) + +(module + . (comment) @comment* + . (expression_statement (string) @string.doc)) (module (expression_statement (assignment)) diff --git a/crates/languages/src/python/textobjects.scm b/crates/languages/src/python/textobjects.scm new file mode 100644 index 0000000000..abd28ab75a --- /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 d0938024d6..85f2e370d6 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 7f5912d73e..274d96f5fa 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, @@ -76,6 +77,7 @@ impl LspAdapter for RustLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, + _: Arc, _: &AsyncAppContext, ) -> Option { let path = delegate.which("rust-analyzer".as_ref()).await?; @@ -443,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")); @@ -468,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(), + ), ]))); } } @@ -567,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(), @@ -576,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()), @@ -634,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); @@ -655,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, + }) }, ) } @@ -666,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)); } } } @@ -1065,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"}]}]}"#, @@ -1073,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, )), ), ( @@ -1087,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/textobjects.scm b/crates/languages/src/rust/textobjects.scm new file mode 100644 index 0000000000..4e7e7fa0cd --- /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 0c3589071d..34b80b733b 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 68c81d04c7..af619dacb7 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 0000000000..836fed35ba --- /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 c580575a1e..076d8d3374 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 0c3589071d..34b80b733b 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 21a965fd31..af619dacb7 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 0000000000..836fed35ba --- /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/vtsls.rs b/crates/languages/src/vtsls.rs index 0ad9158003..e44e4e295f 100644 --- a/crates/languages/src/vtsls.rs +++ b/crates/languages/src/vtsls.rs @@ -77,6 +77,7 @@ impl LspAdapter for VtslsLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, + _: Arc, _: &AsyncAppContext, ) -> Option { let env = delegate.shell_env().await; diff --git a/crates/languages/src/yaml/textobjects.scm b/crates/languages/src/yaml/textobjects.scm new file mode 100644 index 0000000000..5262b7e232 --- /dev/null +++ b/crates/languages/src/yaml/textobjects.scm @@ -0,0 +1 @@ +(comment)+ @comment diff --git a/crates/live_kit_client/.cargo/config.toml b/crates/livekit_client/.cargo/config.toml similarity index 62% rename from crates/live_kit_client/.cargo/config.toml rename to crates/livekit_client/.cargo/config.toml index b33fe211bd..77f7c9dd6c 100644 --- a/crates/live_kit_client/.cargo/config.toml +++ b/crates/livekit_client/.cargo/config.toml @@ -1,2 +1,2 @@ -[live_kit_client_test] +[livekit_client_test] rustflags = ["-C", "link-args=-ObjC"] diff --git a/crates/livekit_client/Cargo.toml b/crates/livekit_client/Cargo.toml new file mode 100644 index 0000000000..ac0c3b5740 --- /dev/null +++ b/crates/livekit_client/Cargo.toml @@ -0,0 +1,65 @@ +[package] +name = "livekit_client" +version = "0.1.0" +edition = "2021" +description = "Logic for using LiveKit with GPUI" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/livekit_client.rs" +doctest = false + +[[example]] +name = "test_app" + +[features] +no-webrtc = [] +test-support = [ + "collections/test-support", + "gpui/test-support", + "nanoid", +] + +[dependencies] +anyhow.workspace = true +async-trait.workspace = true +collections.workspace = true +cpal = "0.15" +futures.workspace = true +gpui.workspace = true +http_2 = { package = "http", version = "0.2.1" } +livekit_server.workspace = true +log.workspace = true +media.workspace = true +nanoid = { workspace = true, optional = true} +parking_lot.workspace = true +postage.workspace = true +util.workspace = true +http_client.workspace = true +smallvec.workspace = true +image.workspace = true + +[target.'cfg(not(target_os = "windows"))'.dependencies] +livekit.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation.workspace = true +coreaudio-rs = "0.12.1" + +[dev-dependencies] +collections = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +nanoid.workspace = true +sha2.workspace = true +simplelog.workspace = true + +[build-dependencies] +serde.workspace = true +serde_json.workspace = true + +[package.metadata.cargo-machete] +ignored = ["serde_json"] diff --git a/crates/livekit_client/LICENSE-GPL b/crates/livekit_client/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/livekit_client/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/livekit_client/examples/test_app.rs b/crates/livekit_client/examples/test_app.rs new file mode 100644 index 0000000000..ef7fc91d31 --- /dev/null +++ b/crates/livekit_client/examples/test_app.rs @@ -0,0 +1,442 @@ +#![cfg_attr(windows, allow(unused))] +// TODO: For some reason mac build complains about import of postage::stream::Stream, but removal of +// it causes compile errors. +#![cfg_attr(target_os = "macos", allow(unused_imports))] + +use gpui::{ + actions, bounds, div, point, + prelude::{FluentBuilder as _, IntoElement}, + px, rgb, size, AsyncAppContext, Bounds, InteractiveElement, KeyBinding, Menu, MenuItem, + ParentElement, Pixels, Render, ScreenCaptureStream, SharedString, + StatefulInteractiveElement as _, Styled, Task, View, ViewContext, VisualContext, WindowBounds, + WindowHandle, WindowOptions, +}; +#[cfg(not(target_os = "windows"))] +use livekit_client::{ + capture_local_audio_track, capture_local_video_track, + id::ParticipantIdentity, + options::{TrackPublishOptions, VideoCodec}, + participant::{Participant, RemoteParticipant}, + play_remote_audio_track, + publication::{LocalTrackPublication, RemoteTrackPublication}, + track::{LocalTrack, RemoteTrack, RemoteVideoTrack, TrackSource}, + AudioStream, RemoteVideoTrackView, Room, RoomEvent, RoomOptions, +}; +#[cfg(not(target_os = "windows"))] +use postage::stream::Stream; + +#[cfg(target_os = "windows")] +use livekit_client::{ + participant::{Participant, RemoteParticipant}, + publication::{LocalTrackPublication, RemoteTrackPublication}, + track::{LocalTrack, RemoteTrack, RemoteVideoTrack}, + AudioStream, RemoteVideoTrackView, Room, RoomEvent, +}; + +use livekit_server::token::{self, VideoGrant}; +use log::LevelFilter; +use simplelog::SimpleLogger; + +actions!(livekit_client, [Quit]); + +#[cfg(windows)] +fn main() {} + +#[cfg(not(windows))] +fn main() { + SimpleLogger::init(LevelFilter::Info, Default::default()).expect("could not initialize logger"); + + gpui::App::new().run(|cx| { + livekit_client::init( + cx.background_executor().dispatcher.clone(), + cx.http_client(), + ); + + #[cfg(any(test, feature = "test-support"))] + println!("USING TEST LIVEKIT"); + + #[cfg(not(any(test, feature = "test-support")))] + println!("USING REAL LIVEKIT"); + + cx.activate(true); + cx.on_action(quit); + cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); + cx.set_menus(vec![Menu { + name: "Zed".into(), + items: vec![MenuItem::Action { + name: "Quit".into(), + action: Box::new(Quit), + os_action: None, + }], + }]); + + let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or("http://localhost:7880".into()); + let livekit_key = std::env::var("LIVEKIT_KEY").unwrap_or("devkey".into()); + let livekit_secret = std::env::var("LIVEKIT_SECRET").unwrap_or("secret".into()); + let height = px(800.); + let width = px(800.); + + cx.spawn(|cx| async move { + let mut windows = Vec::new(); + for i in 0..2 { + let token = token::create( + &livekit_key, + &livekit_secret, + Some(&format!("test-participant-{i}")), + VideoGrant::to_join("test-room"), + ) + .unwrap(); + + let bounds = bounds(point(width * i, px(0.0)), size(width, height)); + let window = + LivekitWindow::new(livekit_url.as_str(), token.as_str(), bounds, cx.clone()) + .await; + windows.push(window); + } + }) + .detach(); + }); +} + +fn quit(_: &Quit, cx: &mut gpui::AppContext) { + cx.quit(); +} + +struct LivekitWindow { + room: Room, + microphone_track: Option, + screen_share_track: Option, + microphone_stream: Option, + screen_share_stream: Option>, + #[cfg(not(target_os = "windows"))] + remote_participants: Vec<(ParticipantIdentity, ParticipantState)>, + _events_task: Task<()>, +} + +#[derive(Default)] +struct ParticipantState { + audio_output_stream: Option<(RemoteTrackPublication, AudioStream)>, + muted: bool, + screen_share_output_view: Option<(RemoteVideoTrack, View)>, + speaking: bool, +} + +#[cfg(not(windows))] +impl LivekitWindow { + async fn new( + url: &str, + token: &str, + bounds: Bounds, + cx: AsyncAppContext, + ) -> WindowHandle { + let (room, mut events) = Room::connect(url, token, RoomOptions::default()) + .await + .unwrap(); + + cx.update(|cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |cx| { + cx.new_view(|cx| { + let _events_task = cx.spawn(|this, mut cx| async move { + while let Some(event) = events.recv().await { + this.update(&mut cx, |this: &mut LivekitWindow, cx| { + this.handle_room_event(event, cx) + }) + .ok(); + } + }); + + Self { + room, + microphone_track: None, + microphone_stream: None, + screen_share_track: None, + screen_share_stream: None, + remote_participants: Vec::new(), + _events_task, + } + }) + }, + ) + .unwrap() + }) + .unwrap() + } + + fn handle_room_event(&mut self, event: RoomEvent, cx: &mut ViewContext) { + eprintln!("event: {event:?}"); + + match event { + RoomEvent::TrackUnpublished { + publication, + participant, + } => { + let output = self.remote_participant(participant); + let unpublish_sid = publication.sid(); + if output + .audio_output_stream + .as_ref() + .map_or(false, |(track, _)| track.sid() == unpublish_sid) + { + output.audio_output_stream.take(); + } + if output + .screen_share_output_view + .as_ref() + .map_or(false, |(track, _)| track.sid() == unpublish_sid) + { + output.screen_share_output_view.take(); + } + cx.notify(); + } + + RoomEvent::TrackSubscribed { + publication, + participant, + track, + } => { + let output = self.remote_participant(participant); + match track { + RemoteTrack::Audio(track) => { + output.audio_output_stream = Some(( + publication.clone(), + play_remote_audio_track(&track, cx.background_executor()).unwrap(), + )); + } + RemoteTrack::Video(track) => { + output.screen_share_output_view = Some(( + track.clone(), + cx.new_view(|cx| RemoteVideoTrackView::new(track, cx)), + )); + } + } + cx.notify(); + } + + RoomEvent::TrackMuted { participant, .. } => { + if let Participant::Remote(participant) = participant { + self.remote_participant(participant).muted = true; + cx.notify(); + } + } + + RoomEvent::TrackUnmuted { participant, .. } => { + if let Participant::Remote(participant) = participant { + self.remote_participant(participant).muted = false; + cx.notify(); + } + } + + RoomEvent::ActiveSpeakersChanged { speakers } => { + for (identity, output) in &mut self.remote_participants { + output.speaking = speakers.iter().any(|speaker| { + if let Participant::Remote(speaker) = speaker { + speaker.identity() == *identity + } else { + false + } + }); + } + cx.notify(); + } + + _ => {} + } + + cx.notify(); + } + + fn remote_participant(&mut self, participant: RemoteParticipant) -> &mut ParticipantState { + match self + .remote_participants + .binary_search_by_key(&&participant.identity(), |row| &row.0) + { + Ok(ix) => &mut self.remote_participants[ix].1, + Err(ix) => { + self.remote_participants + .insert(ix, (participant.identity(), ParticipantState::default())); + &mut self.remote_participants[ix].1 + } + } + } + + fn toggle_mute(&mut self, cx: &mut ViewContext) { + if let Some(track) = &self.microphone_track { + if track.is_muted() { + track.unmute(); + } else { + track.mute(); + } + cx.notify(); + } else { + let participant = self.room.local_participant(); + cx.spawn(|this, mut cx| async move { + let (track, stream) = capture_local_audio_track(cx.background_executor())?.await; + let publication = participant + .publish_track( + LocalTrack::Audio(track), + TrackPublishOptions { + source: TrackSource::Microphone, + ..Default::default() + }, + ) + .await + .unwrap(); + this.update(&mut cx, |this, cx| { + this.microphone_track = Some(publication); + this.microphone_stream = Some(stream); + cx.notify(); + }) + }) + .detach(); + } + } + + fn toggle_screen_share(&mut self, cx: &mut ViewContext) { + if let Some(track) = self.screen_share_track.take() { + self.screen_share_stream.take(); + let participant = self.room.local_participant(); + cx.background_executor() + .spawn(async move { + participant.unpublish_track(&track.sid()).await.unwrap(); + }) + .detach(); + cx.notify(); + } else { + let participant = self.room.local_participant(); + let sources = cx.screen_capture_sources(); + cx.spawn(|this, mut cx| async move { + let sources = sources.await.unwrap()?; + let source = sources.into_iter().next().unwrap(); + let (track, stream) = capture_local_video_track(&*source).await?; + let publication = participant + .publish_track( + LocalTrack::Video(track), + TrackPublishOptions { + source: TrackSource::Screenshare, + video_codec: VideoCodec::H264, + ..Default::default() + }, + ) + .await + .unwrap(); + this.update(&mut cx, |this, cx| { + this.screen_share_track = Some(publication); + this.screen_share_stream = Some(stream); + cx.notify(); + }) + }) + .detach(); + } + } + + fn toggle_remote_audio_for_participant( + &mut self, + identity: &ParticipantIdentity, + cx: &mut ViewContext, + ) -> Option<()> { + let participant = self.remote_participants.iter().find_map(|(id, state)| { + if id == identity { + Some(state) + } else { + None + } + })?; + let publication = &participant.audio_output_stream.as_ref()?.0; + publication.set_enabled(!publication.is_enabled()); + cx.notify(); + Some(()) + } +} + +#[cfg(not(windows))] +impl Render for LivekitWindow { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + fn button() -> gpui::Div { + div() + .w(px(180.0)) + .h(px(30.0)) + .px_2() + .m_2() + .bg(rgb(0x8888ff)) + } + + div() + .bg(rgb(0xffffff)) + .size_full() + .flex() + .flex_col() + .child( + div().bg(rgb(0xffd4a8)).flex().flex_row().children([ + button() + .id("toggle-mute") + .child(if let Some(track) = &self.microphone_track { + if track.is_muted() { + "Unmute" + } else { + "Mute" + } + } else { + "Publish mic" + }) + .on_click(cx.listener(|this, _, cx| this.toggle_mute(cx))), + button() + .id("toggle-screen-share") + .child(if self.screen_share_track.is_none() { + "Share screen" + } else { + "Unshare screen" + }) + .on_click(cx.listener(|this, _, cx| this.toggle_screen_share(cx))), + ]), + ) + .child( + div() + .id("remote-participants") + .overflow_y_scroll() + .flex() + .flex_col() + .flex_grow() + .children(self.remote_participants.iter().map(|(identity, state)| { + div() + .h(px(300.0)) + .flex() + .flex_col() + .m_2() + .px_2() + .bg(rgb(0x8888ff)) + .child(SharedString::from(if state.speaking { + format!("{} (speaking)", &identity.0) + } else if state.muted { + format!("{} (muted)", &identity.0) + } else { + identity.0.clone() + })) + .when_some(state.audio_output_stream.as_ref(), |el, state| { + el.child( + button() + .id(SharedString::from(identity.0.clone())) + .child(if state.0.is_enabled() { + "Deafen" + } else { + "Undeafen" + }) + .on_click(cx.listener({ + let identity = identity.clone(); + move |this, _, cx| { + this.toggle_remote_audio_for_participant( + &identity, cx, + ); + } + })), + ) + }) + .children(state.screen_share_output_view.as_ref().map(|e| e.1.clone())) + })), + ) + } +} diff --git a/crates/livekit_client/src/livekit_client.rs b/crates/livekit_client/src/livekit_client.rs new file mode 100644 index 0000000000..5031dfdb33 --- /dev/null +++ b/crates/livekit_client/src/livekit_client.rs @@ -0,0 +1,661 @@ +#![cfg_attr(target_os = "windows", allow(unused))] + +mod remote_video_track_view; +#[cfg(any(test, feature = "test-support", target_os = "windows"))] +pub mod test; + +use anyhow::{anyhow, Context as _, Result}; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _}; +use futures::{io, Stream, StreamExt as _}; +use gpui::{ + BackgroundExecutor, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, Task, +}; +use parking_lot::Mutex; +use std::{borrow::Cow, collections::VecDeque, future::Future, pin::Pin, sync::Arc, thread}; +use util::{debug_panic, ResultExt as _}; +#[cfg(not(target_os = "windows"))] +use webrtc::{ + audio_frame::AudioFrame, + audio_source::{native::NativeAudioSource, AudioSourceOptions, RtcAudioSource}, + audio_stream::native::NativeAudioStream, + video_frame::{VideoBuffer, VideoFrame, VideoRotation}, + video_source::{native::NativeVideoSource, RtcVideoSource, VideoResolution}, + video_stream::native::NativeVideoStream, +}; + +#[cfg(all(not(any(test, feature = "test-support")), not(target_os = "windows")))] +use livekit::track::RemoteAudioTrack; +#[cfg(all(not(any(test, feature = "test-support")), not(target_os = "windows")))] +pub use livekit::*; +#[cfg(any(test, feature = "test-support", target_os = "windows"))] +use test::track::RemoteAudioTrack; +#[cfg(any(test, feature = "test-support", target_os = "windows"))] +pub use test::*; + +pub use remote_video_track_view::{RemoteVideoTrackView, RemoteVideoTrackViewEvent}; + +pub enum AudioStream { + Input { + _thread_handle: std::sync::mpsc::Sender<()>, + _transmit_task: Task<()>, + }, + Output { + _task: Task<()>, + }, +} + +struct Dispatcher(Arc); + +#[cfg(not(target_os = "windows"))] +impl livekit::dispatcher::Dispatcher for Dispatcher { + fn dispatch(&self, runnable: livekit::dispatcher::Runnable) { + self.0.dispatch(runnable, None); + } + + fn dispatch_after( + &self, + duration: std::time::Duration, + runnable: livekit::dispatcher::Runnable, + ) { + self.0.dispatch_after(duration, runnable); + } +} + +struct HttpClientAdapter(Arc); + +fn http_2_status(status: http_client::http::StatusCode) -> http_2::StatusCode { + http_2::StatusCode::from_u16(status.as_u16()) + .expect("valid status code to status code conversion") +} + +#[cfg(not(target_os = "windows"))] +impl livekit::dispatcher::HttpClient for HttpClientAdapter { + fn get( + &self, + url: &str, + ) -> Pin> + Send>> { + let http_client = self.0.clone(); + let url = url.to_string(); + Box::pin(async move { + let response = http_client + .get(&url, http_client::AsyncBody::empty(), false) + .await + .map_err(io::Error::other)?; + Ok(livekit::dispatcher::Response { + status: http_2_status(response.status()), + body: Box::pin(response.into_body()), + }) + }) + } + + fn send_async( + &self, + request: http_2::Request>, + ) -> Pin> + Send>> { + let http_client = self.0.clone(); + let mut builder = http_client::http::Request::builder() + .method(request.method().as_str()) + .uri(request.uri().to_string()); + + for (key, value) in request.headers().iter() { + builder = builder.header(key.as_str(), value.as_bytes()); + } + + if !request.extensions().is_empty() { + debug_panic!( + "Livekit sent an HTTP request with a protocol extension that Zed doesn't support!" + ); + } + + let request = builder + .body(http_client::AsyncBody::from_bytes( + request.into_body().into(), + )) + .unwrap(); + + Box::pin(async move { + let response = http_client.send(request).await.map_err(io::Error::other)?; + Ok(livekit::dispatcher::Response { + status: http_2_status(response.status()), + body: Box::pin(response.into_body()), + }) + }) + } +} + +#[cfg(target_os = "windows")] +pub fn init( + dispatcher: Arc, + http_client: Arc, +) { +} + +#[cfg(not(target_os = "windows"))] +pub fn init( + dispatcher: Arc, + http_client: Arc, +) { + livekit::dispatcher::set_dispatcher(Dispatcher(dispatcher)); + livekit::dispatcher::set_http_client(HttpClientAdapter(http_client)); +} + +#[cfg(not(target_os = "windows"))] +pub async fn capture_local_video_track( + capture_source: &dyn ScreenCaptureSource, +) -> Result<(track::LocalVideoTrack, Box)> { + let resolution = capture_source.resolution()?; + let track_source = NativeVideoSource::new(VideoResolution { + width: resolution.width.0 as u32, + height: resolution.height.0 as u32, + }); + + let capture_stream = capture_source + .stream({ + let track_source = track_source.clone(); + Box::new(move |frame| { + if let Some(buffer) = video_frame_buffer_to_webrtc(frame) { + track_source.capture_frame(&VideoFrame { + rotation: VideoRotation::VideoRotation0, + timestamp_us: 0, + buffer, + }); + } + }) + }) + .await??; + + Ok(( + track::LocalVideoTrack::create_video_track( + "screen share", + RtcVideoSource::Native(track_source), + ), + capture_stream, + )) +} + +#[cfg(not(target_os = "windows"))] +pub fn capture_local_audio_track( + background_executor: &BackgroundExecutor, +) -> Result> { + use util::maybe; + + let (frame_tx, mut frame_rx) = futures::channel::mpsc::unbounded(); + let (thread_handle, thread_kill_rx) = std::sync::mpsc::channel::<()>(); + let sample_rate; + let channels; + + if cfg!(any(test, feature = "test-support")) { + sample_rate = 2; + channels = 1; + } else { + let (device, config) = default_device(true)?; + sample_rate = config.sample_rate().0; + channels = config.channels() as u32; + thread::spawn(move || { + maybe!({ + if let Some(name) = device.name().ok() { + log::info!("Using microphone: {}", name) + } else { + log::info!("Using microphone: "); + } + + let stream = device + .build_input_stream_raw( + &config.config(), + cpal::SampleFormat::I16, + move |data, _: &_| { + frame_tx + .unbounded_send(AudioFrame { + data: Cow::Owned(data.as_slice::().unwrap().to_vec()), + sample_rate, + num_channels: channels, + samples_per_channel: data.len() as u32 / channels, + }) + .ok(); + }, + |err| log::error!("error capturing audio track: {:?}", err), + None, + ) + .context("failed to build input stream")?; + + stream.play()?; + // Keep the thread alive and holding onto the `stream` + thread_kill_rx.recv().ok(); + anyhow::Ok(Some(())) + }) + .log_err(); + }); + } + + Ok(background_executor.spawn({ + let background_executor = background_executor.clone(); + async move { + let source = NativeAudioSource::new( + AudioSourceOptions { + echo_cancellation: true, + noise_suppression: true, + auto_gain_control: true, + }, + sample_rate, + channels, + 100, + ); + let transmit_task = background_executor.spawn({ + let source = source.clone(); + async move { + while let Some(frame) = frame_rx.next().await { + source.capture_frame(&frame).await.log_err(); + } + } + }); + + let track = track::LocalAudioTrack::create_audio_track( + "microphone", + RtcAudioSource::Native(source), + ); + + ( + track, + AudioStream::Input { + _thread_handle: thread_handle, + _transmit_task: transmit_task, + }, + ) + } + })) +} + +#[cfg(not(target_os = "windows"))] +pub fn play_remote_audio_track( + track: &RemoteAudioTrack, + background_executor: &BackgroundExecutor, +) -> Result { + let track = track.clone(); + // We track device changes in our output because Livekit has a resampler built in, + // and it's easy to create a new native audio stream when the device changes. + if cfg!(any(test, feature = "test-support")) { + Ok(AudioStream::Output { + _task: background_executor.spawn(async {}), + }) + } else { + let mut default_change_listener = DeviceChangeListener::new(false)?; + let (output_device, output_config) = default_device(false)?; + + let _task = background_executor.spawn({ + let background_executor = background_executor.clone(); + async move { + let (mut _receive_task, mut _thread) = + start_output_stream(output_config, output_device, &track, &background_executor); + + while let Some(_) = default_change_listener.next().await { + let Some((output_device, output_config)) = get_default_output().log_err() + else { + continue; + }; + + if let Ok(name) = output_device.name() { + log::info!("Using speaker: {}", name) + } else { + log::info!("Using speaker: ") + } + + (_receive_task, _thread) = start_output_stream( + output_config, + output_device, + &track, + &background_executor, + ); + } + + futures::future::pending::<()>().await; + } + }); + + Ok(AudioStream::Output { _task }) + } +} + +fn default_device(input: bool) -> anyhow::Result<(cpal::Device, cpal::SupportedStreamConfig)> { + let device; + let config; + if input { + device = cpal::default_host() + .default_input_device() + .ok_or_else(|| anyhow!("no audio input device available"))?; + config = device + .default_input_config() + .context("failed to get default input config")?; + } else { + device = cpal::default_host() + .default_output_device() + .ok_or_else(|| anyhow!("no audio output device available"))?; + config = device + .default_output_config() + .context("failed to get default output config")?; + } + Ok((device, config)) +} + +#[cfg(not(target_os = "windows"))] +fn get_default_output() -> anyhow::Result<(cpal::Device, cpal::SupportedStreamConfig)> { + let host = cpal::default_host(); + let output_device = host + .default_output_device() + .context("failed to read default output device")?; + let output_config = output_device.default_output_config()?; + Ok((output_device, output_config)) +} + +#[cfg(not(target_os = "windows"))] +fn start_output_stream( + output_config: cpal::SupportedStreamConfig, + output_device: cpal::Device, + track: &track::RemoteAudioTrack, + background_executor: &BackgroundExecutor, +) -> (Task<()>, std::sync::mpsc::Sender<()>) { + let buffer = Arc::new(Mutex::new(VecDeque::::new())); + let sample_rate = output_config.sample_rate(); + + let mut stream = NativeAudioStream::new( + track.rtc_track(), + sample_rate.0 as i32, + output_config.channels() as i32, + ); + + let receive_task = background_executor.spawn({ + let buffer = buffer.clone(); + async move { + const MS_OF_BUFFER: u32 = 100; + const MS_IN_SEC: u32 = 1000; + while let Some(frame) = stream.next().await { + let frame_size = frame.samples_per_channel * frame.num_channels; + debug_assert!(frame.data.len() == frame_size as usize); + + let buffer_size = + ((frame.sample_rate * frame.num_channels) / MS_IN_SEC * MS_OF_BUFFER) as usize; + + let mut buffer = buffer.lock(); + let new_size = buffer.len() + frame.data.len(); + if new_size > buffer_size { + let overflow = new_size - buffer_size; + buffer.drain(0..overflow); + } + + buffer.extend(frame.data.iter()); + } + } + }); + + // The _output_stream needs to be on it's own thread because it's !Send + // and we experienced a deadlock when it's created on the main thread. + let (thread, end_on_drop_rx) = std::sync::mpsc::channel::<()>(); + thread::spawn(move || { + if cfg!(any(test, feature = "test-support")) { + // Can't play audio in tests + return; + } + + let output_stream = output_device.build_output_stream( + &output_config.config(), + { + let buffer = buffer.clone(); + move |data, _info| { + let mut buffer = buffer.lock(); + if buffer.len() < data.len() { + // Instead of partially filling a buffer, output silence. If a partial + // buffer was outputted then this could lead to a perpetual state of + // outputting partial buffers as it never gets filled enough for a full + // frame. + data.fill(0); + } else { + // SAFETY: We know that buffer has at least data.len() values in it. + // because we just checked + let mut drain = buffer.drain(..data.len()); + data.fill_with(|| unsafe { drain.next().unwrap_unchecked() }); + } + } + }, + |error| log::error!("error playing audio track: {:?}", error), + None, + ); + + let Some(output_stream) = output_stream.log_err() else { + return; + }; + + output_stream.play().log_err(); + // Block forever to keep the output stream alive + end_on_drop_rx.recv().ok(); + }); + + (receive_task, thread) +} + +#[cfg(target_os = "windows")] +pub fn play_remote_video_track( + track: &track::RemoteVideoTrack, +) -> impl Stream { + futures::stream::empty() +} + +#[cfg(not(target_os = "windows"))] +pub fn play_remote_video_track( + track: &track::RemoteVideoTrack, +) -> impl Stream { + NativeVideoStream::new(track.rtc_track()) + .filter_map(|frame| async move { video_frame_buffer_from_webrtc(frame.buffer) }) +} + +#[cfg(target_os = "macos")] +pub type RemoteVideoFrame = media::core_video::CVImageBuffer; + +#[cfg(target_os = "macos")] +fn video_frame_buffer_from_webrtc(buffer: Box) -> Option { + use core_foundation::base::TCFType as _; + use media::core_video::CVImageBuffer; + + let buffer = buffer.as_native()?; + let pixel_buffer = buffer.get_cv_pixel_buffer(); + if pixel_buffer.is_null() { + return None; + } + + unsafe { Some(CVImageBuffer::wrap_under_get_rule(pixel_buffer as _)) } +} + +#[cfg(not(target_os = "macos"))] +pub type RemoteVideoFrame = Arc; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn video_frame_buffer_from_webrtc(buffer: Box) -> Option { + use gpui::RenderImage; + use image::{Frame, RgbaImage}; + use livekit::webrtc::prelude::VideoFormatType; + use smallvec::SmallVec; + use std::alloc::{alloc, Layout}; + + let width = buffer.width(); + let height = buffer.height(); + let stride = width * 4; + let byte_len = (stride * height) as usize; + let argb_image = unsafe { + // Motivation for this unsafe code is to avoid initializing the frame data, since to_argb + // will write all bytes anyway. + let start_ptr = alloc(Layout::array::(byte_len).log_err()?); + if start_ptr.is_null() { + return None; + } + let bgra_frame_slice = std::slice::from_raw_parts_mut(start_ptr, byte_len); + buffer.to_argb( + VideoFormatType::ARGB, // For some reason, this displays correctly while RGBA (the correct format) does not + bgra_frame_slice, + stride, + width as i32, + height as i32, + ); + Vec::from_raw_parts(start_ptr, byte_len, byte_len) + }; + + Some(Arc::new(RenderImage::new(SmallVec::from_elem( + Frame::new( + RgbaImage::from_raw(width, height, argb_image) + .with_context(|| "Bug: not enough bytes allocated for image.") + .log_err()?, + ), + 1, + )))) +} + +#[cfg(target_os = "macos")] +fn video_frame_buffer_to_webrtc(frame: ScreenCaptureFrame) -> Option> { + use core_foundation::base::TCFType as _; + + let pixel_buffer = frame.0.as_concrete_TypeRef(); + std::mem::forget(frame.0); + unsafe { + Some(webrtc::video_frame::native::NativeBuffer::from_cv_pixel_buffer(pixel_buffer as _)) + } +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn video_frame_buffer_to_webrtc(_frame: ScreenCaptureFrame) -> Option> { + None as Option> +} + +trait DeviceChangeListenerApi: Stream + Sized { + fn new(input: bool) -> Result; +} + +#[cfg(target_os = "macos")] +mod macos { + + use coreaudio::sys::{ + kAudioHardwarePropertyDefaultInputDevice, kAudioHardwarePropertyDefaultOutputDevice, + kAudioObjectPropertyElementMaster, kAudioObjectPropertyScopeGlobal, + kAudioObjectSystemObject, AudioObjectAddPropertyListener, AudioObjectID, + AudioObjectPropertyAddress, AudioObjectRemovePropertyListener, OSStatus, + }; + use futures::{channel::mpsc::UnboundedReceiver, StreamExt}; + + use crate::DeviceChangeListenerApi; + + /// Implementation from: https://github.com/zed-industries/cpal/blob/fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50/src/host/coreaudio/macos/property_listener.rs#L15 + pub struct CoreAudioDefaultDeviceChangeListener { + rx: UnboundedReceiver<()>, + callback: Box, + input: bool, + } + + trait _AssertSend: Send {} + impl _AssertSend for CoreAudioDefaultDeviceChangeListener {} + + struct PropertyListenerCallbackWrapper(Box); + + unsafe extern "C" fn property_listener_handler_shim( + _: AudioObjectID, + _: u32, + _: *const AudioObjectPropertyAddress, + callback: *mut ::std::os::raw::c_void, + ) -> OSStatus { + let wrapper = callback as *mut PropertyListenerCallbackWrapper; + (*wrapper).0(); + 0 + } + + impl DeviceChangeListenerApi for CoreAudioDefaultDeviceChangeListener { + fn new(input: bool) -> gpui::Result { + let (tx, rx) = futures::channel::mpsc::unbounded(); + + let callback = Box::new(PropertyListenerCallbackWrapper(Box::new(move || { + tx.unbounded_send(()).ok(); + }))); + + unsafe { + coreaudio::Error::from_os_status(AudioObjectAddPropertyListener( + kAudioObjectSystemObject, + &AudioObjectPropertyAddress { + mSelector: if input { + kAudioHardwarePropertyDefaultInputDevice + } else { + kAudioHardwarePropertyDefaultOutputDevice + }, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster, + }, + Some(property_listener_handler_shim), + &*callback as *const _ as *mut _, + ))?; + } + + Ok(Self { + rx, + callback, + input, + }) + } + } + + impl Drop for CoreAudioDefaultDeviceChangeListener { + fn drop(&mut self) { + unsafe { + AudioObjectRemovePropertyListener( + kAudioObjectSystemObject, + &AudioObjectPropertyAddress { + mSelector: if self.input { + kAudioHardwarePropertyDefaultInputDevice + } else { + kAudioHardwarePropertyDefaultOutputDevice + }, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster, + }, + Some(property_listener_handler_shim), + &*self.callback as *const _ as *mut _, + ); + } + } + } + + impl futures::Stream for CoreAudioDefaultDeviceChangeListener { + type Item = (); + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.rx.poll_next_unpin(cx) + } + } +} + +#[cfg(target_os = "macos")] +type DeviceChangeListener = macos::CoreAudioDefaultDeviceChangeListener; + +#[cfg(not(target_os = "macos"))] +mod noop_change_listener { + use std::task::Poll; + + use crate::DeviceChangeListenerApi; + + pub struct NoopOutputDeviceChangelistener {} + + impl DeviceChangeListenerApi for NoopOutputDeviceChangelistener { + fn new(_input: bool) -> anyhow::Result { + Ok(NoopOutputDeviceChangelistener {}) + } + } + + impl futures::Stream for NoopOutputDeviceChangelistener { + type Item = (); + + fn poll_next( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> Poll> { + Poll::Pending + } + } +} + +#[cfg(not(target_os = "macos"))] +type DeviceChangeListener = noop_change_listener::NoopOutputDeviceChangelistener; diff --git a/crates/livekit_client/src/remote_video_track_view.rs b/crates/livekit_client/src/remote_video_track_view.rs new file mode 100644 index 0000000000..d7618391d6 --- /dev/null +++ b/crates/livekit_client/src/remote_video_track_view.rs @@ -0,0 +1,99 @@ +use crate::track::RemoteVideoTrack; +use anyhow::Result; +use futures::StreamExt as _; +use gpui::{Empty, EventEmitter, IntoElement, Render, Task, View, ViewContext, VisualContext as _}; + +pub struct RemoteVideoTrackView { + track: RemoteVideoTrack, + latest_frame: Option, + #[cfg(not(target_os = "macos"))] + current_rendered_frame: Option, + #[cfg(not(target_os = "macos"))] + previous_rendered_frame: Option, + _maintain_frame: Task>, +} + +#[derive(Debug)] +pub enum RemoteVideoTrackViewEvent { + Close, +} + +impl RemoteVideoTrackView { + pub fn new(track: RemoteVideoTrack, cx: &mut ViewContext) -> Self { + cx.focus_handle(); + let frames = super::play_remote_video_track(&track); + + Self { + track, + latest_frame: None, + _maintain_frame: cx.spawn(|this, mut cx| async move { + futures::pin_mut!(frames); + while let Some(frame) = frames.next().await { + this.update(&mut cx, |this, cx| { + this.latest_frame = Some(frame); + cx.notify(); + })?; + } + this.update(&mut cx, |_this, cx| { + #[cfg(not(target_os = "macos"))] + { + use util::ResultExt as _; + if let Some(frame) = _this.previous_rendered_frame.take() { + cx.window_context().drop_image(frame).log_err(); + } + // TODO(mgsloan): This might leak the last image of the screenshare if + // render is called after the screenshare ends. + if let Some(frame) = _this.current_rendered_frame.take() { + cx.window_context().drop_image(frame).log_err(); + } + } + cx.emit(RemoteVideoTrackViewEvent::Close) + })?; + Ok(()) + }), + #[cfg(not(target_os = "macos"))] + current_rendered_frame: None, + #[cfg(not(target_os = "macos"))] + previous_rendered_frame: None, + } + } + + pub fn clone(&self, cx: &mut ViewContext) -> View { + cx.new_view(|cx| Self::new(self.track.clone(), cx)) + } +} + +impl EventEmitter for RemoteVideoTrackView {} + +impl Render for RemoteVideoTrackView { + fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { + #[cfg(target_os = "macos")] + if let Some(latest_frame) = &self.latest_frame { + use gpui::Styled as _; + return gpui::surface(latest_frame.clone()) + .size_full() + .into_any_element(); + } + + #[cfg(not(target_os = "macos"))] + if let Some(latest_frame) = &self.latest_frame { + use gpui::Styled as _; + if let Some(current_rendered_frame) = self.current_rendered_frame.take() { + if let Some(frame) = self.previous_rendered_frame.take() { + // Only drop the frame if it's not also the current frame. + if frame.id != current_rendered_frame.id { + use util::ResultExt as _; + _cx.window_context().drop_image(frame).log_err(); + } + } + self.previous_rendered_frame = Some(current_rendered_frame) + } + self.current_rendered_frame = Some(latest_frame.clone()); + return gpui::img(latest_frame.clone()) + .size_full() + .into_any_element(); + } + + Empty.into_any_element() + } +} diff --git a/crates/livekit_client/src/test.rs b/crates/livekit_client/src/test.rs new file mode 100644 index 0000000000..e67189c09c --- /dev/null +++ b/crates/livekit_client/src/test.rs @@ -0,0 +1,825 @@ +pub mod participant; +pub mod publication; +pub mod track; + +#[cfg(not(windows))] +pub mod webrtc; + +#[cfg(not(windows))] +use self::id::*; +use self::{participant::*, publication::*, track::*}; +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use collections::{btree_map::Entry as BTreeEntry, hash_map::Entry, BTreeMap, HashMap, HashSet}; +use gpui::BackgroundExecutor; +#[cfg(not(windows))] +use livekit::options::TrackPublishOptions; +use livekit_server::{proto, token}; +use parking_lot::Mutex; +use postage::{mpsc, sink::Sink}; +use std::sync::{ + atomic::{AtomicBool, Ordering::SeqCst}, + Arc, Weak, +}; + +#[cfg(not(windows))] +pub use livekit::{id, options, ConnectionState, DisconnectReason, RoomOptions}; + +static SERVERS: Mutex>> = Mutex::new(BTreeMap::new()); + +pub struct TestServer { + pub url: String, + pub api_key: String, + pub secret_key: String, + #[cfg(not(target_os = "windows"))] + rooms: Mutex>, + executor: BackgroundExecutor, +} + +#[cfg(not(target_os = "windows"))] +impl TestServer { + pub fn create( + url: String, + api_key: String, + secret_key: String, + executor: BackgroundExecutor, + ) -> Result> { + let mut servers = SERVERS.lock(); + if let BTreeEntry::Vacant(e) = servers.entry(url.clone()) { + let server = Arc::new(TestServer { + url, + api_key, + secret_key, + rooms: Default::default(), + executor, + }); + e.insert(server.clone()); + Ok(server) + } else { + Err(anyhow!("a server with url {:?} already exists", url)) + } + } + + fn get(url: &str) -> Result> { + Ok(SERVERS + .lock() + .get(url) + .ok_or_else(|| anyhow!("no server found for url"))? + .clone()) + } + + pub fn teardown(&self) -> Result<()> { + SERVERS + .lock() + .remove(&self.url) + .ok_or_else(|| anyhow!("server with url {:?} does not exist", self.url))?; + Ok(()) + } + + pub fn create_api_client(&self) -> TestApiClient { + TestApiClient { + url: self.url.clone(), + } + } + + pub async fn create_room(&self, room: String) -> Result<()> { + self.executor.simulate_random_delay().await; + + let mut server_rooms = self.rooms.lock(); + if let Entry::Vacant(e) = server_rooms.entry(room.clone()) { + e.insert(Default::default()); + Ok(()) + } else { + Err(anyhow!("room {:?} already exists", room)) + } + } + + async fn delete_room(&self, room: String) -> Result<()> { + self.executor.simulate_random_delay().await; + + let mut server_rooms = self.rooms.lock(); + server_rooms + .remove(&room) + .ok_or_else(|| anyhow!("room {:?} does not exist", room))?; + Ok(()) + } + + async fn join_room(&self, token: String, client_room: Room) -> Result { + self.executor.simulate_random_delay().await; + + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + let room_name = claims.video.room.unwrap(); + let mut server_rooms = self.rooms.lock(); + let room = (*server_rooms).entry(room_name.to_string()).or_default(); + + if let Entry::Vacant(e) = room.client_rooms.entry(identity.clone()) { + for server_track in &room.video_tracks { + let track = RemoteTrack::Video(RemoteVideoTrack { + server_track: server_track.clone(), + _room: client_room.downgrade(), + }); + client_room + .0 + .lock() + .updates_tx + .blocking_send(RoomEvent::TrackSubscribed { + track: track.clone(), + publication: RemoteTrackPublication { + sid: server_track.sid.clone(), + room: client_room.downgrade(), + track, + }, + participant: RemoteParticipant { + room: client_room.downgrade(), + identity: server_track.publisher_id.clone(), + }, + }) + .unwrap(); + } + for server_track in &room.audio_tracks { + let track = RemoteTrack::Audio(RemoteAudioTrack { + server_track: server_track.clone(), + room: client_room.downgrade(), + }); + client_room + .0 + .lock() + .updates_tx + .blocking_send(RoomEvent::TrackSubscribed { + track: track.clone(), + publication: RemoteTrackPublication { + sid: server_track.sid.clone(), + room: client_room.downgrade(), + track, + }, + participant: RemoteParticipant { + room: client_room.downgrade(), + identity: server_track.publisher_id.clone(), + }, + }) + .unwrap(); + } + e.insert(client_room); + Ok(identity) + } else { + Err(anyhow!( + "{:?} attempted to join room {:?} twice", + identity, + room_name + )) + } + } + + async fn leave_room(&self, token: String) -> Result<()> { + self.executor.simulate_random_delay().await; + + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + let room_name = claims.video.room.unwrap(); + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&*room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + room.client_rooms.remove(&identity).ok_or_else(|| { + anyhow!( + "{:?} attempted to leave room {:?} before joining it", + identity, + room_name + ) + })?; + Ok(()) + } + + fn remote_participants( + &self, + token: String, + ) -> Result> { + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let local_identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + let room_name = claims.video.room.unwrap().to_string(); + + if let Some(server_room) = self.rooms.lock().get(&room_name) { + let room = server_room + .client_rooms + .get(&local_identity) + .unwrap() + .downgrade(); + Ok(server_room + .client_rooms + .iter() + .filter(|(identity, _)| *identity != &local_identity) + .map(|(identity, _)| { + ( + identity.clone(), + RemoteParticipant { + room: room.clone(), + identity: identity.clone(), + }, + ) + }) + .collect()) + } else { + Ok(Default::default()) + } + } + + async fn remove_participant( + &self, + room_name: String, + identity: ParticipantIdentity, + ) -> Result<()> { + self.executor.simulate_random_delay().await; + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + room.client_rooms.remove(&identity).ok_or_else(|| { + anyhow!( + "participant {:?} did not join room {:?}", + identity, + room_name + ) + })?; + Ok(()) + } + + async fn update_participant( + &self, + room_name: String, + identity: String, + permission: proto::ParticipantPermission, + ) -> Result<()> { + self.executor.simulate_random_delay().await; + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + room.participant_permissions + .insert(ParticipantIdentity(identity), permission); + Ok(()) + } + + pub async fn disconnect_client(&self, client_identity: String) { + let client_identity = ParticipantIdentity(client_identity); + + self.executor.simulate_random_delay().await; + + let mut server_rooms = self.rooms.lock(); + for room in server_rooms.values_mut() { + if let Some(room) = room.client_rooms.remove(&client_identity) { + let mut room = room.0.lock(); + room.connection_state = ConnectionState::Disconnected; + room.updates_tx + .blocking_send(RoomEvent::Disconnected { + reason: DisconnectReason::SignalClose, + }) + .ok(); + } + } + } + + async fn publish_video_track( + &self, + token: String, + _local_track: LocalVideoTrack, + ) -> Result { + self.executor.simulate_random_delay().await; + + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + let room_name = claims.video.room.unwrap(); + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&*room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + + let can_publish = room + .participant_permissions + .get(&identity) + .map(|permission| permission.can_publish) + .or(claims.video.can_publish) + .unwrap_or(true); + + if !can_publish { + return Err(anyhow!("user is not allowed to publish")); + } + + let sid: TrackSid = format!("TR_{}", nanoid::nanoid!(17)).try_into().unwrap(); + let server_track = Arc::new(TestServerVideoTrack { + sid: sid.clone(), + publisher_id: identity.clone(), + }); + + room.video_tracks.push(server_track.clone()); + + for (room_identity, client_room) in &room.client_rooms { + if *room_identity != identity { + let track = RemoteTrack::Video(RemoteVideoTrack { + server_track: server_track.clone(), + _room: client_room.downgrade(), + }); + let publication = RemoteTrackPublication { + sid: sid.clone(), + room: client_room.downgrade(), + track: track.clone(), + }; + let participant = RemoteParticipant { + identity: identity.clone(), + room: client_room.downgrade(), + }; + client_room + .0 + .lock() + .updates_tx + .blocking_send(RoomEvent::TrackSubscribed { + track, + publication, + participant, + }) + .unwrap(); + } + } + + Ok(sid) + } + + async fn publish_audio_track( + &self, + token: String, + _local_track: &LocalAudioTrack, + ) -> Result { + self.executor.simulate_random_delay().await; + + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + let room_name = claims.video.room.unwrap(); + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&*room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + + let can_publish = room + .participant_permissions + .get(&identity) + .map(|permission| permission.can_publish) + .or(claims.video.can_publish) + .unwrap_or(true); + + if !can_publish { + return Err(anyhow!("user is not allowed to publish")); + } + + let sid: TrackSid = format!("TR_{}", nanoid::nanoid!(17)).try_into().unwrap(); + let server_track = Arc::new(TestServerAudioTrack { + sid: sid.clone(), + publisher_id: identity.clone(), + muted: AtomicBool::new(false), + }); + + room.audio_tracks.push(server_track.clone()); + + for (room_identity, client_room) in &room.client_rooms { + if *room_identity != identity { + let track = RemoteTrack::Audio(RemoteAudioTrack { + server_track: server_track.clone(), + room: client_room.downgrade(), + }); + let publication = RemoteTrackPublication { + sid: sid.clone(), + room: client_room.downgrade(), + track: track.clone(), + }; + let participant = RemoteParticipant { + identity: identity.clone(), + room: client_room.downgrade(), + }; + client_room + .0 + .lock() + .updates_tx + .blocking_send(RoomEvent::TrackSubscribed { + track, + publication, + participant, + }) + .ok(); + } + } + + Ok(sid) + } + + async fn unpublish_track(&self, _token: String, _track: &TrackSid) -> Result<()> { + Ok(()) + } + + fn set_track_muted(&self, token: &str, track_sid: &TrackSid, muted: bool) -> Result<()> { + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let room_name = claims.video.room.unwrap(); + let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&*room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + if let Some(track) = room + .audio_tracks + .iter_mut() + .find(|track| track.sid == *track_sid) + { + track.muted.store(muted, SeqCst); + for (id, client_room) in room.client_rooms.iter() { + if *id != identity { + let participant = Participant::Remote(RemoteParticipant { + identity: identity.clone(), + room: client_room.downgrade(), + }); + let track = RemoteTrack::Audio(RemoteAudioTrack { + server_track: track.clone(), + room: client_room.downgrade(), + }); + let publication = TrackPublication::Remote(RemoteTrackPublication { + sid: track_sid.clone(), + room: client_room.downgrade(), + track, + }); + + let event = if muted { + RoomEvent::TrackMuted { + participant, + publication, + } + } else { + RoomEvent::TrackUnmuted { + participant, + publication, + } + }; + + client_room + .0 + .lock() + .updates_tx + .blocking_send(event) + .unwrap(); + } + } + } + Ok(()) + } + + fn is_track_muted(&self, token: &str, track_sid: &TrackSid) -> Option { + let claims = livekit_server::token::validate(&token, &self.secret_key).ok()?; + let room_name = claims.video.room.unwrap(); + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms.get_mut(&*room_name)?; + room.audio_tracks.iter().find_map(|track| { + if track.sid == *track_sid { + Some(track.muted.load(SeqCst)) + } else { + None + } + }) + } + + fn video_tracks(&self, token: String) -> Result> { + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let room_name = claims.video.room.unwrap(); + let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&*room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + let client_room = room + .client_rooms + .get(&identity) + .ok_or_else(|| anyhow!("not a participant in room"))?; + Ok(room + .video_tracks + .iter() + .map(|track| RemoteVideoTrack { + server_track: track.clone(), + _room: client_room.downgrade(), + }) + .collect()) + } + + fn audio_tracks(&self, token: String) -> Result> { + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let room_name = claims.video.room.unwrap(); + let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&*room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + let client_room = room + .client_rooms + .get(&identity) + .ok_or_else(|| anyhow!("not a participant in room"))?; + Ok(room + .audio_tracks + .iter() + .map(|track| RemoteAudioTrack { + server_track: track.clone(), + room: client_room.downgrade(), + }) + .collect()) + } +} + +#[cfg(not(target_os = "windows"))] +#[derive(Default, Debug)] +struct TestServerRoom { + client_rooms: HashMap, + video_tracks: Vec>, + audio_tracks: Vec>, + participant_permissions: HashMap, +} + +#[cfg(not(target_os = "windows"))] +#[derive(Debug)] +struct TestServerVideoTrack { + sid: TrackSid, + publisher_id: ParticipantIdentity, + // frames_rx: async_broadcast::Receiver(&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 ee10e924f4..7a68fdd6ba 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 std::{cmp, ops::ControlFlow, path::PathBuf, sync::Arc, time::Duration}; -use crate::{default_working_directory, TerminalView}; +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,15 @@ 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(), false, cx); + let center = PaneGroup::new(pane.clone()); + let enabled = project.read(cx).supports_terminal(cx); + cx.focus_view(&pane); + 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 +98,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 +122,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 +170,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 +236,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 +284,150 @@ 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::ZoomIn => cx.emit(PanelEvent::ZoomIn), - pane::Event::ZoomOut => cx.emit(PanelEvent::ZoomOut), - + 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 { + self.center.first_pane().update(cx, |pane, cx| { + pane.set_zoomed(false, cx); + }); + 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 => { + for pane in self.center.panes() { + pane.update(cx, |pane, cx| { + pane.set_zoomed(true, cx); + }) + } + cx.emit(PanelEvent::ZoomIn); + cx.notify(); + } + pane::Event::ZoomOut => { + for pane in self.center.panes() { + pane.update(cx, |pane, cx| { + pane.set_zoomed(false, cx); + }) + } + cx.emit(PanelEvent::ZoomOut); + cx.notify(); + } 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) + }) } + self.serialize(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 |terminal_panel, mut cx| async move { + let Some(new_pane) = new_pane.await else { + return; + }; + terminal_panel + .update(&mut cx, |terminal_panel, cx| { + terminal_panel + .center + .split(&pane, &new_pane, direction) + .log_err(); + cx.focus_view(&new_pane); + }) + .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 |terminal_panel, 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 = terminal_panel + .update(&mut cx, |terminal_panel, cx| { + let pane = new_terminal_pane( + weak_workspace, + project, + terminal_panel.active_pane.read(cx).is_zoomed(), + cx, + ); + terminal_panel.apply_tab_bar_buttons(&pane, cx); + pane + }) + .ok()?; + + pane.update(&mut cx, |pane, cx| { + pane.add_item(terminal_view, true, true, None, cx); + }) + .ok()?; + + Some(pane) + }) + } + pub fn open_terminal( workspace: &mut Workspace, action: &workspace::OpenTerminal, @@ -494,40 +544,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 +634,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 +684,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 +726,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(); - } - RevealStrategy::Never => {} - } + .ok()? + .await + .log_err()?; + terminal_to_replace + .update(&mut cx, |terminal_to_replace, cx| { + terminal_to_replace.set_terminal(new_terminal, cx); + }) + .ok()?; - Some(()) + 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 => {} + } + + 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 +841,156 @@ impl TerminalPanel { } } +pub fn new_terminal_pane( + workspace: WeakView, + project: Model, + zoomed: bool, + 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_zoomed(zoomed, cx); + pane.set_can_navigate(false, cx); + pane.display_nav_history_buttons(None); + pane.set_should_display_tab_bar(|_| true); + pane.set_zoom_out_on_close(false); + + 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(), + terminal_panel.active_pane.read(cx).is_zoomed(), + 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 +1025,7 @@ impl Render for TerminalPanel { let mut registrar = DivRegistrar::new( |panel, cx| { panel - .pane + .active_pane .read(cx) .toolbar() .read(cx) @@ -790,13 +1034,113 @@ 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(|terminal_panel, mut cx| async move { + if let Some(new_pane) = new_pane.await { + terminal_panel + .update(&mut cx, |terminal_panel, cx| { + terminal_panel + .center + .split( + &terminal_panel.active_pane, + &new_pane, + SplitDirection::Right, + ) + .log_err(); + cx.focus_view(&new_pane); + }) + .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 +1192,16 @@ 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)); + for pane in self.center.panes() { + pane.update(cx, |pane, cx| { + pane.set_zoomed(zoomed, cx); + }) + } + cx.notify(); } fn set_active(&mut self, active: bool, cx: &mut ViewContext) { @@ -872,7 +1221,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 +1255,7 @@ impl Panel for TerminalPanel { } fn pane(&self) -> Option> { - Some(self.pane.clone()) + Some(self.active_pane.clone()) } } @@ -923,14 +1277,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 ad0c7f520d..7a83e530fe 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); + 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()?; - 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); - } + 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/text/src/selection.rs b/crates/text/src/selection.rs index 94c373d630..fffece26b2 100644 --- a/crates/text/src/selection.rs +++ b/crates/text/src/selection.rs @@ -84,6 +84,31 @@ impl Selection { } self.goal = new_goal; } + + pub fn set_tail(&mut self, tail: T, new_goal: SelectionGoal) { + if tail.cmp(&self.head()) <= Ordering::Equal { + if self.reversed { + self.end = self.start; + self.reversed = false; + } + self.start = tail; + } else { + if !self.reversed { + self.start = self.end; + self.reversed = true; + } + self.end = tail; + } + self.goal = new_goal; + } + + pub fn swap_head_tail(&mut self) { + if self.reversed { + self.reversed = false; + } else { + std::mem::swap(&mut self.start, &mut self.end); + } + } } impl Selection { diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index 05dd6cd1e7..b9780a304a 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -58,7 +58,7 @@ impl ThemeColors { tab_active_background: neutral().light().step_1(), search_match_background: neutral().light().step_5(), panel_background: neutral().light().step_2(), - panel_focused_border: blue().light().step_5(), + panel_focused_border: blue().light().step_10(), panel_indent_guide: neutral().light_alpha().step_5(), panel_indent_guide_hover: neutral().light_alpha().step_6(), panel_indent_guide_active: neutral().light_alpha().step_6(), @@ -164,7 +164,7 @@ impl ThemeColors { tab_active_background: neutral().dark().step_1(), search_match_background: neutral().dark().step_5(), panel_background: neutral().dark().step_2(), - panel_focused_border: blue().dark().step_5(), + panel_focused_border: blue().dark().step_12(), panel_indent_guide: neutral().dark_alpha().step_4(), panel_indent_guide_hover: neutral().dark_alpha().step_6(), panel_indent_guide_active: neutral().dark_alpha().step_6(), diff --git a/crates/theme_importer/src/main.rs b/crates/theme_importer/src/main.rs index d92966ae24..db287956c5 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 cca4b56321..a1a6c7a27c 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/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index 0a2878b357..9d2fb598fa 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -37,6 +37,7 @@ project.workspace = true remote.workspace = true rpc.workspace = true serde.workspace = true +settings.workspace = true smallvec.workspace = true story = { workspace = true, optional = true } theme.workspace = true diff --git a/crates/title_bar/src/collab.rs b/crates/title_bar/src/collab.rs index 649dfb34f7..7d977bb458 100644 --- a/crates/title_bar/src/collab.rs +++ b/crates/title_bar/src/collab.rs @@ -294,9 +294,9 @@ impl TitleBar { let is_muted = room.is_muted(); let is_deafened = room.is_deafened().unwrap_or(false); let is_screen_sharing = room.is_screen_sharing(); - let can_use_microphone = room.can_use_microphone(); + let can_use_microphone = room.can_use_microphone(cx); let can_share_projects = room.can_share_projects(); - let platform_supported = match self.platform_style { + let screen_sharing_supported = match self.platform_style { PlatformStyle::Mac => true, PlatformStyle::Linux | PlatformStyle::Windows => false, }; @@ -363,9 +363,7 @@ impl TitleBar { ) .tooltip(move |cx| { Tooltip::text( - if !platform_supported { - "Cannot share microphone" - } else if is_muted { + if is_muted { "Unmute microphone" } else { "Mute microphone" @@ -375,56 +373,45 @@ impl TitleBar { }) .style(ButtonStyle::Subtle) .icon_size(IconSize::Small) - .selected(platform_supported && is_muted) - .disabled(!platform_supported) + .selected(is_muted) .selected_style(ButtonStyle::Tinted(TintColor::Negative)) .on_click(move |_, cx| { toggle_mute(&Default::default(), cx); }) .into_any_element(), ); + + children.push( + IconButton::new( + "mute-sound", + if is_deafened { + ui::IconName::AudioOff + } else { + ui::IconName::AudioOn + }, + ) + .style(ButtonStyle::Subtle) + .selected_style(ButtonStyle::Tinted(TintColor::Negative)) + .icon_size(IconSize::Small) + .selected(is_deafened) + .tooltip(move |cx| { + Tooltip::with_meta("Deafen Audio", None, "Mic will be muted", cx) + }) + .on_click(move |_, cx| toggle_deafen(&Default::default(), cx)) + .into_any_element(), + ); } - children.push( - IconButton::new( - "mute-sound", - if is_deafened { - ui::IconName::AudioOff - } else { - ui::IconName::AudioOn - }, - ) - .style(ButtonStyle::Subtle) - .selected_style(ButtonStyle::Tinted(TintColor::Negative)) - .icon_size(IconSize::Small) - .selected(is_deafened) - .disabled(!platform_supported) - .tooltip(move |cx| { - if !platform_supported { - Tooltip::text("Cannot share microphone", cx) - } else if can_use_microphone { - Tooltip::with_meta("Deafen Audio", None, "Mic will be muted", cx) - } else { - Tooltip::text("Deafen Audio", cx) - } - }) - .on_click(move |_, cx| toggle_deafen(&Default::default(), cx)) - .into_any_element(), - ); - - if can_share_projects { + if screen_sharing_supported { children.push( IconButton::new("screen-share", ui::IconName::Screen) .style(ButtonStyle::Subtle) .icon_size(IconSize::Small) .selected(is_screen_sharing) - .disabled(!platform_supported) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .tooltip(move |cx| { Tooltip::text( - if !platform_supported { - "Cannot share screen" - } else if is_screen_sharing { + if is_screen_sharing { "Stop Sharing Screen" } else { "Share Screen" diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 4e9a99433a..b6e08e2126 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -19,6 +19,7 @@ use gpui::{ }; use project::{Project, RepositoryEntry}; use rpc::proto; +use settings::Settings as _; use smallvec::SmallVec; use std::sync::Arc; use theme::ActiveTheme; @@ -600,7 +601,11 @@ impl TitleBar { .child( h_flex() .gap_0p5() - .child(Avatar::new(user.avatar_uri.clone())) + .children( + workspace::WorkspaceSettings::get_global(cx) + .show_user_picture + .then(|| Avatar::new(user.avatar_uri.clone())), + ) .child( Icon::new(IconName::ChevronDown) .size(IconSize::Small) diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 1071cc737f..298c184b2f 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -165,7 +165,6 @@ pub enum IconName { Copy, CountdownTimer, CursorIBeam, - TextSnippet, Dash, DatabaseZap, Delete, @@ -176,8 +175,8 @@ pub enum IconName { EllipsisVertical, Envelope, Escape, - Exit, ExpandVertical, + Exit, ExternalLink, Eye, File, @@ -203,6 +202,7 @@ pub enum IconName { GenericMinimize, GenericRestore, Github, + Globe, Hash, HistoryRerun, Indicator, @@ -228,13 +228,13 @@ pub enum IconName { PageUp, Pencil, Person, + PhoneIncoming, Pin, Play, Plus, PocketKnife, Public, PullRequest, - PhoneIncoming, Quote, RefreshTitle, Regex, @@ -280,6 +280,7 @@ pub enum IconName { SwatchBook, Tab, Terminal, + TextSnippet, Trash, TrashAlt, Triangle, @@ -292,11 +293,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 03d8ae719a..4d772fdd7a 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 133bddeda3..2d6c5298da 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 25a9458238..aa93abda5b 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 { @@ -179,6 +189,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 25477194dc..e5c591a970 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 0000000000..3a507f9a5a --- /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 94d580e643..2f84114409 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 f4e494f66e..e3b0af1fdb 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/util/src/util.rs b/crates/util/src/util.rs index fe3f7ef9a0..777b8b60dc 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -206,6 +206,9 @@ pub trait ResultExt { /// Assert that this result should never be an error in development or tests. fn debug_assert_ok(self, reason: &str) -> Self; fn warn_on_err(self) -> Option; + fn anyhow(self) -> anyhow::Result + where + E: Into; } impl ResultExt for Result @@ -243,6 +246,13 @@ where } } } + + fn anyhow(self) -> anyhow::Result + where + E: Into, + { + self.map_err(Into::into) + } } fn log_error_with_caller(caller: core::panic::Location<'_>, error: E, level: log::Level) diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index ddf738d067..02d4136faa 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 69fcdd8319..adf553983b 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 2fa75c8579..68aefc8cd7 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, @@ -136,7 +136,7 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext) { vim.update_editor(cx, |vim, editor, cx| { let snapshot = editor.snapshot(cx); if let Ok(range) = action.range.buffer_range(vim, editor, cx) { - let end = if range.end < snapshot.max_buffer_row() { + let end = if range.end < snapshot.buffer_snapshot.max_row() { Point::new(range.end.0 + 1, 0) } else { snapshot.buffer_snapshot.max_point() @@ -436,9 +436,11 @@ impl Position { .row .saturating_add_signed(*offset) } - Position::LastLine { offset } => { - snapshot.max_buffer_row().0.saturating_add_signed(*offset) - } + Position::LastLine { offset } => snapshot + .buffer_snapshot + .max_row() + .0 + .saturating_add_signed(*offset), Position::CurrentLine { offset } => editor .selections .newest_anchor() @@ -448,7 +450,7 @@ impl Position { .saturating_add_signed(*offset), }; - Ok(MultiBufferRow(target).min(snapshot.max_buffer_row())) + Ok(MultiBufferRow(target).min(snapshot.buffer_snapshot.max_row())) } } diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs new file mode 100644 index 0000000000..3358538991 --- /dev/null +++ b/crates/vim/src/helix.rs @@ -0,0 +1,373 @@ +use editor::{movement, scroll::Autoscroll, DisplayPoint, Editor}; +use gpui::{actions, Action}; +use language::{CharClassifier, CharKind}; +use ui::ViewContext; + +use crate::{motion::Motion, state::Mode, Vim}; + +actions!(vim, [HelixNormalAfter, HelixDelete]); + +pub fn register(editor: &mut Editor, cx: &mut ViewContext) { + Vim::action(editor, cx, Vim::helix_normal_after); + Vim::action(editor, cx, Vim::helix_delete); +} + +impl Vim { + pub fn helix_normal_after(&mut self, action: &HelixNormalAfter, cx: &mut ViewContext) { + if self.active_operator().is_some() { + self.operator_stack.clear(); + self.sync_vim_settings(cx); + return; + } + self.stop_recording_immediately(action.boxed_clone(), cx); + self.switch_mode(Mode::HelixNormal, false, cx); + return; + } + + pub fn helix_normal_motion( + &mut self, + motion: Motion, + times: Option, + cx: &mut ViewContext, + ) { + self.helix_move_cursor(motion, times, cx); + } + + fn helix_find_range_forward( + &mut self, + times: Option, + cx: &mut ViewContext, + mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool, + ) { + self.update_editor(cx, |_, editor, cx| { + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.move_with(|map, selection| { + let times = times.unwrap_or(1); + + if selection.head() == map.max_point() { + return; + } + + // collapse to block cursor + if selection.tail() < selection.head() { + selection.set_tail(movement::left(map, selection.head()), selection.goal); + } else { + selection.set_tail(selection.head(), selection.goal); + selection.set_head(movement::right(map, selection.head()), selection.goal); + } + + // create a classifier + let classifier = map + .buffer_snapshot + .char_classifier_at(selection.head().to_point(map)); + + let mut last_selection = selection.clone(); + for _ in 0..times { + let (new_tail, new_head) = + movement::find_boundary_trail(map, selection.head(), |left, right| { + is_boundary(left, right, &classifier) + }); + + selection.set_head(new_head, selection.goal); + if let Some(new_tail) = new_tail { + selection.set_tail(new_tail, selection.goal); + } + + if selection.head() == last_selection.head() + && selection.tail() == last_selection.tail() + { + break; + } + last_selection = selection.clone(); + } + }); + }); + }); + } + + fn helix_find_range_backward( + &mut self, + times: Option, + cx: &mut ViewContext, + mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool, + ) { + self.update_editor(cx, |_, editor, cx| { + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.move_with(|map, selection| { + let times = times.unwrap_or(1); + + if selection.head() == DisplayPoint::zero() { + return; + } + + // collapse to block cursor + if selection.tail() < selection.head() { + selection.set_tail(movement::left(map, selection.head()), selection.goal); + } else { + selection.set_tail(selection.head(), selection.goal); + selection.set_head(movement::right(map, selection.head()), selection.goal); + } + + // flip the selection + selection.swap_head_tail(); + + // create a classifier + let classifier = map + .buffer_snapshot + .char_classifier_at(selection.head().to_point(map)); + + let mut last_selection = selection.clone(); + for _ in 0..times { + let (new_tail, new_head) = movement::find_preceding_boundary_trail( + map, + selection.head(), + |left, right| is_boundary(left, right, &classifier), + ); + + selection.set_head(new_head, selection.goal); + if let Some(new_tail) = new_tail { + selection.set_tail(new_tail, selection.goal); + } + + if selection.head() == last_selection.head() + && selection.tail() == last_selection.tail() + { + break; + } + last_selection = selection.clone(); + } + }); + }) + }); + } + + pub fn helix_move_and_collapse( + &mut self, + motion: Motion, + times: Option, + cx: &mut ViewContext, + ) { + self.update_editor(cx, |_, editor, cx| { + let text_layout_details = editor.text_layout_details(cx); + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.move_with(|map, selection| { + let goal = selection.goal; + let cursor = if selection.is_empty() || selection.reversed { + selection.head() + } else { + movement::left(map, selection.head()) + }; + + let (point, goal) = motion + .move_point(map, cursor, selection.goal, times, &text_layout_details) + .unwrap_or((cursor, goal)); + + selection.collapse_to(point, goal) + }) + }); + }); + } + + pub fn helix_move_cursor( + &mut self, + motion: Motion, + times: Option, + cx: &mut ViewContext, + ) { + match motion { + Motion::NextWordStart { ignore_punctuation } => { + self.helix_find_range_forward(times, cx, |left, right, classifier| { + let left_kind = classifier.kind_with(left, ignore_punctuation); + let right_kind = classifier.kind_with(right, ignore_punctuation); + let at_newline = right == '\n'; + + let found = + left_kind != right_kind && right_kind != CharKind::Whitespace || at_newline; + + found + }) + } + Motion::NextWordEnd { ignore_punctuation } => { + self.helix_find_range_forward(times, cx, |left, right, classifier| { + let left_kind = classifier.kind_with(left, ignore_punctuation); + let right_kind = classifier.kind_with(right, ignore_punctuation); + let at_newline = right == '\n'; + + let found = left_kind != right_kind + && (left_kind != CharKind::Whitespace || at_newline); + + found + }) + } + Motion::PreviousWordStart { ignore_punctuation } => { + self.helix_find_range_backward(times, cx, |left, right, classifier| { + let left_kind = classifier.kind_with(left, ignore_punctuation); + let right_kind = classifier.kind_with(right, ignore_punctuation); + let at_newline = right == '\n'; + + let found = left_kind != right_kind + && (left_kind != CharKind::Whitespace || at_newline); + + found + }) + } + Motion::PreviousWordEnd { ignore_punctuation } => { + self.helix_find_range_backward(times, cx, |left, right, classifier| { + let left_kind = classifier.kind_with(left, ignore_punctuation); + let right_kind = classifier.kind_with(right, ignore_punctuation); + let at_newline = right == '\n'; + + let found = left_kind != right_kind + && right_kind != CharKind::Whitespace + && !at_newline; + + found + }) + } + _ => self.helix_move_and_collapse(motion, times, cx), + } + } + + pub fn helix_delete(&mut self, _: &HelixDelete, cx: &mut ViewContext) { + self.store_visual_marks(cx); + self.update_editor(cx, |vim, editor, cx| { + // Fixup selections so they have helix's semantics. + // Specifically: + // - Make sure that each cursor acts as a 1 character wide selection + editor.transact(cx, |editor, cx| { + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.move_with(|map, selection| { + if selection.is_empty() && !selection.reversed { + selection.end = movement::right(map, selection.end); + } + }); + }); + }); + + vim.copy_selections_content(editor, false, cx); + editor.insert("", cx); + }); + } +} + +#[cfg(test)] +mod test { + use indoc::indoc; + + use crate::{state::Mode, test::VimTestContext}; + + #[gpui::test] + async fn test_next_word_start(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + // « + // ˇ + // » + cx.set_state( + indoc! {" + The quˇick brown + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("w"); + + cx.assert_state( + indoc! {" + The qu«ick ˇ»brown + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("w"); + + cx.assert_state( + indoc! {" + The quick «brownˇ» + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + } + + #[gpui::test] + async fn test_delete(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // test delete a selection + cx.set_state( + indoc! {" + The qu«ick ˇ»brown + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("d"); + + cx.assert_state( + indoc! {" + The quˇbrown + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + + // test deleting a single character + cx.simulate_keystrokes("d"); + + cx.assert_state( + indoc! {" + The quˇrown + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + } + + #[gpui::test] + async fn test_delete_character_end_of_line(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {" + The quick brownˇ + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("d"); + + cx.assert_state( + indoc! {" + The quick brownˇfox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + } + + #[gpui::test] + async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {" + The quick brown + fox jumps over + the lazy dog.ˇ"}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("d"); + + cx.assert_state( + indoc! {" + The quick brown + fox jumps over + the lazy dog.ˇ"}, + Mode::HelixNormal, + ); + } +} diff --git a/crates/vim/src/indent.rs b/crates/vim/src/indent.rs index b6ca2de34c..6d5ce78f5c 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 ba83e2125b..b1e7af9b10 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 619bb6e1f4..8b608fdfe3 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 9f7a30afe9..0e236861b6 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 { @@ -442,6 +529,8 @@ impl Vim { return; } } + + Mode::HelixNormal => {} } } @@ -455,7 +544,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 { @@ -471,11 +560,13 @@ impl Vim { Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { self.visual_motion(motion.clone(), count, cx) } + + Mode::HelixNormal => self.helix_normal_motion(motion.clone(), count, cx), } 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 +592,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 +640,8 @@ impl Motion { | Up { .. } | EndOfLine { .. } | Matching + | UnmatchedForward { .. } + | UnmatchedBackward { .. } | FindForward { .. } | RepeatFind { .. } | Left @@ -568,6 +673,16 @@ impl Motion { | NextLineStart | PreviousLineStart | ZedSearchResult { .. } + | NextSectionStart + | NextSectionEnd + | PreviousSectionStart + | PreviousSectionEnd + | NextMethodStart + | NextMethodEnd + | PreviousMethodStart + | PreviousMethodEnd + | NextComment + | PreviousComment | Jump { .. } => false, } } @@ -583,6 +698,8 @@ impl Motion { | EndOfLine { .. } | EndOfLineDownward | Matching + | UnmatchedForward { .. } + | UnmatchedBackward { .. } | FindForward { .. } | WindowTop | WindowMiddle @@ -611,6 +728,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 +834,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 +953,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)) @@ -1690,7 +1866,7 @@ fn end_of_document( let new_row = if let Some(line) = line { (line - 1) as u32 } else { - map.max_buffer_row().0 + map.buffer_snapshot.max_row().0 }; let new_point = Point::new(new_row, point.column()); @@ -1792,6 +1968,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 +2256,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 +2605,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 37a8115e33..bde3c12027 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 2c591a1f1f..405185adf5 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(); @@ -145,6 +145,8 @@ impl Vim { cursor_positions.push(selection.start..selection.start); } } + + Mode::HelixNormal => {} Mode::Insert | Mode::Normal | Mode::Replace => { let start = selection.start; let mut end = start; diff --git a/crates/vim/src/normal/increment.rs b/crates/vim/src/normal/increment.rs index ec24064b31..ca300fc1be 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 feb060d594..8d49a6802c 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 c89b63ecc6..41c89269f1 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 8d1443e633..3f71401e2e 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 5d78c8937e..103d33f8af 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 dc27e2b219..c2b27227ca 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/normal/yank.rs b/crates/vim/src/normal/yank.rs index 763f1a3d16..d23dc2f9b0 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -4,13 +4,14 @@ use crate::{ motion::Motion, object::Object, state::{Mode, Register}, - Vim, + Vim, VimSettings, }; use collections::HashMap; use editor::{ClipboardSelection, Editor}; use gpui::ViewContext; use language::Point; use multi_buffer::MultiBufferRow; +use settings::Settings; struct HighlightOnYank; @@ -154,9 +155,9 @@ impl Vim { // contains a newline (so that delete works as expected). We undo that change // here. let is_last_line = linewise - && end.row == buffer.max_buffer_row().0 + && end.row == buffer.max_row().0 && buffer.max_point().column > 0 - && start.row < buffer.max_buffer_row().0 + && start.row < buffer.max_row().0 && start == Point::new(start.row, buffer.line_len(MultiBufferRow(start.row))); if is_last_line { @@ -195,7 +196,8 @@ impl Vim { ) }); - if !is_yank || self.mode == Mode::Visual { + let highlight_duration = VimSettings::get_global(cx).highlight_on_yank_duration; + if !is_yank || self.mode == Mode::Visual || highlight_duration == 0 { return; } @@ -206,7 +208,7 @@ impl Vim { ); cx.spawn(|this, mut cx| async move { cx.background_executor() - .timer(Duration::from_millis(200)) + .timer(Duration::from_millis(highlight_duration)) .await; this.update(&mut cx, |editor, cx| { editor.clear_background_highlights::(cx) diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index f97312e7f8..c63cb0e843 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; @@ -28,7 +32,11 @@ pub enum Object { CurlyBrackets, AngleBrackets, Argument, + IndentObj { include_below: bool }, Tag, + Method, + Class, + Comment, } #[derive(Clone, Deserialize, PartialEq)] @@ -37,8 +45,14 @@ struct Word { #[serde(default)] ignore_punctuation: bool, } +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct IndentObj { + #[serde(default)] + include_below: bool, +} -impl_actions!(vim, [Word]); +impl_actions!(vim, [Word, IndentObj]); actions!( vim, @@ -54,7 +68,10 @@ actions!( CurlyBrackets, AngleBrackets, Argument, - Tag + Tag, + Method, + Class, + Comment ] ); @@ -100,6 +117,25 @@ 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, + |vim, &IndentObj { include_below }: &IndentObj, cx| { + vim.object(Object::IndentObj { include_below }, cx) + }, + ); } impl Vim { @@ -107,7 +143,7 @@ impl Vim { match self.mode { Mode::Normal => self.normal_object(object, cx), Mode::Visual | Mode::VisualLine | Mode::VisualBlock => self.visual_object(object, cx), - Mode::Insert | Mode::Replace => { + Mode::Insert | Mode::Replace | Mode::HelixNormal => { // Shouldn't execute a text object in insert mode. Ignoring } } @@ -129,13 +165,21 @@ impl Object { | Object::AngleBrackets | Object::CurlyBrackets | Object::SquareBrackets - | Object::Argument => true, + | Object::Argument + | Object::Method + | Object::Class + | Object::Comment + | Object::IndentObj { .. } => true, } } pub fn always_expands_both_ways(self) -> bool { match self { - Object::Word { .. } | Object::Sentence | Object::Paragraph | Object::Argument => false, + Object::Word { .. } + | Object::Sentence + | Object::Paragraph + | Object::Argument + | Object::IndentObj { .. } => false, Object::Quotes | Object::BackQuotes | Object::DoubleQuotes @@ -143,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 @@ -167,7 +214,16 @@ impl Object { | Object::AngleBrackets | Object::VerticalBars | Object::Tag - | Object::Argument => Mode::Visual, + | Object::Comment + | Object::Argument + | Object::IndentObj { .. } => Mode::Visual, + Object::Method | Object::Class => { + if around { + Mode::VisualLine + } else { + Mode::Visual + } + } Object::Paragraph => Mode::VisualLine, } } @@ -218,7 +274,35 @@ 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), } } @@ -420,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, @@ -569,6 +694,58 @@ fn argument( } } +fn indent( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + around: bool, + include_below: bool, +) -> Option> { + let point = relative_to.to_point(map); + let row = point.row; + + let desired_indent = map.line_indent_for_buffer_row(MultiBufferRow(row)); + + // Loop backwards until we find a non-blank line with less indent + let mut start_row = row; + for prev_row in (0..row).rev() { + let indent = map.line_indent_for_buffer_row(MultiBufferRow(prev_row)); + if indent.is_line_empty() { + continue; + } + if indent.spaces < desired_indent.spaces || indent.tabs < desired_indent.tabs { + if around { + // When around is true, include the first line with less indent + start_row = prev_row; + } + break; + } + start_row = prev_row; + } + + // Loop forwards until we find a non-blank line with less indent + let mut end_row = row; + let max_rows = map.buffer_snapshot.max_row().0; + for next_row in (row + 1)..=max_rows { + let indent = map.line_indent_for_buffer_row(MultiBufferRow(next_row)); + if indent.is_line_empty() { + continue; + } + if indent.spaces < desired_indent.spaces || indent.tabs < desired_indent.tabs { + if around && include_below { + // When around is true and including below, include this line + end_row = next_row; + } + break; + } + end_row = next_row; + } + + let end_len = map.buffer_snapshot.line_len(MultiBufferRow(end_row)); + let start = map.point_to_display_point(Point::new(start_row, 0), Bias::Right); + let end = map.point_to_display_point(Point::new(end_row, end_len), Bias::Left); + Some(start..end) +} + fn sentence( map: &DisplaySnapshot, relative_to: DisplayPoint, @@ -781,13 +958,13 @@ pub fn start_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> /// The trailing newline is excluded from the paragraph. pub fn end_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint { let point = display_point.to_point(map); - if point.row == map.max_buffer_row().0 { + if point.row == map.buffer_snapshot.max_row().0 { return map.max_point(); } let is_current_line_blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(point.row)); - for row in point.row + 1..map.max_buffer_row().0 + 1 { + for row in point.row + 1..map.buffer_snapshot.max_row().0 + 1 { let blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(row)); if blank != is_current_line_blank { let previous_row = row - 1; @@ -1458,6 +1635,94 @@ mod test { cx.assert_state("let a = [«test::call(first_arg)ˇ»]", Mode::Visual); } + #[gpui::test] + async fn test_indent_object(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Base use case + cx.set_state( + indoc! {" + fn boop() { + // Comment + baz();ˇ + + loop { + bar(1); + bar(2); + } + + result + } + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v i i"); + cx.assert_state( + indoc! {" + fn boop() { + « // Comment + baz(); + + loop { + bar(1); + bar(2); + } + + resultˇ» + } + "}, + Mode::Visual, + ); + + // Around indent (include line above) + cx.set_state( + indoc! {" + const ABOVE: str = true; + fn boop() { + + hello(); + worˇld() + } + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a i"); + cx.assert_state( + indoc! {" + const ABOVE: str = true; + «fn boop() { + + hello(); + world()ˇ» + } + "}, + Mode::Visual, + ); + + // Around indent (include line above & below) + cx.set_state( + indoc! {" + const ABOVE: str = true; + fn boop() { + hellˇo(); + world() + + } + const BELOW: str = true; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("c a shift-i"); + cx.assert_state( + indoc! {" + const ABOVE: str = true; + ˇ + const BELOW: str = true; + "}, + Mode::Insert, + ); + } + #[gpui::test] async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/replace.rs b/crates/vim/src/replace.rs index 753eec0971..8b84849043 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 db54c4ed57..1ef4a3fc03 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 510ed6557d..e93eeef404 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -26,6 +26,7 @@ pub enum Mode { Visual, VisualLine, VisualBlock, + HelixNormal, } impl Display for Mode { @@ -37,6 +38,7 @@ impl Display for Mode { Mode::Visual => write!(f, "VISUAL"), Mode::VisualLine => write!(f, "VISUAL LINE"), Mode::VisualBlock => write!(f, "VISUAL BLOCK"), + Mode::HelixNormal => write!(f, "HELIX NORMAL"), } } } @@ -46,6 +48,7 @@ impl Mode { match self { Mode::Normal | Mode::Insert | Mode::Replace => false, Mode::Visual | Mode::VisualLine | Mode::VisualBlock => true, + Mode::HelixNormal => false, } } } @@ -72,6 +75,7 @@ pub enum Operator { Jump { line: bool }, Indent, Outdent, + AutoIndent, Rewrap, Lowercase, Uppercase, @@ -150,6 +154,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 +469,7 @@ impl Operator { Operator::Jump { line: true } => "'", Operator::Jump { line: false } => "`", Operator::Indent => ">", + Operator::AutoIndent => "eq", Operator::Rewrap => "gq", Operator::Outdent => "<", Operator::Uppercase => "gU", @@ -480,6 +490,7 @@ impl Operator { Operator::Literal { prefix: Some(prefix), } => format!("^V{prefix}"), + Operator::AutoIndent => "=".to_string(), _ => self.id().to_string(), } } @@ -505,6 +516,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 88bcb6a2e1..719a147062 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/test/neovim_connection.rs b/crates/vim/src/test/neovim_connection.rs index a2ab1f3972..a0a2343bdf 100644 --- a/crates/vim/src/test/neovim_connection.rs +++ b/crates/vim/src/test/neovim_connection.rs @@ -442,6 +442,7 @@ impl NeovimConnection { } Mode::Insert | Mode::Normal | Mode::Replace => selections .push(Point::new(selection_row, selection_col)..Point::new(cursor_row, cursor_col)), + Mode::HelixNormal => unreachable!(), } let ranges = encode_ranges(&text, &selections); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index dd3bf297cb..843b094700 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -6,6 +6,7 @@ mod test; mod change_list; mod command; mod digraph; +mod helix; mod indent; mod insert; mod mode_indicator; @@ -25,8 +26,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 +41,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 +80,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 +120,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 +187,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 +202,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 +248,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(), @@ -289,6 +338,7 @@ impl Vim { normal::register(editor, cx); insert::register(editor, cx); + helix::register(editor, cx); motion::register(editor, cx); command::register(editor, cx); replace::register(editor, cx); @@ -422,6 +472,7 @@ impl Vim { | Operator::Replace | Operator::Indent | Operator::Outdent + | Operator::AutoIndent | Operator::Lowercase | Operator::Uppercase | Operator::OppositeCase @@ -471,7 +522,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 +602,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 } @@ -580,7 +633,9 @@ impl Vim { } } Mode::Replace => CursorShape::Underline, - Mode::Visual | Mode::VisualLine | Mode::VisualBlock => CursorShape::Block, + Mode::HelixNormal | Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { + CursorShape::Block + } Mode::Insert => CursorShape::Bar, } } @@ -594,9 +649,12 @@ impl Vim { true } } - Mode::Normal | Mode::Replace | Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { - false - } + Mode::Normal + | Mode::HelixNormal + | Mode::Replace + | Mode::Visual + | Mode::VisualLine + | Mode::VisualBlock => false, } } @@ -606,27 +664,31 @@ impl Vim { pub fn clip_at_line_ends(&self) -> bool { match self.mode { - Mode::Insert | Mode::Visual | Mode::VisualLine | Mode::VisualBlock | Mode::Replace => { - false - } + Mode::Insert + | Mode::Visual + | Mode::VisualLine + | Mode::VisualBlock + | Mode::Replace + | Mode::HelixNormal => false, Mode::Normal => true, } } - 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", Mode::Insert => "insert", Mode::Replace => "replace", + Mode::HelixNormal => "helix_normal", } .to_string(); 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 +899,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 +942,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); @@ -947,7 +1009,7 @@ impl Vim { }) }); } - Mode::Insert | Mode::Replace => {} + Mode::Insert | Mode::Replace | Mode::HelixNormal => {} } } @@ -1137,6 +1199,7 @@ struct VimSettings { pub use_multiline_find: bool, pub use_smartcase_find: bool, pub custom_digraphs: HashMap>, + pub highlight_on_yank_duration: u64, } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] @@ -1146,6 +1209,7 @@ struct VimSettingsContent { pub use_multiline_find: Option, pub use_smartcase_find: Option, pub custom_digraphs: Option>>, + pub highlight_on_yank_duration: Option, } impl Settings for VimSettings { diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 47aa618b5c..8d2b31a1de 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 0000000000..bb3825dcd2 --- /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 0000000000..a6b4a38f29 --- /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/Cargo.toml b/crates/workspace/Cargo.toml index 1fa4db2af8..3b17ed8dab 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -38,7 +38,6 @@ db.workspace = true derive_more.workspace = true fs.workspace = true futures.workspace = true -git.workspace = true gpui.workspace = true http_client.workspace = true itertools.workspace = true diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index a7bf90dd17..7b9478a9a7 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -42,6 +42,8 @@ pub struct ItemSettings { pub close_position: ClosePosition, pub activate_on_close: ActivateOnClose, pub file_icons: bool, + pub show_diagnostics: ShowDiagnostics, + pub always_show_close_button: bool, } #[derive(Deserialize)] @@ -59,6 +61,15 @@ pub enum ClosePosition { Right, } +#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ShowDiagnostics { + #[default] + Off, + Errors, + All, +} + #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum ActivateOnClose { @@ -85,6 +96,15 @@ pub struct ItemSettingsContent { /// /// Default: history pub activate_on_close: Option, + /// Which files containing diagnostic errors/warnings to mark in the tabs. + /// This setting can take the following three values: + /// + /// Default: off + show_diagnostics: 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 +228,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 +335,7 @@ pub trait SerializableItem: Item { _workspace: WeakView, _workspace_id: WorkspaceId, _item_id: ItemId, - _cx: &mut ViewContext, + _cx: &mut WindowContext, ) -> Task>>; fn serialize( @@ -386,7 +406,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 +583,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 +911,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 +1052,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 +1060,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 +1086,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 +1094,6 @@ pub mod test { ) -> Option>>> { None } - fn entry_id(&self, _: &AppContext) -> Option { self.entry_id } @@ -1080,6 +1101,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 +1121,7 @@ pub mod test { cx.new_model(|_| Self { entry_id, project_path, + is_dirty: false, }) } @@ -1103,6 +1129,7 @@ pub mod test { cx.new_model(|_| Self { project_path: None, entry_id: None, + is_dirty: false, }) } } @@ -1224,7 +1251,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 +1366,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 4eec2f18d1..a4ca58c11c 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1,7 +1,7 @@ use crate::{ item::{ ActivateOnClose, ClosePosition, Item, ItemHandle, ItemSettings, PreviewTabsSettings, - TabContentParams, WeakItemHandle, + ShowDiagnostics, TabContentParams, WeakItemHandle, }, move_item, notifications::NotifyResultExt, @@ -13,7 +13,6 @@ use crate::{ use anyhow::Result; use collections::{BTreeSet, HashMap, HashSet, VecDeque}; use futures::{stream::FuturesUnordered, StreamExt}; -use git::repository::GitFileStatus; use gpui::{ actions, anchored, deferred, impl_actions, prelude::*, Action, AnchorCorner, AnyElement, AppContext, AsyncWindowContext, ClickEvent, ClipboardItem, Div, DragMoveEvent, EntityId, @@ -23,6 +22,7 @@ use gpui::{ WindowContext, }; use itertools::Itertools; +use language::DiagnosticSeverity; use parking_lot::Mutex; use project::{Project, ProjectEntryId, ProjectPath, WorktreeId}; use serde::Deserialize; @@ -39,10 +39,10 @@ use std::{ }, }; use theme::ThemeSettings; - use ui::{ - prelude::*, right_click_menu, ButtonSize, Color, IconButton, IconButtonShape, IconName, - IconSize, Indicator, Label, PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition, Tooltip, + prelude::*, right_click_menu, ButtonSize, Color, DecoratedIcon, IconButton, IconButtonShape, + IconDecoration, IconDecorationKind, IconName, IconSize, Indicator, Label, PopoverMenu, + PopoverMenuHandle, Tab, TabBar, TabPosition, Tooltip, }; use ui::{v_flex, ContextMenu}; use util::{debug_panic, maybe, truncate_and_remove_front, ResultExt}; @@ -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,8 +303,10 @@ 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, + diagnostics: HashMap, + zoom_out_on_close: bool, } pub struct ActivationHistoryEntry { @@ -381,6 +383,7 @@ impl Pane { cx.on_focus_in(&focus_handle, Pane::focus_in), cx.on_focus_out(&focus_handle, Pane::focus_out), cx.observe_global::(Self::settings_changed), + cx.subscribe(&project, Self::project_events), ]; let handle = cx.view().downgrade(); @@ -411,7 +414,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) { @@ -504,6 +507,8 @@ impl Pane { split_item_context_menu_handle: Default::default(), new_item_context_menu_handle: Default::default(), pinned_tab_count: 0, + diagnostics: Default::default(), + zoom_out_on_close: true, } } @@ -598,6 +603,47 @@ impl Pane { cx.notify(); } + fn project_events( + this: &mut Pane, + _project: Model, + event: &project::Event, + cx: &mut ViewContext, + ) { + match event { + project::Event::DiskBasedDiagnosticsFinished { .. } + | project::Event::DiagnosticsUpdated { .. } => { + if ItemSettings::get_global(cx).show_diagnostics != ShowDiagnostics::Off { + this.update_diagnostics(cx); + cx.notify(); + } + } + _ => {} + } + } + + fn update_diagnostics(&mut self, cx: &mut ViewContext) { + let show_diagnostics = ItemSettings::get_global(cx).show_diagnostics; + self.diagnostics = if show_diagnostics != ShowDiagnostics::Off { + self.project + .read(cx) + .diagnostic_summaries(false, cx) + .filter_map(|(project_path, _, diagnostic_summary)| { + if diagnostic_summary.error_count > 0 { + Some((project_path, DiagnosticSeverity::ERROR)) + } else if diagnostic_summary.warning_count > 0 + && show_diagnostics != ShowDiagnostics::Errors + { + Some((project_path, DiagnosticSeverity::WARNING)) + } else { + None + } + }) + .collect::>() + } else { + Default::default() + } + } + fn settings_changed(&mut self, cx: &mut ViewContext) { if let Some(display_nav_history_buttons) = self.display_nav_history_buttons.as_mut() { *display_nav_history_buttons = TabBarSettings::get_global(cx).show_nav_history_buttons; @@ -605,6 +651,7 @@ impl Pane { if !PreviewTabsSettings::get_global(cx).enabled { self.preview_item_id = None; } + self.update_diagnostics(cx); cx.notify(); } @@ -623,9 +670,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 +875,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 +1343,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 +1389,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 +1413,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 +1451,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 +1463,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) } @@ -1523,7 +1588,7 @@ impl Pane { .remove(&item.item_id()); } - if self.items.is_empty() && close_pane_if_empty && self.zoomed { + if self.zoom_out_on_close && self.items.is_empty() && close_pane_if_empty && self.zoomed { cx.emit(Event::ZoomOut); } @@ -1595,7 +1660,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 +1756,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 +1822,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(()) @@ -1821,23 +1886,6 @@ impl Pane { } } - pub fn git_aware_icon_color( - git_status: Option, - ignored: bool, - selected: bool, - ) -> Color { - if ignored { - Color::Ignored - } else { - match git_status { - Some(GitFileStatus::Added) => Color::Created, - Some(GitFileStatus::Modified) => Color::Modified, - Some(GitFileStatus::Conflict) => Color::Conflict, - None => Self::icon_color(selected), - } - } - } - fn toggle_pin_tab(&mut self, _: &TogglePinTab, cx: &mut ViewContext<'_, Self>) { if self.items.is_empty() { return; @@ -1870,7 +1918,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(); @@ -1901,8 +1949,6 @@ impl Pane { focus_handle: &FocusHandle, cx: &mut ViewContext<'_, Pane>, ) -> impl IntoElement { - let project_path = item.project_path(cx); - let is_active = ix == self.active_item_index; let is_preview = self .preview_item_id @@ -1918,20 +1964,56 @@ impl Pane { cx, ); - let icon_color = if ItemSettings::get_global(cx).git_status { - project_path - .as_ref() - .and_then(|path| self.project.read(cx).entry_for_path(path, cx)) - .map(|entry| { - Self::git_aware_icon_color(entry.git_status, entry.is_ignored, is_active) - }) - .unwrap_or_else(|| Self::icon_color(is_active)) + let item_diagnostic = item + .project_path(cx) + .map_or(None, |project_path| self.diagnostics.get(&project_path)); + + let decorated_icon = item_diagnostic.map_or(None, |diagnostic| { + let icon = match item.tab_icon(cx) { + Some(icon) => icon, + None => return None, + }; + + let knockout_item_color = if is_active { + cx.theme().colors().tab_active_background + } else { + cx.theme().colors().tab_bar_background + }; + + let (icon_decoration, icon_color) = if matches!(diagnostic, &DiagnosticSeverity::ERROR) + { + (IconDecorationKind::X, Color::Error) + } else { + (IconDecorationKind::Triangle, Color::Warning) + }; + + Some(DecoratedIcon::new( + icon.size(IconSize::Small).color(Color::Muted), + Some( + IconDecoration::new(icon_decoration, knockout_item_color, cx) + .color(icon_color.color(cx)) + .position(Point { + x: px(-2.), + y: px(-2.), + }), + ), + )) + }); + + let icon = if decorated_icon.is_none() { + match item_diagnostic { + Some(&DiagnosticSeverity::ERROR) => None, + Some(&DiagnosticSeverity::WARNING) => None, + _ => item.tab_icon(cx).map(|icon| icon.color(Color::Muted)), + } + .map(|icon| icon.size(IconSize::Small)) } else { - Self::icon_color(is_active) + None }; - 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 +2108,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) @@ -2056,7 +2140,17 @@ impl Pane { .child( h_flex() .gap_1() - .children(icon.map(|icon| icon.size(IconSize::Small).color(icon_color))) + .items_center() + .children( + std::iter::once(if let Some(decorated_icon) = decorated_icon { + Some(div().child(decorated_icon.into_any_element())) + } else if let Some(icon) = icon { + Some(div().child(icon.into_any_element())) + } else { + None + }) + .flatten(), + ) .child(label), ); @@ -2071,8 +2165,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 +2347,7 @@ impl Pane { } } - menu + menu.context(menu_context) }) }) } @@ -2384,8 +2480,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 +2785,14 @@ impl Pane { }) .collect() } + + pub fn drag_split_direction(&self) -> Option { + self.drag_split_direction + } + + pub fn set_zoom_out_on_close(&mut self, zoom_out_on_close: bool) { + self.zoom_out_on_close = zoom_out_on_close; + } } impl FocusableView for Pane { @@ -3705,9 +3819,18 @@ mod tests { 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); + 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 @@ -3726,6 +3849,30 @@ mod tests { 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( + &CloseAllItems { + save_intent: None, + close_pinned: false, + }, + cx, + ) + }) + .unwrap(); + + 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, ["A*^", "B^", "C^"], cx); } #[gpui::test] @@ -3813,14 +3960,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 +3986,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 390fa6d174..4461e58925 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 a2510b8bec..7a368ee441 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/shared_screen.rs b/crates/workspace/src/shared_screen.rs index 59df859488..1d17cfa145 100644 --- a/crates/workspace/src/shared_screen.rs +++ b/crates/workspace/src/shared_screen.rs @@ -1,126 +1,11 @@ -use crate::{ - item::{Item, ItemEvent}, - ItemNavHistory, WorkspaceId, -}; -use anyhow::Result; -use call::participant::{Frame, RemoteVideoTrack}; -use client::{proto::PeerId, User}; -use futures::StreamExt; -use gpui::{ - div, surface, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, - ParentElement, Render, SharedString, Styled, Task, View, ViewContext, VisualContext, - WindowContext, -}; -use std::sync::{Arc, Weak}; -use ui::{prelude::*, Icon, IconName}; +#[cfg(target_os = "macos")] +mod macos; -pub enum Event { - Close, -} +#[cfg(target_os = "macos")] +pub use macos::*; -pub struct SharedScreen { - track: Weak, - frame: Option, - pub peer_id: PeerId, - user: Arc, - nav_history: Option, - _maintain_frame: Task>, - focus: FocusHandle, -} +#[cfg(not(target_os = "macos"))] +mod cross_platform; -impl SharedScreen { - pub fn new( - track: &Arc, - peer_id: PeerId, - user: Arc, - cx: &mut ViewContext, - ) -> Self { - cx.focus_handle(); - let mut frames = track.frames(); - Self { - track: Arc::downgrade(track), - frame: None, - peer_id, - user, - nav_history: Default::default(), - _maintain_frame: cx.spawn(|this, mut cx| async move { - while let Some(frame) = frames.next().await { - this.update(&mut cx, |this, cx| { - this.frame = Some(frame); - cx.notify(); - })?; - } - this.update(&mut cx, |_, cx| cx.emit(Event::Close))?; - Ok(()) - }), - focus: cx.focus_handle(), - } - } -} - -impl EventEmitter for SharedScreen {} - -impl FocusableView for SharedScreen { - fn focus_handle(&self, _: &AppContext) -> FocusHandle { - self.focus.clone() - } -} -impl Render for SharedScreen { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - div() - .bg(cx.theme().colors().editor_background) - .track_focus(&self.focus) - .key_context("SharedScreen") - .size_full() - .children( - self.frame - .as_ref() - .map(|frame| surface(frame.image()).size_full()), - ) - } -} - -impl Item for SharedScreen { - type Event = Event; - - fn tab_tooltip_text(&self, _: &AppContext) -> Option { - Some(format!("{}'s screen", self.user.github_login).into()) - } - - fn deactivated(&mut self, cx: &mut ViewContext) { - if let Some(nav_history) = self.nav_history.as_mut() { - nav_history.push::<()>(None, cx); - } - } - - fn tab_icon(&self, _cx: &WindowContext) -> Option { - Some(Icon::new(IconName::Screen)) - } - - fn tab_content_text(&self, _cx: &WindowContext) -> Option { - Some(format!("{}'s screen", self.user.github_login).into()) - } - - fn telemetry_event_text(&self) -> Option<&'static str> { - None - } - - fn set_nav_history(&mut self, history: ItemNavHistory, _: &mut ViewContext) { - self.nav_history = Some(history); - } - - fn clone_on_split( - &self, - _workspace_id: Option, - cx: &mut ViewContext, - ) -> Option> { - let track = self.track.upgrade()?; - Some(cx.new_view(|cx| Self::new(&track, self.peer_id, self.user.clone(), cx))) - } - - fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { - match event { - Event::Close => f(ItemEvent::CloseItem), - } - } -} +#[cfg(not(target_os = "macos"))] +pub use cross_platform::*; diff --git a/crates/workspace/src/shared_screen/cross_platform.rs b/crates/workspace/src/shared_screen/cross_platform.rs new file mode 100644 index 0000000000..285946cce0 --- /dev/null +++ b/crates/workspace/src/shared_screen/cross_platform.rs @@ -0,0 +1,114 @@ +use crate::{ + item::{Item, ItemEvent}, + ItemNavHistory, WorkspaceId, +}; +use call::{RemoteVideoTrack, RemoteVideoTrackView}; +use client::{proto::PeerId, User}; +use gpui::{ + div, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, ParentElement, + Render, SharedString, Styled, View, ViewContext, VisualContext, WindowContext, +}; +use std::sync::Arc; +use ui::{prelude::*, Icon, IconName}; + +pub enum Event { + Close, +} + +pub struct SharedScreen { + pub peer_id: PeerId, + user: Arc, + nav_history: Option, + view: View, + focus: FocusHandle, +} + +impl SharedScreen { + pub fn new( + track: RemoteVideoTrack, + peer_id: PeerId, + user: Arc, + cx: &mut ViewContext, + ) -> Self { + let view = cx.new_view(|cx| RemoteVideoTrackView::new(track.clone(), cx)); + cx.subscribe(&view, |_, _, ev, cx| match ev { + call::RemoteVideoTrackViewEvent::Close => cx.emit(Event::Close), + }) + .detach(); + Self { + view, + peer_id, + user, + nav_history: Default::default(), + focus: cx.focus_handle(), + } + } +} + +impl EventEmitter for SharedScreen {} + +impl FocusableView for SharedScreen { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus.clone() + } +} +impl Render for SharedScreen { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .bg(cx.theme().colors().editor_background) + .track_focus(&self.focus) + .key_context("SharedScreen") + .size_full() + .child(self.view.clone()) + } +} + +impl Item for SharedScreen { + type Event = Event; + + fn tab_tooltip_text(&self, _: &AppContext) -> Option { + Some(format!("{}'s screen", self.user.github_login).into()) + } + + fn deactivated(&mut self, cx: &mut ViewContext) { + if let Some(nav_history) = self.nav_history.as_mut() { + nav_history.push::<()>(None, cx); + } + } + + fn tab_icon(&self, _cx: &WindowContext) -> Option { + Some(Icon::new(IconName::Screen)) + } + + fn tab_content_text(&self, _cx: &WindowContext) -> Option { + Some(format!("{}'s screen", self.user.github_login).into()) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + + fn set_nav_history(&mut self, history: ItemNavHistory, _: &mut ViewContext) { + self.nav_history = Some(history); + } + + fn clone_on_split( + &self, + _workspace_id: Option, + cx: &mut ViewContext, + ) -> Option> { + Some(cx.new_view(|cx| Self { + view: self.view.update(cx, |view, cx| view.clone(cx)), + peer_id: self.peer_id, + user: self.user.clone(), + nav_history: Default::default(), + focus: cx.focus_handle(), + })) + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { + match event { + Event::Close => f(ItemEvent::CloseItem), + } + } +} diff --git a/crates/workspace/src/shared_screen/macos.rs b/crates/workspace/src/shared_screen/macos.rs new file mode 100644 index 0000000000..ad0b4c4275 --- /dev/null +++ b/crates/workspace/src/shared_screen/macos.rs @@ -0,0 +1,126 @@ +use crate::{ + item::{Item, ItemEvent}, + ItemNavHistory, WorkspaceId, +}; +use anyhow::Result; +use call::participant::{Frame, RemoteVideoTrack}; +use client::{proto::PeerId, User}; +use futures::StreamExt; +use gpui::{ + div, surface, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, + ParentElement, Render, SharedString, Styled, Task, View, ViewContext, VisualContext, + WindowContext, +}; +use std::sync::{Arc, Weak}; +use ui::{prelude::*, Icon, IconName}; + +pub enum Event { + Close, +} + +pub struct SharedScreen { + track: Weak, + frame: Option, + pub peer_id: PeerId, + user: Arc, + nav_history: Option, + _maintain_frame: Task>, + focus: FocusHandle, +} + +impl SharedScreen { + pub fn new( + track: Arc, + peer_id: PeerId, + user: Arc, + cx: &mut ViewContext, + ) -> Self { + cx.focus_handle(); + let mut frames = track.frames(); + Self { + track: Arc::downgrade(&track), + frame: None, + peer_id, + user, + nav_history: Default::default(), + _maintain_frame: cx.spawn(|this, mut cx| async move { + while let Some(frame) = frames.next().await { + this.update(&mut cx, |this, cx| { + this.frame = Some(frame); + cx.notify(); + })?; + } + this.update(&mut cx, |_, cx| cx.emit(Event::Close))?; + Ok(()) + }), + focus: cx.focus_handle(), + } + } +} + +impl EventEmitter for SharedScreen {} + +impl FocusableView for SharedScreen { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus.clone() + } +} +impl Render for SharedScreen { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .bg(cx.theme().colors().editor_background) + .track_focus(&self.focus) + .key_context("SharedScreen") + .size_full() + .children( + self.frame + .as_ref() + .map(|frame| surface(frame.image()).size_full()), + ) + } +} + +impl Item for SharedScreen { + type Event = Event; + + fn tab_tooltip_text(&self, _: &AppContext) -> Option { + Some(format!("{}'s screen", self.user.github_login).into()) + } + + fn deactivated(&mut self, cx: &mut ViewContext) { + if let Some(nav_history) = self.nav_history.as_mut() { + nav_history.push::<()>(None, cx); + } + } + + fn tab_icon(&self, _cx: &WindowContext) -> Option { + Some(Icon::new(IconName::Screen)) + } + + fn tab_content_text(&self, _cx: &WindowContext) -> Option { + Some(format!("{}'s screen", self.user.github_login).into()) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + + fn set_nav_history(&mut self, history: ItemNavHistory, _: &mut ViewContext) { + self.nav_history = Some(history); + } + + fn clone_on_split( + &self, + _workspace_id: Option, + cx: &mut ViewContext, + ) -> Option> { + let track = self.track.upgrade()?; + Some(cx.new_view(|cx| Self::new(track, self.peer_id, self.user.clone(), cx))) + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { + match event { + Event::Close => f(ItemEvent::CloseItem), + } + } +} diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index 00a0078032..585b2700b4 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 42db3183bd..ec6e9015d4 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, @@ -810,7 +810,7 @@ impl Workspace { this.collaborator_left(*peer_id, cx); } - project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded => { + project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded(_) => { this.update_window_title(cx); this.serialize_workspace(cx); } @@ -832,7 +832,7 @@ impl Workspace { cx.remove_window(); } - project::Event::DeletedEntry(entry_id) => { + project::Event::DeletedEntry(_, entry_id) => { for pane in this.panes.iter() { pane.update(cx, |pane, cx| { pane.handle_deleted_project_item(*entry_id, cx) @@ -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); } })?; @@ -3946,6 +3944,17 @@ impl Workspace { None } + #[cfg(target_os = "windows")] + fn shared_screen_for_peer( + &self, + _peer_id: PeerId, + _pane: &View, + _cx: &mut WindowContext, + ) -> Option> { + None + } + + #[cfg(not(target_os = "windows"))] fn shared_screen_for_peer( &self, peer_id: PeerId, @@ -3964,7 +3973,7 @@ impl Workspace { } } - Some(cx.new_view(|cx| SharedScreen::new(&track, peer_id, user.clone(), cx))) + Some(cx.new_view(|cx| SharedScreen::new(track, peer_id, user.clone(), cx))) } pub fn on_window_activation_changed(&mut self, cx: &mut ViewContext) { @@ -4146,30 +4155,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 +4585,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 +6433,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 +6544,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 +6726,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 +7427,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 +7867,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 +7887,10 @@ mod tests { fn project_path(&self, _: &AppContext) -> Option { None } + + fn is_dirty(&self) -> bool { + false + } } impl Item for TestPngItemView { @@ -7483,7 +7933,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 +7953,10 @@ mod tests { fn project_path(&self, _: &AppContext) -> Option { None } + + fn is_dirty(&self) -> bool { + false + } } impl Item for TestIpynbItemView { @@ -7700,4 +8154,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/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 0d872425c1..b27a09c24c 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -19,6 +19,7 @@ pub struct WorkspaceSettings { pub when_closing_with_no_tabs: CloseWindowWhenNoItems, pub use_system_path_prompts: bool, pub command_aliases: HashMap, + pub show_user_picture: bool, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)] @@ -128,6 +129,10 @@ pub struct WorkspaceSettingsContent { /// /// Default: true pub command_aliases: Option>, + /// Whether to show user avatar in the title bar. + /// + /// Default: true + pub show_user_picture: Option, } #[derive(Deserialize)] diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index b7ee4466c7..86981687ce 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; @@ -104,7 +104,6 @@ pub enum CreatedEntry { pub struct LoadedFile { pub file: Arc, pub text: String, - pub diff_base: Option, } pub struct LoadedBinaryFile { @@ -149,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, @@ -356,7 +355,7 @@ enum ScanState { scanning: bool, }, RootUpdated { - new_path: Option>, + new_path: Option, }, } @@ -654,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(), } } @@ -707,6 +706,30 @@ impl Worktree { } } + pub fn load_staged_file(&self, path: &Path, cx: &AppContext) -> Task>> { + match self { + Worktree::Local(this) => { + let path = Arc::from(path); + let snapshot = this.snapshot(); + cx.background_executor().spawn(async move { + if let Some(repo) = snapshot.repository_for_path(&path) { + if let Some(repo_path) = repo.relativize(&snapshot, &path).log_err() { + if let Some(git_repo) = + snapshot.git_repositories.get(&*repo.work_directory) + { + return Ok(git_repo.repo_ptr.load_index_text(&repo_path)); + } + } + } + Ok(None) + }) + } + Worktree::Remote(_) => { + Task::ready(Err(anyhow!("remote worktrees can't yet load staged files"))) + } + } + } + pub fn load_binary_file( &self, path: &Path, @@ -1026,6 +1049,7 @@ impl LocalWorktree { } pub fn contains_abs_path(&self, path: &Path) -> bool { + let path = SanitizedPath::from(path); path.starts_with(&self.abs_path) } @@ -1066,13 +1090,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; @@ -1135,6 +1159,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); @@ -1360,28 +1385,9 @@ impl LocalWorktree { let entry = self.refresh_entry(path.clone(), None, cx); let is_private = self.is_path_private(path.as_ref()); - cx.spawn(|this, mut cx| async move { + cx.spawn(|this, _cx| async move { let abs_path = abs_path?; let text = fs.load(&abs_path).await?; - let mut index_task = None; - let snapshot = this.update(&mut cx, |this, _| this.as_local().unwrap().snapshot())?; - if let Some(repo) = snapshot.repository_for_path(&path) { - if let Some(repo_path) = repo.relativize(&snapshot, &path).log_err() { - if let Some(git_repo) = snapshot.git_repositories.get(&*repo.work_directory) { - let git_repo = git_repo.repo_ptr.clone(); - index_task = Some( - cx.background_executor() - .spawn(async move { git_repo.load_index_text(&repo_path) }), - ); - } - } - } - - let diff_base = if let Some(index_task) = index_task { - index_task.await - } else { - None - }; let worktree = this .upgrade() @@ -1411,11 +1417,7 @@ impl LocalWorktree { } }; - Ok(LoadedFile { - file, - text, - diff_base, - }) + Ok(LoadedFile { file, text }) }) } @@ -2075,7 +2077,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(), @@ -2091,8 +2093,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 { @@ -2132,9 +2146,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()) } } @@ -2193,7 +2207,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(); @@ -2212,7 +2226,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, ); @@ -2632,7 +2646,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 @@ -2786,8 +2800,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)) @@ -2941,7 +2956,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)); @@ -3004,7 +3019,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 @@ -3085,18 +3104,14 @@ 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)?; let actual_repo_path = repository.path(); - let actual_dot_git_dir_abs_path: Arc = Arc::from( - actual_repo_path - .ancestors() - .find(|ancestor| ancestor.file_name() == Some(&*DOT_GIT))?, - ); + let actual_dot_git_dir_abs_path = smol::block_on(find_git_dir(&actual_repo_path, fs))?; watcher.add(&actual_repo_path).log_err()?; let dot_git_worktree_abs_path = if actual_dot_git_dir_abs_path.as_ref() == dot_git_abs_path @@ -3142,6 +3157,31 @@ impl BackgroundScannerState { } } +async fn is_git_dir(path: &Path, fs: &dyn Fs) -> bool { + if path.file_name() == Some(&*DOT_GIT) { + return true; + } + + // If we're in a bare repository, we are not inside a `.git` folder. In a + // bare repository, the root folder contains what would normally be in the + // `.git` folder. + let head_metadata = fs.metadata(&path.join("HEAD")).await; + if !matches!(head_metadata, Ok(Some(_))) { + return false; + } + let config_metadata = fs.metadata(&path.join("config")).await; + matches!(config_metadata, Ok(Some(_))) +} + +async fn find_git_dir(path: &Path, fs: &dyn Fs) -> Option> { + for ancestor in path.ancestors() { + if is_git_dir(ancestor, fs).await { + return Some(Arc::from(ancestor)); + } + } + None +} + async fn build_gitignore(abs_path: &Path, fs: &dyn Fs) -> Result { let contents = fs.load(abs_path).await?; let parent = abs_path.parent().unwrap_or_else(|| Path::new("/")); @@ -3299,9 +3339,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) } } @@ -3712,7 +3752,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 @@ -3744,7 +3784,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(), ); @@ -3763,12 +3809,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); } }; @@ -3818,7 +3864,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() { @@ -3845,7 +3891,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); @@ -3874,7 +3920,7 @@ impl BackgroundScanner { } self.reload_entries_for_paths( - root_path, + root_path.into(), root_canonical_path, &request.relative_paths, abs_paths, @@ -3887,7 +3933,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 @@ -3897,21 +3943,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; } @@ -3943,7 +3988,7 @@ impl BackgroundScanner { } else if fsmonitor_parse_state == Some(FsMonitorParseState::Cookies) && file_name == Some(*FSMONITOR_DAEMON) { fsmonitor_parse_state = Some(FsMonitorParseState::FsMonitor); false - } else if fsmonitor_parse_state != Some(FsMonitorParseState::FsMonitor) && file_name == Some(*DOT_GIT) { + } else if fsmonitor_parse_state != Some(FsMonitorParseState::FsMonitor) && smol::block_on(is_git_dir(ancestor, self.fs.as_ref())) { true } else { fsmonitor_parse_state.take(); @@ -4006,7 +4051,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, @@ -4044,7 +4089,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; @@ -4548,7 +4593,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() { @@ -4627,7 +4672,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/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index fbedd896e3..121caf0b7b 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -12,7 +12,13 @@ use pretty_assertions::assert_eq; use rand::prelude::*; use serde_json::json; use settings::{Settings, SettingsStore}; -use std::{env, fmt::Write, mem, path::Path, sync::Arc}; +use std::{ + env, + fmt::Write, + mem, + path::{Path, PathBuf}, + sync::Arc, +}; use util::{test::temp_tree, ResultExt}; #[gpui::test] @@ -532,14 +538,20 @@ async fn test_open_gitignored_files(cx: &mut TestAppContext) { assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 1); }); + let path = PathBuf::from("/root/one/node_modules/c/lib"); + // No work happens when files and directories change within an unloaded directory. let prev_fs_call_count = fs.read_dir_call_count() + fs.metadata_call_count(); - fs.create_dir("/root/one/node_modules/c/lib".as_ref()) - .await - .unwrap(); + // When we open a directory, we check each ancestor whether it's a git + // repository. That means we have an fs.metadata call per ancestor that we + // need to subtract here. + let ancestors = path.ancestors().count(); + + fs.create_dir(path.as_ref()).await.unwrap(); cx.executor().run_until_parked(); + assert_eq!( - fs.read_dir_call_count() + fs.metadata_call_count() - prev_fs_call_count, + fs.read_dir_call_count() + fs.metadata_call_count() - prev_fs_call_count - ancestors, 0 ); } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 1959fb0e00..2220cc7be0 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.166.0" publish = false license = "GPL-3.0-or-later" authors = ["Zed Team "] @@ -20,6 +20,7 @@ anyhow.workspace = true assets.workspace = true assistant.workspace = true assistant2.workspace = true +assistant_tools.workspace = true async-watch.workspace = true audio.workspace = true auto_update.workspace = true @@ -68,6 +69,7 @@ language_tools.workspace = true languages = { workspace = true, features = ["load-grammars"] } libc.workspace = true log.workspace = true +markdown.workspace = true markdown_preview.workspace = true menu.workspace = true mimalloc = { version = "0.1", optional = true } diff --git a/crates/zed/build.rs b/crates/zed/build.rs index 3013773f91..bf2a0d99fe 100644 --- a/crates/zed/build.rs +++ b/crates/zed/build.rs @@ -13,6 +13,14 @@ fn main() { println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path"); } + if std::env::var("ZED_BUNDLE").ok().as_deref() == Some("true") { + // Find WebRTC.framework in the Frameworks folder when running as part of an application bundle. + println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path/../Frameworks"); + } else { + // Find WebRTC.framework as a sibling of the executable when running outside of an application bundle. + println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path"); + } + // Weakly link ReplayKit to ensure Zed can be used on macOS 10.15+. println!("cargo:rustc-link-arg=-Wl,-weak_framework,ReplayKit"); diff --git a/crates/zed/resources/flatpak/manifest-template.json b/crates/zed/resources/flatpak/manifest-template.json index 7905058f44..1560027e9f 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 6febe05d10..c598054356 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -61,7 +61,7 @@ use zed::{ OpenRequest, }; -use crate::zed::{assistant_hints, inline_completion_registry}; +use crate::zed::inline_completion_registry; #[cfg(feature = "mimalloc")] #[global_allocator] @@ -407,7 +407,7 @@ fn main() { cx, ); assistant2::init(cx); - assistant_hints::init(cx); + assistant_tools::init(cx); repl::init( app_state.fs.clone(), app_state.client.telemetry().clone(), @@ -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/reliability.rs b/crates/zed/src/reliability.rs index 681cc9834f..837db9df60 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -85,7 +85,7 @@ pub fn init_panic_hook( // Strip out leading stack frames for rust panic-handling. if let Some(ix) = backtrace .iter() - .position(|name| name == "rust_begin_unwind") + .position(|name| name == "rust_begin_unwind" || name == "_rust_begin_unwind") { backtrace.drain(0..=ix); } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 086935542c..2adb287b4d 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -1,5 +1,4 @@ mod app_menus; -pub mod assistant_hints; pub mod inline_completion_registry; #[cfg(any(target_os = "linux", target_os = "freebsd"))] pub(crate) mod linux_prompts; @@ -30,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; @@ -241,25 +240,6 @@ pub fn initialize_workspace( let prompt_builder = prompt_builder.clone(); cx.spawn(|workspace_handle, mut cx| async move { - let is_assistant2_enabled = if cfg!(test) { - false - } else { - let is_assistant2_feature_flag_enabled = assistant2_feature_flag.await; - release_channel == ReleaseChannel::Dev && is_assistant2_feature_flag_enabled - }; - - let (assistant_panel, assistant2_panel) = if is_assistant2_enabled { - let assistant2_panel = - assistant2::AssistantPanel::load(workspace_handle.clone(), cx.clone()).await?; - - (None, Some(assistant2_panel)) - } else { - let assistant_panel = - assistant::AssistantPanel::load(workspace_handle.clone(), prompt_builder, cx.clone()).await?; - - (Some(assistant_panel), None) - }; - let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone()); let outline_panel = OutlinePanel::load(workspace_handle.clone(), cx.clone()); let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone()); @@ -288,6 +268,33 @@ pub fn initialize_workspace( notification_panel, )?; + workspace_handle.update(&mut cx, |workspace, cx| { + workspace.add_panel(project_panel, cx); + workspace.add_panel(outline_panel, cx); + workspace.add_panel(terminal_panel, cx); + workspace.add_panel(channels_panel, cx); + workspace.add_panel(chat_panel, cx); + workspace.add_panel(notification_panel, cx); + })?; + let is_assistant2_enabled = + if cfg!(test) || release_channel != ReleaseChannel::Dev { + false + } else { + assistant2_feature_flag.await + } + ; + + let (assistant_panel, assistant2_panel) = if is_assistant2_enabled { + let assistant2_panel = + assistant2::AssistantPanel::load(workspace_handle.clone(), cx.clone()).await?; + + (None, Some(assistant2_panel)) + } else { + let assistant_panel = + assistant::AssistantPanel::load(workspace_handle.clone(), prompt_builder, cx.clone()).await?; + + (Some(assistant_panel), None) + }; workspace_handle.update(&mut cx, |workspace, cx| { if let Some(assistant_panel) = assistant_panel { workspace.add_panel(assistant_panel, cx); @@ -296,13 +303,6 @@ pub fn initialize_workspace( if let Some(assistant2_panel) = assistant2_panel { workspace.add_panel(assistant2_panel, cx); } - - workspace.add_panel(project_panel, cx); - workspace.add_panel(outline_panel, cx); - workspace.add_panel(terminal_panel, cx); - workspace.add_panel(channels_panel, cx); - workspace.add_panel(chat_panel, cx); - workspace.add_panel(notification_panel, cx); }) }) .detach(); @@ -3190,12 +3190,7 @@ mod tests { .fs .save( "/settings.json".as_ref(), - &r#" - { - "base_keymap": "Atom" - } - "# - .into(), + &r#"{"base_keymap": "Atom"}"#.into(), Default::default(), ) .await @@ -3205,16 +3200,7 @@ mod tests { .fs .save( "/keymap.json".as_ref(), - &r#" - [ - { - "bindings": { - "backspace": "test1::A" - } - } - ] - "# - .into(), + &r#"[{"bindings": {"backspace": "test1::A"}}]"#.into(), Default::default(), ) .await @@ -3257,16 +3243,7 @@ mod tests { .fs .save( "/keymap.json".as_ref(), - &r#" - [ - { - "bindings": { - "backspace": "test1::B" - } - } - ] - "# - .into(), + &r#"[{"bindings": {"backspace": "test1::B"}}]"#.into(), Default::default(), ) .await @@ -3286,12 +3263,7 @@ mod tests { .fs .save( "/settings.json".as_ref(), - &r#" - { - "base_keymap": "JetBrains" - } - "# - .into(), + &r#"{"base_keymap": "JetBrains"}"#.into(), Default::default(), ) .await @@ -3318,24 +3290,20 @@ mod tests { // From the Atom keymap use workspace::ActivatePreviousPane; // From the JetBrains keymap - use pane::ActivatePrevItem; + use diagnostics::Deploy; + workspace .update(cx, |workspace, _| { - workspace - .register_action(|_, _: &A, _| {}) - .register_action(|_, _: &B, _| {}); + workspace.register_action(|_, _: &A, _cx| {}); + workspace.register_action(|_, _: &B, _cx| {}); + workspace.register_action(|_, _: &Deploy, _cx| {}); }) .unwrap(); app_state .fs .save( "/settings.json".as_ref(), - &r#" - { - "base_keymap": "Atom" - } - "# - .into(), + &r#"{"base_keymap": "Atom"}"#.into(), Default::default(), ) .await @@ -3344,16 +3312,7 @@ mod tests { .fs .save( "/keymap.json".as_ref(), - &r#" - [ - { - "bindings": { - "backspace": "test2::A" - } - } - ] - "# - .into(), + &r#"[{"bindings": {"backspace": "test2::A"}}]"#.into(), Default::default(), ) .await @@ -3391,16 +3350,7 @@ mod tests { .fs .save( "/keymap.json".as_ref(), - &r#" - [ - { - "bindings": { - "backspace": null - } - } - ] - "# - .into(), + &r#"[{"bindings": {"backspace": null}}]"#.into(), Default::default(), ) .await @@ -3420,12 +3370,7 @@ mod tests { .fs .save( "/settings.json".as_ref(), - &r#" - { - "base_keymap": "JetBrains" - } - "# - .into(), + &r#"{"base_keymap": "JetBrains"}"#.into(), Default::default(), ) .await @@ -3433,12 +3378,7 @@ mod tests { cx.background_executor.run_until_parked(); - assert_key_bindings_for( - workspace.into(), - cx, - vec![("[", &ActivatePrevItem)], - line!(), - ); + assert_key_bindings_for(workspace.into(), cx, vec![("6", &Deploy)], line!()); } #[gpui::test] diff --git a/crates/zed/src/zed/assistant_hints.rs b/crates/zed/src/zed/assistant_hints.rs deleted file mode 100644 index 244b7fab26..0000000000 --- a/crates/zed/src/zed/assistant_hints.rs +++ /dev/null @@ -1,115 +0,0 @@ -use assistant::assistant_settings::AssistantSettings; -use collections::HashMap; -use editor::{ActiveLineTrailerProvider, Editor, EditorMode}; -use gpui::{AnyWindowHandle, AppContext, ViewContext, WeakView, WindowContext}; -use settings::{Settings, SettingsStore}; -use std::{cell::RefCell, rc::Rc}; -use theme::ActiveTheme; -use ui::prelude::*; -use workspace::Workspace; - -pub fn init(cx: &mut AppContext) { - let editors: Rc, AnyWindowHandle>>> = Rc::default(); - - cx.observe_new_views({ - let editors = editors.clone(); - move |_: &mut Workspace, cx: &mut ViewContext| { - let workspace_handle = cx.view().clone(); - cx.subscribe(&workspace_handle, { - let editors = editors.clone(); - move |_, _, event, cx| match event { - workspace::Event::ItemAdded { item } => { - if let Some(editor) = item.act_as::(cx) { - if editor.read(cx).mode() != EditorMode::Full { - return; - } - - cx.on_release({ - let editor_handle = editor.downgrade(); - let editors = editors.clone(); - move |_, _, _| { - editors.borrow_mut().remove(&editor_handle); - } - }) - .detach(); - editors - .borrow_mut() - .insert(editor.downgrade(), cx.window_handle()); - - let show_hints = should_show_hints(cx); - editor.update(cx, |editor, cx| { - assign_active_line_trailer_provider(editor, show_hints, cx) - }) - } - } - _ => {} - } - }) - .detach(); - } - }) - .detach(); - - let mut show_hints = AssistantSettings::get_global(cx).show_hints; - cx.observe_global::(move |cx| { - let new_show_hints = should_show_hints(cx); - if new_show_hints != show_hints { - show_hints = new_show_hints; - for (editor, window) in editors.borrow().iter() { - _ = window.update(cx, |_window, cx| { - _ = editor.update(cx, |editor, cx| { - assign_active_line_trailer_provider(editor, show_hints, cx); - }) - }); - } - } - }) - .detach(); -} - -struct AssistantHintsProvider; - -impl ActiveLineTrailerProvider for AssistantHintsProvider { - fn render_active_line_trailer( - &mut self, - style: &editor::EditorStyle, - focus_handle: &gpui::FocusHandle, - cx: &mut WindowContext, - ) -> Option { - if !focus_handle.is_focused(cx) { - return None; - } - - let chat_keybinding = - cx.keystroke_text_for_action_in(&assistant::ToggleFocus, focus_handle); - let generate_keybinding = - cx.keystroke_text_for_action_in(&zed_actions::InlineAssist::default(), focus_handle); - - Some( - h_flex() - .id("inline-assistant-instructions") - .w_full() - .font_family(style.text.font().family) - .text_color(cx.theme().status().hint) - .line_height(style.text.line_height) - .child(format!( - "{chat_keybinding} to chat, {generate_keybinding} to generate" - )) - .into_any(), - ) - } -} - -fn assign_active_line_trailer_provider( - editor: &mut Editor, - show_hints: bool, - cx: &mut ViewContext, -) { - let provider = show_hints.then_some(AssistantHintsProvider); - editor.set_active_line_trailer_provider(provider, cx); -} - -fn should_show_hints(cx: &AppContext) -> bool { - let assistant_settings = AssistantSettings::get_global(cx); - assistant_settings.enabled && assistant_settings.show_hints -} diff --git a/crates/zed/src/zed/linux_prompts.rs b/crates/zed/src/zed/linux_prompts.rs index 1961a5f9cd..aa262a11b9 100644 --- a/crates/zed/src/zed/linux_prompts.rs +++ b/crates/zed/src/zed/linux_prompts.rs @@ -1,13 +1,15 @@ use gpui::{ div, AppContext, EventEmitter, FocusHandle, FocusableView, FontWeight, InteractiveElement, - IntoElement, ParentElement, PromptHandle, PromptLevel, PromptResponse, Render, - RenderablePromptHandle, Styled, ViewContext, VisualContext, WindowContext, + IntoElement, ParentElement, PromptHandle, PromptLevel, PromptResponse, Refineable, Render, + RenderablePromptHandle, Styled, TextStyleRefinement, View, ViewContext, VisualContext, + WindowContext, }; +use markdown::{Markdown, MarkdownStyle}; use settings::Settings; use theme::ThemeSettings; use ui::{ - h_flex, v_flex, ButtonCommon, ButtonStyle, Clickable, ElevationIndex, FluentBuilder, LabelSize, - TintColor, + h_flex, v_flex, ActiveTheme, ButtonCommon, ButtonStyle, Clickable, ElevationIndex, + FluentBuilder, LabelSize, TintColor, }; use workspace::ui::StyledExt; @@ -28,10 +30,27 @@ pub fn fallback_prompt_renderer( |cx| FallbackPromptRenderer { _level: level, message: message.to_string(), - detail: detail.map(ToString::to_string), actions: actions.iter().map(ToString::to_string).collect(), focus: cx.focus_handle(), active_action_id: 0, + detail: detail.filter(|text| !text.is_empty()).map(|text| { + cx.new_view(|cx| { + let settings = ThemeSettings::get_global(cx); + let mut base_text_style = cx.text_style(); + base_text_style.refine(&TextStyleRefinement { + font_family: Some(settings.ui_font.family.clone()), + font_size: Some(settings.ui_font_size.into()), + color: Some(ui::Color::Muted.color(cx)), + ..Default::default() + }); + let markdown_style = MarkdownStyle { + base_text_style, + selection_background_color: { cx.theme().players().local().selection }, + ..Default::default() + }; + Markdown::new(text.to_string(), markdown_style, None, None, cx) + }) + }), } }); @@ -42,10 +61,10 @@ pub fn fallback_prompt_renderer( pub struct FallbackPromptRenderer { _level: PromptLevel, message: String, - detail: Option, actions: Vec, focus: FocusHandle, active_action_id: usize, + detail: Option>, } impl FallbackPromptRenderer { @@ -111,13 +130,11 @@ impl Render for FallbackPromptRenderer { .child(self.message.clone()) .text_color(ui::Color::Default.color(cx)), ) - .children(self.detail.clone().map(|detail| { - div() - .w_full() - .text_xs() - .text_color(ui::Color::Muted.color(cx)) - .child(detail) - })) + .children( + self.detail + .clone() + .map(|detail| div().w_full().text_xs().child(detail)), + ) .child(h_flex().justify_end().gap_2().children( self.actions.iter().enumerate().rev().map(|(ix, action)| { ui::Button::new(ix, action.clone()) diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index 85090a1b97..bfcd3fa391 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, ) }; @@ -235,26 +238,6 @@ impl Render for QuickActionBar { ); } - menu = menu.toggleable_entry( - "Inline Git Blame", - git_blame_inline_enabled, - IconPosition::Start, - Some(editor::actions::ToggleGitBlameInline.boxed_clone()), - { - let editor = editor.clone(); - move |cx| { - editor - .update(cx, |editor, cx| { - editor.toggle_git_blame_inline( - &editor::actions::ToggleGitBlameInline, - cx, - ) - }) - .ok(); - } - }, - ); - menu = menu.toggleable_entry( "Selection Menu", selection_menu_enabled, @@ -295,6 +278,46 @@ impl Render for QuickActionBar { }, ); + menu = menu.separator(); + + menu = menu.toggleable_entry( + "Inline Git Blame", + git_blame_inline_enabled, + IconPosition::Start, + Some(editor::actions::ToggleGitBlameInline.boxed_clone()), + { + let editor = editor.clone(); + move |cx| { + editor + .update(cx, |editor, cx| { + 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(); + } + }, + ); + menu }); Some(menu) diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index bc7ba52869..d807da8193 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -43,6 +43,8 @@ - [Inline Assistant](./assistant/inline-assistant.md) - [Commands](./assistant/commands.md) - [Prompts](./assistant/prompting.md) +- [Context Servers](./assistant/context-servers.md) + - [Model Context Protocol](./assistant/model-context-protocol.md) # Extensions @@ -51,7 +53,8 @@ - [Developing Extensions](./extensions/developing-extensions.md) - [Language Extensions](./extensions/languages.md) - [Theme Extensions](./extensions/themes.md) -- [Slash Commands](./extensions/slash-commands.md) +- [Slash Command Extensions](./extensions/slash-commands.md) +- [Context Server Extensions](./extensions/context-servers.md) # Language Support diff --git a/docs/src/assistant/assistant.md b/docs/src/assistant/assistant.md index ee4796ec02..94144882f0 100644 --- a/docs/src/assistant/assistant.md +++ b/docs/src/assistant/assistant.md @@ -15,3 +15,5 @@ This section covers various aspects of the Assistant: - [Using Commands](./commands.md): Explore slash commands that enhance the Assistant's capabilities and future extensibility. - [Prompting & Prompt Library](./prompting.md): Learn how to write and save prompts, how to use the Prompt Library, and how to edit prompt templates. + +- [Context Servers](./context-servers.md): Learn about context servers that enhance the Assistant's capabilities via the [Model Context Protocol](./model-context-protocol.md). diff --git a/docs/src/assistant/configuration.md b/docs/src/assistant/configuration.md index 1be96491f4..8e558007bf 100644 --- a/docs/src/assistant/configuration.md +++ b/docs/src/assistant/configuration.md @@ -192,6 +192,30 @@ The Zed Assistant comes pre-configured to use the latest version for common mode You must provide the model's Context Window in the `max_tokens` parameter, this can be found [OpenAI Model Docs](https://platform.openai.com/docs/models). OpenAI `o1` models should set `max_completion_tokens` as well to avoid incurring high reasoning token costs. Custom models will be listed in the model dropdown in the assistant panel. +### OpenAI API Compatible + +Zed supports using OpenAI compatible APIs by specifying a custom `endpoint` and `available_models` for the OpenAI provider. + +#### X.ai Grok + +Example configuration for using X.ai Grok with Zed: + +```json + "language_models": { + "openai": { + "api_url": "https://api.x.ai/v1", + "available_models": [ + { + "name": "grok-beta", + "display_name": "X.ai Grok (Beta)", + "max_tokens": 131072 + } + ], + "version": "1" + }, + } +``` + ### Advanced configuration {#advanced-configuration} #### Example Configuration @@ -200,28 +224,18 @@ You must provide the model's Context Window in the `max_tokens` parameter, this { "assistant": { "enabled": true, - "show_hints": true, - "button": true, - "dock": "right" - "default_width": 480, "default_model": { "provider": "zed.dev", "model": "claude-3-5-sonnet" }, "version": "2", + "button": true, + "default_width": 480, + "dock": "right" } } ``` -| key | type | default | description | -| -------------- | ------- | ------- | ------------------------------------------------------------------------------------- | -| enabled | boolean | true | Setting this to `false` will completely disable the assistant | -| show_hints | boolean | true | Whether to to show hints in the editor explaining how to use assistant | -| button | boolean | true | Show the assistant icon in the status bar | -| dock | string | "right" | The default dock position for the assistant panel. Can be ["left", "right", "bottom"] | -| default_height | string | null | The pixel height of the assistant panel when docked to the bottom | -| default_width | string | null | The pixel width of the assistant panel when docked to the left or right | - #### Custom endpoints {#custom-endpoint} You can use a custom API endpoint for different providers, as long as it's compatible with the providers API structure. @@ -281,3 +295,13 @@ will generate two outputs for every assist. One with Claude 3.5 Sonnet, and one } } ``` + +#### Common Panel Settings + +| key | type | default | description | +| -------------- | ------- | ------- | ------------------------------------------------------------------------------------- | +| enabled | boolean | true | Setting this to `false` will completely disable the assistant | +| button | boolean | true | Show the assistant icon in the status bar | +| dock | string | "right" | The default dock position for the assistant panel. Can be ["left", "right", "bottom"] | +| default_height | string | null | The pixel height of the assistant panel when docked to the bottom | +| default_width | string | null | The pixel width of the assistant panel when docked to the left or right | diff --git a/docs/src/assistant/context-servers.md b/docs/src/assistant/context-servers.md new file mode 100644 index 0000000000..398442044c --- /dev/null +++ b/docs/src/assistant/context-servers.md @@ -0,0 +1,49 @@ +# Context Servers + +Context servers are a mechanism for pulling context into the Assistant from an external source. They are powered by the [Model Context Protocol](./model-context-protocol.md). + +Currently Zed supports context servers providing [slash commands](./commands.md) for use in the Assistant. + +## Installation + +Context servers can be installed via [extensions](../extensions/context-servers.md). + +If you don't already have a context server, check out one of these: + +- [Postgres Context Server](https://github.com/zed-extensions/postgres-context-server) + +## Configuration + +Context servers may require some configuration in order to run or to change their behavior. + +You can configure each context server using the `context_servers` setting in your `settings.json`: + +```json +{ + "context_servers": { + "postgres-context-server": { + "settings": { + "database_url": "postgresql://postgres@localhost/my_database" + } + } +} +``` + +If desired, you may also provide a custom command to execute a context server: + +```json +{ + "context_servers": { + "my-context-server": { + "command": { + "path": "/path/to/my-context-server", + "args": ["run"], + "env": {} + }, + "settings": { + "enable_something": true + } + } + } +} +``` diff --git a/docs/src/assistant/model-context-protocol.md b/docs/src/assistant/model-context-protocol.md new file mode 100644 index 0000000000..74e16b59ff --- /dev/null +++ b/docs/src/assistant/model-context-protocol.md @@ -0,0 +1,21 @@ +# Model Context Protocol + +Zed uses the [Model Context Protocol](https://modelcontextprotocol.io/) to interact with [context servers](./context-servers.md): + +> The Model Context Protocol (MCP) is an open protocol that enables seamless integration between LLM applications and external data sources and tools. Whether you're building an AI-powered IDE, enhancing a chat interface, or creating custom AI workflows, MCP provides a standardized way to connect LLMs with the context they need. + +Check out the [Anthropic news post](https://www.anthropic.com/news/model-context-protocol) and the [Zed blog post](https://zed.dev/blog/mcp) for an introduction to MCP. + +## Try it out + +Want to try it for yourself? + +The following context servers are available today as Zed extensions: + +- [Postgres Context Server](https://github.com/zed-extensions/postgres-context-server) + +## Bring your own context server + +If there's an existing context server you'd like to bring to Zed, check out the [context server extension docs](../extensions/context-servers.md) for how to make it available as an extension. + +If you are interested in building your own context server, check out the [Model Context Protocol docs](https://modelcontextprotocol.io/introduction#get-started-with-mcp) to get started. diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 4991ff1119..d4f8c40dbd 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. @@ -2333,18 +2350,15 @@ Run the `theme selector: toggle` action in the command palette to see a current - Default: ```json -{ - "assistant": { - "enabled": true, - "button": true, - "dock": "right", - "default_width": 640, - "default_height": 320, - "provider": "openai", - "version": "1", - "show_hints": true - } -} +"assistant": { + "enabled": true, + "button": true, + "dock": "right", + "default_width": 640, + "default_height": 320, + "provider": "openai", + "version": "1", +}, ``` ## Outline Panel diff --git a/docs/src/development/linux.md b/docs/src/development/linux.md index 5dba44d2f0..1505f99e88 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 2fd076b0fa..fe15e9f56e 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 f95cfb3ed0..4d1e565a57 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 @@ -149,3 +139,5 @@ New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name ``` For more information on this, please see [win32 docs](https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=powershell) + +(note that you will need to restart your system after enabling longpath support) diff --git a/docs/src/extensions/context-servers.md b/docs/src/extensions/context-servers.md new file mode 100644 index 0000000000..6e61987384 --- /dev/null +++ b/docs/src/extensions/context-servers.md @@ -0,0 +1,39 @@ +# Context Servers + +Extensions may provide [context servers](../assistant/context-servers.md) for use in the Assistant. + +## Example extension + +To see a working example of an extension that provides context servers, check out the [`postgres-context-server` extension](https://github.com/zed-extensions/postgres-context-server). + +This extension can be [installed as a dev extension](./developing-extensions.html#developing-an-extension-locally) if you want to try it out for yourself. + +## Defining context servers + +A given extension may provide one or more context servers. Each context server must be registered in the `extension.toml`: + +```toml +[context-servers.my-context-server] +``` + +Then, in the Rust code for your extension, implement the `context_server_command` method on your extension: + +```rust +impl zed::Extension for MyExtension { + fn context_server_command( + &mut self, + context_server_id: &ContextServerId, + project: &zed::Project, + ) -> Result { + Ok(zed::Command { + command: get_path_to_context_server_executable()?, + args: get_args_for_context_server()?, + env: get_env_for_context_server()?, + }) + } +} +``` + +This method should return the command to start up a context server, along with any arguments or environment variables necessary for it to function. + +If you need to download the context server from an external source—like GitHub Releases or npm—you can also do this here. diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md index 36939d4f1e..c404d260a0 100644 --- a/docs/src/extensions/developing-extensions.md +++ b/docs/src/extensions/developing-extensions.md @@ -7,6 +7,17 @@ Extensions can add the following capabilities to Zed: - [Languages](./languages.md) - [Themes](./themes.md) - [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 @@ -74,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 0995ed97fd..fc2c42c74a 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 4737a99a3e..ecdbdace59 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 68db517480..4d0a33ce55 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 @@ -146,20 +146,15 @@ Finally keyboards that support extended Latin alphabets (usually ISO keyboards) For example on a German QWERTZ keyboard, the `cmd->` shortcut is moved to `cmd-:` because `cmd->` is the system window switcher and this is where that shortcut is typed on a QWERTY keyboard. `cmd-+` stays the same because + is still typable without option, and as a result, `cmd-[` and `cmd-]` become `cmd-ö` and `cmd-ä`, moving out of the way of the `+` key. -If you are defining shortcuts in your personal keymap, you can opt-out of the key equivalent mapping by setting `use_layout_keys` to `true` in your keymap: +If you are defining shortcuts in your personal keymap, you can opt into the key equivalent mapping by setting `use_key_equivalents` to `true` in your keymap: ```json [ { + "use_key_equivalents": true, "bindings": { "ctrl->": "editor::Indent" // parsed as ctrl-: when a German QWERTZ keyboard is active } - }, - { - "use_layout_keys": true, - "bindings": { - "ctrl->": "editor::Indent" // remains ctrl-> when a German QWERTZ keyboard is active - } } ] ``` diff --git a/docs/src/languages/luau.md b/docs/src/languages/luau.md index c7abd0cae9..0c5e94dcf8 100644 --- a/docs/src/languages/luau.md +++ b/docs/src/languages/luau.md @@ -5,7 +5,7 @@ Luau language support in Zed is provided by the community-maintained [Luau extension](https://github.com/4teapo/zed-luau). Report issues to: [https://github.com/4teapo/zed-luau/issues](https://github.com/4teapo/zed-luau/issues) -- Tree Sitter: [tree-sitter-grammars/tree-sitter-luau](https://github.com/tree-sitter-grammars/tree-sitter-luau) +- Tree Sitter: [4teapo/tree-sitter-luau](https://github.com/4teapo/tree-sitter-luau) - Language Server: [JohnnyMorganz/luau-lsp](https://github.com/JohnnyMorganz/luau-lsp) ## Configuration @@ -33,7 +33,7 @@ Then add the following to your Zed `settings.json`: "formatter": { "external": { "command": "stylua", - "arguments": ["-"] + "arguments": ["--stdin-filepath", "{buffer_path}", "-"] } } } diff --git a/docs/src/vim.md b/docs/src/vim.md index 8bfa6aa73f..4f87c649ef 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. +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 | -| ---------------------------- | ---------------- | -| Select a smaller syntax node | `] x` | -| Select a larger syntax node | `[ x` | +| 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 @@ -409,6 +438,7 @@ You can change the following settings to modify vim mode's behavior: | use_smartcase_find | If `true`, `f` and `t` motions are case-insensitive when the target letter is lowercase. | false | | toggle_relative_line_numbers | If `true`, line numbers are relative in normal mode and absolute in insert mode, giving you the best of both options. | false | | custom_digraphs | An object that allows you to add custom digraphs. Read below for an example. | {} | +| highlight_on_yank_duration | The duration of the highlight animation(in ms). Set to `0` to disable | 200 | Here's an example of adding a digraph for the zombie emoji. This allows you to type `ctrl-k f z` to insert a zombie emoji. You can add as many digraphs as you like. @@ -431,6 +461,7 @@ Here's an example of these settings changed: "use_multiline_find": true, "use_smartcase_find": true, "toggle_relative_line_numbers": true, + "highlight_on_yank_duration": 50, "custom_digraphs": { "fz": "🧟‍♀️" } @@ -442,15 +473,15 @@ Here's an example of these settings changed: Here are a few general Zed settings that can help you fine-tune your Vim experience: -| Property | Description | Default Value | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | -| cursor_blink | If `true`, the cursor blinks. | `true` | -| relative_line_numbers | If `true`, line numbers in the left gutter are relative to the cursor. | `true` | -| scrollbar | Object that controls the scrollbar display. Set to `{ "show": "never" }` to hide the scroll bar. | `{ "show": "always" }` | -| scroll_beyond_last_line | If set to `"one_page"`, allows scrolling up to one page beyond the last line. Set to `"off"` to prevent this behavior. | `"one_page"` | -| vertical_scroll_margin | The number of lines to keep above or below the cursor when scrolling. Set to `0` to allow the cursor to go up to the edges of the screen vertically. | `3` | -| gutter.line_numbers | Controls the display of line numbers in the gutter. Set the `"line_numbers"` property to `false` to hide line numbers. | `true` | -| command_aliases | Object that defines aliases for commands in the command palette. You can use it to define shortcut names for commands you use often. Read below for examples. | `{}` | +| Property | Description | Default Value | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | +| cursor_blink | If `true`, the cursor blinks. | `true` | +| relative_line_numbers | If `true`, line numbers in the left gutter are relative to the cursor. | `false` | +| scrollbar | Object that controls the scrollbar display. Set to `{ "show": "never" }` to hide the scroll bar. | `{ "show": "auto" }` | +| scroll_beyond_last_line | If set to `"one_page"`, allows scrolling up to one page beyond the last line. Set to `"off"` to prevent this behavior. | `"one_page"` | +| vertical_scroll_margin | The number of lines to keep above or below the cursor when scrolling. Set to `0` to allow the cursor to go up to the edges of the screen vertically. | `3` | +| gutter.line_numbers | Controls the display of line numbers in the gutter. Set the `"line_numbers"` property to `false` to hide line numbers. | `true` | +| command_aliases | Object that defines aliases for commands in the command palette. You can use it to define shortcut names for commands you use often. Read below for examples. | `{}` | Here's an example of these settings changed: diff --git a/extensions/erlang/Cargo.toml b/extensions/erlang/Cargo.toml index 5067344896..ca354e0cbc 100644 --- a/extensions/erlang/Cargo.toml +++ b/extensions/erlang/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_erlang" -version = "0.1.0" +version = "0.1.1" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/erlang/extension.toml b/extensions/erlang/extension.toml index 8dd2628fd2..f6e903ccf9 100644 --- a/extensions/erlang/extension.toml +++ b/extensions/erlang/extension.toml @@ -1,7 +1,7 @@ id = "erlang" name = "Erlang" description = "Erlang support." -version = "0.1.0" +version = "0.1.1" schema_version = 1 authors = ["Dairon M ", "Fabian Bergström "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/erlang/languages/erlang/textobjects.scm b/extensions/erlang/languages/erlang/textobjects.scm new file mode 100644 index 0000000000..e802a2f362 --- /dev/null +++ b/extensions/erlang/languages/erlang/textobjects.scm @@ -0,0 +1,6 @@ +(function_clause + body: (_ "->" (_)* @function.inside)) @function.around + +(type_alias ty: (_) @class.inside) @class.around + +(comment)+ @comment.around diff --git a/extensions/haskell/Cargo.toml b/extensions/haskell/Cargo.toml index 0b69075a20..c106a0dd1b 100644 --- a/extensions/haskell/Cargo.toml +++ b/extensions/haskell/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_haskell" -version = "0.1.1" +version = "0.1.2" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/haskell/extension.toml b/extensions/haskell/extension.toml index 2ef30cb3d5..003687136e 100644 --- a/extensions/haskell/extension.toml +++ b/extensions/haskell/extension.toml @@ -1,7 +1,7 @@ id = "haskell" name = "Haskell" description = "Haskell support." -version = "0.1.1" +version = "0.1.2" schema_version = 1 authors = [ "Pocæus ", diff --git a/extensions/haskell/languages/haskell/textobjects.scm b/extensions/haskell/languages/haskell/textobjects.scm new file mode 100644 index 0000000000..4302397467 --- /dev/null +++ b/extensions/haskell/languages/haskell/textobjects.scm @@ -0,0 +1,12 @@ +(comment)+ @comment.around + +[ + (adt) + (type_alias) + (newtype) +] @class.around + +(record_fields "{" (_)* @class.inside "}") + +((signature)? (function)+) @function.around +(function rhs:(_) @function.inside) diff --git a/extensions/lua/Cargo.toml b/extensions/lua/Cargo.toml index f577ce1871..8eec6ed62f 100644 --- a/extensions/lua/Cargo.toml +++ b/extensions/lua/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_lua" -version = "0.1.0" +version = "0.1.1" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/lua/extension.toml b/extensions/lua/extension.toml index 82026f48ba..52120cdfa2 100644 --- a/extensions/lua/extension.toml +++ b/extensions/lua/extension.toml @@ -1,7 +1,7 @@ id = "lua" name = "Lua" description = "Lua support." -version = "0.1.0" +version = "0.1.1" schema_version = 1 authors = ["Max Brunsfeld "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/lua/languages/lua/textobjects.scm b/extensions/lua/languages/lua/textobjects.scm new file mode 100644 index 0000000000..1f8bf66059 --- /dev/null +++ b/extensions/lua/languages/lua/textobjects.scm @@ -0,0 +1,7 @@ +(function_definition + body: (_) @function.inside) @function.around + +(function_declaration + body: (_) @function.inside) @function.around + +(comment)+ @comment.around diff --git a/extensions/perplexity/extension.toml b/extensions/perplexity/extension.toml index 205f8a5cc2..474d9ee981 100644 --- a/extensions/perplexity/extension.toml +++ b/extensions/perplexity/extension.toml @@ -3,7 +3,7 @@ name = "Perplexity" version = "0.1.0" description = "Ask questions to Perplexity AI directly from Zed" authors = ["Zed Industries "] -repository = "https://github.com/zed-industries/zed-perplexity" +repository = "https://github.com/zed-industries/zed" schema_version = 1 [slash_commands.perplexity] diff --git a/extensions/php/Cargo.toml b/extensions/php/Cargo.toml index a78c133e8e..8bf6a523f4 100644 --- a/extensions/php/Cargo.toml +++ b/extensions/php/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_php" -version = "0.2.2" +version = "0.2.3" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/php/extension.toml b/extensions/php/extension.toml index eec2fe5d39..a2bc1d921e 100644 --- a/extensions/php/extension.toml +++ b/extensions/php/extension.toml @@ -1,7 +1,7 @@ id = "php" name = "PHP" description = "PHP support." -version = "0.2.2" +version = "0.2.3" schema_version = 1 authors = ["Piotr Osiewicz "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/php/languages/php/textobjects.scm b/extensions/php/languages/php/textobjects.scm new file mode 100644 index 0000000000..d86a0c1252 --- /dev/null +++ b/extensions/php/languages/php/textobjects.scm @@ -0,0 +1,45 @@ +(function_definition + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + +(method_declaration + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + +(method_declaration) @function.around + +(class_declaration + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(interface_declaration + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(trait_declaration + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(enum_declaration + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(namespace_definition + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(comment)+ @comment.around diff --git a/extensions/prisma/Cargo.toml b/extensions/prisma/Cargo.toml index e5a261266a..68256bd1cc 100644 --- a/extensions/prisma/Cargo.toml +++ b/extensions/prisma/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_prisma" -version = "0.0.3" +version = "0.0.4" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/prisma/extension.toml b/extensions/prisma/extension.toml index 449f990d2f..22b2bd9f2b 100644 --- a/extensions/prisma/extension.toml +++ b/extensions/prisma/extension.toml @@ -1,7 +1,7 @@ id = "prisma" name = "Prisma" description = "Prisma support." -version = "0.0.3" +version = "0.0.4" schema_version = 1 authors = ["Matthew Gramigna "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/prisma/languages/prisma/textobjects.scm b/extensions/prisma/languages/prisma/textobjects.scm new file mode 100644 index 0000000000..0158c90786 --- /dev/null +++ b/extensions/prisma/languages/prisma/textobjects.scm @@ -0,0 +1,25 @@ +(model_declaration + (statement_block + "{" + (_)* @class.inside + "}")) @class.around + +(datasource_declaration + (statement_block + "{" + (_)* @class.inside + "}")) @class.around + +(generator_declaration + (statement_block + "{" + (_)* @class.inside + "}")) @class.around + +(enum_declaration + (enum_block + "{" + (_)* @class.inside + "}")) @class.around + +(developer_comment)+ @comment.around diff --git a/extensions/proto/Cargo.toml b/extensions/proto/Cargo.toml index 215a09f896..03c9bc5626 100644 --- a/extensions/proto/Cargo.toml +++ b/extensions/proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_proto" -version = "0.2.0" +version = "0.2.1" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/proto/extension.toml b/extensions/proto/extension.toml index f26aee7dde..232602faf7 100644 --- a/extensions/proto/extension.toml +++ b/extensions/proto/extension.toml @@ -1,7 +1,7 @@ id = "proto" name = "Proto" description = "Protocol Buffers support." -version = "0.2.0" +version = "0.2.1" schema_version = 1 authors = ["Zed Industries "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/proto/languages/proto/textobjects.scm b/extensions/proto/languages/proto/textobjects.scm new file mode 100644 index 0000000000..90ea84282d --- /dev/null +++ b/extensions/proto/languages/proto/textobjects.scm @@ -0,0 +1,18 @@ +(message (message_body + "{" + (_)* @class.inside + "}")) @class.around +(enum (enum_body + "{" + (_)* @class.inside + "}")) @class.around +(service + "service" + (_) + "{" + (_)* @class.inside + "}") @class.around + +(rpc) @function.around + +(comment)+ @comment.around diff --git a/extensions/toml/Cargo.toml b/extensions/toml/Cargo.toml index 3aa7b69224..85d933e236 100644 --- a/extensions/toml/Cargo.toml +++ b/extensions/toml/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_toml" -version = "0.1.1" +version = "0.1.2" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/toml/extension.toml b/extensions/toml/extension.toml index 15db5c464d..a8b9250226 100644 --- a/extensions/toml/extension.toml +++ b/extensions/toml/extension.toml @@ -1,7 +1,7 @@ id = "toml" name = "TOML" description = "TOML support." -version = "0.1.1" +version = "0.1.2" schema_version = 1 authors = [ "Max Brunsfeld ", diff --git a/extensions/toml/languages/toml/textobjects.scm b/extensions/toml/languages/toml/textobjects.scm new file mode 100644 index 0000000000..f5b4856e27 --- /dev/null +++ b/extensions/toml/languages/toml/textobjects.scm @@ -0,0 +1,6 @@ +(comment)+ @comment +(table "[" (_) "]" + (_)* @class.inside) @class.around + +(table_array_element "[[" (_) "]]" + (_)* @class.inside) @class.around diff --git a/extensions/zig/Cargo.toml b/extensions/zig/Cargo.toml index 63f3c5c007..e29542d27e 100644 --- a/extensions/zig/Cargo.toml +++ b/extensions/zig/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_zig" -version = "0.3.1" +version = "0.3.2" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/zig/extension.toml b/extensions/zig/extension.toml index bcd4f58555..380300683b 100644 --- a/extensions/zig/extension.toml +++ b/extensions/zig/extension.toml @@ -1,7 +1,7 @@ id = "zig" name = "Zig" description = "Zig support." -version = "0.3.1" +version = "0.3.2" schema_version = 1 authors = ["Allan Calix "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/zig/languages/zig/textobjects.scm b/extensions/zig/languages/zig/textobjects.scm new file mode 100644 index 0000000000..b08df97ea9 --- /dev/null +++ b/extensions/zig/languages/zig/textobjects.scm @@ -0,0 +1,27 @@ +(function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(test_declaration + (block + "{" + (_)* @function.inside + "}")) @function.around + +(variable_declaration + (struct_declaration + "struct" + "{" + [(_) ","]* @class.inside + "}")) @class.around + +(variable_declaration + (enum_declaration + "enum" + "{" + (_)* @class.inside + "}")) @class.around + +(comment)+ @comment.around diff --git a/flake.lock b/flake.lock index 5666e73569..ae27b51678 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 2ee86c4466..f797227fba 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"]; + outputs = + { + nixpkgs, + rust-overlay, + crane, + ... + }: + let + systems = [ + "x86_64-linux" + "x86_64-darwin" + "aarch64-linux" + "aarch64-darwin" + ]; - 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; + 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 4782c9a56f..e78025dffd 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 [] ../.; + withGLES ? false, +}: + +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 e0b4018778..75ceb0d8e3 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 +pkgs.mkShell rec { + packages = [ + pkgs.clang + pkgs.curl + pkgs.cmake + pkgs.perl + pkgs.pkg-config + pkgs.protobuf + pkgs.rustPlatform.bindgenHook + pkgs.rust-analyzer + ]; + + 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; + + # 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" ]; - in - pkgs.mkShell.override {inherit stdenv;} { - nativeBuildInputs = with pkgs; [ - clang - curl - cmake - perl - pkg-config - protobuf - rustPlatform.bindgenHook - ]; - - inherit buildInputs; - - shellHook = '' - export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath buildInputs}:$LD_LIBRARY_PATH" - export PROTOC="${pkgs.protobuf}/bin/protoc" - ''; - - FONTCONFIG_FILE = pkgs.makeFontsConf { - fontDirectories = [ - "./assets/fonts/zed-mono" - "./assets/fonts/zed-sans" - ]; - }; - ZSTD_SYS_USE_PKG_CONFIG = true; - } + }; + ZSTD_SYS_USE_PKG_CONFIG = true; +} diff --git a/script/bundle-linux b/script/bundle-linux index 98b49ae4da..c05037b6cc 100755 --- a/script/bundle-linux +++ b/script/bundle-linux @@ -92,7 +92,7 @@ cp "${target_dir}/${target_triple}/release/cli" "${zed_dir}/bin/zed" find_libs() { ldd ${target_dir}/${target_triple}/release/zed |\ cut -d' ' -f3 |\ - grep -v '\<\(libstdc++.so\|libc.so\|libgcc_s.so\|libm.so\|libpthread.so\|libdl.so\)' + grep -v '\<\(libstdc++.so\|libc.so\|libgcc_s.so\|libm.so\|libpthread.so\|libdl.so\|libasound.so\)' } mkdir -p "${zed_dir}/lib" diff --git a/script/check-rust-livekit-macos b/script/check-rust-livekit-macos new file mode 100755 index 0000000000..e2d0f9cf62 --- /dev/null +++ b/script/check-rust-livekit-macos @@ -0,0 +1,19 @@ +#!/bin/bash + + +set -exuo pipefail + +git apply script/patches/use-cross-platform-livekit.patch + +# Re-enable error skipping for this check, so that we can unapply the patch +set +e + +cargo check -p workspace +exit_code=$? + +# Disable error skipping again +set -e + +git apply -R script/patches/use-cross-platform-livekit.patch + +exit "$exit_code" diff --git a/script/get-released-version b/script/get-released-version index e1f4783f8a..357de7c240 100755 --- a/script/get-released-version +++ b/script/get-released-version @@ -18,4 +18,4 @@ case $channel in ;; esac -curl -s https://zed.dev/api/releases/latest?asset=Zed.dmg$query | jq -r .version +curl -s "https://zed.dev/api/releases/latest?asset=zed&os=macos&arch=aarch64$query" | jq -r .version diff --git a/script/import-themes b/script/import-themes index ce9ce9ef12..8f07df2ef3 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 3f2c690779..9cd21119b7 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/language-extension-version b/script/language-extension-version index fc5c448736..d547886087 100755 --- a/script/language-extension-version +++ b/script/language-extension-version @@ -26,4 +26,3 @@ fi sed -i '' -e "s/^version = \".*\"/version = \"$VERSION\"/" "$EXTENSION_TOML" sed -i '' -e "s/^version = \".*\"/version = \"$VERSION\"/" "$CARGO_TOML" -cargo check diff --git a/script/linux b/script/linux index eecf70f90e..7457b8de76 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/patches/use-cross-platform-livekit.patch b/script/patches/use-cross-platform-livekit.patch new file mode 100644 index 0000000000..81dcca80f6 --- /dev/null +++ b/script/patches/use-cross-platform-livekit.patch @@ -0,0 +1,59 @@ +diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml +index 9ba10e56ba..bb69440691 100644 +--- a/crates/call/Cargo.toml ++++ b/crates/call/Cargo.toml +@@ -41,10 +41,10 @@ serde_derive.workspace = true + settings.workspace = true + util.workspace = true + +-[target.'cfg(target_os = "macos")'.dependencies] ++[target.'cfg(any())'.dependencies] + livekit_client_macos = { workspace = true } + +-[target.'cfg(not(target_os = "macos"))'.dependencies] ++[target.'cfg(all())'.dependencies] + livekit_client = { workspace = true } + + [dev-dependencies] +diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs +index 5e212d35b7..a8f9e8f43e 100644 +--- a/crates/call/src/call.rs ++++ b/crates/call/src/call.rs +@@ -1,13 +1,13 @@ + pub mod call_settings; + +-#[cfg(target_os = "macos")] ++#[cfg(any())] + mod macos; + +-#[cfg(target_os = "macos")] ++#[cfg(any())] + pub use macos::*; + +-#[cfg(not(target_os = "macos"))] ++#[cfg(all())] + mod cross_platform; + +-#[cfg(not(target_os = "macos"))] ++#[cfg(all())] + pub use cross_platform::*; +diff --git a/crates/workspace/src/shared_screen.rs b/crates/workspace/src/shared_screen.rs +index 1d17cfa145..f845234987 100644 +--- a/crates/workspace/src/shared_screen.rs ++++ b/crates/workspace/src/shared_screen.rs +@@ -1,11 +1,11 @@ +-#[cfg(target_os = "macos")] ++#[cfg(any())] + mod macos; + +-#[cfg(target_os = "macos")] ++#[cfg(any())] + pub use macos::*; + +-#[cfg(not(target_os = "macos"))] ++#[cfg(all())] + mod cross_platform; + +-#[cfg(not(target_os = "macos"))] ++#[cfg(all())] + pub use cross_platform::*; diff --git a/script/uninstall.sh b/script/uninstall.sh new file mode 100644 index 0000000000..3e460b8186 --- /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 "$@" diff --git a/typos.toml b/typos.toml index 0682d0a3a9..50f3aadd0a 100644 --- a/typos.toml +++ b/typos.toml @@ -22,7 +22,7 @@ extend-exclude = [ # Stripe IDs are flagged as typos. "crates/collab/src/db/tests/processed_stripe_event_tests.rs", # Not our typos. - "crates/live_kit_server/", + "crates/livekit_server/", # Vim makes heavy use of partial typing tables. "crates/vim/", # Editor and file finder rely on partial typing and custom in-string syntax. @@ -43,6 +43,8 @@ extend-exclude = [ "docs/theme/css/", # Spellcheck triggers on `|Fixe[sd]|` regex part. "script/danger/dangerfile.ts", + # Hashes are not typos + "script/patches/use-cross-platform-livekit.patch" ] [default]