diff --git a/.github/actions/check_formatting/action.yml b/.github/actions/check_formatting/action.yml deleted file mode 100644 index 7fef26407b..0000000000 --- a/.github/actions/check_formatting/action.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: 'Check formatting' -description: 'Checks code formatting use cargo fmt' - -runs: - using: "composite" - steps: - - name: Install Rust - shell: bash -euxo pipefail {0} - run: | - rustup set profile minimal - rustup update stable - - - name: cargo fmt - shell: bash -euxo pipefail {0} - run: cargo fmt --all -- --check diff --git a/.github/actions/check_style/action.yml b/.github/actions/check_style/action.yml new file mode 100644 index 0000000000..25020e4e4c --- /dev/null +++ b/.github/actions/check_style/action.yml @@ -0,0 +1,23 @@ +name: "Check formatting" +description: "Checks code formatting use cargo fmt" + +runs: + using: "composite" + steps: + - name: cargo fmt + shell: bash -euxo pipefail {0} + run: cargo fmt --all -- --check + + - name: cargo clippy + shell: bash -euxo pipefail {0} + # clippy.toml is not currently supporting specifying allowed lints + # so specify those here, and disable the rest until Zed's workspace + # will have more fixes & suppression for the standard lint set + run: | + cargo clippy --workspace --all-features --all-targets -- -A clippy::all -D clippy::dbg_macro -D clippy::todo + + - name: Find modified migrations + shell: bash -euxo pipefail {0} + run: | + export SQUAWK_GITHUB_TOKEN=${{ github.token }} + . ./script/squawk diff --git a/.github/actions/run_tests/action.yml b/.github/actions/run_tests/action.yml index 1ea51a06a6..af37af7fc4 100644 --- a/.github/actions/run_tests/action.yml +++ b/.github/actions/run_tests/action.yml @@ -2,29 +2,26 @@ name: "Run tests" description: "Runs the tests" runs: - using: "composite" - steps: - - name: Install Rust - shell: bash -euxo pipefail {0} - run: | - rustup set profile minimal - rustup update stable - rustup target add wasm32-wasi - cargo install cargo-nextest + using: "composite" + steps: + - name: Install Rust + shell: bash -euxo pipefail {0} + run: | + cargo install cargo-nextest - - name: Install Node - uses: actions/setup-node@v3 - with: - node-version: "18" + - name: Install Node + uses: actions/setup-node@v3 + with: + node-version: "18" - - name: Limit target directory size - shell: bash -euxo pipefail {0} - run: script/clear-target-dir-if-larger-than 100 + - name: Limit target directory size + shell: bash -euxo pipefail {0} + run: script/clear-target-dir-if-larger-than 100 - - name: Run check - shell: bash -euxo pipefail {0} - run: cargo check --tests --workspace + - name: Run check + shell: bash -euxo pipefail {0} + run: cargo check --tests --workspace - - name: Run tests - shell: bash -euxo pipefail {0} - run: cargo nextest run --workspace --no-fail-fast + - name: Run tests + shell: bash -euxo pipefail {0} + run: cargo nextest run --workspace --no-fail-fast diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ba25dbf94..2c660b7a0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,8 @@ env: RUST_BACKTRACE: 1 jobs: - rustfmt: - name: Check formatting + style: + name: Check formatting, Clippy lints, and spelling runs-on: - self-hosted - test @@ -33,19 +33,27 @@ jobs: with: clean: false submodules: "recursive" + fetch-depth: 0 - name: Set up default .cargo/config.toml run: cp ./.cargo/ci-config.toml ~/.cargo/config.toml - - name: Run rustfmt - uses: ./.github/actions/check_formatting + - name: Check spelling + run: | + if ! which typos > /dev/null; then + cargo install typos-cli + fi + typos + + - name: Run style checks + uses: ./.github/actions/check_style tests: name: Run tests runs-on: - self-hosted - test - needs: rustfmt + needs: style steps: - name: Checkout repo uses: actions/checkout@v3 @@ -75,14 +83,6 @@ jobs: APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }} APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} steps: - - name: Install Rust - run: | - rustup set profile minimal - rustup update stable - rustup target add aarch64-apple-darwin - rustup target add x86_64-apple-darwin - rustup target add wasm32-wasi - - name: Install Node uses: actions/setup-node@v3 with: diff --git a/.github/workflows/randomized_tests.yml b/.github/workflows/randomized_tests.yml index d1b8ddfdfb..a1704d58bd 100644 --- a/.github/workflows/randomized_tests.yml +++ b/.github/workflows/randomized_tests.yml @@ -3,41 +3,36 @@ name: Randomized Tests concurrency: randomized-tests on: - push: - branches: - - randomized-tests-runner - # schedule: - # - cron: '0 * * * *' + push: + branches: + - randomized-tests-runner + # schedule: + # - cron: '0 * * * *' env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - ZED_SERVER_URL: https://zed.dev - ZED_CLIENT_SECRET_TOKEN: ${{ secrets.ZED_CLIENT_SECRET_TOKEN }} + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: 1 + ZED_SERVER_URL: https://zed.dev + ZED_CLIENT_SECRET_TOKEN: ${{ secrets.ZED_CLIENT_SECRET_TOKEN }} jobs: - tests: - name: Run randomized tests - runs-on: - - self-hosted - - randomized-tests - steps: - - name: Install Rust - run: | - rustup set profile minimal - rustup update stable + tests: + name: Run randomized tests + runs-on: + - self-hosted + - randomized-tests + steps: + - name: Install Node + uses: actions/setup-node@v3 + with: + node-version: "18" - - name: Install Node - uses: actions/setup-node@v3 - with: - node-version: '18' + - name: Checkout repo + uses: actions/checkout@v3 + with: + clean: false + submodules: "recursive" - - name: Checkout repo - uses: actions/checkout@v3 - with: - clean: false - submodules: 'recursive' - - - name: Run randomized tests - run: script/randomized-test-ci + - name: Run randomized tests + run: script/randomized-test-ci diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index b7e6a0321e..33ccb4cba9 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -14,8 +14,8 @@ env: RUST_BACKTRACE: 1 jobs: - rustfmt: - name: Check formatting + style: + name: Check formatting and Clippy lints runs-on: - self-hosted - test @@ -25,16 +25,17 @@ jobs: with: clean: false submodules: "recursive" + fetch-depth: 0 - - name: Run rustfmt - uses: ./.github/actions/check_formatting + - name: Run style checks + uses: ./.github/actions/check_style tests: name: Run tests runs-on: - self-hosted - test - needs: rustfmt + needs: style steps: - name: Checkout repo uses: actions/checkout@v3 @@ -59,14 +60,6 @@ jobs: DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} steps: - - name: Install Rust - run: | - rustup set profile minimal - rustup update stable - rustup target add aarch64-apple-darwin - rustup target add x86_64-apple-darwin - rustup target add wasm32-wasi - - name: Install Node uses: actions/setup-node@v3 with: diff --git a/.gitignore b/.gitignore index 2d8807a4b0..6923b060f6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /styles/src/types/zed.ts /crates/theme/schemas/theme.json /crates/collab/static/styles.css +/crates/collab/.admins.json /vendor/bin /assets/themes/*.json /assets/*licenses.md diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000000..1064df3b50 --- /dev/null +++ b/.mailmap @@ -0,0 +1,39 @@ +# Canonical author names and emails. +# +# Use this to provide a canonical name and email for an author when their +# name is not always written the same way and/or they have commits authored +# under different email addresses. +# +# Reference: https://git-scm.com/docs/gitmailmap + +# Keep these entries sorted alphabetically. +# In Zed: `editor: sort lines case sensitive` + +Antonio Scandurra +Antonio Scandurra +Joseph T. Lyons +Joseph T. Lyons +Julia +Julia <30666851+ForLoveOfCats@users.noreply.github.com> +Kaylee Simmons +Kaylee Simmons +Kaylee Simmons +Kaylee Simmons +Kirill Bulatov +Kirill Bulatov +Kyle Caverly +Kyle Caverly +Marshall Bowers +Marshall Bowers +Max Brunsfeld +Max Brunsfeld +Mikayla Maki +Mikayla Maki +Mikayla Maki +Nate Butler +Nate Butler +Nathan Sobo +Nathan Sobo +Nathan Sobo +Piotr Osiewicz +Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> diff --git a/.zed/settings.json b/.zed/settings.json index d4b3375b0d..205d610046 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,5 +1,6 @@ { "JSON": { "tab_size": 4 - } + }, + "formatter": "auto" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..a85be83321 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# CONTRIBUTING + +Thanks for your interest in contributing to Zed, the collaborative platform that is also a code editor! + +We want to ensure that no one ends up spending time on a pull request that may not be accepted, so we ask that you discuss your ideas with the team and community before starting on a contribution. + +All activity in Zed communities is subject to our [Code of Conduct](https://docs.zed.dev/community/code-of-conduct). Contributors to Zed must sign our Contributor License Agreement (link coming soon) before their contributions can be merged. + +## Contribution ideas + +If you already have an idea of what you'd like to contribute, you can skip this section, otherwise, here are a few resources to help you find something to work on: + +- Our public roadmap (link coming soon!) details what features we plan to add to Zed. +- Our [Top-Ranking Issues issue](https://github.com/zed-industries/community/issues/52) shows the most popular feature requests and issues, as voted on by the community. + +At the moment, we are generally not looking to extend Zed's language or theme support by directly adding these features to Zed - we really want to build a plugin system to handle making the editor extensible going forward. + +If you are passionate about shipping new languages or themes we suggest contributing to the extension system to help us get there faster. + +## Resources + +### Bird-eye's view of Zed + +Zed is made up of several smaller crates - let's go over those you're most likely to interact with: + +- [gpui](/crates/gpui) is a GPU-accelerated UI framework which provides all of the building blocks for Zed. **We recommend familiarizing yourself with the root level GPUI documentation** +- [editor](/crates/editor) contains the core `Editor` type that drives both the code editor and all various input fields within Zed. It also handles a display layer for LSP features such as Inlay Hints or code completions. +- [project](/crates/project) manages files and navigation within the filetree. It is also Zed's side of communication with LSP. +- [workspace](/crates/workspace) handles local state serialization and groups projects together. +- [vim](/crates/vim) is a thin implementation of Vim workflow over `editor`. +- [lsp](/crates/lsp) handles communication with external LSP server. +- [language](/crates/language) drives `editor`'s understanding of language - from providing a list of symbols to the syntax map. +- [collab](/crates/collab) is the collaboration server itself, driving the collaboration features such as project sharing. +- [rpc](/crates/rpc) defines messages to be exchanged with collaboration server. +- [theme](/crates/theme) defines the theme system and provides a default theme. +- [ui](/crates/ui) is a collection of UI components and common patterns used throughout Zed. + +### Proposal & Discussion + +Before starting on a contribution, we ask that you look to see if there is any existing PRs, or in-Zed discussions about the thing you want to implement. If there is no existing work, find a public channel that is relevant to your contribution, check the channel notes to see which Zed team members typically work in that channel, and post a message in the chat. If you're not sure which channel is best, you can start a discussion, ask a team member or another contributor. + +*Please remember contributions not discussed with the team ahead of time likely have a lower chance of being merged or looked at in a timely manner.* + +## Implementation & Help + +When you start working on your contribution if you find you are struggling with something specific feel free to reach out to the team for help. + +Remember the team is more likely to be available to help if you have already discussed your contribution or are working on something that is higher priority, like something on the roadmap or a top-ranking issue. + +We're happy to pair with you to help you learn the codebase and get your contribution merged. + +**Zed makes heavy use of unit and integration testing, it is highly likely that contributions without any unit tests will be rejected** + +Reviewing code in a pull request, after the fact, is hard and tedious - the team generally likes to build trust and review code through pair programming. +We'd prefer have conversations about the code, through Zed, while it is being written, so decisions can be made in real-time and less time is spent on fixing things after the fact. Ideally, GitHub is only used to merge code that has already been discussed and reviewed in Zed. + +Remember that smaller, incremental PRs are easier to review and merge than large PRs. diff --git a/Cargo.lock b/Cargo.lock index 00c13d10ff..62dbd027dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1452,7 +1452,7 @@ dependencies = [ [[package]] name = "collab" -version = "0.35.0" +version = "0.37.0" dependencies = [ "anyhow", "async-trait", @@ -1473,6 +1473,7 @@ dependencies = [ "editor", "env_logger", "envy", + "file_finder", "fs", "futures 0.3.28", "git", @@ -1486,6 +1487,7 @@ dependencies = [ "live_kit_server", "log", "lsp", + "menu", "nanoid", "node_runtime", "notifications", @@ -1559,6 +1561,7 @@ dependencies = [ "serde_json", "settings", "smallvec", + "story", "theme", "theme_selector", "time", @@ -1577,6 +1580,28 @@ dependencies = [ "rustc-hash", ] +[[package]] +name = "color" +version = "0.1.0" +dependencies = [ + "anyhow", + "fs", + "indexmap 1.9.3", + "itertools 0.11.0", + "palette", + "parking_lot 0.11.2", + "refineable", + "schemars", + "serde", + "serde_derive", + "serde_json", + "settings", + "story", + "toml 0.5.11", + "util", + "uuid 1.4.1", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1604,6 +1629,7 @@ name = "command_palette" version = "0.1.0" dependencies = [ "anyhow", + "client", "collections", "ctor", "editor", @@ -1946,6 +1972,16 @@ dependencies = [ "syn 2.0.37", ] +[[package]] +name = "ctrlc" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b467862cc8610ca6fc9a1532d7777cee0804e678ab45410897b9396495994a0b" +dependencies = [ + "nix 0.27.1", + "windows-sys 0.52.0", +] + [[package]] name = "curl" version = "0.4.44" @@ -2519,6 +2555,7 @@ dependencies = [ name = "file_finder" version = "0.1.0" dependencies = [ + "anyhow", "collections", "ctor", "editor", @@ -2606,7 +2643,7 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "font-kit" version = "0.11.0" -source = "git+https://github.com/zed-industries/font-kit?rev=b2f77d56f450338aa4f7dd2f0197d8c9acb0cf18#b2f77d56f450338aa4f7dd2f0197d8c9acb0cf18" +source = "git+https://github.com/zed-industries/font-kit?rev=d97147f#d97147ff11a9024b9707d9c9c7e3a0bdaba048ac" dependencies = [ "bitflags 1.3.2", "byteorder", @@ -4485,6 +4522,17 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.4.1", + "cfg-if 1.0.0", + "libc", +] + [[package]] name = "node_runtime" version = "0.1.0" @@ -4951,6 +4999,7 @@ dependencies = [ "approx", "fast-srgb8", "palette_derive", + "phf", ] [[package]] @@ -5139,6 +5188,48 @@ dependencies = [ "indexmap 2.0.0", ] +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.37", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher 0.3.11", +] + [[package]] name = "picker" version = "0.1.0" @@ -7048,6 +7139,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b8de496cf83d4ed58b6be86c3a275b8602f6ffe98d3024a869e124147a9a3ac" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "slab" version = "0.4.9" @@ -7447,6 +7544,8 @@ dependencies = [ "backtrace-on-stack-overflow", "chrono", "clap 4.4.4", + "collab_ui", + "ctrlc", "dialoguer", "editor", "fuzzy", @@ -7616,7 +7715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c536faaff1a10837cfe373142583f6e27d81e96beba339147e77b67c9f260ff" dependencies = [ "float-cmp", - "siphasher", + "siphasher 0.2.3", ] [[package]] @@ -7747,6 +7846,7 @@ dependencies = [ "schemars", "serde", "serde_derive", + "serde_json", "settings", "shellexpand", "smallvec", @@ -7826,6 +7926,7 @@ name = "theme" version = "0.1.0" dependencies = [ "anyhow", + "color", "fs", "gpui", "indexmap 1.9.3", @@ -8829,7 +8930,7 @@ dependencies = [ "roxmltree", "rustybuzz", "simplecss", - "siphasher", + "siphasher 0.2.3", "svgtypes", "ttf-parser 0.12.3", "unicode-bidi", @@ -9283,6 +9384,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.0", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -9313,6 +9423,21 @@ dependencies = [ "windows_x86_64_msvc 0.48.5", ] +[[package]] +name = "windows-targets" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +dependencies = [ + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -9325,6 +9450,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -9337,6 +9468,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -9349,6 +9486,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -9361,6 +9504,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -9373,6 +9522,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -9385,6 +9540,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -9397,6 +9558,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + [[package]] name = "winnow" version = "0.5.15" @@ -9555,6 +9722,7 @@ dependencies = [ "client", "collab_ui", "collections", + "color", "command_palette", "copilot", "copilot_ui", diff --git a/Procfile b/Procfile index 3f42c3a967..7bd9114dad 100644 --- a/Procfile +++ b/Procfile @@ -1,4 +1,2 @@ -web: cd ../zed.dev && PORT=3000 npm run dev collab: cd crates/collab && RUST_LOG=${RUST_LOG:-warn,collab=info} cargo run serve livekit: livekit-server --dev -postgrest: postgrest crates/collab/admin_api.conf diff --git a/README.md b/README.md index eed8dd4d91..7376156231 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,27 @@ +# 🚧 TODO 🚧 + +- [ ] Add intro +- [ ] Add link to contributing guide +- [ ] Add barebones running zed from source instructions +- [ ] Link out to further dev docs + # Zed [![CI](https://github.com/zed-industries/zed/actions/workflows/ci.yml/badge.svg)](https://github.com/zed-industries/zed/actions/workflows/ci.yml) -Welcome to Zed, a lightning-fast, collaborative code editor that makes your dreams come true. +Welcome to Zed, a high-performance, multiplayer code editor from the creators of [Atom](https://github.com/atom/atom) and [Tree-sitter](https://github.com/tree-sitter/tree-sitter). -## Development tips +## Developing Zed -### Dependencies - -* Install Xcode from https://apps.apple.com/us/app/xcode/id497799835?mt=12, and accept the license: - ``` - sudo xcodebuild -license - ``` - -* Install homebrew, node and rustup-init (rustup, rust, cargo, etc.) - ``` - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" - brew install node rustup-init - rustup-init # follow the installation steps - ``` - -* Install postgres and configure the database - ``` - brew install postgresql@15 - brew services start postgresql@15 - psql -c "CREATE ROLE postgres SUPERUSER LOGIN" postgres - psql -U postgres -c "CREATE DATABASE zed" - ``` - -* Install the `LiveKit` server, the `PostgREST` API server, and the `foreman` process supervisor: - - ``` - brew install livekit - brew install postgrest - brew install foreman - ``` - -* Ensure the Zed.dev website is checked out in a sibling directory and install its dependencies: - - ``` - cd .. - git clone https://github.com/zed-industries/zed.dev - cd zed.dev && npm install - npm install -g vercel - ``` - -* Return to Zed project directory and Initialize submodules - - ``` - cd zed - git submodule update --init --recursive - ``` - -* Set up a local `zed` database and seed it with some initial users: - - [Create a personal GitHub token](https://github.com/settings/tokens/new) to run `script/bootstrap` once successfully: the token needs to have an access to private repositories for the script to work (`repo` OAuth scope). - Then delete that token. - - ``` - GITHUB_TOKEN=<$token> script/bootstrap - ``` - -* Now try running zed with collaboration disabled: - ``` - cargo run - ``` - -### Common errors - -* `xcrun: error: unable to find utility "metal", not a developer tool or in PATH` - * You need to install Xcode and then run: `xcode-select --switch /Applications/Xcode.app/Contents/Developer` - * (see https://github.com/gfx-rs/gfx/issues/2309) - -### Testing against locally-running servers - -Start the web and collab servers: - -``` -foreman start -``` - -If you want to run Zed pointed at the local servers, you can run: - -``` -script/zed-local -``` - -### Dump element JSON - -If you trigger `cmd-alt-i`, Zed will copy a JSON representation of the current window contents to the clipboard. You can paste this in a tool like [DJSON](https://chrome.google.com/webstore/detail/djson-json-viewer-formatt/chaeijjekipecdajnijdldjjipaegdjc?hl=en) to navigate the state of on-screen elements in a structured way. +- [Building Zed](./docs/src/developing_zed__building_zed.md) +- [Running Collaboration Locally](./docs/src/developing_zed__local_collaboration.md) ### Licensing +License information for third party dependencies must be correctly provided for CI to pass. + We use [`cargo-about`](https://github.com/EmbarkStudios/cargo-about) to automatically comply with open source licenses. If CI is failing, check the following: - Is it showing a `no license specified` error for a crate you've created? If so, add `publish = false` under `[package]` in your crate's Cargo.toml. - Is the error `failed to satisfy license requirements` for a dependency? If so, first determine what license the project has and whether this system is sufficient to comply with this license's requirements. If you're unsure, ask a lawyer. Once you've verified that this system is acceptable add the license's SPDX identifier to the `accepted` array in `script/licenses/zed-licenses.toml`. - Is `cargo-about` unable to find the license for a dependency? If so, add a clarification field at the end of `script/licenses/zed-licenses.toml`, as specified in the [cargo-about book](https://embarkstudios.github.io/cargo-about/cli/generate/config.html#crate-configuration). - - -### Wasm Plugins - -Zed has a Wasm-based plugin runtime which it currently uses to embed plugins. To compile Zed, you'll need to have the `wasm32-wasi` toolchain installed on your system. To install this toolchain, run: - -```bash -rustup target add wasm32-wasi -``` - -Plugins can be found in the `plugins` folder in the root. For more information about how plugins work, check the [Plugin Guide](./crates/plugin_runtime/README.md) in `crates/plugin_runtime/README.md`. diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 3ff0db1a16..f18cc2a111 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -402,7 +402,7 @@ "cmd-r": "workspace::ToggleRightDock", "cmd-j": "workspace::ToggleBottomDock", "alt-cmd-y": "workspace::CloseAllDocks", - "cmd-shift-f": "workspace::DeploySearch", + "cmd-shift-f": "pane::DeploySearch", "cmd-k cmd-t": "theme_selector::Toggle", "cmd-k cmd-s": "zed::OpenKeymap", "cmd-t": "project_symbols::Toggle", @@ -412,7 +412,8 @@ "cmd-shift-e": "project_panel::ToggleFocus", "cmd-?": "assistant::ToggleFocus", "cmd-alt-s": "workspace::SaveAll", - "cmd-k m": "language_selector::Toggle" + "cmd-k m": "language_selector::Toggle", + "escape": "workspace::Unfollow" } }, // Bindings from Sublime Text diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 81235bb72a..1da6f0ef8c 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -99,7 +99,7 @@ "ctrl-i": "pane::GoForward", "ctrl-]": "editor::GoToDefinition", "escape": ["vim::SwitchMode", "Normal"], - "ctrl+[": ["vim::SwitchMode", "Normal"], + "ctrl-[": ["vim::SwitchMode", "Normal"], "v": "vim::ToggleVisual", "shift-v": "vim::ToggleVisualLine", "ctrl-v": "vim::ToggleVisualBlock", @@ -288,7 +288,7 @@ "context": "Editor && vim_mode == normal && vim_operator == none && !VimWaiting", "bindings": { "escape": "editor::Cancel", - "ctrl+[": "editor::Cancel" + "ctrl-[": "editor::Cancel" } }, { @@ -441,7 +441,7 @@ "r": ["vim::PushOperator", "Replace"], "ctrl-c": ["vim::SwitchMode", "Normal"], "escape": ["vim::SwitchMode", "Normal"], - "ctrl+[": ["vim::SwitchMode", "Normal"], + "ctrl-[": ["vim::SwitchMode", "Normal"], ">": "editor::Indent", "<": "editor::Outdent", "i": [ @@ -481,7 +481,7 @@ "tab": "vim::Tab", "enter": "vim::Enter", "escape": ["vim::SwitchMode", "Normal"], - "ctrl+[": ["vim::SwitchMode", "Normal"] + "ctrl-[": ["vim::SwitchMode", "Normal"] } }, { diff --git a/assets/screenshots/staff_usage_of_channels.png b/assets/screenshots/staff_usage_of_channels.png new file mode 100644 index 0000000000..b9e607e59c Binary files /dev/null and b/assets/screenshots/staff_usage_of_channels.png differ diff --git a/assets/settings/default.json b/assets/settings/default.json index bd157c3e61..87cf0517a2 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -36,7 +36,7 @@ // }, "buffer_line_height": "comfortable", // The name of a font to use for rendering text in the UI - "ui_font_family": "Zed Mono", + "ui_font_family": "Zed Sans", // The OpenType features to enable for text in the UI "ui_font_features": { // Disable ligatures: diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index 4b990fa430..ac63592d6b 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -77,9 +77,6 @@ impl ActivityIndicator { cx.observe(auto_updater, |_, _, cx| cx.notify()).detach(); } - // cx.observe_active_labeled_tasks(|_, cx| cx.notify()) - // .detach(); - Self { statuses: Default::default(), project: project.clone(), @@ -288,15 +285,6 @@ impl ActivityIndicator { }; } - // todo!(show active tasks) - // if let Some(most_recent_active_task) = cx.active_labeled_tasks().last() { - // return Content { - // icon: None, - // message: most_recent_active_task.to_string(), - // on_click: None, - // }; - // } - Default::default() } } @@ -307,7 +295,7 @@ impl Render for ActivityIndicator { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let content = self.content_to_render(cx); - let mut result = h_stack() + let mut result = h_flex() .id("activity-indicator") .on_action(cx.listener(Self::show_error_message)) .on_action(cx.listener(Self::dismiss_error_message)); diff --git a/crates/ai/src/prompts/base.rs b/crates/ai/src/prompts/base.rs index 75bad00154..5e624f23ac 100644 --- a/crates/ai/src/prompts/base.rs +++ b/crates/ai/src/prompts/base.rs @@ -81,8 +81,8 @@ impl PromptChain { pub fn generate(&self, truncate: bool) -> anyhow::Result<(String, usize)> { // Argsort based on Prompt Priority - let seperator = "\n"; - let seperator_tokens = self.args.model.count_tokens(seperator)?; + let separator = "\n"; + let separator_tokens = self.args.model.count_tokens(separator)?; let mut sorted_indices = (0..self.templates.len()).collect::>(); sorted_indices.sort_by_key(|&i| Reverse(&self.templates[i].0)); @@ -104,7 +104,7 @@ impl PromptChain { prompts[idx] = template_prompt; if let Some(remaining_tokens) = tokens_outstanding { - let new_tokens = prompt_token_count + seperator_tokens; + let new_tokens = prompt_token_count + separator_tokens; tokens_outstanding = if remaining_tokens > new_tokens { Some(remaining_tokens - new_tokens) } else { @@ -117,9 +117,9 @@ impl PromptChain { prompts.retain(|x| x != ""); - let full_prompt = prompts.join(seperator); + let full_prompt = prompts.join(separator); let total_token_count = self.args.model.count_tokens(&full_prompt)?; - anyhow::Ok((prompts.join(seperator), total_token_count)) + anyhow::Ok((prompts.join(separator), total_token_count)) } } diff --git a/crates/ai/src/prompts/repository_context.rs b/crates/ai/src/prompts/repository_context.rs index 0d831c2cb2..89869c53a0 100644 --- a/crates/ai/src/prompts/repository_context.rs +++ b/crates/ai/src/prompts/repository_context.rs @@ -68,7 +68,7 @@ impl PromptTemplate for RepositoryContext { let mut prompt = String::new(); let mut remaining_tokens = max_token_length.clone(); - let seperator_token_length = args.model.count_tokens("\n")?; + let separator_token_length = args.model.count_tokens("\n")?; for snippet in &args.snippets { let mut snippet_prompt = template.to_string(); let content = snippet.to_string(); @@ -79,9 +79,9 @@ impl PromptTemplate for RepositoryContext { if let Some(tokens_left) = remaining_tokens { if tokens_left >= token_count { writeln!(prompt, "{snippet_prompt}").unwrap(); - remaining_tokens = if tokens_left >= (token_count + seperator_token_length) + remaining_tokens = if tokens_left >= (token_count + separator_token_length) { - Some(tokens_left - token_count - seperator_token_length) + Some(tokens_left - token_count - separator_token_length) } else { Some(0) }; diff --git a/crates/ai/src/providers/open_ai/completion.rs b/crates/ai/src/providers/open_ai/completion.rs index c9a2abd0c8..f99b7f95e3 100644 --- a/crates/ai/src/providers/open_ai/completion.rs +++ b/crates/ai/src/providers/open_ai/completion.rs @@ -273,7 +273,7 @@ impl CompletionProvider for OpenAICompletionProvider { ) -> BoxFuture<'static, Result>>> { // Currently the CompletionRequest for OpenAI, includes a 'model' parameter // This means that the model is determined by the CompletionRequest and not the CompletionProvider, - // which is currently model based, due to the langauge model. + // which is currently model based, due to the language model. // At some point in the future we should rectify this. let credential = self.credential.read().clone(); let request = stream_completion(credential, self.executor.clone(), prompt); diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index f53343531a..f2988b907c 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -19,12 +19,13 @@ use chrono::{DateTime, Local}; use client::telemetry::AssistantKind; use collections::{hash_map, HashMap, HashSet, VecDeque}; use editor::{ + actions::{MoveDown, MoveUp}, display_map::{ BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint, }, - scroll::autoscroll::{Autoscroll, AutoscrollStrategy}, - Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MoveDown, MoveUp, MultiBufferSnapshot, - ToOffset, ToPoint, + scroll::{Autoscroll, AutoscrollStrategy}, + Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MultiBufferSnapshot, ToOffset, + ToPoint, }; use fs::Fs; use futures::StreamExt; @@ -40,7 +41,7 @@ use language::{language_settings::SoftWrap, Buffer, LanguageRegistry, ToOffset a use project::Project; use search::{buffer_search::DivRegistrar, BufferSearchBar}; use semantic_index::{SemanticIndex, SemanticIndexStatus}; -use settings::{Settings, SettingsStore}; +use settings::Settings; use std::{ cell::Cell, cmp, @@ -165,7 +166,7 @@ impl AssistantPanel { cx.on_focus_in(&focus_handle, Self::focus_in).detach(); cx.on_focus_out(&focus_handle, Self::focus_out).detach(); - let mut this = Self { + Self { workspace: workspace_handle, active_editor_index: Default::default(), prev_active_editor_index: Default::default(), @@ -190,20 +191,7 @@ impl AssistantPanel { _watch_saved_conversations, semantic_index, retrieve_context_in_next_inline_assist: false, - }; - - let mut old_dock_position = this.position(cx); - this.subscriptions = - vec![cx.observe_global::(move |this, cx| { - let new_dock_position = this.position(cx); - if new_dock_position != old_dock_position { - old_dock_position = new_dock_position; - cx.emit(PanelEvent::ChangePosition); - } - cx.notify(); - })]; - - this + } }) }) }) @@ -492,7 +480,7 @@ impl AssistantPanel { fn cancel_last_inline_assist( workspace: &mut Workspace, - _: &editor::Cancel, + _: &editor::actions::Cancel, cx: &mut ViewContext, ) { if let Some(panel) = workspace.panel::(cx) { @@ -904,7 +892,7 @@ impl AssistantPanel { } } - fn handle_editor_cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { + fn handle_editor_cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext) { if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { if !search_bar.read(cx).is_dismissed() { search_bar.update(cx, |search_bar, cx| { @@ -932,8 +920,41 @@ impl AssistantPanel { self.editors.get(self.active_editor_index?) } + fn render_api_key_editor( + &self, + editor: &View, + cx: &mut ViewContext, + ) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: if editor.read(cx).read_only(cx) { + cx.theme().colors().text_disabled + } else { + cx.theme().colors().text + }, + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features, + font_size: rems(0.875).into(), + font_weight: FontWeight::NORMAL, + font_style: FontStyle::Normal, + line_height: relative(1.3).into(), + background_color: None, + underline: None, + white_space: WhiteSpace::Normal, + }; + EditorElement::new( + &editor, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + } + fn render_hamburger_button(cx: &mut ViewContext) -> impl IntoElement { - IconButton::new("hamburger_button", Icon::Menu) + IconButton::new("hamburger_button", IconName::Menu) .on_click(cx.listener(|this, _event, cx| { if this.active_editor().is_some() { this.set_active_editor_index(None, cx); @@ -957,7 +978,7 @@ impl AssistantPanel { } fn render_split_button(cx: &mut ViewContext) -> impl IntoElement { - IconButton::new("split_button", Icon::Snip) + IconButton::new("split_button", IconName::Snip) .on_click(cx.listener(|this, _event, cx| { if let Some(active_editor) = this.active_editor() { active_editor.update(cx, |editor, cx| editor.split(&Default::default(), cx)); @@ -968,7 +989,7 @@ impl AssistantPanel { } fn render_assist_button(cx: &mut ViewContext) -> impl IntoElement { - IconButton::new("assist_button", Icon::MagicWand) + IconButton::new("assist_button", IconName::MagicWand) .on_click(cx.listener(|this, _event, cx| { if let Some(active_editor) = this.active_editor() { active_editor.update(cx, |editor, cx| editor.assist(&Default::default(), cx)); @@ -979,7 +1000,7 @@ impl AssistantPanel { } fn render_quote_button(cx: &mut ViewContext) -> impl IntoElement { - IconButton::new("quote_button", Icon::Quote) + IconButton::new("quote_button", IconName::Quote) .on_click(cx.listener(|this, _event, cx| { if let Some(workspace) = this.workspace.upgrade() { cx.window_context().defer(move |cx| { @@ -994,7 +1015,7 @@ impl AssistantPanel { } fn render_plus_button(cx: &mut ViewContext) -> impl IntoElement { - IconButton::new("plus_button", Icon::Plus) + IconButton::new("plus_button", IconName::Plus) .on_click(cx.listener(|this, _event, cx| { this.new_conversation(cx); })) @@ -1004,12 +1025,12 @@ impl AssistantPanel { fn render_zoom_button(&self, cx: &mut ViewContext) -> impl IntoElement { let zoomed = self.zoomed; - IconButton::new("zoom_button", Icon::Maximize) + IconButton::new("zoom_button", IconName::Maximize) .on_click(cx.listener(|this, _event, cx| { this.toggle_zoom(&ToggleZoom, cx); })) .selected(zoomed) - .selected_icon(Icon::Minimize) + .selected_icon(IconName::Minimize) .icon_size(IconSize::Small) .tooltip(move |cx| { Tooltip::for_action(if zoomed { "Zoom Out" } else { "Zoom In" }, &ToggleZoom, cx) @@ -1103,51 +1124,65 @@ fn build_api_key_editor(cx: &mut ViewContext) -> View { impl Render for AssistantPanel { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { if let Some(api_key_editor) = self.api_key_editor.clone() { - v_stack() + const INSTRUCTIONS: [&'static str; 5] = [ + "To use the assistant panel or inline assistant, you need to add your OpenAI API key.", + " - You can create an API key at: platform.openai.com/api-keys", + " - Having a subscription for another service like GitHub Copilot won't work.", + " ", + "Paste your OpenAI API key and press Enter to use the assistant:" + ]; + + v_flex() + .p_4() + .size_full() .on_action(cx.listener(AssistantPanel::save_credentials)) .track_focus(&self.focus_handle) - .child(Label::new( - "To use the assistant panel or inline assistant, you need to add your OpenAI api key.", - )) - .child(Label::new( - " - Having a subscription for another service like GitHub Copilot won't work." - )) - .child(Label::new( - " - You can create a api key at: platform.openai.com/api-keys" - )) - .child(Label::new( - " " - )) - .child(Label::new( - "Paste your OpenAI API key and press Enter to use the assistant" - )) - .child(api_key_editor) - .child(Label::new( - "Click on the Z button in the status bar to close this panel." - )) + .children( + INSTRUCTIONS.map(|instruction| Label::new(instruction).size(LabelSize::Small)), + ) + .child( + h_flex() + .w_full() + .my_2() + .px_2() + .py_1() + .bg(cx.theme().colors().editor_background) + .rounded_md() + .child(self.render_api_key_editor(&api_key_editor, cx)), + ) + .child( + h_flex() + .gap_2() + .child(Label::new("Click on").size(LabelSize::Small)) + .child(Icon::new(IconName::Ai).size(IconSize::XSmall)) + .child( + Label::new("in the status bar to close this panel.") + .size(LabelSize::Small), + ), + ) } else { let header = TabBar::new("assistant_header") .start_child( - h_stack().gap_1().child(Self::render_hamburger_button(cx)), // .children(title), + h_flex().gap_1().child(Self::render_hamburger_button(cx)), // .children(title), ) .children(self.active_editor().map(|editor| { - h_stack() - .h(rems(Tab::HEIGHT_IN_REMS)) + h_flex() + .h(rems(Tab::CONTAINER_HEIGHT_IN_REMS)) .flex_1() .px_2() .child(Label::new(editor.read(cx).title(cx)).into_element()) })) .end_child(if self.focus_handle.contains_focused(cx) { - h_stack() + h_flex() .gap_2() - .child(h_stack().gap_1().children(self.render_editor_tools(cx))) + .child(h_flex().gap_1().children(self.render_editor_tools(cx))) .child( ui::Divider::vertical() .inset() .color(ui::DividerColor::Border), ) .child( - h_stack() + h_flex() .gap_1() .child(Self::render_plus_button(cx)) .child(self.render_zoom_button(cx)), @@ -1161,12 +1196,12 @@ impl Render for AssistantPanel { |panel, cx| panel.toolbar.read(cx).item_of_type::(), cx, ); - BufferSearchBar::register_inner(&mut registrar); + BufferSearchBar::register(&mut registrar); registrar.into_div() } else { div() }; - v_stack() + v_flex() .key_context("AssistantPanel") .size_full() .on_action(cx.listener(|this, _: &workspace::NewFile, cx| { @@ -1286,8 +1321,8 @@ impl Panel for AssistantPanel { } } - fn icon(&self, cx: &WindowContext) -> Option { - Some(Icon::Ai).filter(|_| AssistantSettings::get_global(cx).button) + fn icon(&self, cx: &WindowContext) -> Option { + Some(IconName::Ai).filter(|_| AssistantSettings::get_global(cx).button) } fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> { @@ -2171,7 +2206,7 @@ impl ConversationEditor { } } - fn cancel_last_assist(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { + fn cancel_last_assist(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext) { if !self .conversation .update(cx, |conversation, _| conversation.cancel_last_assist()) @@ -2324,8 +2359,7 @@ impl ConversationEditor { } }); - div() - .h_flex() + h_flex() .id(("message_header", message_id.0)) .h_11() .relative() @@ -2341,6 +2375,7 @@ impl ConversationEditor { .add_suffix(true) .to_string(), ) + .size(LabelSize::XSmall) .color(Color::Muted), ) .children( @@ -2349,7 +2384,7 @@ impl ConversationEditor { div() .id("error") .tooltip(move |cx| Tooltip::text(error.clone(), cx)) - .child(IconElement::new(Icon::XCircle)), + .child(Icon::new(IconName::XCircle)), ) } else { None @@ -2430,7 +2465,7 @@ impl ConversationEditor { } } - fn copy(&mut self, _: &editor::Copy, cx: &mut ViewContext) { + fn copy(&mut self, _: &editor::actions::Copy, cx: &mut ViewContext) { let editor = self.editor.read(cx); let conversation = self.conversation.read(cx); if editor.selections.count() == 1 { @@ -2543,7 +2578,7 @@ impl Render for ConversationEditor { .child(self.editor.clone()), ) .child( - h_stack() + h_flex() .absolute() .gap_1() .top_3() @@ -2629,7 +2664,7 @@ impl EventEmitter for InlineAssistant {} impl Render for InlineAssistant { fn render(&mut self, cx: &mut ViewContext) -> impl Element { let measurements = self.measurements.get(); - h_stack() + h_flex() .w_full() .py_2() .border_y_1() @@ -2641,11 +2676,11 @@ impl Render for InlineAssistant { .on_action(cx.listener(Self::move_up)) .on_action(cx.listener(Self::move_down)) .child( - h_stack() + h_flex() .justify_center() .w(measurements.gutter_width) .child( - IconButton::new("include_conversation", Icon::Ai) + IconButton::new("include_conversation", IconName::Ai) .on_click(cx.listener(|this, _, cx| { this.toggle_include_conversation(&ToggleIncludeConversation, cx) })) @@ -2660,7 +2695,7 @@ impl Render for InlineAssistant { ) .children(if SemanticIndex::enabled(cx) { Some( - IconButton::new("retrieve_context", Icon::MagnifyingGlass) + IconButton::new("retrieve_context", IconName::MagnifyingGlass) .on_click(cx.listener(|this, _, cx| { this.toggle_retrieve_context(&ToggleRetrieveContext, cx) })) @@ -2682,14 +2717,14 @@ impl Render for InlineAssistant { div() .id("error") .tooltip(move |cx| Tooltip::text(error_message.clone(), cx)) - .child(IconElement::new(Icon::XCircle).color(Color::Error)), + .child(Icon::new(IconName::XCircle).color(Color::Error)), ) } else { None }), ) .child( - h_stack() + h_flex() .w_full() .ml(measurements.anchor_x - measurements.gutter_width) .child(self.render_prompt_editor(cx)), @@ -2841,7 +2876,7 @@ impl InlineAssistant { cx.notify(); } - fn cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { + fn cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext) { cx.emit(InlineAssistantEvent::Canceled); } @@ -2930,7 +2965,7 @@ impl InlineAssistant { let semantic_permissioned = self.semantic_permissioned(cx); if let Some(semantic_index) = SemanticIndex::global(cx) { cx.spawn(|_, mut cx| async move { - // This has to be updated to accomodate for semantic_permissions + // This has to be updated to accommodate for semantic_permissions if semantic_permissioned.await.unwrap_or(false) { semantic_index .update(&mut cx, |index, cx| index.index_project(project, cx))? @@ -2957,7 +2992,7 @@ impl InlineAssistant { div() .id("error") .tooltip(|cx| Tooltip::text("Not Authenticated. Please ensure you have a valid 'OPENAI_API_KEY' in your environment variables.", cx)) - .child(IconElement::new(Icon::XCircle)) + .child(Icon::new(IconName::XCircle)) .into_any_element() ), @@ -2965,7 +3000,7 @@ impl InlineAssistant { div() .id("error") .tooltip(|cx| Tooltip::text("Not Indexed", cx)) - .child(IconElement::new(Icon::XCircle)) + .child(Icon::new(IconName::XCircle)) .into_any_element() ), @@ -2996,7 +3031,7 @@ impl InlineAssistant { div() .id("update") .tooltip(move |cx| Tooltip::text(status_text.clone(), cx)) - .child(IconElement::new(Icon::Update).color(Color::Info)) + .child(Icon::new(IconName::Update).color(Color::Info)) .into_any_element() ) } @@ -3005,7 +3040,7 @@ impl InlineAssistant { div() .id("check") .tooltip(|cx| Tooltip::text("Index up to date", cx)) - .child(IconElement::new(Icon::Check).color(Color::Success)) + .child(Icon::new(IconName::Check).color(Color::Success)) .into_any_element() ), } @@ -3133,6 +3168,7 @@ mod tests { use crate::MessageId; use ai::test::FakeCompletionProvider; use gpui::AppContext; + use settings::SettingsStore; #[gpui::test] fn test_inserting_and_removing_messages(cx: &mut AppContext) { diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 06e445e3de..3b8d1c6e61 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -93,7 +93,9 @@ pub fn init(http_client: Arc, server_url: String, cx: &mut AppCo cx.observe_new_views(|workspace: &mut Workspace, _cx| { workspace.register_action(|_, action: &Check, cx| check(action, cx)); - workspace.register_action(|_, action, cx| view_release_notes(action, cx)); + workspace.register_action(|_, action, cx| { + view_release_notes(action, cx); + }); // @nate - code to trigger update notification on launch // todo!("remove this when Nate is done") @@ -140,24 +142,23 @@ pub fn check(_: &Check, cx: &mut WindowContext) { } } -pub fn view_release_notes(_: &ViewReleaseNotes, cx: &mut AppContext) { - if let Some(auto_updater) = AutoUpdater::get(cx) { +pub fn view_release_notes(_: &ViewReleaseNotes, cx: &mut AppContext) -> Option<()> { + let auto_updater = AutoUpdater::get(cx)?; + let release_channel = cx.try_global::()?; + + if matches!( + release_channel, + ReleaseChannel::Stable | ReleaseChannel::Preview + ) { let auto_updater = auto_updater.read(cx); let server_url = &auto_updater.server_url; + let release_channel = release_channel.dev_name(); let current_version = auto_updater.current_version; - if cx.has_global::() { - match cx.global::() { - ReleaseChannel::Dev => {} - ReleaseChannel::Nightly => {} - ReleaseChannel::Preview => { - cx.open_url(&format!("{server_url}/releases/preview/{current_version}")) - } - ReleaseChannel::Stable => { - cx.open_url(&format!("{server_url}/releases/stable/{current_version}")) - } - } - } + let url = format!("{server_url}/releases/{release_channel}/{current_version}"); + cx.open_url(&url); } + + None } pub fn notify_of_any_new_update(cx: &mut ViewContext) -> Option<()> { @@ -257,11 +258,13 @@ impl AutoUpdater { "{server_url}/api/releases/latest?token={ZED_SECRET_CLIENT_TOKEN}&asset=Zed.dmg" ); cx.update(|cx| { - if cx.has_global::() { - if let Some(param) = cx.global::().release_query_param() { - url_string += "&"; - url_string += param; - } + if let Some(param) = cx + .try_global::() + .map(|release_channel| release_channel.release_query_param()) + .flatten() + { + url_string += "&"; + url_string += param; } })?; @@ -313,8 +316,8 @@ impl AutoUpdater { let (installation_id, release_channel, telemetry) = cx.update(|cx| { let installation_id = cx.global::>().telemetry().installation_id(); let release_channel = cx - .has_global::() - .then(|| cx.global::().display_name()); + .try_global::() + .map(|release_channel| release_channel.display_name()); let telemetry = TelemetrySettings::get_global(cx).metrics; (installation_id, release_channel, telemetry) diff --git a/crates/auto_update/src/update_notification.rs b/crates/auto_update/src/update_notification.rs index f00172591e..1562d90057 100644 --- a/crates/auto_update/src/update_notification.rs +++ b/crates/auto_update/src/update_notification.rs @@ -4,7 +4,7 @@ use gpui::{ }; use menu::Cancel; use util::channel::ReleaseChannel; -use workspace::ui::{h_stack, v_stack, Icon, IconElement, Label, StyledExt}; +use workspace::ui::{h_flex, v_flex, Icon, IconName, Label, StyledExt}; pub struct UpdateNotification { version: SemanticVersion, @@ -16,12 +16,12 @@ impl Render for UpdateNotification { fn render(&mut self, cx: &mut gpui::ViewContext) -> impl IntoElement { let app_name = cx.global::().display_name(); - v_stack() + v_flex() .on_action(cx.listener(UpdateNotification::dismiss)) .elevation_3(cx) .p_4() .child( - h_stack() + h_flex() .justify_between() .child(Label::new(format!( "Updated to {app_name} {}", @@ -30,7 +30,7 @@ impl Render for UpdateNotification { .child( div() .id("cancel") - .child(IconElement::new(Icon::Close)) + .child(Icon::new(IconName::Close)) .cursor_pointer() .on_click(cx.listener(|this, _, cx| this.dismiss(&menu::Cancel, cx))), ), @@ -40,7 +40,9 @@ impl Render for UpdateNotification { .id("notes") .child(Label::new("View the release notes")) .cursor_pointer() - .on_click(|_, cx| crate::view_release_notes(&Default::default(), cx)), + .on_click(|_, cx| { + crate::view_release_notes(&Default::default(), cx); + }), ) } } diff --git a/crates/breadcrumbs/src/breadcrumbs.rs b/crates/breadcrumbs/src/breadcrumbs.rs index e41c0c06b1..ca07a205ac 100644 --- a/crates/breadcrumbs/src/breadcrumbs.rs +++ b/crates/breadcrumbs/src/breadcrumbs.rs @@ -31,7 +31,7 @@ impl EventEmitter for Breadcrumbs {} impl Render for Breadcrumbs { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let element = h_stack().text_ui(); + let element = h_flex().text_ui(); let Some(active_item) = self.active_item.as_ref() else { return element; }; @@ -51,7 +51,7 @@ impl Render for Breadcrumbs { Label::new("›").color(Color::Muted).into_any_element() }); - let breadcrumbs_stack = h_stack().gap_1().children(breadcrumbs); + let breadcrumbs_stack = h_flex().gap_1().children(breadcrumbs); match active_item .downcast::() .map(|editor| editor.downgrade()) diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index 3561cc3385..6d57a42ff7 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -239,7 +239,8 @@ impl ActiveCall { if result.is_ok() { this.update(&mut cx, |this, cx| this.report_call_event("invite", cx))?; } else { - // TODO: Resport collaboration error + //TODO: report collaboration error + log::error!("invite failed: {:?}", result); } this.update(&mut cx, |this, cx| { @@ -282,7 +283,7 @@ impl ActiveCall { return Task::ready(Err(anyhow!("cannot join while on another call"))); } - let call = if let Some(call) = self.incoming_call.1.borrow().clone() { + let call = if let Some(call) = self.incoming_call.0.borrow_mut().take() { call } else { return Task::ready(Err(anyhow!("no incoming call"))); @@ -441,6 +442,8 @@ impl ActiveCall { .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 { diff --git a/crates/call/src/call_settings.rs b/crates/call/src/call_settings.rs index 88c3fe84ce..441323ad5f 100644 --- a/crates/call/src/call_settings.rs +++ b/crates/call/src/call_settings.rs @@ -9,9 +9,12 @@ pub struct CallSettings { pub mute_on_join: bool, } +/// Configuration of voice calls in Zed. #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] pub struct CallSettingsContent { /// Whether the microphone should be muted when joining a channel or a call. + /// + /// Default: false pub mute_on_join: Option, } diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index e2c9bf5886..97ba2fc97d 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -15,10 +15,7 @@ use gpui::{ AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, Task, WeakModel, }; use language::LanguageRegistry; -use live_kit_client::{ - LocalAudioTrack, LocalTrackPublication, LocalVideoTrack, RemoteAudioTrackUpdate, - RemoteVideoTrackUpdate, -}; +use live_kit_client::{LocalAudioTrack, LocalTrackPublication, LocalVideoTrack, RoomUpdate}; use postage::{sink::Sink, stream::Stream, watch}; use project::Project; use settings::Settings as _; @@ -29,6 +26,9 @@ 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, }, @@ -52,7 +52,9 @@ pub enum Event { RemoteProjectInvitationDiscarded { project_id: u64, }, - Left, + Left { + channel_id: Option, + }, } pub struct Room { @@ -131,11 +133,11 @@ impl Room { } }); - let _maintain_video_tracks = cx.spawn({ + let _handle_updates = cx.spawn({ let room = room.clone(); move |this, mut cx| async move { - let mut track_video_changes = room.remote_video_track_updates(); - while let Some(track_change) = track_video_changes.next().await { + let mut updates = room.updates(); + while let Some(update) = updates.next().await { let this = if let Some(this) = this.upgrade() { this } else { @@ -143,26 +145,7 @@ impl Room { }; this.update(&mut cx, |this, cx| { - this.remote_video_track_updated(track_change, cx).log_err() - }) - .ok(); - } - } - }); - - let _maintain_audio_tracks = cx.spawn({ - let room = room.clone(); - |this, mut cx| async move { - let mut track_audio_changes = room.remote_audio_track_updates(); - while let Some(track_change) = track_audio_changes.next().await { - let this = if let Some(this) = this.upgrade() { - this - } else { - break; - }; - - this.update(&mut cx, |this, cx| { - this.remote_audio_track_updated(track_change, cx).log_err() + this.live_kit_room_updated(update, cx).log_err() }) .ok(); } @@ -172,13 +155,17 @@ impl Room { let connect = room.connect(&connection_info.server_url, &connection_info.token); cx.spawn(|this, mut cx| async move { connect.await?; - - if !cx.update(|cx| Self::mute_on_join(cx))? { - this.update(&mut cx, |this, cx| this.share_microphone(cx))? - .await?; - } - - anyhow::Ok(()) + this.update(&mut cx, |this, cx| { + if !this.read_only() { + if let Some(live_kit) = &this.live_kit { + if !live_kit.muted_by_user && !live_kit.deafened { + return this.share_microphone(cx); + } + } + } + Task::ready(Ok(())) + })? + .await }) .detach_and_log_err(cx); @@ -187,11 +174,11 @@ impl Room { screen_track: LocalTrack::None, microphone_track: LocalTrack::None, next_publish_id: 0, - muted_by_user: false, + muted_by_user: Self::mute_on_join(cx), deafened: false, speaking: false, _maintain_room, - _maintain_tracks: [_maintain_video_tracks, _maintain_audio_tracks], + _handle_updates, }) } else { None @@ -375,7 +362,9 @@ impl Room { pub(crate) fn leave(&mut self, cx: &mut ModelContext) -> Task> { cx.notify(); - cx.emit(Event::Left); + cx.emit(Event::Left { + channel_id: self.channel_id(), + }); self.leave_internal(cx) } @@ -616,10 +605,39 @@ impl Room { .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 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 } @@ -729,9 +747,21 @@ impl Room { 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(live_kit_room) = &mut this.live_kit { + live_kit_room.stop_publishing(cx); + } + } + this.joined_projects.retain(|project| { if let Some(project) = project.upgrade() { - project.update(cx, |project, _| project.set_role(role)); + project.update(cx, |project, cx| project.set_role(role, cx)); true } else { false @@ -840,8 +870,8 @@ impl Room { .remote_audio_track_publications(&user.id.to_string()); for track in video_tracks { - this.remote_video_track_updated( - RemoteVideoTrackUpdate::Subscribed(track), + this.live_kit_room_updated( + RoomUpdate::SubscribedToRemoteVideoTrack(track), cx, ) .log_err(); @@ -850,8 +880,8 @@ impl Room { for (track, publication) in audio_tracks.iter().zip(publications.iter()) { - this.remote_audio_track_updated( - RemoteAudioTrackUpdate::Subscribed( + this.live_kit_room_updated( + RoomUpdate::SubscribedToRemoteAudioTrack( track.clone(), publication.clone(), ), @@ -942,13 +972,13 @@ impl Room { } } - fn remote_video_track_updated( + fn live_kit_room_updated( &mut self, - change: RemoteVideoTrackUpdate, + update: RoomUpdate, cx: &mut ModelContext, ) -> Result<()> { - match change { - RemoteVideoTrackUpdate::Subscribed(track) => { + match update { + RoomUpdate::SubscribedToRemoteVideoTrack(track) => { let user_id = track.publisher_id().parse()?; let track_id = track.sid().to_string(); let participant = self @@ -960,7 +990,8 @@ impl Room { participant_id: participant.peer_id, }); } - RemoteVideoTrackUpdate::Unsubscribed { + + RoomUpdate::UnsubscribedFromRemoteVideoTrack { publisher_id, track_id, } => { @@ -974,19 +1005,8 @@ impl Room { participant_id: participant.peer_id, }); } - } - cx.notify(); - Ok(()) - } - - fn remote_audio_track_updated( - &mut self, - change: RemoteAudioTrackUpdate, - cx: &mut ModelContext, - ) -> Result<()> { - match change { - RemoteAudioTrackUpdate::ActiveSpeakersChanged { speakers } => { + RoomUpdate::ActiveSpeakersChanged { speakers } => { let mut speaker_ids = speakers .into_iter() .filter_map(|speaker_sid| speaker_sid.parse().ok()) @@ -1008,9 +1028,9 @@ impl Room { } } } - cx.notify(); } - RemoteAudioTrackUpdate::MuteChanged { track_id, muted } => { + + RoomUpdate::RemoteAudioTrackMuteChanged { track_id, muted } => { let mut found = false; for participant in &mut self.remote_participants.values_mut() { for track in participant.audio_tracks.values() { @@ -1024,10 +1044,18 @@ impl Room { break; } } - - cx.notify(); } - RemoteAudioTrackUpdate::Subscribed(track, publication) => { + + RoomUpdate::SubscribedToRemoteAudioTrack(track, publication) => { + if let Some(live_kit) = &self.live_kit { + if live_kit.deafened { + track.stop(); + cx.foreground_executor() + .spawn(publication.set_enabled(false)) + .detach(); + } + } + let user_id = track.publisher_id().parse()?; let track_id = track.sid().to_string(); let participant = self @@ -1041,7 +1069,8 @@ impl Room { participant_id: participant.peer_id, }); } - RemoteAudioTrackUpdate::Unsubscribed { + + RoomUpdate::UnsubscribedFromRemoteAudioTrack { publisher_id, track_id, } => { @@ -1055,6 +1084,28 @@ impl Room { participant_id: participant.peer_id, }); } + + RoomUpdate::LocalAudioTrackUnpublished { publication } => { + log::info!("unpublished audio track {}", publication.sid()); + if let Some(room) = &mut self.live_kit { + room.microphone_track = LocalTrack::None; + } + } + + RoomUpdate::LocalVideoTrackUnpublished { publication } => { + log::info!("unpublished video track {}", publication.sid()); + if let Some(room) = &mut self.live_kit { + room.screen_track = LocalTrack::None; + } + } + + RoomUpdate::LocalAudioTrackPublished { publication } => { + log::info!("published audio track {}", publication.sid()); + } + + RoomUpdate::LocalVideoTrackPublished { publication } => { + log::info!("published video track {}", publication.sid()); + } } cx.notify(); @@ -1198,7 +1249,12 @@ impl Room { }; self.client.send(proto::UnshareProject { project_id })?; - project.update(cx, |this, cx| this.unshare(cx)) + 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( @@ -1254,15 +1310,12 @@ impl Room { }) } - pub fn is_muted(&self, cx: &AppContext) -> bool { - self.live_kit - .as_ref() - .and_then(|live_kit| match &live_kit.microphone_track { - LocalTrack::None => Some(Self::mute_on_join(cx)), - LocalTrack::Pending { muted, .. } => Some(*muted), - LocalTrack::Published { muted, .. } => Some(*muted), - }) - .unwrap_or(false) + 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 read_only(&self) -> bool { @@ -1284,16 +1337,11 @@ impl Room { pub fn share_microphone(&mut self, cx: &mut ModelContext) -> Task> { if self.status.is_offline() { return Task::ready(Err(anyhow!("room is offline"))); - } else if self.is_sharing_mic() { - return Task::ready(Err(anyhow!("microphone was already shared"))); } let 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, - muted: false, - }; + live_kit.microphone_track = LocalTrack::Pending { publish_id }; cx.notify(); publish_id } else { @@ -1322,14 +1370,13 @@ impl Room { .as_mut() .ok_or_else(|| anyhow!("live-kit was not initialized"))?; - let (canceled, muted) = if let LocalTrack::Pending { + let canceled = if let LocalTrack::Pending { publish_id: cur_publish_id, - muted, } = &live_kit.microphone_track { - (*cur_publish_id != publish_id, *muted) + *cur_publish_id != publish_id } else { - (true, false) + true }; match publication { @@ -1337,14 +1384,13 @@ impl Room { if canceled { live_kit.room.unpublish_track(publication); } else { - if muted { + if live_kit.muted_by_user || live_kit.deafened { cx.background_executor() - .spawn(publication.set_mute(muted)) + .spawn(publication.set_mute(true)) .detach(); } live_kit.microphone_track = LocalTrack::Published { track_publication: publication, - muted, }; cx.notify(); } @@ -1373,10 +1419,7 @@ impl Room { let (displays, 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, - muted: false, - }; + live_kit.screen_track = LocalTrack::Pending { publish_id }; cx.notify(); (live_kit.room.display_sources(), publish_id) } else { @@ -1410,14 +1453,13 @@ impl Room { .as_mut() .ok_or_else(|| anyhow!("live-kit was not initialized"))?; - let (canceled, muted) = if let LocalTrack::Pending { + let canceled = if let LocalTrack::Pending { publish_id: cur_publish_id, - muted, } = &live_kit.screen_track { - (*cur_publish_id != publish_id, *muted) + *cur_publish_id != publish_id } else { - (true, false) + true }; match publication { @@ -1425,14 +1467,8 @@ impl Room { if canceled { live_kit.room.unpublish_track(publication); } else { - if muted { - cx.background_executor() - .spawn(publication.set_mute(muted)) - .detach(); - } live_kit.screen_track = LocalTrack::Published { track_publication: publication, - muted, }; cx.notify(); } @@ -1455,61 +1491,51 @@ impl Room { }) } - pub fn toggle_mute(&mut self, cx: &mut ModelContext) -> Result>> { - let should_mute = !self.is_muted(cx); + pub fn toggle_mute(&mut self, cx: &mut ModelContext) { if let Some(live_kit) = self.live_kit.as_mut() { - if matches!(live_kit.microphone_track, LocalTrack::None) { - return Ok(self.share_microphone(cx)); + // 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); } - let (ret_task, old_muted) = live_kit.set_mute(should_mute, cx)?; - live_kit.muted_by_user = should_mute; - - if old_muted == true && live_kit.deafened == true { - if let Some(task) = self.toggle_deafen(cx).ok() { - task.detach(); + if should_undeafen { + if let Some(task) = self.set_deafened(false, cx) { + task.detach_and_log_err(cx); } } - - Ok(ret_task) - } else { - Err(anyhow!("LiveKit not started")) } } - pub fn toggle_deafen(&mut self, cx: &mut ModelContext) -> Result>> { + pub fn toggle_deafen(&mut self, cx: &mut ModelContext) { if let Some(live_kit) = self.live_kit.as_mut() { - (*live_kit).deafened = !live_kit.deafened; + // 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; - let mut tasks = Vec::with_capacity(self.remote_participants.len()); - // Context notification is sent within set_mute itself. - let mut mute_task = None; - // When deafening, mute user's mic as well. - // When undeafening, unmute user's mic unless it was manually muted prior to deafening. - if live_kit.deafened || !live_kit.muted_by_user { - mute_task = Some(live_kit.set_mute(live_kit.deafened, cx)?.0); - }; - for participant in self.remote_participants.values() { - for track in live_kit - .room - .remote_audio_track_publications(&participant.user.id.to_string()) - { - let deafened = live_kit.deafened; - tasks.push(cx.foreground_executor().spawn(track.set_enabled(!deafened))); - } + if let Some(task) = self.set_deafened(deafened, cx) { + task.detach_and_log_err(cx); } - Ok(cx.foreground_executor().spawn(async move { - if let Some(mute_task) = mute_task { - mute_task.await?; + if should_change_mute { + if let Some(task) = self.set_mute(deafened, cx) { + task.detach_and_log_err(cx); } - for task in tasks { - task.await?; - } - Ok(()) - })) - } else { - Err(anyhow!("LiveKit not started")) + } } } @@ -1540,6 +1566,70 @@ impl Room { } } + fn set_deafened( + &mut self, + deafened: bool, + cx: &mut ModelContext, + ) -> Option>> { + let live_kit = self.live_kit.as_mut()?; + cx.notify(); + + let mut track_updates = Vec::new(); + for participant in self.remote_participants.values() { + for publication in live_kit + .room + .remote_audio_track_publications(&participant.user.id.to_string()) + { + track_updates.push(publication.set_enabled(!deafened)); + } + + for track in participant.audio_tracks.values() { + if deafened { + track.stop(); + } else { + track.start(); + } + } + } + + Some(cx.foreground_executor().spawn(async move { + for result in futures::future::join_all(track_updates).await { + result?; + } + Ok(()) + })) + } + + 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 } => Some( + cx.foreground_executor() + .spawn(track_publication.set_mute(should_mute)), + ), + } + } + #[cfg(any(test, feature = "test-support"))] pub fn set_display_sources(&self, sources: Vec) { self.live_kit @@ -1560,52 +1650,26 @@ struct LiveKitRoom { speaking: bool, next_publish_id: usize, _maintain_room: Task<()>, - _maintain_tracks: [Task<()>; 2], + _handle_updates: Task<()>, } impl LiveKitRoom { - fn set_mute( - self: &mut LiveKitRoom, - should_mute: bool, - cx: &mut ModelContext, - ) -> Result<(Task>, bool)> { - if !should_mute { - // clear user muting state. - self.muted_by_user = false; + fn stop_publishing(&mut self, cx: &mut ModelContext) { + if let LocalTrack::Published { + track_publication, .. + } = mem::replace(&mut self.microphone_track, LocalTrack::None) + { + self.room.unpublish_track(track_publication); + cx.notify(); } - let (result, old_muted) = match &mut self.microphone_track { - LocalTrack::None => Err(anyhow!("microphone was not shared")), - LocalTrack::Pending { muted, .. } => { - let old_muted = *muted; - *muted = should_mute; - cx.notify(); - Ok((Task::Ready(Some(Ok(()))), old_muted)) - } - LocalTrack::Published { - track_publication, - muted, - } => { - let old_muted = *muted; - *muted = should_mute; - cx.notify(); - Ok(( - cx.background_executor() - .spawn(track_publication.set_mute(*muted)), - old_muted, - )) - } - }?; - - if old_muted != should_mute { - if should_mute { - Audio::play_sound(Sound::Mute, cx); - } else { - Audio::play_sound(Sound::Unmute, cx); - } + if let LocalTrack::Published { + track_publication, .. + } = mem::replace(&mut self.screen_track, LocalTrack::None) + { + self.room.unpublish_track(track_publication); + cx.notify(); } - - Ok((result, old_muted)) } } @@ -1613,11 +1677,9 @@ enum LocalTrack { None, Pending { publish_id: usize, - muted: bool, }, Published { track_publication: LocalTrackPublication, - muted: bool, }, } diff --git a/crates/channel/src/channel_chat.rs b/crates/channel/src/channel_chat.rs index d2250972f3..e9353a1441 100644 --- a/crates/channel/src/channel_chat.rs +++ b/crates/channel/src/channel_chat.rs @@ -144,7 +144,7 @@ impl ChannelChat { message: MessageParams, cx: &mut ModelContext, ) -> Result>> { - if message.text.is_empty() { + if message.text.trim().is_empty() { Err(anyhow!("message body can't be empty"))?; } @@ -174,6 +174,8 @@ impl ChannelChat { let user_store = self.user_store.clone(); let rpc = self.rpc.clone(); let outgoing_messages_lock = self.outgoing_messages_lock.clone(); + + // todo - handle messages that fail to send (e.g. >1024 chars) Ok(cx.spawn(move |this, mut cx| async move { let outgoing_message_guard = outgoing_messages_lock.lock().await; let request = rpc.request(proto::SendChannelMessage { diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 0821a8e534..75bcc2512a 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -52,6 +52,7 @@ pub use user::*; lazy_static! { pub static ref ZED_SERVER_URL: String = std::env::var("ZED_SERVER_URL").unwrap_or_else(|_| "https://zed.dev".to_string()); + pub static ref ZED_RPC_URL: Option = std::env::var("ZED_RPC_URL").ok(); pub static ref IMPERSONATE_LOGIN: Option = std::env::var("ZED_IMPERSONATE") .ok() .and_then(|s| if s.is_empty() { None } else { Some(s) }); @@ -933,6 +934,10 @@ impl Client { http: Arc, release_channel: Option, ) -> Result { + if let Some(url) = &*ZED_RPC_URL { + return Url::parse(url).context("invalid rpc url"); + } + let mut url = format!("{}/rpc", *ZED_SERVER_URL); if let Some(preview_param) = release_channel.and_then(|channel| channel.release_query_param()) @@ -941,14 +946,6 @@ impl Client { url += preview_param; } let response = http.get(&url, Default::default(), false).await?; - - // Normally, ZED_SERVER_URL is set to the URL of zed.dev website. - // The website's /rpc endpoint redirects to a collab server's /rpc endpoint, - // which requires authorization via an HTTP header. - // - // For testing purposes, ZED_SERVER_URL can also set to the direct URL of - // of a collab server. In that case, a request to the /rpc endpoint will - // return an 'unauthorized' response. let collab_url = if response.status().is_redirection() { response .headers() @@ -957,8 +954,6 @@ impl Client { .to_str() .map_err(EstablishConnectionError::other)? .to_string() - } else if response.status() == StatusCode::UNAUTHORIZED { - url } else { Err(anyhow!( "unexpected /rpc response status {}", @@ -1371,10 +1366,7 @@ fn read_credentials_from_keychain(cx: &AsyncAppContext) -> Option { }) } -async fn write_credentials_to_keychain( - credentials: Credentials, - cx: &AsyncAppContext, -) -> Result<()> { +fn write_credentials_to_keychain(credentials: Credentials, cx: &AsyncAppContext) -> Result<()> { cx.update(move |cx| { cx.write_credentials( &ZED_SERVER_URL, @@ -1384,7 +1376,7 @@ async fn write_credentials_to_keychain( })? } -async fn delete_credentials_from_keychain(cx: &AsyncAppContext) -> Result<()> { +fn delete_credentials_from_keychain(cx: &AsyncAppContext) -> Result<()> { cx.update(move |cx| cx.delete_credentials(&ZED_SERVER_URL))? } diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index 26b5748187..313133ebef 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -1,3 +1,5 @@ +mod event_coalescer; + use crate::{TelemetrySettings, ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL}; use chrono::{DateTime, Utc}; use futures::Future; @@ -5,7 +7,6 @@ use gpui::{AppContext, AppMetadata, BackgroundExecutor, Task}; use lazy_static::lazy_static; use parking_lot::Mutex; use serde::Serialize; -use serde_json; use settings::{Settings, SettingsStore}; use std::{env, io::Write, mem, path::PathBuf, sync::Arc, time::Duration}; use sysinfo::{ @@ -13,8 +14,12 @@ use sysinfo::{ }; use tempfile::NamedTempFile; use util::http::HttpClient; +#[cfg(not(debug_assertions))] +use util::ResultExt; use util::{channel::ReleaseChannel, TryFutureExt}; +use self::event_coalescer::EventCoalescer; + pub struct Telemetry { http_client: Arc, executor: BackgroundExecutor, @@ -34,6 +39,7 @@ struct TelemetryState { log_file: Option, is_staff: Option, first_event_datetime: Option>, + event_coalescer: EventCoalescer, } const EVENTS_URL_PATH: &'static str = "/api/events"; @@ -110,7 +116,7 @@ pub enum Event { milliseconds_since_first_event: i64, }, App { - operation: &'static str, + operation: String, milliseconds_since_first_event: i64, }, Setting { @@ -118,27 +124,35 @@ pub enum Event { value: String, milliseconds_since_first_event: i64, }, + Edit { + duration: i64, + environment: &'static str, + milliseconds_since_first_event: i64, + }, + Action { + source: &'static str, + action: String, + milliseconds_since_first_event: i64, + }, } #[cfg(debug_assertions)] -const MAX_QUEUE_LEN: usize = 1; +const MAX_QUEUE_LEN: usize = 5; #[cfg(not(debug_assertions))] const MAX_QUEUE_LEN: usize = 50; #[cfg(debug_assertions)] -const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(1); +const FLUSH_INTERVAL: Duration = Duration::from_secs(1); #[cfg(not(debug_assertions))] -const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(60 * 5); +const FLUSH_INTERVAL: Duration = Duration::from_secs(60 * 5); impl Telemetry { pub fn new(client: Arc, cx: &mut AppContext) -> Arc { - let release_channel = if cx.has_global::() { - Some(cx.global::().display_name()) - } else { - None - }; + let release_channel = cx + .try_global::() + .map(|release_channel| release_channel.display_name()); TelemetrySettings::register(cx); @@ -150,13 +164,28 @@ impl Telemetry { installation_id: None, metrics_id: None, session_id: None, - events_queue: Default::default(), - flush_events_task: Default::default(), + events_queue: Vec::new(), + flush_events_task: None, log_file: None, is_staff: None, first_event_datetime: None, + event_coalescer: EventCoalescer::new(), })); + #[cfg(not(debug_assertions))] + cx.background_executor() + .spawn({ + let state = state.clone(); + async move { + if let Some(tempfile) = + NamedTempFile::new_in(util::paths::CONFIG_DIR.as_path()).log_err() + { + state.lock().log_file = Some(tempfile); + } + } + }) + .detach(); + cx.observe_global::({ let state = state.clone(); @@ -193,8 +222,8 @@ impl Telemetry { // TestAppContext ends up calling this function on shutdown and it panics when trying to find the TelemetrySettings #[cfg(not(any(test, feature = "test-support")))] fn shutdown_telemetry(self: &Arc) -> impl Future { - self.report_app_event("close"); - self.flush_events(); + self.report_app_event("close".to_string()); + // TODO: close final edit period and make sure it's sent Task::ready(()) } @@ -359,7 +388,7 @@ impl Telemetry { self.report_event(event) } - pub fn report_app_event(self: &Arc, operation: &'static str) { + pub fn report_app_event(self: &Arc, operation: String) { let event = Event::App { operation, milliseconds_since_first_event: self.milliseconds_since_first_event(), @@ -378,8 +407,35 @@ impl Telemetry { self.report_event(event) } + pub fn log_edit_event(self: &Arc, environment: &'static str) { + let mut state = self.state.lock(); + let period_data = state.event_coalescer.log_event(environment); + drop(state); + + if let Some((start, end, environment)) = period_data { + let event = Event::Edit { + duration: end.timestamp_millis() - start.timestamp_millis(), + environment, + milliseconds_since_first_event: self.milliseconds_since_first_event(), + }; + + self.report_event(event); + } + } + + pub fn report_action_event(self: &Arc, source: &'static str, action: String) { + let event = Event::Action { + source, + action, + milliseconds_since_first_event: self.milliseconds_since_first_event(), + }; + + self.report_event(event) + } + fn milliseconds_since_first_event(&self) -> i64 { let mut state = self.state.lock(); + match state.first_event_datetime { Some(first_event_datetime) => { let now: DateTime = Utc::now(); @@ -399,6 +455,15 @@ impl Telemetry { return; } + if state.flush_events_task.is_none() { + let this = self.clone(); + let executor = self.executor.clone(); + state.flush_events_task = Some(self.executor.spawn(async move { + executor.timer(FLUSH_INTERVAL).await; + this.flush_events(); + })); + } + let signed_in = state.metrics_id.is_some(); state.events_queue.push(EventWrapper { signed_in, event }); @@ -406,13 +471,6 @@ impl Telemetry { if state.events_queue.len() >= MAX_QUEUE_LEN { drop(state); self.flush_events(); - } else { - let this = self.clone(); - let executor = self.executor.clone(); - state.flush_events_task = Some(self.executor.spawn(async move { - executor.timer(DEBOUNCE_INTERVAL).await; - this.flush_events(); - })); } } } @@ -435,6 +493,9 @@ impl Telemetry { let mut events = mem::take(&mut state.events_queue); state.flush_events_task.take(); drop(state); + if events.is_empty() { + return; + } let this = self.clone(); self.executor diff --git a/crates/client/src/telemetry/event_coalescer.rs b/crates/client/src/telemetry/event_coalescer.rs new file mode 100644 index 0000000000..f0efeb38e6 --- /dev/null +++ b/crates/client/src/telemetry/event_coalescer.rs @@ -0,0 +1,279 @@ +use chrono::{DateTime, Duration, Utc}; +use std::time; + +const COALESCE_TIMEOUT: time::Duration = time::Duration::from_secs(20); +const SIMULATED_DURATION_FOR_SINGLE_EVENT: time::Duration = time::Duration::from_millis(1); + +#[derive(Debug, PartialEq)] +struct PeriodData { + environment: &'static str, + start: DateTime, + end: Option>, +} + +pub struct EventCoalescer { + state: Option, +} + +impl EventCoalescer { + pub fn new() -> Self { + Self { state: None } + } + + pub fn log_event( + &mut self, + environment: &'static str, + ) -> Option<(DateTime, DateTime, &'static str)> { + self.log_event_with_time(Utc::now(), environment) + } + + // pub fn close_current_period(&mut self) -> Option<(DateTime, DateTime)> { + // self.environment.map(|env| self.log_event(env)).flatten() + // } + + fn log_event_with_time( + &mut self, + log_time: DateTime, + environment: &'static str, + ) -> Option<(DateTime, DateTime, &'static str)> { + let coalesce_timeout = Duration::from_std(COALESCE_TIMEOUT).unwrap(); + + let Some(state) = &mut self.state else { + self.state = Some(PeriodData { + start: log_time, + end: None, + environment, + }); + return None; + }; + + let period_end = state + .end + .unwrap_or(state.start + SIMULATED_DURATION_FOR_SINGLE_EVENT); + let within_timeout = log_time - period_end < coalesce_timeout; + let environment_is_same = state.environment == environment; + let should_coaelesce = !within_timeout || !environment_is_same; + + if should_coaelesce { + let previous_environment = state.environment; + let original_start = state.start; + + state.start = log_time; + state.end = None; + state.environment = environment; + + return Some(( + original_start, + if within_timeout { log_time } else { period_end }, + previous_environment, + )); + } + + state.end = Some(log_time); + + None + } +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + + use super::*; + + #[test] + fn test_same_context_exceeding_timeout() { + let environment_1 = "environment_1"; + let mut event_coalescer = EventCoalescer::new(); + + assert_eq!(event_coalescer.state, None); + + let period_start = Utc.with_ymd_and_hms(1990, 4, 12, 0, 0, 0).unwrap(); + let period_data = event_coalescer.log_event_with_time(period_start, environment_1); + + assert_eq!(period_data, None); + assert_eq!( + event_coalescer.state, + Some(PeriodData { + start: period_start, + end: None, + environment: environment_1, + }) + ); + + let within_timeout_adjustment = Duration::from_std(COALESCE_TIMEOUT / 2).unwrap(); + let mut period_end = period_start; + + // Ensure that many calls within the timeout don't start a new period + for _ in 0..100 { + period_end += within_timeout_adjustment; + let period_data = event_coalescer.log_event_with_time(period_end, environment_1); + + assert_eq!(period_data, None); + assert_eq!( + event_coalescer.state, + Some(PeriodData { + start: period_start, + end: Some(period_end), + environment: environment_1, + }) + ); + } + + let exceed_timeout_adjustment = Duration::from_std(COALESCE_TIMEOUT * 2).unwrap(); + // Logging an event exceeding the timeout should start a new period + let new_period_start = period_end + exceed_timeout_adjustment; + let period_data = event_coalescer.log_event_with_time(new_period_start, environment_1); + + assert_eq!(period_data, Some((period_start, period_end, environment_1))); + assert_eq!( + event_coalescer.state, + Some(PeriodData { + start: new_period_start, + end: None, + environment: environment_1, + }) + ); + } + + #[test] + fn test_different_environment_under_timeout() { + let environment_1 = "environment_1"; + let mut event_coalescer = EventCoalescer::new(); + + assert_eq!(event_coalescer.state, None); + + let period_start = Utc.with_ymd_and_hms(1990, 4, 12, 0, 0, 0).unwrap(); + let period_data = event_coalescer.log_event_with_time(period_start, environment_1); + + assert_eq!(period_data, None); + assert_eq!( + event_coalescer.state, + Some(PeriodData { + start: period_start, + end: None, + environment: environment_1, + }) + ); + + let within_timeout_adjustment = Duration::from_std(COALESCE_TIMEOUT / 2).unwrap(); + let period_end = period_start + within_timeout_adjustment; + let period_data = event_coalescer.log_event_with_time(period_end, environment_1); + + assert_eq!(period_data, None); + assert_eq!( + event_coalescer.state, + Some(PeriodData { + start: period_start, + end: Some(period_end), + environment: environment_1, + }) + ); + + // Logging an event within the timeout but with a different environment should start a new period + let period_end = period_end + within_timeout_adjustment; + let environment_2 = "environment_2"; + let period_data = event_coalescer.log_event_with_time(period_end, environment_2); + + assert_eq!(period_data, Some((period_start, period_end, environment_1))); + assert_eq!( + event_coalescer.state, + Some(PeriodData { + start: period_end, + end: None, + environment: environment_2, + }) + ); + } + + #[test] + fn test_switching_environment_while_within_timeout() { + let environment_1 = "environment_1"; + let mut event_coalescer = EventCoalescer::new(); + + assert_eq!(event_coalescer.state, None); + + let period_start = Utc.with_ymd_and_hms(1990, 4, 12, 0, 0, 0).unwrap(); + let period_data = event_coalescer.log_event_with_time(period_start, environment_1); + + assert_eq!(period_data, None); + assert_eq!( + event_coalescer.state, + Some(PeriodData { + start: period_start, + end: None, + environment: environment_1, + }) + ); + + let within_timeout_adjustment = Duration::from_std(COALESCE_TIMEOUT / 2).unwrap(); + let period_end = period_start + within_timeout_adjustment; + let environment_2 = "environment_2"; + let period_data = event_coalescer.log_event_with_time(period_end, environment_2); + + assert_eq!(period_data, Some((period_start, period_end, environment_1))); + assert_eq!( + event_coalescer.state, + Some(PeriodData { + start: period_end, + end: None, + environment: environment_2, + }) + ); + } + // // 0 20 40 60 + // // |-------------------|-------------------|-------------------|------------------- + // // |--------|----------env change + // // |------------------- + // // |period_start |period_end + // // |new_period_start + + #[test] + fn test_switching_environment_while_exceeding_timeout() { + let environment_1 = "environment_1"; + let mut event_coalescer = EventCoalescer::new(); + + assert_eq!(event_coalescer.state, None); + + let period_start = Utc.with_ymd_and_hms(1990, 4, 12, 0, 0, 0).unwrap(); + let period_data = event_coalescer.log_event_with_time(period_start, environment_1); + + assert_eq!(period_data, None); + assert_eq!( + event_coalescer.state, + Some(PeriodData { + start: period_start, + end: None, + environment: environment_1, + }) + ); + + let exceed_timeout_adjustment = Duration::from_std(COALESCE_TIMEOUT * 2).unwrap(); + let period_end = period_start + exceed_timeout_adjustment; + let environment_2 = "environment_2"; + let period_data = event_coalescer.log_event_with_time(period_end, environment_2); + + assert_eq!( + period_data, + Some(( + period_start, + period_start + SIMULATED_DURATION_FOR_SINGLE_EVENT, + environment_1 + )) + ); + assert_eq!( + event_coalescer.state, + Some(PeriodData { + start: period_end, + end: None, + environment: environment_2, + }) + ); + } + // 0 20 40 60 + // |-------------------|-------------------|-------------------|------------------- + // |--------|----------------------------------------env change + // |-------------------| + // |period_start |period_end + // |new_period_start +} diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 1c288c875d..4453bb40ea 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -3,7 +3,7 @@ use anyhow::{anyhow, Context, Result}; use collections::{hash_map::Entry, HashMap, HashSet}; use feature_flags::FeatureFlagAppExt; use futures::{channel::mpsc, Future, StreamExt}; -use gpui::{AsyncAppContext, EventEmitter, Model, ModelContext, SharedString, Task}; +use gpui::{AsyncAppContext, EventEmitter, Model, ModelContext, SharedUrl, Task}; use postage::{sink::Sink, watch}; use rpc::proto::{RequestMessage, UsersResponse}; use std::sync::{Arc, Weak}; @@ -19,7 +19,7 @@ pub struct ParticipantIndex(pub u32); pub struct User { pub id: UserId, pub github_login: String, - pub avatar_uri: SharedString, + pub avatar_uri: SharedUrl, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/crates/collab/.admins.default.json b/crates/collab/.admins.default.json new file mode 100644 index 0000000000..75153ae06b --- /dev/null +++ b/crates/collab/.admins.default.json @@ -0,0 +1,8 @@ +[ + "nathansobo", + "as-cii", + "maxbrunsfeld", + "iamnbutler", + "mikayla-maki", + "JosephTLyons" +] diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index baf279d634..9209d9ac2d 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -3,7 +3,7 @@ authors = ["Nathan Sobo "] default-run = "collab" edition = "2021" name = "collab" -version = "0.35.0" +version = "0.37.0" publish = false [[bin]] @@ -74,6 +74,8 @@ live_kit_client = { path = "../live_kit_client", features = ["test-support"] } lsp = { path = "../lsp", features = ["test-support"] } node_runtime = { path = "../node_runtime" } notifications = { path = "../notifications", features = ["test-support"] } +file_finder = { path = "../file_finder"} +menu = { path = "../menu"} project = { path = "../project", features = ["test-support"] } rpc = { path = "../rpc", features = ["test-support"] } diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 9bbbf88dac..8d8f523c94 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -19,6 +19,7 @@ CREATE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id"); CREATE TABLE "access_tokens" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER REFERENCES users (id), + "impersonated_user_id" INTEGER REFERENCES users (id), "hash" VARCHAR(128) ); CREATE INDEX "index_access_tokens_user_id" ON "access_tokens" ("user_id"); @@ -37,7 +38,7 @@ CREATE INDEX "index_contacts_user_id_b" ON "contacts" ("user_id_b"); CREATE TABLE "rooms" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "live_kit_room" VARCHAR NOT NULL, - "enviroment" VARCHAR, + "environment" VARCHAR, "channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE ); CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id"); diff --git a/crates/collab/migrations/20240111085546_fix_column_name.sql b/crates/collab/migrations/20240111085546_fix_column_name.sql new file mode 100644 index 0000000000..3f32ee35c5 --- /dev/null +++ b/crates/collab/migrations/20240111085546_fix_column_name.sql @@ -0,0 +1 @@ +ALTER TABLE rooms ADD COLUMN environment TEXT; diff --git a/crates/collab/migrations/20240117150300_add_impersonator_to_access_tokens.sql b/crates/collab/migrations/20240117150300_add_impersonator_to_access_tokens.sql new file mode 100644 index 0000000000..8c79640cd8 --- /dev/null +++ b/crates/collab/migrations/20240117150300_add_impersonator_to_access_tokens.sql @@ -0,0 +1 @@ +ALTER TABLE access_tokens ADD COLUMN impersonated_user_id integer; diff --git a/crates/collab/src/api.rs b/crates/collab/src/api.rs index a28aeac9ab..6bdbd7357f 100644 --- a/crates/collab/src/api.rs +++ b/crates/collab/src/api.rs @@ -156,11 +156,11 @@ async fn create_access_token( .await? .ok_or_else(|| anyhow!("user not found"))?; - let mut user_id = user.id; + let mut impersonated_user_id = None; if let Some(impersonate) = params.impersonate { if user.admin { if let Some(impersonated_user) = app.db.get_user_by_github_login(&impersonate).await? { - user_id = impersonated_user.id; + impersonated_user_id = Some(impersonated_user.id); } else { return Err(Error::Http( StatusCode::UNPROCESSABLE_ENTITY, @@ -175,12 +175,13 @@ async fn create_access_token( } } - let access_token = auth::create_access_token(app.db.as_ref(), user_id).await?; + let access_token = + auth::create_access_token(app.db.as_ref(), user_id, impersonated_user_id).await?; let encrypted_access_token = auth::encrypt_access_token(&access_token, params.public_key.clone())?; Ok(Json(CreateAccessTokenResponse { - user_id, + user_id: impersonated_user_id.unwrap_or(user_id), encrypted_access_token, })) } diff --git a/crates/collab/src/auth.rs b/crates/collab/src/auth.rs index 9ce602c577..dc0374df6a 100644 --- a/crates/collab/src/auth.rs +++ b/crates/collab/src/auth.rs @@ -27,6 +27,11 @@ lazy_static! { .unwrap(); } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Impersonator(pub Option); + +/// Validates the authorization header. This has two mechanisms, one for the ADMIN_TOKEN +/// and one for the access tokens that we issue. pub async fn validate_header(mut req: Request, next: Next) -> impl IntoResponse { let mut auth_header = req .headers() @@ -55,28 +60,50 @@ pub async fn validate_header(mut req: Request, next: Next) -> impl Into })?; let state = req.extensions().get::>().unwrap(); - let credentials_valid = if let Some(admin_token) = access_token.strip_prefix("ADMIN_TOKEN:") { - state.config.api_token == admin_token + + // In development, allow impersonation using the admin API token. + // Don't allow this in production because we can't tell who is doing + // the impersonating. + let validate_result = if let (Some(admin_token), true) = ( + access_token.strip_prefix("ADMIN_TOKEN:"), + state.config.is_development(), + ) { + Ok(VerifyAccessTokenResult { + is_valid: state.config.api_token == admin_token, + impersonator_id: None, + }) } else { - verify_access_token(&access_token, user_id, &state.db) - .await - .unwrap_or(false) + verify_access_token(&access_token, user_id, &state.db).await }; - if credentials_valid { - let user = state - .db - .get_user_by_id(user_id) - .await? - .ok_or_else(|| anyhow!("user {} not found", user_id))?; - req.extensions_mut().insert(user); - Ok::<_, Error>(next.run(req).await) - } else { - Err(Error::Http( - StatusCode::UNAUTHORIZED, - "invalid credentials".to_string(), - )) + if let Ok(validate_result) = validate_result { + if validate_result.is_valid { + let user = state + .db + .get_user_by_id(user_id) + .await? + .ok_or_else(|| anyhow!("user {} not found", user_id))?; + + let impersonator = if let Some(impersonator_id) = validate_result.impersonator_id { + let impersonator = state + .db + .get_user_by_id(impersonator_id) + .await? + .ok_or_else(|| anyhow!("user {} not found", impersonator_id))?; + Some(impersonator) + } else { + None + }; + req.extensions_mut().insert(user); + req.extensions_mut().insert(Impersonator(impersonator)); + return Ok::<_, Error>(next.run(req).await); + } } + + Err(Error::Http( + StatusCode::UNAUTHORIZED, + "invalid credentials".to_string(), + )) } const MAX_ACCESS_TOKENS_TO_STORE: usize = 8; @@ -88,13 +115,24 @@ struct AccessTokenJson { token: String, } -pub async fn create_access_token(db: &db::Database, user_id: UserId) -> Result { +/// Creates a new access token to identify the given user. before returning it, you should +/// encrypt it with the user's public key. +pub async fn create_access_token( + db: &db::Database, + user_id: UserId, + impersonated_user_id: Option, +) -> Result { const VERSION: usize = 1; let access_token = rpc::auth::random_token(); let access_token_hash = hash_access_token(&access_token).context("failed to hash access token")?; let id = db - .create_access_token(user_id, &access_token_hash, MAX_ACCESS_TOKENS_TO_STORE) + .create_access_token( + user_id, + impersonated_user_id, + &access_token_hash, + MAX_ACCESS_TOKENS_TO_STORE, + ) .await?; Ok(serde_json::to_string(&AccessTokenJson { version: VERSION, @@ -122,6 +160,8 @@ fn hash_access_token(token: &str) -> Result { .to_string()) } +/// Encrypts the given access token with the given public key to avoid leaking it on the way +/// to the client. pub fn encrypt_access_token(access_token: &str, public_key: String) -> Result { let native_app_public_key = rpc::auth::PublicKey::try_from(public_key).context("failed to parse app public key")?; @@ -131,11 +171,22 @@ pub fn encrypt_access_token(access_token: &str, public_key: String) -> Result) -> Result { +pub struct VerifyAccessTokenResult { + pub is_valid: bool, + pub impersonator_id: Option, +} + +/// Checks that the given access token is valid for the given user. +pub async fn verify_access_token( + token: &str, + user_id: UserId, + db: &Arc, +) -> Result { let token: AccessTokenJson = serde_json::from_str(&token)?; let db_token = db.get_access_token(token.id).await?; - if db_token.user_id != user_id { + let token_user_id = db_token.impersonated_user_id.unwrap_or(db_token.user_id); + if token_user_id != user_id { return Err(anyhow!("no such access token"))?; } @@ -147,5 +198,12 @@ pub async fn verify_access_token(token: &str, user_id: UserId, db: &Arc(&client, &github_token, "https://api.github.com/user").await; - current_user - .email - .get_or_insert_with(|| "placeholder@example.com".to_string()); - let staff_users = fetch_github::>( - &client, - &github_token, - "https://api.github.com/orgs/zed-industries/teams/staff/members", - ) - .await; + // Create admin users for all of the users in `.admins.toml` or `.admins.default.toml`. + for admin_login in admin_logins { + let user = fetch_github::( + &client, + &format!("https://api.github.com/users/{admin_login}"), + ) + .await; + db.create_user( + &user.email.unwrap_or(format!("{admin_login}@example.com")), + true, + NewUserParams { + github_login: user.login, + github_user_id: user.id, + }, + ) + .await + .expect("failed to create admin user"); + } - let mut zed_users = Vec::new(); - zed_users.push((current_user, true)); - zed_users.extend(staff_users.into_iter().map(|user| (user, true))); - - let user_count = db + // Fetch 100 other random users from GitHub and insert them into the database. + let mut user_count = db .get_all_users(0, 200) .await .expect("failed to load users from db") .len(); - if user_count < 100 { - let mut last_user_id = None; - for _ in 0..10 { - let mut uri = "https://api.github.com/users?per_page=100".to_string(); - if let Some(last_user_id) = last_user_id { - write!(&mut uri, "&since={}", last_user_id).unwrap(); - } - let users = fetch_github::>(&client, &github_token, &uri).await; - if let Some(last_user) = users.last() { - last_user_id = Some(last_user.id); - zed_users.extend(users.into_iter().map(|user| (user, false))); - } else { - break; - } + let mut last_user_id = None; + while user_count < 100 { + let mut uri = "https://api.github.com/users?per_page=100".to_string(); + if let Some(last_user_id) = last_user_id { + write!(&mut uri, "&since={}", last_user_id).unwrap(); } - } + let users = fetch_github::>(&client, &uri).await; - for (github_user, admin) in zed_users { - if db - .get_user_by_github_login(&github_user.login) + for github_user in users { + last_user_id = Some(github_user.id); + user_count += 1; + db.get_or_create_user_by_github_account( + &github_user.login, + Some(github_user.id), + github_user.email.as_deref(), + ) .await - .expect("failed to fetch user") - .is_none() - { - if admin { - db.create_user( - &format!("{}@zed.dev", github_user.login), - admin, - db::NewUserParams { - github_login: github_user.login, - github_user_id: github_user.id, - }, - ) - .await - .expect("failed to insert user"); - } else { - db.get_or_create_user_by_github_account( - &github_user.login, - Some(github_user.id), - github_user.email.as_deref(), - ) - .await - .expect("failed to insert user"); - } + .expect("failed to insert user"); } } } -async fn fetch_github( - client: &reqwest::Client, - access_token: &str, - url: &str, -) -> T { +fn load_admins(path: &str) -> anyhow::Result> { + let file_content = fs::read_to_string(path)?; + Ok(serde_json::from_str(&file_content)?) +} + +async fn fetch_github(client: &reqwest::Client, url: &str) -> T { let response = client .get(url) - .bearer_auth(&access_token) .header("user-agent", "zed") .send() .await diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index df33416a46..480dcf6f85 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -47,6 +47,8 @@ pub use ids::*; pub use sea_orm::ConnectOptions; pub use tables::user::Model as User; +/// Database gives you a handle that lets you access the database. +/// It handles pooling internally. pub struct Database { options: ConnectOptions, pool: DatabaseConnection, @@ -62,6 +64,7 @@ pub struct Database { // The `Database` type has so many methods that its impl blocks are split into // separate files in the `queries` folder. impl Database { + /// Connects to the database with the given options pub async fn new(options: ConnectOptions, executor: Executor) -> Result { sqlx::any::install_default_drivers(); Ok(Self { @@ -82,6 +85,7 @@ impl Database { self.rooms.clear(); } + /// Runs the database migrations. pub async fn migrate( &self, migrations_path: &Path, @@ -123,11 +127,15 @@ impl Database { Ok(new_migrations) } + /// Initializes static data that resides in the database by upserting it. pub async fn initialize_static_data(&mut self) -> Result<()> { self.initialize_notification_kinds().await?; Ok(()) } + /// Transaction runs things in a transaction. If you want to call other methods + /// and pass the transaction around you need to reborrow the transaction at each + /// call site with: `&*tx`. pub async fn transaction(&self, f: F) -> Result where F: Send + Fn(TransactionHandle) -> Fut, @@ -160,6 +168,7 @@ impl Database { self.run(body).await } + /// The same as room_transaction, but if you need to only optionally return a Room. async fn optional_room_transaction(&self, f: F) -> Result>> where F: Send + Fn(TransactionHandle) -> Fut, @@ -210,6 +219,9 @@ impl Database { self.run(body).await } + /// room_transaction runs the block in a transaction. It returns a RoomGuard, that keeps + /// the database locked until it is dropped. This ensures that updates sent to clients are + /// properly serialized with respect to database changes. async fn room_transaction(&self, room_id: RoomId, f: F) -> Result> where F: Send + Fn(TransactionHandle) -> Fut, @@ -330,6 +342,7 @@ fn is_serialization_error(error: &Error) -> bool { } } +/// A handle to a [`DatabaseTransaction`]. pub struct TransactionHandle(Arc>); impl Deref for TransactionHandle { @@ -340,6 +353,8 @@ impl Deref for TransactionHandle { } } +/// [`RoomGuard`] keeps a database transaction alive until it is dropped. +/// so that updates to rooms are serialized. pub struct RoomGuard { data: T, _guard: OwnedMutexGuard<()>, @@ -361,6 +376,7 @@ impl DerefMut for RoomGuard { } impl RoomGuard { + /// Returns the inner value of the guard. pub fn into_inner(self) -> T { self.data } @@ -420,12 +436,14 @@ pub struct WaitlistSummary { pub unknown_count: i64, } +/// The parameters to create a new user. #[derive(Debug, Serialize, Deserialize)] pub struct NewUserParams { pub github_login: String, pub github_user_id: i32, } +/// The result of creating a new user. #[derive(Debug)] pub struct NewUserResult { pub user_id: UserId, @@ -434,6 +452,7 @@ pub struct NewUserResult { pub signup_device_id: Option, } +/// The result of moving a channel. #[derive(Debug)] pub struct MoveChannelResult { pub participants_to_update: HashMap, @@ -441,18 +460,21 @@ pub struct MoveChannelResult { pub moved_channels: HashSet, } +/// The result of renaming a channel. #[derive(Debug)] pub struct RenameChannelResult { pub channel: Channel, pub participants_to_update: HashMap, } +/// The result of creating a channel. #[derive(Debug)] pub struct CreateChannelResult { pub channel: Channel, pub participants_to_update: Vec<(UserId, ChannelsForUser)>, } +/// The result of setting a channel's visibility. #[derive(Debug)] pub struct SetChannelVisibilityResult { pub participants_to_update: HashMap, @@ -460,6 +482,7 @@ pub struct SetChannelVisibilityResult { pub channels_to_remove: Vec, } +/// The result of updating a channel membership. #[derive(Debug)] pub struct MembershipUpdated { pub channel_id: ChannelId, @@ -467,12 +490,14 @@ pub struct MembershipUpdated { pub removed_channels: Vec, } +/// The result of setting a member's role. #[derive(Debug)] pub enum SetMemberRoleResult { InviteUpdated(Channel), MembershipUpdated(MembershipUpdated), } +/// The result of inviting a member to a channel. #[derive(Debug)] pub struct InviteMemberResult { pub channel: Channel, @@ -497,6 +522,7 @@ pub struct Channel { pub name: String, pub visibility: ChannelVisibility, pub role: ChannelRole, + /// parent_path is the channel ids from the root to this one (not including this one) pub parent_path: Vec, } diff --git a/crates/collab/src/db/ids.rs b/crates/collab/src/db/ids.rs index 9f77225fb7..a920265b57 100644 --- a/crates/collab/src/db/ids.rs +++ b/crates/collab/src/db/ids.rs @@ -19,19 +19,23 @@ macro_rules! id_type { Deserialize, DeriveValueType, )] + #[allow(missing_docs)] #[serde(transparent)] pub struct $name(pub i32); impl $name { #[allow(unused)] + #[allow(missing_docs)] pub const MAX: Self = Self(i32::MAX); #[allow(unused)] + #[allow(missing_docs)] pub fn from_proto(value: u64) -> Self { Self(value as i32) } #[allow(unused)] + #[allow(missing_docs)] pub fn to_proto(self) -> u64 { self.0 as u64 } @@ -84,21 +88,28 @@ id_type!(FlagId); id_type!(NotificationId); id_type!(NotificationKindId); +/// ChannelRole gives you permissions for both channels and calls. #[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash)] #[sea_orm(rs_type = "String", db_type = "String(None)")] pub enum ChannelRole { + /// Admin can read/write and change permissions. #[sea_orm(string_value = "admin")] Admin, + /// Member can read/write, but not change pemissions. #[sea_orm(string_value = "member")] #[default] Member, + /// Guest can read, but not write. + /// (thought they can use the channel chat) #[sea_orm(string_value = "guest")] Guest, + /// Banned may not read. #[sea_orm(string_value = "banned")] Banned, } impl ChannelRole { + /// Returns true if this role is more powerful than the other role. pub fn should_override(&self, other: Self) -> bool { use ChannelRole::*; match self { @@ -109,6 +120,7 @@ impl ChannelRole { } } + /// Returns the maximal role between the two pub fn max(&self, other: Self) -> Self { if self.should_override(other) { *self @@ -117,6 +129,7 @@ impl ChannelRole { } } + /// True if the role allows access to all descendant channels pub fn can_see_all_descendants(&self) -> bool { use ChannelRole::*; match self { @@ -125,6 +138,7 @@ impl ChannelRole { } } + /// True if the role only allows access to public descendant channels pub fn can_only_see_public_descendants(&self) -> bool { use ChannelRole::*; match self { @@ -133,7 +147,8 @@ impl ChannelRole { } } - pub fn can_share_projects(&self) -> bool { + /// True if the role can share screen/microphone/projects into rooms. + pub fn can_publish_to_rooms(&self) -> bool { use ChannelRole::*; match self { Admin | Member => true, @@ -141,6 +156,7 @@ impl ChannelRole { } } + /// True if the role can edit shared projects. pub fn can_edit_projects(&self) -> bool { use ChannelRole::*; match self { @@ -149,6 +165,7 @@ impl ChannelRole { } } + /// True if the role can read shared projects. pub fn can_read_projects(&self) -> bool { use ChannelRole::*; match self { @@ -187,11 +204,14 @@ impl Into for ChannelRole { } } +/// ChannelVisibility controls whether channels are public or private. #[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash)] #[sea_orm(rs_type = "String", db_type = "String(None)")] pub enum ChannelVisibility { + /// Public channels are visible to anyone with the link. People join with the Guest role by default. #[sea_orm(string_value = "public")] Public, + /// Members channels are only visible to members of this channel or its parents. #[sea_orm(string_value = "members")] #[default] Members, diff --git a/crates/collab/src/db/queries/access_tokens.rs b/crates/collab/src/db/queries/access_tokens.rs index 589b6483df..af58d51a33 100644 --- a/crates/collab/src/db/queries/access_tokens.rs +++ b/crates/collab/src/db/queries/access_tokens.rs @@ -2,9 +2,11 @@ use super::*; use sea_orm::sea_query::Query; impl Database { + /// Creates a new access token for the given user. pub async fn create_access_token( &self, user_id: UserId, + impersonated_user_id: Option, access_token_hash: &str, max_access_token_count: usize, ) -> Result { @@ -13,6 +15,7 @@ impl Database { let token = access_token::ActiveModel { user_id: ActiveValue::set(user_id), + impersonated_user_id: ActiveValue::set(impersonated_user_id), hash: ActiveValue::set(access_token_hash.into()), ..Default::default() } @@ -39,6 +42,7 @@ impl Database { .await } + /// Retrieves the access token with the given ID. pub async fn get_access_token( &self, access_token_id: AccessTokenId, diff --git a/crates/collab/src/db/queries/buffers.rs b/crates/collab/src/db/queries/buffers.rs index 9eddb1f618..bdcaaab6ef 100644 --- a/crates/collab/src/db/queries/buffers.rs +++ b/crates/collab/src/db/queries/buffers.rs @@ -9,6 +9,8 @@ pub struct LeftChannelBuffer { } impl Database { + /// Open a channel buffer. Returns the current contents, and adds you to the list of people + /// to notify on changes. pub async fn join_channel_buffer( &self, channel_id: ChannelId, @@ -121,6 +123,7 @@ impl Database { .await } + /// Rejoin a channel buffer (after a connection interruption) pub async fn rejoin_channel_buffers( &self, buffers: &[proto::ChannelBufferVersion], @@ -149,7 +152,7 @@ impl Database { .await?; // If the buffer epoch hasn't changed since the client lost - // connection, then the client's buffer can be syncronized with + // connection, then the client's buffer can be synchronized with // the server's buffer. if buffer.epoch as u64 != client_buffer.epoch { log::info!("can't rejoin buffer, epoch has changed"); @@ -232,6 +235,7 @@ impl Database { .await } + /// Clear out any buffer collaborators who are no longer collaborating. pub async fn clear_stale_channel_buffer_collaborators( &self, channel_id: ChannelId, @@ -274,6 +278,7 @@ impl Database { .await } + /// Close the channel buffer, and stop receiving updates for it. pub async fn leave_channel_buffer( &self, channel_id: ChannelId, @@ -286,6 +291,7 @@ impl Database { .await } + /// Close the channel buffer, and stop receiving updates for it. pub async fn channel_buffer_connection_lost( &self, connection: ConnectionId, @@ -309,6 +315,7 @@ impl Database { Ok(()) } + /// Close all open channel buffers pub async fn leave_channel_buffers( &self, connection: ConnectionId, @@ -342,7 +349,7 @@ impl Database { .await } - pub async fn leave_channel_buffer_internal( + async fn leave_channel_buffer_internal( &self, channel_id: ChannelId, connection: ConnectionId, @@ -798,6 +805,7 @@ impl Database { Ok(changes) } + /// Returns the latest operations for the buffers with the specified IDs. pub async fn get_latest_operations_for_buffers( &self, buffer_ids: impl IntoIterator, @@ -962,7 +970,7 @@ fn version_from_storage(version: &Vec) -> Vec Option { match operation.variant? { proto::operation::Variant::Edit(edit) => Some(text::Operation::Edit(EditOperation { diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 9c28e998c9..7ff9f00bc1 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -40,6 +40,7 @@ impl Database { .id) } + /// Creates a new channel. pub async fn create_channel( &self, name: &str, @@ -97,6 +98,7 @@ impl Database { .await } + /// Adds a user to the specified channel. pub async fn join_channel( &self, channel_id: ChannelId, @@ -179,6 +181,7 @@ impl Database { .await } + /// Sets the visibiltity of the given channel. pub async fn set_channel_visibility( &self, channel_id: ChannelId, @@ -258,6 +261,7 @@ impl Database { .await } + /// Deletes the channel with the specified ID. pub async fn delete_channel( &self, channel_id: ChannelId, @@ -294,6 +298,7 @@ impl Database { .await } + /// Invites a user to a channel as a member. pub async fn invite_channel_member( &self, channel_id: ChannelId, @@ -349,6 +354,7 @@ impl Database { Ok(new_name) } + /// Renames the specified channel. pub async fn rename_channel( &self, channel_id: ChannelId, @@ -387,6 +393,7 @@ impl Database { .await } + /// accept or decline an invite to join a channel pub async fn respond_to_channel_invite( &self, channel_id: ChannelId, @@ -486,6 +493,7 @@ impl Database { }) } + /// Removes a channel member. pub async fn remove_channel_member( &self, channel_id: ChannelId, @@ -530,6 +538,7 @@ impl Database { .await } + /// Returns all channel invites for the user with the given ID. pub async fn get_channel_invites_for_user(&self, user_id: UserId) -> Result> { self.transaction(|tx| async move { let mut role_for_channel: HashMap = HashMap::default(); @@ -565,6 +574,7 @@ impl Database { .await } + /// Returns all channels for the user with the given ID. pub async fn get_channels_for_user(&self, user_id: UserId) -> Result { self.transaction(|tx| async move { let tx = tx; @@ -574,6 +584,8 @@ impl Database { .await } + /// Returns all channels for the user with the given ID that are descendants + /// of the specified ancestor channel. pub async fn get_user_channels( &self, user_id: UserId, @@ -743,6 +755,7 @@ impl Database { Ok(results) } + /// Sets the role for the specified channel member. pub async fn set_channel_member_role( &self, channel_id: ChannelId, @@ -786,6 +799,7 @@ impl Database { .await } + /// Returns the details for the specified channel member. pub async fn get_channel_participant_details( &self, channel_id: ChannelId, @@ -911,6 +925,7 @@ impl Database { .collect()) } + /// Returns the participants in the given channel. pub async fn get_channel_participants( &self, channel: &channel::Model, @@ -925,6 +940,7 @@ impl Database { .collect()) } + /// Returns whether the given user is an admin in the specified channel. pub async fn check_user_is_channel_admin( &self, channel: &channel::Model, @@ -943,6 +959,7 @@ impl Database { } } + /// Returns whether the given user is a member of the specified channel. pub async fn check_user_is_channel_member( &self, channel: &channel::Model, @@ -958,6 +975,7 @@ impl Database { } } + /// Returns whether the given user is a participant in the specified channel. pub async fn check_user_is_channel_participant( &self, channel: &channel::Model, @@ -975,6 +993,7 @@ impl Database { } } + /// Returns a user's pending invite for the given channel, if one exists. pub async fn pending_invite_for_channel( &self, channel: &channel::Model, @@ -991,7 +1010,7 @@ impl Database { Ok(row) } - pub async fn public_parent_channel( + async fn public_parent_channel( &self, channel: &channel::Model, tx: &DatabaseTransaction, @@ -1003,7 +1022,7 @@ impl Database { Ok(path.pop()) } - pub async fn public_ancestors_including_self( + pub(crate) async fn public_ancestors_including_self( &self, channel: &channel::Model, tx: &DatabaseTransaction, @@ -1018,6 +1037,7 @@ impl Database { Ok(visible_channels) } + /// Returns the role for a user in the given channel. pub async fn channel_role_for_user( &self, channel: &channel::Model, @@ -1143,7 +1163,7 @@ impl Database { .await?) } - /// Returns the channel with the given ID + /// Returns the channel with the given ID. pub async fn get_channel(&self, channel_id: ChannelId, user_id: UserId) -> Result { self.transaction(|tx| async move { let channel = self.get_channel_internal(channel_id, &*tx).await?; @@ -1156,7 +1176,7 @@ impl Database { .await } - pub async fn get_channel_internal( + pub(crate) async fn get_channel_internal( &self, channel_id: ChannelId, tx: &DatabaseTransaction, @@ -1180,7 +1200,7 @@ impl Database { .await?; let room_id = if let Some(room) = room { - if let Some(env) = room.enviroment { + if let Some(env) = room.environment { if &env != environment { Err(anyhow!("must join using the {} release", env))?; } @@ -1190,7 +1210,7 @@ impl Database { let result = room::Entity::insert(room::ActiveModel { channel_id: ActiveValue::Set(Some(channel_id)), live_kit_room: ActiveValue::Set(live_kit_room.to_string()), - enviroment: ActiveValue::Set(Some(environment.to_string())), + environment: ActiveValue::Set(Some(environment.to_string())), ..Default::default() }) .exec(&*tx) diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index f31f1addbd..c66c33b80d 100644 --- a/crates/collab/src/db/queries/contacts.rs +++ b/crates/collab/src/db/queries/contacts.rs @@ -1,6 +1,7 @@ use super::*; impl Database { + /// Retrieves the contacts for the user with the given ID. pub async fn get_contacts(&self, user_id: UserId) -> Result> { #[derive(Debug, FromQueryResult)] struct ContactWithUserBusyStatuses { @@ -86,6 +87,7 @@ impl Database { .await } + /// Returns whether the given user is a busy (on a call). pub async fn is_user_busy(&self, user_id: UserId) -> Result { self.transaction(|tx| async move { let participant = room_participant::Entity::find() @@ -97,6 +99,9 @@ impl Database { .await } + /// Returns whether the user with `user_id_1` has the user with `user_id_2` as a contact. + /// + /// In order for this to return `true`, `user_id_2` must have an accepted invite from `user_id_1`. pub async fn has_contact(&self, user_id_1: UserId, user_id_2: UserId) -> Result { self.transaction(|tx| async move { let (id_a, id_b) = if user_id_1 < user_id_2 { @@ -119,6 +124,7 @@ impl Database { .await } + /// Invite the user with `receiver_id` to be a contact of the user with `sender_id`. pub async fn send_contact_request( &self, sender_id: UserId, @@ -231,6 +237,7 @@ impl Database { .await } + /// Dismisses a contact notification for the given user. pub async fn dismiss_contact_notification( &self, user_id: UserId, @@ -272,6 +279,7 @@ impl Database { .await } + /// Accept or decline a contact request pub async fn respond_to_contact_request( &self, responder_id: UserId, diff --git a/crates/collab/src/db/queries/messages.rs b/crates/collab/src/db/queries/messages.rs index 47bb27df39..9ee313d91b 100644 --- a/crates/collab/src/db/queries/messages.rs +++ b/crates/collab/src/db/queries/messages.rs @@ -4,6 +4,7 @@ use sea_orm::TryInsertResult; use time::OffsetDateTime; impl Database { + /// Inserts a record representing a user joining the chat for a given channel. pub async fn join_channel_chat( &self, channel_id: ChannelId, @@ -28,6 +29,7 @@ impl Database { .await } + /// Removes `channel_chat_participant` records associated with the given connection ID. pub async fn channel_chat_connection_lost( &self, connection_id: ConnectionId, @@ -47,6 +49,8 @@ impl Database { Ok(()) } + /// Removes `channel_chat_participant` records associated with the given user ID so they + /// will no longer get chat notifications. pub async fn leave_channel_chat( &self, channel_id: ChannelId, @@ -72,6 +76,9 @@ impl Database { .await } + /// Retrieves the messages in the specified channel. + /// + /// Use `before_message_id` to paginate through the channel's messages. pub async fn get_channel_messages( &self, channel_id: ChannelId, @@ -103,6 +110,7 @@ impl Database { .await } + /// Returns the channel messages with the given IDs. pub async fn get_channel_messages_by_id( &self, user_id: UserId, @@ -190,6 +198,7 @@ impl Database { Ok(messages) } + /// Creates a new channel message. pub async fn create_channel_message( &self, channel_id: ChannelId, @@ -256,6 +265,7 @@ impl Database { message_id = result.last_insert_id; let mentioned_user_ids = mentions.iter().map(|m| m.user_id).collect::>(); + let mentions = mentions .iter() .filter_map(|mention| { @@ -375,6 +385,7 @@ impl Database { Ok(()) } + /// Returns the unseen messages for the given user in the specified channels. pub async fn unseen_channel_messages( &self, user_id: UserId, @@ -448,6 +459,7 @@ impl Database { Ok(changes) } + /// Removes the channel message with the given ID. pub async fn remove_channel_message( &self, channel_id: ChannelId, diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index 6f2511c23e..57685e141b 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -2,6 +2,7 @@ use super::*; use rpc::Notification; impl Database { + /// Initializes the different kinds of notifications by upserting records for them. pub async fn initialize_notification_kinds(&mut self) -> Result<()> { notification_kind::Entity::insert_many(Notification::all_variant_names().iter().map( |kind| notification_kind::ActiveModel { @@ -28,6 +29,7 @@ impl Database { Ok(()) } + /// Returns the notifications for the given recipient. pub async fn get_notifications( &self, recipient_id: UserId, @@ -140,6 +142,7 @@ impl Database { .await } + /// Marks the given notification as read. pub async fn mark_notification_as_read( &self, recipient_id: UserId, @@ -150,6 +153,7 @@ impl Database { .await } + /// Marks the notification with the given ID as read. pub async fn mark_notification_as_read_by_id( &self, recipient_id: UserId, diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 6e1bf16309..f81403a796 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -1,6 +1,7 @@ use super::*; impl Database { + /// Returns the count of all projects, excluding ones marked as admin. pub async fn project_count_excluding_admins(&self) -> Result { #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] enum QueryAs { @@ -21,6 +22,7 @@ impl Database { .await } + /// Shares a project with the given room. pub async fn share_project( &self, room_id: RoomId, @@ -49,7 +51,7 @@ impl Database { if !participant .role .unwrap_or(ChannelRole::Member) - .can_share_projects() + .can_publish_to_rooms() { return Err(anyhow!("guests cannot share projects"))?; } @@ -100,6 +102,7 @@ impl Database { .await } + /// Unshares the given project. pub async fn unshare_project( &self, project_id: ProjectId, @@ -126,6 +129,7 @@ impl Database { .await } + /// Updates the worktrees associated with the given project. pub async fn update_project( &self, project_id: ProjectId, @@ -346,6 +350,7 @@ impl Database { .await } + /// Updates the diagnostic summary for the given connection. pub async fn update_diagnostic_summary( &self, update: &proto::UpdateDiagnosticSummary, @@ -401,6 +406,7 @@ impl Database { .await } + /// Starts the language server for the given connection. pub async fn start_language_server( &self, update: &proto::StartLanguageServer, @@ -447,6 +453,7 @@ impl Database { .await } + /// Updates the worktree settings for the given connection. pub async fn update_worktree_settings( &self, update: &proto::UpdateWorktreeSettings, @@ -499,6 +506,7 @@ impl Database { .await } + /// Adds the given connection to the specified project. pub async fn join_project( &self, project_id: ProjectId, @@ -704,6 +712,7 @@ impl Database { .await } + /// Removes the given connection from the specified project. pub async fn leave_project( &self, project_id: ProjectId, @@ -805,6 +814,7 @@ impl Database { .map(|guard| guard.into_inner()) } + /// Returns the host connection for a read-only request to join a shared project. pub async fn host_for_read_only_project_request( &self, project_id: ProjectId, @@ -842,6 +852,7 @@ impl Database { .map(|guard| guard.into_inner()) } + /// Returns the host connection for a request to join a shared project. pub async fn host_for_mutating_project_request( &self, project_id: ProjectId, @@ -883,6 +894,7 @@ impl Database { &self, project_id: ProjectId, connection_id: ConnectionId, + requires_write: bool, ) -> Result>> { let room_id = self.room_id_for_project(project_id).await?; self.room_transaction(room_id, |tx| async move { @@ -893,9 +905,10 @@ impl Database { .await? .ok_or_else(|| anyhow!("no such room"))?; - if !current_participant - .role - .map_or(false, |role| role.can_edit_projects()) + if requires_write + && !current_participant + .role + .map_or(false, |role| role.can_edit_projects()) { Err(anyhow!("not authorized to edit projects"))?; } @@ -925,6 +938,10 @@ impl Database { .await } + /// Returns the connection IDs in the given project. + /// + /// The provided `connection_id` must also be a collaborator in the project, + /// otherwise an error will be returned. pub async fn project_connection_ids( &self, project_id: ProjectId, @@ -974,6 +991,7 @@ impl Database { Ok(guest_connection_ids) } + /// Returns the [`RoomId`] for the given project. pub async fn room_id_for_project(&self, project_id: ProjectId) -> Result { self.transaction(|tx| async move { let project = project::Entity::find_by_id(project_id) @@ -1018,6 +1036,7 @@ impl Database { .await } + /// Adds the given follower connection as a follower of the given leader connection. pub async fn follow( &self, room_id: RoomId, @@ -1048,6 +1067,7 @@ impl Database { .await } + /// Removes the given follower connection as a follower of the given leader connection. pub async fn unfollow( &self, room_id: RoomId, diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 9a87f91b81..7434e2d20d 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -1,6 +1,7 @@ use super::*; impl Database { + /// Clears all room participants in rooms attached to a stale server. pub async fn clear_stale_room_participants( &self, room_id: RoomId, @@ -78,6 +79,7 @@ impl Database { .await } + /// Returns the incoming calls for user with the given ID. pub async fn incoming_call_for_user( &self, user_id: UserId, @@ -102,6 +104,7 @@ impl Database { .await } + /// Creates a new room. pub async fn create_room( &self, user_id: UserId, @@ -112,7 +115,7 @@ impl Database { self.transaction(|tx| async move { let room = room::ActiveModel { live_kit_room: ActiveValue::set(live_kit_room.into()), - enviroment: ActiveValue::set(Some(release_channel.to_string())), + environment: ActiveValue::set(Some(release_channel.to_string())), ..Default::default() } .insert(&*tx) @@ -299,28 +302,28 @@ impl Database { room_id: RoomId, user_id: UserId, connection: ConnectionId, - enviroment: &str, + environment: &str, ) -> Result> { self.room_transaction(room_id, |tx| async move { #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] - enum QueryChannelIdAndEnviroment { + enum QueryChannelIdAndEnvironment { ChannelId, - Enviroment, + Environment, } let (channel_id, release_channel): (Option, Option) = room::Entity::find() .select_only() .column(room::Column::ChannelId) - .column(room::Column::Enviroment) + .column(room::Column::Environment) .filter(room::Column::Id.eq(room_id)) - .into_values::<_, QueryChannelIdAndEnviroment>() + .into_values::<_, QueryChannelIdAndEnvironment>() .one(&*tx) .await? .ok_or_else(|| anyhow!("no such room"))?; if let Some(release_channel) = release_channel { - if &release_channel != enviroment { + if &release_channel != environment { Err(anyhow!("must join using the {} release", release_channel))?; } } @@ -394,6 +397,7 @@ impl Database { Ok(participant_index) } + /// Returns the channel ID for the given room, if it has one. pub async fn channel_id_for_room(&self, room_id: RoomId) -> Result> { self.transaction(|tx| async move { let room: Option = room::Entity::find() @@ -944,6 +948,7 @@ impl Database { .await } + /// Updates the location of a participant in the given room. pub async fn update_room_participant_location( &self, room_id: RoomId, @@ -1004,6 +1009,47 @@ impl Database { .await } + /// Sets the role of a participant in the given room. + pub async fn set_room_participant_role( + &self, + admin_id: UserId, + room_id: RoomId, + user_id: UserId, + role: ChannelRole, + ) -> Result> { + self.room_transaction(room_id, |tx| async move { + room_participant::Entity::find() + .filter( + Condition::all() + .add(room_participant::Column::RoomId.eq(room_id)) + .add(room_participant::Column::UserId.eq(admin_id)) + .add(room_participant::Column::Role.eq(ChannelRole::Admin)), + ) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("only admins can set participant role"))?; + + let result = room_participant::Entity::update_many() + .filter( + Condition::all() + .add(room_participant::Column::RoomId.eq(room_id)) + .add(room_participant::Column::UserId.eq(user_id)), + ) + .set(room_participant::ActiveModel { + role: ActiveValue::set(Some(ChannelRole::from(role))), + ..Default::default() + }) + .exec(&*tx) + .await?; + + if result.rows_affected != 1 { + Err(anyhow!("could not update room participant role"))?; + } + Ok(self.get_room(room_id, &tx).await?) + }) + .await + } + pub async fn connection_lost(&self, connection: ConnectionId) -> Result<()> { self.transaction(|tx| async move { self.room_connection_lost(connection, &*tx).await?; diff --git a/crates/collab/src/db/queries/servers.rs b/crates/collab/src/db/queries/servers.rs index e5ceee8887..c79b00eee8 100644 --- a/crates/collab/src/db/queries/servers.rs +++ b/crates/collab/src/db/queries/servers.rs @@ -1,6 +1,7 @@ use super::*; impl Database { + /// Creates a new server in the given environment. pub async fn create_server(&self, environment: &str) -> Result { self.transaction(|tx| async move { let server = server::ActiveModel { @@ -14,6 +15,10 @@ impl Database { .await } + /// Returns the IDs of resources associated with stale servers. + /// + /// A server is stale if it is in the specified `environment` and does not + /// match the provided `new_server_id`. pub async fn stale_server_resource_ids( &self, environment: &str, @@ -61,6 +66,7 @@ impl Database { .await } + /// Deletes any stale servers in the environment that don't match the `new_server_id`. pub async fn delete_stale_servers( &self, environment: &str, diff --git a/crates/collab/src/db/queries/users.rs b/crates/collab/src/db/queries/users.rs index 27e64e2598..8f975b5cbe 100644 --- a/crates/collab/src/db/queries/users.rs +++ b/crates/collab/src/db/queries/users.rs @@ -1,6 +1,7 @@ use super::*; impl Database { + /// Creates a new user. pub async fn create_user( &self, email_address: &str, @@ -19,7 +20,11 @@ impl Database { }) .on_conflict( OnConflict::column(user::Column::GithubLogin) - .update_column(user::Column::GithubLogin) + .update_columns([ + user::Column::Admin, + user::Column::EmailAddress, + user::Column::GithubUserId, + ]) .to_owned(), ) .exec_with_returning(&*tx) @@ -35,11 +40,13 @@ impl Database { .await } + /// Returns a user by ID. There are no access checks here, so this should only be used internally. pub async fn get_user_by_id(&self, id: UserId) -> Result> { self.transaction(|tx| async move { Ok(user::Entity::find_by_id(id).one(&*tx).await?) }) .await } + /// Returns all users by ID. There are no access checks here, so this should only be used internally. pub async fn get_users_by_ids(&self, ids: Vec) -> Result> { self.transaction(|tx| async { let tx = tx; @@ -51,6 +58,7 @@ impl Database { .await } + /// Returns a user by GitHub login. There are no access checks here, so this should only be used internally. pub async fn get_user_by_github_login(&self, github_login: &str) -> Result> { self.transaction(|tx| async move { Ok(user::Entity::find() @@ -111,6 +119,8 @@ impl Database { .await } + /// get_all_users returns the next page of users. To get more call again with + /// the same limit and the page incremented by 1. pub async fn get_all_users(&self, page: u32, limit: u32) -> Result> { self.transaction(|tx| async move { Ok(user::Entity::find() @@ -123,6 +133,7 @@ impl Database { .await } + /// Returns the metrics id for the user. pub async fn get_user_metrics_id(&self, id: UserId) -> Result { #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] enum QueryAs { @@ -142,6 +153,7 @@ impl Database { .await } + /// Set "connected_once" on the user for analytics. pub async fn set_user_connected_once(&self, id: UserId, connected_once: bool) -> Result<()> { self.transaction(|tx| async move { user::Entity::update_many() @@ -157,6 +169,7 @@ impl Database { .await } + /// hard delete the user. pub async fn destroy_user(&self, id: UserId) -> Result<()> { self.transaction(|tx| async move { access_token::Entity::delete_many() @@ -169,6 +182,7 @@ impl Database { .await } + /// Find users where github_login ILIKE name_query. pub async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result> { self.transaction(|tx| async { let tx = tx; @@ -193,6 +207,8 @@ impl Database { .await } + /// fuzzy_like_string creates a string for matching in-order using fuzzy_search_users. + /// e.g. "cir" would become "%c%i%r%" pub fn fuzzy_like_string(string: &str) -> String { let mut result = String::with_capacity(string.len() * 2 + 1); for c in string.chars() { @@ -205,6 +221,7 @@ impl Database { result } + /// Creates a new feature flag. pub async fn create_user_flag(&self, flag: &str) -> Result { self.transaction(|tx| async move { let flag = feature_flag::Entity::insert(feature_flag::ActiveModel { @@ -220,6 +237,7 @@ impl Database { .await } + /// Add the given user to the feature flag pub async fn add_user_flag(&self, user: UserId, flag: FlagId) -> Result<()> { self.transaction(|tx| async move { user_feature::Entity::insert(user_feature::ActiveModel { @@ -234,6 +252,7 @@ impl Database { .await } + /// Return the active flags for the user. pub async fn get_user_flags(&self, user: UserId) -> Result> { self.transaction(|tx| async move { #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] diff --git a/crates/collab/src/db/tables/access_token.rs b/crates/collab/src/db/tables/access_token.rs index da7392b98c..22635fb64d 100644 --- a/crates/collab/src/db/tables/access_token.rs +++ b/crates/collab/src/db/tables/access_token.rs @@ -7,6 +7,7 @@ pub struct Model { #[sea_orm(primary_key)] pub id: AccessTokenId, pub user_id: UserId, + pub impersonated_user_id: Option, pub hash: String, } diff --git a/crates/collab/src/db/tables/room.rs b/crates/collab/src/db/tables/room.rs index 4150c741ac..f75a079317 100644 --- a/crates/collab/src/db/tables/room.rs +++ b/crates/collab/src/db/tables/room.rs @@ -8,7 +8,7 @@ pub struct Model { pub id: RoomId, pub live_kit_room: String, pub channel_id: Option, - pub enviroment: Option, + pub environment: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/src/db/tables/user.rs b/crates/collab/src/db/tables/user.rs index 739693527f..53866b5c54 100644 --- a/crates/collab/src/db/tables/user.rs +++ b/crates/collab/src/db/tables/user.rs @@ -2,6 +2,7 @@ use crate::db::UserId; use sea_orm::entity::prelude::*; use serde::Serialize; +/// A user model. #[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)] #[sea_orm(table_name = "users")] pub struct Model { diff --git a/crates/collab/src/db/tests/db_tests.rs b/crates/collab/src/db/tests/db_tests.rs index 5332f227ef..3e1bdede71 100644 --- a/crates/collab/src/db/tests/db_tests.rs +++ b/crates/collab/src/db/tests/db_tests.rs @@ -146,7 +146,7 @@ test_both_dbs!( ); async fn test_create_access_tokens(db: &Arc) { - let user = db + let user_1 = db .create_user( "u1@example.com", false, @@ -158,14 +158,27 @@ async fn test_create_access_tokens(db: &Arc) { .await .unwrap() .user_id; + let user_2 = db + .create_user( + "u2@example.com", + false, + NewUserParams { + github_login: "u2".into(), + github_user_id: 2, + }, + ) + .await + .unwrap() + .user_id; - let token_1 = db.create_access_token(user, "h1", 2).await.unwrap(); - let token_2 = db.create_access_token(user, "h2", 2).await.unwrap(); + let token_1 = db.create_access_token(user_1, None, "h1", 2).await.unwrap(); + let token_2 = db.create_access_token(user_1, None, "h2", 2).await.unwrap(); assert_eq!( db.get_access_token(token_1).await.unwrap(), access_token::Model { id: token_1, - user_id: user, + user_id: user_1, + impersonated_user_id: None, hash: "h1".into(), } ); @@ -173,17 +186,19 @@ async fn test_create_access_tokens(db: &Arc) { db.get_access_token(token_2).await.unwrap(), access_token::Model { id: token_2, - user_id: user, + user_id: user_1, + impersonated_user_id: None, hash: "h2".into() } ); - let token_3 = db.create_access_token(user, "h3", 2).await.unwrap(); + let token_3 = db.create_access_token(user_1, None, "h3", 2).await.unwrap(); assert_eq!( db.get_access_token(token_3).await.unwrap(), access_token::Model { id: token_3, - user_id: user, + user_id: user_1, + impersonated_user_id: None, hash: "h3".into() } ); @@ -191,18 +206,20 @@ async fn test_create_access_tokens(db: &Arc) { db.get_access_token(token_2).await.unwrap(), access_token::Model { id: token_2, - user_id: user, + user_id: user_1, + impersonated_user_id: None, hash: "h2".into() } ); assert!(db.get_access_token(token_1).await.is_err()); - let token_4 = db.create_access_token(user, "h4", 2).await.unwrap(); + let token_4 = db.create_access_token(user_1, None, "h4", 2).await.unwrap(); assert_eq!( db.get_access_token(token_4).await.unwrap(), access_token::Model { id: token_4, - user_id: user, + user_id: user_1, + impersonated_user_id: None, hash: "h4".into() } ); @@ -210,12 +227,77 @@ async fn test_create_access_tokens(db: &Arc) { db.get_access_token(token_3).await.unwrap(), access_token::Model { id: token_3, - user_id: user, + user_id: user_1, + impersonated_user_id: None, hash: "h3".into() } ); assert!(db.get_access_token(token_2).await.is_err()); assert!(db.get_access_token(token_1).await.is_err()); + + // An access token for user 2 impersonating user 1 does not + // count against user 1's access token limit (of 2). + let token_5 = db + .create_access_token(user_2, Some(user_1), "h5", 2) + .await + .unwrap(); + assert_eq!( + db.get_access_token(token_5).await.unwrap(), + access_token::Model { + id: token_5, + user_id: user_2, + impersonated_user_id: Some(user_1), + hash: "h5".into() + } + ); + assert_eq!( + db.get_access_token(token_3).await.unwrap(), + access_token::Model { + id: token_3, + user_id: user_1, + impersonated_user_id: None, + hash: "h3".into() + } + ); + + // Only a limited number (2) of access tokens are stored for user 2 + // impersonating other users. + let token_6 = db + .create_access_token(user_2, Some(user_1), "h6", 2) + .await + .unwrap(); + let token_7 = db + .create_access_token(user_2, Some(user_1), "h7", 2) + .await + .unwrap(); + assert_eq!( + db.get_access_token(token_6).await.unwrap(), + access_token::Model { + id: token_6, + user_id: user_2, + impersonated_user_id: Some(user_1), + hash: "h6".into() + } + ); + assert_eq!( + db.get_access_token(token_7).await.unwrap(), + access_token::Model { + id: token_7, + user_id: user_2, + impersonated_user_id: Some(user_1), + hash: "h7".into() + } + ); + assert!(db.get_access_token(token_5).await.is_err()); + assert_eq!( + db.get_access_token(token_3).await.unwrap(), + access_token::Model { + id: token_3, + user_id: user_1, + impersonated_user_id: None, + hash: "h3".into() + } + ); } test_both_dbs!( diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 7dbf4513d9..aba9bd75d1 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -103,6 +103,12 @@ pub struct Config { pub zed_environment: Arc, } +impl Config { + pub fn is_development(&self) -> bool { + self.zed_environment == "development".into() + } +} + #[derive(Default, Deserialize)] pub struct MigrateConfig { pub database_url: String, diff --git a/crates/collab/src/main.rs b/crates/collab/src/main.rs index 6fbb451fee..87df7cac6f 100644 --- a/crates/collab/src/main.rs +++ b/crates/collab/src/main.rs @@ -53,6 +53,25 @@ async fn main() -> Result<()> { let config = envy::from_env::().expect("error loading config"); init_tracing(&config); + if config.is_development() { + // sanity check database url so even if we deploy a busted ZED_ENVIRONMENT to production + // we do not run + if config.database_url != "postgres://postgres@localhost/zed" { + panic!("about to run development migrations on a non-development database?") + } + let migrations_path = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations")); + let db_options = db::ConnectOptions::new(config.database_url.clone()); + let db = Database::new(db_options, Executor::Production).await?; + + let migrations = db.migrate(&migrations_path, false).await?; + for (migration, duration) in migrations { + println!( + "Ran {} {} {:?}", + migration.version, migration.description, duration + ); + } + } + let state = AppState::new(config).await?; let listener = TcpListener::bind(&format!("0.0.0.0:{}", state.config.http_port)) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 5301ca9a23..4e9807acfb 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -1,7 +1,7 @@ mod connection_pool; use crate::{ - auth, + auth::{self, Impersonator}, db::{ self, BufferId, ChannelId, ChannelRole, ChannelsForUser, CreateChannelResult, CreatedChannelMessage, Database, InviteMemberResult, MembershipUpdated, MessageId, @@ -65,7 +65,7 @@ use std::{ use time::OffsetDateTime; use tokio::sync::{watch, Semaphore}; use tower::ServiceBuilder; -use tracing::{info_span, instrument, Instrument}; +use tracing::{field, info_span, instrument, Instrument}; pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30); pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10); @@ -202,6 +202,7 @@ impl Server { .add_request_handler(join_room) .add_request_handler(rejoin_room) .add_request_handler(leave_room) + .add_request_handler(set_room_participant_role) .add_request_handler(call) .add_request_handler(cancel_call) .add_message_handler(decline_call) @@ -560,13 +561,17 @@ impl Server { connection: Connection, address: String, user: User, + impersonator: Option, mut send_connection_id: Option>, executor: Executor, ) -> impl Future> { let this = self.clone(); let user_id = user.id; let login = user.github_login; - let span = info_span!("handle connection", %user_id, %login, %address); + let span = info_span!("handle connection", %user_id, %login, %address, impersonator = field::Empty); + if let Some(impersonator) = impersonator { + span.record("impersonator", &impersonator.github_login); + } let mut teardown = self.teardown.subscribe(); async move { let (connection_id, handle_io, mut incoming_rx) = this @@ -838,6 +843,7 @@ pub async fn handle_websocket_request( ConnectInfo(socket_address): ConnectInfo, Extension(server): Extension>, Extension(user): Extension, + Extension(impersonator): Extension, ws: WebSocketUpgrade, ) -> axum::response::Response { if protocol_version != rpc::PROTOCOL_VERSION { @@ -857,7 +863,14 @@ pub async fn handle_websocket_request( let connection = Connection::new(Box::pin(socket)); async move { server - .handle_connection(connection, socket_address, user, None, Executor::Production) + .handle_connection( + connection, + socket_address, + user, + impersonator.0, + None, + Executor::Production, + ) .await .log_err(); } @@ -931,11 +944,13 @@ async fn connection_lost( Ok(()) } +/// Acknowledges a ping from a client, used to keep the connection alive. async fn ping(_: proto::Ping, response: Response, _session: Session) -> Result<()> { response.send(proto::Ack {})?; Ok(()) } +/// Create a new room for calling (outside of channels) async fn create_room( _request: proto::CreateRoom, response: Response, @@ -983,6 +998,7 @@ async fn create_room( Ok(()) } +/// Join a room from an invitation. Equivalent to joining a channel if there is one. async fn join_room( request: proto::JoinRoom, response: Response, @@ -1057,6 +1073,7 @@ async fn join_room( Ok(()) } +/// Rejoin room is used to reconnect to a room after connection errors. async fn rejoin_room( request: proto::RejoinRoom, response: Response, @@ -1248,6 +1265,7 @@ async fn rejoin_room( Ok(()) } +/// leave room disonnects from the room. async fn leave_room( _: proto::LeaveRoom, response: Response, @@ -1258,6 +1276,52 @@ async fn leave_room( Ok(()) } +/// Update the permissions of someone else in the room. +async fn set_room_participant_role( + request: proto::SetRoomParticipantRole, + response: Response, + session: Session, +) -> Result<()> { + let (live_kit_room, can_publish) = { + let room = session + .db() + .await + .set_room_participant_role( + session.user_id, + RoomId::from_proto(request.room_id), + UserId::from_proto(request.user_id), + ChannelRole::from(request.role()), + ) + .await?; + + let live_kit_room = room.live_kit_room.clone(); + let can_publish = ChannelRole::from(request.role()).can_publish_to_rooms(); + room_updated(&room, &session.peer); + (live_kit_room, can_publish) + }; + + if let Some(live_kit) = session.live_kit_client.as_ref() { + live_kit + .update_participant( + live_kit_room.clone(), + request.user_id.to_string(), + live_kit_server::proto::ParticipantPermission { + can_subscribe: true, + can_publish, + can_publish_data: can_publish, + hidden: false, + recorder: false, + }, + ) + .await + .trace_err(); + } + + response.send(proto::Ack {})?; + Ok(()) +} + +/// Call someone else into the current room async fn call( request: proto::Call, response: Response, @@ -1326,6 +1390,7 @@ async fn call( Err(anyhow!("failed to ring user"))? } +/// Cancel an outgoing call. async fn cancel_call( request: proto::CancelCall, response: Response, @@ -1363,6 +1428,7 @@ async fn cancel_call( Ok(()) } +/// Decline an incoming call. async fn decline_call(message: proto::DeclineCall, session: Session) -> Result<()> { let room_id = RoomId::from_proto(message.room_id); { @@ -1394,6 +1460,7 @@ async fn decline_call(message: proto::DeclineCall, session: Session) -> Result<( Ok(()) } +/// Update other participants in the room with your current location. async fn update_participant_location( request: proto::UpdateParticipantLocation, response: Response, @@ -1414,6 +1481,7 @@ async fn update_participant_location( Ok(()) } +/// Share a project into the room. async fn share_project( request: proto::ShareProject, response: Response, @@ -1436,6 +1504,7 @@ async fn share_project( Ok(()) } +/// Unshare a project from the room. async fn unshare_project(message: proto::UnshareProject, session: Session) -> Result<()> { let project_id = ProjectId::from_proto(message.project_id); @@ -1455,6 +1524,7 @@ async fn unshare_project(message: proto::UnshareProject, session: Session) -> Re Ok(()) } +/// Join someone elses shared project. async fn join_project( request: proto::JoinProject, response: Response, @@ -1580,6 +1650,7 @@ async fn join_project( Ok(()) } +/// Leave someone elses shared project. async fn leave_project(request: proto::LeaveProject, session: Session) -> Result<()> { let sender_id = session.connection_id; let project_id = ProjectId::from_proto(request.project_id); @@ -1602,6 +1673,7 @@ async fn leave_project(request: proto::LeaveProject, session: Session) -> Result Ok(()) } +/// Update other participants with changes to the project async fn update_project( request: proto::UpdateProject, response: Response, @@ -1628,6 +1700,7 @@ async fn update_project( Ok(()) } +/// Update other participants with changes to the worktree async fn update_worktree( request: proto::UpdateWorktree, response: Response, @@ -1652,6 +1725,7 @@ async fn update_worktree( Ok(()) } +/// Update other participants with changes to the diagnostics async fn update_diagnostic_summary( message: proto::UpdateDiagnosticSummary, session: Session, @@ -1675,6 +1749,7 @@ async fn update_diagnostic_summary( Ok(()) } +/// Update other participants with changes to the worktree settings async fn update_worktree_settings( message: proto::UpdateWorktreeSettings, session: Session, @@ -1698,6 +1773,7 @@ async fn update_worktree_settings( Ok(()) } +/// Notify other participants that a language server has started. async fn start_language_server( request: proto::StartLanguageServer, session: Session, @@ -1720,6 +1796,7 @@ async fn start_language_server( Ok(()) } +/// Notify other participants that a language server has changed. async fn update_language_server( request: proto::UpdateLanguageServer, session: Session, @@ -1742,6 +1819,8 @@ async fn update_language_server( Ok(()) } +/// forward a project request to the host. These requests should be read only +/// as guests are allowed to send them. async fn forward_read_only_project_request( request: T, response: Response, @@ -1764,6 +1843,8 @@ where Ok(()) } +/// forward a project request to the host. These requests are disallowed +/// for guests. async fn forward_mutating_project_request( request: T, response: Response, @@ -1786,6 +1867,7 @@ where Ok(()) } +/// Notify other participants that a new buffer has been created async fn create_buffer_for_peer( request: proto::CreateBufferForPeer, session: Session, @@ -1805,6 +1887,8 @@ async fn create_buffer_for_peer( Ok(()) } +/// Notify other participants that a buffer has been updated. This is +/// allowed for guests as long as the update is limited to selections. async fn update_buffer( request: proto::UpdateBuffer, response: Response, @@ -1814,11 +1898,24 @@ async fn update_buffer( let mut guest_connection_ids; let mut host_connection_id = None; + let mut requires_write_permission = false; + + for op in request.operations.iter() { + match op.variant { + None | Some(proto::operation::Variant::UpdateSelections(_)) => {} + Some(_) => requires_write_permission = true, + } + } + { let collaborators = session .db() .await - .project_collaborators_for_buffer_update(project_id, session.connection_id) + .project_collaborators_for_buffer_update( + project_id, + session.connection_id, + requires_write_permission, + ) .await?; guest_connection_ids = Vec::with_capacity(collaborators.len() - 1); for collaborator in collaborators.iter() { @@ -1851,6 +1948,7 @@ async fn update_buffer( Ok(()) } +/// Notify other participants that a project has been updated. async fn broadcast_project_message_from_host>( request: T, session: Session, @@ -1874,6 +1972,7 @@ async fn broadcast_project_message_from_host, @@ -1911,6 +2010,7 @@ async fn follow( Ok(()) } +/// Stop following another user in a call. async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> { let room_id = RoomId::from_proto(request.room_id); let project_id = request.project_id.map(ProjectId::from_proto); @@ -1942,6 +2042,7 @@ async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> { Ok(()) } +/// Notify everyone following you of your current location. async fn update_followers(request: proto::UpdateFollowers, session: Session) -> Result<()> { let room_id = RoomId::from_proto(request.room_id); let database = session.db.lock().await; @@ -1978,6 +2079,7 @@ async fn update_followers(request: proto::UpdateFollowers, session: Session) -> Ok(()) } +/// Get public data about users. async fn get_users( request: proto::GetUsers, response: Response, @@ -2004,6 +2106,7 @@ async fn get_users( Ok(()) } +/// Search for users (to invite) buy Github login async fn fuzzy_search_users( request: proto::FuzzySearchUsers, response: Response, @@ -2034,6 +2137,7 @@ async fn fuzzy_search_users( Ok(()) } +/// Send a contact request to another user. async fn request_contact( request: proto::RequestContact, response: Response, @@ -2080,6 +2184,7 @@ async fn request_contact( Ok(()) } +/// Accept or decline a contact request async fn respond_to_contact_request( request: proto::RespondToContactRequest, response: Response, @@ -2137,6 +2242,7 @@ async fn respond_to_contact_request( Ok(()) } +/// Remove a contact. async fn remove_contact( request: proto::RemoveContact, response: Response, @@ -2187,6 +2293,7 @@ async fn remove_contact( Ok(()) } +/// Create a new channel. async fn create_channel( request: proto::CreateChannel, response: Response, @@ -2221,6 +2328,7 @@ async fn create_channel( Ok(()) } +/// Delete a channel async fn delete_channel( request: proto::DeleteChannel, response: Response, @@ -2250,6 +2358,7 @@ async fn delete_channel( Ok(()) } +/// Invite someone to join a channel. async fn invite_channel_member( request: proto::InviteChannelMember, response: Response, @@ -2286,6 +2395,7 @@ async fn invite_channel_member( Ok(()) } +/// remove someone from a channel async fn remove_channel_member( request: proto::RemoveChannelMember, response: Response, @@ -2327,6 +2437,7 @@ async fn remove_channel_member( Ok(()) } +/// Toggle the channel between public and private async fn set_channel_visibility( request: proto::SetChannelVisibility, response: Response, @@ -2365,6 +2476,7 @@ async fn set_channel_visibility( Ok(()) } +/// Alter the role for a user in the channel async fn set_channel_member_role( request: proto::SetChannelMemberRole, response: Response, @@ -2412,6 +2524,7 @@ async fn set_channel_member_role( Ok(()) } +/// Change the name of a channel async fn rename_channel( request: proto::RenameChannel, response: Response, @@ -2445,6 +2558,7 @@ async fn rename_channel( Ok(()) } +/// Move a channel to a new parent. async fn move_channel( request: proto::MoveChannel, response: Response, @@ -2497,6 +2611,7 @@ async fn notify_channel_moved(result: Option, session: Sessio Ok(()) } +/// Get the list of channel members async fn get_channel_members( request: proto::GetChannelMembers, response: Response, @@ -2511,6 +2626,7 @@ async fn get_channel_members( Ok(()) } +/// Accept or decline a channel invitation. async fn respond_to_channel_invite( request: proto::RespondToChannelInvite, response: Response, @@ -2551,6 +2667,7 @@ async fn respond_to_channel_invite( Ok(()) } +/// Join the channels' room async fn join_channel( request: proto::JoinChannel, response: Response, @@ -2655,6 +2772,7 @@ async fn join_channel_internal( Ok(()) } +/// Start editing the channel notes async fn join_channel_buffer( request: proto::JoinChannelBuffer, response: Response, @@ -2686,6 +2804,7 @@ async fn join_channel_buffer( Ok(()) } +/// Edit the channel notes async fn update_channel_buffer( request: proto::UpdateChannelBuffer, session: Session, @@ -2732,6 +2851,7 @@ async fn update_channel_buffer( Ok(()) } +/// Rejoin the channel notes after a connection blip async fn rejoin_channel_buffers( request: proto::RejoinChannelBuffers, response: Response, @@ -2766,6 +2886,7 @@ async fn rejoin_channel_buffers( Ok(()) } +/// Stop editing the channel notes async fn leave_channel_buffer( request: proto::LeaveChannelBuffer, response: Response, @@ -2827,6 +2948,7 @@ fn send_notifications( } } +/// Send a message to the channel async fn send_channel_message( request: proto::SendChannelMessage, response: Response, @@ -2915,6 +3037,7 @@ async fn send_channel_message( Ok(()) } +/// Delete a channel message async fn remove_channel_message( request: proto::RemoveChannelMessage, response: Response, @@ -2934,6 +3057,7 @@ async fn remove_channel_message( Ok(()) } +/// Mark a channel message as read async fn acknowledge_channel_message( request: proto::AckChannelMessage, session: Session, @@ -2953,6 +3077,7 @@ async fn acknowledge_channel_message( Ok(()) } +/// Mark a buffer version as synced async fn acknowledge_buffer_version( request: proto::AckBufferOperation, session: Session, @@ -2971,6 +3096,7 @@ async fn acknowledge_buffer_version( Ok(()) } +/// Start receiving chat updates for a channel async fn join_channel_chat( request: proto::JoinChannelChat, response: Response, @@ -2991,6 +3117,7 @@ async fn join_channel_chat( Ok(()) } +/// Stop receiving chat updates for a channel async fn leave_channel_chat(request: proto::LeaveChannelChat, session: Session) -> Result<()> { let channel_id = ChannelId::from_proto(request.channel_id); session @@ -3001,6 +3128,7 @@ async fn leave_channel_chat(request: proto::LeaveChannelChat, session: Session) Ok(()) } +/// Retrieve the chat history for a channel async fn get_channel_messages( request: proto::GetChannelMessages, response: Response, @@ -3024,6 +3152,7 @@ async fn get_channel_messages( Ok(()) } +/// Retrieve specific chat messages async fn get_channel_messages_by_id( request: proto::GetChannelMessagesById, response: Response, @@ -3046,6 +3175,7 @@ async fn get_channel_messages_by_id( Ok(()) } +/// Retrieve the current users notifications async fn get_notifications( request: proto::GetNotifications, response: Response, @@ -3069,6 +3199,7 @@ async fn get_notifications( Ok(()) } +/// Mark notifications as read async fn mark_notification_as_read( request: proto::MarkNotificationRead, response: Response, @@ -3090,6 +3221,7 @@ async fn mark_notification_as_read( Ok(()) } +/// Get the current users information async fn get_private_user_info( _request: proto::GetPrivateUserInfo, response: Response, diff --git a/crates/collab/src/tests/channel_buffer_tests.rs b/crates/collab/src/tests/channel_buffer_tests.rs index 19411ed892..76cc8cb9e1 100644 --- a/crates/collab/src/tests/channel_buffer_tests.rs +++ b/crates/collab/src/tests/channel_buffer_tests.rs @@ -599,152 +599,6 @@ async fn test_channel_buffers_and_server_restarts( }); } -#[gpui::test(iterations = 10)] -async fn test_following_to_channel_notes_without_a_shared_project( - deterministic: BackgroundExecutor, - mut cx_a: &mut TestAppContext, - mut cx_b: &mut TestAppContext, - mut cx_c: &mut TestAppContext, -) { - let mut server = TestServer::start(deterministic.clone()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let client_b = server.create_client(cx_b, "user_b").await; - - let client_c = server.create_client(cx_c, "user_c").await; - - cx_a.update(editor::init); - cx_b.update(editor::init); - cx_c.update(editor::init); - cx_a.update(collab_ui::channel_view::init); - cx_b.update(collab_ui::channel_view::init); - cx_c.update(collab_ui::channel_view::init); - - let channel_1_id = server - .make_channel( - "channel-1", - None, - (&client_a, cx_a), - &mut [(&client_b, cx_b), (&client_c, cx_c)], - ) - .await; - let channel_2_id = server - .make_channel( - "channel-2", - None, - (&client_a, cx_a), - &mut [(&client_b, cx_b), (&client_c, cx_c)], - ) - .await; - - // Clients A, B, and C join a channel. - let active_call_a = cx_a.read(ActiveCall::global); - let active_call_b = cx_b.read(ActiveCall::global); - let active_call_c = cx_c.read(ActiveCall::global); - for (call, cx) in [ - (&active_call_a, &mut cx_a), - (&active_call_b, &mut cx_b), - (&active_call_c, &mut cx_c), - ] { - call.update(*cx, |call, cx| call.join_channel(channel_1_id, cx)) - .await - .unwrap(); - } - deterministic.run_until_parked(); - - // Clients A, B, and C all open their own unshared projects. - client_a.fs().insert_tree("/a", json!({})).await; - client_b.fs().insert_tree("/b", json!({})).await; - client_c.fs().insert_tree("/c", json!({})).await; - let (project_a, _) = client_a.build_local_project("/a", cx_a).await; - let (project_b, _) = client_b.build_local_project("/b", cx_b).await; - let (project_c, _) = client_b.build_local_project("/c", cx_c).await; - let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); - let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); - let (_workspace_c, _cx_c) = client_c.build_workspace(&project_c, cx_c); - - active_call_a - .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) - .await - .unwrap(); - - // Client A opens the notes for channel 1. - let channel_view_1_a = cx_a - .update(|cx| ChannelView::open(channel_1_id, workspace_a.clone(), cx)) - .await - .unwrap(); - channel_view_1_a.update(cx_a, |notes, cx| { - assert_eq!(notes.channel(cx).unwrap().name, "channel-1"); - notes.editor.update(cx, |editor, cx| { - editor.insert("Hello from A.", cx); - editor.change_selections(None, cx, |selections| { - selections.select_ranges(vec![3..4]); - }); - }); - }); - - // Client B follows client A. - workspace_b - .update(cx_b, |workspace, cx| { - workspace - .start_following(client_a.peer_id().unwrap(), cx) - .unwrap() - }) - .await - .unwrap(); - - // Client B is taken to the notes for channel 1, with the same - // text selected as client A. - deterministic.run_until_parked(); - let channel_view_1_b = workspace_b.update(cx_b, |workspace, cx| { - assert_eq!( - workspace.leader_for_pane(workspace.active_pane()), - Some(client_a.peer_id().unwrap()) - ); - workspace - .active_item(cx) - .expect("no active item") - .downcast::() - .expect("active item is not a channel view") - }); - channel_view_1_b.update(cx_b, |notes, cx| { - assert_eq!(notes.channel(cx).unwrap().name, "channel-1"); - let editor = notes.editor.read(cx); - assert_eq!(editor.text(cx), "Hello from A."); - assert_eq!(editor.selections.ranges::(cx), &[3..4]); - }); - - // Client A opens the notes for channel 2. - eprintln!("opening -------------------->"); - - let channel_view_2_a = cx_a - .update(|cx| ChannelView::open(channel_2_id, workspace_a.clone(), cx)) - .await - .unwrap(); - channel_view_2_a.update(cx_a, |notes, cx| { - assert_eq!(notes.channel(cx).unwrap().name, "channel-2"); - }); - - // Client B is taken to the notes for channel 2. - deterministic.run_until_parked(); - - eprintln!("opening <--------------------"); - - let channel_view_2_b = workspace_b.update(cx_b, |workspace, cx| { - assert_eq!( - workspace.leader_for_pane(workspace.active_pane()), - Some(client_a.peer_id().unwrap()) - ); - workspace - .active_item(cx) - .expect("no active item") - .downcast::() - .expect("active item is not a channel view") - }); - channel_view_2_b.update(cx_b, |notes, cx| { - assert_eq!(notes.channel(cx).unwrap().name, "channel-2"); - }); -} - #[gpui::test] async fn test_channel_buffer_changes( deterministic: BackgroundExecutor, diff --git a/crates/collab/src/tests/channel_guest_tests.rs b/crates/collab/src/tests/channel_guest_tests.rs index 32cc074ec9..f3326cd692 100644 --- a/crates/collab/src/tests/channel_guest_tests.rs +++ b/crates/collab/src/tests/channel_guest_tests.rs @@ -1,8 +1,8 @@ use crate::tests::TestServer; use call::ActiveCall; -use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; +use editor::Editor; +use gpui::{BackgroundExecutor, TestAppContext}; use rpc::proto; -use workspace::Workspace; #[gpui::test] async fn test_channel_guests( @@ -13,37 +13,18 @@ async fn test_channel_guests( let mut server = TestServer::start(executor.clone()).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; - - let channel_id = server - .make_channel("the-channel", None, (&client_a, cx_a), &mut []) - .await; - - client_a - .channel_store() - .update(cx_a, |channel_store, cx| { - channel_store.set_channel_visibility(channel_id, proto::ChannelVisibility::Public, cx) - }) - .await - .unwrap(); - - client_a - .fs() - .insert_tree( - "/a", - serde_json::json!({ - "a.txt": "a-contents", - }), - ) - .await; - let active_call_a = cx_a.read(ActiveCall::global); + let channel_id = server + .make_public_channel("the-channel", &client_a, cx_a) + .await; + // Client A shares a project in the channel + let project_a = client_a.build_test_project(cx_a).await; active_call_a .update(cx_a, |call, cx| call.join_channel(channel_id, cx)) .await .unwrap(); - let (project_a, _) = client_a.build_local_project("/a", cx_a).await; let project_id = active_call_a .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) .await @@ -57,38 +38,124 @@ async fn test_channel_guests( // b should be following a in the shared project. // B is a guest, - cx_a.executor().run_until_parked(); + executor.run_until_parked(); - // todo!() the test window does not call activation handlers - // correctly yet, so this API does not work. - // let project_b = active_call_b.read_with(cx_b, |call, _| { - // call.location() - // .unwrap() - // .upgrade() - // .expect("should not be weak") - // }); - - let window_b = cx_b.update(|cx| cx.active_window().unwrap()); - let cx_b = &mut VisualTestContext::from_window(window_b, cx_b); - - let workspace_b = window_b - .downcast::() - .unwrap() - .root_view(cx_b) - .unwrap(); - let project_b = workspace_b.update(cx_b, |workspace, _| workspace.project().clone()); + let active_call_b = cx_b.read(ActiveCall::global); + let project_b = + active_call_b.read_with(cx_b, |call, _| call.location().unwrap().upgrade().unwrap()); + let room_b = active_call_b.update(cx_b, |call, _| call.room().unwrap().clone()); assert_eq!( project_b.read_with(cx_b, |project, _| project.remote_id()), Some(project_id), ); assert!(project_b.read_with(cx_b, |project, _| project.is_read_only())); - assert!(project_b .update(cx_b, |project, cx| { let worktree_id = project.worktrees().next().unwrap().read(cx).id(); project.create_entry((worktree_id, "b.txt"), false, cx) }) .await - .is_err()) + .is_err()); + assert!(room_b.read_with(cx_b, |room, _| room.is_muted())); +} + +#[gpui::test] +async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let active_call_a = cx_a.read(ActiveCall::global); + + let channel_id = server + .make_public_channel("the-channel", &client_a, cx_a) + .await; + + let project_a = client_a.build_test_project(cx_a).await; + cx_a.update(|cx| workspace::join_channel(channel_id, client_a.app_state.clone(), None, cx)) + .await + .unwrap(); + + // Client A shares a project in the channel + active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + cx_a.run_until_parked(); + + // Client B joins channel A as a guest + cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx)) + .await + .unwrap(); + cx_a.run_until_parked(); + + // client B opens 1.txt as a guest + let (workspace_b, cx_b) = client_b.active_workspace(cx_b); + let room_b = cx_b + .read(ActiveCall::global) + .update(cx_b, |call, _| call.room().unwrap().clone()); + cx_b.simulate_keystrokes("cmd-p 1 enter"); + + let (project_b, editor_b) = workspace_b.update(cx_b, |workspace, cx| { + ( + workspace.project().clone(), + workspace.active_item_as::(cx).unwrap(), + ) + }); + assert!(project_b.read_with(cx_b, |project, _| project.is_read_only())); + assert!(editor_b.update(cx_b, |e, cx| e.read_only(cx))); + assert!(room_b.read_with(cx_b, |room, _| room.read_only())); + assert!(room_b + .update(cx_b, |room, cx| room.share_microphone(cx)) + .await + .is_err()); + + // B is promoted + active_call_a + .update(cx_a, |call, cx| { + call.room().unwrap().update(cx, |room, cx| { + room.set_participant_role( + client_b.user_id().unwrap(), + proto::ChannelRole::Member, + cx, + ) + }) + }) + .await + .unwrap(); + cx_a.run_until_parked(); + + // project and buffers are now editable + assert!(project_b.read_with(cx_b, |project, _| !project.is_read_only())); + 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.read_only())); + 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(); + room_b.read_with(cx_b, |room, _| assert!(!room.is_muted())); + + // B is demoted + active_call_a + .update(cx_a, |call, cx| { + call.room().unwrap().update(cx, |room, cx| { + room.set_participant_role( + client_b.user_id().unwrap(), + proto::ChannelRole::Guest, + cx, + ) + }) + }) + .await + .unwrap(); + cx_a.run_until_parked(); + + // project and buffers are no longer editable + assert!(project_b.read_with(cx_b, |project, _| project.is_read_only())); + assert!(editor_b.update(cx_b, |editor, cx| editor.read_only(cx))); + assert!(room_b + .update(cx_b, |room, cx| room.share_microphone(cx)) + .await + .is_err()); } diff --git a/crates/collab/src/tests/channel_message_tests.rs b/crates/collab/src/tests/channel_message_tests.rs index f5da0e3ee6..e59aa3c705 100644 --- a/crates/collab/src/tests/channel_message_tests.rs +++ b/crates/collab/src/tests/channel_message_tests.rs @@ -1,7 +1,9 @@ use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; use channel::{ChannelChat, ChannelMessageId, MessageParams}; +use collab_ui::chat_panel::ChatPanel; use gpui::{BackgroundExecutor, Model, TestAppContext}; use rpc::Notification; +use workspace::dock::Panel; #[gpui::test] async fn test_basic_channel_messages( @@ -262,7 +264,6 @@ async fn test_remove_channel_message( #[track_caller] fn assert_messages(chat: &Model, messages: &[&str], cx: &mut TestAppContext) { - // todo!(don't directly borrow here) assert_eq!( chat.read_with(cx, |chat, _| { chat.messages() @@ -274,135 +275,135 @@ fn assert_messages(chat: &Model, messages: &[&str], cx: &mut TestAp ); } -//todo!(collab_ui) -// #[gpui::test] -// async fn test_channel_message_changes( -// executor: BackgroundExecutor, -// cx_a: &mut TestAppContext, -// cx_b: &mut TestAppContext, -// ) { -// let mut server = TestServer::start(&executor).await; -// let client_a = server.create_client(cx_a, "user_a").await; -// let client_b = server.create_client(cx_b, "user_b").await; +#[gpui::test] +async fn test_channel_message_changes( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; -// let channel_id = server -// .make_channel( -// "the-channel", -// None, -// (&client_a, cx_a), -// &mut [(&client_b, cx_b)], -// ) -// .await; + let channel_id = server + .make_channel( + "the-channel", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b)], + ) + .await; -// // Client A sends a message, client B should see that there is a new message. -// let channel_chat_a = client_a -// .channel_store() -// .update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx)) -// .await -// .unwrap(); + // Client A sends a message, client B should see that there is a new message. + let channel_chat_a = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx)) + .await + .unwrap(); -// channel_chat_a -// .update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap()) -// .await -// .unwrap(); + channel_chat_a + .update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap()) + .await + .unwrap(); -// executor.run_until_parked(); + executor.run_until_parked(); -// let b_has_messages = cx_b.read_with(|cx| { -// client_b -// .channel_store() -// .read(cx) -// .has_new_messages(channel_id) -// .unwrap() -// }); + let b_has_messages = cx_b.update(|cx| { + client_b + .channel_store() + .read(cx) + .has_new_messages(channel_id) + .unwrap() + }); -// assert!(b_has_messages); + assert!(b_has_messages); -// // Opening the chat should clear the changed flag. -// cx_b.update(|cx| { -// collab_ui::init(&client_b.app_state, cx); -// }); -// let project_b = client_b.build_empty_local_project(cx_b); -// let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); -// let chat_panel_b = workspace_b.update(cx_b, |workspace, cx| ChatPanel::new(workspace, cx)); -// chat_panel_b -// .update(cx_b, |chat_panel, cx| { -// chat_panel.set_active(true, cx); -// chat_panel.select_channel(channel_id, None, cx) -// }) -// .await -// .unwrap(); + // Opening the chat should clear the changed flag. + cx_b.update(|cx| { + collab_ui::init(&client_b.app_state, cx); + }); + let project_b = client_b.build_empty_local_project(cx_b); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); -// executor.run_until_parked(); + let chat_panel_b = workspace_b.update(cx_b, |workspace, cx| ChatPanel::new(workspace, cx)); + chat_panel_b + .update(cx_b, |chat_panel, cx| { + chat_panel.set_active(true, cx); + chat_panel.select_channel(channel_id, None, cx) + }) + .await + .unwrap(); -// let b_has_messages = cx_b.read_with(|cx| { -// client_b -// .channel_store() -// .read(cx) -// .has_new_messages(channel_id) -// .unwrap() -// }); + executor.run_until_parked(); -// assert!(!b_has_messages); + let b_has_messages = cx_b.update(|cx| { + client_b + .channel_store() + .read(cx) + .has_new_messages(channel_id) + .unwrap() + }); -// // Sending a message while the chat is open should not change the flag. -// channel_chat_a -// .update(cx_a, |c, cx| c.send_message("two".into(), cx).unwrap()) -// .await -// .unwrap(); + assert!(!b_has_messages); -// executor.run_until_parked(); + // Sending a message while the chat is open should not change the flag. + channel_chat_a + .update(cx_a, |c, cx| c.send_message("two".into(), cx).unwrap()) + .await + .unwrap(); -// let b_has_messages = cx_b.read_with(|cx| { -// client_b -// .channel_store() -// .read(cx) -// .has_new_messages(channel_id) -// .unwrap() -// }); + executor.run_until_parked(); -// assert!(!b_has_messages); + let b_has_messages = cx_b.update(|cx| { + client_b + .channel_store() + .read(cx) + .has_new_messages(channel_id) + .unwrap() + }); -// // Sending a message while the chat is closed should change the flag. -// chat_panel_b.update(cx_b, |chat_panel, cx| { -// chat_panel.set_active(false, cx); -// }); + assert!(!b_has_messages); -// // Sending a message while the chat is open should not change the flag. -// channel_chat_a -// .update(cx_a, |c, cx| c.send_message("three".into(), cx).unwrap()) -// .await -// .unwrap(); + // Sending a message while the chat is closed should change the flag. + chat_panel_b.update(cx_b, |chat_panel, cx| { + chat_panel.set_active(false, cx); + }); -// executor.run_until_parked(); + // Sending a message while the chat is open should not change the flag. + channel_chat_a + .update(cx_a, |c, cx| c.send_message("three".into(), cx).unwrap()) + .await + .unwrap(); -// let b_has_messages = cx_b.read_with(|cx| { -// client_b -// .channel_store() -// .read(cx) -// .has_new_messages(channel_id) -// .unwrap() -// }); + executor.run_until_parked(); -// assert!(b_has_messages); + let b_has_messages = cx_b.update(|cx| { + client_b + .channel_store() + .read(cx) + .has_new_messages(channel_id) + .unwrap() + }); -// // Closing the chat should re-enable change tracking -// cx_b.update(|_| drop(chat_panel_b)); + assert!(b_has_messages); -// channel_chat_a -// .update(cx_a, |c, cx| c.send_message("four".into(), cx).unwrap()) -// .await -// .unwrap(); + // Closing the chat should re-enable change tracking + cx_b.update(|_| drop(chat_panel_b)); -// executor.run_until_parked(); + channel_chat_a + .update(cx_a, |c, cx| c.send_message("four".into(), cx).unwrap()) + .await + .unwrap(); -// let b_has_messages = cx_b.read_with(|cx| { -// client_b -// .channel_store() -// .read(cx) -// .has_new_messages(channel_id) -// .unwrap() -// }); + executor.run_until_parked(); -// assert!(b_has_messages); -// } + let b_has_messages = cx_b.update(|cx| { + client_b + .channel_store() + .read(cx) + .has_new_messages(channel_id) + .unwrap() + }); + + assert!(b_has_messages); +} diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 49e7060301..7fbdf8ba7f 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -203,7 +203,7 @@ async fn test_core_channels( executor.run_until_parked(); // Observe that client B is now an admin of channel A, and that - // their admin priveleges extend to subchannels of channel A. + // their admin privileges extend to subchannels of channel A. assert_channel_invitations(client_b.channel_store(), cx_b, &[]); assert_channels( client_b.channel_store(), @@ -1337,6 +1337,7 @@ async fn test_guest_access( }) .await .unwrap(); + executor.run_until_parked(); assert_channels_list_shape(client_b.channel_store(), cx_b, &[]); @@ -1417,8 +1418,6 @@ async fn test_channel_moving( ) { let mut server = TestServer::start(executor.clone()).await; let client_a = server.create_client(cx_a, "user_a").await; - // let client_b = server.create_client(cx_b, "user_b").await; - // let client_c = server.create_client(cx_c, "user_c").await; let channels = server .make_channel_tree( diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index 6f06e9f10f..0e11ec1684 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -8,9 +8,11 @@ use std::{ use call::ActiveCall; use editor::{ + actions::{ + ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Redo, Rename, ToggleCodeActions, Undo, + }, test::editor_test_context::{AssertionContextManager, EditorTestContext}, - ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Editor, Redo, Rename, ToggleCodeActions, - Undo, + Editor, }; use futures::StreamExt; use gpui::{TestAppContext, VisualContext, VisualTestContext}; @@ -71,6 +73,7 @@ async fn test_host_disconnect( let workspace_b = cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx)); let cx_b = &mut VisualTestContext::from_window(*workspace_b, cx_b); + let workspace_b_view = workspace_b.root_view(cx_b).unwrap(); let editor_b = workspace_b .update(cx_b, |workspace, cx| { @@ -85,8 +88,10 @@ async fn test_host_disconnect( //TODO: focus assert!(cx_b.update_view(&editor_b, |editor, cx| editor.is_focused(cx))); editor_b.update(cx_b, |editor, cx| editor.insert("X", cx)); - //todo(is_edited) - // assert!(workspace_b.is_edited(cx_b)); + + cx_b.update(|cx| { + assert!(workspace_b_view.read(cx).is_edited()); + }); // Drop client A's connection. Collaborators should disappear and the project should not be shown as shared. server.forbid_connections(); @@ -105,11 +110,11 @@ async fn test_host_disconnect( // Ensure client B's edited state is reset and that the whole window is blurred. workspace_b - .update(cx_b, |_, cx| { + .update(cx_b, |workspace, cx| { assert_eq!(cx.focused(), None); + assert!(!workspace.is_edited()) }) .unwrap(); - // assert!(!workspace_b.is_edited(cx_b)); // Ensure client B is not prompted to save edits when closing window after disconnecting. let can_close = workspace_b @@ -182,31 +187,27 @@ async fn test_newline_above_or_below_does_not_move_guest_cursor( .update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) .await .unwrap(); - let window_a = cx_a.add_empty_window(); - let editor_a = - window_a.build_view(cx_a, |cx| Editor::for_buffer(buffer_a, Some(project_a), cx)); + let cx_a = cx_a.add_empty_window(); + let editor_a = cx_a.new_view(|cx| Editor::for_buffer(buffer_a, Some(project_a), cx)); let mut editor_cx_a = EditorTestContext { - cx: VisualTestContext::from_window(window_a, cx_a), - window: window_a.into(), + cx: cx_a.clone(), + window: cx_a.handle(), editor: editor_a, assertion_cx: AssertionContextManager::new(), }; - let window_b = cx_b.add_empty_window(); - let mut cx_b = VisualTestContext::from_window(window_b, cx_b); - + let cx_b = cx_b.add_empty_window(); // Open a buffer as client B let buffer_b = project_b - .update(&mut cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) + .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) .await .unwrap(); - let editor_b = window_b.build_view(&mut cx_b, |cx| { - Editor::for_buffer(buffer_b, Some(project_b), cx) - }); + let editor_b = cx_b.new_view(|cx| Editor::for_buffer(buffer_b, Some(project_b), cx)); + let mut editor_cx_b = EditorTestContext { - cx: cx_b, - window: window_b.into(), + cx: cx_b.clone(), + window: cx_b.handle(), editor: editor_b, assertion_cx: AssertionContextManager::new(), }; @@ -218,7 +219,8 @@ async fn test_newline_above_or_below_does_not_move_guest_cursor( editor_cx_b.set_selections_state(indoc! {" Some textˇ "}); - editor_cx_a.update_editor(|editor, cx| editor.newline_above(&editor::NewlineAbove, cx)); + editor_cx_a + .update_editor(|editor, cx| editor.newline_above(&editor::actions::NewlineAbove, cx)); executor.run_until_parked(); editor_cx_a.assert_editor_state(indoc! {" ˇ @@ -238,7 +240,8 @@ async fn test_newline_above_or_below_does_not_move_guest_cursor( Some textˇ "}); - editor_cx_a.update_editor(|editor, cx| editor.newline_below(&editor::NewlineBelow, cx)); + editor_cx_a + .update_editor(|editor, cx| editor.newline_below(&editor::actions::NewlineBelow, cx)); executor.run_until_parked(); editor_cx_a.assert_editor_state(indoc! {" @@ -308,10 +311,9 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu .update(cx_b, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx)) .await .unwrap(); - let window_b = cx_b.add_empty_window(); - let editor_b = window_b.build_view(cx_b, |cx| { - Editor::for_buffer(buffer_b.clone(), Some(project_b.clone()), cx) - }); + let cx_b = cx_b.add_empty_window(); + let editor_b = + cx_b.new_view(|cx| Editor::for_buffer(buffer_b.clone(), Some(project_b.clone()), cx)); let fake_language_server = fake_language_servers.next().await.unwrap(); cx_a.background_executor.run_until_parked(); @@ -320,10 +322,8 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu assert!(!buffer.completion_triggers().is_empty()) }); - let mut cx_b = VisualTestContext::from_window(window_b, cx_b); - // Type a completion trigger character as the guest. - editor_b.update(&mut cx_b, |editor, cx| { + editor_b.update(cx_b, |editor, cx| { editor.change_selections(None, cx, |s| s.select_ranges([13..13])); editor.handle_input(".", cx); }); @@ -389,8 +389,7 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu }); // Confirm a completion on the guest. - - editor_b.update(&mut cx_b, |editor, cx| { + editor_b.update(cx_b, |editor, cx| { assert!(editor.context_menu_visible()); editor.confirm_completion(&ConfirmCompletion { item_ix: Some(0) }, cx); assert_eq!(editor.text(cx), "fn main() { a.first_method() }"); @@ -428,7 +427,7 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu ); }); - buffer_b.read_with(&mut cx_b, |buffer, _| { + buffer_b.read_with(cx_b, |buffer, _| { assert_eq!( buffer.text(), "use d::SomeTrait;\nfn main() { a.first_method() }" @@ -957,7 +956,7 @@ async fn test_share_project( cx_c: &mut TestAppContext, ) { let executor = cx_a.executor(); - let window_b = cx_b.add_empty_window(); + let cx_b = cx_b.add_empty_window(); let mut server = TestServer::start(executor.clone()).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; @@ -1072,7 +1071,7 @@ async fn test_share_project( .await .unwrap(); - let editor_b = window_b.build_view(cx_b, |cx| Editor::for_buffer(buffer_b, None, cx)); + let editor_b = cx_b.new_view(|cx| Editor::for_buffer(buffer_b, None, cx)); // Client A sees client B's selection executor.run_until_parked(); @@ -1086,8 +1085,7 @@ async fn test_share_project( }); // Edit the buffer as client B and see that edit as client A. - let mut cx_b = VisualTestContext::from_window(window_b, cx_b); - editor_b.update(&mut cx_b, |editor, cx| editor.handle_input("ok, ", cx)); + editor_b.update(cx_b, |editor, cx| editor.handle_input("ok, ", cx)); executor.run_until_parked(); buffer_a.read_with(cx_a, |buffer, _| { @@ -1096,7 +1094,7 @@ async fn test_share_project( // Client B can invite client C on a project shared by client A. active_call_b - .update(&mut cx_b, |call, cx| { + .update(cx_b, |call, cx| { call.invite(client_c.user_id().unwrap(), Some(project_b.clone()), cx) }) .await @@ -1187,18 +1185,14 @@ async fn test_on_input_format_from_host_to_guest( .update(cx_a, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx)) .await .unwrap(); - let window_a = cx_a.add_empty_window(); - let editor_a = window_a - .update(cx_a, |_, cx| { - cx.new_view(|cx| Editor::for_buffer(buffer_a, Some(project_a.clone()), cx)) - }) - .unwrap(); + let cx_a = cx_a.add_empty_window(); + let editor_a = cx_a.new_view(|cx| Editor::for_buffer(buffer_a, Some(project_a.clone()), cx)); let fake_language_server = fake_language_servers.next().await.unwrap(); executor.run_until_parked(); // Receive an OnTypeFormatting request as the host's language server. - // Return some formattings from the host's language server. + // Return some formatting from the host's language server. fake_language_server.handle_request::( |params, _| async move { assert_eq!( @@ -1217,16 +1211,15 @@ async fn test_on_input_format_from_host_to_guest( }, ); - // Open the buffer on the guest and see that the formattings worked + // Open the buffer on the guest and see that the formatting worked let buffer_b = project_b .update(cx_b, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx)) .await .unwrap(); - let mut cx_a = VisualTestContext::from_window(window_a, cx_a); // Type a on type formatting trigger character as the guest. cx_a.focus_view(&editor_a); - editor_a.update(&mut cx_a, |editor, cx| { + editor_a.update(cx_a, |editor, cx| { editor.change_selections(None, cx, |s| s.select_ranges([13..13])); editor.handle_input(">", cx); }); @@ -1238,7 +1231,7 @@ async fn test_on_input_format_from_host_to_guest( }); // Undo should remove LSP edits first - editor_a.update(&mut cx_a, |editor, cx| { + editor_a.update(cx_a, |editor, cx| { assert_eq!(editor.text(cx), "fn main() { a>~< }"); editor.undo(&Undo, cx); assert_eq!(editor.text(cx), "fn main() { a> }"); @@ -1249,7 +1242,7 @@ async fn test_on_input_format_from_host_to_guest( assert_eq!(buffer.text(), "fn main() { a> }") }); - editor_a.update(&mut cx_a, |editor, cx| { + editor_a.update(cx_a, |editor, cx| { assert_eq!(editor.text(cx), "fn main() { a> }"); editor.undo(&Undo, cx); assert_eq!(editor.text(cx), "fn main() { a }"); @@ -1320,23 +1313,21 @@ async fn test_on_input_format_from_guest_to_host( .update(cx_b, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx)) .await .unwrap(); - let window_b = cx_b.add_empty_window(); - let editor_b = window_b.build_view(cx_b, |cx| { - Editor::for_buffer(buffer_b, Some(project_b.clone()), cx) - }); + let cx_b = cx_b.add_empty_window(); + let editor_b = cx_b.new_view(|cx| Editor::for_buffer(buffer_b, Some(project_b.clone()), cx)); let fake_language_server = fake_language_servers.next().await.unwrap(); executor.run_until_parked(); - let mut cx_b = VisualTestContext::from_window(window_b, cx_b); + // Type a on type formatting trigger character as the guest. cx_b.focus_view(&editor_b); - editor_b.update(&mut cx_b, |editor, cx| { + editor_b.update(cx_b, |editor, cx| { editor.change_selections(None, cx, |s| s.select_ranges([13..13])); editor.handle_input(":", cx); }); // Receive an OnTypeFormatting request as the host's language server. - // Return some formattings from the host's language server. + // Return some formatting from the host's language server. executor.start_waiting(); fake_language_server .handle_request::(|params, _| async move { @@ -1359,7 +1350,7 @@ async fn test_on_input_format_from_guest_to_host( .unwrap(); executor.finish_waiting(); - // Open the buffer on the host and see that the formattings worked + // Open the buffer on the host and see that the formatting worked let buffer_a = project_a .update(cx_a, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx)) .await @@ -1371,7 +1362,7 @@ async fn test_on_input_format_from_guest_to_host( }); // Undo should remove LSP edits first - editor_b.update(&mut cx_b, |editor, cx| { + editor_b.update(cx_b, |editor, cx| { assert_eq!(editor.text(cx), "fn main() { a:~: }"); editor.undo(&Undo, cx); assert_eq!(editor.text(cx), "fn main() { a: }"); @@ -1382,7 +1373,7 @@ async fn test_on_input_format_from_guest_to_host( assert_eq!(buffer.text(), "fn main() { a: }") }); - editor_b.update(&mut cx_b, |editor, cx| { + editor_b.update(cx_b, |editor, cx| { assert_eq!(editor.text(cx), "fn main() { a: }"); editor.undo(&Undo, cx); assert_eq!(editor.text(cx), "fn main() { a }"); @@ -1833,7 +1824,7 @@ async fn test_inlay_hint_refresh_is_forwarded( assert_eq!( inlay_cache.version(), 1, - "Should update cache verison after first hints" + "Should update cache version after first hints" ); }); diff --git a/crates/collab/src/tests/following_tests.rs b/crates/collab/src/tests/following_tests.rs index 0486e29461..584de35e29 100644 --- a/crates/collab/src/tests/following_tests.rs +++ b/crates/collab/src/tests/following_tests.rs @@ -1,9 +1,12 @@ use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; -use call::ActiveCall; -use collab_ui::notifications::project_shared_notification::ProjectSharedNotification; +use call::{ActiveCall, ParticipantLocation}; +use collab_ui::{ + channel_view::ChannelView, + notifications::project_shared_notification::ProjectSharedNotification, +}; use editor::{Editor, ExcerptRange, MultiBuffer}; use gpui::{ - point, BackgroundExecutor, Context, SharedString, TestAppContext, View, VisualContext, + point, BackgroundExecutor, Context, Entity, SharedString, TestAppContext, View, VisualContext, VisualTestContext, }; use language::Capability; @@ -76,6 +79,10 @@ async fn test_basic_following( let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + cx_b.update(|cx| { + assert!(cx.is_window_active()); + }); + // Client A opens some editors. let pane_a = workspace_a.update(cx_a, |workspace, _| workspace.active_pane().clone()); let editor_a1 = workspace_a @@ -157,7 +164,6 @@ async fn test_basic_following( .update(cx_c, |call, cx| call.set_location(Some(&project_c), cx)) .await .unwrap(); - let weak_project_c = project_c.downgrade(); drop(project_c); // Client C also follows client A. @@ -234,17 +240,16 @@ async fn test_basic_following( workspace_c.update(cx_c, |workspace, cx| { workspace.close_window(&Default::default(), cx); }); - cx_c.update(|_| { - drop(workspace_c); - }); - cx_b.executor().run_until_parked(); + executor.run_until_parked(); // are you sure you want to leave the call? cx_c.simulate_prompt_answer(0); - cx_b.executor().run_until_parked(); + cx_c.cx.update(|_| { + drop(workspace_c); + }); executor.run_until_parked(); + cx_c.cx.update(|_| {}); - weak_workspace_c.assert_dropped(); - weak_project_c.assert_dropped(); + weak_workspace_c.assert_released(); // Clients A and B see that client B is following A, and client C is not present in the followers. executor.run_until_parked(); @@ -1224,7 +1229,9 @@ async fn test_auto_unfollowing(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont }); // When client B moves, it automatically stops following client A. - editor_b2.update(cx_b, |editor, cx| editor.move_right(&editor::MoveRight, cx)); + editor_b2.update(cx_b, |editor, cx| { + editor.move_right(&editor::actions::MoveRight, cx) + }); assert_eq!( workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), None @@ -1363,8 +1370,6 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut let mut server = TestServer::start(executor.clone()).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; - cx_a.update(editor::init); - cx_b.update(editor::init); client_a .fs() @@ -1400,9 +1405,6 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); - cx_a.update(|cx| collab_ui::init(&client_a.app_state, cx)); - cx_b.update(|cx| collab_ui::init(&client_b.app_state, cx)); - active_call_a .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) .await @@ -1571,6 +1573,59 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut }); } +#[gpui::test] +async fn test_following_stops_on_unshare(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + let (client_a, client_b, channel_id) = TestServer::start2(cx_a, cx_b).await; + + let (workspace_a, cx_a) = client_a.build_test_workspace(cx_a).await; + client_a + .host_workspace(&workspace_a, channel_id, cx_a) + .await; + let (workspace_b, cx_b) = client_b.join_workspace(channel_id, cx_b).await; + + cx_a.simulate_keystrokes("cmd-p 2 enter"); + cx_a.run_until_parked(); + + let editor_a = workspace_a.update(cx_a, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + let editor_b = workspace_b.update(cx_b, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + + // b should follow a to position 1 + editor_a.update(cx_a, |editor, cx| { + editor.change_selections(None, cx, |s| s.select_ranges([1..1])) + }); + cx_a.run_until_parked(); + editor_b.update(cx_b, |editor, cx| { + assert_eq!(editor.selections.ranges(cx), vec![1..1]) + }); + + // a unshares the project + cx_a.update(|cx| { + let project = workspace_a.read(cx).project().clone(); + ActiveCall::global(cx).update(cx, |call, cx| { + call.unshare_project(project, cx).unwrap(); + }) + }); + cx_a.run_until_parked(); + + // b should not follow a to position 2 + editor_a.update(cx_a, |editor, cx| { + editor.change_selections(None, cx, |s| s.select_ranges([2..2])) + }); + cx_a.run_until_parked(); + editor_b.update(cx_b, |editor, cx| { + assert_eq!(editor.selections.ranges(cx), vec![1..1]) + }); + cx_b.update(|cx| { + let room = ActiveCall::global(cx).read(cx).room().unwrap().read(cx); + let participant = room.remote_participants().get(&client_a.id()).unwrap(); + assert_eq!(participant.location, ParticipantLocation::UnsharedProject) + }) +} + #[gpui::test] async fn test_following_into_excluded_file( mut cx_a: &mut TestAppContext, @@ -1596,9 +1651,6 @@ async fn test_following_into_excluded_file( let active_call_b = cx_b.read(ActiveCall::global); let peer_id_a = client_a.peer_id().unwrap(); - cx_a.update(editor::init); - cx_b.update(editor::init); - client_a .fs() .insert_tree( @@ -1685,6 +1737,11 @@ async fn test_following_into_excluded_file( vec![18..17] ); + editor_for_excluded_a.update(cx_a, |editor, cx| { + editor.select_right(&Default::default(), cx); + }); + executor.run_until_parked(); + // Changes from B to the excluded file are replicated in A's editor editor_for_excluded_b.update(cx_b, |editor, cx| { editor.handle_input("\nCo-Authored-By: B ", cx); @@ -1693,7 +1750,7 @@ async fn test_following_into_excluded_file( editor_for_excluded_a.update(cx_a, |editor, cx| { assert_eq!( editor.text(cx), - "new commit messag\nCo-Authored-By: B " + "new commit message\nCo-Authored-By: B " ); }); } @@ -1775,3 +1832,167 @@ fn pane_summaries(workspace: &View, cx: &mut VisualTestContext) -> Ve .collect() }) } + +#[gpui::test(iterations = 10)] +async fn test_following_to_channel_notes_without_a_shared_project( + deterministic: BackgroundExecutor, + mut cx_a: &mut TestAppContext, + mut cx_b: &mut TestAppContext, + mut cx_c: &mut TestAppContext, +) { + let mut server = TestServer::start(deterministic.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + + cx_a.update(editor::init); + cx_b.update(editor::init); + cx_c.update(editor::init); + cx_a.update(collab_ui::channel_view::init); + cx_b.update(collab_ui::channel_view::init); + cx_c.update(collab_ui::channel_view::init); + + let channel_1_id = server + .make_channel( + "channel-1", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b), (&client_c, cx_c)], + ) + .await; + let channel_2_id = server + .make_channel( + "channel-2", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b), (&client_c, cx_c)], + ) + .await; + + // Clients A, B, and C join a channel. + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + let active_call_c = cx_c.read(ActiveCall::global); + for (call, cx) in [ + (&active_call_a, &mut cx_a), + (&active_call_b, &mut cx_b), + (&active_call_c, &mut cx_c), + ] { + call.update(*cx, |call, cx| call.join_channel(channel_1_id, cx)) + .await + .unwrap(); + } + deterministic.run_until_parked(); + + // Clients A, B, and C all open their own unshared projects. + client_a + .fs() + .insert_tree("/a", json!({ "1.txt": "" })) + .await; + client_b.fs().insert_tree("/b", json!({})).await; + client_c.fs().insert_tree("/c", json!({})).await; + let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + let (project_b, _) = client_b.build_local_project("/b", cx_b).await; + let (project_c, _) = client_b.build_local_project("/c", cx_c).await; + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + let (_workspace_c, _cx_c) = client_c.build_workspace(&project_c, cx_c); + + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + + // Client A opens the notes for channel 1. + let channel_notes_1_a = cx_a + .update(|cx| ChannelView::open(channel_1_id, workspace_a.clone(), cx)) + .await + .unwrap(); + channel_notes_1_a.update(cx_a, |notes, cx| { + assert_eq!(notes.channel(cx).unwrap().name, "channel-1"); + notes.editor.update(cx, |editor, cx| { + editor.insert("Hello from A.", cx); + editor.change_selections(None, cx, |selections| { + selections.select_ranges(vec![3..4]); + }); + }); + }); + + // Client B follows client A. + workspace_b + .update(cx_b, |workspace, cx| { + workspace + .start_following(client_a.peer_id().unwrap(), cx) + .unwrap() + }) + .await + .unwrap(); + + // Client B is taken to the notes for channel 1, with the same + // text selected as client A. + deterministic.run_until_parked(); + let channel_notes_1_b = workspace_b.update(cx_b, |workspace, cx| { + assert_eq!( + workspace.leader_for_pane(workspace.active_pane()), + Some(client_a.peer_id().unwrap()) + ); + workspace + .active_item(cx) + .expect("no active item") + .downcast::() + .expect("active item is not a channel view") + }); + channel_notes_1_b.update(cx_b, |notes, cx| { + assert_eq!(notes.channel(cx).unwrap().name, "channel-1"); + let editor = notes.editor.read(cx); + assert_eq!(editor.text(cx), "Hello from A."); + assert_eq!(editor.selections.ranges::(cx), &[3..4]); + }); + + // Client A opens the notes for channel 2. + let channel_notes_2_a = cx_a + .update(|cx| ChannelView::open(channel_2_id, workspace_a.clone(), cx)) + .await + .unwrap(); + channel_notes_2_a.update(cx_a, |notes, cx| { + assert_eq!(notes.channel(cx).unwrap().name, "channel-2"); + }); + + // Client B is taken to the notes for channel 2. + deterministic.run_until_parked(); + let channel_notes_2_b = workspace_b.update(cx_b, |workspace, cx| { + assert_eq!( + workspace.leader_for_pane(workspace.active_pane()), + Some(client_a.peer_id().unwrap()) + ); + workspace + .active_item(cx) + .expect("no active item") + .downcast::() + .expect("active item is not a channel view") + }); + channel_notes_2_b.update(cx_b, |notes, cx| { + assert_eq!(notes.channel(cx).unwrap().name, "channel-2"); + }); + + // Client A opens a local buffer in their unshared project. + let _unshared_editor_a1 = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // This does not send any leader update message to client B. + // If it did, an error would occur on client B, since this buffer + // is not shared with them. + deterministic.run_until_parked(); + workspace_b.update(cx_b, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).expect("no active item").item_id(), + channel_notes_2_b.entity_id() + ); + }); +} diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index a21235b6f3..a8e52f4094 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -7,7 +7,10 @@ use client::{User, RECEIVE_TIMEOUT}; use collections::{HashMap, HashSet}; use fs::{repository::GitFileStatus, FakeFs, Fs as _, RemoveOptions}; use futures::StreamExt as _; -use gpui::{AppContext, BackgroundExecutor, Model, TestAppContext}; +use gpui::{ + px, size, AppContext, BackgroundExecutor, Model, Modifiers, MouseButton, MouseDownEvent, + TestAppContext, +}; use language::{ language_settings::{AllLanguageSettings, Formatter}, tree_sitter_rust, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language, LanguageConfig, @@ -1876,6 +1879,186 @@ fn active_call_events(cx: &mut TestAppContext) -> Rc>> events } +#[gpui::test] +async fn test_mute_deafen( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + + server + .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) + .await; + + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + let active_call_c = cx_c.read(ActiveCall::global); + + // User A calls user B, B answers. + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + executor.run_until_parked(); + active_call_b + .update(cx_b, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + executor.run_until_parked(); + + let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone()); + let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone()); + + 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. + assert_eq!( + participant_audio_state(&room_a, cx_a), + &[ParticipantAudioState { + user_id: client_b.user_id().unwrap(), + is_muted: false, + audio_tracks_playing: vec![true], + }] + ); + assert_eq!( + participant_audio_state(&room_b, cx_b), + &[ParticipantAudioState { + user_id: client_a.user_id().unwrap(), + is_muted: false, + audio_tracks_playing: vec![true], + }] + ); + + // User A mutes + room_a.update(cx_a, |room, cx| room.toggle_mute(cx)); + executor.run_until_parked(); + + // User A hears user B, but B doesn't hear A. + room_a.read_with(cx_a, |room, _| assert!(room.is_muted())); + room_b.read_with(cx_b, |room, _| assert!(!room.is_muted())); + assert_eq!( + participant_audio_state(&room_a, cx_a), + &[ParticipantAudioState { + user_id: client_b.user_id().unwrap(), + is_muted: false, + audio_tracks_playing: vec![true], + }] + ); + assert_eq!( + participant_audio_state(&room_b, cx_b), + &[ParticipantAudioState { + user_id: client_a.user_id().unwrap(), + is_muted: true, + audio_tracks_playing: vec![true], + }] + ); + + // User A deafens + room_a.update(cx_a, |room, cx| room.toggle_deafen(cx)); + executor.run_until_parked(); + + // User A does not hear user B. + room_a.read_with(cx_a, |room, _| assert!(room.is_muted())); + room_b.read_with(cx_b, |room, _| assert!(!room.is_muted())); + assert_eq!( + participant_audio_state(&room_a, cx_a), + &[ParticipantAudioState { + user_id: client_b.user_id().unwrap(), + is_muted: false, + audio_tracks_playing: vec![false], + }] + ); + assert_eq!( + participant_audio_state(&room_b, cx_b), + &[ParticipantAudioState { + user_id: client_a.user_id().unwrap(), + is_muted: true, + audio_tracks_playing: vec![true], + }] + ); + + // User B calls user C, C joins. + active_call_b + .update(cx_b, |call, cx| { + call.invite(client_c.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + executor.run_until_parked(); + active_call_c + .update(cx_c, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + executor.run_until_parked(); + + // User A does not hear users B or C. + assert_eq!( + participant_audio_state(&room_a, cx_a), + &[ + ParticipantAudioState { + user_id: client_b.user_id().unwrap(), + is_muted: false, + audio_tracks_playing: vec![false], + }, + ParticipantAudioState { + user_id: client_c.user_id().unwrap(), + is_muted: false, + audio_tracks_playing: vec![false], + } + ] + ); + assert_eq!( + participant_audio_state(&room_b, cx_b), + &[ + ParticipantAudioState { + user_id: client_a.user_id().unwrap(), + is_muted: true, + audio_tracks_playing: vec![true], + }, + ParticipantAudioState { + user_id: client_c.user_id().unwrap(), + is_muted: false, + audio_tracks_playing: vec![true], + } + ] + ); + + #[derive(PartialEq, Eq, Debug)] + struct ParticipantAudioState { + user_id: u64, + is_muted: bool, + audio_tracks_playing: Vec, + } + + fn participant_audio_state( + room: &Model, + cx: &TestAppContext, + ) -> Vec { + room.read_with(cx, |room, _| { + room.remote_participants() + .iter() + .map(|(user_id, participant)| ParticipantAudioState { + user_id: *user_id, + is_muted: participant.muted, + audio_tracks_playing: participant + .audio_tracks + .values() + .map(|track| track.is_playing()) + .collect(), + }) + .collect::>() + }) + } +} + #[gpui::test(iterations = 10)] async fn test_room_location( executor: BackgroundExecutor, @@ -3065,6 +3248,7 @@ async fn test_local_settings( .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) .await .unwrap(); + executor.run_until_parked(); // As client B, join that project and observe the local settings. let project_b = client_b.build_remote_project(project_id, cx_b).await; @@ -5722,3 +5906,42 @@ async fn test_join_call_after_screen_was_shared( ); }); } + +#[gpui::test] +async fn test_right_click_menu_behind_collab_panel(cx: &mut TestAppContext) { + let mut server = TestServer::start(cx.executor().clone()).await; + let client_a = server.create_client(cx, "user_a").await; + let (_workspace_a, cx) = client_a.build_test_workspace(cx).await; + + cx.simulate_resize(size(px(300.), px(300.))); + + cx.simulate_keystrokes("cmd-n cmd-n cmd-n"); + cx.update(|cx| cx.refresh()); + + let tab_bounds = cx.debug_bounds("TAB-2").unwrap(); + let new_tab_button_bounds = cx.debug_bounds("ICON-Plus").unwrap(); + + assert!( + tab_bounds.intersects(&new_tab_button_bounds), + "Tab should overlap with the new tab button, if this is failing check if there's been a redesign!" + ); + + cx.simulate_event(MouseDownEvent { + button: MouseButton::Right, + position: new_tab_button_bounds.center(), + modifiers: Modifiers::default(), + click_count: 1, + }); + + // regression test that the right click menu for tabs does not open. + assert!(cx.debug_bounds("MENU_ITEM-Close").is_none()); + + let tab_bounds = cx.debug_bounds("TAB-1").unwrap(); + cx.simulate_event(MouseDownEvent { + button: MouseButton::Right, + position: tab_bounds.center(), + modifiers: Modifiers::default(), + click_count: 1, + }); + assert!(cx.debug_bounds("MENU_ITEM-Close").is_some()); +} diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 034a85961f..ea08d83b6c 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -20,7 +20,11 @@ use node_runtime::FakeNodeRuntime; use notifications::NotificationStore; use parking_lot::Mutex; use project::{Project, WorktreeId}; -use rpc::{proto::ChannelRole, RECEIVE_TIMEOUT}; +use rpc::{ + proto::{self, ChannelRole}, + RECEIVE_TIMEOUT, +}; +use serde_json::json; use settings::SettingsStore; use std::{ cell::{Ref, RefCell, RefMut}, @@ -109,6 +113,20 @@ impl TestServer { } } + pub async fn start2( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + ) -> (TestClient, TestClient, u64) { + let mut server = Self::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let channel_id = server + .make_channel("a", None, (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .await; + + (client_a, client_b, channel_id) + } + pub async fn reset(&self) { self.app_state.db.reset(); let epoch = self @@ -195,6 +213,7 @@ impl TestServer { server_conn, client_name, user, + None, Some(connection_id_tx), Executor::Deterministic(cx.background_executor().clone()), )) @@ -228,12 +247,15 @@ impl TestServer { Project::init(&client, cx); client::init(&client, cx); language::init(cx); - editor::init_settings(cx); + editor::init(cx); workspace::init(app_state.clone(), cx); - audio::init((), cx); call::init(client.clone(), user_store.clone(), cx); channel::init(&client, user_store.clone(), cx); notifications::init(client.clone(), user_store, cx); + collab_ui::init(&app_state, cx); + file_finder::init(cx); + menu::init(); + settings::KeymapFile::load_asset("keymaps/default.json", cx).unwrap(); }); client @@ -351,6 +373,31 @@ impl TestServer { channel_id } + pub async fn make_public_channel( + &self, + channel: &str, + client: &TestClient, + cx: &mut TestAppContext, + ) -> u64 { + let channel_id = self + .make_channel(channel, None, (client, cx), &mut []) + .await; + + client + .channel_store() + .update(cx, |channel_store, cx| { + channel_store.set_channel_visibility( + channel_id, + proto::ChannelVisibility::Public, + cx, + ) + }) + .await + .unwrap(); + + channel_id + } + pub async fn make_channel_tree( &self, channels: &[(&str, Option<&str>)], @@ -580,6 +627,55 @@ impl TestClient { (project, worktree.read_with(cx, |tree, _| tree.id())) } + pub async fn build_test_project(&self, cx: &mut TestAppContext) -> Model { + self.fs() + .insert_tree( + "/a", + json!({ + "1.txt": "one\none\none", + "2.js": "function two() { return 2; }", + "3.rs": "mod test", + }), + ) + .await; + self.build_local_project("/a", cx).await.0 + } + + pub async fn host_workspace( + &self, + workspace: &View, + channel_id: u64, + cx: &mut VisualTestContext, + ) { + cx.update(|cx| { + let active_call = ActiveCall::global(cx); + active_call.update(cx, |call, cx| call.join_channel(channel_id, cx)) + }) + .await + .unwrap(); + cx.update(|cx| { + let active_call = ActiveCall::global(cx); + let project = workspace.read(cx).project().clone(); + active_call.update(cx, |call, cx| call.share_project(project, cx)) + }) + .await + .unwrap(); + cx.executor().run_until_parked(); + } + + pub async fn join_workspace<'a>( + &'a self, + channel_id: u64, + cx: &'a mut TestAppContext, + ) -> (View, &'a mut VisualTestContext) { + cx.update(|cx| workspace::join_channel(channel_id, self.app_state.clone(), None, cx)) + .await + .unwrap(); + cx.run_until_parked(); + + self.active_workspace(cx) + } + pub fn build_empty_local_project(&self, cx: &mut TestAppContext) -> Model { cx.update(|cx| { Project::local( @@ -617,7 +713,33 @@ impl TestClient { project: &Model, cx: &'a mut TestAppContext, ) -> (View, &'a mut VisualTestContext) { - cx.add_window_view(|cx| Workspace::new(0, project.clone(), self.app_state.clone(), cx)) + cx.add_window_view(|cx| { + cx.activate_window(); + Workspace::new(0, project.clone(), self.app_state.clone(), cx) + }) + } + + pub async fn build_test_workspace<'a>( + &'a self, + cx: &'a mut TestAppContext, + ) -> (View, &'a mut VisualTestContext) { + let project = self.build_test_project(cx).await; + cx.add_window_view(|cx| { + cx.activate_window(); + Workspace::new(0, project.clone(), self.app_state.clone(), cx) + }) + } + + pub fn active_workspace<'a>( + &'a self, + cx: &'a mut TestAppContext, + ) -> (View, &'a mut VisualTestContext) { + let window = cx.update(|cx| cx.active_window().unwrap().downcast::().unwrap()); + + let view = window.root_view(cx).unwrap(); + let cx = Box::new(VisualTestContext::from_window(*window.deref(), cx)); + // it might be nice to try and cleanup these at the end of each test. + (view, Box::leak(cx)) } } diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index f845de3a93..84c1810bc8 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -9,6 +9,8 @@ path = "src/collab_ui.rs" doctest = false [features] +default = [] +stories = ["dep:story"] test-support = [ "call/test-support", "client/test-support", @@ -44,6 +46,7 @@ project = { path = "../project" } recent_projects = { path = "../recent_projects" } rpc = { path = "../rpc" } settings = { path = "../settings" } +story = { path = "../story", optional = true } feature_flags = { path = "../feature_flags"} theme = { path = "../theme" } theme_selector = { path = "../theme_selector" } diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index ce68acfbd8..033889f771 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -266,6 +266,10 @@ impl Item for ChannelView { .into_any_element() } + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + fn clone_on_split(&self, _: WorkspaceId, cx: &mut ViewContext) -> Option> { Some(cx.new_view(|cx| { Self::new( diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 4f8e12f1e8..44b0669c30 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -1,15 +1,15 @@ -use crate::{channel_view::ChannelView, is_channels_feature_enabled, ChatPanelSettings}; +use crate::{collab_panel, ChatPanelSettings}; use anyhow::Result; -use call::ActiveCall; +use call::{room, ActiveCall}; use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore}; use client::Client; use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use editor::Editor; use gpui::{ - actions, div, list, prelude::*, px, AnyElement, AppContext, AsyncWindowContext, ClickEvent, - ElementId, EventEmitter, FocusableView, ListOffset, ListScrollEvent, ListState, Model, Render, - Subscription, Task, View, ViewContext, VisualContext, WeakView, + actions, div, list, prelude::*, px, Action, AppContext, AsyncWindowContext, DismissEvent, + ElementId, EventEmitter, FocusHandle, FocusableView, FontWeight, ListOffset, ListScrollEvent, + ListState, Model, Render, Subscription, Task, View, ViewContext, VisualContext, WeakView, }; use language::LanguageRegistry; use menu::Confirm; @@ -17,10 +17,13 @@ use message_editor::MessageEditor; use project::Fs; use rich_text::RichText; use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore}; +use settings::Settings; use std::sync::Arc; use time::{OffsetDateTime, UtcOffset}; -use ui::{prelude::*, Avatar, Button, Icon, IconButton, Label, TabBar, Tooltip}; +use ui::{ + popover_menu, prelude::*, Avatar, Button, ContextMenu, IconButton, IconName, KeyBinding, Label, + TabBar, +}; use util::{ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, @@ -54,9 +57,10 @@ pub struct ChatPanel { active: bool, pending_serialization: Task>, subscriptions: Vec, - workspace: WeakView, is_scrolled_to_bottom: bool, markdown_data: HashMap, + focus_handle: FocusHandle, + open_context_menu: Option<(u64, Subscription)>, } #[derive(Serialize, Deserialize)] @@ -64,13 +68,6 @@ struct SerializedChatPanel { width: Option, } -#[derive(Debug)] -pub enum Event { - DockPositionChanged, - Focus, - Dismissed, -} - actions!(chat_panel, [ToggleFocus]); impl ChatPanel { @@ -89,8 +86,6 @@ impl ChatPanel { ) }); - let workspace_handle = workspace.weak_handle(); - cx.new_view(|cx: &mut ViewContext| { let view = cx.view().downgrade(); let message_list = @@ -108,7 +103,7 @@ impl ChatPanel { if event.visible_range.start < MESSAGE_LOADING_THRESHOLD { this.load_more_messages(cx); } - this.is_scrolled_to_bottom = event.visible_range.end == event.count; + this.is_scrolled_to_bottom = !event.is_scrolled; })); let mut this = Self { @@ -122,22 +117,54 @@ impl ChatPanel { message_editor: input_editor, local_timezone: cx.local_timezone(), subscriptions: Vec::new(), - workspace: workspace_handle, is_scrolled_to_bottom: true, active: false, width: None, markdown_data: Default::default(), + focus_handle: cx.focus_handle(), + open_context_menu: None, }; - let mut old_dock_position = this.position(cx); - this.subscriptions.push(cx.observe_global::( - move |this: &mut Self, cx| { - let new_dock_position = this.position(cx); - if new_dock_position != old_dock_position { - old_dock_position = new_dock_position; - cx.emit(Event::DockPositionChanged); + if let Some(channel_id) = ActiveCall::global(cx) + .read(cx) + .room() + .and_then(|room| room.read(cx).channel_id()) + { + this.select_channel(channel_id, None, cx) + .detach_and_log_err(cx); + + if ActiveCall::global(cx) + .read(cx) + .room() + .is_some_and(|room| room.read(cx).contains_guests()) + { + cx.emit(PanelEvent::Activate) + } + } + + this.subscriptions.push(cx.subscribe( + &ActiveCall::global(cx), + move |this: &mut Self, call, event: &room::Event, cx| match event { + room::Event::RoomJoined { channel_id } => { + if let Some(channel_id) = channel_id { + this.select_channel(*channel_id, None, cx) + .detach_and_log_err(cx); + + if call + .read(cx) + .room() + .is_some_and(|room| room.read(cx).contains_guests()) + { + cx.emit(PanelEvent::Activate) + } + } } - cx.notify(); + room::Event::Left { channel_id } => { + if channel_id == &this.channel_id(cx) { + cx.emit(PanelEvent::Close) + } + } + _ => {} }, )); @@ -145,6 +172,12 @@ impl ChatPanel { }) } + pub fn channel_id(&self, cx: &AppContext) -> Option { + self.active_chat + .as_ref() + .map(|(chat, _)| chat.read(cx).channel_id) + } + pub fn is_scrolled_to_bottom(&self) -> bool { self.is_scrolled_to_bottom } @@ -259,53 +292,9 @@ impl ChatPanel { } } - fn render_channel(&self, cx: &mut ViewContext) -> AnyElement { - v_stack() - .full() - .on_action(cx.listener(Self::send)) - .child( - h_stack().z_index(1).child( - TabBar::new("chat_header") - .child( - h_stack() - .w_full() - .h(rems(ui::Tab::HEIGHT_IN_REMS)) - .px_2() - .child(Label::new( - self.active_chat - .as_ref() - .and_then(|c| { - Some(format!("#{}", c.0.read(cx).channel(cx)?.name)) - }) - .unwrap_or_default(), - )), - ) - .end_child( - IconButton::new("notes", Icon::File) - .on_click(cx.listener(Self::open_notes)) - .tooltip(|cx| Tooltip::text("Open notes", cx)), - ) - .end_child( - IconButton::new("call", Icon::AudioOn) - .on_click(cx.listener(Self::join_call)) - .tooltip(|cx| Tooltip::text("Join call", cx)), - ), - ), - ) - .child(div().flex_grow().px_2().py_1().map(|this| { - if self.active_chat.is_some() { - this.child(list(self.message_list.clone()).full()) - } else { - this - } - })) - .child(h_stack().p_2().child(self.message_editor.clone())) - .into_any() - } - fn render_message(&mut self, ix: usize, cx: &mut ViewContext) -> impl IntoElement { let active_chat = &self.active_chat.as_ref().unwrap().0; - let (message, is_continuation_from_previous, is_continuation_to_next, is_admin) = + let (message, is_continuation_from_previous, is_admin) = active_chat.update(cx, |active_chat, cx| { let is_admin = self .channel_store @@ -314,13 +303,9 @@ impl ChatPanel { let last_message = active_chat.message(ix.saturating_sub(1)); let this_message = active_chat.message(ix).clone(); - let next_message = - active_chat.message(ix.saturating_add(1).min(active_chat.message_count() - 1)); let is_continuation_from_previous = last_message.id != this_message.id && last_message.sender.id == this_message.sender.id; - let is_continuation_to_next = this_message.id != next_message.id - && this_message.sender.id == next_message.sender.id; if let ChannelMessageId::Saved(id) = this_message.id { if this_message @@ -332,12 +317,7 @@ impl ChatPanel { } } - ( - this_message, - is_continuation_from_previous, - is_continuation_to_next, - is_admin, - ) + (this_message, is_continuation_from_previous, is_admin) }); let _is_pending = message.is_pending(); @@ -360,50 +340,100 @@ impl ChatPanel { ChannelMessageId::Saved(id) => ("saved-message", id).into(), ChannelMessageId::Pending(id) => ("pending-message", id).into(), }; + let this = cx.view().clone(); - v_stack() + v_flex() .w_full() - .id(element_id) .relative() .overflow_hidden() - .group("") .when(!is_continuation_from_previous, |this| { - this.child( - h_stack() - .gap_2() - .child(Avatar::new(message.sender.avatar_uri.clone())) - .child(Label::new(message.sender.github_login.clone())) + this.pt_3().child( + h_flex() + .child( + div().absolute().child( + Avatar::new(message.sender.avatar_uri.clone()) + .size(cx.rem_size() * 1.5), + ), + ) + .child( + div() + .pl(cx.rem_size() * 1.5 + px(6.0)) + .pr(px(8.0)) + .font_weight(FontWeight::BOLD) + .child(Label::new(message.sender.github_login.clone())), + ) .child( Label::new(format_timestamp( message.timestamp, now, self.local_timezone, )) + .size(LabelSize::Small) .color(Color::Muted), ), ) }) - .when(!is_continuation_to_next, |this| - // HACK: This should really be a margin, but margins seem to get collapsed. - this.pb_2()) - .child(text.element("body".into(), cx)) + .when(is_continuation_from_previous, |this| this.pt_1()) .child( - div() - .absolute() - .top_1() - .right_2() - .w_8() - .visible_on_hover("") - .children(message_id_to_remove.map(|message_id| { - IconButton::new(("remove", message_id), Icon::XCircle).on_click( - cx.listener(move |this, _, cx| { - this.remove_message(message_id, cx); - }), - ) - })), + v_flex() + .w_full() + .text_ui_sm() + .id(element_id) + .group("") + .child(text.element("body".into(), cx)) + .child( + div() + .absolute() + .z_index(1) + .right_0() + .w_6() + .bg(cx.theme().colors().panel_background) + .when(!self.has_open_menu(message_id_to_remove), |el| { + el.visible_on_hover("") + }) + .children(message_id_to_remove.map(|message_id| { + popover_menu(("menu", message_id)) + .trigger(IconButton::new( + ("trigger", message_id), + IconName::Ellipsis, + )) + .menu(move |cx| { + Some(Self::render_message_menu(&this, message_id, cx)) + }) + })), + ), ) } + fn has_open_menu(&self, message_id: Option) -> bool { + match self.open_context_menu.as_ref() { + Some((id, _)) => Some(*id) == message_id, + None => false, + } + } + + fn render_message_menu( + this: &View, + message_id: u64, + cx: &mut WindowContext, + ) -> View { + let menu = { + let this = this.clone(); + ContextMenu::build(cx, move |menu, _| { + menu.entry("Delete message", None, move |cx| { + this.update(cx, |this, cx| this.remove_message(message_id, cx)) + }) + }) + }; + this.update(cx, |this, cx| { + let subscription = cx.subscribe(&menu, |this: &mut Self, _, _: &DismissEvent, _| { + this.open_context_menu = None; + }); + this.open_context_menu = Some((message_id, subscription)); + }); + menu + } + fn render_markdown_with_mentions( language_registry: &Arc, current_user_id: u64, @@ -421,44 +451,6 @@ impl ChatPanel { rich_text::render_markdown(message.body.clone(), &mentions, language_registry, None) } - fn render_sign_in_prompt(&self, cx: &mut ViewContext) -> impl IntoElement { - v_stack() - .gap_2() - .p_4() - .child( - Button::new("sign-in", "Sign in") - .style(ButtonStyle::Filled) - .icon_color(Color::Muted) - .icon(Icon::Github) - .icon_position(IconPosition::Start) - .full_width() - .on_click(cx.listener(move |this, _, cx| { - let client = this.client.clone(); - cx.spawn(|this, mut cx| async move { - if client - .authenticate_and_connect(true, &cx) - .log_err() - .await - .is_some() - { - this.update(&mut cx, |_, cx| { - cx.focus_self(); - }) - .ok(); - } - }) - .detach(); - })), - ) - .child( - div().flex().w_full().items_center().child( - Label::new("Sign in to chat.") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - } - fn send(&mut self, _: &Confirm, cx: &mut ViewContext) { if let Some((chat, _)) = self.active_chat.as_ref() { let message = self @@ -535,50 +527,93 @@ impl ChatPanel { Ok(()) }) } - - fn open_notes(&mut self, _: &ClickEvent, cx: &mut ViewContext) { - if let Some((chat, _)) = &self.active_chat { - let channel_id = chat.read(cx).channel_id; - if let Some(workspace) = self.workspace.upgrade() { - ChannelView::open(channel_id, workspace, cx).detach(); - } - } - } - - fn join_call(&mut self, _: &ClickEvent, cx: &mut ViewContext) { - if let Some((chat, _)) = &self.active_chat { - let channel_id = chat.read(cx).channel_id; - ActiveCall::global(cx) - .update(cx, |call, cx| call.join_channel(channel_id, cx)) - .detach_and_log_err(cx); - } - } } -impl EventEmitter for ChatPanel {} - impl Render for ChatPanel { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - v_stack() - .size_full() - .map(|this| match (self.client.user_id(), self.active_chat()) { - (Some(_), Some(_)) => this.child(self.render_channel(cx)), - (Some(_), None) => this.child( - div().p_4().child( - Label::new("Select a channel to chat in.") - .size(LabelSize::Small) - .color(Color::Muted), + v_flex() + .track_focus(&self.focus_handle) + .full() + .on_action(cx.listener(Self::send)) + .child( + h_flex().z_index(1).child( + TabBar::new("chat_header").child( + h_flex() + .w_full() + .h(rems(ui::Tab::CONTAINER_HEIGHT_IN_REMS)) + .px_2() + .child(Label::new( + self.active_chat + .as_ref() + .and_then(|c| { + Some(format!("#{}", c.0.read(cx).channel(cx)?.name)) + }) + .unwrap_or("Chat".to_string()), + )), ), ), - (None, _) => this.child(self.render_sign_in_prompt(cx)), - }) - .min_w(px(150.)) + ) + .child(div().flex_grow().px_2().pt_1().map(|this| { + if self.active_chat.is_some() { + this.child(list(self.message_list.clone()).full()) + } else { + this.child( + div() + .p_4() + .child( + Label::new("Select a channel to chat in.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + div().pt_1().w_full().items_center().child( + Button::new("toggle-collab", "Open") + .full_width() + .key_binding(KeyBinding::for_action( + &collab_panel::ToggleFocus, + cx, + )) + .on_click(|_, cx| { + cx.dispatch_action( + collab_panel::ToggleFocus.boxed_clone(), + ) + }), + ), + ), + ) + } + })) + .child( + h_flex() + .when(!self.is_scrolled_to_bottom, |el| { + el.border_t_1().border_color(cx.theme().colors().border) + }) + .p_2() + .map(|el| { + if self.active_chat.is_some() { + el.child(self.message_editor.clone()) + } else { + el.child( + div() + .rounded_md() + .h_7() + .w_full() + .bg(cx.theme().colors().editor_background), + ) + } + }), + ) + .into_any() } } impl FocusableView for ChatPanel { fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle { - self.message_editor.read(cx).focus_handle(cx) + if self.active_chat.is_some() { + self.message_editor.read(cx).focus_handle(cx) + } else { + self.focus_handle.clone() + } } } @@ -612,9 +647,6 @@ impl Panel for ChatPanel { self.active = active; if active { self.acknowledge_last_message(cx); - if !is_channels_feature_enabled(cx) { - cx.emit(Event::Dismissed); - } } } @@ -622,12 +654,8 @@ impl Panel for ChatPanel { "ChatPanel" } - fn icon(&self, cx: &WindowContext) -> Option { - if !is_channels_feature_enabled(cx) { - return None; - } - - Some(ui::Icon::MessageBubbles).filter(|_| ChatPanelSettings::get_global(cx).button) + fn icon(&self, cx: &WindowContext) -> Option { + Some(ui::IconName::MessageBubbles).filter(|_| ChatPanelSettings::get_global(cx).button) } fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> { diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 86d0131d70..4bfb5fe3ae 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -12,32 +12,30 @@ use client::{Client, Contact, User, UserStore}; use contact_finder::ContactFinder; use db::kvp::KEY_VALUE_STORE; use editor::{Editor, EditorElement, EditorStyle}; -use feature_flags::{ChannelsAlpha, FeatureFlagAppExt, FeatureFlagViewExt}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ actions, canvas, div, fill, list, overlay, point, prelude::*, px, AnyElement, AppContext, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, InteractiveElement, IntoElement, ListOffset, ListState, - Model, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, RenderOnce, - SharedString, Styled, Subscription, Task, TextStyle, View, ViewContext, VisualContext, - WeakView, WhiteSpace, + Model, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, SharedString, Styled, + Subscription, Task, TextStyle, View, ViewContext, VisualContext, WeakView, WhiteSpace, }; use menu::{Cancel, Confirm, SelectNext, SelectPrev}; use project::{Fs, Project}; use rpc::proto::{self, PeerId}; use serde_derive::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore}; +use settings::Settings; use smallvec::SmallVec; use std::{mem, sync::Arc}; use theme::{ActiveTheme, ThemeSettings}; use ui::{ - prelude::*, Avatar, Button, Color, ContextMenu, Icon, IconButton, IconElement, IconSize, Label, - ListHeader, ListItem, Tooltip, + prelude::*, Avatar, AvatarAvailabilityIndicator, Button, Color, ContextMenu, Icon, IconButton, + IconName, IconSize, Label, ListHeader, ListItem, Tooltip, }; use util::{maybe, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, - notifications::NotifyResultExt, + notifications::{NotifyResultExt, NotifyTaskExt}, Workspace, }; @@ -140,6 +138,7 @@ enum ListEntry { user: Arc, peer_id: Option, is_pending: bool, + role: proto::ChannelRole, }, ParticipantProject { project_id: u64, @@ -151,10 +150,6 @@ enum ListEntry { peer_id: Option, is_last: bool, }, - GuestCount { - count: usize, - has_visible_participants: bool, - }, IncomingRequest(Arc), OutgoingRequest(Arc), ChannelInvite(Arc), @@ -257,19 +252,6 @@ impl CollabPanel { this.update_entries(false, cx); - // Update the dock position when the setting changes. - let mut old_dock_position = this.position(cx); - this.subscriptions.push(cx.observe_global::( - move |this: &mut Self, cx| { - let new_dock_position = this.position(cx); - if new_dock_position != old_dock_position { - old_dock_position = new_dock_position; - cx.emit(PanelEvent::ChangePosition); - } - cx.notify(); - }, - )); - let active_call = ActiveCall::global(cx); this.subscriptions .push(cx.observe(&this.user_store, |this, _, cx| { @@ -281,10 +263,6 @@ impl CollabPanel { })); this.subscriptions .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx))); - this.subscriptions - .push(cx.observe_flag::(move |_, this, cx| { - this.update_entries(true, cx) - })); this.subscriptions.push(cx.subscribe( &this.channel_store, |this, _channel_store, e, cx| match e { @@ -384,14 +362,10 @@ impl CollabPanel { if !self.collapsed_sections.contains(&Section::ActiveCall) { let room = room.read(cx); - let mut guest_count_ix = 0; - let mut guest_count = if room.read_only() { 1 } else { 0 }; - let mut non_guest_count = if room.read_only() { 0 } else { 1 }; if let Some(channel_id) = room.channel_id() { self.entries.push(ListEntry::ChannelNotes { channel_id }); self.entries.push(ListEntry::ChannelChat { channel_id }); - guest_count_ix = self.entries.len(); } // Populate the active user. @@ -410,12 +384,13 @@ impl CollabPanel { &Default::default(), executor.clone(), )); - if !matches.is_empty() && !room.read_only() { + if !matches.is_empty() { let user_id = user.id; self.entries.push(ListEntry::CallParticipant { user, peer_id: None, is_pending: false, + role: room.local_participant().role, }); let mut projects = room.local_participant().projects.iter().peekable(); while let Some(project) = projects.next() { @@ -442,12 +417,6 @@ impl CollabPanel { room.remote_participants() .iter() .filter_map(|(_, participant)| { - if participant.role == proto::ChannelRole::Guest { - guest_count += 1; - return None; - } else { - non_guest_count += 1; - } Some(StringMatchCandidate { id: participant.user.id as usize, string: participant.user.github_login.clone(), @@ -455,7 +424,7 @@ impl CollabPanel { }) }), ); - let matches = executor.block(match_strings( + let mut matches = executor.block(match_strings( &self.match_candidates, &query, true, @@ -463,6 +432,15 @@ impl CollabPanel { &Default::default(), executor.clone(), )); + matches.sort_by(|a, b| { + let a_is_guest = room.role_for_user(a.candidate_id as u64) + == Some(proto::ChannelRole::Guest); + let b_is_guest = room.role_for_user(b.candidate_id as u64) + == Some(proto::ChannelRole::Guest); + a_is_guest + .cmp(&b_is_guest) + .then_with(|| a.string.cmp(&b.string)) + }); for mat in matches { let user_id = mat.candidate_id as u64; let participant = &room.remote_participants()[&user_id]; @@ -470,6 +448,7 @@ impl CollabPanel { user: participant.user.clone(), peer_id: Some(participant.peer_id), is_pending: false, + role: participant.role, }); let mut projects = participant.projects.iter().peekable(); while let Some(project) = projects.next() { @@ -488,15 +467,6 @@ impl CollabPanel { }); } } - if guest_count > 0 { - self.entries.insert( - guest_count_ix, - ListEntry::GuestCount { - count: guest_count, - has_visible_participants: non_guest_count > 0, - }, - ); - } // Populate pending participants. self.match_candidates.clear(); @@ -521,121 +491,125 @@ impl CollabPanel { user: room.pending_participants()[mat.candidate_id].clone(), peer_id: None, is_pending: true, + role: proto::ChannelRole::Member, })); } } let mut request_entries = Vec::new(); - if cx.has_flag::() { - self.entries.push(ListEntry::Header(Section::Channels)); + self.entries.push(ListEntry::Header(Section::Channels)); - if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() { - self.match_candidates.clear(); - self.match_candidates - .extend(channel_store.ordered_channels().enumerate().map( - |(ix, (_, channel))| StringMatchCandidate { + if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() { + self.match_candidates.clear(); + self.match_candidates + .extend( + channel_store + .ordered_channels() + .enumerate() + .map(|(ix, (_, channel))| StringMatchCandidate { id: ix, string: channel.name.clone().into(), char_bag: channel.name.chars().collect(), - }, - )); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - if let Some(state) = &self.channel_editing_state { - if matches!(state, ChannelEditingState::Create { location: None, .. }) { - self.entries.push(ListEntry::ChannelEditor { depth: 0 }); + }), + ); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + if let Some(state) = &self.channel_editing_state { + if matches!(state, ChannelEditingState::Create { location: None, .. }) { + self.entries.push(ListEntry::ChannelEditor { depth: 0 }); + } + } + let mut collapse_depth = None; + for mat in matches { + let channel = channel_store.channel_at_index(mat.candidate_id).unwrap(); + let depth = channel.parent_path.len(); + + if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) { + collapse_depth = Some(depth); + } else if let Some(collapsed_depth) = collapse_depth { + if depth > collapsed_depth { + continue; + } + if self.is_channel_collapsed(channel.id) { + collapse_depth = Some(depth); + } else { + collapse_depth = None; } } - let mut collapse_depth = None; - for mat in matches { - let channel = channel_store.channel_at_index(mat.candidate_id).unwrap(); - let depth = channel.parent_path.len(); - if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) { - collapse_depth = Some(depth); - } else if let Some(collapsed_depth) = collapse_depth { - if depth > collapsed_depth { - continue; - } - if self.is_channel_collapsed(channel.id) { - collapse_depth = Some(depth); - } else { - collapse_depth = None; - } - } + let has_children = channel_store + .channel_at_index(mat.candidate_id + 1) + .map_or(false, |next_channel| { + next_channel.parent_path.ends_with(&[channel.id]) + }); - let has_children = channel_store - .channel_at_index(mat.candidate_id + 1) - .map_or(false, |next_channel| { - next_channel.parent_path.ends_with(&[channel.id]) + match &self.channel_editing_state { + Some(ChannelEditingState::Create { + location: parent_id, + .. + }) if *parent_id == Some(channel.id) => { + self.entries.push(ListEntry::Channel { + channel: channel.clone(), + depth, + has_children: false, + }); + self.entries + .push(ListEntry::ChannelEditor { depth: depth + 1 }); + } + Some(ChannelEditingState::Rename { + location: parent_id, + .. + }) if parent_id == &channel.id => { + self.entries.push(ListEntry::ChannelEditor { depth }); + } + _ => { + self.entries.push(ListEntry::Channel { + channel: channel.clone(), + depth, + has_children, }); - - match &self.channel_editing_state { - Some(ChannelEditingState::Create { - location: parent_id, - .. - }) if *parent_id == Some(channel.id) => { - self.entries.push(ListEntry::Channel { - channel: channel.clone(), - depth, - has_children: false, - }); - self.entries - .push(ListEntry::ChannelEditor { depth: depth + 1 }); - } - Some(ChannelEditingState::Rename { - location: parent_id, - .. - }) if parent_id == &channel.id => { - self.entries.push(ListEntry::ChannelEditor { depth }); - } - _ => { - self.entries.push(ListEntry::Channel { - channel: channel.clone(), - depth, - has_children, - }); - } } } } + } - let channel_invites = channel_store.channel_invitations(); - if !channel_invites.is_empty() { - self.match_candidates.clear(); - self.match_candidates - .extend(channel_invites.iter().enumerate().map(|(ix, channel)| { - StringMatchCandidate { - id: ix, - string: channel.name.clone().into(), - char_bag: channel.name.chars().collect(), - } - })); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - request_entries.extend(matches.iter().map(|mat| { - ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone()) - })); - - if !request_entries.is_empty() { - self.entries - .push(ListEntry::Header(Section::ChannelInvites)); - if !self.collapsed_sections.contains(&Section::ChannelInvites) { - self.entries.append(&mut request_entries); + let channel_invites = channel_store.channel_invitations(); + if !channel_invites.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend(channel_invites.iter().enumerate().map(|(ix, channel)| { + StringMatchCandidate { + id: ix, + string: channel.name.clone().into(), + char_bag: channel.name.chars().collect(), } + })); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + request_entries.extend( + matches + .iter() + .map(|mat| ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())), + ); + + if !request_entries.is_empty() { + self.entries + .push(ListEntry::Header(Section::ChannelInvites)); + if !self.collapsed_sections.contains(&Section::ChannelInvites) { + self.entries.append(&mut request_entries); } } } @@ -834,13 +808,19 @@ impl CollabPanel { user: &Arc, peer_id: Option, is_pending: bool, + role: proto::ChannelRole, is_selected: bool, cx: &mut ViewContext, ) -> ListItem { + let user_id = user.id; let is_current_user = - self.user_store.read(cx).current_user().map(|user| user.id) == Some(user.id); + self.user_store.read(cx).current_user().map(|user| user.id) == Some(user_id); let tooltip = format!("Follow {}", user.github_login); + let is_call_admin = ActiveCall::global(cx).read(cx).room().is_some_and(|room| { + room.read(cx).local_participant().role == proto::ChannelRole::Admin + }); + ListItem::new(SharedString::from(user.github_login.clone())) .start_slot(Avatar::new(user.avatar_uri.clone())) .child(Label::new(user.github_login.clone())) @@ -848,22 +828,32 @@ impl CollabPanel { .end_slot(if is_pending { Label::new("Calling").color(Color::Muted).into_any_element() } else if is_current_user { - IconButton::new("leave-call", Icon::Exit) + IconButton::new("leave-call", IconName::Exit) .style(ButtonStyle::Subtle) .on_click(move |_, cx| Self::leave_call(cx)) .tooltip(|cx| Tooltip::text("Leave Call", cx)) .into_any_element() + } else if role == proto::ChannelRole::Guest { + Label::new("Guest").color(Color::Muted).into_any_element() } else { div().into_any_element() }) - .when_some(peer_id, |this, peer_id| { - this.tooltip(move |cx| Tooltip::text(tooltip.clone(), cx)) + .when_some(peer_id, |el, peer_id| { + if role == proto::ChannelRole::Guest { + return el; + } + el.tooltip(move |cx| Tooltip::text(tooltip.clone(), cx)) .on_click(cx.listener(move |this, _, cx| { this.workspace .update(cx, |workspace, cx| workspace.follow(peer_id, cx)) .ok(); })) }) + .when(is_call_admin, |el| { + el.on_secondary_mouse_down(cx.listener(move |this, event: &MouseDownEvent, cx| { + this.deploy_participant_context_menu(event.position, user_id, role, cx) + })) + }) } fn render_participant_project( @@ -894,10 +884,10 @@ impl CollabPanel { .ok(); })) .start_slot( - h_stack() + h_flex() .gap_1() .child(render_tree_branch(is_last, false, cx)) - .child(IconButton::new(0, Icon::Folder)), + .child(IconButton::new(0, IconName::Folder)), ) .child(Label::new(project_name.clone())) .tooltip(move |cx| Tooltip::text(format!("Open {}", project_name), cx)) @@ -915,10 +905,10 @@ impl CollabPanel { ListItem::new(("screen", id)) .selected(is_selected) .start_slot( - h_stack() + h_flex() .gap_1() .child(render_tree_branch(is_last, false, cx)) - .child(IconButton::new(0, Icon::Screen)), + .child(IconButton::new(0, IconName::Screen)), ) .child(Label::new("Screen")) .when_some(peer_id, |this, _| { @@ -956,10 +946,10 @@ impl CollabPanel { this.open_channel_notes(channel_id, cx); })) .start_slot( - h_stack() + h_flex() .gap_1() .child(render_tree_branch(false, true, cx)) - .child(IconButton::new(0, Icon::File)), + .child(IconButton::new(0, IconName::File)), ) .child(div().h_7().w_full().child(Label::new("notes"))) .tooltip(move |cx| Tooltip::text("Open Channel Notes", cx)) @@ -977,50 +967,15 @@ impl CollabPanel { this.join_channel_chat(channel_id, cx); })) .start_slot( - h_stack() + h_flex() .gap_1() .child(render_tree_branch(false, false, cx)) - .child(IconButton::new(0, Icon::MessageBubbles)), + .child(IconButton::new(0, IconName::MessageBubbles)), ) .child(Label::new("chat")) .tooltip(move |cx| Tooltip::text("Open Chat", cx)) } - fn render_guest_count( - &self, - count: usize, - has_visible_participants: bool, - is_selected: bool, - cx: &mut ViewContext, - ) -> impl IntoElement { - let manageable_channel_id = ActiveCall::global(cx).read(cx).room().and_then(|room| { - let room = room.read(cx); - if room.local_participant_is_admin() { - room.channel_id() - } else { - None - } - }); - - ListItem::new("guest_count") - .selected(is_selected) - .start_slot( - h_stack() - .gap_1() - .child(render_tree_branch(!has_visible_participants, false, cx)) - .child(""), - ) - .child(Label::new(if count == 1 { - format!("{} guest", count) - } else { - format!("{} guests", count) - })) - .when_some(manageable_channel_id, |el, channel_id| { - el.tooltip(move |cx| Tooltip::text("Manage Members", cx)) - .on_click(cx.listener(move |this, _, cx| this.manage_members(channel_id, cx))) - }) - } - fn has_subchannels(&self, ix: usize) -> bool { self.entries.get(ix).map_or(false, |entry| { if let ListEntry::Channel { has_children, .. } = entry { @@ -1031,6 +986,80 @@ impl CollabPanel { }) } + fn deploy_participant_context_menu( + &mut self, + position: Point, + user_id: u64, + role: proto::ChannelRole, + cx: &mut ViewContext, + ) { + let this = cx.view().clone(); + if !(role == proto::ChannelRole::Guest || role == proto::ChannelRole::Member) { + return; + } + + let context_menu = ContextMenu::build(cx, |context_menu, cx| { + if role == proto::ChannelRole::Guest { + context_menu.entry( + "Grant Write Access", + None, + cx.handler_for(&this, move |_, cx| { + ActiveCall::global(cx) + .update(cx, |call, cx| { + let Some(room) = call.room() else { + return Task::ready(Ok(())); + }; + room.update(cx, |room, cx| { + room.set_participant_role( + user_id, + proto::ChannelRole::Member, + cx, + ) + }) + }) + .detach_and_notify_err(cx) + }), + ) + } else if role == proto::ChannelRole::Member { + context_menu.entry( + "Revoke Write Access", + None, + cx.handler_for(&this, move |_, cx| { + ActiveCall::global(cx) + .update(cx, |call, cx| { + let Some(room) = call.room() else { + return Task::ready(Ok(())); + }; + room.update(cx, |room, cx| { + room.set_participant_role( + user_id, + proto::ChannelRole::Guest, + cx, + ) + }) + }) + .detach_and_notify_err(cx) + }), + ) + } else { + unreachable!() + } + }); + + cx.focus_view(&context_menu); + let subscription = + cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| { + if this.context_menu.as_ref().is_some_and(|context_menu| { + context_menu.0.focus_handle(cx).contains_focused(cx) + }) { + cx.focus_self(); + } + this.context_menu.take(); + cx.notify(); + }); + self.context_menu = Some((context_menu, position, subscription)); + } + fn deploy_channel_context_menu( &mut self, position: Point, @@ -1242,18 +1271,6 @@ impl CollabPanel { }); } } - ListEntry::GuestCount { .. } => { - let Some(room) = ActiveCall::global(cx).read(cx).room() else { - return; - }; - let room = room.read(cx); - let Some(channel_id) = room.channel_id() else { - return; - }; - if room.local_participant_is_admin() { - self.manage_members(channel_id, cx) - } - } ListEntry::Channel { channel, .. } => { let is_active = maybe!({ let call_channel = ActiveCall::global(cx) @@ -1393,14 +1410,6 @@ impl CollabPanel { self.toggle_channel_collapsed(id, cx) } - // fn toggle_channel_collapsed_action( - // &mut self, - // action: &ToggleCollapse, - // cx: &mut ViewContext, - // ) { - // self.toggle_channel_collapsed(action.location, cx); - // } - fn toggle_channel_collapsed<'a>(&mut self, channel_id: ChannelId, cx: &mut ViewContext) { match self.collapsed_channels.binary_search(&channel_id) { Ok(ix) => { @@ -1714,17 +1723,17 @@ impl CollabPanel { fn render_signed_out(&mut self, cx: &mut ViewContext) -> Div { let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more."; - v_stack() + v_flex() .gap_6() .p_4() .child(Label::new(collab_blurb)) .child( - v_stack() + v_flex() .gap_2() .child( Button::new("sign_in", "Sign in") .icon_color(Color::Muted) - .icon(Icon::Github) + .icon(IconName::Github) .icon_position(IconPosition::Start) .style(ButtonStyle::Filled) .full_width() @@ -1788,8 +1797,9 @@ impl CollabPanel { user, peer_id, is_pending, + role, } => self - .render_call_participant(user, *peer_id, *is_pending, is_selected, cx) + .render_call_participant(user, *peer_id, *is_pending, *role, is_selected, cx) .into_any_element(), ListEntry::ParticipantProject { project_id, @@ -1809,12 +1819,6 @@ impl CollabPanel { ListEntry::ParticipantScreen { peer_id, is_last } => self .render_participant_screen(*peer_id, *is_last, is_selected, cx) .into_any_element(), - ListEntry::GuestCount { - count, - has_visible_participants, - } => self - .render_guest_count(*count, *has_visible_participants, is_selected, cx) - .into_any_element(), ListEntry::ChannelNotes { channel_id } => self .render_channel_notes(*channel_id, is_selected, cx) .into_any_element(), @@ -1825,14 +1829,14 @@ impl CollabPanel { } fn render_signed_in(&mut self, cx: &mut ViewContext) -> Div { - v_stack() + v_flex() .size_full() .child(list(self.list_state.clone()).full()) .child( - v_stack() + v_flex() .child(div().mx_2().border_primary(cx).border_t()) .child( - v_stack() + v_flex() .p_2() .child(self.render_filter_input(&self.filter_editor, cx)), ), @@ -1882,7 +1886,6 @@ impl CollabPanel { let mut channel_link = None; let mut channel_tooltip_text = None; let mut channel_icon = None; - // let mut is_dragged_over = false; let text = match section { Section::ActiveCall => { @@ -1921,7 +1924,7 @@ impl CollabPanel { let button = match section { Section::ActiveCall => channel_link.map(|channel_link| { let channel_link_copy = channel_link.clone(); - IconButton::new("channel-link", Icon::Copy) + IconButton::new("channel-link", IconName::Copy) .icon_size(IconSize::Small) .size(ButtonSize::None) .visible_on_hover("section-header") @@ -1933,13 +1936,13 @@ impl CollabPanel { .into_any_element() }), Section::Contacts => Some( - IconButton::new("add-contact", Icon::Plus) + IconButton::new("add-contact", IconName::Plus) .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx))) .tooltip(|cx| Tooltip::text("Search for new contact", cx)) .into_any_element(), ), Section::Channels => Some( - IconButton::new("add-channel", Icon::Plus) + IconButton::new("add-channel", IconName::Plus) .on_click(cx.listener(|this, _, cx| this.new_root_channel(cx))) .tooltip(|cx| Tooltip::text("Create a channel", cx)) .into_any_element(), @@ -1955,7 +1958,7 @@ impl CollabPanel { | Section::Offline => true, }; - h_stack() + h_flex() .w_full() .group("section-header") .child( @@ -1994,43 +1997,49 @@ impl CollabPanel { let busy = contact.busy || calling; let user_id = contact.user.id; let github_login = SharedString::from(contact.user.github_login.clone()); - let item = - ListItem::new(github_login.clone()) - .indent_level(1) - .indent_step_size(px(20.)) - .selected(is_selected) - .on_click(cx.listener(move |this, _, cx| this.call(user_id, cx))) - .child( - h_stack() - .w_full() - .justify_between() - .child(Label::new(github_login.clone())) - .when(calling, |el| { - el.child(Label::new("Calling").color(Color::Muted)) - }) - .when(!calling, |el| { - el.child( - IconButton::new("remove_contact", Icon::Close) - .icon_color(Color::Muted) - .visible_on_hover("") - .tooltip(|cx| Tooltip::text("Remove Contact", cx)) - .on_click(cx.listener({ - let github_login = github_login.clone(); - move |this, _, cx| { - this.remove_contact(user_id, &github_login, cx); - } - })), - ) - }), - ) - .start_slot( - // todo!() handle contacts with no avatar - Avatar::new(contact.user.avatar_uri.clone()) - .availability_indicator(if online { Some(!busy) } else { None }), - ) - .when(online && !busy, |el| { - el.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx))) - }); + let item = ListItem::new(github_login.clone()) + .indent_level(1) + .indent_step_size(px(20.)) + .selected(is_selected) + .on_click(cx.listener(move |this, _, cx| this.call(user_id, cx))) + .child( + h_flex() + .w_full() + .justify_between() + .child(Label::new(github_login.clone())) + .when(calling, |el| { + el.child(Label::new("Calling").color(Color::Muted)) + }) + .when(!calling, |el| { + el.child( + IconButton::new("remove_contact", IconName::Close) + .icon_color(Color::Muted) + .visible_on_hover("") + .tooltip(|cx| Tooltip::text("Remove Contact", cx)) + .on_click(cx.listener({ + let github_login = github_login.clone(); + move |this, _, cx| { + this.remove_contact(user_id, &github_login, cx); + } + })), + ) + }), + ) + .start_slot( + // todo handle contacts with no avatar + Avatar::new(contact.user.avatar_uri.clone()) + .indicator::(if online { + Some(AvatarAvailabilityIndicator::new(match busy { + true => ui::Availability::Busy, + false => ui::Availability::Free, + })) + } else { + None + }), + ) + .when(online && !busy, |el| { + el.on_click(cx.listener(move |this, _, cx| this.call(user_id, cx))) + }); div() .id(github_login.clone()) @@ -2071,13 +2080,13 @@ impl CollabPanel { let controls = if is_incoming { vec![ - IconButton::new("decline-contact", Icon::Close) + IconButton::new("decline-contact", IconName::Close) .on_click(cx.listener(move |this, _, cx| { this.respond_to_contact_request(user_id, false, cx); })) .icon_color(color) .tooltip(|cx| Tooltip::text("Decline invite", cx)), - IconButton::new("accept-contact", Icon::Check) + IconButton::new("accept-contact", IconName::Check) .on_click(cx.listener(move |this, _, cx| { this.respond_to_contact_request(user_id, true, cx); })) @@ -2086,7 +2095,7 @@ impl CollabPanel { ] } else { let github_login = github_login.clone(); - vec![IconButton::new("remove_contact", Icon::Close) + vec![IconButton::new("remove_contact", IconName::Close) .on_click(cx.listener(move |this, _, cx| { this.remove_contact(user_id, &github_login, cx); })) @@ -2099,11 +2108,11 @@ impl CollabPanel { .indent_step_size(px(20.)) .selected(is_selected) .child( - h_stack() + h_flex() .w_full() .justify_between() .child(Label::new(github_login.clone())) - .child(h_stack().children(controls)), + .child(h_flex().children(controls)), ) .start_slot(Avatar::new(user.avatar_uri.clone())) } @@ -2126,13 +2135,13 @@ impl CollabPanel { }; let controls = [ - IconButton::new("reject-invite", Icon::Close) + IconButton::new("reject-invite", IconName::Close) .on_click(cx.listener(move |this, _, cx| { this.respond_to_channel_invite(channel_id, false, cx); })) .icon_color(color) .tooltip(|cx| Tooltip::text("Decline invite", cx)), - IconButton::new("accept-invite", Icon::Check) + IconButton::new("accept-invite", IconName::Check) .on_click(cx.listener(move |this, _, cx| { this.respond_to_channel_invite(channel_id, true, cx); })) @@ -2143,14 +2152,14 @@ impl CollabPanel { ListItem::new(("channel-invite", channel.id as usize)) .selected(is_selected) .child( - h_stack() + h_flex() .w_full() .justify_between() .child(Label::new(channel.name.clone())) - .child(h_stack().children(controls)), + .child(h_flex().children(controls)), ) .start_slot( - IconElement::new(Icon::Hash) + Icon::new(IconName::Hash) .size(IconSize::Small) .color(Color::Muted), ) @@ -2162,7 +2171,7 @@ impl CollabPanel { cx: &mut ViewContext, ) -> ListItem { ListItem::new("contact-placeholder") - .child(IconElement::new(Icon::Plus)) + .child(Icon::new(IconName::Plus)) .child(Label::new("Add a Contact")) .selected(is_selected) .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx))) @@ -2228,47 +2237,6 @@ impl CollabPanel { None }; - let button_container = |cx: &mut ViewContext| { - h_stack() - .absolute() - // We're using a negative coordinate for the right anchor to - // counteract the padding of the `ListItem`. - // - // This prevents a gap from showing up between the background - // of this element and the edge of the collab panel. - .right(rems(-0.5)) - // HACK: Without this the channel name clips on top of the icons, but I'm not sure why. - .z_index(10) - .bg(cx.theme().colors().panel_background) - .when(is_selected || is_active, |this| { - this.bg(cx.theme().colors().ghost_element_selected) - }) - }; - - let messages_button = |cx: &mut ViewContext| { - IconButton::new("channel_chat", Icon::MessageBubbles) - .icon_size(IconSize::Small) - .icon_color(if has_messages_notification { - Color::Default - } else { - Color::Muted - }) - .on_click(cx.listener(move |this, _, cx| this.join_channel_chat(channel_id, cx))) - .tooltip(|cx| Tooltip::text("Open channel chat", cx)) - }; - - let notes_button = |cx: &mut ViewContext| { - IconButton::new("channel_notes", Icon::File) - .icon_size(IconSize::Small) - .icon_color(if has_notes_notification { - Color::Default - } else { - Color::Muted - }) - .on_click(cx.listener(move |this, _, cx| this.open_channel_notes(channel_id, cx))) - .tooltip(|cx| Tooltip::text("Open channel notes", cx)) - }; - let width = self.width.unwrap_or(px(240.)); div() @@ -2315,65 +2283,69 @@ impl CollabPanel { }, )) .start_slot( - IconElement::new(if is_public { Icon::Public } else { Icon::Hash }) - .size(IconSize::Small) - .color(Color::Muted), + Icon::new(if is_public { + IconName::Public + } else { + IconName::Hash + }) + .size(IconSize::Small) + .color(Color::Muted), ) .child( - h_stack() + h_flex() .id(channel_id as usize) - // HACK: This is a dirty hack to help with the positioning of the button container. - // - // We're using a pixel width for the elements but then allowing the contents to - // overflow. This means that the label and facepile will be shown, but will not - // push the button container off the edge of the panel. - .w_px() .child(Label::new(channel.name.clone())) - .children(face_pile.map(|face_pile| face_pile.render(cx))), - ) - .end_slot::
( - // If we have a notification for either button, we want to show the corresponding - // button(s) as indicators. - if has_messages_notification || has_notes_notification { - Some( - button_container(cx).child( - h_stack() - .px_1() - .children( - // We only want to render the messages button if there are unseen messages. - // This way we don't take up any space that might overlap the channel name - // when there are no notifications. - has_messages_notification.then(|| messages_button(cx)), - ) - .child( - // We always want the notes button to take up space to prevent layout - // shift when hovering over the channel. - // However, if there are is no notes notification we just show an empty slot. - notes_button(cx) - .when(!has_notes_notification, |this| { - this.visible_on_hover("") - }), - ), - ), + .children(face_pile.map(|face_pile| face_pile.render().p_1())), + ), + ) + .child( + h_flex() + .absolute() + .right(rems(0.)) + .h_full() + // HACK: Without this the channel name clips on top of the icons, but I'm not sure why. + .z_index(10) + .child( + h_flex() + .h_full() + .gap_1() + .px_1() + .child( + IconButton::new("channel_chat", IconName::MessageBubbles) + .style(ButtonStyle::Filled) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(if has_messages_notification { + Color::Default + } else { + Color::Muted + }) + .on_click(cx.listener(move |this, _, cx| { + this.join_channel_chat(channel_id, cx) + })) + .tooltip(|cx| Tooltip::text("Open channel chat", cx)) + .when(!has_messages_notification, |this| { + this.visible_on_hover("") + }), ) - } else { - None - }, - ) - .end_hover_slot( - // When we hover the channel entry we want to always show both buttons. - button_container(cx).child( - h_stack() - .px_1() - // The element hover background has a slight transparency to it, so we - // need to apply it to the inner element so that it blends with the solid - // background color of the absolutely-positioned element. - .group_hover("", |style| { - style.bg(cx.theme().colors().ghost_element_hover) - }) - .child(messages_button(cx)) - .child(notes_button(cx)), - ), + .child( + IconButton::new("channel_notes", IconName::File) + .style(ButtonStyle::Filled) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(if has_notes_notification { + Color::Default + } else { + Color::Muted + }) + .on_click(cx.listener(move |this, _, cx| { + this.open_channel_notes(channel_id, cx) + })) + .tooltip(|cx| Tooltip::text("Open channel notes", cx)) + .when(!has_notes_notification, |this| { + this.visible_on_hover("") + }), + ), ), ) .tooltip(|cx| Tooltip::text("Join channel", cx)) @@ -2386,7 +2358,7 @@ impl CollabPanel { .indent_level(depth + 1) .indent_step_size(px(20.)) .start_slot( - IconElement::new(Icon::Hash) + Icon::new(IconName::Hash) .size(IconSize::Small) .color(Color::Muted), ); @@ -2441,7 +2413,7 @@ fn render_tree_branch(is_last: bool, overdraw: bool, cx: &mut WindowContext) -> impl Render for CollabPanel { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - v_stack() + v_flex() .key_context("CollabPanel") .on_action(cx.listener(CollabPanel::cancel)) .on_action(cx.listener(CollabPanel::select_next)) @@ -2500,10 +2472,10 @@ impl Panel for CollabPanel { cx.notify(); } - fn icon(&self, cx: &gpui::WindowContext) -> Option { + fn icon(&self, cx: &gpui::WindowContext) -> Option { CollaborationPanelSettings::get_global(cx) .button - .then(|| ui::Icon::Collab) + .then(|| ui::IconName::Collab) } fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> { @@ -2621,11 +2593,6 @@ impl PartialEq for ListEntry { return true; } } - ListEntry::GuestCount { .. } => { - if let ListEntry::GuestCount { .. } = other { - return true; - } - } } false } @@ -2639,18 +2606,18 @@ struct DraggedChannelView { impl Render for DraggedChannelView { fn render(&mut self, cx: &mut ViewContext) -> impl Element { let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone(); - h_stack() + h_flex() .font(ui_font) .bg(cx.theme().colors().background) .w(self.width) .p_1() .gap_1() .child( - IconElement::new( + Icon::new( if self.channel.visibility == proto::ChannelVisibility::Public { - Icon::Public + IconName::Public } else { - Icon::Hash + IconName::Hash }, ) .size(IconSize::Small) diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index f3ae16f793..e92422d76d 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -111,7 +111,7 @@ impl ChannelModal { .detach(); } - fn set_channel_visiblity(&mut self, selection: &Selection, cx: &mut ViewContext) { + fn set_channel_visibility(&mut self, selection: &Selection, cx: &mut ViewContext) { self.channel_store.update(cx, |channel_store, cx| { channel_store .set_channel_visibility( @@ -152,33 +152,33 @@ impl Render for ChannelModal { let visibility = channel.visibility; let mode = self.picker.read(cx).delegate.mode; - v_stack() + v_flex() .key_context("ChannelModal") .on_action(cx.listener(Self::toggle_mode)) .on_action(cx.listener(Self::dismiss)) .elevation_3(cx) .w(rems(34.)) .child( - v_stack() + v_flex() .px_2() .py_1() .gap_2() .child( - h_stack() + h_flex() .w_px() .flex_1() .gap_1() - .child(IconElement::new(Icon::Hash).size(IconSize::Medium)) + .child(Icon::new(IconName::Hash).size(IconSize::Medium)) .child(Label::new(channel_name)), ) .child( - h_stack() + h_flex() .w_full() .h(rems(22. / 16.)) .justify_between() .line_height(rems(1.25)) .child( - h_stack() + h_flex() .gap_2() .child( Checkbox::new( @@ -189,7 +189,7 @@ impl Render for ChannelModal { ui::Selection::Unselected }, ) - .on_click(cx.listener(Self::set_channel_visiblity)), + .on_click(cx.listener(Self::set_channel_visibility)), ) .child(Label::new("Public").size(LabelSize::Small)), ) @@ -212,7 +212,7 @@ impl Render for ChannelModal { ), ) .child( - h_stack() + h_flex() .child( div() .id("manage-members") @@ -348,6 +348,10 @@ impl PickerDelegate for ChannelModalDelegate { fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { if let Some((selected_user, role)) = self.user_at_index(self.selected_index) { + if Some(selected_user.id) == self.user_store.read(cx).current_user().map(|user| user.id) + { + return; + } match self.mode { Mode::ManageMembers => { self.show_context_menu(selected_user, role.unwrap_or(ChannelRole::Member), cx) @@ -383,6 +387,7 @@ impl PickerDelegate for ChannelModalDelegate { ) -> Option { let (user, role) = self.user_at_index(ix)?; let request_status = self.member_status(user.id, cx); + let is_me = self.user_store.read(cx).current_user().map(|user| user.id) == Some(user.id); Some( ListItem::new(ix) @@ -391,7 +396,7 @@ impl PickerDelegate for ChannelModalDelegate { .selected(selected) .start_slot(Avatar::new(user.avatar_uri.clone())) .child(Label::new(user.github_login.clone())) - .end_slot(h_stack().gap_2().map(|slot| { + .end_slot(h_flex().gap_2().map(|slot| { match self.mode { Mode::ManageMembers => slot .children( @@ -406,7 +411,10 @@ impl PickerDelegate for ChannelModalDelegate { Some(ChannelRole::Guest) => Some(Label::new("Guest")), _ => None, }) - .child(IconButton::new("ellipsis", Icon::Ellipsis)) + .when(!is_me, |el| { + el.child(IconButton::new("ellipsis", IconName::Ellipsis)) + }) + .when(is_me, |el| el.child(Label::new("You").color(Color::Muted))) .children( if let (Some((menu, _)), true) = (&self.context_menu, selected) { Some( diff --git a/crates/collab_ui/src/collab_panel/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs index dbcacef7d6..2c59df2eb5 100644 --- a/crates/collab_ui/src/collab_panel/contact_finder.rs +++ b/crates/collab_ui/src/collab_panel/contact_finder.rs @@ -36,17 +36,17 @@ impl ContactFinder { impl Render for ContactFinder { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - v_stack() + v_flex() .elevation_3(cx) .child( - v_stack() + v_flex() .px_2() .py_1() .bg(cx.theme().colors().element_background) // HACK: Prevent the background color from overflowing the parent container. .rounded_t(px(8.)) .child(Label::new("Contacts")) - .child(h_stack().child(Label::new("Invite new contacts"))), + .child(h_flex().child(Label::new("Invite new contacts"))), ) .child(self.picker.clone()) .w(rems(34.)) @@ -155,9 +155,7 @@ impl PickerDelegate for ContactFinderDelegate { .selected(selected) .start_slot(Avatar::new(user.avatar_uri.clone())) .child(Label::new(user.github_login.clone())) - .end_slot::( - icon_path.map(|icon_path| IconElement::from_path(icon_path)), - ), + .end_slot::(icon_path.map(|icon_path| Icon::from_path(icon_path))), ) } } diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 6ccad2db0d..e864c4c54a 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -1,9 +1,9 @@ use crate::face_pile::FacePile; use auto_update::AutoUpdateStatus; use call::{ActiveCall, ParticipantLocation, Room}; -use client::{proto::PeerId, Client, ParticipantIndex, User, UserStore}; +use client::{proto::PeerId, Client, User, UserStore}; use gpui::{ - actions, canvas, div, point, px, rems, Action, AnyElement, AppContext, Element, Hsla, + actions, canvas, div, point, px, Action, AnyElement, AppContext, Element, Hsla, InteractiveElement, IntoElement, Model, ParentElement, Path, Render, StatefulInteractiveElement, Styled, Subscription, View, ViewContext, VisualContext, WeakView, WindowBounds, @@ -12,14 +12,14 @@ use project::{Project, RepositoryEntry}; use recent_projects::RecentProjects; use rpc::proto; use std::sync::Arc; -use theme::{ActiveTheme, PlayerColors}; +use theme::ActiveTheme; use ui::{ - h_stack, popover_menu, prelude::*, Avatar, Button, ButtonLike, ButtonStyle, ContextMenu, Icon, - IconButton, IconElement, TintColor, Tooltip, + h_flex, popover_menu, prelude::*, Avatar, AvatarAudioStatusIndicator, Button, ButtonLike, + ButtonStyle, ContextMenu, Icon, IconButton, IconName, TintColor, Tooltip, }; use util::ResultExt; use vcs_menu::{build_branch_list, BranchList, OpenRecent as ToggleVcsMenu}; -use workspace::{notifications::NotifyResultExt, Workspace}; +use workspace::{notifications::NotifyResultExt, titlebar_height, Workspace}; const MAX_PROJECT_NAME_LENGTH: usize = 40; const MAX_BRANCH_NAME_LENGTH: usize = 40; @@ -41,12 +41,6 @@ pub fn init(cx: &mut AppContext) { workspace.set_titlebar_item(titlebar_item.into(), cx) }) .detach(); - // todo!() - // cx.add_action(CollabTitlebarItem::share_project); - // cx.add_action(CollabTitlebarItem::unshare_project); - // cx.add_action(CollabTitlebarItem::toggle_user_menu); - // cx.add_action(CollabTitlebarItem::toggle_vcs_menu); - // cx.add_action(CollabTitlebarItem::toggle_project_menu); } pub struct CollabTitlebarItem { @@ -63,15 +57,13 @@ impl Render for CollabTitlebarItem { let current_user = self.user_store.read(cx).current_user(); let client = self.client.clone(); let project_id = self.project.read(cx).remote_id(); + let workspace = self.workspace.upgrade(); - h_stack() + h_flex() .id("titlebar") .justify_between() .w_full() - .h(rems(1.75)) - // Set a non-scaling min-height here to ensure the titlebar is - // always at least the height of the traffic lights. - .min_h(px(32.)) + .h(titlebar_height(cx)) .map(|this| { if matches!(cx.window_bounds(), WindowBounds::Fullscreen) { this.pl_2() @@ -89,7 +81,7 @@ impl Render for CollabTitlebarItem { }) // left side .child( - h_stack() + h_flex() .gap_1() .children(self.render_project_host(cx)) .child(self.render_project_name(cx)) @@ -103,19 +95,32 @@ impl Render for CollabTitlebarItem { room.remote_participants().values().collect::>(); remote_participants.sort_by_key(|p| p.participant_index.0); - this.children(self.render_collaborator( + let current_user_face_pile = self.render_collaborator( ¤t_user, peer_id, true, room.is_speaking(), - room.is_muted(cx), + room.is_muted(), + None, &room, project_id, ¤t_user, cx, - )) + ); + + this.children(current_user_face_pile.map(|face_pile| { + v_flex() + .child(face_pile) + .child(render_color_ribbon(player_colors.local().cursor)) + })) .children( remote_participants.iter().filter_map(|collaborator| { + let player_color = player_colors + .color_for_participant(collaborator.participant_index.0); + let is_following = workspace + .as_ref()? + .read(cx) + .is_being_followed(collaborator.peer_id); let is_present = project_id.map_or(false, |project_id| { collaborator.location == ParticipantLocation::SharedProject { project_id } @@ -127,6 +132,7 @@ impl Render for CollabTitlebarItem { is_present, collaborator.speaking, collaborator.muted, + is_following.then_some(player_color.selection), &room, project_id, ¤t_user, @@ -134,13 +140,10 @@ impl Render for CollabTitlebarItem { )?; Some( - v_stack() + v_flex() .id(("collaborator", collaborator.user.id)) .child(face_pile) - .child(render_color_ribbon( - collaborator.participant_index, - player_colors, - )) + .child(render_color_ribbon(player_color.cursor)) .cursor_pointer() .on_click({ let peer_id = collaborator.peer_id; @@ -166,7 +169,7 @@ impl Render for CollabTitlebarItem { ) // right side .child( - h_stack() + h_flex() .gap_1() .pr_1() .when_some(room, |this, room| { @@ -174,7 +177,7 @@ impl Render for CollabTitlebarItem { let project = self.project.read(cx); let is_local = project.is_local(); let is_shared = is_local && project.is_shared(); - let is_muted = room.is_muted(cx); + let is_muted = room.is_muted(); let is_deafened = room.is_deafened().unwrap_or(false); let is_screen_sharing = room.is_screen_sharing(); let read_only = room.read_only(); @@ -213,7 +216,7 @@ impl Render for CollabTitlebarItem { .child( div() .child( - IconButton::new("leave-call", ui::Icon::Exit) + IconButton::new("leave-call", ui::IconName::Exit) .style(ButtonStyle::Subtle) .tooltip(|cx| Tooltip::text("Leave call", cx)) .icon_size(IconSize::Small) @@ -230,9 +233,9 @@ impl Render for CollabTitlebarItem { IconButton::new( "mute-microphone", if is_muted { - ui::Icon::MicMute + ui::IconName::MicMute } else { - ui::Icon::Mic + ui::IconName::Mic }, ) .tooltip(move |cx| { @@ -256,9 +259,9 @@ impl Render for CollabTitlebarItem { IconButton::new( "mute-sound", if is_deafened { - ui::Icon::AudioOff + ui::IconName::AudioOff } else { - ui::Icon::AudioOn + ui::IconName::AudioOn }, ) .style(ButtonStyle::Subtle) @@ -281,7 +284,7 @@ impl Render for CollabTitlebarItem { ) .when(!read_only, |this| { this.child( - IconButton::new("screen-share", ui::Icon::Screen) + IconButton::new("screen-share", ui::IconName::Screen) .style(ButtonStyle::Subtle) .icon_size(IconSize::Small) .selected(is_screen_sharing) @@ -318,8 +321,7 @@ impl Render for CollabTitlebarItem { } } -fn render_color_ribbon(participant_index: ParticipantIndex, colors: &PlayerColors) -> gpui::Canvas { - let color = colors.color_for_participant(participant_index.0).cursor; +fn render_color_ribbon(color: Hsla) -> gpui::Canvas { canvas(move |bounds, cx| { let height = bounds.size.height; let horizontal_offset = height; @@ -469,43 +471,86 @@ impl CollabTitlebarItem { is_present: bool, is_speaking: bool, is_muted: bool, + leader_selection_color: Option, room: &Room, project_id: Option, current_user: &Arc, cx: &ViewContext, - ) -> Option { + ) -> Option
{ if room.role_for_user(user.id) == Some(proto::ChannelRole::Guest) { return None; } + const FACEPILE_LIMIT: usize = 3; let followers = project_id.map_or(&[] as &[_], |id| room.followers_for(peer_id, id)); + let extra_count = followers.len().saturating_sub(FACEPILE_LIMIT); - let pile = FacePile::default() - .child( - Avatar::new(user.avatar_uri.clone()) - .grayscale(!is_present) - .border_color(if is_speaking { - cx.theme().status().info_border - } else if is_muted { - cx.theme().status().error_border - } else { - Hsla::default() - }), - ) - .children(followers.iter().filter_map(|follower_peer_id| { - let follower = room - .remote_participants() - .values() - .find_map(|p| (p.peer_id == *follower_peer_id).then_some(&p.user)) - .or_else(|| { - (self.client.peer_id() == Some(*follower_peer_id)).then_some(current_user) - })? - .clone(); + Some( + div() + .m_0p5() + .p_0p5() + // When the collaborator is not followed, still draw this wrapper div, but leave + // it transparent, so that it does not shift the layout when following. + .when_some(leader_selection_color, |div, color| { + div.rounded_md().bg(color) + }) + .child( + FacePile::default() + .child( + Avatar::new(user.avatar_uri.clone()) + .grayscale(!is_present) + .border_color(if is_speaking { + cx.theme().status().info + } else { + // We draw the border in a transparent color rather to avoid + // the layout shift that would come with adding/removing the border. + gpui::transparent_black() + }) + .when(is_muted, |avatar| { + avatar.indicator( + AvatarAudioStatusIndicator::new(ui::AudioStatus::Muted) + .tooltip({ + let github_login = user.github_login.clone(); + move |cx| { + Tooltip::text( + format!("{} is muted", github_login), + cx, + ) + } + }), + ) + }), + ) + .children(followers.iter().take(FACEPILE_LIMIT).filter_map( + |follower_peer_id| { + let follower = room + .remote_participants() + .values() + .find_map(|p| { + (p.peer_id == *follower_peer_id).then_some(&p.user) + }) + .or_else(|| { + (self.client.peer_id() == Some(*follower_peer_id)) + .then_some(current_user) + })? + .clone(); - Some(Avatar::new(follower.avatar_uri.clone())) - })); - - Some(pile) + Some(Avatar::new(follower.avatar_uri.clone())) + }, + )) + .children(if extra_count > 0 { + Some( + div() + .ml_1() + .child(Label::new(format!("+{extra_count}"))) + .into_any_element(), + ) + } else { + None + }) + .render(), + ), + ) } fn window_activation_changed(&mut self, cx: &mut ViewContext) { @@ -573,7 +618,7 @@ impl CollabTitlebarItem { | client::Status::ReconnectionError { .. } => Some( div() .id("disconnected") - .child(IconElement::new(Icon::Disconnected).size(IconSize::Small)) + .child(Icon::new(IconName::Disconnected).size(IconSize::Small)) .tooltip(|cx| Tooltip::text("Disconnected", cx)) .into_any_element(), ), @@ -640,10 +685,10 @@ impl CollabTitlebarItem { .trigger( ButtonLike::new("user-menu") .child( - h_stack() + h_flex() .gap_0p5() .child(Avatar::new(user.avatar_uri.clone())) - .child(IconElement::new(Icon::ChevronDown).color(Color::Muted)), + .child(Icon::new(IconName::ChevronDown).color(Color::Muted)), ) .style(ButtonStyle::Subtle) .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)), @@ -663,9 +708,9 @@ impl CollabTitlebarItem { .trigger( ButtonLike::new("user-menu") .child( - h_stack() + h_flex() .gap_0p5() - .child(IconElement::new(Icon::ChevronDown).color(Color::Muted)), + .child(Icon::new(IconName::ChevronDown).color(Color::Muted)), ) .style(ButtonStyle::Subtle) .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)), diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index 3c0473e67d..befdf5db74 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -9,10 +9,9 @@ mod panel_settings; use std::{rc::Rc, sync::Arc}; -use call::{report_call_event_for_room, ActiveCall, Room}; +use call::{report_call_event_for_room, ActiveCall}; pub use collab_panel::CollabPanel; pub use collab_titlebar_item::CollabTitlebarItem; -use feature_flags::{ChannelsAlpha, FeatureFlagAppExt}; use gpui::{ actions, point, AppContext, GlobalPixels, Pixels, PlatformDisplay, Size, Task, WindowBounds, WindowKind, WindowOptions, @@ -21,7 +20,6 @@ pub use panel_settings::{ ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings, }; use settings::Settings; -use util::ResultExt; use workspace::AppState; actions!( @@ -41,10 +39,6 @@ pub fn init(app_state: &Arc, cx: &mut AppContext) { chat_panel::init(cx); notification_panel::init(cx); notifications::init(&app_state, cx); - - // cx.add_global_action(toggle_screen_sharing); - // cx.add_global_action(toggle_mute); - // cx.add_global_action(toggle_deafen); } pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) { @@ -79,7 +73,7 @@ pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) { if let Some(room) = call.room().cloned() { let client = call.client(); room.update(cx, |room, cx| { - let operation = if room.is_muted(cx) { + let operation = if room.is_muted() { "enable microphone" } else { "disable microphone" @@ -87,17 +81,13 @@ pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) { report_call_event_for_room(operation, room.id(), room.channel_id(), &client); room.toggle_mute(cx) - }) - .map(|task| task.detach_and_log_err(cx)) - .log_err(); + }); } } pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) { if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { - room.update(cx, Room::toggle_deafen) - .map(|task| task.detach_and_log_err(cx)) - .log_err(); + room.update(cx, |room, cx| room.toggle_deafen(cx)); } } @@ -111,7 +101,6 @@ fn notification_window_options( let screen_bounds = screen.bounds(); let size: Size = window_size.into(); - // todo!() use content bounds instead of screen.bounds and get rid of magics in point's 2nd argument. let bounds = gpui::Bounds:: { origin: screen_bounds.upper_right() - point( @@ -131,35 +120,3 @@ fn notification_window_options( display_id: Some(screen.id()), } } - -// fn render_avatar( -// avatar: Option>, -// avatar_style: &AvatarStyle, -// container: ContainerStyle, -// ) -> AnyElement { -// avatar -// .map(|avatar| { -// Image::from_data(avatar) -// .with_style(avatar_style.image) -// .aligned() -// .contained() -// .with_corner_radius(avatar_style.outer_corner_radius) -// .constrained() -// .with_width(avatar_style.outer_width) -// .with_height(avatar_style.outer_width) -// .into_any() -// }) -// .unwrap_or_else(|| { -// Empty::new() -// .constrained() -// .with_width(avatar_style.outer_width) -// .into_any() -// }) -// .contained() -// .with_style(container) -// .into_any() -// } - -fn is_channels_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool { - cx.is_staff() || cx.has_flag::() -} diff --git a/crates/collab_ui/src/face_pile.rs b/crates/collab_ui/src/face_pile.rs index fb6c59cc80..985c1944f4 100644 --- a/crates/collab_ui/src/face_pile.rs +++ b/crates/collab_ui/src/face_pile.rs @@ -1,13 +1,13 @@ -use gpui::{div, AnyElement, IntoElement, ParentElement, RenderOnce, Styled, WindowContext}; +use gpui::{div, AnyElement, Div, IntoElement, ParentElement, Styled}; use smallvec::SmallVec; -#[derive(Default, IntoElement)] +#[derive(Default)] pub struct FacePile { pub faces: SmallVec<[AnyElement; 2]>, } -impl RenderOnce for FacePile { - fn render(self, _: &mut WindowContext) -> impl IntoElement { +impl FacePile { + pub fn render(self) -> Div { let player_count = self.faces.len(); let player_list = self.faces.into_iter().enumerate().map(|(ix, player)| { let isnt_last = ix < player_count - 1; @@ -17,7 +17,7 @@ impl RenderOnce for FacePile { .when(isnt_last, |div| div.neg_mr_1()) .child(player) }); - div().p_1().flex().items_center().children(player_list) + div().flex().items_center().children(player_list) } } diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index e7c94984b2..b30f8d15f0 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; use std::{sync::Arc, time::Duration}; use time::{OffsetDateTime, UtcOffset}; -use ui::{h_stack, prelude::*, v_stack, Avatar, Button, Icon, IconButton, IconElement, Label}; +use ui::{h_flex, prelude::*, v_flex, Avatar, Button, Icon, IconButton, IconName, Label}; use util::{ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, @@ -251,13 +251,13 @@ impl NotificationPanel { .rounded_full() })) .child( - v_stack() + v_flex() .gap_1() .size_full() .overflow_hidden() .child(Label::new(text.clone())) .child( - h_stack() + h_flex() .child( Label::new(format_timestamp( timestamp, @@ -276,7 +276,7 @@ impl NotificationPanel { ))) } else if needs_response { Some( - h_stack() + h_flex() .flex_grow() .justify_end() .child(Button::new("decline", "Decline").on_click({ @@ -541,30 +541,30 @@ impl NotificationPanel { impl Render for NotificationPanel { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - v_stack() + v_flex() .size_full() .child( - h_stack() + h_flex() .justify_between() .px_2() .py_1() // Match the height of the tab bar so they line up. - .h(rems(ui::Tab::HEIGHT_IN_REMS)) + .h(rems(ui::Tab::CONTAINER_HEIGHT_IN_REMS)) .border_b_1() .border_color(cx.theme().colors().border) .child(Label::new("Notifications")) - .child(IconElement::new(Icon::Envelope)), + .child(Icon::new(IconName::Envelope)), ) .map(|this| { if self.client.user_id().is_none() { this.child( - v_stack() + v_flex() .gap_2() .p_4() .child( Button::new("sign_in_prompt_button", "Sign in") .icon_color(Color::Muted) - .icon(Icon::Github) + .icon(IconName::Github) .icon_position(IconPosition::Start) .style(ButtonStyle::Filled) .full_width() @@ -592,7 +592,7 @@ impl Render for NotificationPanel { ) } else if self.notification_list.item_count() == 0 { this.child( - v_stack().p_4().child( + v_flex().p_4().child( div().flex().w_full().items_center().child( Label::new("You have no notifications.") .color(Color::Muted) @@ -655,10 +655,10 @@ impl Panel for NotificationPanel { } } - fn icon(&self, cx: &gpui::WindowContext) -> Option { + fn icon(&self, cx: &gpui::WindowContext) -> Option { (NotificationPanelSettings::get_global(cx).button && self.notification_store.read(cx).notification_count() > 0) - .then(|| Icon::Bell) + .then(|| IconName::Bell) } fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> { @@ -711,12 +711,12 @@ impl Render for NotificationToast { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let user = self.actor.clone(); - h_stack() + h_flex() .id("notification_panel_toast") .children(user.map(|user| Avatar::new(user.avatar_uri.clone()))) .child(Label::new(self.text.clone())) .child( - IconButton::new("close", Icon::Close) + IconButton::new("close", IconName::Close) .on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))), ) .on_click(cx.listener(|this, _, cx| { diff --git a/crates/collab_ui/src/notifications.rs b/crates/collab_ui/src/notifications.rs index 5c184ec5c8..7759fef520 100644 --- a/crates/collab_ui/src/notifications.rs +++ b/crates/collab_ui/src/notifications.rs @@ -1,9 +1,16 @@ +mod collab_notification; +pub mod incoming_call_notification; +pub mod project_shared_notification; + +#[cfg(feature = "stories")] +mod stories; + use gpui::AppContext; use std::sync::Arc; use workspace::AppState; -pub mod incoming_call_notification; -pub mod project_shared_notification; +#[cfg(feature = "stories")] +pub use stories::*; pub fn init(app_state: &Arc, cx: &mut AppContext) { incoming_call_notification::init(app_state, cx); diff --git a/crates/collab_ui/src/notifications/collab_notification.rs b/crates/collab_ui/src/notifications/collab_notification.rs new file mode 100644 index 0000000000..8157bc1318 --- /dev/null +++ b/crates/collab_ui/src/notifications/collab_notification.rs @@ -0,0 +1,52 @@ +use gpui::{img, prelude::*, AnyElement, SharedUrl}; +use smallvec::SmallVec; +use ui::prelude::*; + +#[derive(IntoElement)] +pub struct CollabNotification { + avatar_uri: SharedUrl, + accept_button: Button, + dismiss_button: Button, + children: SmallVec<[AnyElement; 2]>, +} + +impl CollabNotification { + pub fn new( + avatar_uri: impl Into, + accept_button: Button, + dismiss_button: Button, + ) -> Self { + Self { + avatar_uri: avatar_uri.into(), + accept_button, + dismiss_button, + children: SmallVec::new(), + } + } +} + +impl ParentElement for CollabNotification { + fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> { + &mut self.children + } +} + +impl RenderOnce for CollabNotification { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + h_flex() + .text_ui() + .justify_between() + .size_full() + .overflow_hidden() + .elevation_3(cx) + .p_2() + .gap_2() + .child(img(self.avatar_uri).w_12().h_12().rounded_full()) + .child(v_flex().overflow_hidden().children(self.children)) + .child( + v_flex() + .child(self.accept_button) + .child(self.dismiss_button), + ) + } +} diff --git a/crates/collab_ui/src/notifications/incoming_call_notification.rs b/crates/collab_ui/src/notifications/incoming_call_notification.rs index fa28ef9a60..f66194c52a 100644 --- a/crates/collab_ui/src/notifications/incoming_call_notification.rs +++ b/crates/collab_ui/src/notifications/incoming_call_notification.rs @@ -1,15 +1,12 @@ use crate::notification_window_options; +use crate::notifications::collab_notification::CollabNotification; use call::{ActiveCall, IncomingCall}; use futures::StreamExt; -use gpui::{ - img, px, AppContext, ParentElement, Render, RenderOnce, Styled, ViewContext, - VisualContext as _, WindowHandle, -}; +use gpui::{prelude::*, AppContext, WindowHandle}; use settings::Settings; use std::sync::{Arc, Weak}; use theme::ThemeSettings; -use ui::prelude::*; -use ui::{h_stack, v_stack, Button, Label}; +use ui::{prelude::*, Button, Label}; use util::ResultExt; use workspace::AppState; @@ -22,7 +19,6 @@ pub fn init(app_state: &Arc, cx: &mut AppContext) { for window in notification_windows.drain(..) { window .update(&mut cx, |_, cx| { - // todo!() cx.remove_window(); }) .log_err(); @@ -31,8 +27,8 @@ pub fn init(app_state: &Arc, cx: &mut AppContext) { if let Some(incoming_call) = incoming_call { let unique_screens = cx.update(|cx| cx.displays()).unwrap(); let window_size = gpui::Size { - width: px(380.), - height: px(64.), + width: px(400.), + height: px(72.), }; for screen in unique_screens { @@ -129,35 +125,22 @@ impl Render for IncomingCallNotification { cx.set_rem_size(ui_font_size); - h_stack() - .font(ui_font) - .text_ui() - .justify_between() - .size_full() - .overflow_hidden() - .elevation_3(cx) - .p_2() - .gap_2() - .child( - img(self.state.call.calling_user.avatar_uri.clone()) - .w_12() - .h_12() - .rounded_full(), + div().size_full().font(ui_font).child( + CollabNotification::new( + self.state.call.calling_user.avatar_uri.clone(), + Button::new("accept", "Accept").on_click({ + let state = self.state.clone(); + move |_, cx| state.respond(true, cx) + }), + Button::new("decline", "Decline").on_click({ + let state = self.state.clone(); + move |_, cx| state.respond(false, cx) + }), ) - .child(v_stack().overflow_hidden().child(Label::new(format!( + .child(v_flex().overflow_hidden().child(Label::new(format!( "{} is sharing a project in Zed", self.state.call.calling_user.github_login - )))) - .child( - v_stack() - .child(Button::new("accept", "Accept").render(cx).on_click({ - let state = self.state.clone(); - move |_, cx| state.respond(true, cx) - })) - .child(Button::new("decline", "Decline").render(cx).on_click({ - let state = self.state.clone(); - move |_, cx| state.respond(false, cx) - })), - ) + )))), + ) } } diff --git a/crates/collab_ui/src/notifications/project_shared_notification.rs b/crates/collab_ui/src/notifications/project_shared_notification.rs index 982214c3e5..b8ceefcd76 100644 --- a/crates/collab_ui/src/notifications/project_shared_notification.rs +++ b/crates/collab_ui/src/notifications/project_shared_notification.rs @@ -1,12 +1,13 @@ use crate::notification_window_options; +use crate::notifications::collab_notification::CollabNotification; use call::{room, ActiveCall}; use client::User; use collections::HashMap; -use gpui::{img, px, AppContext, ParentElement, Render, Size, Styled, ViewContext, VisualContext}; +use gpui::{AppContext, Size}; use settings::Settings; use std::sync::{Arc, Weak}; use theme::ThemeSettings; -use ui::{h_stack, prelude::*, v_stack, Button, Label}; +use ui::{prelude::*, Button, Label}; use workspace::AppState; pub fn init(app_state: &Arc, cx: &mut AppContext) { @@ -50,7 +51,6 @@ pub fn init(app_state: &Arc, cx: &mut AppContext) { for window in windows { window .update(cx, |_, cx| { - // todo!() cx.remove_window(); }) .ok(); @@ -58,12 +58,11 @@ pub fn init(app_state: &Arc, cx: &mut AppContext) { } } - room::Event::Left => { + room::Event::Left { .. } => { for (_, windows) in notification_windows.drain() { for window in windows { window .update(cx, |_, cx| { - // todo!() cx.remove_window(); }) .ok(); @@ -130,51 +129,30 @@ impl Render for ProjectSharedNotification { cx.set_rem_size(ui_font_size); - h_stack() - .font(ui_font) - .text_ui() - .justify_between() - .size_full() - .overflow_hidden() - .elevation_3(cx) - .p_2() - .gap_2() - .child( - img(self.owner.avatar_uri.clone()) - .w_12() - .h_12() - .rounded_full(), - ) - .child( - v_stack() - .overflow_hidden() - .child(Label::new(self.owner.github_login.clone())) - .child(Label::new(format!( - "is sharing a project in Zed{}", - if self.worktree_root_names.is_empty() { - "" - } else { - ":" - } - ))) - .children(if self.worktree_root_names.is_empty() { - None - } else { - Some(Label::new(self.worktree_root_names.join(", "))) - }), - ) - .child( - v_stack() - .child(Button::new("open", "Open").on_click(cx.listener( - move |this, _event, cx| { - this.join(cx); - }, - ))) - .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( - move |this, _event, cx| { - this.dismiss(cx); - }, - ))), + div().size_full().font(ui_font).child( + CollabNotification::new( + self.owner.avatar_uri.clone(), + Button::new("open", "Open").on_click(cx.listener(move |this, _event, cx| { + this.join(cx); + })), + Button::new("dismiss", "Dismiss").on_click(cx.listener(move |this, _event, cx| { + this.dismiss(cx); + })), ) + .child(Label::new(self.owner.github_login.clone())) + .child(Label::new(format!( + "is sharing a project in Zed{}", + if self.worktree_root_names.is_empty() { + "" + } else { + ":" + } + ))) + .children(if self.worktree_root_names.is_empty() { + None + } else { + Some(Label::new(self.worktree_root_names.join(", "))) + }), + ) } } diff --git a/crates/collab_ui/src/notifications/stories.rs b/crates/collab_ui/src/notifications/stories.rs new file mode 100644 index 0000000000..36518679c6 --- /dev/null +++ b/crates/collab_ui/src/notifications/stories.rs @@ -0,0 +1,3 @@ +mod collab_notification; + +pub use collab_notification::*; diff --git a/crates/collab_ui/src/notifications/stories/collab_notification.rs b/crates/collab_ui/src/notifications/stories/collab_notification.rs new file mode 100644 index 0000000000..e67ce817b6 --- /dev/null +++ b/crates/collab_ui/src/notifications/stories/collab_notification.rs @@ -0,0 +1,50 @@ +use gpui::prelude::*; +use story::{StoryContainer, StoryItem, StorySection}; +use ui::prelude::*; + +use crate::notifications::collab_notification::CollabNotification; + +pub struct CollabNotificationStory; + +impl Render for CollabNotificationStory { + fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { + let window_container = |width, height| div().w(px(width)).h(px(height)); + + StoryContainer::new( + "CollabNotification Story", + "crates/collab_ui/src/notifications/stories/collab_notification.rs", + ) + .child( + StorySection::new().child(StoryItem::new( + "Incoming Call Notification", + window_container(400., 72.).child( + CollabNotification::new( + "https://avatars.githubusercontent.com/u/1486634?v=4", + Button::new("accept", "Accept"), + Button::new("decline", "Decline"), + ) + .child( + v_flex() + .overflow_hidden() + .child(Label::new("maxdeviant is sharing a project in Zed")), + ), + ), + )), + ) + .child( + StorySection::new().child(StoryItem::new( + "Project Shared Notification", + window_container(400., 72.).child( + CollabNotification::new( + "https://avatars.githubusercontent.com/u/1714999?v=4", + Button::new("open", "Open"), + Button::new("dismiss", "Dismiss"), + ) + .child(Label::new("iamnbutler")) + .child(Label::new("is sharing a project in Zed:")) + .child(Label::new("zed")), + ), + )), + ) + } +} diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml new file mode 100644 index 0000000000..c6416f9691 --- /dev/null +++ b/crates/color/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "color" +version = "0.1.0" +edition = "2021" +publish = false + +[features] +default = [] +stories = ["dep:itertools", "dep:story"] + +[lib] +path = "src/color.rs" +doctest = true + +[dependencies] +# TODO: Clean up dependencies +anyhow.workspace = true +fs = { path = "../fs" } +indexmap = "1.6.2" +parking_lot.workspace = true +refineable.workspace = true +schemars.workspace = true +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true +settings = { path = "../settings" } +story = { path = "../story", optional = true } +toml.workspace = true +uuid.workspace = true +util = { path = "../util" } +itertools = { version = "0.11.0", optional = true } +palette = "0.7.3" diff --git a/crates/color/src/color.rs b/crates/color/src/color.rs new file mode 100644 index 0000000000..8529f3bc5f --- /dev/null +++ b/crates/color/src/color.rs @@ -0,0 +1,234 @@ +//! # Color +//! +//! The `color` crate provides a set utilities for working with colors. It is a wrapper around the [`palette`](https://docs.rs/palette) crate with some additional functionality. +//! +//! It is used to create a manipulate colors when building themes. +//! +//! === In development note === +//! +//! This crate is meant to sit between gpui and the theme/ui for all the color related stuff. +//! +//! It could be folded into gpui, ui or theme potentially but for now we'll continue +//! to develop it in isolation. +//! +//! Once we have a good idea of the needs of the theme system and color in gpui in general I see 3 paths: +//! 1. Use `palette` (or another color library) directly in gpui and everywhere else, rather than rolling our own color system. +//! 2. Keep this crate as a thin wrapper around `palette` and use it everywhere except gpui, and convert to gpui's color system when needed. +//! 3. Build the needed functionality into gpui and keep using it's color system everywhere. +//! +//! I'm leaning towards 2 in the short term and 1 in the long term, but we'll need to discuss it more. +//! +//! === End development note === +use palette::{ + blend::Blend, convert::FromColorUnclamped, encoding, rgb::Rgb, Clamp, Mix, Srgb, WithAlpha, +}; + +/// The types of blend modes supported +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum BlendMode { + /// Multiplies the colors, resulting in a darker color. This mode is useful for creating shadows. + Multiply, + /// Lightens the color by adding the source and destination colors. It results in a lighter color. + Screen, + /// Combines Multiply and Screen blend modes. Parts of the image that are lighter than 50% gray are lightened, and parts that are darker are darkened. + Overlay, + /// Selects the darker of the base or blend color as the resulting color. Useful for darkening images without affecting the overall contrast. + Darken, + /// Selects the lighter of the base or blend color as the resulting color. Useful for lightening images without affecting the overall contrast. + Lighten, + /// Brightens the base color to reflect the blend color. The result is a lightened image. + Dodge, + /// Darkens the base color to reflect the blend color. The result is a darkened image. + Burn, + /// Similar to Overlay, but with a stronger effect. Hard Light can either multiply or screen colors, depending on the blend color. + HardLight, + /// A softer version of Hard Light. Soft Light either darkens or lightens colors, depending on the blend color. + SoftLight, + /// Subtracts the darker of the two constituent colors from the lighter color. Difference mode is useful for creating more vivid colors. + Difference, + /// Similar to Difference, but with a lower contrast. Exclusion mode produces an effect similar to Difference but with less intensity. + Exclusion, +} + +/// Converts a hexadecimal color string to a `palette::Hsla` color. +/// +/// This function supports the following hex formats: +/// `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`. +pub fn hex_to_hsla(s: &str) -> Result { + let hex = s.trim_start_matches('#'); + + // Expand shorthand formats #RGB and #RGBA to #RRGGBB and #RRGGBBAA + let hex = match hex.len() { + 3 => hex + .chars() + .map(|c| c.to_string().repeat(2)) + .collect::(), + 4 => { + let (rgb, alpha) = hex.split_at(3); + let rgb = rgb + .chars() + .map(|c| c.to_string().repeat(2)) + .collect::(); + let alpha = alpha.chars().next().unwrap().to_string().repeat(2); + format!("{}{}", rgb, alpha) + } + 6 => format!("{}ff", hex), // Add alpha if missing + 8 => hex.to_string(), // Already in full format + _ => return Err("Invalid hexadecimal string length".to_string()), + }; + + let hex_val = + u32::from_str_radix(&hex, 16).map_err(|_| format!("Invalid hexadecimal string: {}", s))?; + + let r = ((hex_val >> 24) & 0xFF) as f32 / 255.0; + let g = ((hex_val >> 16) & 0xFF) as f32 / 255.0; + let b = ((hex_val >> 8) & 0xFF) as f32 / 255.0; + let a = (hex_val & 0xFF) as f32 / 255.0; + + let color = RGBAColor { r, g, b, a }; + + Ok(color) +} + +// These derives implement to and from palette's color types. +#[derive(FromColorUnclamped, WithAlpha, Debug, Clone)] +#[palette(skip_derives(Rgb), rgb_standard = "encoding::Srgb")] +pub struct RGBAColor { + r: f32, + g: f32, + b: f32, + // Let Palette know this is our alpha channel. + #[palette(alpha)] + a: f32, +} + +impl FromColorUnclamped for RGBAColor { + fn from_color_unclamped(color: RGBAColor) -> RGBAColor { + color + } +} + +impl FromColorUnclamped> for RGBAColor +where + Srgb: FromColorUnclamped>, +{ + fn from_color_unclamped(color: Rgb) -> RGBAColor { + let srgb = Srgb::from_color_unclamped(color); + RGBAColor { + r: srgb.red, + g: srgb.green, + b: srgb.blue, + a: 1.0, + } + } +} + +impl FromColorUnclamped for Rgb +where + Rgb: FromColorUnclamped, +{ + fn from_color_unclamped(color: RGBAColor) -> Self { + let srgb = Srgb::new(color.r, color.g, color.b); + Self::from_color_unclamped(srgb) + } +} + +impl Clamp for RGBAColor { + fn clamp(self) -> Self { + RGBAColor { + r: self.r.min(1.0).max(0.0), + g: self.g.min(1.0).max(0.0), + b: self.b.min(1.0).max(0.0), + a: self.a.min(1.0).max(0.0), + } + } +} + +impl RGBAColor { + /// Creates a new color from the given RGBA values. + /// + /// This color can be used to convert to any [`palette::Color`] type. + pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self { + RGBAColor { r, g, b, a } + } + + /// Returns a set of states for this color. + pub fn states(self, is_light: bool) -> ColorStates { + states_for_color(self, is_light) + } + + /// Mixes this color with another [`palette::Hsl`] color at the given `mix_ratio`. + pub fn mixed(&self, other: RGBAColor, mix_ratio: f32) -> Self { + let srgb_self = Srgb::new(self.r, self.g, self.b); + let srgb_other = Srgb::new(other.r, other.g, other.b); + + // Directly mix the colors as sRGB values + let mixed = srgb_self.mix(srgb_other, mix_ratio); + RGBAColor::from_color_unclamped(mixed) + } + + pub fn blend(&self, other: RGBAColor, blend_mode: BlendMode) -> Self { + let srgb_self = Srgb::new(self.r, self.g, self.b); + let srgb_other = Srgb::new(other.r, other.g, other.b); + + let blended = match blend_mode { + // replace hsl methods with the respective sRGB methods + BlendMode::Multiply => srgb_self.multiply(srgb_other), + _ => unimplemented!(), + }; + + Self { + r: blended.red, + g: blended.green, + b: blended.blue, + a: self.a, + } + } +} + +/// A set of colors for different states of an element. +#[derive(Debug, Clone)] +pub struct ColorStates { + /// The default color. + pub default: RGBAColor, + /// The color when the mouse is hovering over the element. + pub hover: RGBAColor, + /// The color when the mouse button is held down on the element. + pub active: RGBAColor, + /// The color when the element is focused with the keyboard. + pub focused: RGBAColor, + /// The color when the element is disabled. + pub disabled: RGBAColor, +} + +/// Returns a set of colors for different states of an element. +/// +/// todo!("This should take a theme and use appropriate colors from it") +pub fn states_for_color(color: RGBAColor, is_light: bool) -> ColorStates { + let adjustment_factor = if is_light { 0.1 } else { -0.1 }; + let hover_adjustment = 1.0 - adjustment_factor; + let active_adjustment = 1.0 - 2.0 * adjustment_factor; + let focused_adjustment = 1.0 - 3.0 * adjustment_factor; + let disabled_adjustment = 1.0 - 4.0 * adjustment_factor; + + let make_adjustment = |color: RGBAColor, adjustment: f32| -> RGBAColor { + // Adjust lightness for each state + // Note: Adjustment logic may differ; simplify as needed for sRGB + RGBAColor::new( + color.r * adjustment, + color.g * adjustment, + color.b * adjustment, + color.a, + ) + }; + + let color = color.clamp(); + + ColorStates { + default: color.clone(), + hover: make_adjustment(color.clone(), hover_adjustment), + active: make_adjustment(color.clone(), active_adjustment), + focused: make_adjustment(color.clone(), focused_adjustment), + disabled: make_adjustment(color.clone(), disabled_adjustment), + } +} diff --git a/crates/command_palette/Cargo.toml b/crates/command_palette/Cargo.toml index 39ed4fd95e..c762af7c48 100644 --- a/crates/command_palette/Cargo.toml +++ b/crates/command_palette/Cargo.toml @@ -9,6 +9,7 @@ path = "src/command_palette.rs" doctest = false [dependencies] +client = { path = "../client" } collections = { path = "../collections" } editor = { path = "../editor" } fuzzy = { path = "../fuzzy" } @@ -16,9 +17,9 @@ gpui = { path = "../gpui" } picker = { path = "../picker" } project = { path = "../project" } settings = { path = "../settings" } +theme = { path = "../theme" } ui = { path = "../ui" } util = { path = "../util" } -theme = { path = "../theme" } workspace = { path = "../workspace" } zed_actions = { path = "../zed_actions" } anyhow.workspace = true diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index bbc2cd4123..c90e448865 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -3,6 +3,7 @@ use std::{ sync::Arc, }; +use client::telemetry::Telemetry; use collections::{CommandPaletteFilter, HashMap}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ @@ -11,7 +12,7 @@ use gpui::{ }; use picker::{Picker, PickerDelegate}; -use ui::{h_stack, prelude::*, v_stack, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing}; +use ui::{h_flex, prelude::*, v_flex, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing}; use util::{ channel::{parse_zed_link, ReleaseChannel, RELEASE_CHANNEL}, ResultExt, @@ -39,11 +40,18 @@ impl CommandPalette { let Some(previous_focus_handle) = cx.focused() else { return; }; - workspace.toggle_modal(cx, move |cx| CommandPalette::new(previous_focus_handle, cx)); + let telemetry = workspace.client().telemetry().clone(); + workspace.toggle_modal(cx, move |cx| { + CommandPalette::new(previous_focus_handle, telemetry, cx) + }); }); } - fn new(previous_focus_handle: FocusHandle, cx: &mut ViewContext) -> Self { + fn new( + previous_focus_handle: FocusHandle, + telemetry: Arc, + cx: &mut ViewContext, + ) -> Self { let filter = cx.try_global::(); let commands = cx @@ -66,8 +74,12 @@ impl CommandPalette { }) .collect(); - let delegate = - CommandPaletteDelegate::new(cx.view().downgrade(), commands, previous_focus_handle); + let delegate = CommandPaletteDelegate::new( + cx.view().downgrade(), + commands, + telemetry, + previous_focus_handle, + ); let picker = cx.new_view(|cx| Picker::new(delegate, cx)); Self { picker } @@ -84,7 +96,7 @@ impl FocusableView for CommandPalette { impl Render for CommandPalette { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - v_stack().w(rems(34.)).child(self.picker.clone()) + v_flex().w(rems(34.)).child(self.picker.clone()) } } @@ -103,6 +115,7 @@ pub struct CommandPaletteDelegate { commands: Vec, matches: Vec, selected_ix: usize, + telemetry: Arc, previous_focus_handle: FocusHandle, } @@ -130,6 +143,7 @@ impl CommandPaletteDelegate { fn new( command_palette: WeakView, commands: Vec, + telemetry: Arc, previous_focus_handle: FocusHandle, ) -> Self { Self { @@ -138,6 +152,7 @@ impl CommandPaletteDelegate { matches: vec![], commands, selected_ix: 0, + telemetry, previous_focus_handle, } } @@ -284,6 +299,10 @@ impl PickerDelegate for CommandPaletteDelegate { } let action_ix = self.matches[self.selected_ix].candidate_id; let command = self.commands.swap_remove(action_ix); + + self.telemetry + .report_action_event("command palette", command.name.clone()); + self.matches.clear(); self.commands.clear(); cx.update_global(|hit_counts: &mut HitCounts, _| { @@ -311,7 +330,7 @@ impl PickerDelegate for CommandPaletteDelegate { .spacing(ListItemSpacing::Sparse) .selected(selected) .child( - h_stack() + h_flex() .w_full() .justify_between() .child(HighlightedLabel::new( diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 89d1086c8e..f36567c6b9 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -308,11 +308,7 @@ impl EventEmitter for Copilot {} impl Copilot { pub fn global(cx: &AppContext) -> Option> { - if cx.has_global::>() { - Some(cx.global::>().clone()) - } else { - None - } + cx.try_global::>().map(|model| model.clone()) } fn start( @@ -373,10 +369,11 @@ impl Copilot { #[cfg(any(test, feature = "test-support"))] pub fn fake(cx: &mut gpui::TestAppContext) -> (Model, lsp::FakeLanguageServer) { + use lsp::FakeLanguageServer; use node_runtime::FakeNodeRuntime; let (server, fake_server) = - LanguageServer::fake("copilot".into(), Default::default(), cx.to_async()); + FakeLanguageServer::new("copilot".into(), Default::default(), cx.to_async()); let http = util::http::FakeHttpClient::create(|_| async { unreachable!() }); let node_runtime = FakeNodeRuntime::new(); let this = cx.new_model(|cx| Self { diff --git a/crates/copilot_ui/src/copilot_button.rs b/crates/copilot_ui/src/copilot_button.rs index e55f45c293..9dc4e75cb1 100644 --- a/crates/copilot_ui/src/copilot_button.rs +++ b/crates/copilot_ui/src/copilot_button.rs @@ -1,7 +1,7 @@ use crate::sign_in::CopilotCodeVerification; use anyhow::Result; use copilot::{Copilot, SignOut, Status}; -use editor::{scroll::autoscroll::Autoscroll, Editor}; +use editor::{scroll::Autoscroll, Editor}; use fs::Fs; use gpui::{ div, Action, AnchorCorner, AppContext, AsyncWindowContext, Entity, IntoElement, ParentElement, @@ -17,7 +17,9 @@ use util::{paths, ResultExt}; use workspace::{ create_and_open_local_file, item::ItemHandle, - ui::{popover_menu, ButtonCommon, Clickable, ContextMenu, Icon, IconButton, IconSize, Tooltip}, + ui::{ + popover_menu, ButtonCommon, Clickable, ContextMenu, IconButton, IconName, IconSize, Tooltip, + }, StatusItemView, Toast, Workspace, }; use zed_actions::OpenBrowser; @@ -51,15 +53,15 @@ impl Render for CopilotButton { .unwrap_or_else(|| all_language_settings.copilot_enabled(None, None)); let icon = match status { - Status::Error(_) => Icon::CopilotError, + Status::Error(_) => IconName::CopilotError, Status::Authorized => { if enabled { - Icon::Copilot + IconName::Copilot } else { - Icon::CopilotDisabled + IconName::CopilotDisabled } } - _ => Icon::CopilotInit, + _ => IconName::CopilotInit, }; if let Status::Error(e) = status { diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs index aeaa35784b..2bea2e016c 100644 --- a/crates/copilot_ui/src/sign_in.rs +++ b/crates/copilot_ui/src/sign_in.rs @@ -4,7 +4,7 @@ use gpui::{ FocusableView, InteractiveElement, IntoElement, Model, ParentElement, Render, Styled, Subscription, ViewContext, }; -use ui::{prelude::*, Button, Icon, Label}; +use ui::{prelude::*, Button, IconName, Label}; use workspace::ModalView; const COPILOT_SIGN_UP_URL: &'static str = "https://github.com/features/copilot"; @@ -57,7 +57,7 @@ impl CopilotCodeVerification { .read_from_clipboard() .map(|item| item.text() == &data.user_code) .unwrap_or(false); - h_stack() + h_flex() .w_full() .p_1() .border() @@ -69,7 +69,7 @@ impl CopilotCodeVerification { let user_code = data.user_code.clone(); move |_, cx| { cx.write_to_clipboard(ClipboardItem::new(user_code.clone())); - cx.notify(); + cx.refresh(); } }) .child(div().flex_1().child(Label::new(data.user_code.clone()))) @@ -90,13 +90,13 @@ impl CopilotCodeVerification { } else { "Connect to Github" }; - v_stack() + v_flex() .flex_1() .gap_2() .items_center() .child(Headline::new("Use Github Copilot in Zed.").size(HeadlineSize::Large)) .child( - Label::new("Using Copilot requres an active subscription on Github.") + Label::new("Using Copilot requires an active subscription on Github.") .color(Color::Muted), ) .child(Self::render_device_code(data, cx)) @@ -118,7 +118,7 @@ impl CopilotCodeVerification { ) } fn render_enabled_modal(cx: &mut ViewContext) -> impl Element { - v_stack() + v_flex() .gap_2() .child(Headline::new("Copilot Enabled!").size(HeadlineSize::Large)) .child(Label::new( @@ -132,14 +132,14 @@ impl CopilotCodeVerification { } fn render_unauthorized_modal() -> impl Element { - v_stack() + v_flex() .child(Headline::new("You must have an active GitHub Copilot subscription.").size(HeadlineSize::Large)) .child(Label::new( "You can enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.", ).color(Color::Warning)) .child( - Button::new("copilot-subscribe-button", "Subscibe on Github") + Button::new("copilot-subscribe-button", "Subscribe on Github") .full_width() .on_click(|_, cx| cx.open_url(COPILOT_SIGN_UP_URL)), ) @@ -163,7 +163,7 @@ impl Render for CopilotCodeVerification { _ => div().into_any_element(), }; - v_stack() + v_flex() .id("copilot code verification") .elevation_3(cx) .w_96() @@ -175,7 +175,7 @@ impl Render for CopilotCodeVerification { .w_32() .h_16() .flex_none() - .path(Icon::ZedXCopilot.path()) + .path(IconName::ZedXCopilot.path()) .text_color(cx.theme().colors().icon), ) .child(prompt) diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 613fadf7f7..8504d3de5e 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -8,7 +8,7 @@ use editor::{ diagnostic_block_renderer, display_map::{BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock}, highlight_diagnostic_message, - scroll::autoscroll::Autoscroll, + scroll::Autoscroll, Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, ToOffset, }; use futures::future::try_join_all; @@ -36,7 +36,7 @@ use std::{ }; use theme::ActiveTheme; pub use toolbar_controls::ToolbarControls; -use ui::{h_stack, prelude::*, Icon, IconElement, Label}; +use ui::{h_flex, prelude::*, Icon, IconName, Label}; use util::TryFutureExt; use workspace::{ item::{BreadcrumbText, Item, ItemEvent, ItemHandle}, @@ -654,13 +654,13 @@ impl Item for ProjectDiagnosticsEditor { }) .into_any_element() } else { - h_stack() + h_flex() .gap_1() .when(self.summary.error_count > 0, |then| { then.child( - h_stack() + h_flex() .gap_1() - .child(IconElement::new(Icon::XCircle).color(Color::Error)) + .child(Icon::new(IconName::XCircle).color(Color::Error)) .child(Label::new(self.summary.error_count.to_string()).color( if selected { Color::Default @@ -672,11 +672,9 @@ impl Item for ProjectDiagnosticsEditor { }) .when(self.summary.warning_count > 0, |then| { then.child( - h_stack() + h_flex() .gap_1() - .child( - IconElement::new(Icon::ExclamationTriangle).color(Color::Warning), - ) + .child(Icon::new(IconName::ExclamationTriangle).color(Color::Warning)) .child(Label::new(self.summary.warning_count.to_string()).color( if selected { Color::Default @@ -690,6 +688,10 @@ impl Item for ProjectDiagnosticsEditor { } } + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("project diagnostics") + } + fn for_each_project_item( &self, cx: &AppContext, @@ -798,7 +800,7 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock { let message: SharedString = message.into(); Arc::new(move |cx| { let highlight_style: HighlightStyle = cx.theme().colors().text_accent.into(); - h_stack() + h_flex() .id("diagnostic header") .py_2() .pl_10() @@ -807,7 +809,7 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock { .justify_between() .gap_2() .child( - h_stack() + h_flex() .gap_3() .map(|stack| { stack.child( @@ -816,17 +818,17 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock { .flex_none() .map(|icon| { if diagnostic.severity == DiagnosticSeverity::ERROR { - icon.path(Icon::XCircle.path()) + icon.path(IconName::XCircle.path()) .text_color(Color::Error.color(cx)) } else { - icon.path(Icon::ExclamationTriangle.path()) + icon.path(IconName::ExclamationTriangle.path()) .text_color(Color::Warning.color(cx)) } }), ) }) .child( - h_stack() + h_flex() .gap_1() .child( StyledText::new(message.clone()).with_highlights( @@ -846,7 +848,7 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock { ), ) .child( - h_stack() + h_flex() .gap_1() .when_some(diagnostic.source.as_ref(), |stack, source| { stack.child( diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 0c2d673d8e..d823ad52af 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -6,7 +6,7 @@ use gpui::{ }; use language::Diagnostic; use lsp::LanguageServerId; -use ui::{h_stack, prelude::*, Button, ButtonLike, Color, Icon, IconElement, Label, Tooltip}; +use ui::{h_flex, prelude::*, Button, ButtonLike, Color, Icon, IconName, Label, Tooltip}; use workspace::{item::ItemHandle, StatusItemView, ToolbarItemEvent, Workspace}; use crate::{Deploy, ProjectDiagnosticsEditor}; @@ -23,39 +23,39 @@ pub struct DiagnosticIndicator { impl Render for DiagnosticIndicator { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let diagnostic_indicator = match (self.summary.error_count, self.summary.warning_count) { - (0, 0) => h_stack().map(|this| { + (0, 0) => h_flex().map(|this| { this.child( - IconElement::new(Icon::Check) + Icon::new(IconName::Check) .size(IconSize::Small) .color(Color::Default), ) }), - (0, warning_count) => h_stack() + (0, warning_count) => h_flex() .gap_1() .child( - IconElement::new(Icon::ExclamationTriangle) + Icon::new(IconName::ExclamationTriangle) .size(IconSize::Small) .color(Color::Warning), ) .child(Label::new(warning_count.to_string()).size(LabelSize::Small)), - (error_count, 0) => h_stack() + (error_count, 0) => h_flex() .gap_1() .child( - IconElement::new(Icon::XCircle) + Icon::new(IconName::XCircle) .size(IconSize::Small) .color(Color::Error), ) .child(Label::new(error_count.to_string()).size(LabelSize::Small)), - (error_count, warning_count) => h_stack() + (error_count, warning_count) => h_flex() .gap_1() .child( - IconElement::new(Icon::XCircle) + Icon::new(IconName::XCircle) .size(IconSize::Small) .color(Color::Error), ) .child(Label::new(error_count.to_string()).size(LabelSize::Small)) .child( - IconElement::new(Icon::ExclamationTriangle) + Icon::new(IconName::ExclamationTriangle) .size(IconSize::Small) .color(Color::Warning), ) @@ -64,9 +64,9 @@ impl Render for DiagnosticIndicator { let status = if !self.in_progress_checks.is_empty() { Some( - h_stack() + h_flex() .gap_2() - .child(IconElement::new(Icon::ArrowCircle).size(IconSize::Small)) + .child(Icon::new(IconName::ArrowCircle).size(IconSize::Small)) .child( Label::new("Checking…") .size(LabelSize::Small) @@ -80,7 +80,7 @@ impl Render for DiagnosticIndicator { Button::new("diagnostic_message", message) .label_size(LabelSize::Small) .tooltip(|cx| { - Tooltip::for_action("Next Diagnostic", &editor::GoToDiagnostic, cx) + Tooltip::for_action("Next Diagnostic", &editor::actions::GoToDiagnostic, cx) }) .on_click(cx.listener(|this, _, cx| { this.go_to_next_diagnostic(cx); @@ -91,7 +91,7 @@ impl Render for DiagnosticIndicator { None }; - h_stack() + h_flex() .h(rems(1.375)) .gap_2() .child( diff --git a/crates/diagnostics/src/toolbar_controls.rs b/crates/diagnostics/src/toolbar_controls.rs index 897e2ccf40..3c09e3fad9 100644 --- a/crates/diagnostics/src/toolbar_controls.rs +++ b/crates/diagnostics/src/toolbar_controls.rs @@ -1,7 +1,7 @@ use crate::ProjectDiagnosticsEditor; use gpui::{div, EventEmitter, ParentElement, Render, ViewContext, WeakView}; use ui::prelude::*; -use ui::{Icon, IconButton, Tooltip}; +use ui::{IconButton, IconName, Tooltip}; use workspace::{item::ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView}; pub struct ToolbarControls { @@ -24,7 +24,7 @@ impl Render for ToolbarControls { }; div().child( - IconButton::new("toggle-warnings", Icon::ExclamationTriangle) + IconButton::new("toggle-warnings", IconName::ExclamationTriangle) .tooltip(move |cx| Tooltip::text(tooltip, cx)) .on_click(cx.listener(|this, _, cx| { if let Some(editor) = this.editor.as_ref().and_then(|editor| editor.upgrade()) { diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs new file mode 100644 index 0000000000..9532bb642d --- /dev/null +++ b/crates/editor/src/actions.rs @@ -0,0 +1,218 @@ +//! This module contains all actions supported by [`Editor`]. +use super::*; + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct SelectNext { + #[serde(default)] + pub replace_newest: bool, +} + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct SelectPrevious { + #[serde(default)] + pub replace_newest: bool, +} + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct SelectAllMatches { + #[serde(default)] + pub replace_newest: bool, +} + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct SelectToBeginningOfLine { + #[serde(default)] + pub(super) stop_at_soft_wraps: bool, +} + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct MovePageUp { + #[serde(default)] + pub(super) center_cursor: bool, +} + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct MovePageDown { + #[serde(default)] + pub(super) center_cursor: bool, +} + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct SelectToEndOfLine { + #[serde(default)] + pub(super) stop_at_soft_wraps: bool, +} + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct ToggleCodeActions { + #[serde(default)] + pub deployed_from_indicator: bool, +} + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct ConfirmCompletion { + #[serde(default)] + pub item_ix: Option, +} + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct ConfirmCodeAction { + #[serde(default)] + pub item_ix: Option, +} + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct ToggleComments { + #[serde(default)] + pub advance_downwards: bool, +} + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct FoldAt { + pub buffer_row: u32, +} + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct UnfoldAt { + pub buffer_row: u32, +} +impl_actions!( + editor, + [ + SelectNext, + SelectPrevious, + SelectAllMatches, + SelectToBeginningOfLine, + MovePageUp, + MovePageDown, + SelectToEndOfLine, + ToggleCodeActions, + ConfirmCompletion, + ConfirmCodeAction, + ToggleComments, + FoldAt, + UnfoldAt + ] +); + +gpui::actions!( + editor, + [ + AddSelectionAbove, + AddSelectionBelow, + Backspace, + Cancel, + ConfirmRename, + ContextMenuFirst, + ContextMenuLast, + ContextMenuNext, + ContextMenuPrev, + ConvertToKebabCase, + ConvertToLowerCamelCase, + ConvertToLowerCase, + ConvertToSnakeCase, + ConvertToTitleCase, + ConvertToUpperCamelCase, + ConvertToUpperCase, + Copy, + CopyHighlightJson, + CopyPath, + CopyRelativePath, + Cut, + CutToEndOfLine, + Delete, + DeleteLine, + DeleteToBeginningOfLine, + DeleteToEndOfLine, + DeleteToNextSubwordEnd, + DeleteToNextWordEnd, + DeleteToPreviousSubwordStart, + DeleteToPreviousWordStart, + DuplicateLine, + ExpandMacroRecursively, + FindAllReferences, + Fold, + FoldSelectedRanges, + Format, + GoToDefinition, + GoToDefinitionSplit, + GoToDiagnostic, + GoToHunk, + GoToPrevDiagnostic, + GoToPrevHunk, + GoToTypeDefinition, + GoToTypeDefinitionSplit, + HalfPageDown, + HalfPageUp, + Hover, + Indent, + JoinLines, + LineDown, + LineUp, + MoveDown, + MoveLeft, + MoveLineDown, + MoveLineUp, + MoveRight, + MoveToBeginning, + MoveToBeginningOfLine, + MoveToEnclosingBracket, + MoveToEnd, + MoveToEndOfLine, + MoveToEndOfParagraph, + MoveToNextSubwordEnd, + MoveToNextWordEnd, + MoveToPreviousSubwordStart, + MoveToPreviousWordStart, + MoveToStartOfParagraph, + MoveUp, + Newline, + NewlineAbove, + NewlineBelow, + NextScreen, + OpenExcerpts, + Outdent, + PageDown, + PageUp, + Paste, + Redo, + RedoSelection, + Rename, + RestartLanguageServer, + RevealInFinder, + ReverseLines, + ScrollCursorBottom, + ScrollCursorCenter, + ScrollCursorTop, + SelectAll, + SelectDown, + SelectLargerSyntaxNode, + SelectLeft, + SelectLine, + SelectRight, + SelectSmallerSyntaxNode, + SelectToBeginning, + SelectToEnd, + SelectToEndOfParagraph, + SelectToNextSubwordEnd, + SelectToNextWordEnd, + SelectToPreviousSubwordStart, + SelectToPreviousWordStart, + SelectToStartOfParagraph, + SelectUp, + ShowCharacterPalette, + ShowCompletions, + ShuffleLines, + SortLinesCaseInsensitive, + SortLinesCaseSensitive, + SplitSelectionIntoLines, + Tab, + TabPrev, + ToggleInlayHints, + ToggleSoftWrap, + Transpose, + Undo, + UndoSelection, + UnfoldLines, + ] +); diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 4511ffe407..7f29a7d04f 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -1,3 +1,22 @@ +//! This module defines where the text should be displayed in an [`Editor`][Editor]. +//! +//! Not literally though - rendering, layout and all that jazz is a responsibility of [`EditorElement`][EditorElement]. +//! Instead, [`DisplayMap`] decides where Inlays/Inlay hints are displayed, when +//! to apply a soft wrap, where to add fold indicators, whether there are any tabs in the buffer that +//! we display as spaces and where to display custom blocks (like diagnostics). +//! Seems like a lot? That's because it is. [`DisplayMap`] is conceptually made up +//! of several smaller structures that form a hierarchy (starting at the bottom): +//! - [`InlayMap`] that decides where the [`Inlay`]s should be displayed. +//! - [`FoldMap`] that decides where the fold indicators should be; it also tracks parts of a source file that are currently folded. +//! - [`TabMap`] that keeps track of hard tabs in a buffer. +//! - [`WrapMap`] that handles soft wrapping. +//! - [`BlockMap`] that tracks custom blocks such as diagnostics that should be displayed within buffer. +//! - [`DisplayMap`] that adds background highlights to the regions of text. +//! Each one of those builds on top of preceding map. +//! +//! [Editor]: crate::Editor +//! [EditorElement]: crate::element::EditorElement + mod block_map; mod fold_map; mod inlay_map; @@ -30,7 +49,8 @@ pub use block_map::{ }; pub use self::fold_map::{Fold, FoldPoint}; -pub use self::inlay_map::{Inlay, InlayOffset, InlayPoint}; +pub use self::inlay_map::{InlayOffset, InlayPoint}; +pub(crate) use inlay_map::Inlay; #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum FoldStatus { @@ -220,7 +240,7 @@ impl DisplayMap { .insert(Some(type_id), Arc::new((style, ranges))); } - pub fn highlight_inlays( + pub(crate) fn highlight_inlays( &mut self, type_id: TypeId, highlights: Vec, @@ -258,11 +278,11 @@ impl DisplayMap { .update(cx, |map, cx| map.set_wrap_width(width, cx)) } - pub fn current_inlays(&self) -> impl Iterator { + pub(crate) fn current_inlays(&self) -> impl Iterator { self.inlay_map.current_inlays() } - pub fn splice_inlays( + pub(crate) fn splice_inlays( &mut self, to_remove: Vec, to_insert: Vec, @@ -306,7 +326,7 @@ impl DisplayMap { } #[derive(Debug, Default)] -pub struct Highlights<'a> { +pub(crate) struct Highlights<'a> { pub text_highlights: Option<&'a TextHighlights>, pub inlay_highlights: Option<&'a InlayHighlights>, pub inlay_highlight_style: Option, @@ -880,8 +900,9 @@ impl DisplaySnapshot { self.text_highlights.get(&Some(type_id)).cloned() } + #[allow(unused)] #[cfg(any(test, feature = "test-support"))] - pub fn inlay_highlights( + pub(crate) fn inlay_highlights( &self, ) -> Option<&HashMap> { let type_id = TypeId::of::(); @@ -969,24 +990,6 @@ impl ToDisplayPoint for Anchor { } } -pub fn next_rows(display_row: u32, display_map: &DisplaySnapshot) -> impl Iterator { - let max_row = display_map.max_point().row(); - let start_row = display_row + 1; - let mut current = None; - std::iter::from_fn(move || { - if current == None { - current = Some(start_row); - } else { - current = Some(current.unwrap() + 1) - } - if current.unwrap() > max_row { - None - } else { - current - } - }) -} - #[cfg(test)] pub mod tests { use super::*; @@ -1015,7 +1018,6 @@ pub mod tests { .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) .unwrap_or(10); - let _test_platform = &cx.test_platform; let mut tab_size = rng.gen_range(1..=4); let buffer_start_excerpt_header_height = rng.gen_range(1..=5); let excerpt_header_height = rng.gen_range(1..=5); diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 6eb0d05bfe..dbbcbccb6e 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -582,7 +582,7 @@ impl BlockSnapshot { .collect() } - pub fn chunks<'a>( + pub(crate) fn chunks<'a>( &'a self, rows: Range, language_aware: bool, diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index 4dad2d52ae..61b973cc6c 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -71,10 +71,10 @@ impl<'a> sum_tree::Dimension<'a, TransformSummary> for FoldPoint { } } -pub struct FoldMapWriter<'a>(&'a mut FoldMap); +pub(crate) struct FoldMapWriter<'a>(&'a mut FoldMap); impl<'a> FoldMapWriter<'a> { - pub fn fold( + pub(crate) fn fold( &mut self, ranges: impl IntoIterator>, ) -> (FoldSnapshot, Vec) { @@ -129,7 +129,7 @@ impl<'a> FoldMapWriter<'a> { (self.0.snapshot.clone(), edits) } - pub fn unfold( + pub(crate) fn unfold( &mut self, ranges: impl IntoIterator>, inclusive: bool, @@ -178,14 +178,14 @@ impl<'a> FoldMapWriter<'a> { } } -pub struct FoldMap { +pub(crate) struct FoldMap { snapshot: FoldSnapshot, ellipses_color: Option, next_fold_id: FoldId, } impl FoldMap { - pub fn new(inlay_snapshot: InlaySnapshot) -> (Self, FoldSnapshot) { + pub(crate) fn new(inlay_snapshot: InlaySnapshot) -> (Self, FoldSnapshot) { let this = Self { snapshot: FoldSnapshot { folds: Default::default(), @@ -655,7 +655,7 @@ impl FoldSnapshot { } } - pub fn chunks<'a>( + pub(crate) fn chunks<'a>( &'a self, range: Range, language_aware: bool, diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index 84fad96a48..c0d5198ddd 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -35,8 +35,8 @@ enum Transform { } #[derive(Debug, Clone)] -pub struct Inlay { - pub id: InlayId, +pub(crate) struct Inlay { + pub(crate) id: InlayId, pub position: Anchor, pub text: text::Rope, } @@ -1016,7 +1016,7 @@ impl InlaySnapshot { (line_end - line_start) as u32 } - pub fn chunks<'a>( + pub(crate) fn chunks<'a>( &'a self, range: Range, language_aware: bool, diff --git a/crates/editor/src/display_map/wrap_map.rs b/crates/editor/src/display_map/wrap_map.rs index 05aa381627..39f9a2315b 100644 --- a/crates/editor/src/display_map/wrap_map.rs +++ b/crates/editor/src/display_map/wrap_map.rs @@ -11,7 +11,6 @@ use smol::future::yield_now; use std::{cmp, collections::VecDeque, mem, ops::Range, time::Duration}; use sum_tree::{Bias, Cursor, SumTree}; use text::Patch; -use util::ResultExt; pub use super::tab_map::TextSummary; pub type WrapEdit = text::Edit; @@ -154,26 +153,24 @@ impl WrapMap { if let Some(wrap_width) = self.wrap_width { let mut new_snapshot = self.snapshot.clone(); - let mut edits = Patch::default(); + let text_system = cx.text_system().clone(); let (font, font_size) = self.font_with_size.clone(); let task = cx.background_executor().spawn(async move { - if let Some(mut line_wrapper) = text_system.line_wrapper(font, font_size).log_err() - { - let tab_snapshot = new_snapshot.tab_snapshot.clone(); - let range = TabPoint::zero()..tab_snapshot.max_point(); - edits = new_snapshot - .update( - tab_snapshot, - &[TabEdit { - old: range.clone(), - new: range.clone(), - }], - wrap_width, - &mut line_wrapper, - ) - .await; - } + let mut line_wrapper = text_system.line_wrapper(font, font_size); + let tab_snapshot = new_snapshot.tab_snapshot.clone(); + let range = TabPoint::zero()..tab_snapshot.max_point(); + let edits = new_snapshot + .update( + tab_snapshot, + &[TabEdit { + old: range.clone(), + new: range.clone(), + }], + wrap_width, + &mut line_wrapper, + ) + .await; (new_snapshot, edits) }); @@ -245,15 +242,12 @@ impl WrapMap { let (font, font_size) = self.font_with_size.clone(); let update_task = cx.background_executor().spawn(async move { let mut edits = Patch::default(); - if let Some(mut line_wrapper) = - text_system.line_wrapper(font, font_size).log_err() - { - for (tab_snapshot, tab_edits) in pending_edits { - let wrap_edits = snapshot - .update(tab_snapshot, &tab_edits, wrap_width, &mut line_wrapper) - .await; - edits = edits.compose(&wrap_edits); - } + let mut line_wrapper = text_system.line_wrapper(font, font_size); + for (tab_snapshot, tab_edits) in pending_edits { + let wrap_edits = snapshot + .update(tab_snapshot, &tab_edits, wrap_width, &mut line_wrapper) + .await; + edits = edits.compose(&wrap_edits); } (snapshot, edits) }); @@ -574,7 +568,7 @@ impl WrapSnapshot { Patch::new(wrap_edits) } - pub fn chunks<'a>( + pub(crate) fn chunks<'a>( &'a self, rows: Range, language_aware: bool, @@ -1043,7 +1037,7 @@ mod tests { #[gpui::test(iterations = 100)] async fn test_random_wraps(cx: &mut gpui::TestAppContext, mut rng: StdRng) { - // todo!() this test is flaky + // todo this test is flaky init_test(cx); cx.background_executor.set_block_on_ticks(0..=50); @@ -1059,7 +1053,7 @@ mod tests { }; let tab_size = NonZeroU32::new(rng.gen_range(1..=4)).unwrap(); let font = font("Helvetica"); - let _font_id = text_system.font_id(&font).unwrap(); + let _font_id = text_system.font_id(&font); let font_size = px(14.0); log::info!("Tab size: {}", tab_size); @@ -1086,7 +1080,7 @@ mod tests { let tabs_snapshot = tab_map.set_max_expansion_column(32); log::info!("TabMap text: {:?}", tabs_snapshot.text()); - let mut line_wrapper = text_system.line_wrapper(font.clone(), font_size).unwrap(); + let mut line_wrapper = text_system.line_wrapper(font.clone(), font_size); let unwrapped_text = tabs_snapshot.text(); let expected_text = wrap_text(&unwrapped_text, wrap_width, &mut line_wrapper); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 231f76218a..0e894c5f3f 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1,3 +1,18 @@ +#![allow(rustdoc::private_intra_doc_links)] +//! This is the place where everything editor-related is stored (data-wise) and displayed (ui-wise). +//! The main point of interest in this crate is [`Editor`] type, which is used in every other Zed part as a user input element. +//! It comes in different flavors: single line, multiline and a fixed height one. +//! +//! Editor contains of multiple large submodules: +//! * [`element`] — the place where all rendering happens +//! * [`display_map`] - chunks up text in the editor into the logical blocks, establishes coordinates and mapping between each of them. +//! Contains all metadata related to text transformations (folds, fake inlay text insertions, soft wraps, tab markup, etc.). +//! * [`inlay_hint_cache`] - is a storage of inlay hints out of LSP requests, responsible for querying LSP and updating `display_map`'s state accordingly. +//! +//! All other submodules and structs are mostly concerned with holding editor data about the way it displays current buffer region(s). +//! +//! If you're looking to improve Vim mode, you should check out Vim crate that wraps Editor and overrides it's behaviour. +pub mod actions; mod blink_manager; pub mod display_map; mod editor_settings; @@ -14,13 +29,14 @@ pub mod movement; mod persistence; mod rust_analyzer_ext; pub mod scroll; -pub mod selections_collection; +mod selections_collection; #[cfg(test)] mod editor_tests; #[cfg(any(test, feature = "test-support"))] pub mod test; use ::git::diff::DiffHunk; +pub(crate) use actions::*; use aho_corasick::AhoCorasick; use anyhow::{anyhow, Context as _, Result}; use blink_manager::BlinkManager; @@ -32,14 +48,13 @@ use copilot::Copilot; pub use display_map::DisplayPoint; use display_map::*; pub use editor_settings::EditorSettings; -pub use element::{ - Cursor, EditorElement, HighlightedRange, HighlightedRangeLine, LineWithInvisibles, -}; +use element::LineWithInvisibles; +pub use element::{Cursor, EditorElement, HighlightedRange, HighlightedRangeLine}; use futures::FutureExt; use fuzzy::{StringMatch, StringMatchCandidate}; use git::diff_hunk_to_display; use gpui::{ - actions, div, impl_actions, point, prelude::*, px, relative, rems, size, uniform_list, Action, + div, impl_actions, point, prelude::*, px, relative, rems, size, uniform_list, Action, AnyElement, AppContext, AsyncWindowContext, BackgroundExecutor, Bounds, ClipboardItem, Context, DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, HighlightStyle, Hsla, InputHandler, InteractiveText, KeyContext, Model, MouseButton, @@ -51,7 +66,7 @@ use hover_popover::{hide_hover, HoverState}; use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy}; pub use items::MAX_TAB_TITLE_LEN; use itertools::Itertools; -pub use language::{char_kind, CharKind}; +use language::{char_kind, CharKind}; use language::{ language_settings::{self, all_language_settings, InlayHintSettings}, markdown, point_from_lsp, AutoindentMode, BracketPair, Buffer, Capability, CodeAction, @@ -74,9 +89,7 @@ use parking_lot::RwLock; use project::{FormatTrigger, Location, Project, ProjectPath, ProjectTransaction}; use rand::prelude::*; use rpc::proto::{self, *}; -use scroll::{ - autoscroll::Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager, ScrollbarAutoHide, -}; +use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager, ScrollbarAutoHide}; use selections_collection::{resolve_multiple, MutableSelectionsCollection, SelectionsCollection}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; @@ -97,9 +110,12 @@ use std::{ pub use sum_tree::Bias; use sum_tree::TreeMap; use text::{OffsetUtf16, Rope}; -use theme::{ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, ThemeColors, ThemeSettings}; +use theme::{ + observe_buffer_font_size_adjustment, ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, + ThemeColors, ThemeSettings, +}; use ui::{ - h_stack, prelude::*, ButtonSize, ButtonStyle, Icon, IconButton, IconSize, ListItem, Popover, + h_flex, prelude::*, ButtonSize, ButtonStyle, IconButton, IconName, IconSize, ListItem, Popover, Tooltip, }; use util::{post_inc, RangeExt, ResultExt, TryFutureExt}; @@ -110,10 +126,12 @@ const MAX_LINE_LEN: usize = 1024; const MIN_NAVIGATION_HISTORY_ROW_DELTA: i64 = 10; const MAX_SELECTION_HISTORY_LEN: usize = 1024; const COPILOT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75); +#[doc(hidden)] pub const CODE_ACTIONS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250); +#[doc(hidden)] pub const DOCUMENT_HIGHLIGHTS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75); -pub const FORMAT_TIMEOUT: Duration = Duration::from_secs(2); +pub(crate) const FORMAT_TIMEOUT: Duration = Duration::from_secs(2); pub fn render_parsed_markdown( element_id: impl Into, @@ -178,103 +196,8 @@ pub fn render_parsed_markdown( }) } -#[derive(PartialEq, Clone, Deserialize, Default)] -pub struct SelectNext { - #[serde(default)] - pub replace_newest: bool, -} - -#[derive(PartialEq, Clone, Deserialize, Default)] -pub struct SelectPrevious { - #[serde(default)] - pub replace_newest: bool, -} - -#[derive(PartialEq, Clone, Deserialize, Default)] -pub struct SelectAllMatches { - #[serde(default)] - pub replace_newest: bool, -} - -#[derive(PartialEq, Clone, Deserialize, Default)] -pub struct SelectToBeginningOfLine { - #[serde(default)] - stop_at_soft_wraps: bool, -} - -#[derive(PartialEq, Clone, Deserialize, Default)] -pub struct MovePageUp { - #[serde(default)] - center_cursor: bool, -} - -#[derive(PartialEq, Clone, Deserialize, Default)] -pub struct MovePageDown { - #[serde(default)] - center_cursor: bool, -} - -#[derive(PartialEq, Clone, Deserialize, Default)] -pub struct SelectToEndOfLine { - #[serde(default)] - stop_at_soft_wraps: bool, -} - -#[derive(PartialEq, Clone, Deserialize, Default)] -pub struct ToggleCodeActions { - #[serde(default)] - pub deployed_from_indicator: bool, -} - -#[derive(PartialEq, Clone, Deserialize, Default)] -pub struct ConfirmCompletion { - #[serde(default)] - pub item_ix: Option, -} - -#[derive(PartialEq, Clone, Deserialize, Default)] -pub struct ConfirmCodeAction { - #[serde(default)] - pub item_ix: Option, -} - -#[derive(PartialEq, Clone, Deserialize, Default)] -pub struct ToggleComments { - #[serde(default)] - pub advance_downwards: bool, -} - -#[derive(PartialEq, Clone, Deserialize, Default)] -pub struct FoldAt { - pub buffer_row: u32, -} - -#[derive(PartialEq, Clone, Deserialize, Default)] -pub struct UnfoldAt { - pub buffer_row: u32, -} - -impl_actions!( - editor, - [ - SelectNext, - SelectPrevious, - SelectAllMatches, - SelectToBeginningOfLine, - MovePageUp, - MovePageDown, - SelectToEndOfLine, - ToggleCodeActions, - ConfirmCompletion, - ConfirmCodeAction, - ToggleComments, - FoldAt, - UnfoldAt - ] -); - #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum InlayId { +pub(crate) enum InlayId { Suggestion(usize), Hint(usize), } @@ -288,128 +211,6 @@ impl InlayId { } } -actions!( - editor, - [ - AddSelectionAbove, - AddSelectionBelow, - Backspace, - Cancel, - ConfirmRename, - ContextMenuFirst, - ContextMenuLast, - ContextMenuNext, - ContextMenuPrev, - ConvertToKebabCase, - ConvertToLowerCamelCase, - ConvertToLowerCase, - ConvertToSnakeCase, - ConvertToTitleCase, - ConvertToUpperCamelCase, - ConvertToUpperCase, - Copy, - CopyHighlightJson, - CopyPath, - CopyRelativePath, - Cut, - CutToEndOfLine, - Delete, - DeleteLine, - DeleteToBeginningOfLine, - DeleteToEndOfLine, - DeleteToNextSubwordEnd, - DeleteToNextWordEnd, - DeleteToPreviousSubwordStart, - DeleteToPreviousWordStart, - DuplicateLine, - ExpandMacroRecursively, - FindAllReferences, - Fold, - FoldSelectedRanges, - Format, - GoToDefinition, - GoToDefinitionSplit, - GoToDiagnostic, - GoToHunk, - GoToPrevDiagnostic, - GoToPrevHunk, - GoToTypeDefinition, - GoToTypeDefinitionSplit, - HalfPageDown, - HalfPageUp, - Hover, - Indent, - JoinLines, - LineDown, - LineUp, - MoveDown, - MoveLeft, - MoveLineDown, - MoveLineUp, - MoveRight, - MoveToBeginning, - MoveToBeginningOfLine, - MoveToEnclosingBracket, - MoveToEnd, - MoveToEndOfLine, - MoveToEndOfParagraph, - MoveToNextSubwordEnd, - MoveToNextWordEnd, - MoveToPreviousSubwordStart, - MoveToPreviousWordStart, - MoveToStartOfParagraph, - MoveUp, - Newline, - NewlineAbove, - NewlineBelow, - NextScreen, - OpenExcerpts, - Outdent, - PageDown, - PageUp, - Paste, - Redo, - RedoSelection, - Rename, - RestartLanguageServer, - RevealInFinder, - ReverseLines, - ScrollCursorBottom, - ScrollCursorCenter, - ScrollCursorTop, - SelectAll, - SelectDown, - SelectLargerSyntaxNode, - SelectLeft, - SelectLine, - SelectRight, - SelectSmallerSyntaxNode, - SelectToBeginning, - SelectToEnd, - SelectToEndOfParagraph, - SelectToNextSubwordEnd, - SelectToNextWordEnd, - SelectToPreviousSubwordStart, - SelectToPreviousWordStart, - SelectToStartOfParagraph, - SelectUp, - ShowCharacterPalette, - ShowCompletions, - ShuffleLines, - SortLinesCaseInsensitive, - SortLinesCaseSensitive, - SplitSelectionIntoLines, - Tab, - TabPrev, - ToggleInlayHints, - ToggleSoftWrap, - Transpose, - Undo, - UndoSelection, - UnfoldLines, - ] -); - enum DocumentHighlightRead {} enum DocumentHighlightWrite {} enum InputComposition {} @@ -486,7 +287,7 @@ pub enum SelectPhase { } #[derive(Clone, Debug)] -pub enum SelectMode { +pub(crate) enum SelectMode { Character, Word(Range), Line(Range), @@ -507,7 +308,7 @@ pub enum SoftWrap { Column(u32), } -#[derive(Clone, Default)] +#[derive(Clone)] pub struct EditorStyle { pub background: Hsla, pub local_player: PlayerColor, @@ -519,6 +320,24 @@ pub struct EditorStyle { pub suggestions_style: HighlightStyle, } +impl Default for EditorStyle { + fn default() -> Self { + Self { + background: Hsla::default(), + local_player: PlayerColor::default(), + text: TextStyle::default(), + scrollbar_width: Pixels::default(), + syntax: Default::default(), + // HACK: Status colors don't have a real default. + // We should look into removing the status colors from the editor + // style and retrieve them directly from the theme. + status: StatusColors::dark(), + inlays_style: HighlightStyle::default(), + suggestions_style: HighlightStyle::default(), + } + } +} + type CompletionId = usize; // type GetFieldEditorTheme = dyn Fn(&theme::Theme) -> theme::FieldEditor; @@ -586,6 +405,7 @@ pub struct Editor { gutter_width: Pixels, style: Option, editor_actions: Vec)>>, + show_copilot_suggestions: bool, } pub struct EditorSnapshot { @@ -738,6 +558,7 @@ struct SnippetState { active_index: usize, } +#[doc(hidden)] pub struct RenameState { pub range: Range, pub old_name: Arc, @@ -1245,7 +1066,7 @@ impl CompletionsMenu { None } else { Some( - h_stack().ml_4().child( + h_flex().ml_4().child( Label::new(text.clone()) .size(LabelSize::Small) .color(Color::Muted), @@ -1271,7 +1092,7 @@ impl CompletionsMenu { ) .map(|task| task.detach_and_log_err(cx)); })) - .child(h_stack().overflow_hidden().child(completion_label)) + .child(h_flex().overflow_hidden().child(completion_label)) .end_slot::
(documentation_label), ) }) @@ -1477,7 +1298,7 @@ impl CodeActionsMenu { } } -pub struct CopilotState { +pub(crate) struct CopilotState { excerpt_id: Option, pending_refresh: Task>, pending_cycling_refresh: Task>, @@ -1597,15 +1418,13 @@ pub struct ClipboardSelection { } #[derive(Debug)] -pub struct NavigationData { +pub(crate) struct NavigationData { cursor_anchor: Anchor, cursor_position: Point, scroll_anchor: ScrollAnchor, scroll_top_row: u32, } -pub struct EditorCreated(pub View); - enum GotoDefinitionKind { Symbol, Type, @@ -1786,12 +1605,14 @@ impl Editor { gutter_width: Default::default(), style: None, editor_actions: Default::default(), + show_copilot_suggestions: mode == EditorMode::Full, _subscriptions: vec![ cx.observe(&buffer, Self::on_buffer_changed), cx.subscribe(&buffer, Self::on_buffer_event), cx.observe(&display_map, Self::on_display_map_changed), cx.observe(&blink_manager, |_, _, cx| cx.notify()), cx.observe_global::(Self::settings_changed), + observe_buffer_font_size_adjustment(cx, |_, cx| cx.notify()), cx.observe_window_activation(|editor, cx| { let active = cx.is_window_active(); editor.blink_manager.update(cx, |blink_manager, cx| { @@ -1811,10 +1632,6 @@ impl Editor { this.end_selection(cx); this.scroll_manager.show_scrollbar(cx); - // todo!("use a different mechanism") - // let editor_created_event = EditorCreated(cx.handle()); - // cx.emit_global(editor_created_event); - if mode == EditorMode::Full { let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars(); cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars)); @@ -1941,17 +1758,21 @@ impl Editor { } } - // pub fn language_at<'a, T: ToOffset>( - // &self, - // point: T, - // cx: &'a AppContext, - // ) -> Option> { - // self.buffer.read(cx).language_at(point, cx) - // } + pub fn language_at<'a, T: ToOffset>( + &self, + point: T, + cx: &'a AppContext, + ) -> Option> { + self.buffer.read(cx).language_at(point, cx) + } - // pub fn file_at<'a, T: ToOffset>(&self, point: T, cx: &'a AppContext) -> Option> { - // self.buffer.read(cx).read(cx).file_at(point).cloned() - // } + pub fn file_at<'a, T: ToOffset>( + &self, + point: T, + cx: &'a AppContext, + ) -> Option> { + self.buffer.read(cx).read(cx).file_at(point).cloned() + } pub fn active_excerpt( &self, @@ -1962,15 +1783,6 @@ impl Editor { .excerpt_containing(self.selections.newest_anchor().head(), cx) } - // pub fn style(&self, cx: &AppContext) -> EditorStyle { - // build_style( - // settings::get::(cx), - // self.get_field_editor_theme.as_deref(), - // self.override_text_style.as_deref(), - // cx, - // ) - // } - pub fn mode(&self) -> EditorMode { self.mode } @@ -2057,6 +1869,10 @@ impl Editor { self.read_only = read_only; } + pub fn set_show_copilot_suggestions(&mut self, show_copilot_suggestions: bool) { + self.show_copilot_suggestions = show_copilot_suggestions; + } + fn selections_did_change( &mut self, local: bool, @@ -3967,7 +3783,7 @@ impl Editor { cx: &mut ViewContext, ) -> Option<()> { let copilot = Copilot::global(cx)?; - if self.mode != EditorMode::Full || !copilot.read(cx).status().is_authorized() { + if !self.show_copilot_suggestions || !copilot.read(cx).status().is_authorized() { self.clear_copilot_suggestions(cx); return None; } @@ -4027,7 +3843,7 @@ impl Editor { cx: &mut ViewContext, ) -> Option<()> { let copilot = Copilot::global(cx)?; - if self.mode != EditorMode::Full || !copilot.read(cx).status().is_authorized() { + if !self.show_copilot_suggestions || !copilot.read(cx).status().is_authorized() { return None; } @@ -4152,7 +3968,8 @@ impl Editor { let file = snapshot.file_at(location); let language = snapshot.language_at(location); let settings = all_language_settings(file, cx); - settings.copilot_enabled(language, file.map(|f| f.path().as_ref())) + self.show_copilot_suggestions + && settings.copilot_enabled(language, file.map(|f| f.path().as_ref())) } fn has_active_copilot_suggestion(&self, cx: &AppContext) -> bool { @@ -4223,7 +4040,7 @@ impl Editor { ) -> Option { if self.available_code_actions.is_some() { Some( - IconButton::new("code_actions_indicator", ui::Icon::Bolt) + IconButton::new("code_actions_indicator", ui::IconName::Bolt) .icon_size(IconSize::Small) .icon_color(Color::Muted) .selected(is_active) @@ -4257,7 +4074,7 @@ impl Editor { fold_data .map(|(fold_status, buffer_row, active)| { (active || gutter_hovered || fold_status == FoldStatus::Folded).then(|| { - IconButton::new(ix as usize, ui::Icon::ChevronDown) + IconButton::new(ix as usize, ui::IconName::ChevronDown) .on_click(cx.listener(move |editor, _e, cx| match fold_status { FoldStatus::Folded => { editor.unfold_at(&UnfoldAt { buffer_row }, cx); @@ -4269,7 +4086,7 @@ impl Editor { .icon_color(ui::Color::Muted) .icon_size(ui::IconSize::Small) .selected(fold_status == FoldStatus::Folded) - .selected_icon(ui::Icon::ChevronRight) + .selected_icon(ui::IconName::ChevronRight) .size(ui::ButtonSize::None) }) }) @@ -4497,7 +4314,7 @@ impl Editor { } pub fn tab(&mut self, _: &Tab, cx: &mut ViewContext) { - if self.move_to_next_snippet_tabstop(cx) { + if self.move_to_next_snippet_tabstop(cx) || self.read_only(cx) { return; } @@ -5429,6 +5246,10 @@ impl Editor { } pub fn paste(&mut self, _: &Paste, cx: &mut ViewContext) { + if self.read_only(cx) { + return; + } + self.transact(cx, |this, cx| { if let Some(item) = cx.read_from_clipboard() { let clipboard_text = Cow::Borrowed(item.text()); @@ -5501,6 +5322,10 @@ impl Editor { } pub fn undo(&mut self, _: &Undo, cx: &mut ViewContext) { + if self.read_only(cx) { + return; + } + if let Some(tx_id) = self.buffer.update(cx, |buffer, cx| buffer.undo(cx)) { if let Some((selections, _)) = self.selection_history.transaction(tx_id).cloned() { self.change_selections(None, cx, |s| { @@ -5515,6 +5340,10 @@ impl Editor { } pub fn redo(&mut self, _: &Redo, cx: &mut ViewContext) { + if self.read_only(cx) { + return; + } + if let Some(tx_id) = self.buffer.update(cx, |buffer, cx| buffer.redo(cx)) { if let Some((_, Some(selections))) = self.selection_history.transaction(tx_id).cloned() { @@ -6439,42 +6268,79 @@ impl Editor { } self.select_next_state = Some(select_next_state); - } else if selections.len() == 1 { - let selection = selections.last_mut().unwrap(); - if selection.start == selection.end { - let word_range = movement::surrounding_word( - &display_map, - selection.start.to_display_point(&display_map), - ); - selection.start = word_range.start.to_offset(&display_map, Bias::Left); - selection.end = word_range.end.to_offset(&display_map, Bias::Left); - selection.goal = SelectionGoal::None; - selection.reversed = false; + } else { + let mut only_carets = true; + let mut same_text_selected = true; + let mut selected_text = None; - let query = buffer - .text_for_range(selection.start..selection.end) - .collect::(); + let mut selections_iter = selections.iter().peekable(); + while let Some(selection) = selections_iter.next() { + if selection.start != selection.end { + only_carets = false; + } - let is_empty = query.is_empty(); - let select_state = SelectNextState { - query: AhoCorasick::new(&[query])?, - wordwise: true, - done: is_empty, - }; - select_next_match_ranges( - self, - selection.start..selection.end, - replace_newest, - autoscroll, - cx, - ); - self.select_next_state = Some(select_state); - } else { - let query = buffer - .text_for_range(selection.start..selection.end) - .collect::(); + if same_text_selected { + if selected_text.is_none() { + selected_text = + Some(buffer.text_for_range(selection.range()).collect::()); + } + + if let Some(next_selection) = selections_iter.peek() { + if next_selection.range().len() == selection.range().len() { + let next_selected_text = buffer + .text_for_range(next_selection.range()) + .collect::(); + if Some(next_selected_text) != selected_text { + same_text_selected = false; + selected_text = None; + } + } else { + same_text_selected = false; + selected_text = None; + } + } + } + } + + if only_carets { + for selection in &mut selections { + let word_range = movement::surrounding_word( + &display_map, + selection.start.to_display_point(&display_map), + ); + selection.start = word_range.start.to_offset(&display_map, Bias::Left); + selection.end = word_range.end.to_offset(&display_map, Bias::Left); + selection.goal = SelectionGoal::None; + selection.reversed = false; + select_next_match_ranges( + self, + selection.start..selection.end, + replace_newest, + autoscroll, + cx, + ); + } + + if selections.len() == 1 { + let selection = selections + .last() + .expect("ensured that there's only one selection"); + let query = buffer + .text_for_range(selection.start..selection.end) + .collect::(); + let is_empty = query.is_empty(); + let select_state = SelectNextState { + query: AhoCorasick::new(&[query])?, + wordwise: true, + done: is_empty, + }; + self.select_next_state = Some(select_state); + } else { + self.select_next_state = None; + } + } else if let Some(selected_text) = selected_text { self.select_next_state = Some(SelectNextState { - query: AhoCorasick::new(&[query])?, + query: AhoCorasick::new(&[selected_text])?, wordwise: false, done: false, }); @@ -6578,39 +6444,81 @@ impl Editor { } self.select_prev_state = Some(select_prev_state); - } else if selections.len() == 1 { - let selection = selections.last_mut().unwrap(); - if selection.start == selection.end { - let word_range = movement::surrounding_word( - &display_map, - selection.start.to_display_point(&display_map), - ); - selection.start = word_range.start.to_offset(&display_map, Bias::Left); - selection.end = word_range.end.to_offset(&display_map, Bias::Left); - selection.goal = SelectionGoal::None; - selection.reversed = false; + } else { + let mut only_carets = true; + let mut same_text_selected = true; + let mut selected_text = None; - let query = buffer - .text_for_range(selection.start..selection.end) - .collect::(); - let query = query.chars().rev().collect::(); - let select_state = SelectNextState { - query: AhoCorasick::new(&[query])?, - wordwise: true, - done: false, - }; - self.unfold_ranges([selection.start..selection.end], false, true, cx); + let mut selections_iter = selections.iter().peekable(); + while let Some(selection) = selections_iter.next() { + if selection.start != selection.end { + only_carets = false; + } + + if same_text_selected { + if selected_text.is_none() { + selected_text = + Some(buffer.text_for_range(selection.range()).collect::()); + } + + if let Some(next_selection) = selections_iter.peek() { + if next_selection.range().len() == selection.range().len() { + let next_selected_text = buffer + .text_for_range(next_selection.range()) + .collect::(); + if Some(next_selected_text) != selected_text { + same_text_selected = false; + selected_text = None; + } + } else { + same_text_selected = false; + selected_text = None; + } + } + } + } + + if only_carets { + for selection in &mut selections { + let word_range = movement::surrounding_word( + &display_map, + selection.start.to_display_point(&display_map), + ); + selection.start = word_range.start.to_offset(&display_map, Bias::Left); + selection.end = word_range.end.to_offset(&display_map, Bias::Left); + selection.goal = SelectionGoal::None; + selection.reversed = false; + } + if selections.len() == 1 { + let selection = selections + .last() + .expect("ensured that there's only one selection"); + let query = buffer + .text_for_range(selection.start..selection.end) + .collect::(); + let is_empty = query.is_empty(); + let select_state = SelectNextState { + query: AhoCorasick::new(&[query.chars().rev().collect::()])?, + wordwise: true, + done: is_empty, + }; + self.select_prev_state = Some(select_state); + } else { + self.select_prev_state = None; + } + + self.unfold_ranges( + selections.iter().map(|s| s.range()).collect::>(), + false, + true, + cx, + ); self.change_selections(Some(Autoscroll::newest()), cx, |s| { s.select(selections); }); - self.select_prev_state = Some(select_state); - } else { - let query = buffer - .text_for_range(selection.start..selection.end) - .collect::(); - let query = query.chars().rev().collect::(); + } else if let Some(selected_text) = selected_text { self.select_prev_state = Some(SelectNextState { - query: AhoCorasick::new(&[query])?, + query: AhoCorasick::new(&[selected_text.chars().rev().collect::()])?, wordwise: false, done: false, }); @@ -7036,7 +6944,7 @@ impl Editor { let buffer = self.buffer.read(cx).snapshot(cx); let selection = self.selections.newest::(cx); - // If there is an active Diagnostic Popover. Jump to it's diagnostic instead. + // If there is an active Diagnostic Popover jump to its diagnostic instead. if direction == Direction::Next { if let Some(popover) = self.hover_state.diagnostic_popover.as_ref() { let (group_id, jump_to) = popover.activation_info(); @@ -7663,7 +7571,6 @@ impl Editor { scrollbar_width: cx.editor_style.scrollbar_width, syntax: cx.editor_style.syntax.clone(), status: cx.editor_style.status.clone(), - // todo!("what about the rest of the highlight style parts for inlays and suggestions?") inlays_style: HighlightStyle { color: Some(cx.theme().status().hint), font_weight: Some(FontWeight::BOLD), @@ -8015,7 +7922,7 @@ impl Editor { } } - pub fn fold(&mut self, _: &Fold, cx: &mut ViewContext) { + pub fn fold(&mut self, _: &actions::Fold, cx: &mut ViewContext) { let mut fold_ranges = Vec::new(); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); @@ -8374,7 +8281,7 @@ impl Editor { cx.notify(); } - pub fn highlight_inlay_background( + pub(crate) fn highlight_inlay_background( &mut self, ranges: Vec, color_fetcher: fn(&ThemeColors) -> Hsla, @@ -8581,7 +8488,7 @@ impl Editor { cx.notify(); } - pub fn highlight_inlays( + pub(crate) fn highlight_inlays( &mut self, highlights: Vec, style: HighlightStyle, @@ -8626,7 +8533,7 @@ impl Editor { ) { match event { multi_buffer::Event::Edited { - sigleton_buffer_edited, + singleton_buffer_edited, } => { self.refresh_active_diagnostics(cx); self.refresh_code_actions(cx); @@ -8636,7 +8543,7 @@ impl Editor { cx.emit(EditorEvent::BufferEdited); cx.emit(SearchEvent::MatchesInvalidated); - if *sigleton_buffer_edited { + if *singleton_buffer_edited { if let Some(project) = &self.project { let project = project.read(cx); let languages_affected = multibuffer @@ -8664,6 +8571,10 @@ impl Editor { } } } + + let Some(project) = &self.project else { return }; + let telemetry = project.read(cx).client().telemetry().clone(); + telemetry.log_edit_event("editor"); } multi_buffer::Event::ExcerptsAdded { buffer, @@ -8710,6 +8621,7 @@ impl Editor { )), cx, ); + cx.notify(); } pub fn set_searchable(&mut self, searchable: bool) { @@ -9332,7 +9244,6 @@ impl Render for Editor { scrollbar_width: px(12.), syntax: cx.theme().syntax().clone(), status: cx.theme().status().clone(), - // todo!("what about the rest of the highlight style parts?") inlays_style: HighlightStyle { color: Some(cx.theme().status().hint), font_weight: Some(FontWeight::BOLD), @@ -9717,7 +9628,7 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> Ren let group_id: SharedString = cx.block_id.to_string().into(); // TODO: Nate: We should tint the background of the block with the severity color // We need to extend the theme before we can do this - h_stack() + h_flex() .id(cx.block_id) .group(group_id.clone()) .relative() @@ -9739,7 +9650,7 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> Ren ), ) .child( - IconButton::new(("copy-block", cx.block_id), Icon::Copy) + IconButton::new(("copy-block", cx.block_id), IconName::Copy) .icon_color(Color::Muted) .size(ButtonSize::Compact) .style(ButtonStyle::Transparent) @@ -9785,7 +9696,7 @@ pub fn highlight_diagnostic_message(diagnostic: &Diagnostic) -> (SharedString, V (text_without_backticks.into(), code_ranges) } -pub fn diagnostic_style(severity: DiagnosticSeverity, valid: bool, colors: &StatusColors) -> Hsla { +fn diagnostic_style(severity: DiagnosticSeverity, valid: bool, colors: &StatusColors) -> Hsla { match (severity, valid) { (DiagnosticSeverity::ERROR, true) => colors.error, (DiagnosticSeverity::ERROR, false) => colors.error, @@ -9844,7 +9755,7 @@ pub fn styled_runs_for_code_label<'a>( }) } -pub fn split_words<'a>(text: &'a str) -> impl std::iter::Iterator + 'a { +pub(crate) fn split_words<'a>(text: &'a str) -> impl std::iter::Iterator + 'a { let mut index = 0; let mut codepoints = text.char_indices().peekable(); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 66f28db3e4..a6e3d19995 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -539,7 +539,6 @@ fn test_clone(cx: &mut TestAppContext) { ); } -//todo!(editor navigate) #[gpui::test] async fn test_navigation_history(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -993,7 +992,6 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) { }); } -//todo!(finish editor tests) #[gpui::test] fn test_move_cursor_different_line_lengths(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -1259,7 +1257,6 @@ fn test_prev_next_word_boundary(cx: &mut TestAppContext) { }); } -//todo!(finish editor tests) #[gpui::test] fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -1318,7 +1315,6 @@ fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut TestAppContext) { }); } -//todo!(simulate_resize) #[gpui::test] async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); @@ -2546,7 +2542,6 @@ fn test_delete_line(cx: &mut TestAppContext) { }); } -//todo!(select_anchor_ranges) #[gpui::test] fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -3114,7 +3109,6 @@ fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) { }); } -//todo!(test_transpose) #[gpui::test] fn test_transpose(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -3827,62 +3821,137 @@ async fn test_select_next(cx: &mut gpui::TestAppContext) { } #[gpui::test] -async fn test_select_previous(cx: &mut gpui::TestAppContext) { +async fn test_select_next_with_multiple_carets(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); - { - // `Select previous` without a selection (selects wordwise) - let mut cx = EditorTestContext::new(cx).await; - cx.set_state("abc\nˇabc abc\ndefabc\nabc"); - cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) - .unwrap(); - cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state( + r#"let foo = 2; +lˇet foo = 2; +let fooˇ = 2; +let foo = 2; +let foo = ˇ2;"#, + ); - cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) - .unwrap(); - cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc"); + cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx)) + .unwrap(); + cx.assert_editor_state( + r#"let foo = 2; +«letˇ» foo = 2; +let «fooˇ» = 2; +let foo = 2; +let foo = «2ˇ»;"#, + ); - cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx)); - cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); + // noop for multiple selections with different contents + cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx)) + .unwrap(); + cx.assert_editor_state( + r#"let foo = 2; +«letˇ» foo = 2; +let «fooˇ» = 2; +let foo = 2; +let foo = «2ˇ»;"#, + ); +} - cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx)); - cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc"); +#[gpui::test] +async fn test_select_previous_with_single_caret(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); - cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) - .unwrap(); - cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\n«abcˇ»"); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("abc\nˇabc abc\ndefabc\nabc"); - cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) - .unwrap(); - cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»"); - } - { - // `Select previous` with a selection - let mut cx = EditorTestContext::new(cx).await; - cx.set_state("abc\n«ˇabc» abc\ndefabc\nabc"); + cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) + .unwrap(); + cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); - cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) - .unwrap(); - cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\nabc"); + cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) + .unwrap(); + cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc"); - cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) - .unwrap(); - cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\n«abcˇ»"); + cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx)); + cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); - cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx)); - cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\nabc"); + cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx)); + cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc"); - cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx)); - cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\n«abcˇ»"); + cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) + .unwrap(); + cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\n«abcˇ»"); - cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) - .unwrap(); - cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndef«abcˇ»\n«abcˇ»"); + cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) + .unwrap(); + cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndef«abcˇ»\n«abcˇ»"); - cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) - .unwrap(); - cx.assert_editor_state("«abcˇ»\n«ˇabc» «abcˇ»\ndef«abcˇ»\n«abcˇ»"); - } + cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) + .unwrap(); + cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndef«abcˇ»\n«abcˇ»"); +} + +#[gpui::test] +async fn test_select_previous_with_multiple_carets(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state( + r#"let foo = 2; +lˇet foo = 2; +let fooˇ = 2; +let foo = 2; +let foo = ˇ2;"#, + ); + + cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) + .unwrap(); + cx.assert_editor_state( + r#"let foo = 2; +«letˇ» foo = 2; +let «fooˇ» = 2; +let foo = 2; +let foo = «2ˇ»;"#, + ); + + // noop for multiple selections with different contents + cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) + .unwrap(); + cx.assert_editor_state( + r#"let foo = 2; +«letˇ» foo = 2; +let «fooˇ» = 2; +let foo = 2; +let foo = «2ˇ»;"#, + ); +} + +#[gpui::test] +async fn test_select_previous_with_single_selection(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("abc\n«ˇabc» abc\ndefabc\nabc"); + + cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) + .unwrap(); + cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\nabc"); + + cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) + .unwrap(); + cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\n«abcˇ»"); + + cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx)); + cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\nabc"); + + cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx)); + cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\n«abcˇ»"); + + cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) + .unwrap(); + cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndef«abcˇ»\n«abcˇ»"); + + cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) + .unwrap(); + cx.assert_editor_state("«abcˇ»\n«ˇabc» «abcˇ»\ndef«abcˇ»\n«abcˇ»"); } #[gpui::test] @@ -4860,7 +4929,6 @@ async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) { }); } -// todo!(select_anchor_ranges) #[gpui::test] async fn test_snippets(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); @@ -6455,7 +6523,6 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) { }); } -// todo!(following) #[gpui::test] async fn test_following(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); @@ -7094,7 +7161,6 @@ async fn test_move_to_enclosing_bracket(cx: &mut gpui::TestAppContext) { ); } -// todo!(completions) #[gpui::test(iterations = 10)] async fn test_copilot(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) { // flaky diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index c7fbb658a3..06faf3265e 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -26,11 +26,11 @@ use git::diff::DiffHunkStatus; use gpui::{ div, fill, outline, overlay, point, px, quad, relative, size, transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, BorrowWindow, Bounds, ContentMask, Corners, - CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Hsla, InteractiveBounds, - InteractiveElement, IntoElement, ModifiersChangedEvent, MouseButton, MouseDownEvent, - MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, ScrollWheelEvent, ShapedLine, - SharedString, Size, StackingOrder, StatefulInteractiveElement, Style, Styled, TextRun, - TextStyle, View, ViewContext, WindowContext, + CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity, Hsla, + InteractiveBounds, InteractiveElement, IntoElement, ModifiersChangedEvent, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, ScrollDelta, + ScrollWheelEvent, ShapedLine, SharedString, Size, StackingOrder, StatefulInteractiveElement, + Style, Styled, TextRun, TextStyle, View, ViewContext, WindowContext, }; use itertools::Itertools; use language::language_settings::ShowWhitespaceSetting; @@ -53,7 +53,7 @@ use std::{ use sum_tree::Bias; use theme::{ActiveTheme, PlayerColor}; use ui::prelude::*; -use ui::{h_stack, ButtonLike, ButtonStyle, IconButton, Tooltip}; +use ui::{h_flex, ButtonLike, ButtonStyle, IconButton, Tooltip}; use util::ResultExt; use workspace::item::Item; @@ -388,7 +388,9 @@ impl EditorElement { let mut click_count = event.click_count; let modifiers = event.modifiers; - if gutter_bounds.contains(&event.position) { + if cx.default_prevented() { + return; + } else if gutter_bounds.contains(&event.position) { click_count = 3; // Simulate triple-click when clicking the gutter to select lines } else if !text_bounds.contains(&event.position) { return; @@ -454,6 +456,7 @@ impl EditorElement { event: &MouseUpEvent, position_map: &PositionMap, text_bounds: Bounds, + interactive_bounds: &InteractiveBounds, stacking_order: &StackingOrder, cx: &mut ViewContext, ) { @@ -464,7 +467,8 @@ impl EditorElement { editor.select(SelectPhase::End, cx); } - if !pending_nonempty_selections + if interactive_bounds.visibly_contains(&event.position, cx) + && !pending_nonempty_selections && event.modifiers.command && text_bounds.contains(&event.position) && cx.was_top_layer(&event.position, stacking_order) @@ -581,41 +585,6 @@ impl EditorElement { } } - fn scroll( - editor: &mut Editor, - event: &ScrollWheelEvent, - position_map: &PositionMap, - bounds: &InteractiveBounds, - cx: &mut ViewContext, - ) { - if !bounds.visibly_contains(&event.position, cx) { - return; - } - - let line_height = position_map.line_height; - let max_glyph_width = position_map.em_width; - let (delta, axis) = match event.delta { - gpui::ScrollDelta::Pixels(mut pixels) => { - //Trackpad - let axis = position_map.snapshot.ongoing_scroll.filter(&mut pixels); - (pixels, axis) - } - - gpui::ScrollDelta::Lines(lines) => { - //Not trackpad - let pixels = point(lines.x * max_glyph_width, lines.y * line_height); - (pixels, None) - } - }; - - let scroll_position = position_map.snapshot.scroll_position(); - let x = f32::from((scroll_position.x * max_glyph_width - delta.x) / max_glyph_width); - let y = f32::from((scroll_position.y * line_height - delta.y) / line_height); - let scroll_position = point(x, y).clamp(&point(0., 0.), &position_map.scroll_max); - editor.scroll(scroll_position, axis, cx); - cx.stop_propagation(); - } - fn paint_background( &self, gutter_bounds: Bounds, @@ -839,9 +808,27 @@ impl EditorElement { let start_row = display_row_range.start; let end_row = display_row_range.end; + // If we're in a multibuffer, row range span might include an + // excerpt header, so if we were to draw the marker straight away, + // the hunk might include the rows of that header. + // Making the range inclusive doesn't quite cut it, as we rely on the exclusivity for the soft wrap. + // Instead, we simply check whether the range we're dealing with includes + // any excerpt headers and if so, we stop painting the diff hunk on the first row of that header. + let end_row_in_current_excerpt = layout + .position_map + .snapshot + .blocks_in_range(start_row..end_row) + .find_map(|(start_row, block)| { + if matches!(block, TransformBlock::ExcerptHeader { .. }) { + Some(start_row) + } else { + None + } + }) + .unwrap_or(end_row); let start_y = start_row as f32 * line_height - scroll_top; - let end_y = end_row as f32 * line_height - scroll_top; + let end_y = end_row_in_current_excerpt as f32 * line_height - scroll_top; let width = 0.275 * line_height; let highlight_origin = bounds.origin + point(-width, start_y); @@ -900,16 +887,23 @@ impl EditorElement { let fold_corner_radius = 0.15 * layout.position_map.line_height; cx.with_element_id(Some("folds"), |cx| { let snapshot = &layout.position_map.snapshot; + for fold in snapshot.folds_in_range(layout.visible_anchor_range.clone()) { let fold_range = fold.range.clone(); let display_range = fold.range.start.to_display_point(&snapshot) ..fold.range.end.to_display_point(&snapshot); debug_assert_eq!(display_range.start.row(), display_range.end.row()); let row = display_range.start.row(); + debug_assert!(row < layout.visible_display_row_range.end); + let Some(line_layout) = &layout + .position_map + .line_layouts + .get((row - layout.visible_display_row_range.start) as usize) + .map(|l| &l.line) + else { + continue; + }; - let line_layout = &layout.position_map.line_layouts - [(row - layout.visible_display_row_range.start) as usize] - .line; let start_x = content_origin.x + line_layout.x_for_index(display_range.start.column() as usize) - layout.position_map.scroll_position.x; @@ -1032,7 +1026,6 @@ impl EditorElement { .chars_at(cursor_position) .next() .and_then(|(character, _)| { - // todo!() currently shape_line panics if text conatins newlines let text = if character == '\n' { SharedString::from(" ") } else { @@ -2280,11 +2273,9 @@ impl EditorElement { .map_or(range.context.start, |primary| primary.start); let jump_position = language::ToPoint::to_point(&jump_anchor, buffer); - let jump_handler = cx.listener_for(&self.editor, move |editor, _, cx| { + cx.listener_for(&self.editor, move |editor, _, cx| { editor.jump(jump_path.clone(), jump_position, jump_anchor, cx); - }); - - jump_handler + }) }); let element = if *starts_new_buffer { @@ -2299,17 +2290,18 @@ impl EditorElement { .map(|p| SharedString::from(p.to_string_lossy().to_string() + "/")); } - div() + v_flex() .id(("path header container", block_id)) .size_full() - .p_1p5() + .justify_center() + .p(gpui::px(6.)) .child( - h_stack() + h_flex() .id("path header block") - .py_1p5() - .pl_3() - .pr_2() - .rounded_lg() + .size_full() + .pl(gpui::px(12.)) + .pr(gpui::px(8.)) + .rounded_md() .shadow_md() .border() .border_color(cx.theme().colors().border) @@ -2317,8 +2309,8 @@ impl EditorElement { .justify_between() .hover(|style| style.bg(cx.theme().colors().element_hover)) .child( - h_stack().gap_3().child( - h_stack() + h_flex().gap_3().child( + h_flex() .gap_2() .child( filename @@ -2350,12 +2342,12 @@ impl EditorElement { }), ) } else { - h_stack() + h_flex() .id(("collapsed context", block_id)) .size_full() .gap(gutter_padding) .child( - h_stack() + h_flex() .justify_end() .flex_none() .w(gutter_width - gutter_padding) @@ -2364,34 +2356,25 @@ impl EditorElement { .text_color(cx.theme().colors().editor_line_number) .child("..."), ) - .map(|this| { - if let Some(jump_handler) = jump_handler { - this.child( - ButtonLike::new("jump to collapsed context") - .style(ButtonStyle::Transparent) - .full_width() - .on_click(jump_handler) - .tooltip(|cx| { - Tooltip::for_action( - "Jump to Buffer", - &OpenExcerpts, - cx, - ) - }) - .child( - div() - .h_px() - .w_full() - .bg(cx.theme().colors().border_variant) - .group_hover("", |style| { - style.bg(cx.theme().colors().border) - }), - ), + .child( + ButtonLike::new("jump to collapsed context") + .style(ButtonStyle::Transparent) + .full_width() + .child( + div() + .h_px() + .w_full() + .bg(cx.theme().colors().border_variant) + .group_hover("", |style| { + style.bg(cx.theme().colors().border) + }), ) - } else { - this.child(div().size_full().bg(gpui::green())) - } - }) + .when_some(jump_handler, |this, jump_handler| { + this.on_click(jump_handler).tooltip(|cx| { + Tooltip::for_action("Jump to Buffer", &OpenExcerpts, cx) + }) + }), + ) }; element.into_any() } @@ -2450,6 +2433,64 @@ impl EditorElement { ) } + fn paint_scroll_wheel_listener( + &mut self, + interactive_bounds: &InteractiveBounds, + layout: &LayoutState, + cx: &mut WindowContext, + ) { + cx.on_mouse_event({ + let position_map = layout.position_map.clone(); + let editor = self.editor.clone(); + let interactive_bounds = interactive_bounds.clone(); + let mut delta = ScrollDelta::default(); + + move |event: &ScrollWheelEvent, phase, cx| { + if phase == DispatchPhase::Bubble + && interactive_bounds.visibly_contains(&event.position, cx) + { + delta = delta.coalesce(event.delta); + editor.update(cx, |editor, cx| { + let position = event.position; + let position_map: &PositionMap = &position_map; + let bounds = &interactive_bounds; + if !bounds.visibly_contains(&position, cx) { + return; + } + + let line_height = position_map.line_height; + let max_glyph_width = position_map.em_width; + let (delta, axis) = match delta { + gpui::ScrollDelta::Pixels(mut pixels) => { + //Trackpad + let axis = position_map.snapshot.ongoing_scroll.filter(&mut pixels); + (pixels, axis) + } + + gpui::ScrollDelta::Lines(lines) => { + //Not trackpad + let pixels = + point(lines.x * max_glyph_width, lines.y * line_height); + (pixels, None) + } + }; + + let scroll_position = position_map.snapshot.scroll_position(); + let x = f32::from( + (scroll_position.x * max_glyph_width - delta.x) / max_glyph_width, + ); + let y = + f32::from((scroll_position.y * line_height - delta.y) / line_height); + let scroll_position = + point(x, y).clamp(&point(0., 0.), &position_map.scroll_max); + editor.scroll(scroll_position, axis, cx); + cx.stop_propagation(); + }); + } + } + }); + } + fn paint_mouse_listeners( &mut self, bounds: Bounds, @@ -2463,21 +2504,7 @@ impl EditorElement { stacking_order: cx.stacking_order().clone(), }; - cx.on_mouse_event({ - let position_map = layout.position_map.clone(); - let editor = self.editor.clone(); - let interactive_bounds = interactive_bounds.clone(); - - move |event: &ScrollWheelEvent, phase, cx| { - if phase == DispatchPhase::Bubble - && interactive_bounds.visibly_contains(&event.position, cx) - { - editor.update(cx, |editor, cx| { - Self::scroll(editor, event, &position_map, &interactive_bounds, cx) - }); - } - } - }); + self.paint_scroll_wheel_listener(&interactive_bounds, layout, cx); cx.on_mouse_event({ let position_map = layout.position_map.clone(); @@ -2517,15 +2544,14 @@ impl EditorElement { let interactive_bounds = interactive_bounds.clone(); move |event: &MouseUpEvent, phase, cx| { - if phase == DispatchPhase::Bubble - && interactive_bounds.visibly_contains(&event.position, cx) - { + if phase == DispatchPhase::Bubble { editor.update(cx, |editor, cx| { Self::mouse_up( editor, event, &position_map, text_bounds, + &interactive_bounds, &stacking_order, cx, ) @@ -2574,7 +2600,7 @@ impl EditorElement { } #[derive(Debug)] -pub struct LineWithInvisibles { +pub(crate) struct LineWithInvisibles { pub line: ShapedLine, invisibles: Vec, } @@ -2779,44 +2805,49 @@ impl Element for EditorElement { _element_state: Option, cx: &mut gpui::WindowContext, ) -> (gpui::LayoutId, Self::State) { - self.editor.update(cx, |editor, cx| { - editor.set_style(self.style.clone(), cx); + cx.with_view_id(self.editor.entity_id(), |cx| { + self.editor.update(cx, |editor, cx| { + editor.set_style(self.style.clone(), cx); - let layout_id = match editor.mode { - EditorMode::SingleLine => { - let rem_size = cx.rem_size(); - let mut style = Style::default(); - style.size.width = relative(1.).into(); - style.size.height = self.style.text.line_height_in_pixels(rem_size).into(); - cx.request_layout(&style, None) - } - EditorMode::AutoHeight { max_lines } => { - let editor_handle = cx.view().clone(); - let max_line_number_width = - self.max_line_number_width(&editor.snapshot(cx), cx); - cx.request_measured_layout(Style::default(), move |known_dimensions, _, cx| { - editor_handle - .update(cx, |editor, cx| { - compute_auto_height_layout( - editor, - max_lines, - max_line_number_width, - known_dimensions, - cx, - ) - }) - .unwrap_or_default() - }) - } - EditorMode::Full => { - let mut style = Style::default(); - style.size.width = relative(1.).into(); - style.size.height = relative(1.).into(); - cx.request_layout(&style, None) - } - }; + let layout_id = match editor.mode { + EditorMode::SingleLine => { + let rem_size = cx.rem_size(); + let mut style = Style::default(); + style.size.width = relative(1.).into(); + style.size.height = self.style.text.line_height_in_pixels(rem_size).into(); + cx.request_layout(&style, None) + } + EditorMode::AutoHeight { max_lines } => { + let editor_handle = cx.view().clone(); + let max_line_number_width = + self.max_line_number_width(&editor.snapshot(cx), cx); + cx.request_measured_layout( + Style::default(), + move |known_dimensions, _, cx| { + editor_handle + .update(cx, |editor, cx| { + compute_auto_height_layout( + editor, + max_lines, + max_line_number_width, + known_dimensions, + cx, + ) + }) + .unwrap_or_default() + }, + ) + } + EditorMode::Full => { + let mut style = Style::default(); + style.size.width = relative(1.).into(); + style.size.height = relative(1.).into(); + cx.request_layout(&style, None) + } + }; - (layout_id, ()) + (layout_id, ()) + }) }) } @@ -2828,65 +2859,68 @@ impl Element for EditorElement { ) { let editor = self.editor.clone(); - cx.with_text_style( - Some(gpui::TextStyleRefinement { - font_size: Some(self.style.text.font_size), - ..Default::default() - }), - |cx| { - let mut layout = self.compute_layout(bounds, cx); - let gutter_bounds = Bounds { - origin: bounds.origin, - size: layout.gutter_size, - }; - let text_bounds = Bounds { - origin: gutter_bounds.upper_right(), - size: layout.text_size, - }; + cx.paint_view(self.editor.entity_id(), |cx| { + cx.with_text_style( + Some(gpui::TextStyleRefinement { + font_size: Some(self.style.text.font_size), + line_height: Some(self.style.text.line_height), + ..Default::default() + }), + |cx| { + let mut layout = self.compute_layout(bounds, cx); + let gutter_bounds = Bounds { + origin: bounds.origin, + size: layout.gutter_size, + }; + let text_bounds = Bounds { + origin: gutter_bounds.upper_right(), + size: layout.text_size, + }; - let focus_handle = editor.focus_handle(cx); - let key_context = self.editor.read(cx).key_context(cx); - cx.with_key_dispatch(Some(key_context), Some(focus_handle.clone()), |_, cx| { - self.register_actions(cx); - self.register_key_listeners(cx); + let focus_handle = editor.focus_handle(cx); + let key_context = self.editor.read(cx).key_context(cx); + cx.with_key_dispatch(Some(key_context), Some(focus_handle.clone()), |_, cx| { + self.register_actions(cx); + self.register_key_listeners(cx); - cx.with_content_mask(Some(ContentMask { bounds }), |cx| { - let input_handler = - ElementInputHandler::new(bounds, self.editor.clone(), cx); - cx.handle_input(&focus_handle, input_handler); + cx.with_content_mask(Some(ContentMask { bounds }), |cx| { + let input_handler = + ElementInputHandler::new(bounds, self.editor.clone(), cx); + cx.handle_input(&focus_handle, input_handler); - self.paint_background(gutter_bounds, text_bounds, &layout, cx); - if layout.gutter_size.width > Pixels::ZERO { - self.paint_gutter(gutter_bounds, &mut layout, cx); - } - self.paint_text(text_bounds, &mut layout, cx); + self.paint_background(gutter_bounds, text_bounds, &layout, cx); + if layout.gutter_size.width > Pixels::ZERO { + self.paint_gutter(gutter_bounds, &mut layout, cx); + } + self.paint_text(text_bounds, &mut layout, cx); - cx.with_z_index(0, |cx| { - self.paint_mouse_listeners( - bounds, - gutter_bounds, - text_bounds, - &layout, - cx, - ); - }); - if !layout.blocks.is_empty() { cx.with_z_index(0, |cx| { - cx.with_element_id(Some("editor_blocks"), |cx| { - self.paint_blocks(bounds, &mut layout, cx); - }); - }) - } + self.paint_mouse_listeners( + bounds, + gutter_bounds, + text_bounds, + &layout, + cx, + ); + }); + if !layout.blocks.is_empty() { + cx.with_z_index(0, |cx| { + cx.with_element_id(Some("editor_blocks"), |cx| { + self.paint_blocks(bounds, &mut layout, cx); + }); + }) + } - cx.with_z_index(1, |cx| { - self.paint_overlays(text_bounds, &mut layout, cx); + cx.with_z_index(1, |cx| { + self.paint_overlays(text_bounds, &mut layout, cx); + }); + + cx.with_z_index(2, |cx| self.paint_scrollbar(bounds, &mut layout, cx)); }); - - cx.with_z_index(2, |cx| self.paint_scrollbar(bounds, &mut layout, cx)); - }); - }) - }, - ); + }) + }, + ) + }) } } @@ -3382,14 +3416,16 @@ mod tests { }) .unwrap(); let state = cx - .update_window(window.into(), |_, cx| { - element.compute_layout( - Bounds { - origin: point(px(500.), px(500.)), - size: size(px(500.), px(500.)), - }, - cx, - ) + .update_window(window.into(), |view, cx| { + cx.with_view_id(view.entity_id(), |cx| { + element.compute_layout( + Bounds { + origin: point(px(500.), px(500.)), + size: size(px(500.), px(500.)), + }, + cx, + ) + }) }) .unwrap(); @@ -3474,14 +3510,16 @@ mod tests { }); let state = cx - .update_window(window.into(), |_, cx| { - element.compute_layout( - Bounds { - origin: point(px(500.), px(500.)), - size: size(px(500.), px(500.)), - }, - cx, - ) + .update_window(window.into(), |view, cx| { + cx.with_view_id(view.entity_id(), |cx| { + element.compute_layout( + Bounds { + origin: point(px(500.), px(500.)), + size: size(px(500.), px(500.)), + }, + cx, + ) + }) }) .unwrap(); assert_eq!(state.selections.len(), 1); @@ -3536,14 +3574,16 @@ mod tests { let mut element = EditorElement::new(&editor, style); let state = cx - .update_window(window.into(), |_, cx| { - element.compute_layout( - Bounds { - origin: point(px(500.), px(500.)), - size: size(px(500.), px(500.)), - }, - cx, - ) + .update_window(window.into(), |view, cx| { + cx.with_view_id(view.entity_id(), |cx| { + element.compute_layout( + Bounds { + origin: point(px(500.), px(500.)), + size: size(px(500.), px(500.)), + }, + cx, + ) + }) }) .unwrap(); let size = state.position_map.size; @@ -3560,10 +3600,8 @@ mod tests { // Don't panic. let bounds = Bounds::::new(Default::default(), size); - cx.update_window(window.into(), |_, cx| { - element.paint(bounds, &mut (), cx); - }) - .unwrap() + cx.update_window(window.into(), |_, cx| element.paint(bounds, &mut (), cx)) + .unwrap() } #[gpui::test] diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 22c58056f0..609c20ac68 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -2,13 +2,13 @@ use crate::{ display_map::{InlayOffset, ToDisplayPoint}, link_go_to_definition::{InlayHighlight, RangeInEditor}, Anchor, AnchorRangeExt, DisplayPoint, Editor, EditorSettings, EditorSnapshot, EditorStyle, - ExcerptId, RangeToAnchorExt, + ExcerptId, Hover, RangeToAnchorExt, }; use futures::FutureExt; use gpui::{ - actions, div, px, AnyElement, CursorStyle, Hsla, InteractiveElement, IntoElement, Model, - MouseButton, ParentElement, Pixels, SharedString, Size, StatefulInteractiveElement, Styled, - Task, ViewContext, WeakView, + div, px, AnyElement, CursorStyle, Hsla, InteractiveElement, IntoElement, Model, MouseButton, + ParentElement, Pixels, SharedString, Size, StatefulInteractiveElement, Styled, Task, + ViewContext, WeakView, }; use language::{markdown, Bias, DiagnosticEntry, Language, LanguageRegistry, ParsedMarkdown}; @@ -16,7 +16,7 @@ use lsp::DiagnosticSeverity; use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart, Project}; use settings::Settings; use std::{ops::Range, sync::Arc, time::Duration}; -use ui::{StyledExt, Tooltip}; +use ui::{prelude::*, Tooltip}; use util::TryFutureExt; use workspace::Workspace; @@ -27,8 +27,6 @@ pub const MIN_POPOVER_CHARACTER_WIDTH: f32 = 20.; pub const MIN_POPOVER_LINE_HEIGHT: Pixels = px(4.); pub const HOVER_POPOVER_GAP: Pixels = px(10.); -actions!(editor, [Hover]); - /// Bindable action which uses the most recent selection head to trigger a hover pub fn hover(editor: &mut Editor, _: &Hover, cx: &mut ViewContext) { let head = editor.selections.newest_display(cx).head(); @@ -341,6 +339,7 @@ fn show_hover( this.hover_state.info_popover = hover_popover; cx.notify(); + cx.refresh(); })?; Ok::<_, anyhow::Error>(()) @@ -514,6 +513,8 @@ impl DiagnosticPopover { None => self.local_diagnostic.diagnostic.message.clone(), }; + let status_colors = cx.theme().status(); + struct DiagnosticColors { pub background: Hsla, pub border: Hsla, @@ -521,24 +522,24 @@ impl DiagnosticPopover { let diagnostic_colors = match self.local_diagnostic.diagnostic.severity { DiagnosticSeverity::ERROR => DiagnosticColors { - background: style.status.error_background, - border: style.status.error_border, + background: status_colors.error_background, + border: status_colors.error_border, }, DiagnosticSeverity::WARNING => DiagnosticColors { - background: style.status.warning_background, - border: style.status.warning_border, + background: status_colors.warning_background, + border: status_colors.warning_border, }, DiagnosticSeverity::INFORMATION => DiagnosticColors { - background: style.status.info_background, - border: style.status.info_border, + background: status_colors.info_background, + border: status_colors.info_border, }, DiagnosticSeverity::HINT => DiagnosticColors { - background: style.status.hint_background, - border: style.status.hint_border, + background: status_colors.hint_background, + border: status_colors.hint_border, }, _ => DiagnosticColors { - background: style.status.ignored_background, - border: style.status.ignored_border, + background: status_colors.ignored_background, + border: status_colors.ignored_border, }, }; diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlay_hint_cache.rs index 59c6b8605c..3fba722492 100644 --- a/crates/editor/src/inlay_hint_cache.rs +++ b/crates/editor/src/inlay_hint_cache.rs @@ -1,3 +1,11 @@ +/// Stores and updates all data received from LSP textDocument/inlayHint requests. +/// Has nothing to do with other inlays, e.g. copilot suggestions — those are stored elsewhere. +/// On every update, cache may query for more inlay hints and update inlays on the screen. +/// +/// Inlays stored on screen are in [`crate::display_map::inlay_map`] and this cache is the only way to update any inlay hint data in the visible hints in the inlay map. +/// For determining the update to the `inlay_map`, the cache requires a list of visible inlay hints — all other hints are not relevant and their separate updates are not influencing the cache work. +/// +/// Due to the way the data is stored for both visible inlays and the cache, every inlay (and inlay hint) collection is editor-specific, so a single buffer may have multiple sets of inlays of open on different panes. use std::{ cmp, ops::{ControlFlow, Range}, @@ -39,7 +47,7 @@ struct TasksForRanges { } #[derive(Debug)] -pub struct CachedExcerptHints { +struct CachedExcerptHints { version: usize, buffer_version: Global, buffer_id: u64, @@ -47,15 +55,30 @@ pub struct CachedExcerptHints { hints_by_id: HashMap, } +/// A logic to apply when querying for new inlay hints and deciding what to do with the old entries in the cache in case of conflicts. #[derive(Debug, Clone, Copy)] -pub enum InvalidationStrategy { +pub(super) enum InvalidationStrategy { + /// Hints reset is requested by the LSP server. + /// Demands to re-query all inlay hints needed and invalidate all cached entries, but does not require instant update with invalidation. + /// + /// Despite nothing forbids language server from sending this request on every edit, it is expected to be sent only when certain internal server state update, invisible for the editor otherwise. RefreshRequested, + /// Multibuffer excerpt(s) and/or singleton buffer(s) were edited at least on one place. + /// Neither editor nor LSP is able to tell which open file hints' are not affected, so all of them have to be invalidated, re-queried and do that fast enough to avoid being slow, but also debounce to avoid loading hints on every fast keystroke sequence. BufferEdited, + /// A new file got opened/new excerpt was added to a multibuffer/a [multi]buffer was scrolled to a new position. + /// No invalidation should be done at all, all new hints are added to the cache. + /// + /// A special case is the settings change: in addition to LSP capabilities, Zed allows omitting certain hint kinds (defined by the corresponding LSP part: type/parameter/other). + /// This does not lead to cache invalidation, but would require cache usage for determining which hints are not displayed and issuing an update to inlays on the screen. None, } -#[derive(Debug, Default)] -pub struct InlaySplice { +/// A splice to send into the `inlay_map` for updating the visible inlays on the screen. +/// "Visible" inlays may not be displayed in the buffer right away, but those are ready to be displayed on further buffer scroll, pane item activations, etc. right away without additional LSP queries or settings changes. +/// The data in the cache is never used directly for displaying inlays on the screen, to avoid races with updates from LSP queries and sync overhead. +/// Splice is picked to help avoid extra hint flickering and "jumps" on the screen. +pub(super) struct InlaySplice { pub to_remove: Vec, pub to_insert: Vec, } @@ -237,7 +260,7 @@ impl TasksForRanges { } impl InlayHintCache { - pub fn new(inlay_hint_settings: InlayHintSettings) -> Self { + pub(super) fn new(inlay_hint_settings: InlayHintSettings) -> Self { Self { allowed_hint_kinds: inlay_hint_settings.enabled_inlay_hint_kinds(), enabled: inlay_hint_settings.enabled, @@ -248,7 +271,10 @@ impl InlayHintCache { } } - pub fn update_settings( + /// Checks inlay hint settings for enabled hint kinds and general enabled state. + /// Generates corresponding inlay_map splice updates on settings changes. + /// Does not update inlay hint cache state on disabling or inlay hint kinds change: only reenabling forces new LSP queries. + pub(super) fn update_settings( &mut self, multi_buffer: &Model, new_hint_settings: InlayHintSettings, @@ -299,7 +325,11 @@ impl InlayHintCache { } } - pub fn spawn_hint_refresh( + /// If needed, queries LSP for new inlay hints, using the invalidation strategy given. + /// To reduce inlay hint jumping, attempts to query a visible range of the editor(s) first, + /// followed by the delayed queries of the same range above and below the visible one. + /// This way, concequent refresh invocations are less likely to trigger LSP queries for the invisible ranges. + pub(super) fn spawn_hint_refresh( &mut self, reason: &'static str, excerpts_to_query: HashMap, Global, Range)>, @@ -460,7 +490,11 @@ impl InlayHintCache { } } - pub fn remove_excerpts(&mut self, excerpts_removed: Vec) -> Option { + /// Completely forget of certain excerpts that were removed from the multibuffer. + pub(super) fn remove_excerpts( + &mut self, + excerpts_removed: Vec, + ) -> Option { let mut to_remove = Vec::new(); for excerpt_to_remove in excerpts_removed { self.update_tasks.remove(&excerpt_to_remove); @@ -480,7 +514,7 @@ impl InlayHintCache { } } - pub fn clear(&mut self) { + pub(super) fn clear(&mut self) { if !self.update_tasks.is_empty() || !self.hints.is_empty() { self.version += 1; } @@ -488,7 +522,7 @@ impl InlayHintCache { self.hints.clear(); } - pub fn hint_by_id(&self, excerpt_id: ExcerptId, hint_id: InlayId) -> Option { + pub(super) fn hint_by_id(&self, excerpt_id: ExcerptId, hint_id: InlayId) -> Option { self.hints .get(&excerpt_id)? .read() @@ -516,7 +550,8 @@ impl InlayHintCache { self.version } - pub fn spawn_hint_resolve( + /// Queries a certain hint from the cache for extra data via the LSP resolve request. + pub(super) fn spawn_hint_resolve( &self, buffer_id: u64, excerpt_id: ExcerptId, @@ -925,14 +960,14 @@ async fn fetch_and_update_hints( log::trace!("Fetched hints: {new_hints:?}"); let background_task_buffer_snapshot = buffer_snapshot.clone(); - let backround_fetch_range = fetch_range.clone(); + let background_fetch_range = fetch_range.clone(); let new_update = cx .background_executor() .spawn(async move { calculate_hint_updates( query.excerpt_id, invalidate, - backround_fetch_range, + background_fetch_range, new_hints, &background_task_buffer_snapshot, cached_excerpt_hints, @@ -1199,7 +1234,7 @@ pub mod tests { use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use crate::{ - scroll::{autoscroll::Autoscroll, scroll_amount::ScrollAmount}, + scroll::{scroll_amount::ScrollAmount, Autoscroll}, ExcerptRange, }; use futures::StreamExt; @@ -1449,7 +1484,7 @@ pub mod tests { assert_eq!( editor.inlay_hint_cache().version, edits_made, - "Cache version should udpate once after the work task is done" + "Cache version should update once after the work task is done" ); }); } @@ -1599,7 +1634,7 @@ pub mod tests { assert_eq!( expected_hints, cached_hint_labels(editor), - "Markdown editor should have a separate verison, repeating Rust editor rules" + "Markdown editor should have a separate version, repeating Rust editor rules" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); assert_eq!(editor.inlay_hint_cache().version, 1); @@ -2612,7 +2647,7 @@ pub mod tests { "When scroll is at the edge of a multibuffer, its visible excerpts only should be queried for inlay hints" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - assert_eq!(editor.inlay_hint_cache().version, expected_hints.len(), "Every visible excerpt hints should bump the verison"); + assert_eq!(editor.inlay_hint_cache().version, expected_hints.len(), "Every visible excerpt hints should bump the version"); }); _ = editor.update(cx, |editor, cx| { @@ -2728,7 +2763,7 @@ pub mod tests { expected_hints, cached_hint_labels(editor), "After multibuffer edit, editor gets scolled back to the last selection; \ - all hints should be invalidated and requeried for all of its visible excerpts" + all hints should be invalidated and required for all of its visible excerpts" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 78f9b15051..36a48b2937 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -32,7 +32,7 @@ use std::{ }; use text::Selection; use theme::Theme; -use ui::{h_stack, prelude::*, Label}; +use ui::{h_flex, prelude::*, Label}; use util::{paths::PathExt, paths::FILE_ROW_COLUMN_DELIMITER, ResultExt, TryFutureExt}; use workspace::{ item::{BreadcrumbText, FollowEvent, FollowableItemHandle}, @@ -82,7 +82,9 @@ impl FollowableItem for Editor { let pane = pane.downgrade(); Some(cx.spawn(|mut cx| async move { - let mut buffers = futures::future::try_join_all(buffers).await?; + let mut buffers = futures::future::try_join_all(buffers) + .await + .debug_assert_ok("leaders don't share views for unshared buffers")?; let editor = pane.update(&mut cx, |pane, cx| { let mut editors = pane.items_of_type::(); editors.find(|editor| { @@ -576,6 +578,10 @@ impl Item for Editor { Some(file_path.into()) } + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + fn tab_description<'a>(&self, detail: usize, cx: &'a AppContext) -> Option { let path = path_for_buffer(&self.buffer, detail, true, cx)?; Some(path.to_string_lossy().to_string().into()) @@ -617,7 +623,7 @@ impl Item for Editor { Some(util::truncate_and_trailoff(&description, MAX_TAB_TITLE_LEN)) }); - h_stack() + h_flex() .gap_2() .child(Label::new(self.title(cx).to_string()).color(label_color)) .when_some(description, |this, description| { diff --git a/crates/editor/src/link_go_to_definition.rs b/crates/editor/src/link_go_to_definition.rs index 04dcf93015..c4da7fcd38 100644 --- a/crates/editor/src/link_go_to_definition.rs +++ b/crates/editor/src/link_go_to_definition.rs @@ -69,7 +69,7 @@ pub enum GoToDefinitionLink { } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct InlayHighlight { +pub(crate) struct InlayHighlight { pub inlay: InlayId, pub inlay_position: Anchor, pub range: Range, diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 0b13e25d5d..0cf8ac7440 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -1,3 +1,6 @@ +//! Movement module contains helper functions for calculating intended position +//! 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::{char_kind, CharKind, EditorStyle, ToOffset, ToPoint}; use gpui::{px, Pixels, TextSystem}; @@ -5,6 +8,9 @@ use language::Point; use std::{ops::Range, sync::Arc}; +/// Defines search strategy for items in `movement` module. +/// `FindRange::SingeLine` only looks for a match on a single line at a time, whereas +/// `FindRange::MultiLine` keeps going until the end of a string. #[derive(Debug, PartialEq)] pub enum FindRange { SingleLine, @@ -14,11 +20,13 @@ pub enum FindRange { /// TextLayoutDetails encompasses everything we need to move vertically /// taking into account variable width characters. pub struct TextLayoutDetails { - pub text_system: Arc, - pub editor_style: EditorStyle, - pub rem_size: Pixels, + pub(crate) text_system: Arc, + pub(crate) editor_style: EditorStyle, + pub(crate) rem_size: Pixels, } +/// Returns a column to the left of the current point, wrapping +/// to the previous line if that point is at the start of line. pub fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { if point.column() > 0 { *point.column_mut() -= 1; @@ -29,6 +37,8 @@ pub fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { map.clip_point(point, Bias::Left) } +/// Returns a column to the left of the current point, doing nothing if +/// that point is already at the start of line. pub fn saturating_left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { if point.column() > 0 { *point.column_mut() -= 1; @@ -36,6 +46,8 @@ pub fn saturating_left(map: &DisplaySnapshot, mut point: DisplayPoint) -> Displa map.clip_point(point, Bias::Left) } +/// Returns a column to the right of the current point, wrapping +/// to the next line if that point is at the end of line. pub fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { let max_column = map.line_len(point.row()); if point.column() < max_column { @@ -47,11 +59,14 @@ pub fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { map.clip_point(point, Bias::Right) } +/// Returns a column to the right of the current point, not performing any wrapping +/// if that point is already at the end of line. pub fn saturating_right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { *point.column_mut() += 1; map.clip_point(point, Bias::Right) } +/// Returns a display point for the preceding displayed line (which might be a soft-wrapped line). pub fn up( map: &DisplaySnapshot, start: DisplayPoint, @@ -69,6 +84,7 @@ pub fn up( ) } +/// Returns a display point for the next displayed line (which might be a soft-wrapped line). pub fn down( map: &DisplaySnapshot, start: DisplayPoint, @@ -86,7 +102,7 @@ pub fn down( ) } -pub fn up_by_rows( +pub(crate) fn up_by_rows( map: &DisplaySnapshot, start: DisplayPoint, row_count: u32, @@ -95,7 +111,7 @@ pub fn up_by_rows( text_layout_details: &TextLayoutDetails, ) -> (DisplayPoint, SelectionGoal) { let mut goal_x = match goal { - SelectionGoal::HorizontalPosition(x) => x.into(), // todo!("Can the fields in SelectionGoal by Pixels? We should extract a geometry crate and depend on that.") + SelectionGoal::HorizontalPosition(x) => x.into(), SelectionGoal::WrappedHorizontalPosition((_, x)) => x.into(), SelectionGoal::HorizontalRange { end, .. } => end.into(), _ => map.x_for_display_point(start, text_layout_details), @@ -125,7 +141,7 @@ pub fn up_by_rows( ) } -pub fn down_by_rows( +pub(crate) fn down_by_rows( map: &DisplaySnapshot, start: DisplayPoint, row_count: u32, @@ -161,6 +177,10 @@ pub fn down_by_rows( ) } +/// Returns a position of the start of line. +/// If `stop_at_soft_boundaries` is true, the returned position is that of the +/// displayed line (e.g. it could actually be in the middle of a text line if that line is soft-wrapped). +/// Otherwise it's always going to be the start of a logical line. pub fn line_beginning( map: &DisplaySnapshot, display_point: DisplayPoint, @@ -177,6 +197,10 @@ pub fn line_beginning( } } +/// Returns the last indented position on a given line. +/// If `stop_at_soft_boundaries` is true, the returned [`DisplayPoint`] is that of a +/// displayed line (e.g. if there's soft wrap it's gonna be returned), +/// otherwise it's always going to be a start of a logical line. pub fn indented_line_beginning( map: &DisplaySnapshot, display_point: DisplayPoint, @@ -201,6 +225,11 @@ pub fn indented_line_beginning( } } +/// Returns a position of the end of line. + +/// If `stop_at_soft_boundaries` is true, the returned position is that of the +/// displayed line (e.g. it could actually be in the middle of a text line if that line is soft-wrapped). +/// Otherwise it's always going to be the end of a logical line. pub fn line_end( map: &DisplaySnapshot, display_point: DisplayPoint, @@ -217,6 +246,8 @@ pub fn line_end( } } +/// Returns a position of the previous word boundary, where a word character is defined as either +/// uppercase letter, lowercase letter, '_' character or language-specific word character (like '-' in CSS). pub fn previous_word_start(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { let raw_point = point.to_point(map); let scope = map.buffer_snapshot.language_scope_at(raw_point); @@ -227,6 +258,9 @@ pub fn previous_word_start(map: &DisplaySnapshot, point: DisplayPoint) -> Displa }) } +/// Returns a position of the previous subword boundary, where a subword is defined as a run of +/// word characters of the same "subkind" - where subcharacter kinds are '_' character, +/// lowerspace characters and uppercase characters. pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { let raw_point = point.to_point(map); let scope = map.buffer_snapshot.language_scope_at(raw_point); @@ -240,6 +274,8 @@ pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> Dis }) } +/// Returns a position of the next word boundary, where a word character is defined as either +/// uppercase letter, lowercase letter, '_' character or language-specific word character (like '-' in CSS). pub fn next_word_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { let raw_point = point.to_point(map); let scope = map.buffer_snapshot.language_scope_at(raw_point); @@ -250,6 +286,9 @@ pub fn next_word_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint }) } +/// Returns a position of the next subword boundary, where a subword is defined as a run of +/// word characters of the same "subkind" - where subcharacter kinds are '_' character, +/// lowerspace characters and uppercase characters. pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { let raw_point = point.to_point(map); let scope = map.buffer_snapshot.language_scope_at(raw_point); @@ -263,6 +302,8 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo }) } +/// Returns a position of the start of the current paragraph, where a paragraph +/// is defined as a run of non-blank lines. pub fn start_of_paragraph( map: &DisplaySnapshot, display_point: DisplayPoint, @@ -290,6 +331,8 @@ pub fn start_of_paragraph( DisplayPoint::zero() } +/// Returns a position of the end of the current paragraph, where a paragraph +/// is defined as a run of non-blank lines. pub fn end_of_paragraph( map: &DisplaySnapshot, display_point: DisplayPoint, @@ -376,6 +419,9 @@ pub fn find_boundary( map.clip_point(offset.to_display_point(map), Bias::Right) } +/// Returns an iterator over the characters following a given offset in the [`DisplaySnapshot`]. +/// The returned value also contains a range of the start/end of a returned character in +/// the [`DisplaySnapshot`]. The offsets are relative to the start of a buffer. pub fn chars_after( map: &DisplaySnapshot, mut offset: usize, @@ -387,6 +433,9 @@ pub fn chars_after( }) } +/// Returns a reverse iterator over the characters following a given offset in the [`DisplaySnapshot`]. +/// The returned value also contains a range of the start/end of a returned character in +/// the [`DisplaySnapshot`]. The offsets are relative to the start of a buffer. pub fn chars_before( map: &DisplaySnapshot, mut offset: usize, @@ -400,7 +449,7 @@ pub fn chars_before( }) } -pub fn is_inside_word(map: &DisplaySnapshot, point: DisplayPoint) -> bool { +pub(crate) fn is_inside_word(map: &DisplaySnapshot, point: DisplayPoint) -> bool { let raw_point = point.to_point(map); let scope = map.buffer_snapshot.language_scope_at(raw_point); let ix = map.clip_point(point, Bias::Left).to_offset(map, Bias::Left); @@ -413,7 +462,10 @@ pub fn is_inside_word(map: &DisplaySnapshot, point: DisplayPoint) -> bool { prev_char_kind.zip(next_char_kind) == Some((CharKind::Word, CharKind::Word)) } -pub fn surrounding_word(map: &DisplaySnapshot, position: DisplayPoint) -> Range { +pub(crate) fn surrounding_word( + map: &DisplaySnapshot, + position: DisplayPoint, +) -> Range { let position = map .clip_point(position, Bias::Left) .to_offset(map, Bias::Left); @@ -429,6 +481,12 @@ pub fn surrounding_word(map: &DisplaySnapshot, position: DisplayPoint) -> Range< start..end } +/// Returns a list of lines (represented as a [`DisplayPoint`] range) contained +/// within a passed range. +/// +/// The line ranges are **always* going to be in bounds of a requested range, which means that +/// the first and the last lines might not necessarily represent the +/// full range of a logical line (as their `.start`/`.end` values are clipped to those of a passed in range). pub fn split_display_range_by_lines( map: &DisplaySnapshot, range: Range, diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index 0798870f76..f68004109e 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -1,6 +1,6 @@ -pub mod actions; -pub mod autoscroll; -pub mod scroll_amount; +mod actions; +pub(crate) mod autoscroll; +pub(crate) mod scroll_amount; use crate::{ display_map::{DisplaySnapshot, ToDisplayPoint}, @@ -9,8 +9,10 @@ use crate::{ Anchor, DisplayPoint, Editor, EditorEvent, EditorMode, InlayHintRefreshReason, MultiBufferSnapshot, ToPoint, }; +pub use autoscroll::{Autoscroll, AutoscrollStrategy}; use gpui::{point, px, AppContext, Entity, Pixels, Task, ViewContext}; use language::{Bias, Point}; +pub use scroll_amount::ScrollAmount; use std::{ cmp::Ordering, time::{Duration, Instant}, @@ -18,11 +20,6 @@ use std::{ use util::ResultExt; use workspace::{ItemId, WorkspaceId}; -use self::{ - autoscroll::{Autoscroll, AutoscrollStrategy}, - scroll_amount::ScrollAmount, -}; - pub const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28); pub const VERTICAL_SCROLL_MARGIN: f32 = 3.; const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); @@ -384,10 +381,12 @@ impl Editor { ) { hide_hover(self, cx); let workspace_id = self.workspace.as_ref().map(|workspace| workspace.1); - let top_row = scroll_anchor - .anchor - .to_point(&self.buffer().read(cx).snapshot(cx)) - .row; + let snapshot = &self.buffer().read(cx).snapshot(cx); + if !scroll_anchor.anchor.is_valid(snapshot) { + log::warn!("Invalid scroll anchor: {:?}", scroll_anchor); + return; + } + let top_row = scroll_anchor.anchor.to_point(snapshot).row; self.scroll_manager .set_anchor(scroll_anchor, top_row, false, false, workspace_id, cx); } diff --git a/crates/editor/src/scroll/actions.rs b/crates/editor/src/scroll/actions.rs index 21a4258f6f..436a0291d0 100644 --- a/crates/editor/src/scroll/actions.rs +++ b/crates/editor/src/scroll/actions.rs @@ -11,10 +11,9 @@ impl Editor { return; } - // todo!() - // if self.mouse_context_menu.read(cx).visible() { - // return None; - // } + if self.mouse_context_menu.is_some() { + return; + } if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index ba70739942..955b970540 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -5,7 +5,7 @@ use language::Point; use crate::{display_map::ToDisplayPoint, Editor, EditorMode, LineWithInvisibles}; -#[derive(PartialEq, Eq)] +#[derive(PartialEq, Eq, Clone, Copy)] pub enum Autoscroll { Next, Strategy(AutoscrollStrategy), @@ -25,7 +25,7 @@ impl Autoscroll { } } -#[derive(PartialEq, Eq, Default)] +#[derive(PartialEq, Eq, Default, Clone, Copy)] pub enum AutoscrollStrategy { Fit, Newest, @@ -175,7 +175,7 @@ impl Editor { true } - pub fn autoscroll_horizontally( + pub(crate) fn autoscroll_horizontally( &mut self, start_row: u32, viewport_width: Pixels, diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 8d71916210..96f9507dd2 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -99,7 +99,7 @@ impl SelectionsCollection { .map(|pending| pending.map(|p| p.summary::(&self.buffer(cx)))) } - pub fn pending_mode(&self) -> Option { + pub(crate) fn pending_mode(&self) -> Option { self.pending.as_ref().map(|pending| pending.mode.clone()) } @@ -398,7 +398,7 @@ impl<'a> MutableSelectionsCollection<'a> { } } - pub fn set_pending_anchor_range(&mut self, range: Range, mode: SelectMode) { + pub(crate) fn set_pending_anchor_range(&mut self, range: Range, mode: SelectMode) { self.collection.pending = Some(PendingSelection { selection: Selection { id: post_inc(&mut self.collection.next_selection_id), @@ -412,7 +412,11 @@ impl<'a> MutableSelectionsCollection<'a> { self.selections_changed = true; } - pub fn set_pending_display_range(&mut self, range: Range, mode: SelectMode) { + pub(crate) fn set_pending_display_range( + &mut self, + range: Range, + mode: SelectMode, + ) { let (start, end, reversed) = { let display_map = self.display_map(); let buffer = self.buffer(); @@ -448,7 +452,7 @@ impl<'a> MutableSelectionsCollection<'a> { self.selections_changed = true; } - pub fn set_pending(&mut self, selection: Selection, mode: SelectMode) { + pub(crate) fn set_pending(&mut self, selection: Selection, mode: SelectMode) { self.collection.pending = Some(PendingSelection { selection, mode }); self.selections_changed = true; } @@ -855,7 +859,7 @@ impl<'a> DerefMut for MutableSelectionsCollection<'a> { } // Panics if passed selections are not in order -pub fn resolve_multiple<'a, D, I>( +pub(crate) fn resolve_multiple<'a, D, I>( selections: I, snapshot: &MultiBufferSnapshot, ) -> impl 'a + Iterator> diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs index 4ce539ad79..d3337db258 100644 --- a/crates/editor/src/test.rs +++ b/crates/editor/src/test.rs @@ -60,8 +60,7 @@ pub fn assert_text_with_selections( #[allow(dead_code)] #[cfg(any(test, feature = "test-support"))] pub(crate) fn build_editor(buffer: Model, cx: &mut ViewContext) -> Editor { - // todo!() - Editor::new(EditorMode::Full, buffer, None, /*None,*/ cx) + Editor::new(EditorMode::Full, buffer, None, cx) } pub(crate) fn build_editor_with_project( @@ -69,6 +68,5 @@ pub(crate) fn build_editor_with_project( buffer: Model, cx: &mut ViewContext, ) -> Editor { - // todo!() - Editor::new(EditorMode::Full, buffer, Some(project), /*None,*/ cx) + Editor::new(EditorMode::Full, buffer, Some(project), cx) } diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index 065d06f96d..907c37ddcd 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -16,12 +16,6 @@ pub trait FeatureFlag { const NAME: &'static str; } -pub enum ChannelsAlpha {} - -impl FeatureFlag for ChannelsAlpha { - const NAME: &'static str = "channels_alpha"; -} - pub trait FeatureFlagViewExt { fn observe_flag(&mut self, callback: F) -> Subscription where @@ -63,18 +57,14 @@ impl FeatureFlagAppExt for AppContext { } fn has_flag(&self) -> bool { - if self.has_global::() { - self.global::().has_flag(T::NAME) - } else { - false - } + self.try_global::() + .map(|flags| flags.has_flag(T::NAME)) + .unwrap_or(false) } fn is_staff(&self) -> bool { - if self.has_global::() { - return self.global::().staff; - } else { - false - } + self.try_global::() + .map(|flags| flags.staff) + .unwrap_or(false) } } diff --git a/crates/feedback/src/deploy_feedback_button.rs b/crates/feedback/src/deploy_feedback_button.rs index a02540bc5b..377d4cea5c 100644 --- a/crates/feedback/src/deploy_feedback_button.rs +++ b/crates/feedback/src/deploy_feedback_button.rs @@ -1,5 +1,5 @@ use gpui::{Render, ViewContext, WeakView}; -use ui::{prelude::*, ButtonCommon, Icon, IconButton, Tooltip}; +use ui::{prelude::*, ButtonCommon, IconButton, IconName, Tooltip}; use workspace::{item::ItemHandle, StatusItemView, Workspace}; use crate::{feedback_modal::FeedbackModal, GiveFeedback}; @@ -27,7 +27,7 @@ impl Render for DeployFeedbackButton { }) }) .is_some(); - IconButton::new("give-feedback", Icon::Envelope) + IconButton::new("give-feedback", IconName::Envelope) .style(ui::ButtonStyle::Subtle) .icon_size(IconSize::Small) .selected(is_open) diff --git a/crates/feedback/src/feedback_modal.rs b/crates/feedback/src/feedback_modal.rs index b197d60233..80722580b7 100644 --- a/crates/feedback/src/feedback_modal.rs +++ b/crates/feedback/src/feedback_modal.rs @@ -186,6 +186,7 @@ impl FeedbackModal { cx, ); editor.set_show_gutter(false, cx); + editor.set_show_copilot_suggestions(false); editor.set_vertical_scroll_margin(5, cx); editor }); @@ -421,7 +422,7 @@ impl Render for FeedbackModal { let open_community_repo = cx.listener(|_, _, cx| cx.dispatch_action(Box::new(OpenZedCommunityRepo))); - v_stack() + v_flex() .elevation_3(cx) .key_context("GiveFeedback") .on_action(cx.listener(Self::cancel)) @@ -460,10 +461,10 @@ impl Render for FeedbackModal { .child(self.feedback_editor.clone()), ) .child( - v_stack() + v_flex() .gap_1() .child( - h_stack() + h_flex() .bg(cx.theme().colors().editor_background) .p_2() .border() @@ -482,19 +483,19 @@ impl Render for FeedbackModal { ), ) .child( - h_stack() + h_flex() .justify_between() .gap_1() .child( Button::new("community_repository", "Community Repository") .style(ButtonStyle::Transparent) - .icon(Icon::ExternalLink) + .icon(IconName::ExternalLink) .icon_position(IconPosition::End) .icon_size(IconSize::Small) .on_click(open_community_repo), ) .child( - h_stack() + h_flex() .gap_1() .child( Button::new("cancel_feedback", "Cancel") @@ -525,43 +526,4 @@ impl Render for FeedbackModal { } } -// TODO: Testing of various button states, dismissal prompts, etc. - -// #[cfg(test)] -// mod test { -// use super::*; - -// #[test] -// fn test_invalid_email_addresses() { -// let markdown = markdown.await.log_err(); -// let buffer = project.update(&mut cx, |project, cx| { -// project.create_buffer("", markdown, cx) -// })??; - -// workspace.update(&mut cx, |workspace, cx| { -// let system_specs = SystemSpecs::new(cx); - -// workspace.toggle_modal(cx, move |cx| { -// let feedback_modal = FeedbackModal::new(system_specs, project, buffer, cx); - -// assert!(!feedback_modal.can_submit()); -// assert!(!feedback_modal.valid_email_address(cx)); -// assert!(!feedback_modal.valid_character_count()); - -// feedback_modal -// .email_address_editor -// .update(cx, |this, cx| this.set_text("a", cx)); -// feedback_modal.set_submission_state(cx); - -// assert!(!feedback_modal.valid_email_address(cx)); - -// feedback_modal -// .email_address_editor -// .update(cx, |this, cx| this.set_text("a&b.com", cx)); -// feedback_modal.set_submission_state(cx); - -// assert!(feedback_modal.valid_email_address(cx)); -// }); -// })?; -// } -// } +// TODO: Testing of various button states, dismissal prompts, etc. :) diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index 269d5790bc..e80e310c70 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -23,6 +23,7 @@ theme = { path = "../theme" } ui = { path = "../ui" } workspace = { path = "../workspace" } postage.workspace = true +anyhow.workspace = true serde.workspace = true [dev-dependencies] diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index d49eb9ee60..8484843c87 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1,5 +1,5 @@ use collections::HashMap; -use editor::{scroll::autoscroll::Autoscroll, Bias, Editor}; +use editor::{scroll::Autoscroll, Bias, Editor}; use fuzzy::{CharBag, PathMatch, PathMatchCandidate}; use gpui::{ actions, rems, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model, @@ -119,7 +119,7 @@ impl FocusableView for FileFinder { } impl Render for FileFinder { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - v_stack().w(rems(34.)).child(self.picker.clone()) + v_flex().w(rems(34.)).child(self.picker.clone()) } } @@ -312,7 +312,7 @@ impl FileFinderDelegate { cx: &mut ViewContext, ) -> Self { cx.observe(&project, |file_finder, _, cx| { - //todo!() We should probably not re-render on every project anything + //todo We should probably not re-render on every project anything file_finder .picker .update(cx, |picker, cx| picker.refresh(cx)) @@ -519,6 +519,62 @@ impl FileFinderDelegate { (file_name, file_name_positions, full_path, path_positions) } + + fn lookup_absolute_path( + &self, + query: PathLikeWithPosition, + cx: &mut ViewContext<'_, Picker>, + ) -> Task<()> { + cx.spawn(|picker, mut cx| async move { + let Some((project, fs)) = picker + .update(&mut cx, |picker, cx| { + let fs = Arc::clone(&picker.delegate.project.read(cx).fs()); + (picker.delegate.project.clone(), fs) + }) + .log_err() + else { + return; + }; + + let query_path = Path::new(query.path_like.path_query()); + let mut path_matches = Vec::new(); + match fs.metadata(query_path).await.log_err() { + Some(Some(_metadata)) => { + let update_result = project + .update(&mut cx, |project, cx| { + if let Some((worktree, relative_path)) = + project.find_local_worktree(query_path, cx) + { + path_matches.push(PathMatch { + score: 0.0, + positions: Vec::new(), + worktree_id: worktree.read(cx).id().to_usize(), + path: Arc::from(relative_path), + path_prefix: "".into(), + distance_to_relative_ancestor: usize::MAX, + }); + } + }) + .log_err(); + if update_result.is_none() { + return; + } + } + Some(None) => {} + None => return, + } + + picker + .update(&mut cx, |picker, cx| { + let picker_delegate = &mut picker.delegate; + let search_id = util::post_inc(&mut picker_delegate.search_count); + picker_delegate.set_search_matches(search_id, false, query, path_matches, cx); + + anyhow::Ok(()) + }) + .log_err(); + }) + } } impl PickerDelegate for FileFinderDelegate { @@ -588,7 +644,12 @@ impl PickerDelegate for FileFinderDelegate { }) }) .expect("infallible"); - self.spawn_search(query, cx) + + if Path::new(query.path_like.path_query()).is_absolute() { + self.lookup_absolute_path(query, cx) + } else { + self.spawn_search(query, cx) + } } } @@ -725,7 +786,7 @@ impl PickerDelegate for FileFinderDelegate { .inset(true) .selected(selected) .child( - v_stack() + v_flex() .child(HighlightedLabel::new(file_name, file_name_positions)) .child(HighlightedLabel::new(full_path, full_path_positions)), ), @@ -818,6 +879,68 @@ mod tests { } } + #[gpui::test] + async fn test_absolute_paths(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + "/root", + json!({ + "a": { + "file1.txt": "", + "b": { + "file2.txt": "", + }, + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + + let (picker, workspace, cx) = build_find_picker(project, cx); + + let matching_abs_path = "/root/a/b/file2.txt"; + picker + .update(cx, |picker, cx| { + picker + .delegate + .update_matches(matching_abs_path.to_string(), cx) + }) + .await; + picker.update(cx, |picker, _| { + assert_eq!( + collect_search_results(picker), + vec![PathBuf::from("a/b/file2.txt")], + "Matching abs path should be the only match" + ) + }); + cx.dispatch_action(SelectNext); + cx.dispatch_action(Confirm); + cx.read(|cx| { + let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); + assert_eq!(active_editor.read(cx).title(cx), "file2.txt"); + }); + + let mismatching_abs_path = "/root/a/b/file1.txt"; + picker + .update(cx, |picker, cx| { + picker + .delegate + .update_matches(mismatching_abs_path.to_string(), cx) + }) + .await; + picker.update(cx, |picker, _| { + assert_eq!( + collect_search_results(picker), + Vec::::new(), + "Mismatching abs path should produce no matches" + ) + }); + } + #[gpui::test] async fn test_complex_path(cx: &mut TestAppContext) { let app_state = init_test(cx); diff --git a/crates/fs/src/repository.rs b/crates/fs/src/repository.rs index cf5c65105c..ecb2a93577 100644 --- a/crates/fs/src/repository.rs +++ b/crates/fs/src/repository.rs @@ -29,7 +29,7 @@ pub trait GitRepository: Send { fn branch_name(&self) -> Option; /// Get the statuses of all of the files in the index that start with the given - /// path and have changes with resepect to the HEAD commit. This is fast because + /// path and have changes with respect to the HEAD commit. This is fast because /// the index stores hashes of trees, so that unchanged directories can be skipped. fn staged_statuses(&self, path_prefix: &Path) -> TreeMap; diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index ec58cbdc60..0a74f1ac03 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/crates/go_to_line/src/go_to_line.rs @@ -1,11 +1,11 @@ -use editor::{display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Editor}; +use editor::{display_map::ToDisplayPoint, scroll::Autoscroll, Editor}; use gpui::{ actions, div, prelude::*, AnyWindowHandle, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Render, SharedString, Styled, Subscription, View, ViewContext, VisualContext, }; use text::{Bias, Point}; use theme::ActiveTheme; -use ui::{h_stack, prelude::*, v_stack, Label}; +use ui::{h_flex, prelude::*, v_flex, Label}; use util::paths::FILE_ROW_COLUMN_DELIMITER; use workspace::ModalView; @@ -160,12 +160,12 @@ impl Render for GoToLine { .on_action(cx.listener(Self::confirm)) .w_96() .child( - v_stack() + v_flex() .px_1() .pt_0p5() .gap_px() .child( - v_stack() + v_flex() .py_0p5() .px_1() .child(div().px_1().py_0p5().child(self.line_editor.clone())), @@ -177,7 +177,7 @@ impl Render for GoToLine { .bg(cx.theme().colors().element_background), ) .child( - h_stack() + h_flex() .justify_between() .px_2() .py_1() diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 6ea3524fcc..ee75492873 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -78,7 +78,7 @@ cocoa = "0.24" core-foundation = { version = "0.9.3", features = ["with-uuid"] } core-graphics = "0.22.3" core-text = "19.2" -font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "b2f77d56f450338aa4f7dd2f0197d8c9acb0cf18" } +font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "d97147f" } foreign-types = "0.3" log.workspace = true metal = "0.21.0" diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs index 24e493cb81..44228b2e75 100644 --- a/crates/gpui/build.rs +++ b/crates/gpui/build.rs @@ -70,13 +70,23 @@ fn generate_shader_bindings() -> PathBuf { ]); config.no_includes = true; config.enumeration.prefix_with_name = true; - cbindgen::Builder::new() - .with_src(crate_dir.join("src/scene.rs")) - .with_src(crate_dir.join("src/geometry.rs")) - .with_src(crate_dir.join("src/color.rs")) - .with_src(crate_dir.join("src/window.rs")) - .with_src(crate_dir.join("src/platform.rs")) - .with_src(crate_dir.join("src/platform/mac/metal_renderer.rs")) + + let mut builder = cbindgen::Builder::new(); + + let src_paths = [ + crate_dir.join("src/scene.rs"), + crate_dir.join("src/geometry.rs"), + crate_dir.join("src/color.rs"), + crate_dir.join("src/window.rs"), + crate_dir.join("src/platform.rs"), + crate_dir.join("src/platform/mac/metal_renderer.rs"), + ]; + for src_path in src_paths { + println!("cargo:rerun-if-changed={}", src_path.display()); + builder = builder.with_src(src_path); + } + + builder .with_config(config) .generate() .expect("Unable to generate bindings") diff --git a/crates/gpui/docs/key_dispatch.md b/crates/gpui/docs/key_dispatch.md index daf6f820cd..804a0b5761 100644 --- a/crates/gpui/docs/key_dispatch.md +++ b/crates/gpui/docs/key_dispatch.md @@ -50,7 +50,7 @@ impl Render for Menu { .on_action(|this, move: &MoveDown, cx| { // ... }) - .children(todo!()) + .children(unimplemented!()) } } ``` @@ -68,7 +68,7 @@ impl Render for Menu { .on_action(|this, move: &MoveDown, cx| { // ... }) - .children(todo!()) + .children(unimplemented!()) } } ``` diff --git a/crates/gpui/examples/ownership_post.rs b/crates/gpui/examples/ownership_post.rs new file mode 100644 index 0000000000..cd3b6264c3 --- /dev/null +++ b/crates/gpui/examples/ownership_post.rs @@ -0,0 +1,35 @@ +use gpui::{prelude::*, App, AppContext, EventEmitter, Model, ModelContext}; + +struct Counter { + count: usize, +} + +struct Change { + increment: usize, +} + +impl EventEmitter for Counter {} + +fn main() { + App::new().run(|cx: &mut AppContext| { + let counter: Model = cx.new_model(|_cx| Counter { count: 0 }); + let subscriber = cx.new_model(|cx: &mut ModelContext| { + cx.subscribe(&counter, |subscriber, _emitter, event, _cx| { + subscriber.count += event.increment * 2; + }) + .detach(); + + Counter { + count: counter.read(cx).count * 2, + } + }); + + counter.update(cx, |counter, cx| { + counter.count += 2; + cx.notify(); + cx.emit(Change { increment: 2 }); + }); + + assert_eq!(subscriber.read(cx).count, 4); + }); +} diff --git a/crates/gpui/src/action.rs b/crates/gpui/src/action.rs index e335c4255e..9caa0da482 100644 --- a/crates/gpui/src/action.rs +++ b/crates/gpui/src/action.rs @@ -104,7 +104,7 @@ pub struct ActionData { } /// This constant must be public to be accessible from other crates. -/// But it's existence is an implementation detail and should not be used directly. +/// But its existence is an implementation detail and should not be used directly. #[doc(hidden)] #[linkme::distributed_slice] pub static __GPUI_ACTIONS: [MacroActionBuilder]; @@ -114,14 +114,26 @@ impl ActionRegistry { pub(crate) fn load_actions(&mut self) { for builder in __GPUI_ACTIONS { let action = builder(); - //todo(remove) - let name: SharedString = action.name.into(); - self.builders_by_name.insert(name.clone(), action.build); - self.names_by_type_id.insert(action.type_id, name.clone()); - self.all_names.push(name); + self.insert_action(action); } } + #[cfg(test)] + pub(crate) fn load_action(&mut self) { + self.insert_action(ActionData { + name: A::debug_name(), + type_id: TypeId::of::(), + build: A::build, + }); + } + + fn insert_action(&mut self, action: ActionData) { + let name: SharedString = action.name.into(); + self.builders_by_name.insert(name.clone(), action.build); + self.names_by_type_id.insert(action.type_id, name.clone()); + self.all_names.push(name); + } + /// Construct an action based on its name and optional JSON parameters sourced from the keymap. pub fn build_action_type(&self, type_id: &TypeId) -> Result> { let name = self @@ -158,7 +170,7 @@ impl ActionRegistry { macro_rules! actions { ($namespace:path, [ $($name:ident),* $(,)? ]) => { $( - #[derive(::std::cmp::PartialEq, ::std::clone::Clone, ::std::default::Default, gpui::private::serde_derive::Deserialize)] + #[derive(::std::cmp::PartialEq, ::std::clone::Clone, ::std::default::Default, ::std::fmt::Debug, gpui::private::serde_derive::Deserialize)] #[serde(crate = "gpui::private::serde")] pub struct $name; @@ -203,7 +215,6 @@ macro_rules! __impl_action { ) } - // todo!() why is this needed in addition to name? fn debug_name() -> &'static str where Self: ::std::marker::Sized diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 638396abc5..c7a6a92a17 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1,3 +1,5 @@ +#![deny(missing_docs)] + mod async_context; mod entity_map; mod model_context; @@ -43,13 +45,18 @@ use util::{ ResultExt, }; +/// The duration for which futures returned from [AppContext::on_app_context] or [ModelContext::on_app_quit] can run before the application fully quits. +pub const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100); + /// Temporary(?) wrapper around [`RefCell`] to help us debug any double borrows. /// Strongly consider removing after stabilization. +#[doc(hidden)] pub struct AppCell { app: RefCell, } impl AppCell { + #[doc(hidden)] #[track_caller] pub fn borrow(&self) -> AppRef { if option_env!("TRACK_THREAD_BORROWS").is_some() { @@ -59,6 +66,7 @@ impl AppCell { AppRef(self.app.borrow()) } + #[doc(hidden)] #[track_caller] pub fn borrow_mut(&self) -> AppRefMut { if option_env!("TRACK_THREAD_BORROWS").is_some() { @@ -69,6 +77,7 @@ impl AppCell { } } +#[doc(hidden)] #[derive(Deref, DerefMut)] pub struct AppRef<'a>(Ref<'a, AppContext>); @@ -81,6 +90,7 @@ impl<'a> Drop for AppRef<'a> { } } +#[doc(hidden)] #[derive(Deref, DerefMut)] pub struct AppRefMut<'a>(RefMut<'a, AppContext>); @@ -93,20 +103,32 @@ impl<'a> Drop for AppRefMut<'a> { } } +/// A reference to a GPUI application, typically constructed in the `main` function of your app. +/// You won't interact with this type much outside of initial configuration and startup. pub struct App(Rc); /// Represents an application before it is fully launched. Once your app is /// configured, you'll start the app with `App::run`. impl App { /// Builds an app with the given asset source. - pub fn production(asset_source: Arc) -> Self { + pub fn new() -> Self { Self(AppContext::new( current_platform(), - asset_source, + Arc::new(()), http::client(), )) } + /// Assign + pub fn with_assets(self, asset_source: impl AssetSource) -> Self { + let mut context_lock = self.0.borrow_mut(); + let asset_source = Arc::new(asset_source); + context_lock.asset_source = asset_source.clone(); + context_lock.svg_renderer = SvgRenderer::new(asset_source); + drop(context_lock); + self + } + /// Start the application. The provided callback will be called once the /// app is fully launched. pub fn run(self, on_finish_launching: F) @@ -136,6 +158,8 @@ impl App { self } + /// Invokes a handler when an already-running application is launched. + /// On macOS, this can occur when the application icon is double-clicked or the app is launched via the dock. pub fn on_reopen(&self, mut callback: F) -> &Self where F: 'static + FnMut(&mut AppContext), @@ -149,18 +173,22 @@ impl App { self } + /// Returns metadata associated with the application pub fn metadata(&self) -> AppMetadata { self.0.borrow().app_metadata.clone() } + /// Returns a handle to the [`BackgroundExecutor`] associated with this app, which can be used to spawn futures in the background. pub fn background_executor(&self) -> BackgroundExecutor { self.0.borrow().background_executor.clone() } + /// Returns a handle to the [`ForegroundExecutor`] associated with this app, which can be used to spawn futures in the foreground. pub fn foreground_executor(&self) -> ForegroundExecutor { self.0.borrow().foreground_executor.clone() } + /// Returns a reference to the [`TextSystem`] associated with this app. pub fn text_system(&self) -> Arc { self.0.borrow().text_system.clone() } @@ -174,12 +202,9 @@ type QuitHandler = Box LocalBoxFuture<'static, () type ReleaseListener = Box; type NewViewListener = Box; -// struct FrameConsumer { -// next_frame_callbacks: Vec, -// task: Task<()>, -// display_linker -// } - +/// Contains the state of the full application, and passed as a reference to a variety of callbacks. +/// Other contexts such as [ModelContext], [WindowContext], and [ViewContext] deref to this type, making it the most general context type. +/// You need a reference to an `AppContext` to access the state of a [Model]. pub struct AppContext { pub(crate) this: Weak, pub(crate) platform: Rc, @@ -189,7 +214,6 @@ pub struct AppContext { pending_updates: usize, pub(crate) actions: Rc, pub(crate) active_drag: Option, - pub(crate) active_tooltip: Option, pub(crate) next_frame_callbacks: FxHashMap>, pub(crate) frame_consumers: FxHashMap>, pub(crate) background_executor: BackgroundExecutor, @@ -251,7 +275,6 @@ impl AppContext { flushing_effects: false, pending_updates: 0, active_drag: None, - active_tooltip: None, next_frame_callbacks: FxHashMap::default(), frame_consumers: FxHashMap::default(), background_executor: executor, @@ -292,7 +315,7 @@ impl AppContext { app } - /// Quit the application gracefully. Handlers registered with `ModelContext::on_app_quit` + /// Quit the application gracefully. Handlers registered with [`ModelContext::on_app_quit`] /// will be given 100ms to complete before exiting. pub fn shutdown(&mut self) { let mut futures = Vec::new(); @@ -307,17 +330,19 @@ impl AppContext { let futures = futures::future::join_all(futures); if self .background_executor - .block_with_timeout(Duration::from_millis(100), futures) + .block_with_timeout(SHUTDOWN_TIMEOUT, futures) .is_err() { log::error!("timed out waiting on app_will_quit"); } } + /// Gracefully quit the application via the platform's standard routine. pub fn quit(&mut self) { self.platform.quit(); } + /// Get metadata about the app and platform. pub fn app_metadata(&self) -> AppMetadata { self.app_metadata.clone() } @@ -340,6 +365,7 @@ impl AppContext { result } + /// Arrange a callback to be invoked when the given model or view calls `notify` on its respective context. pub fn observe( &mut self, entity: &E, @@ -355,7 +381,7 @@ impl AppContext { }) } - pub fn observe_internal( + pub(crate) fn observe_internal( &mut self, entity: &E, mut on_notify: impl FnMut(E, &mut AppContext) -> bool + 'static, @@ -380,15 +406,17 @@ impl AppContext { subscription } - pub fn subscribe( + /// Arrange for the given callback to be invoked whenever the given model or view emits an event of a given type. + /// The callback is provided a handle to the emitting entity and a reference to the emitted event. + pub fn subscribe( &mut self, entity: &E, - mut on_event: impl FnMut(E, &Evt, &mut AppContext) + 'static, + mut on_event: impl FnMut(E, &Event, &mut AppContext) + 'static, ) -> Subscription where - T: 'static + EventEmitter, + T: 'static + EventEmitter, E: Entity, - Evt: 'static, + Event: 'static, { self.subscribe_internal(entity, move |entity, event, cx| { on_event(entity, event, cx); @@ -426,6 +454,9 @@ impl AppContext { subscription } + /// Returns handles to all open windows in the application. + /// Each handle could be downcast to a handle typed for the root view of that window. + /// To find all windows of a given type, you could filter on pub fn windows(&self) -> Vec { self.windows .values() @@ -433,6 +464,7 @@ impl AppContext { .collect() } + /// Returns a handle to the window that is currently focused at the platform level, if one exists. pub fn active_window(&self) -> Option { self.platform.active_window() } @@ -461,14 +493,17 @@ impl AppContext { self.platform.activate(ignoring_other_apps); } + /// Hide the application at the platform level. pub fn hide(&self) { self.platform.hide(); } + /// Hide other applications at the platform level. pub fn hide_other_apps(&self) { self.platform.hide_other_apps(); } + /// Unhide other applications at the platform level. pub fn unhide_other_apps(&self) { self.platform.unhide_other_apps(); } @@ -508,18 +543,25 @@ impl AppContext { self.platform.open_url(url); } + /// Returns the full pathname of the current app bundle. + /// If the app is not being run from a bundle, returns an error. pub fn app_path(&self) -> Result { self.platform.app_path() } + /// Returns the file URL of the executable with the specified name in the application bundle pub fn path_for_auxiliary_executable(&self, name: &str) -> Result { self.platform.path_for_auxiliary_executable(name) } + /// Returns the maximum duration in which a second mouse click must occur for an event to be a double-click event. pub fn double_click_interval(&self) -> Duration { self.platform.double_click_interval() } + /// Displays a platform modal for selecting paths. + /// When one or more paths are selected, they'll be relayed asynchronously via the returned oneshot channel. + /// If cancelled, a `None` will be relayed instead. pub fn prompt_for_paths( &self, options: PathPromptOptions, @@ -527,22 +569,30 @@ impl AppContext { self.platform.prompt_for_paths(options) } + /// Displays a platform modal for selecting a new path where a file can be saved. + /// The provided directory will be used to set the iniital location. + /// When a path is selected, it is relayed asynchronously via the returned oneshot channel. + /// If cancelled, a `None` will be relayed instead. pub fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver> { self.platform.prompt_for_new_path(directory) } + /// Reveals the specified path at the platform level, such as in Finder on macOS. pub fn reveal_path(&self, path: &Path) { self.platform.reveal_path(path) } + /// Returns whether the user has configured scrollbars to auto-hide at the platform level. pub fn should_auto_hide_scrollbars(&self) -> bool { self.platform.should_auto_hide_scrollbars() } + /// Restart the application. pub fn restart(&self) { self.platform.restart() } + /// Returns the local timezone at the platform level. pub fn local_timezone(&self) -> UtcOffset { self.platform.local_timezone() } @@ -565,7 +615,7 @@ impl AppContext { self.pending_effects.push_back(effect); } - /// Called at the end of AppContext::update to complete any side effects + /// Called at the end of [`AppContext::update`] to complete any side effects /// such as notifying observers, emitting events, etc. Effects can themselves /// cause effects, so we continue looping until all effects are processed. fn flush_effects(&mut self) { @@ -732,7 +782,7 @@ impl AppContext { } /// Spawns the future returned by the given function on the thread pool. The closure will be invoked - /// with AsyncAppContext, which allows the application state to be accessed across await points. + /// with [AsyncAppContext], which allows the application state to be accessed across await points. pub fn spawn(&self, f: impl FnOnce(AsyncAppContext) -> Fut) -> Task where Fut: Future + 'static, @@ -830,6 +880,7 @@ impl AppContext { /// Remove the global of the given type from the app context. Does not notify global observers. pub fn remove_global(&mut self) -> G { let global_type = TypeId::of::(); + self.push_effect(Effect::NotifyGlobalObservers { global_type }); *self .globals_by_type .remove(&global_type) @@ -882,6 +933,8 @@ impl AppContext { self.globals_by_type.insert(global_type, lease.global); } + /// Arrange for the given function to be invoked whenever a view of the specified type is created. + /// The function will be passed a mutable reference to the view along with an appropriate context. pub fn observe_new_views( &mut self, on_new: impl 'static + Fn(&mut V, &mut ViewContext), @@ -901,6 +954,8 @@ impl AppContext { subscription } + /// Observe the release of a model or view. The callback is invoked after the model or view + /// has no more strong references but before it has been dropped. pub fn observe_release( &mut self, handle: &E, @@ -921,6 +976,9 @@ impl AppContext { subscription } + /// Register a callback to be invoked when a keystroke is received by the application + /// in any window. Note that this fires after all other action and event mechanisms have resolved + /// and that this API will not be invoked if the event's propagation is stopped. pub fn observe_keystrokes( &mut self, f: impl FnMut(&KeystrokeEvent, &mut WindowContext) + 'static, @@ -944,6 +1002,7 @@ impl AppContext { self.pending_effects.push_back(Effect::Refresh); } + /// Clear all key bindings in the app. pub fn clear_key_bindings(&mut self) { self.keymap.lock().clear(); self.pending_effects.push_back(Effect::Refresh); @@ -978,6 +1037,7 @@ impl AppContext { self.propagate_event = true; } + /// Build an action from some arbitrary data, typically a keymap entry. pub fn build_action( &self, name: &str, @@ -986,10 +1046,16 @@ impl AppContext { self.actions.build_action(name, data) } + /// Get a list of all action names that have been registered. + /// in the application. Note that registration only allows for + /// actions to be built dynamically, and is unrelated to binding + /// actions in the element tree. pub fn all_action_names(&self) -> &[SharedString] { self.actions.all_action_names() } + /// Register a callback to be invoked when the application is about to quit. + /// It is not possible to cancel the quit event at this point. pub fn on_app_quit( &mut self, mut on_quit: impl FnMut(&mut AppContext) -> Fut + 'static, @@ -1025,6 +1091,8 @@ impl AppContext { } } + /// Checks if the given action is bound in the current context, as defined by the app's current focus, + /// the bindings in the element tree, and any global action listeners. pub fn is_action_available(&mut self, action: &dyn Action) -> bool { if let Some(window) = self.active_window() { if let Ok(window_action_available) = @@ -1038,10 +1106,13 @@ impl AppContext { .contains_key(&action.as_any().type_id()) } + /// Set the menu bar for this application. This will replace any existing menu bar. pub fn set_menus(&mut self, menus: Vec) { self.platform.set_menus(menus, &self.keymap.lock()); } + /// Dispatch an action to the currently active window or global action handler + /// See [action::Action] for more information on how actions work pub fn dispatch_action(&mut self, action: &dyn Action) { if let Some(active_window) = self.active_window() { active_window @@ -1096,6 +1167,7 @@ impl AppContext { } } + /// Is there currently something being dragged? pub fn has_active_drag(&self) -> bool { self.active_drag.is_some() } @@ -1105,7 +1177,7 @@ impl Context for AppContext { type Result = T; /// Build an entity that is owned by the application. The given function will be invoked with - /// a `ModelContext` and must return an object representing the entity. A `Model` will be returned + /// a `ModelContext` and must return an object representing the entity. A `Model` handle will be returned, /// which can be used to access the entity in a context. fn new_model( &mut self, @@ -1248,19 +1320,34 @@ impl DerefMut for GlobalLease { /// Contains state associated with an active drag operation, started by dragging an element /// within the window or by dragging into the app from the underlying platform. pub struct AnyDrag { + /// The view used to render this drag pub view: AnyView, + + /// The value of the dragged item, to be dropped pub value: Box, + + /// This is used to render the dragged item in the same place + /// on the original element that the drag was initiated pub cursor_offset: Point, } +/// Contains state associated with a tooltip. You'll only need this struct if you're implementing +/// tooltip behavior on a custom element. Otherwise, use [Div::tooltip]. #[derive(Clone)] -pub(crate) struct AnyTooltip { +pub struct AnyTooltip { + /// The view used to display the tooltip pub view: AnyView, + + /// The offset from the cursor to use, relative to the parent view pub cursor_offset: Point, } +/// A keystroke event, and potentially the associated action #[derive(Debug)] pub struct KeystrokeEvent { + /// The keystroke that occurred pub keystroke: Keystroke, + + /// The action that was resolved for the keystroke, if any pub action: Option>, } diff --git a/crates/gpui/src/app/async_context.rs b/crates/gpui/src/app/async_context.rs index 475ef76ef2..1ee01d90df 100644 --- a/crates/gpui/src/app/async_context.rs +++ b/crates/gpui/src/app/async_context.rs @@ -7,6 +7,9 @@ use anyhow::{anyhow, Context as _}; use derive_more::{Deref, DerefMut}; use std::{future::Future, rc::Weak}; +/// An async-friendly version of [AppContext] with a static lifetime so it can be held across `await` points in async code. +/// You're provided with an instance when calling [AppContext::spawn], and you can also create one with [AppContext::to_async]. +/// Internally, this holds a weak reference to an `AppContext`, so its methods are fallible to protect against cases where the [AppContext] is dropped. #[derive(Clone)] pub struct AsyncAppContext { pub(crate) app: Weak, @@ -82,6 +85,7 @@ impl Context for AsyncAppContext { } impl AsyncAppContext { + /// Schedules all windows in the application to be redrawn. pub fn refresh(&mut self) -> Result<()> { let app = self .app @@ -92,14 +96,17 @@ impl AsyncAppContext { Ok(()) } + /// Get an executor which can be used to spawn futures in the background. pub fn background_executor(&self) -> &BackgroundExecutor { &self.background_executor } + /// Get an executor which can be used to spawn futures in the foreground. pub fn foreground_executor(&self) -> &ForegroundExecutor { &self.foreground_executor } + /// Invoke the given function in the context of the app, then flush any effects produced during its invocation. pub fn update(&self, f: impl FnOnce(&mut AppContext) -> R) -> Result { let app = self .app @@ -109,6 +116,7 @@ impl AsyncAppContext { Ok(f(&mut lock)) } + /// Open a window with the given options based on the root view returned by the given function. pub fn open_window( &self, options: crate::WindowOptions, @@ -125,6 +133,7 @@ impl AsyncAppContext { Ok(lock.open_window(options, build_root_view)) } + /// Schedule a future to be polled in the background. pub fn spawn(&self, f: impl FnOnce(AsyncAppContext) -> Fut) -> Task where Fut: Future + 'static, @@ -133,6 +142,8 @@ impl AsyncAppContext { self.foreground_executor.spawn(f(self.clone())) } + /// Determine whether global state of the specified type has been assigned. + /// Returns an error if the `AppContext` has been dropped. pub fn has_global(&self) -> Result { let app = self .app @@ -142,6 +153,9 @@ impl AsyncAppContext { Ok(app.has_global::()) } + /// Reads the global state of the specified type, passing it to the given callback. + /// Panics if no global state of the specified type has been assigned. + /// Returns an error if the `AppContext` has been dropped. pub fn read_global(&self, read: impl FnOnce(&G, &AppContext) -> R) -> Result { let app = self .app @@ -151,6 +165,9 @@ impl AsyncAppContext { Ok(read(app.global(), &app)) } + /// Reads the global state of the specified type, passing it to the given callback. + /// Similar to [read_global], but returns an error instead of panicking if no state of the specified type has been assigned. + /// Returns an error if no state of the specified type has been assigned the `AppContext` has been dropped. pub fn try_read_global( &self, read: impl FnOnce(&G, &AppContext) -> R, @@ -160,6 +177,8 @@ impl AsyncAppContext { Some(read(app.try_global()?, &app)) } + /// A convenience method for [AppContext::update_global] + /// for updating the global state of the specified type. pub fn update_global( &mut self, update: impl FnOnce(&mut G, &mut AppContext) -> R, @@ -173,6 +192,8 @@ impl AsyncAppContext { } } +/// A cloneable, owned handle to the application context, +/// composed with the window associated with the current task. #[derive(Clone, Deref, DerefMut)] pub struct AsyncWindowContext { #[deref] @@ -182,14 +203,16 @@ pub struct AsyncWindowContext { } impl AsyncWindowContext { - pub fn window_handle(&self) -> AnyWindowHandle { - self.window - } - pub(crate) fn new(app: AsyncAppContext, window: AnyWindowHandle) -> Self { Self { app, window } } + /// Get the handle of the window this context is associated with. + pub fn window_handle(&self) -> AnyWindowHandle { + self.window + } + + /// A convenience method for [WindowContext::update()] pub fn update( &mut self, update: impl FnOnce(AnyView, &mut WindowContext) -> R, @@ -197,10 +220,12 @@ impl AsyncWindowContext { self.app.update_window(self.window, update) } + /// A convenience method for [WindowContext::on_next_frame()] pub fn on_next_frame(&mut self, f: impl FnOnce(&mut WindowContext) + 'static) { self.window.update(self, |_, cx| cx.on_next_frame(f)).ok(); } + /// A convenience method for [AppContext::global()] pub fn read_global( &mut self, read: impl FnOnce(&G, &WindowContext) -> R, @@ -208,6 +233,8 @@ impl AsyncWindowContext { self.window.update(self, |_, cx| read(cx.global(), cx)) } + /// A convenience method for [AppContext::update_global()] + /// for updating the global state of the specified type. pub fn update_global( &mut self, update: impl FnOnce(&mut G, &mut WindowContext) -> R, @@ -218,6 +245,8 @@ impl AsyncWindowContext { self.window.update(self, |_, cx| cx.update_global(update)) } + /// Schedule a future to be executed on the main thread. This is used for collecting + /// the results of background tasks and updating the UI. pub fn spawn(&self, f: impl FnOnce(AsyncWindowContext) -> Fut) -> Task where Fut: Future + 'static, diff --git a/crates/gpui/src/app/entity_map.rs b/crates/gpui/src/app/entity_map.rs index 17f6e47ddf..7ab21a5477 100644 --- a/crates/gpui/src/app/entity_map.rs +++ b/crates/gpui/src/app/entity_map.rs @@ -2,7 +2,7 @@ use crate::{seal::Sealed, AppContext, Context, Entity, ModelContext}; use anyhow::{anyhow, Result}; use derive_more::{Deref, DerefMut}; use parking_lot::{RwLock, RwLockUpgradableReadGuard}; -use slotmap::{SecondaryMap, SlotMap}; +use slotmap::{KeyData, SecondaryMap, SlotMap}; use std::{ any::{type_name, Any, TypeId}, fmt::{self, Display}, @@ -19,9 +19,19 @@ use std::{ #[cfg(any(test, feature = "test-support"))] use collections::HashMap; -slotmap::new_key_type! { pub struct EntityId; } +slotmap::new_key_type! { + /// A unique identifier for a model or view across the application. + pub struct EntityId; +} + +impl From for EntityId { + fn from(value: u64) -> Self { + Self(KeyData::from_ffi(value)) + } +} impl EntityId { + /// Converts this entity id to a [u64] pub fn as_u64(self) -> u64 { self.0.as_ffi() } @@ -131,7 +141,7 @@ impl EntityMap { } } -pub struct Lease<'a, T> { +pub(crate) struct Lease<'a, T> { entity: Option>, pub model: &'a Model, entity_type: PhantomData, @@ -160,8 +170,9 @@ impl<'a, T> Drop for Lease<'a, T> { } #[derive(Deref, DerefMut)] -pub struct Slot(Model); +pub(crate) struct Slot(Model); +/// A dynamically typed reference to a model, which can be downcast into a `Model`. pub struct AnyModel { pub(crate) entity_id: EntityId, pub(crate) entity_type: TypeId, @@ -186,14 +197,17 @@ impl AnyModel { } } + /// Returns the id associated with this model. pub fn entity_id(&self) -> EntityId { self.entity_id } + /// Returns the [TypeId] associated with this model. pub fn entity_type(&self) -> TypeId { self.entity_type } + /// Converts this model handle into a weak variant, which does not prevent it from being released. pub fn downgrade(&self) -> AnyWeakModel { AnyWeakModel { entity_id: self.entity_id, @@ -202,6 +216,8 @@ impl AnyModel { } } + /// Converts this model handle into a strongly-typed model handle of the given type. + /// If this model handle is not of the specified type, returns itself as an error variant. pub fn downcast(self) -> Result, AnyModel> { if TypeId::of::() == self.entity_type { Ok(Model { @@ -265,7 +281,7 @@ impl Drop for AnyModel { entity_map .write() .leak_detector - .handle_dropped(self.entity_id, self.handle_id) + .handle_released(self.entity_id, self.handle_id) } } } @@ -298,6 +314,8 @@ impl std::fmt::Debug for AnyModel { } } +/// A strong, well typed reference to a struct which is managed +/// by GPUI #[derive(Deref, DerefMut)] pub struct Model { #[deref] @@ -359,10 +377,12 @@ impl Model { self.any_model } + /// Grab a reference to this entity from the context. pub fn read<'a>(&self, cx: &'a AppContext) -> &'a T { cx.entities.read(self) } + /// Read the entity referenced by this model with the given function. pub fn read_with( &self, cx: &C, @@ -428,6 +448,7 @@ impl PartialEq> for Model { } } +/// A type erased, weak reference to a model. #[derive(Clone)] pub struct AnyWeakModel { pub(crate) entity_id: EntityId, @@ -436,10 +457,12 @@ pub struct AnyWeakModel { } impl AnyWeakModel { + /// Get the entity ID associated with this weak reference. pub fn entity_id(&self) -> EntityId { self.entity_id } + /// Check if this weak handle can be upgraded, or if the model has already been dropped pub fn is_upgradable(&self) -> bool { let ref_count = self .entity_ref_counts @@ -449,6 +472,7 @@ impl AnyWeakModel { ref_count > 0 } + /// Upgrade this weak model reference to a strong reference. pub fn upgrade(&self) -> Option { let ref_counts = &self.entity_ref_counts.upgrade()?; let ref_counts = ref_counts.read(); @@ -476,14 +500,15 @@ impl AnyWeakModel { }) } + /// Assert that model referenced by this weak handle has been released. #[cfg(any(test, feature = "test-support"))] - pub fn assert_dropped(&self) { + pub fn assert_released(&self) { self.entity_ref_counts .upgrade() .unwrap() .write() .leak_detector - .assert_dropped(self.entity_id); + .assert_released(self.entity_id); if self .entity_ref_counts @@ -518,6 +543,7 @@ impl PartialEq for AnyWeakModel { impl Eq for AnyWeakModel {} +/// A weak reference to a model of the given type. #[derive(Deref, DerefMut)] pub struct WeakModel { #[deref] @@ -608,12 +634,12 @@ lazy_static::lazy_static! { #[cfg(any(test, feature = "test-support"))] #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)] -pub struct HandleId { +pub(crate) struct HandleId { id: u64, // id of the handle itself, not the pointed at object } #[cfg(any(test, feature = "test-support"))] -pub struct LeakDetector { +pub(crate) struct LeakDetector { next_handle_id: u64, entity_handles: HashMap>>, } @@ -632,12 +658,12 @@ impl LeakDetector { handle_id } - pub fn handle_dropped(&mut self, entity_id: EntityId, handle_id: HandleId) { + pub fn handle_released(&mut self, entity_id: EntityId, handle_id: HandleId) { let handles = self.entity_handles.entry(entity_id).or_default(); handles.remove(&handle_id); } - pub fn assert_dropped(&mut self, entity_id: EntityId) { + pub fn assert_released(&mut self, entity_id: EntityId) { let handles = self.entity_handles.entry(entity_id).or_default(); if !handles.is_empty() { for (_, backtrace) in handles { diff --git a/crates/gpui/src/app/model_context.rs b/crates/gpui/src/app/model_context.rs index 63db0ee1cb..e2aad9fee9 100644 --- a/crates/gpui/src/app/model_context.rs +++ b/crates/gpui/src/app/model_context.rs @@ -11,6 +11,7 @@ use std::{ future::Future, }; +/// The app context, with specialized behavior for the given model. #[derive(Deref, DerefMut)] pub struct ModelContext<'a, T> { #[deref] @@ -24,20 +25,24 @@ impl<'a, T: 'static> ModelContext<'a, T> { Self { app, model_state } } + /// The entity id of the model backing this context. pub fn entity_id(&self) -> EntityId { self.model_state.entity_id } + /// Returns a handle to the model belonging to this context. pub fn handle(&self) -> Model { self.weak_model() .upgrade() .expect("The entity must be alive if we have a model context") } + /// Returns a weak handle to the model belonging to this context. pub fn weak_model(&self) -> WeakModel { self.model_state.clone() } + /// Arranges for the given function to be called whenever [ModelContext::notify] or [ViewContext::notify] is called with the given model or view. pub fn observe( &mut self, entity: &E, @@ -59,6 +64,7 @@ impl<'a, T: 'static> ModelContext<'a, T> { }) } + /// Subscribe to an event type from another model or view pub fn subscribe( &mut self, entity: &E, @@ -81,6 +87,7 @@ impl<'a, T: 'static> ModelContext<'a, T> { }) } + /// Register a callback to be invoked when GPUI releases this model. pub fn on_release( &mut self, on_release: impl FnOnce(&mut T, &mut AppContext) + 'static, @@ -99,6 +106,7 @@ impl<'a, T: 'static> ModelContext<'a, T> { subscription } + /// Register a callback to be run on the release of another model or view pub fn observe_release( &mut self, entity: &E, @@ -124,6 +132,7 @@ impl<'a, T: 'static> ModelContext<'a, T> { subscription } + /// Register a callback to for updates to the given global pub fn observe_global( &mut self, mut f: impl FnMut(&mut T, &mut ModelContext<'_, T>) + 'static, @@ -140,6 +149,8 @@ impl<'a, T: 'static> ModelContext<'a, T> { subscription } + /// Arrange for the given function to be invoked whenever the application is quit. + /// The future returned from this callback will be polled for up to [gpui::SHUTDOWN_TIMEOUT] until the app fully quits. pub fn on_app_quit( &mut self, mut on_quit: impl FnMut(&mut T, &mut ModelContext) -> Fut + 'static, @@ -165,6 +176,7 @@ impl<'a, T: 'static> ModelContext<'a, T> { subscription } + /// Tell GPUI that this model has changed and observers of it should be notified. pub fn notify(&mut self) { if self .app @@ -177,6 +189,7 @@ impl<'a, T: 'static> ModelContext<'a, T> { } } + /// Update the given global pub fn update_global(&mut self, f: impl FnOnce(&mut G, &mut Self) -> R) -> R where G: 'static, @@ -187,6 +200,9 @@ impl<'a, T: 'static> ModelContext<'a, T> { result } + /// Spawn the future returned by the given function. + /// The function is provided a weak handle to the model owned by this context and a context that can be held across await points. + /// The returned task must be held or detached. pub fn spawn(&self, f: impl FnOnce(WeakModel, AsyncAppContext) -> Fut) -> Task where T: 'static, @@ -199,6 +215,7 @@ impl<'a, T: 'static> ModelContext<'a, T> { } impl<'a, T> ModelContext<'a, T> { + /// Emit an event of the specified type, which can be handled by other entities that have subscribed via `subscribe` methods on their respective contexts. pub fn emit(&mut self, event: Evt) where T: EventEmitter, diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 0f71ea61a9..d11c1239dd 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -1,22 +1,31 @@ +#![deny(missing_docs)] + use crate::{ - div, Action, AnyView, AnyWindowHandle, AppCell, AppContext, AsyncAppContext, - BackgroundExecutor, ClipboardItem, Context, Entity, EventEmitter, ForegroundExecutor, - IntoElement, Keystroke, Model, ModelContext, Pixels, Platform, Render, Result, Size, Task, - TestDispatcher, TestPlatform, TestWindow, TextSystem, View, ViewContext, VisualContext, - WindowContext, WindowHandle, WindowOptions, + Action, AnyElement, AnyView, AnyWindowHandle, AppCell, AppContext, AsyncAppContext, + AvailableSpace, BackgroundExecutor, Bounds, ClipboardItem, Context, Entity, EventEmitter, + ForegroundExecutor, InputEvent, Keystroke, Model, ModelContext, Pixels, Platform, Point, + Render, Result, Size, Task, TestDispatcher, TestPlatform, TestWindow, TextSystem, View, + ViewContext, VisualContext, WindowContext, WindowHandle, WindowOptions, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt}; use std::{future::Future, ops::Deref, rc::Rc, sync::Arc, time::Duration}; +/// A TestAppContext is provided to tests created with `#[gpui::test]`, it provides +/// an implementation of `Context` with additional methods that are useful in tests. #[derive(Clone)] pub struct TestAppContext { + #[doc(hidden)] pub app: Rc, + #[doc(hidden)] pub background_executor: BackgroundExecutor, + #[doc(hidden)] pub foreground_executor: ForegroundExecutor, + #[doc(hidden)] pub dispatcher: TestDispatcher, - pub test_platform: Rc, + test_platform: Rc, text_system: Arc, + fn_name: Option<&'static str>, } impl Context for TestAppContext { @@ -76,7 +85,8 @@ impl Context for TestAppContext { } impl TestAppContext { - pub fn new(dispatcher: TestDispatcher) -> Self { + /// Creates a new `TestAppContext`. Usually you can rely on `#[gpui::test]` to do this for you. + pub fn new(dispatcher: TestDispatcher, fn_name: Option<&'static str>) -> Self { let arc_dispatcher = Arc::new(dispatcher.clone()); let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); let foreground_executor = ForegroundExecutor::new(arc_dispatcher); @@ -92,41 +102,61 @@ impl TestAppContext { dispatcher: dispatcher.clone(), test_platform: platform, text_system, + fn_name, } } - pub fn new_app(&self) -> TestAppContext { - Self::new(self.dispatcher.clone()) + /// The name of the test function that created this `TestAppContext` + pub fn test_function_name(&self) -> Option<&'static str> { + self.fn_name } + /// Checks whether there have been any new path prompts received by the platform. + pub fn did_prompt_for_new_path(&self) -> bool { + self.test_platform.did_prompt_for_new_path() + } + + /// returns a new `TestAppContext` re-using the same executors to interleave tasks. + pub fn new_app(&self) -> TestAppContext { + Self::new(self.dispatcher.clone(), self.fn_name) + } + + /// Simulates quitting the app. pub fn quit(&self) { self.app.borrow_mut().shutdown(); } + /// Schedules all windows to be redrawn on the next effect cycle. pub fn refresh(&mut self) -> Result<()> { let mut app = self.app.borrow_mut(); app.refresh(); Ok(()) } + /// Returns an executor (for running tasks in the background) pub fn executor(&self) -> BackgroundExecutor { self.background_executor.clone() } + /// Returns an executor (for running tasks on the main thread) pub fn foreground_executor(&self) -> &ForegroundExecutor { &self.foreground_executor } + /// Gives you an `&mut AppContext` for the duration of the closure pub fn update(&self, f: impl FnOnce(&mut AppContext) -> R) -> R { let mut cx = self.app.borrow_mut(); cx.update(f) } + /// Gives you an `&AppContext` for the duration of the closure pub fn read(&self, f: impl FnOnce(&AppContext) -> R) -> R { let cx = self.app.borrow(); f(&*cx) } + /// Adds a new window. The Window will always be backed by a `TestWindow` which + /// can be retrieved with `self.test_window(handle)` pub fn add_window(&mut self, build_window: F) -> WindowHandle where F: FnOnce(&mut ViewContext) -> V, @@ -136,12 +166,20 @@ impl TestAppContext { cx.open_window(WindowOptions::default(), |cx| cx.new_view(build_window)) } - pub fn add_empty_window(&mut self) -> AnyWindowHandle { + /// Adds a new window with no content. + pub fn add_empty_window(&mut self) -> &mut VisualTestContext { let mut cx = self.app.borrow_mut(); - cx.open_window(WindowOptions::default(), |cx| cx.new_view(|_| EmptyView {})) - .any_handle + let window = cx.open_window(WindowOptions::default(), |cx| cx.new_view(|_| ())); + drop(cx); + let cx = Box::new(VisualTestContext::from_window(*window.deref(), self)); + cx.run_until_parked(); + // it might be nice to try and cleanup these at the end of each test. + Box::leak(cx) } + /// Adds a new window, and returns its root view and a `VisualTestContext` which can be used + /// as a `WindowContext` for the rest of the test. Typically you would shadow this context with + /// the returned one. `let (view, cx) = cx.add_window_view(...);` pub fn add_window_view(&mut self, build_window: F) -> (View, &mut VisualTestContext) where F: FnOnce(&mut ViewContext) -> V, @@ -152,22 +190,28 @@ impl TestAppContext { drop(cx); let view = window.root_view(self).unwrap(); let cx = Box::new(VisualTestContext::from_window(*window.deref(), self)); + cx.run_until_parked(); // it might be nice to try and cleanup these at the end of each test. (view, Box::leak(cx)) } + /// returns the TextSystem pub fn text_system(&self) -> &Arc { &self.text_system } + /// Simulates writing to the platform clipboard pub fn write_to_clipboard(&self, item: ClipboardItem) { self.test_platform.write_to_clipboard(item) } + /// Simulates reading from the platform clipboard. + /// This will return the most recent value from `write_to_clipboard`. pub fn read_from_clipboard(&self) -> Option { self.test_platform.read_from_clipboard() } + /// Simulates choosing a File in the platform's "Open" dialog. pub fn simulate_new_path_selection( &self, select_path: impl FnOnce(&std::path::Path) -> Option, @@ -175,22 +219,27 @@ impl TestAppContext { self.test_platform.simulate_new_path_selection(select_path); } + /// Simulates clicking a button in an platform-level alert dialog. pub fn simulate_prompt_answer(&self, button_ix: usize) { self.test_platform.simulate_prompt_answer(button_ix); } + /// Returns true if there's an alert dialog open. pub fn has_pending_prompt(&self) -> bool { self.test_platform.has_pending_prompt() } + /// Simulates the user resizing the window to the new size. pub fn simulate_window_resize(&self, window_handle: AnyWindowHandle, size: Size) { self.test_window(window_handle).simulate_resize(size); } + /// Returns all windows open in the test. pub fn windows(&self) -> Vec { self.app.borrow().windows().clone() } + /// Run the given task on the main thread. pub fn spawn(&self, f: impl FnOnce(AsyncAppContext) -> Fut) -> Task where Fut: Future + 'static, @@ -199,16 +248,20 @@ impl TestAppContext { self.foreground_executor.spawn(f(self.to_async())) } + /// true if the given global is defined pub fn has_global(&self) -> bool { let app = self.app.borrow(); app.has_global::() } + /// runs the given closure with a reference to the global + /// panics if `has_global` would return false. pub fn read_global(&self, read: impl FnOnce(&G, &AppContext) -> R) -> R { let app = self.app.borrow(); read(app.global(), &app) } + /// runs the given closure with a reference to the global (if set) pub fn try_read_global( &self, read: impl FnOnce(&G, &AppContext) -> R, @@ -217,11 +270,13 @@ impl TestAppContext { Some(read(lock.try_global()?, &lock)) } + /// sets the global in this context. pub fn set_global(&mut self, global: G) { let mut lock = self.app.borrow_mut(); lock.set_global(global); } + /// updates the global in this context. (panics if `has_global` would return false) pub fn update_global( &mut self, update: impl FnOnce(&mut G, &mut AppContext) -> R, @@ -230,6 +285,8 @@ impl TestAppContext { lock.update_global(update) } + /// Returns an `AsyncAppContext` which can be used to run tasks that expect to be on a background + /// thread on the current thread in tests. pub fn to_async(&self) -> AsyncAppContext { AsyncAppContext { app: Rc::downgrade(&self.app), @@ -238,6 +295,12 @@ impl TestAppContext { } } + /// Wait until there are no more pending tasks. + pub fn run_until_parked(&mut self) { + self.background_executor.run_until_parked() + } + + /// Simulate dispatching an action to the currently focused node in the window. pub fn dispatch_action(&mut self, window: AnyWindowHandle, action: A) where A: Action, @@ -251,7 +314,8 @@ impl TestAppContext { /// simulate_keystrokes takes a space-separated list of keys to type. /// cx.simulate_keystrokes("cmd-shift-p b k s p enter") - /// will run backspace on the current editor through the command palette. + /// in Zed, this will run backspace on the current editor through the command palette. + /// This will also run the background executor until it's parked. pub fn simulate_keystrokes(&mut self, window: AnyWindowHandle, keystrokes: &str) { for keystroke in keystrokes .split(" ") @@ -266,7 +330,8 @@ impl TestAppContext { /// simulate_input takes a string of text to type. /// cx.simulate_input("abc") - /// will type abc into your current editor. + /// will type abc into your current editor + /// This will also run the background executor until it's parked. pub fn simulate_input(&mut self, window: AnyWindowHandle, input: &str) { for keystroke in input.split("").map(Keystroke::parse).map(Result::unwrap) { self.dispatch_keystroke(window, keystroke.into(), false); @@ -275,6 +340,7 @@ impl TestAppContext { self.background_executor.run_until_parked() } + /// dispatches a single Keystroke (see also `simulate_keystrokes` and `simulate_input`) pub fn dispatch_keystroke( &mut self, window: AnyWindowHandle, @@ -285,6 +351,7 @@ impl TestAppContext { .simulate_keystroke(keystroke, is_held) } + /// Returns the `TestWindow` backing the given handle. pub fn test_window(&self, window: AnyWindowHandle) -> TestWindow { self.app .borrow_mut() @@ -299,6 +366,7 @@ impl TestAppContext { .clone() } + /// Returns a stream of notifications whenever the View or Model is updated. pub fn notifications(&mut self, entity: &impl Entity) -> impl Stream { let (tx, rx) = futures::channel::mpsc::unbounded(); self.update(|cx| { @@ -315,6 +383,7 @@ impl TestAppContext { rx } + /// Retuens a stream of events emitted by the given Model. pub fn events>( &mut self, entity: &Model, @@ -333,6 +402,8 @@ impl TestAppContext { rx } + /// Runs until the given condition becomes true. (Prefer `run_until_parked` if you + /// don't need to jump in at a specific time). pub async fn condition( &mut self, model: &Model, @@ -362,6 +433,7 @@ impl TestAppContext { } impl Model { + /// Block until the next event is emitted by the model, then return it. pub fn next_event(&self, cx: &mut TestAppContext) -> Evt where Evt: Send + Clone + 'static, @@ -391,6 +463,7 @@ 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 _}; @@ -417,6 +490,7 @@ impl View { } impl View { + /// Returns a future that resolves when the condition becomes true. pub fn condition( &self, cx: &TestAppContext, @@ -429,7 +503,7 @@ impl View { use postage::prelude::{Sink as _, Stream as _}; let (tx, mut rx) = postage::mpsc::channel(1024); - let timeout_duration = Duration::from_millis(100); //todo!() cx.condition_duration(); + let timeout_duration = Duration::from_millis(100); let mut cx = cx.app.borrow_mut(); let subscriptions = ( @@ -467,12 +541,11 @@ impl View { } } - // todo!(start_waiting) - // cx.borrow().foreground_executor().start_waiting(); + cx.borrow().background_executor().start_waiting(); rx.recv() .await .expect("view dropped with pending condition"); - // cx.borrow().foreground_executor().finish_waiting(); + cx.borrow().background_executor().finish_waiting(); } }) .await @@ -484,18 +557,30 @@ impl View { use derive_more::{Deref, DerefMut}; #[derive(Deref, DerefMut, Clone)] +/// A VisualTestContext is the test-equivalent of a `WindowContext`. It allows you to +/// run window-specific test code. pub struct VisualTestContext { #[deref] #[deref_mut] - cx: TestAppContext, + /// cx is the original TestAppContext (you can more easily access this using Deref) + pub cx: TestAppContext, window: AnyWindowHandle, } impl<'a> VisualTestContext { + /// Get the underlying window handle underlying this context. + pub fn handle(&self) -> AnyWindowHandle { + self.window + } + + /// Provides the `WindowContext` for the duration of the closure. pub fn update(&mut self, f: impl FnOnce(&mut WindowContext) -> R) -> R { self.cx.update_window(self.window, |_, cx| f(cx)).unwrap() } + /// Create a new VisualTestContext. You would typically shadow the passed in + /// TestAppContext with this, as this is typically more useful. + /// `let cx = VisualTestContext::from_window(window, cx);` pub fn from_window(window: AnyWindowHandle, cx: &TestAppContext) -> Self { Self { cx: cx.clone(), @@ -503,10 +588,12 @@ impl<'a> VisualTestContext { } } + /// Wait until there are no more pending tasks. pub fn run_until_parked(&self) { self.cx.background_executor.run_until_parked(); } + /// Dispatch the action to the currently focused node. pub fn dispatch_action(&mut self, action: A) where A: Action, @@ -514,24 +601,72 @@ impl<'a> VisualTestContext { self.cx.dispatch_action(self.window, action) } + /// Read the title off the window (set by `WindowContext#set_window_title`) pub fn window_title(&mut self) -> Option { self.cx.test_window(self.window).0.lock().title.clone() } + /// Simulate a sequence of keystrokes `cx.simulate_keystrokes("cmd-p escape")` + /// Automatically runs until parked. pub fn simulate_keystrokes(&mut self, keystrokes: &str) { self.cx.simulate_keystrokes(self.window, keystrokes) } + /// Simulate typing text `cx.simulate_input("hello")` + /// Automatically runs until parked. pub fn simulate_input(&mut self, input: &str) { self.cx.simulate_input(self.window, input) } + /// Simulates the user resizing the window to the new size. + pub fn simulate_resize(&self, size: Size) { + self.simulate_window_resize(self.window, size) + } + + /// debug_bounds returns the bounds of the element with the given selector. + pub fn debug_bounds(&mut self, selector: &'static str) -> Option> { + self.update(|cx| cx.window.rendered_frame.debug_bounds.get(selector).copied()) + } + + /// Draw an element to the window. Useful for simulating events or actions + pub fn draw( + &mut self, + origin: Point, + space: Size, + f: impl FnOnce(&mut WindowContext) -> AnyElement, + ) { + self.update(|cx| { + let entity_id = cx + .window + .root_view + .as_ref() + .expect("Can't draw to this window without a root view") + .entity_id(); + cx.with_view_id(entity_id, |cx| { + f(cx).draw(origin, space, cx); + }); + + cx.refresh(); + }) + } + + /// Simulate an event from the platform, e.g. a SrollWheelEvent + /// Make sure you've called [VisualTestContext::draw] first! + pub fn simulate_event(&mut self, event: E) { + self.test_window(self.window) + .simulate_input(event.to_platform_input()); + self.background_executor.run_until_parked(); + } + + /// Simulates the user blurring the window. pub fn deactivate_window(&mut self) { if Some(self.window) == self.test_platform.active_window() { self.test_platform.set_active_window(None) } self.background_executor.run_until_parked(); } + + /// Simulates the user closing the window. /// Returns true if the window was closed. pub fn simulate_close(&mut self) -> bool { let handler = self @@ -668,6 +803,7 @@ impl VisualContext for VisualTestContext { } impl AnyWindowHandle { + /// Creates the given view in this window. pub fn build_view( &self, cx: &mut TestAppContext, @@ -676,11 +812,3 @@ impl AnyWindowHandle { self.update(cx, |_, cx| cx.new_view(build_view)).unwrap() } } - -pub struct EmptyView {} - -impl Render for EmptyView { - fn render(&mut self, _cx: &mut crate::ViewContext) -> impl IntoElement { - div() - } -} diff --git a/crates/gpui/src/arena.rs b/crates/gpui/src/arena.rs index bb493a6d06..b3d7f9b0ec 100644 --- a/crates/gpui/src/arena.rs +++ b/crates/gpui/src/arena.rs @@ -66,18 +66,19 @@ impl Arena { } unsafe { - let layout = alloc::Layout::new::().pad_to_align(); - let next_offset = self.offset.add(layout.size()); - assert!(next_offset <= self.end); + let layout = alloc::Layout::new::(); + let offset = self.offset.add(self.offset.align_offset(layout.align())); + let next_offset = offset.add(layout.size()); + assert!(next_offset <= self.end, "not enough space in Arena"); let result = ArenaBox { - ptr: self.offset.cast(), + ptr: offset.cast(), valid: self.valid.clone(), }; inner_writer(result.ptr, f); self.elements.push(ArenaElement { - value: self.offset, + value: offset, drop: drop::, }); self.offset = next_offset; @@ -199,4 +200,43 @@ mod tests { arena.clear(); assert!(dropped.get()); } + + #[test] + #[should_panic(expected = "not enough space in Arena")] + fn test_arena_overflow() { + let mut arena = Arena::new(16); + arena.alloc(|| 1u64); + arena.alloc(|| 2u64); + // This should panic. + arena.alloc(|| 3u64); + } + + #[test] + fn test_arena_alignment() { + let mut arena = Arena::new(256); + let x1 = arena.alloc(|| 1u8); + let x2 = arena.alloc(|| 2u16); + let x3 = arena.alloc(|| 3u32); + let x4 = arena.alloc(|| 4u64); + let x5 = arena.alloc(|| 5u64); + + assert_eq!(*x1, 1); + assert_eq!(*x2, 2); + assert_eq!(*x3, 3); + assert_eq!(*x4, 4); + assert_eq!(*x5, 5); + + assert_eq!(x1.ptr.align_offset(std::mem::align_of_val(&*x1)), 0); + assert_eq!(x2.ptr.align_offset(std::mem::align_of_val(&*x2)), 0); + } + + #[test] + #[should_panic(expected = "attempted to dereference an ArenaRef after its Arena was cleared")] + fn test_arena_use_after_clear() { + let mut arena = Arena::new(16); + let value = arena.alloc(|| 1u64); + + arena.clear(); + let _read_value = *value; + } } diff --git a/crates/gpui/src/color.rs b/crates/gpui/src/color.rs index dc0f5055e7..23fcc25f6a 100644 --- a/crates/gpui/src/color.rs +++ b/crates/gpui/src/color.rs @@ -321,7 +321,7 @@ impl Hsla { /// /// Assumptions: /// - Alpha values are contained in the range [0, 1], with 1 as fully opaque and 0 as fully transparent. - /// - The relative contributions of `self` and `other` is based on `self`'s alpha value (`self.a`) and `other`'s alpha value (`other.a`), `self` contributing `self.a * (1.0 - other.a)` and `other` contributing it's own alpha value. + /// - The relative contributions of `self` and `other` is based on `self`'s alpha value (`self.a`) and `other`'s alpha value (`other.a`), `self` contributing `self.a * (1.0 - other.a)` and `other` contributing its own alpha value. /// - RGB color components are contained in the range [0, 1]. /// - If `self` and `other` colors are out of the valid range, the blend operation's output and behavior is undefined. pub fn blend(self, other: Hsla) -> Hsla { @@ -355,16 +355,6 @@ impl Hsla { } } -// impl From for Rgba { -// fn from(value: Hsla) -> Self { -// let h = value.h; -// let s = value.s; -// let l = value.l; - -// let c = (1 - |2L - 1|) X s -// } -// } - impl From for Hsla { fn from(color: Rgba) -> Self { let r = color.r; diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index 987b91b791..3022f9f30a 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -31,14 +31,14 @@ pub trait IntoElement: Sized { /// The specific type of element into which the implementing type is converted. type Element: Element; - /// The [ElementId] of self once converted into an [Element]. + /// The [`ElementId`] of self once converted into an [`Element`]. /// If present, the resulting element's state will be carried across frames. fn element_id(&self) -> Option; - /// Convert self into a type that implements [Element]. + /// Convert self into a type that implements [`Element`]. fn into_element(self) -> Self::Element; - /// Convert self into a dynamically-typed [AnyElement]. + /// Convert self into a dynamically-typed [`AnyElement`]. fn into_any_element(self) -> AnyElement { self.into_element().into_any() } @@ -115,7 +115,13 @@ pub trait Render: 'static + Sized { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement; } -/// You can derive [IntoElement] on any type that implements this trait. +impl Render for () { + fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { + () + } +} + +/// You can derive [`IntoElement`] on any type that implements this trait. /// It is used to allow views to be expressed in terms of abstract data. pub trait RenderOnce: 'static { fn render(self, cx: &mut WindowContext) -> impl IntoElement; @@ -224,7 +230,7 @@ enum ElementDrawPhase { }, } -/// A wrapper around an implementer of [Element] that allows it to be drawn in a window. +/// A wrapper around an implementer of [`Element`] that allows it to be drawn in a window. impl DrawableElement { fn new(element: E) -> Self { DrawableElement { diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 45097411d1..aa912eadbe 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -416,6 +416,18 @@ pub trait InteractiveElement: Sized { self } + #[cfg(any(test, feature = "test-support"))] + fn debug_selector(mut self, f: impl FnOnce() -> String) -> Self { + self.interactivity().debug_selector = Some(f()); + self + } + + #[cfg(not(any(test, feature = "test-support")))] + #[inline] + fn debug_selector(self, _: impl FnOnce() -> String) -> Self { + self + } + fn capture_any_mouse_down( mut self, listener: impl Fn(&MouseDownEvent, &mut WindowContext) + 'static, @@ -911,6 +923,9 @@ pub struct Interactivity { #[cfg(debug_assertions)] pub location: Option>, + + #[cfg(any(test, feature = "test-support"))] + pub debug_selector: Option, } #[derive(Clone, Debug)] @@ -978,12 +993,39 @@ impl Interactivity { f: impl FnOnce(&Style, Point, &mut WindowContext), ) { let style = self.compute_style(Some(bounds), element_state, cx); + let z_index = style.z_index.unwrap_or(0); + + #[cfg(any(feature = "test-support", test))] + if let Some(debug_selector) = &self.debug_selector { + cx.window + .next_frame + .debug_bounds + .insert(debug_selector.clone(), bounds); + } + + let paint_hover_group_handler = |cx: &mut WindowContext| { + let hover_group_bounds = self + .group_hover_style + .as_ref() + .and_then(|group_hover| GroupBounds::get(&group_hover.group, cx)); + + if let Some(group_bounds) = hover_group_bounds { + let hovered = group_bounds.contains(&cx.mouse_position()); + cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| { + if phase == DispatchPhase::Capture + && group_bounds.contains(&event.position) != hovered + { + cx.refresh(); + } + }); + } + }; if style.visibility == Visibility::Hidden { + cx.with_z_index(z_index, |cx| paint_hover_group_handler(cx)); return; } - let z_index = style.z_index.unwrap_or(0); cx.with_z_index(z_index, |cx| { style.paint(bounds, cx, |cx| { cx.with_text_style(style.text_style().cloned(), |cx| { @@ -1003,7 +1045,7 @@ impl Interactivity { if let Some(text) = cx .text_system() .shape_text( - &element_id, + element_id.into(), FONT_SIZE, &[cx.text_style().to_run(str_len)], None, @@ -1027,7 +1069,7 @@ impl Interactivity { if e.modifiers.command != command_held && text_bounds.contains(&cx.mouse_position()) { - cx.notify(); + cx.refresh(); } } }); @@ -1038,7 +1080,7 @@ impl Interactivity { if phase == DispatchPhase::Capture && bounds.contains(&event.position) != hovered { - cx.notify(); + cx.refresh(); } }, ); @@ -1055,22 +1097,11 @@ impl Interactivity { }; eprintln!( - "This element is created at:\n{}:{}:{}", - location.file(), + "This element was created at:\n{}:{}:{}", + dir.join(location.file()).to_string_lossy(), location.line(), location.column() ); - - std::process::Command::new("zed") - .arg(format!( - "{}/{}:{}:{}", - dir.to_string_lossy(), - location.file(), - location.line(), - location.column() - )) - .spawn() - .ok(); } } }); @@ -1177,21 +1208,7 @@ impl Interactivity { }) } - let hover_group_bounds = self - .group_hover_style - .as_ref() - .and_then(|group_hover| GroupBounds::get(&group_hover.group, cx)); - - if let Some(group_bounds) = hover_group_bounds { - let hovered = group_bounds.contains(&cx.mouse_position()); - cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| { - if phase == DispatchPhase::Capture - && group_bounds.contains(&event.position) != hovered - { - cx.notify(); - } - }); - } + paint_hover_group_handler(cx); if self.hover_style.is_some() || self.base_style.mouse_cursor.is_some() @@ -1203,7 +1220,7 @@ impl Interactivity { if phase == DispatchPhase::Capture && bounds.contains(&event.position) != hovered { - cx.notify(); + cx.refresh(); } }); } @@ -1237,7 +1254,7 @@ impl Interactivity { if can_drop { listener(drag.value.as_ref(), cx); - cx.notify(); + cx.refresh(); cx.stop_propagation(); } } @@ -1268,7 +1285,7 @@ impl Interactivity { && interactive_bounds.visibly_contains(&event.position, cx) { *pending_mouse_down.borrow_mut() = Some(event.clone()); - cx.notify(); + cx.refresh(); } } }); @@ -1299,7 +1316,7 @@ impl Interactivity { cursor_offset, }); pending_mouse_down.take(); - cx.notify(); + cx.refresh(); cx.stop_propagation(); } } @@ -1319,7 +1336,7 @@ impl Interactivity { pending_mouse_down.borrow_mut(); if pending_mouse_down.is_some() { captured_mouse_down = pending_mouse_down.take(); - cx.notify(); + cx.refresh(); } } // Fire click handlers during the bubble phase. @@ -1413,7 +1430,7 @@ impl Interactivity { _task: None, }, ); - cx.notify(); + cx.refresh(); }) .ok(); } @@ -1439,8 +1456,8 @@ impl Interactivity { .borrow() .as_ref() { - if active_tooltip.tooltip.is_some() { - cx.active_tooltip = active_tooltip.tooltip.clone() + if let Some(tooltip) = active_tooltip.tooltip.clone() { + cx.set_tooltip(tooltip); } } } @@ -1453,7 +1470,7 @@ impl Interactivity { cx.on_mouse_event(move |_: &MouseUpEvent, phase, cx| { if phase == DispatchPhase::Capture { *active_state.borrow_mut() = ElementClickedState::default(); - cx.notify(); + cx.refresh(); } }); } else { @@ -1471,7 +1488,7 @@ impl Interactivity { if group || element { *active_state.borrow_mut() = ElementClickedState { group, element }; - cx.notify(); + cx.refresh(); } } }); @@ -1531,7 +1548,7 @@ impl Interactivity { } if *scroll_offset != old_scroll_offset { - cx.notify(); + cx.refresh(); cx.stop_propagation(); } } diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index 650b5b666b..5a656db9fb 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use crate::{ point, size, BorrowWindow, Bounds, DevicePixels, Element, ImageData, InteractiveElement, - InteractiveElementState, Interactivity, IntoElement, LayoutId, Pixels, SharedString, Size, + InteractiveElementState, Interactivity, IntoElement, LayoutId, Pixels, SharedUrl, Size, StyleRefinement, Styled, WindowContext, }; use futures::FutureExt; @@ -12,13 +12,13 @@ use util::ResultExt; #[derive(Clone, Debug)] pub enum ImageSource { /// Image content will be loaded from provided URI at render time. - Uri(SharedString), + Uri(SharedUrl), Data(Arc), Surface(CVImageBuffer), } -impl From for ImageSource { - fn from(value: SharedString) -> Self { +impl From for ImageSource { + fn from(value: SharedUrl) -> Self { Self::Uri(value) } } @@ -109,7 +109,7 @@ impl Element for Img { } else { cx.spawn(|mut cx| async move { if image_future.await.ok().is_some() { - cx.on_next_frame(|cx| cx.notify()); + cx.on_next_frame(|cx| cx.refresh()); } }) .detach(); diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 2a47a16741..2921d90a10 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -30,6 +30,7 @@ struct StateInner { logical_scroll_top: Option, alignment: ListAlignment, overdraw: Pixels, + reset: bool, #[allow(clippy::type_complexity)] scroll_handler: Option>, } @@ -43,6 +44,7 @@ pub enum ListAlignment { pub struct ListScrollEvent { pub visible_range: Range, pub count: usize, + pub is_scrolled: bool, } #[derive(Clone)] @@ -91,11 +93,17 @@ impl ListState { alignment: orientation, overdraw, scroll_handler: None, + reset: false, }))) } + /// Reset this instantiation of the list state. + /// + /// Note that this will cause scroll events to be dropped until the next paint. pub fn reset(&self, element_count: usize) { let state = &mut *self.0.borrow_mut(); + state.reset = true; + state.logical_scroll_top = None; state.items = SumTree::new(); state @@ -151,11 +159,13 @@ impl ListState { scroll_top.item_ix = item_count; scroll_top.offset_in_item = px(0.); } + state.logical_scroll_top = Some(scroll_top); } pub fn scroll_to_reveal_item(&self, ix: usize) { let state = &mut *self.0.borrow_mut(); + let mut scroll_top = state.logical_scroll_top(); let height = state .last_layout_bounds @@ -186,9 +196,9 @@ impl ListState { /// Get the bounds for the given item in window coordinates. pub fn bounds_for_item(&self, ix: usize) -> Option> { let state = &*self.0.borrow(); + let bounds = state.last_layout_bounds.unwrap_or_default(); let scroll_top = state.logical_scroll_top(); - if ix < scroll_top.item_ix { return None; } @@ -229,6 +239,12 @@ impl StateInner { delta: Point, cx: &mut WindowContext, ) { + // Drop scroll events after a reset, since we can't calculate + // the new logical scroll top without the item heights + if self.reset { + return; + } + let scroll_max = (self.items.summary().height - height).max(px(0.)); let new_scroll_top = (self.scroll_top(scroll_top) - delta.y) .max(px(0.)) @@ -253,12 +269,13 @@ impl StateInner { &ListScrollEvent { visible_range, count: self.items.summary().count, + is_scrolled: self.logical_scroll_top.is_some(), }, cx, ); } - cx.notify(); + cx.refresh(); } fn logical_scroll_top(&self) -> ListOffset { @@ -323,6 +340,8 @@ impl Element for List { ) { let state = &mut *self.state.0.borrow_mut(); + state.reset = false; + // If the width of the list has changed, invalidate all cached item heights if state.last_layout_bounds.map_or(true, |last_bounds| { last_bounds.size.width != bounds.size.width @@ -344,8 +363,9 @@ impl Element for List { height: AvailableSpace::MinContent, }; - // Render items after the scroll top, including those in the trailing overdraw let mut cursor = old_items.cursor::(); + + // Render items after the scroll top, including those in the trailing overdraw cursor.seek(&Count(scroll_top.item_ix), Bias::Right, &()); for (ix, item) in cursor.by_ref().enumerate() { let visible_height = rendered_height - scroll_top.offset_in_item; @@ -459,6 +479,7 @@ impl Element for List { let list_state = self.state.clone(); let height = bounds.size.height; + cx.on_mouse_event(move |event: &ScrollWheelEvent, phase, cx| { if phase == DispatchPhase::Bubble && bounds.contains(&event.position) @@ -560,3 +581,49 @@ impl<'a> sum_tree::SeekTarget<'a, ListItemSummary, ListItemSummary> for Height { self.0.partial_cmp(&other.height).unwrap() } } + +#[cfg(test)] +mod test { + + use gpui::{ScrollDelta, ScrollWheelEvent}; + + use crate::{self as gpui, TestAppContext}; + + #[gpui::test] + fn test_reset_after_paint_before_scroll(cx: &mut TestAppContext) { + use crate::{div, list, point, px, size, Element, ListState, Styled}; + + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.), |_, _| { + div().h(px(10.)).w_full().into_any() + }); + + // Ensure that the list is scrolled to the top + state.scroll_to(gpui::ListOffset { + item_ix: 0, + offset_in_item: px(0.0), + }); + + // Paint + cx.draw( + point(px(0.), px(0.)), + size(px(100.), px(20.)).into(), + |_| list(state.clone()).w_full().h_full().z_index(10).into_any(), + ); + + // Reset + state.reset(5); + + // And then receive a scroll event _before_ the next paint + cx.simulate_event(ScrollWheelEvent { + position: point(px(1.), px(1.)), + delta: ScrollDelta::Pixels(point(px(0.), px(-500.))), + ..Default::default() + }); + + // Scroll position should stay at the top of the list + assert_eq!(state.logical_scroll_top().item_ix, 0); + assert_eq!(state.logical_scroll_top().offset_in_item, px(0.)); + } +} diff --git a/crates/gpui/src/elements/overlay.rs b/crates/gpui/src/elements/overlay.rs index eab3ee60b4..6996a13cfa 100644 --- a/crates/gpui/src/elements/overlay.rs +++ b/crates/gpui/src/elements/overlay.rs @@ -14,9 +14,8 @@ pub struct Overlay { children: SmallVec<[AnyElement; 2]>, anchor_corner: AnchorCorner, fit_mode: OverlayFitMode, - // todo!(); anchor_position: Option>, - // position_mode: OverlayPositionMode, + position_mode: OverlayPositionMode, } /// overlay gives you a floating element that will avoid overflowing the window bounds. @@ -27,6 +26,7 @@ pub fn overlay() -> Overlay { anchor_corner: AnchorCorner::TopLeft, fit_mode: OverlayFitMode::SwitchAnchor, anchor_position: None, + position_mode: OverlayPositionMode::Window, } } @@ -44,6 +44,14 @@ impl Overlay { self } + /// Sets the position mode for this overlay. Local will have this + /// interpret its [`Overlay::position`] as relative to the parent element. + /// While Window will have it interpret the position as relative to the window. + pub fn position_mode(mut self, mode: OverlayPositionMode) -> Self { + self.position_mode = mode; + self + } + /// Snap to window edge instead of switching anchor corner when an overflow would occur. pub fn snap_to_window(mut self) -> Self { self.fit_mode = OverlayFitMode::SnapToWindow; @@ -100,9 +108,14 @@ impl Element for Overlay { child_max = child_max.max(&child_bounds.lower_right()); } let size: Size = (child_max - child_min).into(); - let origin = self.anchor_position.unwrap_or(bounds.origin); - let mut desired = self.anchor_corner.get_bounds(origin, size); + let (origin, mut desired) = self.position_mode.get_position_and_bounds( + self.anchor_position, + self.anchor_corner, + size, + bounds, + ); + let limits = Bounds { origin: Point::default(), size: cx.viewport_size(), @@ -184,6 +197,35 @@ pub enum OverlayFitMode { SwitchAnchor, } +#[derive(Copy, Clone, PartialEq)] +pub enum OverlayPositionMode { + Window, + Local, +} + +impl OverlayPositionMode { + fn get_position_and_bounds( + &self, + anchor_position: Option>, + anchor_corner: AnchorCorner, + size: Size, + bounds: Bounds, + ) -> (Point, Bounds) { + match self { + OverlayPositionMode::Window => { + let anchor_position = anchor_position.unwrap_or_else(|| bounds.origin); + let bounds = anchor_corner.get_bounds(anchor_position, size); + (anchor_position, bounds) + } + OverlayPositionMode::Local => { + let anchor_position = anchor_position.unwrap_or_default(); + let bounds = anchor_corner.get_bounds(bounds.origin + anchor_position, size); + (anchor_position, bounds) + } + } + } +} + #[derive(Clone, Copy, PartialEq, Eq)] pub enum AnchorCorner { TopLeft, diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 29c93fd19e..f72b7c6fa9 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -202,7 +202,10 @@ impl TextState { let Some(lines) = cx .text_system() .shape_text( - &text, font_size, &runs, wrap_width, // Wrap if we know the width. + text.clone(), + font_size, + &runs, + wrap_width, // Wrap if we know the width. ) .log_err() else { @@ -389,7 +392,7 @@ impl Element for InteractiveText { } mouse_down.take(); - cx.notify(); + cx.refresh(); } }); } else { @@ -399,7 +402,7 @@ impl Element for InteractiveText { text_state.index_for_position(bounds, event.position) { mouse_down.set(Some(mouse_down_index)); - cx.notify(); + cx.refresh(); } } }); diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index ffa678e9e5..77ef7df10c 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -1,7 +1,7 @@ use crate::{ point, px, size, AnyElement, AvailableSpace, BorrowWindow, Bounds, ContentMask, Element, ElementId, InteractiveElement, InteractiveElementState, Interactivity, IntoElement, LayoutId, - Pixels, Point, Render, Size, StyleRefinement, Styled, View, ViewContext, WindowContext, + Pixels, Render, Size, StyleRefinement, Styled, View, ViewContext, WindowContext, }; use smallvec::SmallVec; use std::{cell::RefCell, cmp, ops::Range, rc::Rc}; @@ -64,40 +64,19 @@ pub struct UniformList { } #[derive(Clone, Default)] -pub struct UniformListScrollHandle(Rc>>); - -#[derive(Clone, Debug)] -struct ScrollHandleState { - item_height: Pixels, - list_height: Pixels, - scroll_offset: Rc>>, +pub struct UniformListScrollHandle { + deferred_scroll_to_item: Rc>>, } impl UniformListScrollHandle { pub fn new() -> Self { - Self(Rc::new(RefCell::new(None))) - } - - pub fn scroll_to_item(&self, ix: usize) { - if let Some(state) = &*self.0.borrow() { - let mut scroll_offset = state.scroll_offset.borrow_mut(); - let item_top = state.item_height * ix; - let item_bottom = item_top + state.item_height; - let scroll_top = -scroll_offset.y; - if item_top < scroll_top { - scroll_offset.y = -item_top; - } else if item_bottom > scroll_top + state.list_height { - scroll_offset.y = -(item_bottom - state.list_height); - } + Self { + deferred_scroll_to_item: Rc::new(RefCell::new(None)), } } - pub fn scroll_top(&self) -> Pixels { - if let Some(state) = &*self.0.borrow() { - -state.scroll_offset.borrow().y - } else { - Pixels::ZERO - } + pub fn scroll_to_item(&mut self, ix: usize) { + self.deferred_scroll_to_item.replace(Some(ix)); } } @@ -190,18 +169,14 @@ impl Element for UniformList { let shared_scroll_offset = element_state .interactive .scroll_offset - .get_or_insert_with(|| { - if let Some(scroll_handle) = self.scroll_handle.as_ref() { - if let Some(scroll_handle) = scroll_handle.0.borrow().as_ref() { - return scroll_handle.scroll_offset.clone(); - } - } - - Rc::default() - }) + .get_or_insert_with(|| Rc::default()) .clone(); let item_height = self.measure_item(Some(padded_bounds.size.width), cx).height; + let shared_scroll_to_item = self + .scroll_handle + .as_mut() + .and_then(|handle| handle.deferred_scroll_to_item.take()); self.interactivity.paint( bounds, @@ -228,12 +203,18 @@ impl Element for UniformList { scroll_offset.y = min_scroll_offset; } - if let Some(scroll_handle) = self.scroll_handle.clone() { - scroll_handle.0.borrow_mut().replace(ScrollHandleState { - item_height, - list_height: padded_bounds.size.height, - scroll_offset: shared_scroll_offset, - }); + if let Some(ix) = shared_scroll_to_item { + let list_height = padded_bounds.size.height; + let mut updated_scroll_offset = shared_scroll_offset.borrow_mut(); + let item_top = item_height * ix; + let item_bottom = item_top + item_height; + let scroll_top = -updated_scroll_offset.y; + if item_top < scroll_top { + updated_scroll_offset.y = -item_top; + } else if item_bottom > scroll_top + list_height { + updated_scroll_offset.y = -(item_bottom - list_height); + } + scroll_offset = *updated_scroll_offset; } let first_visible_element_ix = diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 589493b4bb..9ed643a5ab 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -32,6 +32,12 @@ pub struct ForegroundExecutor { not_send: PhantomData>, } +/// Task is a primitive that allows work to happen in the background. +/// +/// It implements [`Future`] so you can `.await` on it. +/// +/// If you drop a task it will be cancelled immediately. Calling [`Task::detach`] allows +/// the task to continue running in the background, but with no way to return a value. #[must_use] #[derive(Debug)] pub enum Task { @@ -40,10 +46,12 @@ pub enum Task { } impl Task { + /// Create a new task that will resolve with the value pub fn ready(val: T) -> Self { Task::Ready(Some(val)) } + /// Detaching a task runs it to completion in the background pub fn detach(self) { match self { Task::Ready(_) => {} @@ -57,6 +65,8 @@ where T: 'static, E: 'static + Debug, { + /// Run the task to completion in the background and log any + /// errors that occur. #[track_caller] pub fn detach_and_log_err(self, cx: &mut AppContext) { let location = core::panic::Location::caller(); @@ -97,7 +107,12 @@ type AnyLocalFuture = Pin>>; type AnyFuture = Pin>>; +/// BackgroundExecutor lets you run things on background threads. +/// In production this is a thread pool with no ordering guarantees. +/// In tests this is simulated by running tasks one by one in a deterministic +/// (but arbitrary) order controlled by the `SEED` environment variable. impl BackgroundExecutor { + #[doc(hidden)] pub fn new(dispatcher: Arc) -> Self { Self { dispatcher } } @@ -135,6 +150,7 @@ impl BackgroundExecutor { Task::Spawned(task) } + /// Used by the test harness to run an async test in a synchronous fashion. #[cfg(any(test, feature = "test-support"))] #[track_caller] pub fn block_test(&self, future: impl Future) -> R { @@ -145,6 +161,8 @@ impl BackgroundExecutor { } } + /// Block the current thread until the given future resolves. + /// Consider using `block_with_timeout` instead. pub fn block(&self, future: impl Future) -> R { if let Ok(value) = self.block_internal(true, future, usize::MAX) { value @@ -206,6 +224,8 @@ impl BackgroundExecutor { } } + /// Block the current thread until the given future resolves + /// or `duration` has elapsed. pub fn block_with_timeout( &self, duration: Duration, @@ -238,6 +258,8 @@ impl BackgroundExecutor { } } + /// Scoped lets you start a number of tasks and waits + /// for all of them to complete before returning. pub async fn scoped<'scope, F>(&self, scheduler: F) where F: FnOnce(&mut Scope<'scope>), @@ -253,6 +275,9 @@ impl BackgroundExecutor { } } + /// Returns a task that will complete after the given duration. + /// Depending on other concurrent tasks the elapsed duration may be longer + /// than requested. pub fn timer(&self, duration: Duration) -> Task<()> { let (runnable, task) = async_task::spawn(async move {}, { let dispatcher = self.dispatcher.clone(); @@ -262,65 +287,81 @@ impl BackgroundExecutor { Task::Spawned(task) } + /// in tests, start_waiting lets you indicate which task is waiting (for debugging only) #[cfg(any(test, feature = "test-support"))] pub fn start_waiting(&self) { self.dispatcher.as_test().unwrap().start_waiting(); } + /// in tests, removes the debugging data added by start_waiting #[cfg(any(test, feature = "test-support"))] pub fn finish_waiting(&self) { self.dispatcher.as_test().unwrap().finish_waiting(); } + /// in tests, run an arbitrary number of tasks (determined by the SEED environment variable) #[cfg(any(test, feature = "test-support"))] pub fn simulate_random_delay(&self) -> impl Future { self.dispatcher.as_test().unwrap().simulate_random_delay() } + /// in tests, indicate that a given task from `spawn_labeled` should run after everything else #[cfg(any(test, feature = "test-support"))] pub fn deprioritize(&self, task_label: TaskLabel) { self.dispatcher.as_test().unwrap().deprioritize(task_label) } + /// in tests, move time forward. This does not run any tasks, but does make `timer`s ready. #[cfg(any(test, feature = "test-support"))] pub fn advance_clock(&self, duration: Duration) { self.dispatcher.as_test().unwrap().advance_clock(duration) } + /// in tests, run one task. #[cfg(any(test, feature = "test-support"))] pub fn tick(&self) -> bool { self.dispatcher.as_test().unwrap().tick(false) } + /// in tests, run all tasks that are ready to run. If after doing so + /// the test still has outstanding tasks, this will panic. (See also `allow_parking`) #[cfg(any(test, feature = "test-support"))] pub fn run_until_parked(&self) { self.dispatcher.as_test().unwrap().run_until_parked() } + /// in tests, prevents `run_until_parked` from panicking if there are outstanding tasks. + /// This is useful when you are integrating other (non-GPUI) futures, like disk access, that + /// do take real async time to run. #[cfg(any(test, feature = "test-support"))] pub fn allow_parking(&self) { self.dispatcher.as_test().unwrap().allow_parking(); } + /// in tests, returns the rng used by the dispatcher and seeded by the `SEED` environment variable #[cfg(any(test, feature = "test-support"))] pub fn rng(&self) -> StdRng { self.dispatcher.as_test().unwrap().rng() } + /// How many CPUs are available to the dispatcher pub fn num_cpus(&self) -> usize { num_cpus::get() } + /// Whether we're on the main thread. pub fn is_main_thread(&self) -> bool { self.dispatcher.is_main_thread() } #[cfg(any(test, feature = "test-support"))] + /// in tests, control the number of ticks that `block_with_timeout` will run before timing out. pub fn set_block_on_ticks(&self, range: std::ops::RangeInclusive) { self.dispatcher.as_test().unwrap().set_block_on_ticks(range); } } +/// ForegroundExecutor runs things on the main thread. impl ForegroundExecutor { pub fn new(dispatcher: Arc) -> Self { Self { @@ -329,8 +370,7 @@ impl ForegroundExecutor { } } - /// Enqueues the given closure to be run on any thread. The closure returns - /// a future which will be run to completion on any available thread. + /// Enqueues the given Task to run on the main thread at some point in the future. pub fn spawn(&self, future: impl Future + 'static) -> Task where R: 'static, @@ -350,6 +390,7 @@ impl ForegroundExecutor { } } +/// Scope manages a set of tasks that are enqueued and waited on together. See [`BackgroundExecutor::scoped`]. pub struct Scope<'a> { executor: BackgroundExecutor, futures: Vec + Send + 'static>>>, diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index a50de8c344..89e47994a3 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -2272,7 +2272,7 @@ impl From for GlobalPixels { /// For example, if the root element's font-size is `16px`, then `1rem` equals `16px`. A length of `2rems` would then be `32px`. /// /// [set_rem_size]: crate::WindowContext::set_rem_size -#[derive(Clone, Copy, Default, Add, Sub, Mul, Div, Neg)] +#[derive(Clone, Copy, Default, Add, Sub, Mul, Div, Neg, PartialEq)] pub struct Rems(pub f32); impl Mul for Rems { @@ -2295,7 +2295,7 @@ impl Debug for Rems { /// affected by the current font size, or a number of rems, which is relative to the font size of /// the root element. It is used for specifying dimensions that are either independent of or /// related to the typographic scale. -#[derive(Clone, Copy, Debug, Neg)] +#[derive(Clone, Copy, Debug, Neg, PartialEq)] pub enum AbsoluteLength { /// A length in pixels. Pixels(Pixels), @@ -2366,7 +2366,7 @@ impl Default for AbsoluteLength { /// This enum represents lengths that have a specific value, as opposed to lengths that are automatically /// determined by the context. It includes absolute lengths in pixels or rems, and relative lengths as a /// fraction of the parent's size. -#[derive(Clone, Copy, Neg)] +#[derive(Clone, Copy, Neg, PartialEq)] pub enum DefiniteLength { /// An absolute length specified in pixels or rems. Absolute(AbsoluteLength), diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index d5236d8f08..6f5e30149d 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -18,6 +18,7 @@ mod platform; pub mod prelude; mod scene; mod shared_string; +mod shared_url; mod style; mod styled; mod subscription; @@ -67,6 +68,7 @@ pub use refineable::*; pub use scene::*; use seal::Sealed; pub use shared_string::*; +pub use shared_url::*; pub use smol::Timer; pub use style::*; pub use styled::*; diff --git a/crates/gpui/src/image_cache.rs b/crates/gpui/src/image_cache.rs index f80b0f0c2f..0d6ec81557 100644 --- a/crates/gpui/src/image_cache.rs +++ b/crates/gpui/src/image_cache.rs @@ -1,4 +1,4 @@ -use crate::{ImageData, ImageId, SharedString}; +use crate::{ImageData, ImageId, SharedUrl}; use collections::HashMap; use futures::{ future::{BoxFuture, Shared}, @@ -44,7 +44,7 @@ impl From for Error { pub struct ImageCache { client: Arc, - images: Arc>>, + images: Arc>>, } type FetchImageFuture = Shared, Error>>>; @@ -59,7 +59,7 @@ impl ImageCache { pub fn get( &self, - uri: impl Into, + uri: impl Into, ) -> Shared, Error>>> { let uri = uri.into(); let mut images = self.images.lock(); diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index da240a77a8..7290b48abd 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -34,7 +34,7 @@ pub trait InputHandler: 'static + Sized { ) -> Option>; } -/// The canonical implementation of `PlatformInputHandler`. Call `WindowContext::handle_input` +/// The canonical implementation of [`PlatformInputHandler`]. Call [`WindowContext::handle_input`] /// with an instance during your element's paint. pub struct ElementInputHandler { view: View, diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 6f396d31aa..86e0a6378e 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -1,8 +1,14 @@ use crate::{ - div, point, Element, IntoElement, Keystroke, Modifiers, Pixels, Point, Render, ViewContext, + point, seal::Sealed, IntoElement, Keystroke, Modifiers, Pixels, Point, Render, ViewContext, }; use smallvec::SmallVec; -use std::{any::Any, fmt::Debug, marker::PhantomData, ops::Deref, path::PathBuf}; +use std::{any::Any, fmt::Debug, ops::Deref, path::PathBuf}; + +pub trait InputEvent: Sealed + 'static { + fn to_platform_input(self) -> PlatformInput; +} +pub trait KeyEvent: InputEvent {} +pub trait MouseEvent: InputEvent {} #[derive(Clone, Debug, Eq, PartialEq)] pub struct KeyDownEvent { @@ -10,16 +16,40 @@ pub struct KeyDownEvent { pub is_held: bool, } +impl Sealed for KeyDownEvent {} +impl InputEvent for KeyDownEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::KeyDown(self) + } +} +impl KeyEvent for KeyDownEvent {} + #[derive(Clone, Debug)] pub struct KeyUpEvent { pub keystroke: Keystroke, } +impl Sealed for KeyUpEvent {} +impl InputEvent for KeyUpEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::KeyUp(self) + } +} +impl KeyEvent for KeyUpEvent {} + #[derive(Clone, Debug, Default)] pub struct ModifiersChangedEvent { pub modifiers: Modifiers, } +impl Sealed for ModifiersChangedEvent {} +impl InputEvent for ModifiersChangedEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::ModifiersChanged(self) + } +} +impl KeyEvent for ModifiersChangedEvent {} + impl Deref for ModifiersChangedEvent { type Target = Modifiers; @@ -30,9 +60,10 @@ impl Deref for ModifiersChangedEvent { /// The phase of a touch motion event. /// Based on the winit enum of the same name. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Default)] pub enum TouchPhase { Started, + #[default] Moved, Ended, } @@ -45,6 +76,14 @@ pub struct MouseDownEvent { pub click_count: usize, } +impl Sealed for MouseDownEvent {} +impl InputEvent for MouseDownEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::MouseDown(self) + } +} +impl MouseEvent for MouseDownEvent {} + #[derive(Clone, Debug, Default)] pub struct MouseUpEvent { pub button: MouseButton, @@ -53,38 +92,20 @@ pub struct MouseUpEvent { pub click_count: usize, } +impl Sealed for MouseUpEvent {} +impl InputEvent for MouseUpEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::MouseUp(self) + } +} +impl MouseEvent for MouseUpEvent {} + #[derive(Clone, Debug, Default)] pub struct ClickEvent { pub down: MouseDownEvent, pub up: MouseUpEvent, } -pub struct Drag -where - R: Fn(&mut V, &mut ViewContext) -> E, - V: 'static, - E: IntoElement, -{ - pub state: S, - pub render_drag_handle: R, - view_element_types: PhantomData<(V, E)>, -} - -impl Drag -where - R: Fn(&mut V, &mut ViewContext) -> E, - V: 'static, - E: Element, -{ - pub fn new(state: S, render_drag_handle: R) -> Self { - Drag { - state, - render_drag_handle, - view_element_types: Default::default(), - } - } -} - #[derive(Hash, PartialEq, Eq, Copy, Clone, Debug)] pub enum MouseButton { Left, @@ -130,13 +151,21 @@ pub struct MouseMoveEvent { pub modifiers: Modifiers, } +impl Sealed for MouseMoveEvent {} +impl InputEvent for MouseMoveEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::MouseMove(self) + } +} +impl MouseEvent for MouseMoveEvent {} + impl MouseMoveEvent { pub fn dragging(&self) -> bool { self.pressed_button == Some(MouseButton::Left) } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct ScrollWheelEvent { pub position: Point, pub delta: ScrollDelta, @@ -144,6 +173,14 @@ pub struct ScrollWheelEvent { pub touch_phase: TouchPhase, } +impl Sealed for ScrollWheelEvent {} +impl InputEvent for ScrollWheelEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::ScrollWheel(self) + } +} +impl MouseEvent for ScrollWheelEvent {} + impl Deref for ScrollWheelEvent { type Target = Modifiers; @@ -178,6 +215,20 @@ impl ScrollDelta { ScrollDelta::Lines(delta) => point(line_height * delta.x, line_height * delta.y), } } + + pub fn coalesce(self, other: ScrollDelta) -> ScrollDelta { + match (self, other) { + (ScrollDelta::Pixels(px_a), ScrollDelta::Pixels(px_b)) => { + ScrollDelta::Pixels(px_a + px_b) + } + + (ScrollDelta::Lines(lines_a), ScrollDelta::Lines(lines_b)) => { + ScrollDelta::Lines(lines_a + lines_b) + } + + _ => other, + } + } } #[derive(Clone, Debug, Default)] @@ -187,6 +238,14 @@ pub struct MouseExitEvent { pub modifiers: Modifiers, } +impl Sealed for MouseExitEvent {} +impl InputEvent for MouseExitEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::MouseExited(self) + } +} +impl MouseEvent for MouseExitEvent {} + impl Deref for MouseExitEvent { type Target = Modifiers; @@ -206,7 +265,7 @@ impl ExternalPaths { impl Render for ExternalPaths { fn render(&mut self, _: &mut ViewContext) -> impl IntoElement { - div() // Intentionally left empty because the platform will render icons for the dragged files + () // Intentionally left empty because the platform will render icons for the dragged files } } @@ -225,8 +284,16 @@ pub enum FileDropEvent { Exited, } +impl Sealed for FileDropEvent {} +impl InputEvent for FileDropEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::FileDrop(self) + } +} +impl MouseEvent for FileDropEvent {} + #[derive(Clone, Debug)] -pub enum InputEvent { +pub enum PlatformInput { KeyDown(KeyDownEvent), KeyUp(KeyUpEvent), ModifiersChanged(ModifiersChangedEvent), @@ -238,19 +305,19 @@ pub enum InputEvent { FileDrop(FileDropEvent), } -impl InputEvent { +impl PlatformInput { pub fn position(&self) -> Option> { match self { - InputEvent::KeyDown { .. } => None, - InputEvent::KeyUp { .. } => None, - InputEvent::ModifiersChanged { .. } => None, - InputEvent::MouseDown(event) => Some(event.position), - InputEvent::MouseUp(event) => Some(event.position), - InputEvent::MouseMove(event) => Some(event.position), - InputEvent::MouseExited(event) => Some(event.position), - InputEvent::ScrollWheel(event) => Some(event.position), - InputEvent::FileDrop(FileDropEvent::Exited) => None, - InputEvent::FileDrop( + PlatformInput::KeyDown { .. } => None, + PlatformInput::KeyUp { .. } => None, + PlatformInput::ModifiersChanged { .. } => None, + PlatformInput::MouseDown(event) => Some(event.position), + PlatformInput::MouseUp(event) => Some(event.position), + PlatformInput::MouseMove(event) => Some(event.position), + PlatformInput::MouseExited(event) => Some(event.position), + PlatformInput::ScrollWheel(event) => Some(event.position), + PlatformInput::FileDrop(FileDropEvent::Exited) => None, + PlatformInput::FileDrop( FileDropEvent::Entered { position, .. } | FileDropEvent::Pending { position, .. } | FileDropEvent::Submit { position, .. }, @@ -260,29 +327,29 @@ impl InputEvent { pub fn mouse_event(&self) -> Option<&dyn Any> { match self { - InputEvent::KeyDown { .. } => None, - InputEvent::KeyUp { .. } => None, - InputEvent::ModifiersChanged { .. } => None, - InputEvent::MouseDown(event) => Some(event), - InputEvent::MouseUp(event) => Some(event), - InputEvent::MouseMove(event) => Some(event), - InputEvent::MouseExited(event) => Some(event), - InputEvent::ScrollWheel(event) => Some(event), - InputEvent::FileDrop(event) => Some(event), + PlatformInput::KeyDown { .. } => None, + PlatformInput::KeyUp { .. } => None, + PlatformInput::ModifiersChanged { .. } => None, + PlatformInput::MouseDown(event) => Some(event), + PlatformInput::MouseUp(event) => Some(event), + PlatformInput::MouseMove(event) => Some(event), + PlatformInput::MouseExited(event) => Some(event), + PlatformInput::ScrollWheel(event) => Some(event), + PlatformInput::FileDrop(event) => Some(event), } } pub fn keyboard_event(&self) -> Option<&dyn Any> { match self { - InputEvent::KeyDown(event) => Some(event), - InputEvent::KeyUp(event) => Some(event), - InputEvent::ModifiersChanged(event) => Some(event), - InputEvent::MouseDown(_) => None, - InputEvent::MouseUp(_) => None, - InputEvent::MouseMove(_) => None, - InputEvent::MouseExited(_) => None, - InputEvent::ScrollWheel(_) => None, - InputEvent::FileDrop(_) => None, + PlatformInput::KeyDown(event) => Some(event), + PlatformInput::KeyUp(event) => Some(event), + PlatformInput::ModifiersChanged(event) => Some(event), + PlatformInput::MouseDown(_) => None, + PlatformInput::MouseUp(_) => None, + PlatformInput::MouseMove(_) => None, + PlatformInput::MouseExited(_) => None, + PlatformInput::ScrollWheel(_) => None, + PlatformInput::FileDrop(_) => None, } } } diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index 22c4dffc03..85a67168e5 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -1,12 +1,13 @@ use crate::{ - arena::ArenaRef, Action, ActionRegistry, DispatchPhase, FocusId, KeyBinding, KeyContext, - KeyMatch, Keymap, Keystroke, KeystrokeMatcher, WindowContext, + Action, ActionRegistry, DispatchPhase, EntityId, FocusId, KeyBinding, KeyContext, KeyMatch, + Keymap, Keystroke, KeystrokeMatcher, WindowContext, }; -use collections::HashMap; +use collections::FxHashMap; use parking_lot::Mutex; -use smallvec::SmallVec; +use smallvec::{smallvec, SmallVec}; use std::{ any::{Any, TypeId}, + mem, rc::Rc, sync::Arc, }; @@ -18,8 +19,9 @@ pub(crate) struct DispatchTree { node_stack: Vec, pub(crate) context_stack: Vec, nodes: Vec, - focusable_node_ids: HashMap, - keystroke_matchers: HashMap, KeystrokeMatcher>, + focusable_node_ids: FxHashMap, + view_node_ids: FxHashMap, + keystroke_matchers: FxHashMap, KeystrokeMatcher>, keymap: Arc>, action_registry: Rc, } @@ -30,15 +32,16 @@ pub(crate) struct DispatchNode { pub action_listeners: Vec, pub context: Option, focus_id: Option, + view_id: Option, parent: Option, } -type KeyListener = ArenaRef; +type KeyListener = Rc; #[derive(Clone)] pub(crate) struct DispatchActionListener { pub(crate) action_type: TypeId, - pub(crate) listener: ArenaRef, + pub(crate) listener: Rc, } impl DispatchTree { @@ -47,8 +50,9 @@ impl DispatchTree { node_stack: Vec::new(), context_stack: Vec::new(), nodes: Vec::new(), - focusable_node_ids: HashMap::default(), - keystroke_matchers: HashMap::default(), + focusable_node_ids: FxHashMap::default(), + view_node_ids: FxHashMap::default(), + keystroke_matchers: FxHashMap::default(), keymap, action_registry, } @@ -56,31 +60,101 @@ impl DispatchTree { pub fn clear(&mut self) { self.node_stack.clear(); - self.nodes.clear(); self.context_stack.clear(); + self.nodes.clear(); self.focusable_node_ids.clear(); + self.view_node_ids.clear(); self.keystroke_matchers.clear(); } - pub fn push_node(&mut self, context: Option) { + pub fn push_node( + &mut self, + context: Option, + focus_id: Option, + view_id: Option, + ) { let parent = self.node_stack.last().copied(); let node_id = DispatchNodeId(self.nodes.len()); self.nodes.push(DispatchNode { parent, + focus_id, + view_id, ..Default::default() }); self.node_stack.push(node_id); + if let Some(context) = context { self.active_node().context = Some(context.clone()); self.context_stack.push(context); } + + if let Some(focus_id) = focus_id { + self.focusable_node_ids.insert(focus_id, node_id); + } + + if let Some(view_id) = view_id { + self.view_node_ids.insert(view_id, node_id); + } } pub fn pop_node(&mut self) { - let node_id = self.node_stack.pop().unwrap(); - if self.nodes[node_id.0].context.is_some() { + let node = &self.nodes[self.active_node_id().0]; + if node.context.is_some() { self.context_stack.pop(); } + self.node_stack.pop(); + } + + fn move_node(&mut self, source: &mut DispatchNode) { + self.push_node(source.context.take(), source.focus_id, source.view_id); + let target = self.active_node(); + target.key_listeners = mem::take(&mut source.key_listeners); + target.action_listeners = mem::take(&mut source.action_listeners); + } + + pub fn reuse_view(&mut self, view_id: EntityId, source: &mut Self) -> SmallVec<[EntityId; 8]> { + let view_source_node_id = source + .view_node_ids + .get(&view_id) + .expect("view should exist in previous dispatch tree"); + let view_source_node = &mut source.nodes[view_source_node_id.0]; + self.move_node(view_source_node); + + let mut grafted_view_ids = smallvec![view_id]; + let mut source_stack = vec![*view_source_node_id]; + for (source_node_id, source_node) in source + .nodes + .iter_mut() + .enumerate() + .skip(view_source_node_id.0 + 1) + { + let source_node_id = DispatchNodeId(source_node_id); + while let Some(source_ancestor) = source_stack.last() { + if source_node.parent != Some(*source_ancestor) { + source_stack.pop(); + self.pop_node(); + } else { + break; + } + } + + if source_stack.is_empty() { + break; + } else { + source_stack.push(source_node_id); + self.move_node(source_node); + if let Some(view_id) = source_node.view_id { + grafted_view_ids.push(view_id); + } + } + } + + while !source_stack.is_empty() { + source_stack.pop(); + self.pop_node(); + } + + grafted_view_ids } pub fn clear_pending_keystrokes(&mut self) { @@ -117,7 +191,7 @@ impl DispatchTree { pub fn on_action( &mut self, action_type: TypeId, - listener: ArenaRef, + listener: Rc, ) { self.active_node() .action_listeners @@ -127,12 +201,6 @@ impl DispatchTree { }); } - pub fn make_focusable(&mut self, focus_id: FocusId) { - let node_id = self.active_node_id(); - self.active_node().focus_id = Some(focus_id); - self.focusable_node_ids.insert(focus_id, node_id); - } - pub fn focus_contains(&self, parent: FocusId, child: FocusId) -> bool { if parent == child { return true; @@ -192,8 +260,8 @@ impl DispatchTree { keymap .bindings_for_action(action) .filter(|binding| { - for i in 1..context_stack.len() { - let context = &context_stack[0..i]; + for i in 0..context_stack.len() { + let context = &context_stack[0..=i]; if keymap.binding_enabled(binding, context) { return true; } @@ -261,6 +329,20 @@ impl DispatchTree { focus_path } + pub fn view_path(&self, view_id: EntityId) -> SmallVec<[EntityId; 8]> { + let mut view_path: SmallVec<[EntityId; 8]> = SmallVec::new(); + let mut current_node_id = self.view_node_ids.get(&view_id).copied(); + while let Some(node_id) = current_node_id { + let node = self.node(node_id); + if let Some(view_id) = node.view_id { + view_path.push(view_id); + } + current_node_id = node.parent; + } + view_path.reverse(); // Reverse the path so it goes from the root to the view node. + view_path + } + pub fn node(&self, node_id: DispatchNodeId) -> &DispatchNode { &self.nodes[node_id.0] } @@ -283,3 +365,76 @@ impl DispatchTree { *self.node_stack.last().unwrap() } } + +#[cfg(test)] +mod tests { + use std::{rc::Rc, sync::Arc}; + + use parking_lot::Mutex; + + use crate::{Action, ActionRegistry, DispatchTree, KeyBinding, KeyContext, Keymap}; + + #[derive(PartialEq, Eq)] + struct TestAction; + + impl Action for TestAction { + fn name(&self) -> &'static str { + "test::TestAction" + } + + fn debug_name() -> &'static str + where + Self: ::std::marker::Sized, + { + "test::TestAction" + } + + fn partial_eq(&self, action: &dyn Action) -> bool { + action + .as_any() + .downcast_ref::() + .map_or(false, |a| self == a) + } + + fn boxed_clone(&self) -> std::boxed::Box { + Box::new(TestAction) + } + + fn as_any(&self) -> &dyn ::std::any::Any { + self + } + + fn build(_value: serde_json::Value) -> anyhow::Result> + where + Self: Sized, + { + Ok(Box::new(TestAction)) + } + } + + #[test] + fn test_keybinding_for_action_bounds() { + let keymap = Keymap::new(vec![KeyBinding::new( + "cmd-n", + TestAction, + Some("ProjectPanel"), + )]); + + let mut registry = ActionRegistry::default(); + + registry.load_action::(); + + let keymap = Arc::new(Mutex::new(keymap)); + + let tree = DispatchTree::new(keymap, Rc::new(registry)); + + let contexts = vec![ + KeyContext::parse("Workspace").unwrap(), + KeyContext::parse("ProjectPanel").unwrap(), + ]; + + let keybinding = tree.bindings_for_action(&TestAction, &contexts); + + assert!(keybinding[0].action.partial_eq(&TestAction)) + } +} diff --git a/crates/gpui/src/keymap/matcher.rs b/crates/gpui/src/keymap/matcher.rs index ab42f1278c..934becb0d1 100644 --- a/crates/gpui/src/keymap/matcher.rs +++ b/crates/gpui/src/keymap/matcher.rs @@ -28,11 +28,11 @@ impl KeystrokeMatcher { /// Pushes a keystroke onto the matcher. /// The result of the new keystroke is returned: - /// KeyMatch::None => + /// - KeyMatch::None => /// No match is valid for this key given any pending keystrokes. - /// KeyMatch::Pending => + /// - KeyMatch::Pending => /// There exist bindings which are still waiting for more keys. - /// KeyMatch::Complete(matches) => + /// - KeyMatch::Complete(matches) => /// One or more bindings have received the necessary key presses. /// Bindings added later will take precedence over earlier bindings. pub fn match_keystroke( @@ -77,12 +77,10 @@ impl KeystrokeMatcher { if let Some(pending_key) = pending_key { self.pending_keystrokes.push(pending_key); - } - - if self.pending_keystrokes.is_empty() { - KeyMatch::None - } else { KeyMatch::Pending + } else { + self.pending_keystrokes.clear(); + KeyMatch::None } } } @@ -98,367 +96,373 @@ impl KeyMatch { pub fn is_some(&self) -> bool { matches!(self, KeyMatch::Some(_)) } + + pub fn matches(self) -> Option>> { + match self { + KeyMatch::Some(matches) => Some(matches), + _ => None, + } + } } -// #[cfg(test)] -// mod tests { -// use anyhow::Result; -// use serde::Deserialize; +impl PartialEq for KeyMatch { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (KeyMatch::None, KeyMatch::None) => true, + (KeyMatch::Pending, KeyMatch::Pending) => true, + (KeyMatch::Some(a), KeyMatch::Some(b)) => { + if a.len() != b.len() { + return false; + } -// use crate::{actions, impl_actions, keymap_matcher::ActionContext}; + for (a, b) in a.iter().zip(b.iter()) { + if !a.partial_eq(b.as_ref()) { + return false; + } + } -// use super::*; + true + } + _ => false, + } + } +} -// #[test] -// fn test_keymap_and_view_ordering() -> Result<()> { -// actions!(test, [EditorAction, ProjectPanelAction]); +#[cfg(test)] +mod tests { -// let mut editor = ActionContext::default(); -// editor.add_identifier("Editor"); + use serde_derive::Deserialize; -// let mut project_panel = ActionContext::default(); -// project_panel.add_identifier("ProjectPanel"); + use super::*; + use crate::{self as gpui, KeyBindingContextPredicate, Modifiers}; + use crate::{actions, KeyBinding}; -// // Editor 'deeper' in than project panel -// let dispatch_path = vec![(2, editor), (1, project_panel)]; + #[test] + fn test_keymap_and_view_ordering() { + actions!(test, [EditorAction, ProjectPanelAction]); -// // But editor actions 'higher' up in keymap -// let keymap = Keymap::new(vec![ -// Binding::new("left", EditorAction, Some("Editor")), -// Binding::new("left", ProjectPanelAction, Some("ProjectPanel")), -// ]); + let mut editor = KeyContext::default(); + editor.add("Editor"); -// let mut matcher = KeymapMatcher::new(keymap); + let mut project_panel = KeyContext::default(); + project_panel.add("ProjectPanel"); -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("left")?, dispatch_path.clone()), -// KeyMatch::Matches(vec![ -// (2, Box::new(EditorAction)), -// (1, Box::new(ProjectPanelAction)), -// ]), -// ); + // Editor 'deeper' in than project panel + let dispatch_path = vec![project_panel, editor]; -// Ok(()) -// } + // But editor actions 'higher' up in keymap + let keymap = Keymap::new(vec![ + KeyBinding::new("left", EditorAction, Some("Editor")), + KeyBinding::new("left", ProjectPanelAction, Some("ProjectPanel")), + ]); -// #[test] -// fn test_push_keystroke() -> Result<()> { -// actions!(test, [B, AB, C, D, DA, E, EF]); + let mut matcher = KeystrokeMatcher::new(Arc::new(Mutex::new(keymap))); -// let mut context1 = ActionContext::default(); -// context1.add_identifier("1"); + let matches = matcher + .match_keystroke(&Keystroke::parse("left").unwrap(), &dispatch_path) + .matches() + .unwrap(); -// let mut context2 = ActionContext::default(); -// context2.add_identifier("2"); + assert!(matches[0].partial_eq(&EditorAction)); + assert!(matches.get(1).is_none()); + } -// let dispatch_path = vec![(2, context2), (1, context1)]; + #[test] + fn test_multi_keystroke_match() { + actions!(test, [B, AB, C, D, DA, E, EF]); -// let keymap = Keymap::new(vec![ -// Binding::new("a b", AB, Some("1")), -// Binding::new("b", B, Some("2")), -// Binding::new("c", C, Some("2")), -// Binding::new("d", D, Some("1")), -// Binding::new("d", D, Some("2")), -// Binding::new("d a", DA, Some("2")), -// ]); + let mut context1 = KeyContext::default(); + context1.add("1"); -// let mut matcher = KeymapMatcher::new(keymap); + let mut context2 = KeyContext::default(); + context2.add("2"); -// // Binding with pending prefix always takes precedence -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("a")?, dispatch_path.clone()), -// KeyMatch::Pending, -// ); -// // B alone doesn't match because a was pending, so AB is returned instead -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("b")?, dispatch_path.clone()), -// KeyMatch::Matches(vec![(1, Box::new(AB))]), -// ); -// assert!(!matcher.has_pending_keystrokes()); + let dispatch_path = vec![context2, context1]; -// // Without an a prefix, B is dispatched like expected -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("b")?, dispatch_path.clone()), -// KeyMatch::Matches(vec![(2, Box::new(B))]), -// ); -// assert!(!matcher.has_pending_keystrokes()); + let keymap = Keymap::new(vec![ + KeyBinding::new("a b", AB, Some("1")), + KeyBinding::new("b", B, Some("2")), + KeyBinding::new("c", C, Some("2")), + KeyBinding::new("d", D, Some("1")), + KeyBinding::new("d", D, Some("2")), + KeyBinding::new("d a", DA, Some("2")), + ]); -// // If a is prefixed, C will not be dispatched because there -// // was a pending binding for it -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("a")?, dispatch_path.clone()), -// KeyMatch::Pending, -// ); -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("c")?, dispatch_path.clone()), -// KeyMatch::None, -// ); -// assert!(!matcher.has_pending_keystrokes()); + let mut matcher = KeystrokeMatcher::new(Arc::new(Mutex::new(keymap))); -// // If a single keystroke matches multiple bindings in the tree -// // all of them are returned so that we can fallback if the action -// // handler decides to propagate the action -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("d")?, dispatch_path.clone()), -// KeyMatch::Matches(vec![(2, Box::new(D)), (1, Box::new(D))]), -// ); + // Binding with pending prefix always takes precedence + assert_eq!( + matcher.match_keystroke(&Keystroke::parse("a").unwrap(), &dispatch_path), + KeyMatch::Pending, + ); + // B alone doesn't match because a was pending, so AB is returned instead + assert_eq!( + matcher.match_keystroke(&Keystroke::parse("b").unwrap(), &dispatch_path), + KeyMatch::Some(vec![Box::new(AB)]), + ); + assert!(!matcher.has_pending_keystrokes()); -// // If none of the d action handlers consume the binding, a pending -// // binding may then be used -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("a")?, dispatch_path.clone()), -// KeyMatch::Matches(vec![(2, Box::new(DA))]), -// ); -// assert!(!matcher.has_pending_keystrokes()); + // Without an a prefix, B is dispatched like expected + assert_eq!( + matcher.match_keystroke(&Keystroke::parse("b").unwrap(), &dispatch_path[0..1]), + KeyMatch::Some(vec![Box::new(B)]), + ); + assert!(!matcher.has_pending_keystrokes()); -// Ok(()) -// } + // If a is prefixed, C will not be dispatched because there + // was a pending binding for it + assert_eq!( + matcher.match_keystroke(&Keystroke::parse("a").unwrap(), &dispatch_path), + KeyMatch::Pending, + ); + assert_eq!( + matcher.match_keystroke(&Keystroke::parse("c").unwrap(), &dispatch_path), + KeyMatch::None, + ); + assert!(!matcher.has_pending_keystrokes()); -// #[test] -// fn test_keystroke_parsing() -> Result<()> { -// assert_eq!( -// Keystroke::parse("ctrl-p")?, -// Keystroke { -// key: "p".into(), -// ctrl: true, -// alt: false, -// shift: false, -// cmd: false, -// function: false, -// ime_key: None, -// } -// ); + // If a single keystroke matches multiple bindings in the tree + // only one of them is returned. + assert_eq!( + matcher.match_keystroke(&Keystroke::parse("d").unwrap(), &dispatch_path), + KeyMatch::Some(vec![Box::new(D)]), + ); + } -// assert_eq!( -// Keystroke::parse("alt-shift-down")?, -// Keystroke { -// key: "down".into(), -// ctrl: false, -// alt: true, -// shift: true, -// cmd: false, -// function: false, -// ime_key: None, -// } -// ); + #[test] + fn test_keystroke_parsing() { + assert_eq!( + Keystroke::parse("ctrl-p").unwrap(), + Keystroke { + key: "p".into(), + modifiers: Modifiers { + control: true, + alt: false, + shift: false, + command: false, + function: false, + }, + ime_key: None, + } + ); -// assert_eq!( -// Keystroke::parse("shift-cmd--")?, -// Keystroke { -// key: "-".into(), -// ctrl: false, -// alt: false, -// shift: true, -// cmd: true, -// function: false, -// ime_key: None, -// } -// ); + assert_eq!( + Keystroke::parse("alt-shift-down").unwrap(), + Keystroke { + key: "down".into(), + modifiers: Modifiers { + control: false, + alt: true, + shift: true, + command: false, + function: false, + }, + ime_key: None, + } + ); -// Ok(()) -// } + assert_eq!( + Keystroke::parse("shift-cmd--").unwrap(), + Keystroke { + key: "-".into(), + modifiers: Modifiers { + control: false, + alt: false, + shift: true, + command: true, + function: false, + }, + ime_key: None, + } + ); + } -// #[test] -// fn test_context_predicate_parsing() -> Result<()> { -// use KeymapContextPredicate::*; + #[test] + fn test_context_predicate_parsing() { + use KeyBindingContextPredicate::*; -// assert_eq!( -// KeymapContextPredicate::parse("a && (b == c || d != e)")?, -// And( -// Box::new(Identifier("a".into())), -// Box::new(Or( -// Box::new(Equal("b".into(), "c".into())), -// Box::new(NotEqual("d".into(), "e".into())), -// )) -// ) -// ); + assert_eq!( + KeyBindingContextPredicate::parse("a && (b == c || d != e)").unwrap(), + And( + Box::new(Identifier("a".into())), + Box::new(Or( + Box::new(Equal("b".into(), "c".into())), + Box::new(NotEqual("d".into(), "e".into())), + )) + ) + ); -// assert_eq!( -// KeymapContextPredicate::parse("!a")?, -// Not(Box::new(Identifier("a".into())),) -// ); + assert_eq!( + KeyBindingContextPredicate::parse("!a").unwrap(), + Not(Box::new(Identifier("a".into())),) + ); + } -// Ok(()) -// } + #[test] + fn test_context_predicate_eval() { + let predicate = KeyBindingContextPredicate::parse("a && b || c == d").unwrap(); -// #[test] -// fn test_context_predicate_eval() { -// let predicate = KeymapContextPredicate::parse("a && b || c == d").unwrap(); + let mut context = KeyContext::default(); + context.add("a"); + assert!(!predicate.eval(&[context])); -// let mut context = ActionContext::default(); -// context.add_identifier("a"); -// assert!(!predicate.eval(&[context])); + let mut context = KeyContext::default(); + context.add("a"); + context.add("b"); + assert!(predicate.eval(&[context])); -// let mut context = ActionContext::default(); -// context.add_identifier("a"); -// context.add_identifier("b"); -// assert!(predicate.eval(&[context])); + let mut context = KeyContext::default(); + context.add("a"); + context.set("c", "x"); + assert!(!predicate.eval(&[context])); -// let mut context = ActionContext::default(); -// context.add_identifier("a"); -// context.add_key("c", "x"); -// assert!(!predicate.eval(&[context])); + let mut context = KeyContext::default(); + context.add("a"); + context.set("c", "d"); + assert!(predicate.eval(&[context])); -// let mut context = ActionContext::default(); -// context.add_identifier("a"); -// context.add_key("c", "d"); -// assert!(predicate.eval(&[context])); + let predicate = KeyBindingContextPredicate::parse("!a").unwrap(); + assert!(predicate.eval(&[KeyContext::default()])); + } -// let predicate = KeymapContextPredicate::parse("!a").unwrap(); -// assert!(predicate.eval(&[ActionContext::default()])); -// } + #[test] + fn test_context_child_predicate_eval() { + let predicate = KeyBindingContextPredicate::parse("a && b > c").unwrap(); + let contexts = [ + context_set(&["a", "b"]), + context_set(&["c", "d"]), // match this context + context_set(&["e", "f"]), + ]; -// #[test] -// fn test_context_child_predicate_eval() { -// let predicate = KeymapContextPredicate::parse("a && b > c").unwrap(); -// let contexts = [ -// context_set(&["e", "f"]), -// context_set(&["c", "d"]), // match this context -// context_set(&["a", "b"]), -// ]; + assert!(!predicate.eval(&contexts[..=0])); + assert!(predicate.eval(&contexts[..=1])); + assert!(!predicate.eval(&contexts[..=2])); -// assert!(!predicate.eval(&contexts[0..])); -// assert!(predicate.eval(&contexts[1..])); -// assert!(!predicate.eval(&contexts[2..])); + let predicate = KeyBindingContextPredicate::parse("a && b > c && !d > e").unwrap(); + let contexts = [ + context_set(&["a", "b"]), + context_set(&["c", "d"]), + context_set(&["e"]), + context_set(&["a", "b"]), + context_set(&["c"]), + context_set(&["e"]), // only match this context + context_set(&["f"]), + ]; -// let predicate = KeymapContextPredicate::parse("a && b > c && !d > e").unwrap(); -// let contexts = [ -// context_set(&["f"]), -// context_set(&["e"]), // only match this context -// context_set(&["c"]), -// context_set(&["a", "b"]), -// context_set(&["e"]), -// context_set(&["c", "d"]), -// context_set(&["a", "b"]), -// ]; + assert!(!predicate.eval(&contexts[..=0])); + assert!(!predicate.eval(&contexts[..=1])); + assert!(!predicate.eval(&contexts[..=2])); + assert!(!predicate.eval(&contexts[..=3])); + assert!(!predicate.eval(&contexts[..=4])); + assert!(predicate.eval(&contexts[..=5])); + assert!(!predicate.eval(&contexts[..=6])); -// assert!(!predicate.eval(&contexts[0..])); -// assert!(predicate.eval(&contexts[1..])); -// assert!(!predicate.eval(&contexts[2..])); -// assert!(!predicate.eval(&contexts[3..])); -// assert!(!predicate.eval(&contexts[4..])); -// assert!(!predicate.eval(&contexts[5..])); -// assert!(!predicate.eval(&contexts[6..])); + fn context_set(names: &[&str]) -> KeyContext { + let mut keymap = KeyContext::default(); + names.iter().for_each(|name| keymap.add(name.to_string())); + keymap + } + } -// fn context_set(names: &[&str]) -> ActionContext { -// let mut keymap = ActionContext::new(); -// names -// .iter() -// .for_each(|name| keymap.add_identifier(name.to_string())); -// keymap -// } -// } + #[test] + fn test_matcher() { + #[derive(Clone, Deserialize, PartialEq, Eq, Debug)] + pub struct A(pub String); + impl_actions!(test, [A]); + actions!(test, [B, Ab, Dollar, Quote, Ess, Backtick]); -// #[test] -// fn test_matcher() -> Result<()> { -// #[derive(Clone, Deserialize, PartialEq, Eq, Debug)] -// pub struct A(pub String); -// impl_actions!(test, [A]); -// actions!(test, [B, Ab, Dollar, Quote, Ess, Backtick]); + #[derive(Clone, Debug, Eq, PartialEq)] + struct ActionArg { + a: &'static str, + } -// #[derive(Clone, Debug, Eq, PartialEq)] -// struct ActionArg { -// a: &'static str, -// } + let keymap = Keymap::new(vec![ + KeyBinding::new("a", A("x".to_string()), Some("a")), + KeyBinding::new("b", B, Some("a")), + KeyBinding::new("a b", Ab, Some("a || b")), + KeyBinding::new("$", Dollar, Some("a")), + KeyBinding::new("\"", Quote, Some("a")), + KeyBinding::new("alt-s", Ess, Some("a")), + KeyBinding::new("ctrl-`", Backtick, Some("a")), + ]); -// let keymap = Keymap::new(vec![ -// Binding::new("a", A("x".to_string()), Some("a")), -// Binding::new("b", B, Some("a")), -// Binding::new("a b", Ab, Some("a || b")), -// Binding::new("$", Dollar, Some("a")), -// Binding::new("\"", Quote, Some("a")), -// Binding::new("alt-s", Ess, Some("a")), -// Binding::new("ctrl-`", Backtick, Some("a")), -// ]); + let mut context_a = KeyContext::default(); + context_a.add("a"); -// let mut context_a = ActionContext::default(); -// context_a.add_identifier("a"); + let mut context_b = KeyContext::default(); + context_b.add("b"); -// let mut context_b = ActionContext::default(); -// context_b.add_identifier("b"); + let mut matcher = KeystrokeMatcher::new(Arc::new(Mutex::new(keymap))); -// let mut matcher = KeymapMatcher::new(keymap); + // Basic match + assert_eq!( + matcher.match_keystroke(&Keystroke::parse("a").unwrap(), &[context_a.clone()]), + KeyMatch::Some(vec![Box::new(A("x".to_string()))]) + ); + matcher.clear_pending(); -// // Basic match -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("a")?, vec![(1, context_a.clone())]), -// KeyMatch::Matches(vec![(1, Box::new(A("x".to_string())))]) -// ); -// matcher.clear_pending(); + // Multi-keystroke match + assert_eq!( + matcher.match_keystroke(&Keystroke::parse("a").unwrap(), &[context_b.clone()]), + KeyMatch::Pending + ); + assert_eq!( + matcher.match_keystroke(&Keystroke::parse("b").unwrap(), &[context_b.clone()]), + KeyMatch::Some(vec![Box::new(Ab)]) + ); + matcher.clear_pending(); -// // Multi-keystroke match -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("a")?, vec![(1, context_b.clone())]), -// KeyMatch::Pending -// ); -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("b")?, vec![(1, context_b.clone())]), -// KeyMatch::Matches(vec![(1, Box::new(Ab))]) -// ); -// matcher.clear_pending(); + // Failed matches don't interfere with matching subsequent keys + assert_eq!( + matcher.match_keystroke(&Keystroke::parse("x").unwrap(), &[context_a.clone()]), + KeyMatch::None + ); + assert_eq!( + matcher.match_keystroke(&Keystroke::parse("a").unwrap(), &[context_a.clone()]), + KeyMatch::Some(vec![Box::new(A("x".to_string()))]) + ); + matcher.clear_pending(); -// // Failed matches don't interfere with matching subsequent keys -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("x")?, vec![(1, context_a.clone())]), -// KeyMatch::None -// ); -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("a")?, vec![(1, context_a.clone())]), -// KeyMatch::Matches(vec![(1, Box::new(A("x".to_string())))]) -// ); -// matcher.clear_pending(); + let mut context_c = KeyContext::default(); + context_c.add("c"); -// // Pending keystrokes are cleared when the context changes -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("a")?, vec![(1, context_b.clone())]), -// KeyMatch::Pending -// ); -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("b")?, vec![(1, context_a.clone())]), -// KeyMatch::None -// ); -// matcher.clear_pending(); + assert_eq!( + matcher.match_keystroke( + &Keystroke::parse("a").unwrap(), + &[context_c.clone(), context_b.clone()] + ), + KeyMatch::Pending + ); + assert_eq!( + matcher.match_keystroke(&Keystroke::parse("b").unwrap(), &[context_b.clone()]), + KeyMatch::Some(vec![Box::new(Ab)]) + ); -// let mut context_c = ActionContext::default(); -// context_c.add_identifier("c"); + // handle Czech $ (option + 4 key) + assert_eq!( + matcher.match_keystroke(&Keystroke::parse("alt-ç->$").unwrap(), &[context_a.clone()]), + KeyMatch::Some(vec![Box::new(Dollar)]) + ); -// // Pending keystrokes are maintained per-view -// assert_eq!( -// matcher.match_keystroke( -// Keystroke::parse("a")?, -// vec![(1, context_b.clone()), (2, context_c.clone())] -// ), -// KeyMatch::Pending -// ); -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("b")?, vec![(1, context_b.clone())]), -// KeyMatch::Matches(vec![(1, Box::new(Ab))]) -// ); + // handle Brazilian quote (quote key then space key) + assert_eq!( + matcher.match_keystroke( + &Keystroke::parse("space->\"").unwrap(), + &[context_a.clone()] + ), + KeyMatch::Some(vec![Box::new(Quote)]) + ); -// // handle Czech $ (option + 4 key) -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("alt-ç->$")?, vec![(1, context_a.clone())]), -// KeyMatch::Matches(vec![(1, Box::new(Dollar))]) -// ); + // handle ctrl+` on a brazilian keyboard + assert_eq!( + matcher.match_keystroke(&Keystroke::parse("ctrl-->`").unwrap(), &[context_a.clone()]), + KeyMatch::Some(vec![Box::new(Backtick)]) + ); -// // handle Brazillian quote (quote key then space key) -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("space->\"")?, vec![(1, context_a.clone())]), -// KeyMatch::Matches(vec![(1, Box::new(Quote))]) -// ); - -// // handle ctrl+` on a brazillian keyboard -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("ctrl-->`")?, vec![(1, context_a.clone())]), -// KeyMatch::Matches(vec![(1, Box::new(Backtick))]) -// ); - -// // handle alt-s on a US keyboard -// assert_eq!( -// matcher.match_keystroke(Keystroke::parse("alt-s->ß")?, vec![(1, context_a.clone())]), -// KeyMatch::Matches(vec![(1, Box::new(Ess))]) -// ); - -// Ok(()) -// } -// } + // handle alt-s on a US keyboard + assert_eq!( + matcher.match_keystroke(&Keystroke::parse("alt-s->ß").unwrap(), &[context_a.clone()]), + KeyMatch::Some(vec![Box::new(Ess)]) + ); + } +} diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 0ef345d98d..e08d7a8552 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -7,7 +7,7 @@ mod test; use crate::{ Action, AnyWindowHandle, BackgroundExecutor, Bounds, DevicePixels, Font, FontId, FontMetrics, - FontRun, ForegroundExecutor, GlobalPixels, GlyphId, InputEvent, Keymap, LineLayout, Pixels, + FontRun, ForegroundExecutor, GlobalPixels, GlyphId, Keymap, LineLayout, Pixels, PlatformInput, Point, RenderGlyphParams, RenderImageParams, RenderSvgParams, Result, Scene, SharedString, Size, TaskLabel, }; @@ -44,8 +44,6 @@ pub(crate) fn current_platform() -> Rc { Rc::new(MacPlatform::new()) } -pub type DrawWindow = Box Result>; - pub(crate) trait Platform: 'static { fn background_executor(&self) -> BackgroundExecutor; fn foreground_executor(&self) -> ForegroundExecutor; @@ -66,7 +64,6 @@ pub(crate) trait Platform: 'static { &self, handle: AnyWindowHandle, options: WindowOptions, - draw: DrawWindow, ) -> Box; fn set_display_link_output_callback( @@ -91,7 +88,7 @@ pub(crate) trait Platform: 'static { fn on_resign_active(&self, callback: Box); fn on_quit(&self, callback: Box); fn on_reopen(&self, callback: Box); - fn on_event(&self, callback: Box bool>); + fn on_event(&self, callback: Box bool>); fn set_menus(&self, menus: Vec, keymap: &Keymap); fn on_app_menu_action(&self, callback: Box); @@ -117,15 +114,20 @@ pub(crate) trait Platform: 'static { fn delete_credentials(&self, url: &str) -> Result<()>; } +/// A handle to a platform's display, e.g. a monitor or laptop screen. pub trait PlatformDisplay: Send + Sync + Debug { + /// Get the ID for this display fn id(&self) -> DisplayId; + /// Returns a stable identifier for this display that can be persisted and used /// across system restarts. fn uuid(&self) -> Result; - fn as_any(&self) -> &dyn Any; + + /// Get the bounds for this display fn bounds(&self) -> Bounds; } +/// An opaque identifier for a hardware display #[derive(PartialEq, Eq, Hash, Copy, Clone)] pub struct DisplayId(pub(crate) u32); @@ -137,7 +139,7 @@ impl Debug for DisplayId { unsafe impl Send for DisplayId {} -pub trait PlatformWindow { +pub(crate) trait PlatformWindow { fn bounds(&self) -> WindowBounds; fn content_size(&self) -> Size; fn scale_factor(&self) -> f32; @@ -148,7 +150,7 @@ pub trait PlatformWindow { fn modifiers(&self) -> Modifiers; fn as_any_mut(&mut self) -> &mut dyn Any; fn set_input_handler(&mut self, input_handler: Box); - fn clear_input_handler(&mut self); + fn take_input_handler(&mut self) -> Option>; fn prompt(&self, level: PromptLevel, msg: &str, answers: &[&str]) -> oneshot::Receiver; fn activate(&self); fn set_title(&mut self, title: &str); @@ -157,7 +159,8 @@ pub trait PlatformWindow { fn minimize(&self); fn zoom(&self); fn toggle_full_screen(&self); - fn on_input(&self, callback: Box bool>); + fn on_request_frame(&self, callback: Box); + fn on_input(&self, callback: Box bool>); fn on_active_status_change(&self, callback: Box); fn on_resize(&self, callback: Box, f32)>); fn on_fullscreen(&self, callback: Box); @@ -167,6 +170,7 @@ pub trait PlatformWindow { fn on_appearance_changed(&self, callback: Box); fn is_topmost_for_position(&self, position: Point) -> bool; fn invalidate(&self); + fn draw(&self, scene: &Scene); fn sprite_atlas(&self) -> Arc; @@ -176,6 +180,9 @@ pub trait PlatformWindow { } } +/// This type is public so that our test macro can generate and use it, but it should not +/// be considered part of our public API. +#[doc(hidden)] pub trait PlatformDispatcher: Send + Sync { fn is_main_thread(&self) -> bool; fn dispatch(&self, runnable: Runnable, label: Option); @@ -191,8 +198,9 @@ pub trait PlatformDispatcher: Send + Sync { } } -pub trait PlatformTextSystem: Send + Sync { +pub(crate) trait PlatformTextSystem: Send + Sync { fn add_fonts(&self, fonts: &[Arc>]) -> Result<()>; + fn all_font_names(&self) -> Vec; fn all_font_families(&self) -> Vec; fn font_id(&self, descriptor: &Font) -> Result; fn font_metrics(&self, font_id: FontId) -> FontMetrics; @@ -215,15 +223,21 @@ pub trait PlatformTextSystem: Send + Sync { ) -> Vec; } +/// Basic metadata about the current application and operating system. #[derive(Clone, Debug)] pub struct AppMetadata { + /// The name of the current operating system pub os_name: &'static str, + + /// The operating system's version pub os_version: Option, + + /// The current version of the application pub app_version: Option, } #[derive(PartialEq, Eq, Hash, Clone)] -pub enum AtlasKey { +pub(crate) enum AtlasKey { Glyph(RenderGlyphParams), Svg(RenderSvgParams), Image(RenderImageParams), @@ -263,19 +277,17 @@ impl From for AtlasKey { } } -pub trait PlatformAtlas: Send + Sync { +pub(crate) trait PlatformAtlas: Send + Sync { fn get_or_insert_with<'a>( &self, key: &AtlasKey, build: &mut dyn FnMut() -> Result<(Size, Cow<'a, [u8]>)>, ) -> Result; - - fn clear(&self); } #[derive(Clone, Debug, PartialEq, Eq)] #[repr(C)] -pub struct AtlasTile { +pub(crate) struct AtlasTile { pub(crate) texture_id: AtlasTextureId, pub(crate) tile_id: TileId, pub(crate) bounds: Bounds, diff --git a/crates/gpui/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs index cadb9c92e3..64a901789a 100644 --- a/crates/gpui/src/platform/keystroke.rs +++ b/crates/gpui/src/platform/keystroke.rs @@ -19,7 +19,7 @@ impl Keystroke { // the ime_key or the key. On some non-US keyboards keys we use in our // bindings are behind option (for example `$` is typed `alt-ç` on a Czech keyboard), // and on some keyboards the IME handler converts a sequence of keys into a - // specific character (for example `"` is typed as `" space` on a brazillian keyboard). + // specific character (for example `"` is typed as `" space` on a brazilian keyboard). pub fn match_candidates(&self) -> SmallVec<[Keystroke; 2]> { let mut possibilities = SmallVec::new(); match self.ime_key.as_ref() { diff --git a/crates/gpui/src/platform/mac.rs b/crates/gpui/src/platform/mac.rs index 8f48b8ea94..3cc74a9683 100644 --- a/crates/gpui/src/platform/mac.rs +++ b/crates/gpui/src/platform/mac.rs @@ -10,7 +10,7 @@ mod open_type; mod platform; mod text_system; mod window; -mod window_appearence; +mod window_appearance; use crate::{px, size, GlobalPixels, Pixels, Size}; use cocoa::{ diff --git a/crates/gpui/src/platform/mac/display.rs b/crates/gpui/src/platform/mac/display.rs index 123cbf8159..95ec83cd5a 100644 --- a/crates/gpui/src/platform/mac/display.rs +++ b/crates/gpui/src/platform/mac/display.rs @@ -1,11 +1,16 @@ use crate::{point, size, Bounds, DisplayId, GlobalPixels, PlatformDisplay}; use anyhow::Result; +use cocoa::{ + appkit::NSScreen, + base::{id, nil}, + foundation::{NSDictionary, NSString}, +}; use core_foundation::uuid::{CFUUIDGetUUIDBytes, CFUUIDRef}; use core_graphics::{ display::{CGDirectDisplayID, CGDisplayBounds, CGGetActiveDisplayList}, geometry::{CGPoint, CGRect, CGSize}, }; -use std::any::Any; +use objc::{msg_send, sel, sel_impl}; use uuid::Uuid; #[derive(Debug)] @@ -14,12 +19,12 @@ pub struct MacDisplay(pub(crate) CGDirectDisplayID); unsafe impl Send for MacDisplay {} impl MacDisplay { - /// Get the screen with the given [DisplayId]. + /// Get the screen with the given [`DisplayId`]. pub fn find_by_id(id: DisplayId) -> Option { Self::all().find(|screen| screen.id() == id) } - /// Get the screen with the given persistent [Uuid]. + /// Get the screen with the given persistent [`Uuid`]. pub fn find_by_uuid(uuid: Uuid) -> Option { Self::all().find(|screen| screen.uuid().ok() == Some(uuid)) } @@ -27,23 +32,41 @@ impl MacDisplay { /// Get the primary screen - the one with the menu bar, and whose bottom left /// corner is at the origin of the AppKit coordinate system. pub fn primary() -> Self { - Self::all().next().unwrap() + // Instead of iterating through all active systems displays via `all()` we use the first + // NSScreen and gets its CGDirectDisplayID, because we can't be sure that `CGGetActiveDisplayList` + // will always return a list of active displays (machine might be sleeping). + // + // The following is what Chromium does too: + // + // https://chromium.googlesource.com/chromium/src/+/66.0.3359.158/ui/display/mac/screen_mac.mm#56 + unsafe { + let screens = NSScreen::screens(nil); + let screen = cocoa::foundation::NSArray::objectAtIndex(screens, 0); + let device_description = NSScreen::deviceDescription(screen); + let screen_number_key: id = NSString::alloc(nil).init_str("NSScreenNumber"); + let screen_number = device_description.objectForKey_(screen_number_key); + let screen_number: CGDirectDisplayID = msg_send![screen_number, unsignedIntegerValue]; + Self(screen_number) + } } /// Obtains an iterator over all currently active system displays. pub fn all() -> impl Iterator { unsafe { - let mut display_count: u32 = 0; - let result = CGGetActiveDisplayList(0, std::ptr::null_mut(), &mut display_count); + // We're assuming there aren't more than 32 displays connected to the system. + let mut displays = Vec::with_capacity(32); + let mut display_count = 0; + let result = CGGetActiveDisplayList( + displays.capacity() as u32, + displays.as_mut_ptr(), + &mut display_count, + ); if result == 0 { - let mut displays = Vec::with_capacity(display_count as usize); - CGGetActiveDisplayList(display_count, displays.as_mut_ptr(), &mut display_count); displays.set_len(display_count as usize); - displays.into_iter().map(MacDisplay) } else { - panic!("Failed to get active display list"); + panic!("Failed to get active display list. Result: {result}"); } } } @@ -130,10 +153,6 @@ impl PlatformDisplay for MacDisplay { ])) } - fn as_any(&self) -> &dyn Any { - self - } - fn bounds(&self) -> Bounds { unsafe { let native_bounds = CGDisplayBounds(self.0); diff --git a/crates/gpui/src/platform/mac/events.rs b/crates/gpui/src/platform/mac/events.rs index c67018ad5d..f84833d3cb 100644 --- a/crates/gpui/src/platform/mac/events.rs +++ b/crates/gpui/src/platform/mac/events.rs @@ -1,7 +1,7 @@ use crate::{ - point, px, InputEvent, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, - MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, - Pixels, ScrollDelta, ScrollWheelEvent, TouchPhase, + point, px, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, + MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, + PlatformInput, ScrollDelta, ScrollWheelEvent, TouchPhase, }; use cocoa::{ appkit::{NSEvent, NSEventModifierFlags, NSEventPhase, NSEventType}, @@ -82,7 +82,7 @@ unsafe fn read_modifiers(native_event: id) -> Modifiers { } } -impl InputEvent { +impl PlatformInput { pub unsafe fn from_native(native_event: id, window_height: Option) -> Option { let event_type = native_event.eventType(); diff --git a/crates/gpui/src/platform/mac/metal_atlas.rs b/crates/gpui/src/platform/mac/metal_atlas.rs index 10ca53530e..d3caeba522 100644 --- a/crates/gpui/src/platform/mac/metal_atlas.rs +++ b/crates/gpui/src/platform/mac/metal_atlas.rs @@ -74,20 +74,6 @@ impl PlatformAtlas for MetalAtlas { Ok(tile) } } - - fn clear(&self) { - let mut lock = self.0.lock(); - lock.tiles_by_key.clear(); - for texture in &mut lock.monochrome_textures { - texture.clear(); - } - for texture in &mut lock.polychrome_textures { - texture.clear(); - } - for texture in &mut lock.path_textures { - texture.clear(); - } - } } impl MetalAtlasState { diff --git a/crates/gpui/src/platform/mac/metal_renderer.rs b/crates/gpui/src/platform/mac/metal_renderer.rs index a6cdd166d3..1589757d93 100644 --- a/crates/gpui/src/platform/mac/metal_renderer.rs +++ b/crates/gpui/src/platform/mac/metal_renderer.rs @@ -18,7 +18,7 @@ use smallvec::SmallVec; use std::{ffi::c_void, mem, ptr, sync::Arc}; const SHADERS_METALLIB: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/shaders.metallib")); -const INSTANCE_BUFFER_SIZE: usize = 8192 * 1024; // This is an arbitrary decision. There's probably a more optimal value. +const INSTANCE_BUFFER_SIZE: usize = 32 * 1024 * 1024; // This is an arbitrary decision. There's probably a more optimal value (maybe even we could adjust dynamically...) pub(crate) struct MetalRenderer { layer: metal::MetalLayer, @@ -82,7 +82,7 @@ impl MetalRenderer { ]; let unit_vertices = device.new_buffer_with_data( unit_vertices.as_ptr() as *const c_void, - (unit_vertices.len() * mem::size_of::()) as u64, + mem::size_of_val(&unit_vertices) as u64, MTLResourceOptions::StorageModeManaged, ); let instances = device.new_buffer( @@ -204,7 +204,11 @@ impl MetalRenderer { let command_buffer = command_queue.new_command_buffer(); let mut instance_offset = 0; - let path_tiles = self.rasterize_paths(scene.paths(), &mut instance_offset, command_buffer); + let Some(path_tiles) = + self.rasterize_paths(scene.paths(), &mut instance_offset, command_buffer) + else { + panic!("failed to rasterize {} paths", scene.paths().len()); + }; let render_pass_descriptor = metal::RenderPassDescriptor::new(); let color_attachment = render_pass_descriptor @@ -228,67 +232,67 @@ impl MetalRenderer { zfar: 1.0, }); for batch in scene.batches() { - match batch { - PrimitiveBatch::Shadows(shadows) => { - self.draw_shadows( - shadows, - &mut instance_offset, - viewport_size, - command_encoder, - ); - } + let ok = match batch { + PrimitiveBatch::Shadows(shadows) => self.draw_shadows( + shadows, + &mut instance_offset, + viewport_size, + command_encoder, + ), PrimitiveBatch::Quads(quads) => { - self.draw_quads(quads, &mut instance_offset, viewport_size, command_encoder); - } - PrimitiveBatch::Paths(paths) => { - self.draw_paths( - paths, - &path_tiles, - &mut instance_offset, - viewport_size, - command_encoder, - ); - } - PrimitiveBatch::Underlines(underlines) => { - self.draw_underlines( - underlines, - &mut instance_offset, - viewport_size, - command_encoder, - ); + self.draw_quads(quads, &mut instance_offset, viewport_size, command_encoder) } + PrimitiveBatch::Paths(paths) => self.draw_paths( + paths, + &path_tiles, + &mut instance_offset, + viewport_size, + command_encoder, + ), + PrimitiveBatch::Underlines(underlines) => self.draw_underlines( + underlines, + &mut instance_offset, + viewport_size, + command_encoder, + ), PrimitiveBatch::MonochromeSprites { texture_id, sprites, - } => { - self.draw_monochrome_sprites( - texture_id, - sprites, - &mut instance_offset, - viewport_size, - command_encoder, - ); - } + } => self.draw_monochrome_sprites( + texture_id, + sprites, + &mut instance_offset, + viewport_size, + command_encoder, + ), PrimitiveBatch::PolychromeSprites { texture_id, sprites, - } => { - self.draw_polychrome_sprites( - texture_id, - sprites, - &mut instance_offset, - viewport_size, - command_encoder, - ); - } - PrimitiveBatch::Surfaces(surfaces) => { - self.draw_surfaces( - surfaces, - &mut instance_offset, - viewport_size, - command_encoder, - ); - } + } => self.draw_polychrome_sprites( + texture_id, + sprites, + &mut instance_offset, + viewport_size, + command_encoder, + ), + PrimitiveBatch::Surfaces(surfaces) => self.draw_surfaces( + surfaces, + &mut instance_offset, + viewport_size, + command_encoder, + ), + }; + + if !ok { + panic!("scene too large: {} paths, {} shadows, {} quads, {} underlines, {} mono, {} poly, {} surfaces", + scene.paths.len(), + scene.shadows.len(), + scene.quads.len(), + scene.underlines.len(), + scene.monochrome_sprites.len(), + scene.polychrome_sprites.len(), + scene.surfaces.len(), + ) } } @@ -311,7 +315,7 @@ impl MetalRenderer { paths: &[Path], offset: &mut usize, command_buffer: &metal::CommandBufferRef, - ) -> HashMap { + ) -> Option> { let mut tiles = HashMap::default(); let mut vertices_by_texture_id = HashMap::default(); for path in paths { @@ -336,11 +340,11 @@ impl MetalRenderer { for (texture_id, vertices) in vertices_by_texture_id { align_offset(offset); - let next_offset = *offset + vertices.len() * mem::size_of::>(); - assert!( - next_offset <= INSTANCE_BUFFER_SIZE, - "instance buffer exhausted" - ); + let vertices_bytes_len = mem::size_of_val(vertices.as_slice()); + let next_offset = *offset + vertices_bytes_len; + if next_offset > INSTANCE_BUFFER_SIZE { + return None; + } let render_pass_descriptor = metal::RenderPassDescriptor::new(); let color_attachment = render_pass_descriptor @@ -370,7 +374,6 @@ impl MetalRenderer { &texture_size as *const Size as *const _, ); - let vertices_bytes_len = mem::size_of::>() * vertices.len(); let buffer_contents = unsafe { (self.instances.contents() as *mut u8).add(*offset) }; unsafe { ptr::copy_nonoverlapping( @@ -389,7 +392,7 @@ impl MetalRenderer { *offset = next_offset; } - tiles + Some(tiles) } fn draw_shadows( @@ -398,9 +401,9 @@ impl MetalRenderer { offset: &mut usize, viewport_size: Size, command_encoder: &metal::RenderCommandEncoderRef, - ) { + ) -> bool { if shadows.is_empty() { - return; + return true; } align_offset(offset); @@ -427,8 +430,14 @@ impl MetalRenderer { &viewport_size as *const Size as *const _, ); - let shadow_bytes_len = std::mem::size_of_val(shadows); + let shadow_bytes_len = mem::size_of_val(shadows); let buffer_contents = unsafe { (self.instances.contents() as *mut u8).add(*offset) }; + + let next_offset = *offset + shadow_bytes_len; + if next_offset > INSTANCE_BUFFER_SIZE { + return false; + } + unsafe { ptr::copy_nonoverlapping( shadows.as_ptr() as *const u8, @@ -437,12 +446,6 @@ impl MetalRenderer { ); } - let next_offset = *offset + shadow_bytes_len; - assert!( - next_offset <= INSTANCE_BUFFER_SIZE, - "instance buffer exhausted" - ); - command_encoder.draw_primitives_instanced( metal::MTLPrimitiveType::Triangle, 0, @@ -450,6 +453,7 @@ impl MetalRenderer { shadows.len() as u64, ); *offset = next_offset; + true } fn draw_quads( @@ -458,9 +462,9 @@ impl MetalRenderer { offset: &mut usize, viewport_size: Size, command_encoder: &metal::RenderCommandEncoderRef, - ) { + ) -> bool { if quads.is_empty() { - return; + return true; } align_offset(offset); @@ -487,18 +491,18 @@ impl MetalRenderer { &viewport_size as *const Size as *const _, ); - let quad_bytes_len = std::mem::size_of_val(quads); + let quad_bytes_len = mem::size_of_val(quads); let buffer_contents = unsafe { (self.instances.contents() as *mut u8).add(*offset) }; + + let next_offset = *offset + quad_bytes_len; + if next_offset > INSTANCE_BUFFER_SIZE { + return false; + } + unsafe { ptr::copy_nonoverlapping(quads.as_ptr() as *const u8, buffer_contents, quad_bytes_len); } - let next_offset = *offset + quad_bytes_len; - assert!( - next_offset <= INSTANCE_BUFFER_SIZE, - "instance buffer exhausted" - ); - command_encoder.draw_primitives_instanced( metal::MTLPrimitiveType::Triangle, 0, @@ -506,6 +510,7 @@ impl MetalRenderer { quads.len() as u64, ); *offset = next_offset; + true } fn draw_paths( @@ -515,9 +520,9 @@ impl MetalRenderer { offset: &mut usize, viewport_size: Size, command_encoder: &metal::RenderCommandEncoderRef, - ) { + ) -> bool { if paths.is_empty() { - return; + return true; } command_encoder.set_render_pipeline_state(&self.path_sprites_pipeline_state); @@ -586,9 +591,15 @@ impl MetalRenderer { command_encoder .set_fragment_texture(SpriteInputIndex::AtlasTexture as u64, Some(&texture)); - let sprite_bytes_len = mem::size_of::() * sprites.len(); + let sprite_bytes_len = mem::size_of_val(sprites.as_slice()); + let next_offset = *offset + sprite_bytes_len; + if next_offset > INSTANCE_BUFFER_SIZE { + return false; + } + let buffer_contents = unsafe { (self.instances.contents() as *mut u8).add(*offset) }; + unsafe { ptr::copy_nonoverlapping( sprites.as_ptr() as *const u8, @@ -597,12 +608,6 @@ impl MetalRenderer { ); } - let next_offset = *offset + sprite_bytes_len; - assert!( - next_offset <= INSTANCE_BUFFER_SIZE, - "instance buffer exhausted" - ); - command_encoder.draw_primitives_instanced( metal::MTLPrimitiveType::Triangle, 0, @@ -613,6 +618,7 @@ impl MetalRenderer { sprites.clear(); } } + true } fn draw_underlines( @@ -621,9 +627,9 @@ impl MetalRenderer { offset: &mut usize, viewport_size: Size, command_encoder: &metal::RenderCommandEncoderRef, - ) { + ) -> bool { if underlines.is_empty() { - return; + return true; } align_offset(offset); @@ -650,22 +656,22 @@ impl MetalRenderer { &viewport_size as *const Size as *const _, ); - let quad_bytes_len = std::mem::size_of_val(underlines); + let underline_bytes_len = mem::size_of_val(underlines); let buffer_contents = unsafe { (self.instances.contents() as *mut u8).add(*offset) }; + + let next_offset = *offset + underline_bytes_len; + if next_offset > INSTANCE_BUFFER_SIZE { + return false; + } + unsafe { ptr::copy_nonoverlapping( underlines.as_ptr() as *const u8, buffer_contents, - quad_bytes_len, + underline_bytes_len, ); } - let next_offset = *offset + quad_bytes_len; - assert!( - next_offset <= INSTANCE_BUFFER_SIZE, - "instance buffer exhausted" - ); - command_encoder.draw_primitives_instanced( metal::MTLPrimitiveType::Triangle, 0, @@ -673,6 +679,7 @@ impl MetalRenderer { underlines.len() as u64, ); *offset = next_offset; + true } fn draw_monochrome_sprites( @@ -682,9 +689,9 @@ impl MetalRenderer { offset: &mut usize, viewport_size: Size, command_encoder: &metal::RenderCommandEncoderRef, - ) { + ) -> bool { if sprites.is_empty() { - return; + return true; } align_offset(offset); @@ -721,8 +728,14 @@ impl MetalRenderer { ); command_encoder.set_fragment_texture(SpriteInputIndex::AtlasTexture as u64, Some(&texture)); - let sprite_bytes_len = std::mem::size_of_val(sprites); + let sprite_bytes_len = mem::size_of_val(sprites); let buffer_contents = unsafe { (self.instances.contents() as *mut u8).add(*offset) }; + + let next_offset = *offset + sprite_bytes_len; + if next_offset > INSTANCE_BUFFER_SIZE { + return false; + } + unsafe { ptr::copy_nonoverlapping( sprites.as_ptr() as *const u8, @@ -731,12 +744,6 @@ impl MetalRenderer { ); } - let next_offset = *offset + sprite_bytes_len; - assert!( - next_offset <= INSTANCE_BUFFER_SIZE, - "instance buffer exhausted" - ); - command_encoder.draw_primitives_instanced( metal::MTLPrimitiveType::Triangle, 0, @@ -744,6 +751,7 @@ impl MetalRenderer { sprites.len() as u64, ); *offset = next_offset; + true } fn draw_polychrome_sprites( @@ -753,9 +761,9 @@ impl MetalRenderer { offset: &mut usize, viewport_size: Size, command_encoder: &metal::RenderCommandEncoderRef, - ) { + ) -> bool { if sprites.is_empty() { - return; + return true; } align_offset(offset); @@ -792,8 +800,14 @@ impl MetalRenderer { ); command_encoder.set_fragment_texture(SpriteInputIndex::AtlasTexture as u64, Some(&texture)); - let sprite_bytes_len = std::mem::size_of_val(sprites); + let sprite_bytes_len = mem::size_of_val(sprites); let buffer_contents = unsafe { (self.instances.contents() as *mut u8).add(*offset) }; + + let next_offset = *offset + sprite_bytes_len; + if next_offset > INSTANCE_BUFFER_SIZE { + return false; + } + unsafe { ptr::copy_nonoverlapping( sprites.as_ptr() as *const u8, @@ -802,12 +816,6 @@ impl MetalRenderer { ); } - let next_offset = *offset + sprite_bytes_len; - assert!( - next_offset <= INSTANCE_BUFFER_SIZE, - "instance buffer exhausted" - ); - command_encoder.draw_primitives_instanced( metal::MTLPrimitiveType::Triangle, 0, @@ -815,6 +823,7 @@ impl MetalRenderer { sprites.len() as u64, ); *offset = next_offset; + true } fn draw_surfaces( @@ -823,7 +832,7 @@ impl MetalRenderer { offset: &mut usize, viewport_size: Size, command_encoder: &metal::RenderCommandEncoderRef, - ) { + ) -> bool { command_encoder.set_render_pipeline_state(&self.surfaces_pipeline_state); command_encoder.set_vertex_buffer( SurfaceInputIndex::Vertices as u64, @@ -874,10 +883,9 @@ impl MetalRenderer { align_offset(offset); let next_offset = *offset + mem::size_of::(); - assert!( - next_offset <= INSTANCE_BUFFER_SIZE, - "instance buffer exhausted" - ); + if next_offset > INSTANCE_BUFFER_SIZE { + return false; + } command_encoder.set_vertex_buffer( SurfaceInputIndex::Surfaces as u64, @@ -913,6 +921,7 @@ impl MetalRenderer { command_encoder.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 6); *offset = next_offset; } + true } } diff --git a/crates/gpui/src/platform/mac/open_type.rs b/crates/gpui/src/platform/mac/open_type.rs index 50e93a866d..c9d7197c0d 100644 --- a/crates/gpui/src/platform/mac/open_type.rs +++ b/crates/gpui/src/platform/mac/open_type.rs @@ -378,7 +378,7 @@ fn toggle_open_type_feature( new_descriptor.as_concrete_TypeRef(), ); let new_font = CTFont::wrap_under_create_rule(new_font); - *font = Font::from_native_font(new_font); + *font = Font::from_native_font(&new_font); } } } diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 8370e2a495..499ac0b591 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -1,10 +1,9 @@ use super::{events::key_to_native, BoolExt}; use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, - ForegroundExecutor, InputEvent, Keymap, MacDispatcher, MacDisplay, MacDisplayLinker, - MacTextSystem, MacWindow, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, - PlatformTextSystem, PlatformWindow, Result, Scene, SemanticVersion, VideoTimestamp, - WindowOptions, + ForegroundExecutor, Keymap, MacDispatcher, MacDisplay, MacDisplayLinker, MacTextSystem, + MacWindow, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, PlatformInput, + PlatformTextSystem, PlatformWindow, Result, SemanticVersion, VideoTimestamp, WindowOptions, }; use anyhow::anyhow; use block::ConcreteBlock; @@ -154,7 +153,7 @@ pub struct MacPlatformState { resign_active: Option>, reopen: Option>, quit: Option>, - event: Option bool>>, + event: Option bool>>, menu_command: Option>, validate_menu_command: Option bool>>, will_open_menu: Option>, @@ -498,14 +497,8 @@ impl Platform for MacPlatform { &self, handle: AnyWindowHandle, options: WindowOptions, - draw: Box Result>, ) -> Box { - Box::new(MacWindow::open( - handle, - options, - draw, - self.foreground_executor(), - )) + Box::new(MacWindow::open(handle, options, self.foreground_executor())) } fn set_display_link_output_callback( @@ -644,7 +637,7 @@ impl Platform for MacPlatform { self.0.lock().reopen = Some(callback); } - fn on_event(&self, callback: Box bool>) { + fn on_event(&self, callback: Box bool>) { self.0.lock().event = Some(callback); } @@ -983,10 +976,14 @@ unsafe fn get_mac_platform(object: &mut Object) -> &MacPlatform { extern "C" fn send_event(this: &mut Object, _sel: Sel, native_event: id) { unsafe { - if let Some(event) = InputEvent::from_native(native_event, None) { + if let Some(event) = PlatformInput::from_native(native_event, None) { let platform = get_mac_platform(this); - if let Some(callback) = platform.0.lock().event.as_mut() { - if !callback(event) { + let mut lock = platform.0.lock(); + if let Some(mut callback) = lock.event.take() { + drop(lock); + let result = callback(event); + platform.0.lock().event.get_or_insert(callback); + if !result { return; } } @@ -1011,30 +1008,42 @@ extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) { extern "C" fn should_handle_reopen(this: &mut Object, _: Sel, _: id, has_open_windows: bool) { if !has_open_windows { let platform = unsafe { get_mac_platform(this) }; - if let Some(callback) = platform.0.lock().reopen.as_mut() { + let mut lock = platform.0.lock(); + if let Some(mut callback) = lock.reopen.take() { + drop(lock); callback(); + platform.0.lock().reopen.get_or_insert(callback); } } } extern "C" fn did_become_active(this: &mut Object, _: Sel, _: id) { let platform = unsafe { get_mac_platform(this) }; - if let Some(callback) = platform.0.lock().become_active.as_mut() { + let mut lock = platform.0.lock(); + if let Some(mut callback) = lock.become_active.take() { + drop(lock); callback(); + platform.0.lock().become_active.get_or_insert(callback); } } extern "C" fn did_resign_active(this: &mut Object, _: Sel, _: id) { let platform = unsafe { get_mac_platform(this) }; - if let Some(callback) = platform.0.lock().resign_active.as_mut() { + let mut lock = platform.0.lock(); + if let Some(mut callback) = lock.resign_active.take() { + drop(lock); callback(); + platform.0.lock().resign_active.get_or_insert(callback); } } extern "C" fn will_terminate(this: &mut Object, _: Sel, _: id) { let platform = unsafe { get_mac_platform(this) }; - if let Some(callback) = platform.0.lock().quit.as_mut() { + let mut lock = platform.0.lock(); + if let Some(mut callback) = lock.quit.take() { + drop(lock); callback(); + platform.0.lock().quit.get_or_insert(callback); } } @@ -1054,22 +1063,27 @@ extern "C" fn open_urls(this: &mut Object, _: Sel, _: id, urls: id) { .collect::>() }; let platform = unsafe { get_mac_platform(this) }; - if let Some(callback) = platform.0.lock().open_urls.as_mut() { + let mut lock = platform.0.lock(); + if let Some(mut callback) = lock.open_urls.take() { + drop(lock); callback(urls); + platform.0.lock().open_urls.get_or_insert(callback); } } extern "C" fn handle_menu_item(this: &mut Object, _: Sel, item: id) { unsafe { let platform = get_mac_platform(this); - let mut platform = platform.0.lock(); - if let Some(mut callback) = platform.menu_command.take() { + let mut lock = platform.0.lock(); + if let Some(mut callback) = lock.menu_command.take() { let tag: NSInteger = msg_send![item, tag]; let index = tag as usize; - if let Some(action) = platform.menu_actions.get(index) { - callback(action.as_ref()); + if let Some(action) = lock.menu_actions.get(index) { + let action = action.boxed_clone(); + drop(lock); + callback(&*action); } - platform.menu_command = Some(callback); + platform.0.lock().menu_command.get_or_insert(callback); } } } @@ -1078,14 +1092,20 @@ extern "C" fn validate_menu_item(this: &mut Object, _: Sel, item: id) -> bool { unsafe { let mut result = false; let platform = get_mac_platform(this); - let mut platform = platform.0.lock(); - if let Some(mut callback) = platform.validate_menu_command.take() { + let mut lock = platform.0.lock(); + if let Some(mut callback) = lock.validate_menu_command.take() { let tag: NSInteger = msg_send![item, tag]; let index = tag as usize; - if let Some(action) = platform.menu_actions.get(index) { + if let Some(action) = lock.menu_actions.get(index) { + let action = action.boxed_clone(); + drop(lock); result = callback(action.as_ref()); } - platform.validate_menu_command = Some(callback); + platform + .0 + .lock() + .validate_menu_command + .get_or_insert(callback); } result } @@ -1094,10 +1114,11 @@ extern "C" fn validate_menu_item(this: &mut Object, _: Sel, item: id) -> bool { extern "C" fn menu_will_open(this: &mut Object, _: Sel, _: id) { unsafe { let platform = get_mac_platform(this); - let mut platform = platform.0.lock(); - if let Some(mut callback) = platform.will_open_menu.take() { + let mut lock = platform.0.lock(); + if let Some(mut callback) = lock.will_open_menu.take() { + drop(lock); callback(); - platform.will_open_menu = Some(callback); + platform.0.lock().will_open_menu.get_or_insert(callback); } } } diff --git a/crates/gpui/src/platform/mac/text_system.rs b/crates/gpui/src/platform/mac/text_system.rs index d9f7936066..d11efa902a 100644 --- a/crates/gpui/src/platform/mac/text_system.rs +++ b/crates/gpui/src/platform/mac/text_system.rs @@ -5,7 +5,7 @@ use crate::{ }; use anyhow::anyhow; use cocoa::appkit::{CGFloat, CGPoint}; -use collections::HashMap; +use collections::{BTreeSet, HashMap}; use core_foundation::{ array::CFIndex, attributed_string::{CFAttributedStringRef, CFMutableAttributedString}, @@ -78,6 +78,23 @@ impl PlatformTextSystem for MacTextSystem { self.0.write().add_fonts(fonts) } + fn all_font_names(&self) -> Vec { + let collection = core_text::font_collection::create_for_all_families(); + let Some(descriptors) = collection.get_descriptors() else { + return vec![]; + }; + let mut names = BTreeSet::new(); + for descriptor in descriptors.into_iter() { + names.insert(descriptor.font_name()); + names.insert(descriptor.family_name()); + names.insert(descriptor.style_name()); + } + if let Ok(fonts_in_memory) = self.0.read().memory_source.all_families() { + names.extend(fonts_in_memory); + } + names.into_iter().collect() + } + fn all_font_families(&self) -> Vec { self.0 .read() @@ -190,6 +207,9 @@ impl MacTextSystemState { for font in family.fonts() { let mut font = font.load()?; open_type::apply_features(&mut font, features); + let Some(_) = font.glyph_for_char('m') else { + continue; + }; let font_id = FontId(self.fonts.len()); font_ids.push(font_id); let postscript_name = font.postscript_name().unwrap(); @@ -592,169 +612,49 @@ impl From for FontkitStyle { } } -// #[cfg(test)] -// mod tests { -// use super::*; -// use crate::AppContext; -// use font_kit::properties::{Style, Weight}; -// use platform::FontSystem as _; +#[cfg(test)] +mod tests { + use crate::{font, px, FontRun, MacTextSystem, PlatformTextSystem}; -// #[crate::test(self, retries = 5)] -// fn test_layout_str(_: &mut AppContext) { -// // This is failing intermittently on CI and we don't have time to figure it out -// let fonts = FontSystem::new(); -// let menlo = fonts.load_family("Menlo", &Default::default()).unwrap(); -// let menlo_regular = RunStyle { -// font_id: fonts.select_font(&menlo, &Properties::new()).unwrap(), -// color: Default::default(), -// underline: Default::default(), -// }; -// let menlo_italic = RunStyle { -// font_id: fonts -// .select_font(&menlo, Properties::new().style(Style::Italic)) -// .unwrap(), -// color: Default::default(), -// underline: Default::default(), -// }; -// let menlo_bold = RunStyle { -// font_id: fonts -// .select_font(&menlo, Properties::new().weight(Weight::BOLD)) -// .unwrap(), -// color: Default::default(), -// underline: Default::default(), -// }; -// assert_ne!(menlo_regular, menlo_italic); -// assert_ne!(menlo_regular, menlo_bold); -// assert_ne!(menlo_italic, menlo_bold); + #[test] + fn test_wrap_line() { + let fonts = MacTextSystem::new(); + let font_id = fonts.font_id(&font("Helvetica")).unwrap(); -// let line = fonts.layout_line( -// "hello world", -// 16.0, -// &[(2, menlo_bold), (4, menlo_italic), (5, menlo_regular)], -// ); -// assert_eq!(line.runs.len(), 3); -// assert_eq!(line.runs[0].font_id, menlo_bold.font_id); -// assert_eq!(line.runs[0].glyphs.len(), 2); -// assert_eq!(line.runs[1].font_id, menlo_italic.font_id); -// assert_eq!(line.runs[1].glyphs.len(), 4); -// assert_eq!(line.runs[2].font_id, menlo_regular.font_id); -// assert_eq!(line.runs[2].glyphs.len(), 5); -// } + let line = "one two three four five\n"; + let wrap_boundaries = fonts.wrap_line(line, font_id, px(16.), px(64.0)); + assert_eq!(wrap_boundaries, &["one two ".len(), "one two three ".len()]); -// #[test] -// fn test_glyph_offsets() -> crate::Result<()> { -// let fonts = FontSystem::new(); -// let zapfino = fonts.load_family("Zapfino", &Default::default())?; -// let zapfino_regular = RunStyle { -// font_id: fonts.select_font(&zapfino, &Properties::new())?, -// color: Default::default(), -// underline: Default::default(), -// }; -// let menlo = fonts.load_family("Menlo", &Default::default())?; -// let menlo_regular = RunStyle { -// font_id: fonts.select_font(&menlo, &Properties::new())?, -// color: Default::default(), -// underline: Default::default(), -// }; + let line = "aaa ααα ✋✋✋ 🎉🎉🎉\n"; + let wrap_boundaries = fonts.wrap_line(line, font_id, px(16.), px(64.0)); + assert_eq!( + wrap_boundaries, + &["aaa ααα ".len(), "aaa ααα ✋✋✋ ".len(),] + ); + } -// let text = "This is, m𐍈re 𐍈r less, Zapfino!𐍈"; -// let line = fonts.layout_line( -// text, -// 16.0, -// &[ -// (9, zapfino_regular), -// (13, menlo_regular), -// (text.len() - 22, zapfino_regular), -// ], -// ); -// assert_eq!( -// line.runs -// .iter() -// .flat_map(|r| r.glyphs.iter()) -// .map(|g| g.index) -// .collect::>(), -// vec![0, 2, 4, 5, 7, 8, 9, 10, 14, 15, 16, 17, 21, 22, 23, 24, 26, 27, 28, 29, 36, 37], -// ); -// Ok(()) -// } + #[test] + fn test_layout_line_bom_char() { + let fonts = MacTextSystem::new(); + let font_id = fonts.font_id(&font("Helvetica")).unwrap(); + let line = "\u{feff}"; + let mut style = FontRun { + font_id, + len: line.len(), + }; -// #[test] -// #[ignore] -// fn test_rasterize_glyph() { -// use std::{fs::File, io::BufWriter, path::Path}; + let layout = fonts.layout_line(line, px(16.), &[style]); + assert_eq!(layout.len, line.len()); + assert!(layout.runs.is_empty()); -// let fonts = FontSystem::new(); -// let font_ids = fonts.load_family("Fira Code", &Default::default()).unwrap(); -// let font_id = fonts.select_font(&font_ids, &Default::default()).unwrap(); -// let glyph_id = fonts.glyph_for_char(font_id, 'G').unwrap(); - -// const VARIANTS: usize = 1; -// for i in 0..VARIANTS { -// let variant = i as f32 / VARIANTS as f32; -// let (bounds, bytes) = fonts -// .rasterize_glyph( -// font_id, -// 16.0, -// glyph_id, -// vec2f(variant, variant), -// 2., -// RasterizationOptions::Alpha, -// ) -// .unwrap(); - -// let name = format!("/Users/as-cii/Desktop/twog-{}.png", i); -// let path = Path::new(&name); -// let file = File::create(path).unwrap(); -// let w = &mut BufWriter::new(file); - -// let mut encoder = png::Encoder::new(w, bounds.width() as u32, bounds.height() as u32); -// encoder.set_color(png::ColorType::Grayscale); -// encoder.set_depth(png::BitDepth::Eight); -// let mut writer = encoder.write_header().unwrap(); -// writer.write_image_data(&bytes).unwrap(); -// } -// } - -// #[test] -// fn test_wrap_line() { -// let fonts = FontSystem::new(); -// let font_ids = fonts.load_family("Helvetica", &Default::default()).unwrap(); -// let font_id = fonts.select_font(&font_ids, &Default::default()).unwrap(); - -// let line = "one two three four five\n"; -// let wrap_boundaries = fonts.wrap_line(line, font_id, 16., 64.0); -// assert_eq!(wrap_boundaries, &["one two ".len(), "one two three ".len()]); - -// let line = "aaa ααα ✋✋✋ 🎉🎉🎉\n"; -// let wrap_boundaries = fonts.wrap_line(line, font_id, 16., 64.0); -// assert_eq!( -// wrap_boundaries, -// &["aaa ααα ".len(), "aaa ααα ✋✋✋ ".len(),] -// ); -// } - -// #[test] -// fn test_layout_line_bom_char() { -// let fonts = FontSystem::new(); -// let font_ids = fonts.load_family("Helvetica", &Default::default()).unwrap(); -// let style = RunStyle { -// font_id: fonts.select_font(&font_ids, &Default::default()).unwrap(), -// color: Default::default(), -// underline: Default::default(), -// }; - -// let line = "\u{feff}"; -// let layout = fonts.layout_line(line, 16., &[(line.len(), style)]); -// assert_eq!(layout.len, line.len()); -// assert!(layout.runs.is_empty()); - -// let line = "a\u{feff}b"; -// let layout = fonts.layout_line(line, 16., &[(line.len(), style)]); -// assert_eq!(layout.len, line.len()); -// assert_eq!(layout.runs.len(), 1); -// assert_eq!(layout.runs[0].glyphs.len(), 2); -// assert_eq!(layout.runs[0].glyphs[0].id, 68); // a -// // There's no glyph for \u{feff} -// assert_eq!(layout.runs[0].glyphs[1].id, 69); // b -// } -// } + let line = "a\u{feff}b"; + style.len = line.len(); + let layout = fonts.layout_line(line, px(16.), &[style]); + assert_eq!(layout.len, line.len()); + assert_eq!(layout.runs.len(), 1); + assert_eq!(layout.runs[0].glyphs.len(), 2); + assert_eq!(layout.runs[0].glyphs[0].id, 68u32.into()); // a + // There's no glyph for \u{feff} + assert_eq!(layout.runs[0].glyphs[1].id, 69u32.into()); // b + } +} diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 2beac528c1..134390bb79 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -1,9 +1,9 @@ use super::{display_bounds_from_native, ns_string, MacDisplay, MetalRenderer, NSRange}; use crate::{ - display_bounds_to_native, point, px, size, AnyWindowHandle, Bounds, DrawWindow, ExternalPaths, - FileDropEvent, ForegroundExecutor, GlobalPixels, InputEvent, KeyDownEvent, Keystroke, - Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - Pixels, PlatformAtlas, PlatformDisplay, PlatformInputHandler, PlatformWindow, Point, + display_bounds_to_native, point, px, size, AnyWindowHandle, Bounds, ExternalPaths, + FileDropEvent, ForegroundExecutor, GlobalPixels, KeyDownEvent, Keystroke, Modifiers, + ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, + PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptLevel, Size, Timer, WindowAppearance, WindowBounds, WindowKind, WindowOptions, }; use block::ConcreteBlock; @@ -46,7 +46,6 @@ use std::{ sync::{Arc, Weak}, time::Duration, }; -use util::ResultExt; const WINDOW_STATE_IVAR: &str = "windowState"; @@ -269,6 +268,7 @@ unsafe fn build_window_class(name: &'static str, superclass: &Class) -> *const C sel!(windowShouldClose:), window_should_close as extern "C" fn(&Object, Sel, id) -> BOOL, ); + decl.add_method(sel!(close), close_window as extern "C" fn(&Object, Sel)); decl.add_method( @@ -317,9 +317,9 @@ struct MacWindowState { executor: ForegroundExecutor, native_window: id, renderer: MetalRenderer, - draw: Option, kind: WindowKind, - event_callback: Option bool>>, + request_frame_callback: Option>, + event_callback: Option bool>>, activate_callback: Option>, resize_callback: Option, f32)>>, fullscreen_callback: Option>, @@ -333,11 +333,12 @@ struct MacWindowState { synthetic_drag_counter: usize, last_fresh_keydown: Option, traffic_light_position: Option>, - previous_modifiers_changed_event: Option, + previous_modifiers_changed_event: Option, // State tracking what the IME did after the last request ime_state: ImeState, // Retains the last IME Text ime_text: Option, + external_files_dragged: bool, } impl MacWindowState { @@ -453,7 +454,6 @@ impl MacWindow { pub fn open( handle: AnyWindowHandle, options: WindowOptions, - draw: DrawWindow, executor: ForegroundExecutor, ) -> Self { unsafe { @@ -484,7 +484,7 @@ impl MacWindow { let display = options .display_id - .and_then(|display_id| MacDisplay::all().find(|display| display.id() == display_id)) + .and_then(MacDisplay::find_by_id) .unwrap_or_else(MacDisplay::primary); let mut target_screen = nil; @@ -545,8 +545,8 @@ impl MacWindow { executor, native_window, renderer: MetalRenderer::new(true), - draw: Some(draw), kind: options.kind, + request_frame_callback: None, event_callback: None, activate_callback: None, resize_callback: None, @@ -567,6 +567,7 @@ impl MacWindow { previous_modifiers_changed_event: None, ime_state: ImeState::None, ime_text: None, + external_files_dragged: false, }))); (*native_window).set_ivar( @@ -683,9 +684,6 @@ impl Drop for MacWindow { this.executor .spawn(async move { unsafe { - // todo!() this panic()s when you click the red close button - // unless should_close returns false. - // (luckliy in zed it always returns false) window.close(); } }) @@ -770,8 +768,8 @@ impl PlatformWindow for MacWindow { self.0.as_ref().lock().input_handler = Some(input_handler); } - fn clear_input_handler(&mut self) { - self.0.as_ref().lock().input_handler = None; + fn take_input_handler(&mut self) -> Option> { + self.0.as_ref().lock().input_handler.take() } fn prompt(&self, level: PromptLevel, msg: &str, answers: &[&str]) -> oneshot::Receiver { @@ -926,7 +924,11 @@ impl PlatformWindow for MacWindow { .detach(); } - fn on_input(&self, callback: Box bool>) { + fn on_request_frame(&self, callback: Box) { + self.0.as_ref().lock().request_frame_callback = Some(callback); + } + + fn on_input(&self, callback: Box bool>) { self.0.as_ref().lock().event_callback = Some(callback); } @@ -990,6 +992,11 @@ impl PlatformWindow for MacWindow { } } + fn draw(&self, scene: &crate::Scene) { + let mut this = self.0.lock(); + this.renderer.draw(scene); + } + fn sprite_atlas(&self) -> Arc { self.0.lock().renderer.sprite_atlas().clone() } @@ -1046,9 +1053,9 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: let mut lock = window_state.as_ref().lock(); let window_height = lock.content_size().height; - let event = unsafe { InputEvent::from_native(native_event, Some(window_height)) }; + let event = unsafe { PlatformInput::from_native(native_event, Some(window_height)) }; - if let Some(InputEvent::KeyDown(event)) = event { + if let Some(PlatformInput::KeyDown(event)) = event { // For certain keystrokes, macOS will first dispatch a "key equivalent" event. // If that event isn't handled, it will then dispatch a "key down" event. GPUI // makes no distinction between these two types of events, so we need to ignore @@ -1095,37 +1102,7 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: .flatten() .is_some(); if !is_composing { - // if the IME has changed the key, we'll first emit an event with the character - // generated by the IME system; then fallback to the keystroke if that is not - // handled. - // cases that we have working: - // - " on a brazillian layout by typing - // - ctrl-` on a brazillian layout by typing - // - $ on a czech QWERTY layout by typing - // - 4 on a czech QWERTY layout by typing - // - ctrl-4 on a czech QWERTY layout by typing (or ) - if ime_text.is_some() && ime_text.as_ref() != Some(&event.keystroke.key) { - let event_with_ime_text = KeyDownEvent { - is_held: false, - keystroke: Keystroke { - // we match ctrl because some use-cases need it. - // we don't match alt because it's often used to generate the optional character - // we don't match shift because we're not here with letters (usually) - // we don't match cmd/fn because they don't seem to use IME - modifiers: Default::default(), - key: ime_text.clone().unwrap(), - ime_key: None, // todo!("handle IME key") - }, - }; - handled = callback(InputEvent::KeyDown(event_with_ime_text)); - } - if !handled { - // empty key happens when you type a deadkey in input composition. - // (e.g. on a brazillian keyboard typing quote is a deadkey) - if !event.keystroke.key.is_empty() { - handled = callback(InputEvent::KeyDown(event)); - } - } + handled = callback(PlatformInput::KeyDown(event)); } if !handled { @@ -1169,11 +1146,11 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { let is_active = unsafe { lock.native_window.isKeyWindow() == YES }; let window_height = lock.content_size().height; - let event = unsafe { InputEvent::from_native(native_event, Some(window_height)) }; + let event = unsafe { PlatformInput::from_native(native_event, Some(window_height)) }; if let Some(mut event) = event { match &mut event { - InputEvent::MouseDown( + PlatformInput::MouseDown( event @ MouseDownEvent { button: MouseButton::Left, modifiers: Modifiers { control: true, .. }, @@ -1195,7 +1172,7 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { // Because we map a ctrl-left_down to a right_down -> right_up let's ignore // the ctrl-left_up to avoid having a mismatch in button down/up events if the // user is still holding ctrl when releasing the left mouse button - InputEvent::MouseUp( + PlatformInput::MouseUp( event @ MouseUpEvent { button: MouseButton::Left, modifiers: Modifiers { control: true, .. }, @@ -1217,32 +1194,37 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { }; match &event { - InputEvent::MouseMove( + PlatformInput::MouseMove( event @ MouseMoveEvent { pressed_button: Some(_), .. }, ) => { - lock.synthetic_drag_counter += 1; - let executor = lock.executor.clone(); - executor - .spawn(synthetic_drag( - weak_window_state, - lock.synthetic_drag_counter, - event.clone(), - )) - .detach(); + // Synthetic drag is used for selecting long buffer contents while buffer is being scrolled. + // External file drag and drop is able to emit its own synthetic mouse events which will conflict + // with these ones. + if !lock.external_files_dragged { + lock.synthetic_drag_counter += 1; + let executor = lock.executor.clone(); + executor + .spawn(synthetic_drag( + weak_window_state, + lock.synthetic_drag_counter, + event.clone(), + )) + .detach(); + } } - InputEvent::MouseMove(_) if !(is_active || lock.kind == WindowKind::PopUp) => return, + PlatformInput::MouseMove(_) if !(is_active || lock.kind == WindowKind::PopUp) => return, - InputEvent::MouseUp(MouseUpEvent { .. }) => { + PlatformInput::MouseUp(MouseUpEvent { .. }) => { lock.synthetic_drag_counter += 1; } - InputEvent::ModifiersChanged(ModifiersChangedEvent { modifiers }) => { + PlatformInput::ModifiersChanged(ModifiersChangedEvent { modifiers }) => { // Only raise modifiers changed event when they have actually changed - if let Some(InputEvent::ModifiersChanged(ModifiersChangedEvent { + if let Some(PlatformInput::ModifiersChanged(ModifiersChangedEvent { modifiers: prev_modifiers, })) = &lock.previous_modifiers_changed_event { @@ -1276,7 +1258,7 @@ extern "C" fn cancel_operation(this: &Object, _sel: Sel, _sender: id) { key: ".".into(), ime_key: None, }; - let event = InputEvent::KeyDown(KeyDownEvent { + let event = PlatformInput::KeyDown(KeyDownEvent { keystroke: keystroke.clone(), is_held: false, }); @@ -1462,15 +1444,12 @@ extern "C" fn set_frame_size(this: &Object, _: Sel, size: NSSize) { } extern "C" fn display_layer(this: &Object, _: Sel, _: id) { - unsafe { - let window_state = get_window_state(this); - let mut draw = window_state.lock().draw.take().unwrap(); - let scene = draw().log_err(); - let mut window_state = window_state.lock(); - window_state.draw = Some(draw); - if let Some(scene) = scene { - window_state.renderer.draw(&scene); - } + let window_state = unsafe { get_window_state(this) }; + let mut lock = window_state.lock(); + if let Some(mut callback) = lock.request_frame_callback.take() { + drop(lock); + callback(); + window_state.lock().request_frame_callback = Some(callback); } } @@ -1563,6 +1542,9 @@ extern "C" fn insert_text(this: &Object, _: Sel, text: id, replacement_range: NS replacement_range, text: text.to_string(), }); + if text.to_string().to_ascii_lowercase() != pending_key_down.0.keystroke.key { + pending_key_down.0.keystroke.ime_key = Some(text.to_string()); + } window_state.lock().pending_key_down = Some(pending_key_down); } } @@ -1673,8 +1655,9 @@ extern "C" fn dragging_entered(this: &Object, _: Sel, dragging_info: id) -> NSDr if send_new_event(&window_state, { let position = drag_event_position(&window_state, dragging_info); let paths = external_paths_from_event(dragging_info); - InputEvent::FileDrop(FileDropEvent::Entered { position, paths }) + PlatformInput::FileDrop(FileDropEvent::Entered { position, paths }) }) { + window_state.lock().external_files_dragged = true; NSDragOperationCopy } else { NSDragOperationNone @@ -1686,7 +1669,7 @@ extern "C" fn dragging_updated(this: &Object, _: Sel, dragging_info: id) -> NSDr let position = drag_event_position(&window_state, dragging_info); if send_new_event( &window_state, - InputEvent::FileDrop(FileDropEvent::Pending { position }), + PlatformInput::FileDrop(FileDropEvent::Pending { position }), ) { NSDragOperationCopy } else { @@ -1696,7 +1679,11 @@ extern "C" fn dragging_updated(this: &Object, _: Sel, dragging_info: id) -> NSDr extern "C" fn dragging_exited(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; - send_new_event(&window_state, InputEvent::FileDrop(FileDropEvent::Exited)); + send_new_event( + &window_state, + PlatformInput::FileDrop(FileDropEvent::Exited), + ); + window_state.lock().external_files_dragged = false; } extern "C" fn perform_drag_operation(this: &Object, _: Sel, dragging_info: id) -> BOOL { @@ -1704,7 +1691,7 @@ extern "C" fn perform_drag_operation(this: &Object, _: Sel, dragging_info: id) - let position = drag_event_position(&window_state, dragging_info); if send_new_event( &window_state, - InputEvent::FileDrop(FileDropEvent::Submit { position }), + PlatformInput::FileDrop(FileDropEvent::Submit { position }), ) { YES } else { @@ -1728,7 +1715,10 @@ fn external_paths_from_event(dragging_info: *mut Object) -> ExternalPaths { extern "C" fn conclude_drag_operation(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; - send_new_event(&window_state, InputEvent::FileDrop(FileDropEvent::Exited)); + send_new_event( + &window_state, + PlatformInput::FileDrop(FileDropEvent::Exited), + ); } async fn synthetic_drag( @@ -1743,7 +1733,7 @@ async fn synthetic_drag( if lock.synthetic_drag_counter == drag_id { if let Some(mut callback) = lock.event_callback.take() { drop(lock); - callback(InputEvent::MouseMove(event.clone())); + callback(PlatformInput::MouseMove(event.clone())); window_state.lock().event_callback = Some(callback); } } else { @@ -1753,7 +1743,7 @@ async fn synthetic_drag( } } -fn send_new_event(window_state_lock: &Mutex, e: InputEvent) -> bool { +fn send_new_event(window_state_lock: &Mutex, e: PlatformInput) -> bool { let window_state = window_state_lock.lock().event_callback.take(); if let Some(mut callback) = window_state { callback(e); diff --git a/crates/gpui/src/platform/mac/window_appearence.rs b/crates/gpui/src/platform/mac/window_appearance.rs similarity index 100% rename from crates/gpui/src/platform/mac/window_appearence.rs rename to crates/gpui/src/platform/mac/window_appearance.rs diff --git a/crates/gpui/src/platform/test/display.rs b/crates/gpui/src/platform/test/display.rs index 95f1daf8e9..838d600147 100644 --- a/crates/gpui/src/platform/test/display.rs +++ b/crates/gpui/src/platform/test/display.rs @@ -31,10 +31,6 @@ impl PlatformDisplay for TestDisplay { Ok(self.uuid) } - fn as_any(&self) -> &dyn std::any::Any { - todo!() - } - fn bounds(&self) -> crate::Bounds { self.bounds } diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 695323e9c4..f5e2170b28 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -1,7 +1,6 @@ use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, - Keymap, Platform, PlatformDisplay, PlatformTextSystem, Scene, TestDisplay, TestWindow, - WindowOptions, + Keymap, Platform, PlatformDisplay, PlatformTextSystem, TestDisplay, TestWindow, WindowOptions, }; use anyhow::{anyhow, Result}; use collections::VecDeque; @@ -15,6 +14,7 @@ use std::{ time::Duration, }; +/// TestPlatform implements the Platform trait for use in tests. pub struct TestPlatform { background_executor: BackgroundExecutor, foreground_executor: ForegroundExecutor, @@ -101,9 +101,12 @@ impl TestPlatform { }) .detach(); } + + pub(crate) fn did_prompt_for_new_path(&self) -> bool { + self.prompts.borrow().new_path.len() > 0 + } } -// todo!("implement out what our tests needed in GPUI 1") impl Platform for TestPlatform { fn background_executor(&self) -> BackgroundExecutor { self.background_executor.clone() @@ -162,7 +165,6 @@ impl Platform for TestPlatform { &self, handle: AnyWindowHandle, options: WindowOptions, - _draw: Box Result>, ) -> Box { let window = TestWindow::new( options, @@ -237,7 +239,7 @@ impl Platform for TestPlatform { unimplemented!() } - fn on_event(&self, _callback: Box bool>) { + fn on_event(&self, _callback: Box bool>) { unimplemented!() } @@ -278,8 +280,7 @@ impl Platform for TestPlatform { } fn should_auto_hide_scrollbars(&self) -> bool { - // todo() - true + false } fn write_to_clipboard(&self, item: ClipboardItem) { diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 91f965c10a..2f080bd709 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -1,7 +1,7 @@ use crate::{ - px, AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, InputEvent, KeyDownEvent, - Keystroke, Pixels, PlatformAtlas, PlatformDisplay, PlatformInputHandler, PlatformWindow, Point, - Size, TestPlatform, TileId, WindowAppearance, WindowBounds, WindowOptions, + px, AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, KeyDownEvent, Keystroke, + Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, + Point, Size, TestPlatform, TileId, WindowAppearance, WindowBounds, WindowOptions, }; use collections::HashMap; use parking_lot::Mutex; @@ -19,7 +19,7 @@ pub struct TestWindowState { platform: Weak, sprite_atlas: Arc, pub(crate) should_close_handler: Option bool>>, - input_callback: Option bool>>, + input_callback: Option bool>>, active_status_change_callback: Option>, resize_callback: Option, f32)>>, moved_callback: Option>, @@ -85,7 +85,7 @@ impl TestWindow { self.0.lock().active_status_change_callback = Some(callback); } - pub fn simulate_input(&mut self, event: InputEvent) -> bool { + pub fn simulate_input(&mut self, event: PlatformInput) -> bool { let mut lock = self.0.lock(); let Some(mut callback) = lock.input_callback.take() else { return false; @@ -97,7 +97,7 @@ impl TestWindow { } pub fn simulate_keystroke(&mut self, keystroke: Keystroke, is_held: bool) { - if self.simulate_input(InputEvent::KeyDown(KeyDownEvent { + if self.simulate_input(PlatformInput::KeyDown(KeyDownEvent { keystroke: keystroke.clone(), is_held, })) { @@ -167,8 +167,8 @@ impl PlatformWindow for TestWindow { self.0.lock().input_handler = Some(input_handler); } - fn clear_input_handler(&mut self) { - self.0.lock().input_handler = None; + fn take_input_handler(&mut self) -> Option> { + self.0.lock().input_handler.take() } fn prompt( @@ -218,7 +218,9 @@ impl PlatformWindow for TestWindow { unimplemented!() } - fn on_input(&self, callback: Box bool>) { + fn on_request_frame(&self, _callback: Box) {} + + fn on_input(&self, callback: Box bool>) { self.0.lock().input_callback = Some(callback) } @@ -254,9 +256,9 @@ impl PlatformWindow for TestWindow { unimplemented!() } - fn invalidate(&self) { - // (self.draw.lock())().unwrap(); - } + fn invalidate(&self) {} + + fn draw(&self, _scene: &crate::Scene) {} fn sprite_atlas(&self) -> sync::Arc { self.0.lock().sprite_atlas.clone() @@ -323,10 +325,4 @@ impl PlatformAtlas for TestAtlas { Ok(state.tiles[key].clone()) } - - fn clear(&self) { - let mut state = self.0.lock(); - state.tiles = HashMap::default(); - state.next_id = 0; - } } diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index e922c11f53..b69c10c752 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -1,9 +1,9 @@ use crate::{ - point, AtlasTextureId, AtlasTile, Bounds, ContentMask, Corners, Edges, Hsla, Pixels, Point, - ScaledPixels, StackingOrder, + point, AtlasTextureId, AtlasTile, Bounds, ContentMask, Corners, Edges, EntityId, Hsla, Pixels, + Point, ScaledPixels, StackingOrder, }; -use collections::BTreeMap; -use std::{fmt::Debug, iter::Peekable, mem, slice}; +use collections::{BTreeMap, FxHashSet}; +use std::{fmt::Debug, iter::Peekable, slice}; // Exported to metal pub(crate) type PointF = Point; @@ -11,152 +11,58 @@ pub(crate) type PointF = Point; pub(crate) type PathVertex_ScaledPixels = PathVertex; pub type LayerId = u32; - pub type DrawOrder = u32; +#[derive(Default, Copy, Clone, Debug, Eq, PartialEq, Hash)] +#[repr(C)] +pub struct ViewId { + low_bits: u32, + high_bits: u32, +} + +impl From for ViewId { + fn from(value: EntityId) -> Self { + let value = value.as_u64(); + Self { + low_bits: value as u32, + high_bits: (value >> 32) as u32, + } + } +} + +impl From for EntityId { + fn from(value: ViewId) -> Self { + let value = (value.low_bits as u64) | ((value.high_bits as u64) << 32); + value.into() + } +} + #[derive(Default)] -pub(crate) struct SceneBuilder { - last_order: Option<(StackingOrder, LayerId)>, - layers_by_order: BTreeMap, - shadows: Vec, - quads: Vec, - paths: Vec>, - underlines: Vec, - monochrome_sprites: Vec, - polychrome_sprites: Vec, - surfaces: Vec, -} - -impl SceneBuilder { - pub fn build(&mut self) -> Scene { - let mut orders = vec![0; self.layers_by_order.len()]; - for (ix, layer_id) in self.layers_by_order.values().enumerate() { - orders[*layer_id as usize] = ix as u32; - } - self.layers_by_order.clear(); - self.last_order = None; - - for shadow in &mut self.shadows { - shadow.order = orders[shadow.order as usize]; - } - self.shadows.sort_by_key(|shadow| shadow.order); - - for quad in &mut self.quads { - quad.order = orders[quad.order as usize]; - } - self.quads.sort_by_key(|quad| quad.order); - - for path in &mut self.paths { - path.order = orders[path.order as usize]; - } - self.paths.sort_by_key(|path| path.order); - - for underline in &mut self.underlines { - underline.order = orders[underline.order as usize]; - } - self.underlines.sort_by_key(|underline| underline.order); - - for monochrome_sprite in &mut self.monochrome_sprites { - monochrome_sprite.order = orders[monochrome_sprite.order as usize]; - } - self.monochrome_sprites.sort_by_key(|sprite| sprite.order); - - for polychrome_sprite in &mut self.polychrome_sprites { - polychrome_sprite.order = orders[polychrome_sprite.order as usize]; - } - self.polychrome_sprites.sort_by_key(|sprite| sprite.order); - - for surface in &mut self.surfaces { - surface.order = orders[surface.order as usize]; - } - self.surfaces.sort_by_key(|surface| surface.order); - - Scene { - shadows: mem::take(&mut self.shadows), - quads: mem::take(&mut self.quads), - paths: mem::take(&mut self.paths), - underlines: mem::take(&mut self.underlines), - monochrome_sprites: mem::take(&mut self.monochrome_sprites), - polychrome_sprites: mem::take(&mut self.polychrome_sprites), - surfaces: mem::take(&mut self.surfaces), - } - } - - pub fn insert(&mut self, order: &StackingOrder, primitive: impl Into) { - let primitive = primitive.into(); - let clipped_bounds = primitive - .bounds() - .intersect(&primitive.content_mask().bounds); - if clipped_bounds.size.width <= ScaledPixels(0.) - || clipped_bounds.size.height <= ScaledPixels(0.) - { - return; - } - - let layer_id = self.layer_id_for_order(order); - match primitive { - Primitive::Shadow(mut shadow) => { - shadow.order = layer_id; - self.shadows.push(shadow); - } - Primitive::Quad(mut quad) => { - quad.order = layer_id; - self.quads.push(quad); - } - Primitive::Path(mut path) => { - path.order = layer_id; - path.id = PathId(self.paths.len()); - self.paths.push(path); - } - Primitive::Underline(mut underline) => { - underline.order = layer_id; - self.underlines.push(underline); - } - Primitive::MonochromeSprite(mut sprite) => { - sprite.order = layer_id; - self.monochrome_sprites.push(sprite); - } - Primitive::PolychromeSprite(mut sprite) => { - sprite.order = layer_id; - self.polychrome_sprites.push(sprite); - } - Primitive::Surface(mut surface) => { - surface.order = layer_id; - self.surfaces.push(surface); - } - } - } - - fn layer_id_for_order(&mut self, order: &StackingOrder) -> u32 { - if let Some((last_order, last_layer_id)) = self.last_order.as_ref() { - if last_order == order { - return *last_layer_id; - } - }; - - let layer_id = if let Some(layer_id) = self.layers_by_order.get(order) { - *layer_id - } else { - let next_id = self.layers_by_order.len() as LayerId; - self.layers_by_order.insert(order.clone(), next_id); - next_id - }; - self.last_order = Some((order.clone(), layer_id)); - layer_id - } -} - pub struct Scene { - pub shadows: Vec, - pub quads: Vec, - pub paths: Vec>, - pub underlines: Vec, - pub monochrome_sprites: Vec, - pub polychrome_sprites: Vec, - pub surfaces: Vec, + layers_by_order: BTreeMap, + orders_by_layer: BTreeMap, + pub(crate) shadows: Vec, + pub(crate) quads: Vec, + pub(crate) paths: Vec>, + pub(crate) underlines: Vec, + pub(crate) monochrome_sprites: Vec, + pub(crate) polychrome_sprites: Vec, + pub(crate) surfaces: Vec, } impl Scene { + pub fn clear(&mut self) { + self.layers_by_order.clear(); + self.orders_by_layer.clear(); + self.shadows.clear(); + self.quads.clear(); + self.paths.clear(); + self.underlines.clear(); + self.monochrome_sprites.clear(); + self.polychrome_sprites.clear(); + self.surfaces.clear(); + } + pub fn paths(&self) -> &[Path] { &self.paths } @@ -186,6 +92,155 @@ impl Scene { surfaces_iter: self.surfaces.iter().peekable(), } } + + pub(crate) fn insert(&mut self, order: &StackingOrder, primitive: impl Into) { + let primitive = primitive.into(); + let clipped_bounds = primitive + .bounds() + .intersect(&primitive.content_mask().bounds); + if clipped_bounds.size.width <= ScaledPixels(0.) + || clipped_bounds.size.height <= ScaledPixels(0.) + { + return; + } + + let layer_id = self.layer_id_for_order(order); + match primitive { + Primitive::Shadow(mut shadow) => { + shadow.layer_id = layer_id; + self.shadows.push(shadow); + } + Primitive::Quad(mut quad) => { + quad.layer_id = layer_id; + self.quads.push(quad); + } + Primitive::Path(mut path) => { + path.layer_id = layer_id; + path.id = PathId(self.paths.len()); + self.paths.push(path); + } + Primitive::Underline(mut underline) => { + underline.layer_id = layer_id; + self.underlines.push(underline); + } + Primitive::MonochromeSprite(mut sprite) => { + sprite.layer_id = layer_id; + self.monochrome_sprites.push(sprite); + } + Primitive::PolychromeSprite(mut sprite) => { + sprite.layer_id = layer_id; + self.polychrome_sprites.push(sprite); + } + Primitive::Surface(mut surface) => { + surface.layer_id = layer_id; + self.surfaces.push(surface); + } + } + } + + fn layer_id_for_order(&mut self, order: &StackingOrder) -> LayerId { + if let Some(layer_id) = self.layers_by_order.get(order) { + *layer_id + } else { + let next_id = self.layers_by_order.len() as LayerId; + self.layers_by_order.insert(order.clone(), next_id); + self.orders_by_layer.insert(next_id, order.clone()); + next_id + } + } + + pub fn reuse_views(&mut self, views: &FxHashSet, prev_scene: &mut Self) { + for shadow in prev_scene.shadows.drain(..) { + if views.contains(&shadow.view_id.into()) { + let order = &prev_scene.orders_by_layer[&shadow.layer_id]; + self.insert(&order, shadow); + } + } + + for quad in prev_scene.quads.drain(..) { + if views.contains(&quad.view_id.into()) { + let order = &prev_scene.orders_by_layer[&quad.layer_id]; + self.insert(&order, quad); + } + } + + for path in prev_scene.paths.drain(..) { + if views.contains(&path.view_id.into()) { + let order = &prev_scene.orders_by_layer[&path.layer_id]; + self.insert(&order, path); + } + } + + for underline in prev_scene.underlines.drain(..) { + if views.contains(&underline.view_id.into()) { + let order = &prev_scene.orders_by_layer[&underline.layer_id]; + self.insert(&order, underline); + } + } + + for sprite in prev_scene.monochrome_sprites.drain(..) { + if views.contains(&sprite.view_id.into()) { + let order = &prev_scene.orders_by_layer[&sprite.layer_id]; + self.insert(&order, sprite); + } + } + + for sprite in prev_scene.polychrome_sprites.drain(..) { + if views.contains(&sprite.view_id.into()) { + let order = &prev_scene.orders_by_layer[&sprite.layer_id]; + self.insert(&order, sprite); + } + } + + for surface in prev_scene.surfaces.drain(..) { + if views.contains(&surface.view_id.into()) { + let order = &prev_scene.orders_by_layer[&surface.layer_id]; + self.insert(&order, surface); + } + } + } + + pub fn finish(&mut self) { + let mut orders = vec![0; self.layers_by_order.len()]; + for (ix, layer_id) in self.layers_by_order.values().enumerate() { + orders[*layer_id as usize] = ix as u32; + } + + for shadow in &mut self.shadows { + shadow.order = orders[shadow.layer_id as usize]; + } + self.shadows.sort_by_key(|shadow| shadow.order); + + for quad in &mut self.quads { + quad.order = orders[quad.layer_id as usize]; + } + self.quads.sort_by_key(|quad| quad.order); + + for path in &mut self.paths { + path.order = orders[path.layer_id as usize]; + } + self.paths.sort_by_key(|path| path.order); + + for underline in &mut self.underlines { + underline.order = orders[underline.layer_id as usize]; + } + self.underlines.sort_by_key(|underline| underline.order); + + for monochrome_sprite in &mut self.monochrome_sprites { + monochrome_sprite.order = orders[monochrome_sprite.layer_id as usize]; + } + self.monochrome_sprites.sort_by_key(|sprite| sprite.order); + + for polychrome_sprite in &mut self.polychrome_sprites { + polychrome_sprite.order = orders[polychrome_sprite.layer_id as usize]; + } + self.polychrome_sprites.sort_by_key(|sprite| sprite.order); + + for surface in &mut self.surfaces { + surface.order = orders[surface.layer_id as usize]; + } + self.surfaces.sort_by_key(|surface| surface.order); + } } struct BatchIterator<'a> { @@ -244,8 +299,8 @@ impl<'a> Iterator for BatchIterator<'a> { let first = orders_and_kinds[0]; let second = orders_and_kinds[1]; - let (batch_kind, max_order) = if first.0.is_some() { - (first.1, second.0.unwrap_or(u32::MAX)) + let (batch_kind, max_order_and_kind) = if first.0.is_some() { + (first.1, (second.0.unwrap_or(u32::MAX), second.1)) } else { return None; }; @@ -257,7 +312,7 @@ impl<'a> Iterator for BatchIterator<'a> { self.shadows_iter.next(); while self .shadows_iter - .next_if(|shadow| shadow.order < max_order) + .next_if(|shadow| (shadow.order, batch_kind) < max_order_and_kind) .is_some() { shadows_end += 1; @@ -273,7 +328,7 @@ impl<'a> Iterator for BatchIterator<'a> { self.quads_iter.next(); while self .quads_iter - .next_if(|quad| quad.order < max_order) + .next_if(|quad| (quad.order, batch_kind) < max_order_and_kind) .is_some() { quads_end += 1; @@ -287,7 +342,7 @@ impl<'a> Iterator for BatchIterator<'a> { self.paths_iter.next(); while self .paths_iter - .next_if(|path| path.order < max_order) + .next_if(|path| (path.order, batch_kind) < max_order_and_kind) .is_some() { paths_end += 1; @@ -301,7 +356,7 @@ impl<'a> Iterator for BatchIterator<'a> { self.underlines_iter.next(); while self .underlines_iter - .next_if(|underline| underline.order < max_order) + .next_if(|underline| (underline.order, batch_kind) < max_order_and_kind) .is_some() { underlines_end += 1; @@ -319,7 +374,8 @@ impl<'a> Iterator for BatchIterator<'a> { while self .monochrome_sprites_iter .next_if(|sprite| { - sprite.order < max_order && sprite.tile.texture_id == texture_id + (sprite.order, batch_kind) < max_order_and_kind + && sprite.tile.texture_id == texture_id }) .is_some() { @@ -339,7 +395,8 @@ impl<'a> Iterator for BatchIterator<'a> { while self .polychrome_sprites_iter .next_if(|sprite| { - sprite.order < max_order && sprite.tile.texture_id == texture_id + (sprite.order, batch_kind) < max_order_and_kind + && sprite.tile.texture_id == texture_id }) .is_some() { @@ -357,7 +414,7 @@ impl<'a> Iterator for BatchIterator<'a> { self.surfaces_iter.next(); while self .surfaces_iter - .next_if(|surface| surface.order < max_order) + .next_if(|surface| (surface.order, batch_kind) < max_order_and_kind) .is_some() { surfaces_end += 1; @@ -383,7 +440,7 @@ pub enum PrimitiveKind { Surface, } -pub enum Primitive { +pub(crate) enum Primitive { Shadow(Shadow), Quad(Quad), Path(Path), @@ -439,7 +496,9 @@ pub(crate) enum PrimitiveBatch<'a> { #[derive(Default, Debug, Clone, Eq, PartialEq)] #[repr(C)] pub struct Quad { - pub order: u32, // Initially a LayerId, then a DrawOrder. + pub view_id: ViewId, + pub layer_id: LayerId, + pub order: DrawOrder, pub bounds: Bounds, pub content_mask: ContentMask, pub background: Hsla, @@ -469,7 +528,9 @@ impl From for Primitive { #[derive(Debug, Clone, Eq, PartialEq)] #[repr(C)] pub struct Underline { - pub order: u32, + pub view_id: ViewId, + pub layer_id: LayerId, + pub order: DrawOrder, pub bounds: Bounds, pub content_mask: ContentMask, pub thickness: ScaledPixels, @@ -498,7 +559,9 @@ impl From for Primitive { #[derive(Debug, Clone, Eq, PartialEq)] #[repr(C)] pub struct Shadow { - pub order: u32, + pub view_id: ViewId, + pub layer_id: LayerId, + pub order: DrawOrder, pub bounds: Bounds, pub corner_radii: Corners, pub content_mask: ContentMask, @@ -526,8 +589,10 @@ impl From for Primitive { #[derive(Clone, Debug, Eq, PartialEq)] #[repr(C)] -pub struct MonochromeSprite { - pub order: u32, +pub(crate) struct MonochromeSprite { + pub view_id: ViewId, + pub layer_id: LayerId, + pub order: DrawOrder, pub bounds: Bounds, pub content_mask: ContentMask, pub color: Hsla, @@ -557,8 +622,10 @@ impl From for Primitive { #[derive(Clone, Debug, Eq, PartialEq)] #[repr(C)] -pub struct PolychromeSprite { - pub order: u32, +pub(crate) struct PolychromeSprite { + pub view_id: ViewId, + pub layer_id: LayerId, + pub order: DrawOrder, pub bounds: Bounds, pub content_mask: ContentMask, pub corner_radii: Corners, @@ -589,7 +656,9 @@ impl From for Primitive { #[derive(Clone, Debug, Eq, PartialEq)] pub struct Surface { - pub order: u32, + pub view_id: ViewId, + pub layer_id: LayerId, + pub order: DrawOrder, pub bounds: Bounds, pub content_mask: ContentMask, pub image_buffer: media::core_video::CVImageBuffer, @@ -619,7 +688,9 @@ pub(crate) struct PathId(pub(crate) usize); #[derive(Debug)] pub struct Path { pub(crate) id: PathId, - order: u32, + pub(crate) view_id: ViewId, + layer_id: LayerId, + order: DrawOrder, pub(crate) bounds: Bounds

, pub(crate) content_mask: ContentMask

, pub(crate) vertices: Vec>, @@ -633,7 +704,9 @@ impl Path { pub fn new(start: Point) -> Self { Self { id: PathId(0), - order: 0, + view_id: ViewId::default(), + layer_id: LayerId::default(), + order: DrawOrder::default(), vertices: Vec::new(), start, current: start, @@ -650,6 +723,8 @@ impl Path { pub fn scale(&self, factor: f32) -> Path { Path { id: self.id, + view_id: self.view_id, + layer_id: self.layer_id, order: self.order, bounds: self.bounds.scale(factor), content_mask: self.content_mask.scale(factor), diff --git a/crates/gpui/src/shared_url.rs b/crates/gpui/src/shared_url.rs new file mode 100644 index 0000000000..8fb9018943 --- /dev/null +++ b/crates/gpui/src/shared_url.rs @@ -0,0 +1,25 @@ +use derive_more::{Deref, DerefMut}; + +use crate::SharedString; + +/// A [`SharedString`] containing a URL. +#[derive(Deref, DerefMut, Default, PartialEq, Eq, Hash, Clone)] +pub struct SharedUrl(SharedString); + +impl std::fmt::Debug for SharedUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl std::fmt::Display for SharedUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.as_ref()) + } +} + +impl> From for SharedUrl { + fn from(value: T) -> Self { + Self(value.into()) + } +} diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 244ccebf24..095233280e 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -1,10 +1,10 @@ use std::{iter, mem, ops::Range}; use crate::{ - black, phi, point, quad, rems, AbsoluteLength, BorrowWindow, Bounds, ContentMask, Corners, - CornersRefinement, CursorStyle, DefiniteLength, Edges, EdgesRefinement, Font, FontFeatures, - FontStyle, FontWeight, Hsla, Length, Pixels, Point, PointRefinement, Rgba, SharedString, Size, - SizeRefinement, Styled, TextRun, WindowContext, + black, phi, point, quad, rems, AbsoluteLength, BorrowAppContext, BorrowWindow, Bounds, + ContentMask, Corners, CornersRefinement, CursorStyle, DefiniteLength, Edges, EdgesRefinement, + Font, FontFeatures, FontStyle, FontWeight, Hsla, Length, Pixels, Point, PointRefinement, Rgba, + SharedString, Size, SizeRefinement, Styled, TextRun, WindowContext, }; use collections::HashSet; use refineable::{Cascade, Refineable}; @@ -42,7 +42,7 @@ pub struct Style { #[refineable] pub inset: Edges, - // Size properies + // Size properties /// Sets the initial size of the item #[refineable] pub size: Size, @@ -79,7 +79,7 @@ pub struct Style { #[refineable] pub gap: Size, - // Flexbox properies + // Flexbox properties /// Which direction does the main axis flow in? pub flex_direction: FlexDirection, /// Should elements wrap, or stay in a single line? @@ -146,7 +146,7 @@ pub enum WhiteSpace { Nowrap, } -#[derive(Refineable, Clone, Debug)] +#[derive(Refineable, Clone, Debug, PartialEq)] #[refineable(Debug)] pub struct TextStyle { pub color: Hsla, @@ -165,7 +165,8 @@ impl Default for TextStyle { fn default() -> Self { TextStyle { color: black(), - font_family: "Helvetica".into(), // todo!("Get a font we know exists on the system") + // Helvetica is a web safe font, so it should be available + font_family: "Helvetica".into(), font_features: FontFeatures::default(), font_size: rems(1.).into(), line_height: phi(), @@ -307,54 +308,54 @@ impl Style { } } - // pub fn apply_text_style(&self, cx: &mut C, f: F) -> R - // where - // C: BorrowAppContext, - // F: FnOnce(&mut C) -> R, - // { - // if self.text.is_some() { - // cx.with_text_style(Some(self.text.clone()), f) - // } else { - // f(cx) - // } - // } + pub fn apply_text_style(&self, cx: &mut C, f: F) -> R + where + C: BorrowAppContext, + F: FnOnce(&mut C) -> R, + { + if self.text.is_some() { + cx.with_text_style(Some(self.text.clone()), f) + } else { + f(cx) + } + } - // /// Apply overflow to content mask - // pub fn apply_overflow(&self, bounds: Bounds, cx: &mut C, f: F) -> R - // where - // C: BorrowWindow, - // F: FnOnce(&mut C) -> R, - // { - // let current_mask = cx.content_mask(); + /// Apply overflow to content mask + pub fn apply_overflow(&self, bounds: Bounds, cx: &mut C, f: F) -> R + where + C: BorrowWindow, + F: FnOnce(&mut C) -> R, + { + let current_mask = cx.content_mask(); - // let min = current_mask.bounds.origin; - // let max = current_mask.bounds.lower_right(); + let min = current_mask.bounds.origin; + let max = current_mask.bounds.lower_right(); - // let mask_bounds = match ( - // self.overflow.x == Overflow::Visible, - // self.overflow.y == Overflow::Visible, - // ) { - // // x and y both visible - // (true, true) => return f(cx), - // // x visible, y hidden - // (true, false) => Bounds::from_corners( - // point(min.x, bounds.origin.y), - // point(max.x, bounds.lower_right().y), - // ), - // // x hidden, y visible - // (false, true) => Bounds::from_corners( - // point(bounds.origin.x, min.y), - // point(bounds.lower_right().x, max.y), - // ), - // // both hidden - // (false, false) => bounds, - // }; - // let mask = ContentMask { - // bounds: mask_bounds, - // }; + let mask_bounds = match ( + self.overflow.x == Overflow::Visible, + self.overflow.y == Overflow::Visible, + ) { + // x and y both visible + (true, true) => return f(cx), + // x visible, y hidden + (true, false) => Bounds::from_corners( + point(min.x, bounds.origin.y), + point(max.x, bounds.lower_right().y), + ), + // x hidden, y visible + (false, true) => Bounds::from_corners( + point(bounds.origin.x, min.y), + point(bounds.lower_right().x, max.y), + ), + // both hidden + (false, false) => bounds, + }; + let mask = ContentMask { + bounds: mask_bounds, + }; - // cx.with_content_mask(Some(mask), f) - // } + cx.with_content_mask(Some(mask), f) + } /// Paints the background of an element styled with this style. pub fn paint( @@ -385,7 +386,7 @@ impl Style { let background_color = self.background.as_ref().and_then(Fill::color); if background_color.map_or(false, |color| !color.is_transparent()) { - cx.with_z_index(1, |cx| { + cx.with_z_index(0, |cx| { let mut border_color = background_color.unwrap_or_default(); border_color.a = 0.; cx.paint_quad(quad( @@ -398,12 +399,12 @@ impl Style { }); } - cx.with_z_index(2, |cx| { + cx.with_z_index(0, |cx| { continuation(cx); }); if self.is_border_visible() { - cx.with_z_index(3, |cx| { + cx.with_z_index(0, |cx| { let corner_radii = self.corner_radii.to_pixels(bounds.size, rem_size); let border_widths = self.border_widths.to_pixels(rem_size); let max_border_width = border_widths.max(); @@ -501,7 +502,7 @@ impl Default for Style { max_size: Size::auto(), aspect_ratio: None, gap: Size::default(), - // Aligment + // Alignment align_items: None, align_self: None, align_content: None, diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index 2749c31a78..0eba1771f5 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -1,7 +1,7 @@ use crate::{ self as gpui, hsla, point, px, relative, rems, AbsoluteLength, AlignItems, CursorStyle, - DefiniteLength, Display, Fill, FlexDirection, Hsla, JustifyContent, Length, Position, - SharedString, StyleRefinement, Visibility, WhiteSpace, + DefiniteLength, Display, Fill, FlexDirection, FontWeight, Hsla, JustifyContent, Length, + Position, SharedString, StyleRefinement, Visibility, WhiteSpace, }; use crate::{BoxShadow, TextStyleRefinement}; use smallvec::{smallvec, SmallVec}; @@ -494,6 +494,13 @@ pub trait Styled: Sized { self } + fn font_weight(mut self, weight: FontWeight) -> Self { + self.text_style() + .get_or_insert_with(Default::default) + .font_weight = Some(weight); + self + } + fn text_bg(mut self, bg: impl Into) -> Self { self.text_style() .get_or_insert_with(Default::default) diff --git a/crates/gpui/src/subscription.rs b/crates/gpui/src/subscription.rs index b56c9a1ccd..887283d094 100644 --- a/crates/gpui/src/subscription.rs +++ b/crates/gpui/src/subscription.rs @@ -37,10 +37,10 @@ where }))) } - /// Inserts a new `[Subscription]` for the given `emitter_key`. By default, subscriptions + /// Inserts a new [`Subscription`] for the given `emitter_key`. By default, subscriptions /// are inert, meaning that they won't be listed when calling `[SubscriberSet::remove]` or `[SubscriberSet::retain]`. - /// This method returns a tuple of a `[Subscription]` and an `impl FnOnce`, and you can use the latter - /// to activate the `[Subscription]`. + /// This method returns a tuple of a [`Subscription`] and an `impl FnOnce`, and you can use the latter + /// to activate the [`Subscription`]. #[must_use] pub fn insert( &self, diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index 0ebd394217..26d5a2e69e 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -14,6 +14,7 @@ use taffy::{ pub struct TaffyLayoutEngine { taffy: Taffy, + styles: FxHashMap, children_to_parents: FxHashMap, absolute_layout_bounds: FxHashMap>, computed_layouts: FxHashSet, @@ -35,6 +36,7 @@ impl TaffyLayoutEngine { pub fn new() -> Self { TaffyLayoutEngine { taffy: Taffy::new(), + styles: FxHashMap::default(), children_to_parents: FxHashMap::default(), absolute_layout_bounds: FxHashMap::default(), computed_layouts: FxHashSet::default(), @@ -48,6 +50,11 @@ impl TaffyLayoutEngine { self.absolute_layout_bounds.clear(); self.computed_layouts.clear(); self.nodes_to_measure.clear(); + self.styles.clear(); + } + + pub fn requested_style(&self, layout_id: LayoutId) -> Option<&Style> { + self.styles.get(&layout_id) } pub fn request_layout( @@ -56,21 +63,26 @@ impl TaffyLayoutEngine { rem_size: Pixels, children: &[LayoutId], ) -> LayoutId { - let style = style.to_taffy(rem_size); - if children.is_empty() { - self.taffy.new_leaf(style).expect(EXPECT_MESSAGE).into() + let taffy_style = style.to_taffy(rem_size); + let layout_id = if children.is_empty() { + self.taffy + .new_leaf(taffy_style) + .expect(EXPECT_MESSAGE) + .into() } else { let parent_id = self .taffy // This is safe because LayoutId is repr(transparent) to taffy::tree::NodeId. - .new_with_children(style, unsafe { std::mem::transmute(children) }) + .new_with_children(taffy_style, unsafe { std::mem::transmute(children) }) .expect(EXPECT_MESSAGE) .into(); for child_id in children { self.children_to_parents.insert(*child_id, parent_id); } parent_id - } + }; + self.styles.insert(layout_id, style.clone()); + layout_id } pub fn request_measured_layout( @@ -80,14 +92,16 @@ impl TaffyLayoutEngine { measure: impl FnMut(Size>, Size, &mut WindowContext) -> Size + 'static, ) -> LayoutId { - let style = style.to_taffy(rem_size); + let style = style.clone(); + let taffy_style = style.to_taffy(rem_size); let layout_id = self .taffy - .new_leaf_with_context(style, ()) + .new_leaf_with_context(taffy_style, ()) .expect(EXPECT_MESSAGE) .into(); self.nodes_to_measure.insert(layout_id, Box::new(measure)); + self.styles.insert(layout_id, style.clone()); layout_id } @@ -271,20 +285,6 @@ impl ToTaffy for Style { } } -// impl ToTaffy for Bounds { -// type Output = taffy::prelude::Bounds; - -// fn to_taffy( -// &self, -// rem_size: Pixels, -// ) -> taffy::prelude::Bounds { -// taffy::prelude::Bounds { -// origin: self.origin.to_taffy(rem_size), -// size: self.size.to_taffy(rem_size), -// } -// } -// } - impl ToTaffy for Length { fn to_taffy(&self, rem_size: Pixels) -> taffy::prelude::LengthPercentageAuto { match self { diff --git a/crates/gpui/src/test.rs b/crates/gpui/src/test.rs index 5a21576fb2..f53d19fdc8 100644 --- a/crates/gpui/src/test.rs +++ b/crates/gpui/src/test.rs @@ -1,3 +1,30 @@ +//! Test support for GPUI. +//! +//! GPUI provides first-class support for testing, which includes a macro to run test that rely on having a context, +//! and a test implementation of the `ForegroundExecutor` and `BackgroundExecutor` which ensure that your tests run +//! deterministically even in the face of arbitrary parallelism. +//! +//! The output of the `gpui::test` macro is understood by other rust test runners, so you can use it with `cargo test` +//! or `cargo-nextest`, or another runner of your choice. +//! +//! To make it possible to test collaborative user interfaces (like Zed) you can ask for as many different contexts +//! as you need. +//! +//! ## Example +//! +//! ``` +//! use gpui; +//! +//! #[gpui::test] +//! async fn test_example(cx: &TestAppContext) { +//! assert!(true) +//! } +//! +//! #[gpui::test] +//! async fn test_collaboration_example(cx_a: &TestAppContext, cx_b: &TestAppContext) { +//! assert!(true) +//! } +//! ``` use crate::{Entity, Subscription, TestAppContext, TestDispatcher}; use futures::StreamExt as _; use rand::prelude::*; @@ -12,7 +39,6 @@ pub fn run_test( max_retries: usize, test_fn: &mut (dyn RefUnwindSafe + Fn(TestDispatcher, u64)), on_fail_fn: Option, - _fn_name: String, // todo!("re-enable fn_name") ) { let starting_seed = env::var("SEED") .map(|seed| seed.parse().expect("invalid SEED variable")) @@ -68,6 +94,7 @@ impl futures::Stream for Observation { } } +/// observe returns a stream of the change events from the given `View` or `Model` pub fn observe(entity: &impl Entity, cx: &mut TestAppContext) -> Observation<()> { let (tx, rx) = smol::channel::unbounded(); let _subscription = cx.update(|cx| { diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index 0969560e95..1c9de5ea04 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -9,11 +9,11 @@ pub use line_layout::*; pub use line_wrapper::*; use crate::{ - px, Bounds, DevicePixels, Hsla, Pixels, PlatformTextSystem, Point, Result, SharedString, Size, - UnderlineStyle, + px, Bounds, DevicePixels, EntityId, Hsla, Pixels, PlatformTextSystem, Point, Result, + SharedString, Size, UnderlineStyle, }; use anyhow::anyhow; -use collections::FxHashMap; +use collections::{BTreeSet, FxHashMap, FxHashSet}; use core::fmt; use itertools::Itertools; use parking_lot::{Mutex, RwLock, RwLockUpgradableReadGuard}; @@ -47,7 +47,7 @@ pub struct TextSystem { } impl TextSystem { - pub fn new(platform_text_system: Arc) -> Self { + pub(crate) fn new(platform_text_system: Arc) -> Self { TextSystem { line_layout_cache: Arc::new(LineLayoutCache::new(platform_text_system.clone())), platform_text_system, @@ -65,6 +65,20 @@ impl TextSystem { } } + pub fn all_font_names(&self) -> Vec { + let mut names: BTreeSet<_> = self + .platform_text_system + .all_font_names() + .into_iter() + .collect(); + names.extend(self.platform_text_system.all_font_families().into_iter()); + names.extend( + self.fallback_font_stack + .iter() + .map(|font| font.family.to_string()), + ); + names.into_iter().collect() + } pub fn add_fonts(&self, fonts: &[Arc>]) -> Result<()> { self.platform_text_system.add_fonts(fonts) } @@ -90,7 +104,6 @@ impl TextSystem { if let Ok(font_id) = self.font_id(font) { return font_id; } - for fallback in &self.fallback_font_stack { if let Ok(font_id) = self.font_id(fallback) { return font_id; @@ -186,6 +199,10 @@ impl TextSystem { } } + pub fn with_view(&self, view_id: EntityId, f: impl FnOnce() -> R) -> R { + self.line_layout_cache.with_view(view_id, f) + } + pub fn layout_line( &self, text: &str, @@ -258,7 +275,7 @@ impl TextSystem { pub fn shape_text( &self, - text: &str, // todo!("pass a SharedString and preserve it when passed a single line?") + text: SharedString, font_size: Pixels, runs: &[TextRun], wrap_width: Option, @@ -268,8 +285,8 @@ impl TextSystem { let mut lines = SmallVec::new(); let mut line_start = 0; - for line_text in text.split('\n') { - let line_text = SharedString::from(line_text.to_string()); + + let mut process_line = |line_text: SharedString| { let line_end = line_start + line_text.len(); let mut last_font: Option = None; @@ -335,6 +352,24 @@ impl TextSystem { } font_runs.clear(); + }; + + let mut split_lines = text.split('\n'); + let mut processed = false; + + if let Some(first_line) = split_lines.next() { + if let Some(second_line) = split_lines.next() { + processed = true; + process_line(first_line.to_string().into()); + process_line(second_line.to_string().into()); + for line_text in split_lines { + process_line(line_text.to_string().into()); + } + } + } + + if !processed { + process_line(text); } self.font_runs_pool.lock().push(font_runs); @@ -342,32 +377,24 @@ impl TextSystem { Ok(lines) } - pub fn start_frame(&self) { - self.line_layout_cache.start_frame() + pub fn finish_frame(&self, reused_views: &FxHashSet) { + self.line_layout_cache.finish_frame(reused_views) } - pub fn line_wrapper( - self: &Arc, - font: Font, - font_size: Pixels, - ) -> Result { + pub fn line_wrapper(self: &Arc, font: Font, font_size: Pixels) -> LineWrapperHandle { let lock = &mut self.wrapper_pool.lock(); - let font_id = self.font_id(&font)?; + let font_id = self.resolve_font(&font); let wrappers = lock .entry(FontIdWithSize { font_id, font_size }) .or_default(); - let wrapper = wrappers.pop().map(anyhow::Ok).unwrap_or_else(|| { - Ok(LineWrapper::new( - font_id, - font_size, - self.platform_text_system.clone(), - )) - })?; + let wrapper = wrappers.pop().unwrap_or_else(|| { + LineWrapper::new(font_id, font_size, self.platform_text_system.clone()) + }); - Ok(LineWrapperHandle { + LineWrapperHandle { wrapper: Some(wrapper), text_system: self.clone(), - }) + } } pub fn raster_bounds(&self, params: &RenderGlyphParams) -> Result> { diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index 6506d7794c..6c466f9680 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -1,5 +1,5 @@ -use crate::{px, FontId, GlyphId, Pixels, PlatformTextSystem, Point, Size}; -use collections::FxHashMap; +use crate::{px, EntityId, FontId, GlyphId, Pixels, PlatformTextSystem, Point, Size}; +use collections::{FxHashMap, FxHashSet}; use parking_lot::{Mutex, RwLock, RwLockUpgradableReadGuard}; use smallvec::SmallVec; use std::{ @@ -236,6 +236,7 @@ impl WrappedLineLayout { } pub(crate) struct LineLayoutCache { + view_stack: Mutex>, previous_frame: Mutex>>, current_frame: RwLock>>, previous_frame_wrapped: Mutex>>, @@ -246,6 +247,7 @@ pub(crate) struct LineLayoutCache { impl LineLayoutCache { pub fn new(platform_text_system: Arc) -> Self { Self { + view_stack: Mutex::default(), previous_frame: Mutex::default(), current_frame: RwLock::default(), previous_frame_wrapped: Mutex::default(), @@ -254,11 +256,43 @@ impl LineLayoutCache { } } - pub fn start_frame(&self) { + pub fn finish_frame(&self, reused_views: &FxHashSet) { + debug_assert_eq!(self.view_stack.lock().len(), 0); + let mut prev_frame = self.previous_frame.lock(); let mut curr_frame = self.current_frame.write(); + for (key, layout) in prev_frame.drain() { + if key + .parent_view_id + .map_or(false, |view_id| reused_views.contains(&view_id)) + { + curr_frame.insert(key, layout); + } + } std::mem::swap(&mut *prev_frame, &mut *curr_frame); - curr_frame.clear(); + + let mut prev_frame_wrapped = self.previous_frame_wrapped.lock(); + let mut curr_frame_wrapped = self.current_frame_wrapped.write(); + for (key, layout) in prev_frame_wrapped.drain() { + if key + .parent_view_id + .map_or(false, |view_id| reused_views.contains(&view_id)) + { + curr_frame_wrapped.insert(key, layout); + } + } + std::mem::swap(&mut *prev_frame_wrapped, &mut *curr_frame_wrapped); + } + + pub fn with_view(&self, view_id: EntityId, f: impl FnOnce() -> R) -> R { + self.view_stack.lock().push(view_id); + let result = f(); + self.view_stack.lock().pop(); + result + } + + fn parent_view_id(&self) -> Option { + self.view_stack.lock().last().copied() } pub fn layout_wrapped_line( @@ -273,6 +307,7 @@ impl LineLayoutCache { font_size, runs, wrap_width, + parent_view_id: self.parent_view_id(), } as &dyn AsCacheKeyRef; let current_frame = self.current_frame_wrapped.upgradable_read(); @@ -301,6 +336,7 @@ impl LineLayoutCache { font_size, runs: SmallVec::from(runs), wrap_width, + parent_view_id: self.parent_view_id(), }; current_frame.insert(key, layout.clone()); layout @@ -313,6 +349,7 @@ impl LineLayoutCache { font_size, runs, wrap_width: None, + parent_view_id: self.parent_view_id(), } as &dyn AsCacheKeyRef; let current_frame = self.current_frame.upgradable_read(); @@ -331,6 +368,7 @@ impl LineLayoutCache { font_size, runs: SmallVec::from(runs), wrap_width: None, + parent_view_id: self.parent_view_id(), }; current_frame.insert(key, layout.clone()); layout @@ -348,12 +386,13 @@ trait AsCacheKeyRef { fn as_cache_key_ref(&self) -> CacheKeyRef; } -#[derive(Eq)] +#[derive(Debug, Eq)] struct CacheKey { text: String, font_size: Pixels, runs: SmallVec<[FontRun; 1]>, wrap_width: Option, + parent_view_id: Option, } #[derive(Copy, Clone, PartialEq, Eq, Hash)] @@ -362,6 +401,7 @@ struct CacheKeyRef<'a> { font_size: Pixels, runs: &'a [FontRun], wrap_width: Option, + parent_view_id: Option, } impl<'a> PartialEq for (dyn AsCacheKeyRef + 'a) { @@ -385,6 +425,7 @@ impl AsCacheKeyRef for CacheKey { font_size: self.font_size, runs: self.runs.as_slice(), wrap_width: self.wrap_width, + parent_view_id: self.parent_view_id, } } } diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index e2f0a8a5fd..1c5b2a8f99 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -13,7 +13,7 @@ pub struct LineWrapper { impl LineWrapper { pub const MAX_INDENT: u32 = 256; - pub fn new( + pub(crate) fn new( font_id: FontId, font_size: Pixels, text_system: Arc, @@ -137,13 +137,13 @@ impl Boundary { #[cfg(test)] mod tests { use super::*; - use crate::{font, TestAppContext, TestDispatcher}; + use crate::{font, TestAppContext, TestDispatcher, TextRun, WrapBoundary}; use rand::prelude::*; #[test] fn test_wrap_line() { let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0)); - let cx = TestAppContext::new(dispatcher); + let cx = TestAppContext::new(dispatcher, None); cx.update(|cx| { let text_system = cx.text_system().clone(); @@ -206,75 +206,70 @@ mod tests { }); } - // todo!("move this to a test on TextSystem::layout_text") - // todo! repeat this test - // #[test] - // fn test_wrap_shaped_line() { - // App::test().run(|cx| { - // let text_system = cx.text_system().clone(); + // For compatibility with the test macro + use crate as gpui; - // let normal = TextRun { - // len: 0, - // font: font("Helvetica"), - // color: Default::default(), - // underline: Default::default(), - // }; - // let bold = TextRun { - // len: 0, - // font: font("Helvetica").bold(), - // color: Default::default(), - // underline: Default::default(), - // }; + #[crate::test] + fn test_wrap_shaped_line(cx: &mut TestAppContext) { + cx.update(|cx| { + let text_system = cx.text_system().clone(); - // impl TextRun { - // fn with_len(&self, len: usize) -> Self { - // let mut this = self.clone(); - // this.len = len; - // this - // } - // } + let normal = TextRun { + len: 0, + font: font("Helvetica"), + color: Default::default(), + underline: Default::default(), + background_color: None, + }; + let bold = TextRun { + len: 0, + font: font("Helvetica").bold(), + color: Default::default(), + underline: Default::default(), + background_color: None, + }; - // let text = "aa bbb cccc ddddd eeee".into(); - // let lines = text_system - // .layout_text( - // &text, - // px(16.), - // &[ - // normal.with_len(4), - // bold.with_len(5), - // normal.with_len(6), - // bold.with_len(1), - // normal.with_len(7), - // ], - // None, - // ) - // .unwrap(); - // let line = &lines[0]; + impl TextRun { + fn with_len(&self, len: usize) -> Self { + let mut this = self.clone(); + this.len = len; + this + } + } - // let mut wrapper = LineWrapper::new( - // text_system.font_id(&normal.font).unwrap(), - // px(16.), - // text_system.platform_text_system.clone(), - // ); - // assert_eq!( - // wrapper - // .wrap_shaped_line(&text, &line, px(72.)) - // .collect::>(), - // &[ - // ShapedBoundary { - // run_ix: 1, - // glyph_ix: 3 - // }, - // ShapedBoundary { - // run_ix: 2, - // glyph_ix: 3 - // }, - // ShapedBoundary { - // run_ix: 4, - // glyph_ix: 2 - // } - // ], - // ); - // }); - // } + let text = "aa bbb cccc ddddd eeee".into(); + let lines = text_system + .shape_text( + text, + px(16.), + &[ + normal.with_len(4), + bold.with_len(5), + normal.with_len(6), + bold.with_len(1), + normal.with_len(7), + ], + Some(px(72.)), + ) + .unwrap(); + + assert_eq!( + lines[0].layout.wrap_boundaries(), + &[ + WrapBoundary { + run_ix: 1, + glyph_ix: 3 + }, + WrapBoundary { + run_ix: 2, + glyph_ix: 3 + }, + WrapBoundary { + run_ix: 4, + glyph_ix: 2 + } + ], + ); + }); + } } diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index 4472da02e7..e485aa3e57 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -1,8 +1,10 @@ +#![deny(missing_docs)] + use crate::{ seal::Sealed, AnyElement, AnyModel, AnyWeakModel, AppContext, AvailableSpace, BorrowWindow, - Bounds, Element, ElementId, Entity, EntityId, Flatten, FocusHandle, FocusableView, IntoElement, - LayoutId, Model, Pixels, Point, Render, Size, ViewContext, VisualContext, WeakModel, - WindowContext, + Bounds, ContentMask, Element, ElementId, Entity, EntityId, Flatten, FocusHandle, FocusableView, + IntoElement, LayoutId, Model, Pixels, Point, Render, Size, StackingOrder, Style, TextStyle, + ViewContext, VisualContext, WeakModel, WindowContext, }; use anyhow::{Context, Result}; use std::{ @@ -11,12 +13,29 @@ use std::{ hash::{Hash, Hasher}, }; +/// A view is a piece of state that can be presented on screen by implementing the [Render] trait. +/// Views implement [Element] and can composed with other views, and every window is created with a root view. pub struct View { + /// A view is just a [Model] whose type implements `Render`, and the model is accessible via this field. pub model: Model, } impl Sealed for View {} +#[doc(hidden)] +pub struct AnyViewState { + root_style: Style, + cache_key: Option, + element: Option, +} + +struct ViewCacheKey { + bounds: Bounds, + stacking_order: StackingOrder, + content_mask: ContentMask, + text_style: TextStyle, +} + impl Entity for View { type Weak = WeakView; @@ -45,6 +64,7 @@ impl View { Entity::downgrade(self) } + /// Update the view's state with the given function, which is passed a mutable reference and a context. pub fn update( &self, cx: &mut C, @@ -56,20 +76,12 @@ impl View { cx.update_view(self, f) } + /// Obtain a read-only reference to this view's state. pub fn read<'a>(&self, cx: &'a AppContext) -> &'a V { self.model.read(cx) } - // pub fn render_with(&self, component: E) -> RenderViewWith - // where - // E: 'static + Element, - // { - // RenderViewWith { - // view: self.clone(), - // element: Some(component), - // } - // } - + /// Gets a [FocusHandle] for this view when its state implements [FocusableView]. pub fn focus_handle(&self, cx: &AppContext) -> FocusHandle where V: FocusableView, @@ -86,13 +98,15 @@ impl Element for View { _state: Option, cx: &mut WindowContext, ) -> (LayoutId, Self::State) { - let mut element = self.update(cx, |view, cx| view.render(cx).into_any_element()); - let layout_id = element.request_layout(cx); - (layout_id, Some(element)) + cx.with_view_id(self.entity_id(), |cx| { + let mut element = self.update(cx, |view, cx| view.render(cx).into_any_element()); + let layout_id = element.request_layout(cx); + (layout_id, Some(element)) + }) } fn paint(&mut self, _: Bounds, element: &mut Self::State, cx: &mut WindowContext) { - element.take().unwrap().paint(cx); + cx.paint_view(self.entity_id(), |cx| element.take().unwrap().paint(cx)); } } @@ -126,19 +140,24 @@ impl PartialEq for View { impl Eq for View {} +/// A weak variant of [View] which does not prevent the view from being released. pub struct WeakView { pub(crate) model: WeakModel, } impl WeakView { + /// Gets the entity id associated with this handle. pub fn entity_id(&self) -> EntityId { self.model.entity_id } + /// Obtain a strong handle for the view if it hasn't been released. pub fn upgrade(&self) -> Option> { Entity::upgrade_from(self) } + /// Update this view's state if it hasn't been released. + /// Returns an error if this view has been released. pub fn update( &self, cx: &mut C, @@ -152,9 +171,10 @@ impl WeakView { Ok(view.update(cx, f)).flatten() } + /// Assert that the view referenced by this handle has been released. #[cfg(any(test, feature = "test-support"))] - pub fn assert_dropped(&self) { - self.model.assert_dropped() + pub fn assert_released(&self) { + self.model.assert_released() } } @@ -180,37 +200,50 @@ impl PartialEq for WeakView { impl Eq for WeakView {} +/// A dynamically-typed handle to a view, which can be downcast to a [View] for a specific type. #[derive(Clone, Debug)] pub struct AnyView { model: AnyModel, - layout: fn(&AnyView, &mut WindowContext) -> (LayoutId, AnyElement), - paint: fn(&AnyView, &mut AnyElement, &mut WindowContext), + request_layout: fn(&AnyView, &mut WindowContext) -> (LayoutId, AnyElement), + cache: bool, } impl AnyView { + /// Indicate that this view should be cached when using it as an element. + /// When using this method, the view's previous layout and paint will be recycled from the previous frame if [ViewContext::notify] has not been called since it was rendered. + /// The one exception is when [WindowContext::refresh] is called, in which case caching is ignored. + pub fn cached(mut self) -> Self { + self.cache = true; + self + } + + /// Convert this to a weak handle. pub fn downgrade(&self) -> AnyWeakView { AnyWeakView { model: self.model.downgrade(), - layout: self.layout, - paint: self.paint, + layout: self.request_layout, } } + /// Convert this to a [View] of a specific type. + /// If this handle does not contain a view of the specified type, returns itself in an `Err` variant. pub fn downcast(self) -> Result, Self> { match self.model.downcast() { Ok(model) => Ok(View { model }), Err(model) => Err(Self { model, - layout: self.layout, - paint: self.paint, + request_layout: self.request_layout, + cache: self.cache, }), } } + /// Gets the [TypeId] of the underlying view. pub fn entity_type(&self) -> TypeId { self.model.entity_type } + /// Gets the entity id of this handle. pub fn entity_id(&self) -> EntityId { self.model.entity_id() } @@ -221,10 +254,12 @@ impl AnyView { available_space: Size, cx: &mut WindowContext, ) { - cx.with_absolute_element_offset(origin, |cx| { - let (layout_id, mut rendered_element) = (self.layout)(self, cx); - cx.compute_layout(layout_id, available_space); - (self.paint)(self, &mut rendered_element, cx); + cx.paint_view(self.entity_id(), |cx| { + cx.with_absolute_element_offset(origin, |cx| { + let (layout_id, mut rendered_element) = (self.request_layout)(self, cx); + cx.compute_layout(layout_id, available_space); + rendered_element.paint(cx) + }); }) } } @@ -233,30 +268,69 @@ impl From> for AnyView { fn from(value: View) -> Self { AnyView { model: value.model.into_any(), - layout: any_view::layout::, - paint: any_view::paint, + request_layout: any_view::request_layout::, + cache: false, } } } impl Element for AnyView { - type State = Option; + type State = AnyViewState; fn request_layout( &mut self, - _state: Option, + state: Option, cx: &mut WindowContext, ) -> (LayoutId, Self::State) { - let (layout_id, state) = (self.layout)(self, cx); - (layout_id, Some(state)) + cx.with_view_id(self.entity_id(), |cx| { + if self.cache { + if let Some(state) = state { + let layout_id = cx.request_layout(&state.root_style, None); + return (layout_id, state); + } + } + + let (layout_id, element) = (self.request_layout)(self, cx); + let root_style = cx.layout_style(layout_id).unwrap().clone(); + let state = AnyViewState { + root_style, + cache_key: None, + element: Some(element), + }; + (layout_id, state) + }) } - fn paint(&mut self, _: Bounds, state: &mut Self::State, cx: &mut WindowContext) { - debug_assert!( - state.is_some(), - "state is None. Did you include an AnyView twice in the tree?" - ); - (self.paint)(self, state.as_mut().unwrap(), cx) + fn paint(&mut self, bounds: Bounds, state: &mut Self::State, cx: &mut WindowContext) { + cx.paint_view(self.entity_id(), |cx| { + if !self.cache { + state.element.take().unwrap().paint(cx); + return; + } + + if let Some(cache_key) = state.cache_key.as_mut() { + if cache_key.bounds == bounds + && cache_key.content_mask == cx.content_mask() + && cache_key.stacking_order == *cx.stacking_order() + && cache_key.text_style == cx.text_style() + && !cx.window.dirty_views.contains(&self.entity_id()) + && !cx.window.refreshing + { + cx.reuse_view(); + return; + } + } + + let mut element = (self.request_layout)(self, cx).1; + element.draw(bounds.origin, bounds.size.into(), cx); + + state.cache_key = Some(ViewCacheKey { + bounds, + stacking_order: cx.stacking_order().clone(), + content_mask: cx.content_mask(), + text_style: cx.text_style(), + }); + }) } } @@ -284,19 +358,20 @@ impl IntoElement for AnyView { } } +/// A weak, dynamically-typed view handle that does not prevent the view from being released. pub struct AnyWeakView { model: AnyWeakModel, layout: fn(&AnyView, &mut WindowContext) -> (LayoutId, AnyElement), - paint: fn(&AnyView, &mut AnyElement, &mut WindowContext), } impl AnyWeakView { + /// Convert to a strongly-typed handle if the referenced view has not yet been released. pub fn upgrade(&self) -> Option { let model = self.model.upgrade()?; Some(AnyView { model, - layout: self.layout, - paint: self.paint, + request_layout: self.layout, + cache: false, }) } } @@ -305,8 +380,7 @@ impl From> for AnyWeakView { fn from(view: WeakView) -> Self { Self { model: view.model.into(), - layout: any_view::layout::, - paint: any_view::paint, + layout: any_view::request_layout::, } } } @@ -328,7 +402,7 @@ impl std::fmt::Debug for AnyWeakView { mod any_view { use crate::{AnyElement, AnyView, IntoElement, LayoutId, Render, WindowContext}; - pub(crate) fn layout( + pub(crate) fn request_layout( view: &AnyView, cx: &mut WindowContext, ) -> (LayoutId, AnyElement) { @@ -337,8 +411,4 @@ mod any_view { let layout_id = element.request_layout(cx); (layout_id, element) } - - pub(crate) fn paint(_view: &AnyView, element: &mut AnyElement, cx: &mut WindowContext) { - element.paint(cx); - } } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 7e4c5f93f9..2329a5251e 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1,18 +1,21 @@ +#![deny(missing_docs)] + use crate::{ - px, size, transparent_black, Action, AnyDrag, AnyView, AppContext, Arena, ArenaBox, ArenaRef, + px, size, transparent_black, Action, AnyDrag, AnyTooltip, AnyView, AppContext, Arena, AsyncWindowContext, AvailableSpace, Bounds, BoxShadow, Context, Corners, CursorStyle, DevicePixels, DispatchActionListener, DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity, EntityId, EventEmitter, FileDropEvent, Flatten, FontId, GlobalElementId, GlyphId, Hsla, - ImageData, InputEvent, IsZero, KeyBinding, KeyContext, KeyDownEvent, KeystrokeEvent, LayoutId, - Model, ModelContext, Modifiers, MonochromeSprite, MouseButton, MouseMoveEvent, MouseUpEvent, - Path, Pixels, PlatformAtlas, PlatformDisplay, PlatformInputHandler, PlatformWindow, Point, - PolychromeSprite, PromptLevel, Quad, Render, RenderGlyphParams, RenderImageParams, - RenderSvgParams, ScaledPixels, Scene, SceneBuilder, Shadow, SharedString, Size, Style, - SubscriberSet, Subscription, Surface, TaffyLayoutEngine, Task, Underline, UnderlineStyle, View, - VisualContext, WeakView, WindowBounds, WindowOptions, SUBPIXEL_VARIANTS, + ImageData, IsZero, KeyBinding, KeyContext, KeyDownEvent, KeyEvent, KeystrokeEvent, LayoutId, + Model, ModelContext, Modifiers, MonochromeSprite, MouseButton, MouseEvent, MouseMoveEvent, + MouseUpEvent, Path, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, + PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, PromptLevel, Quad, Render, + RenderGlyphParams, RenderImageParams, RenderSvgParams, ScaledPixels, Scene, Shadow, + SharedString, Size, Style, SubscriberSet, Subscription, Surface, TaffyLayoutEngine, Task, + Underline, UnderlineStyle, View, VisualContext, WeakView, WindowBounds, WindowOptions, + SUBPIXEL_VARIANTS, }; use anyhow::{anyhow, Context as _, Result}; -use collections::FxHashMap; +use collections::{FxHashMap, FxHashSet}; use derive_more::{Deref, DerefMut}; use futures::{ channel::{mpsc, oneshot}, @@ -27,7 +30,7 @@ use std::{ borrow::{Borrow, BorrowMut, Cow}, cell::RefCell, collections::hash_map::Entry, - fmt::Debug, + fmt::{Debug, Display}, future::Future, hash::{Hash, Hasher}, marker::PhantomData, @@ -85,17 +88,19 @@ pub enum DispatchPhase { } impl DispatchPhase { + /// Returns true if this represents the "bubble" phase. pub fn bubble(self) -> bool { self == DispatchPhase::Bubble } + /// Returns true if this represents the "capture" phase. pub fn capture(self) -> bool { self == DispatchPhase::Capture } } type AnyObserver = Box bool + 'static>; -type AnyMouseListener = ArenaBox; +type AnyMouseListener = Box; type AnyWindowFocusListener = Box bool + 'static>; struct FocusEvent { @@ -103,7 +108,10 @@ struct FocusEvent { current_focus_path: SmallVec<[FocusId; 8]>, } -slotmap::new_key_type! { pub struct FocusId; } +slotmap::new_key_type! { + /// A globally unique identifier for a focusable element. + pub struct FocusId; +} thread_local! { pub(crate) static ELEMENT_ARENA: RefCell = RefCell::new(Arena::new(4 * 1024 * 1024)); @@ -231,6 +239,7 @@ impl Drop for FocusHandle { /// FocusableView allows users of your view to easily /// focus it (using cx.focus_view(view)) pub trait FocusableView: 'static + Render { + /// Returns the focus handle associated with this view. fn focus_handle(&self, cx: &AppContext) -> FocusHandle; } @@ -240,9 +249,11 @@ pub trait ManagedView: FocusableView + EventEmitter {} impl> ManagedView for M {} +/// Emitted by implementers of [`ManagedView`] to indicate the view should be dismissed, such as when a view is presented as a modal. pub struct DismissEvent; // Holds the state for a specific window. +#[doc(hidden)] pub struct Window { pub(crate) handle: AnyWindowHandle, pub(crate) removed: bool, @@ -256,19 +267,19 @@ pub struct Window { pub(crate) element_id_stack: GlobalElementId, pub(crate) rendered_frame: Frame, pub(crate) next_frame: Frame, - frame_arena: Arena, + pub(crate) dirty_views: FxHashSet, pub(crate) focus_handles: Arc>>, focus_listeners: SubscriberSet<(), AnyWindowFocusListener>, - blur_listeners: SubscriberSet<(), AnyObserver>, + focus_lost_listeners: SubscriberSet<(), AnyObserver>, default_prevented: bool, mouse_position: Point, modifiers: Modifiers, - requested_cursor_style: Option, scale_factor: f32, bounds: WindowBounds, bounds_observers: SubscriberSet<(), AnyObserver>, active: bool, pub(crate) dirty: bool, + pub(crate) refreshing: bool, pub(crate) drawing: bool, activation_observers: SubscriberSet<(), AnyObserver>, pub(crate) focus: Option, @@ -280,36 +291,69 @@ pub struct Window { pub(crate) struct ElementStateBox { inner: Box, + parent_view_id: EntityId, #[cfg(debug_assertions)] type_name: &'static str, } +struct RequestedInputHandler { + view_id: EntityId, + handler: Option>, +} + +struct TooltipRequest { + view_id: EntityId, + tooltip: AnyTooltip, +} + pub(crate) struct Frame { focus: Option, + window_active: bool, pub(crate) element_states: FxHashMap, - mouse_listeners: FxHashMap>, + mouse_listeners: FxHashMap>, pub(crate) dispatch_tree: DispatchTree, - pub(crate) scene_builder: SceneBuilder, - pub(crate) depth_map: Vec<(StackingOrder, Bounds)>, + pub(crate) scene: Scene, + pub(crate) depth_map: Vec<(StackingOrder, EntityId, Bounds)>, pub(crate) z_index_stack: StackingOrder, pub(crate) next_stacking_order_id: u32, + next_root_z_index: u8, content_mask_stack: Vec>, element_offset_stack: Vec>, + requested_input_handler: Option, + tooltip_request: Option, + cursor_styles: FxHashMap, + requested_cursor_style: Option, + pub(crate) view_stack: Vec, + pub(crate) reused_views: FxHashSet, + + #[cfg(any(test, feature = "test-support"))] + pub(crate) debug_bounds: collections::FxHashMap>, } impl Frame { fn new(dispatch_tree: DispatchTree) -> Self { Frame { focus: None, + window_active: false, element_states: FxHashMap::default(), mouse_listeners: FxHashMap::default(), dispatch_tree, - scene_builder: SceneBuilder::default(), + scene: Scene::default(), + depth_map: Vec::new(), z_index_stack: StackingOrder::default(), next_stacking_order_id: 0, - depth_map: Default::default(), + next_root_z_index: 0, content_mask_stack: Vec::new(), element_offset_stack: Vec::new(), + requested_input_handler: None, + tooltip_request: None, + cursor_styles: FxHashMap::default(), + requested_cursor_style: None, + view_stack: Vec::new(), + reused_views: FxHashSet::default(), + + #[cfg(any(test, feature = "test-support"))] + debug_bounds: FxHashMap::default(), } } @@ -319,6 +363,14 @@ impl Frame { self.dispatch_tree.clear(); self.depth_map.clear(); self.next_stacking_order_id = 0; + self.next_root_z_index = 0; + self.reused_views.clear(); + self.scene.clear(); + self.requested_input_handler.take(); + self.tooltip_request.take(); + self.cursor_styles.clear(); + self.requested_cursor_style.take(); + debug_assert_eq!(self.view_stack.len(), 0); } fn focus_path(&self) -> SmallVec<[FocusId; 8]> { @@ -326,6 +378,42 @@ impl Frame { .map(|focus_id| self.dispatch_tree.focus_path(focus_id)) .unwrap_or_default() } + + fn finish(&mut self, prev_frame: &mut Self) { + // Reuse mouse listeners that didn't change since the last frame. + for (type_id, listeners) in &mut prev_frame.mouse_listeners { + let next_listeners = self.mouse_listeners.entry(*type_id).or_default(); + for (order, view_id, listener) in listeners.drain(..) { + if self.reused_views.contains(&view_id) { + next_listeners.push((order, view_id, listener)); + } + } + } + + // Reuse entries in the depth map that didn't change since the last frame. + for (order, view_id, bounds) in prev_frame.depth_map.drain(..) { + if self.reused_views.contains(&view_id) { + match self + .depth_map + .binary_search_by(|(level, _, _)| order.cmp(level)) + { + Ok(i) | Err(i) => self.depth_map.insert(i, (order, view_id, bounds)), + } + } + } + + // Retain element states for views that didn't change since the last frame. + for (element_id, state) in prev_frame.element_states.drain() { + if self.reused_views.contains(&state.parent_view_id) { + self.element_states.entry(element_id).or_insert(state); + } + } + + // Reuse geometry that didn't change since the last frame. + self.scene + .reuse_views(&self.reused_views, &mut prev_frame.scene); + self.scene.finish(); + } } impl Window { @@ -334,14 +422,7 @@ impl Window { options: WindowOptions, cx: &mut AppContext, ) -> Self { - let platform_window = cx.platform.open_window( - handle, - options, - Box::new({ - let mut cx = cx.to_async(); - move || handle.update(&mut cx, |_, cx| cx.draw()) - }), - ); + let platform_window = cx.platform.open_window(handle, options); let display_id = platform_window.display().id(); let sprite_atlas = platform_window.sprite_atlas(); let mouse_position = platform_window.mouse_position(); @@ -350,6 +431,12 @@ impl Window { let scale_factor = platform_window.scale_factor(); let bounds = platform_window.bounds(); + platform_window.on_request_frame(Box::new({ + let mut cx = cx.to_async(); + move || { + handle.update(&mut cx, |_, cx| cx.draw()).log_err(); + } + })); platform_window.on_resize(Box::new({ let mut cx = cx.to_async(); move |_, _| { @@ -404,19 +491,19 @@ impl Window { element_id_stack: GlobalElementId::default(), rendered_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), - frame_arena: Arena::new(1024 * 1024), + dirty_views: FxHashSet::default(), focus_handles: Arc::new(RwLock::new(SlotMap::with_key())), focus_listeners: SubscriberSet::new(), - blur_listeners: SubscriberSet::new(), + focus_lost_listeners: SubscriberSet::new(), default_prevented: true, mouse_position, modifiers, - requested_cursor_style: None, scale_factor, bounds, bounds_observers: SubscriberSet::new(), active: false, dirty: false, + refreshing: false, drawing: false, activation_observers: SubscriberSet::new(), focus: None, @@ -434,6 +521,7 @@ impl Window { #[derive(Clone, Debug, Default, PartialEq, Eq)] #[repr(C)] pub struct ContentMask { + /// The bounds pub bounds: Bounds

, } @@ -453,8 +541,8 @@ impl ContentMask { } /// Provides access to application state in the context of a single window. Derefs -/// to an `AppContext`, so you can also pass a `WindowContext` to any method that takes -/// an `AppContext` and call any `AppContext` methods. +/// to an [`AppContext`], so you can also pass a [`WindowContext`] to any method that takes +/// an [`AppContext`] and call any [`AppContext`] methods. pub struct WindowContext<'a> { pub(crate) app: &'a mut AppContext, pub(crate) window: &'a mut Window, @@ -471,8 +559,9 @@ impl<'a> WindowContext<'a> { } /// Mark the window as dirty, scheduling it to be redrawn on the next frame. - pub fn notify(&mut self) { + pub fn refresh(&mut self) { if !self.window.drawing { + self.window.refreshing = true; self.window.dirty = true; } } @@ -482,20 +571,20 @@ impl<'a> WindowContext<'a> { self.window.removed = true; } - /// Obtain a new `FocusHandle`, which allows you to track and manipulate the keyboard focus + /// Obtain a new [`FocusHandle`], which allows you to track and manipulate the keyboard focus /// for elements rendered within this window. pub fn focus_handle(&mut self) -> FocusHandle { FocusHandle::new(&self.window.focus_handles) } - /// Obtain the currently focused `FocusHandle`. If no elements are focused, returns `None`. + /// Obtain the currently focused [`FocusHandle`]. If no elements are focused, returns `None`. pub fn focused(&self) -> Option { self.window .focus .and_then(|id| FocusHandle::for_id(id, &self.window.focus_handles)) } - /// Move focus to the element associated with the given `FocusHandle`. + /// Move focus to the element associated with the given [`FocusHandle`]. pub fn focus(&mut self, handle: &FocusHandle) { if !self.window.focus_enabled || self.window.focus == Some(handle.id) { return; @@ -512,7 +601,7 @@ impl<'a> WindowContext<'a> { self.window.focus_invalidated = true; } - self.notify(); + self.refresh(); } /// Remove focus from all elements within this context's window. @@ -522,14 +611,16 @@ impl<'a> WindowContext<'a> { } self.window.focus = None; - self.notify(); + self.refresh(); } + /// Blur the window and don't allow anything in it to be focused again. pub fn disable_focus(&mut self) { self.blur(); self.window.focus_enabled = false; } + /// Dispatch the given action on the currently focused element. pub fn dispatch_action(&mut self, action: Box) { let focus_handle = self.focused(); @@ -591,6 +682,9 @@ impl<'a> WindowContext<'a> { }); } + /// Subscribe to events emitted by a model or view. + /// The entity to which you're subscribing must implement the [`EventEmitter`] trait. + /// The callback will be invoked a handle to the emitting entity (either a [`View`] or [`Model`]), the event, and a window context for the current window. pub fn subscribe( &mut self, entity: &E, @@ -754,6 +848,17 @@ impl<'a> WindowContext<'a> { .request_measured_layout(style, rem_size, measure) } + pub(crate) fn layout_style(&self, layout_id: LayoutId) -> Option<&Style> { + self.window + .layout_engine + .as_ref() + .unwrap() + .requested_style(layout_id) + } + + /// Compute the layout for the given id within the given available space. + /// This method is called for its side effect, typically by the framework prior to painting. + /// After calling it, you can request the bounds of the given layout node id or any descendant. pub fn compute_layout(&mut self, layout_id: LayoutId, available_space: Size) { let mut layout_engine = self.window.layout_engine.take().unwrap(); layout_engine.compute_layout(layout_id, available_space, self); @@ -780,7 +885,7 @@ impl<'a> WindowContext<'a> { self.window.viewport_size = self.window.platform_window.content_size(); self.window.bounds = self.window.platform_window.bounds(); self.window.display_id = self.window.platform_window.display().id(); - self.notify(); + self.refresh(); self.window .bounds_observers @@ -788,30 +893,37 @@ impl<'a> WindowContext<'a> { .retain(&(), |callback| callback(self)); } + /// Returns the bounds of the current window in the global coordinate space, which could span across multiple displays. pub fn window_bounds(&self) -> WindowBounds { self.window.bounds } + /// Returns the size of the drawable area within the window. pub fn viewport_size(&self) -> Size { self.window.viewport_size } + /// Returns whether this window is focused by the operating system (receiving key events). pub fn is_window_active(&self) -> bool { self.window.active } + /// Toggle zoom on the window. pub fn zoom_window(&self) { self.window.platform_window.zoom(); } + /// Update the window's title at the platform level. pub fn set_window_title(&mut self, title: &str) { self.window.platform_window.set_title(title); } + /// Mark the window as dirty at the platform level. pub fn set_window_edited(&mut self, edited: bool) { self.window.platform_window.set_edited(edited); } + /// Determine the display on which the window is visible. pub fn display(&self) -> Option> { self.platform .displays() @@ -819,6 +931,7 @@ impl<'a> WindowContext<'a> { .find(|display| display.id() == self.window.display_id) } + /// Show the platform character palette. pub fn show_character_palette(&self) { self.window.platform_window.show_character_palette(); } @@ -865,26 +978,26 @@ impl<'a> WindowContext<'a> { /// Register a mouse event listener on the window for the next frame. The type of event /// is determined by the first parameter of the given listener. When the next frame is rendered /// the listener will be cleared. - pub fn on_mouse_event( + pub fn on_mouse_event( &mut self, mut handler: impl FnMut(&Event, DispatchPhase, &mut WindowContext) + 'static, ) { + let view_id = self.parent_view_id(); let order = self.window.next_frame.z_index_stack.clone(); - let handler = self - .window - .frame_arena - .alloc(|| { - move |event: &dyn Any, phase: DispatchPhase, cx: &mut WindowContext<'_>| { - handler(event.downcast_ref().unwrap(), phase, cx) - } - }) - .map(|handler| handler as _); self.window .next_frame .mouse_listeners .entry(TypeId::of::()) .or_default() - .push((order, handler)) + .push(( + order, + view_id, + Box::new( + move |event: &dyn Any, phase: DispatchPhase, cx: &mut WindowContext<'_>| { + handler(event.downcast_ref().unwrap(), phase, cx) + }, + ), + )) } /// Register a key event listener on the window for the next frame. The type of event @@ -893,25 +1006,17 @@ impl<'a> WindowContext<'a> { /// /// This is a fairly low-level method, so prefer using event handlers on elements unless you have /// a specific need to register a global listener. - pub fn on_key_event( + pub fn on_key_event( &mut self, listener: impl Fn(&Event, DispatchPhase, &mut WindowContext) + 'static, ) { - let listener = self - .window - .frame_arena - .alloc(|| { - move |event: &dyn Any, phase, cx: &mut WindowContext<'_>| { - if let Some(event) = event.downcast_ref::() { - listener(event, phase, cx) - } + self.window.next_frame.dispatch_tree.on_key_event(Rc::new( + move |event: &dyn Any, phase, cx: &mut WindowContext<'_>| { + if let Some(event) = event.downcast_ref::() { + listener(event, phase, cx) } - }) - .map(|handler| handler as _); - self.window - .next_frame - .dispatch_tree - .on_key_event(ArenaRef::from(listener)); + }, + )); } /// Register an action listener on the window for the next frame. The type of action @@ -925,17 +1030,13 @@ impl<'a> WindowContext<'a> { action_type: TypeId, listener: impl Fn(&dyn Any, DispatchPhase, &mut WindowContext) + 'static, ) { - let listener = self - .window - .frame_arena - .alloc(|| listener) - .map(|handler| handler as _); self.window .next_frame .dispatch_tree - .on_action(action_type, ArenaRef::from(listener)); + .on_action(action_type, Rc::new(listener)); } + /// Determine whether the given action is available along the dispatch path to the currently focused element. pub fn is_action_available(&self, action: &dyn Action) -> bool { let target = self .focused() @@ -962,16 +1063,26 @@ impl<'a> WindowContext<'a> { self.window.modifiers } + /// Update the cursor style at the platform level. pub fn set_cursor_style(&mut self, style: CursorStyle) { - self.window.requested_cursor_style = Some(style) + let view_id = self.parent_view_id(); + self.window.next_frame.cursor_styles.insert(view_id, style); + self.window.next_frame.requested_cursor_style = Some(style); + } + + /// Set a tooltip to be rendered for the upcoming frame + pub fn set_tooltip(&mut self, tooltip: AnyTooltip) { + let view_id = self.parent_view_id(); + self.window.next_frame.tooltip_request = Some(TooltipRequest { view_id, tooltip }); } /// Called during painting to track which z-index is on top at each pixel position pub fn add_opaque_layer(&mut self, bounds: Bounds) { let stacking_order = self.window.next_frame.z_index_stack.clone(); + let view_id = self.parent_view_id(); let depth_map = &mut self.window.next_frame.depth_map; - match depth_map.binary_search_by(|(level, _)| stacking_order.cmp(level)) { - Ok(i) | Err(i) => depth_map.insert(i, (stacking_order, bounds)), + match depth_map.binary_search_by(|(level, _, _)| stacking_order.cmp(level)) { + Ok(i) | Err(i) => depth_map.insert(i, (stacking_order, view_id, bounds)), } } @@ -979,7 +1090,7 @@ impl<'a> WindowContext<'a> { /// on top of the given level. Layers whose level is an extension of the /// level are not considered to be on top of the level. pub fn was_top_layer(&self, point: &Point, level: &StackingOrder) -> bool { - for (opaque_level, bounds) in self.window.rendered_frame.depth_map.iter() { + for (opaque_level, _, bounds) in self.window.rendered_frame.depth_map.iter() { if level >= opaque_level { break; } @@ -991,12 +1102,12 @@ impl<'a> WindowContext<'a> { true } - pub fn was_top_layer_under_active_drag( + pub(crate) fn was_top_layer_under_active_drag( &self, point: &Point, level: &StackingOrder, ) -> bool { - for (opaque_level, bounds) in self.window.rendered_frame.depth_map.iter() { + for (opaque_level, _, bounds) in self.window.rendered_frame.depth_map.iter() { if level >= opaque_level { break; } @@ -1025,14 +1136,17 @@ impl<'a> WindowContext<'a> { ) { let scale_factor = self.scale_factor(); let content_mask = self.content_mask(); + let view_id = self.parent_view_id(); let window = &mut *self.window; for shadow in shadows { let mut shadow_bounds = bounds; shadow_bounds.origin += shadow.offset; shadow_bounds.dilate(shadow.spread_radius); - window.next_frame.scene_builder.insert( + window.next_frame.scene.insert( &window.next_frame.z_index_stack, Shadow { + view_id: view_id.into(), + layer_id: 0, order: 0, bounds: shadow_bounds.scale(scale_factor), content_mask: content_mask.scale(scale_factor), @@ -1050,11 +1164,14 @@ impl<'a> WindowContext<'a> { pub fn paint_quad(&mut self, quad: PaintQuad) { let scale_factor = self.scale_factor(); let content_mask = self.content_mask(); + let view_id = self.parent_view_id(); let window = &mut *self.window; - window.next_frame.scene_builder.insert( + window.next_frame.scene.insert( &window.next_frame.z_index_stack, Quad { + view_id: view_id.into(), + layer_id: 0, order: 0, bounds: quad.bounds.scale(scale_factor), content_mask: content_mask.scale(scale_factor), @@ -1070,12 +1187,15 @@ impl<'a> WindowContext<'a> { pub fn paint_path(&mut self, mut path: Path, color: impl Into) { let scale_factor = self.scale_factor(); let content_mask = self.content_mask(); + let view_id = self.parent_view_id(); + path.content_mask = content_mask; path.color = color.into(); + path.view_id = view_id.into(); let window = &mut *self.window; window .next_frame - .scene_builder + .scene .insert(&window.next_frame.z_index_stack, path.scale(scale_factor)); } @@ -1097,10 +1217,14 @@ impl<'a> WindowContext<'a> { size: size(width, height), }; let content_mask = self.content_mask(); + let view_id = self.parent_view_id(); + let window = &mut *self.window; - window.next_frame.scene_builder.insert( + window.next_frame.scene.insert( &window.next_frame.z_index_stack, Underline { + view_id: view_id.into(), + layer_id: 0, order: 0, bounds: bounds.scale(scale_factor), content_mask: content_mask.scale(scale_factor), @@ -1150,10 +1274,13 @@ impl<'a> WindowContext<'a> { size: tile.bounds.size.map(Into::into), }; let content_mask = self.content_mask().scale(scale_factor); + let view_id = self.parent_view_id(); let window = &mut *self.window; - window.next_frame.scene_builder.insert( + window.next_frame.scene.insert( &window.next_frame.z_index_stack, MonochromeSprite { + view_id: view_id.into(), + layer_id: 0, order: 0, bounds, content_mask, @@ -1200,11 +1327,14 @@ impl<'a> WindowContext<'a> { size: tile.bounds.size.map(Into::into), }; let content_mask = self.content_mask().scale(scale_factor); + let view_id = self.parent_view_id(); let window = &mut *self.window; - window.next_frame.scene_builder.insert( + window.next_frame.scene.insert( &window.next_frame.z_index_stack, PolychromeSprite { + view_id: view_id.into(), + layer_id: 0, order: 0, bounds, corner_radii: Default::default(), @@ -1242,11 +1372,14 @@ impl<'a> WindowContext<'a> { Ok((params.size, Cow::Owned(bytes))) })?; let content_mask = self.content_mask().scale(scale_factor); + let view_id = self.parent_view_id(); let window = &mut *self.window; - window.next_frame.scene_builder.insert( + window.next_frame.scene.insert( &window.next_frame.z_index_stack, MonochromeSprite { + view_id: view_id.into(), + layer_id: 0, order: 0, bounds, content_mask, @@ -1278,11 +1411,14 @@ impl<'a> WindowContext<'a> { })?; let content_mask = self.content_mask().scale(scale_factor); let corner_radii = corner_radii.scale(scale_factor); + let view_id = self.parent_view_id(); let window = &mut *self.window; - window.next_frame.scene_builder.insert( + window.next_frame.scene.insert( &window.next_frame.z_index_stack, PolychromeSprite { + view_id: view_id.into(), + layer_id: 0, order: 0, bounds, content_mask, @@ -1299,10 +1435,13 @@ impl<'a> WindowContext<'a> { let scale_factor = self.scale_factor(); let bounds = bounds.scale(scale_factor); let content_mask = self.content_mask().scale(scale_factor); + let view_id = self.parent_view_id(); let window = &mut *self.window; - window.next_frame.scene_builder.insert( + window.next_frame.scene.insert( &window.next_frame.z_index_stack, Surface { + view_id: view_id.into(), + layer_id: 0, order: 0, bounds, content_mask, @@ -1311,8 +1450,50 @@ impl<'a> WindowContext<'a> { ); } + pub(crate) fn reuse_view(&mut self) { + let view_id = self.parent_view_id(); + let grafted_view_ids = self + .window + .next_frame + .dispatch_tree + .reuse_view(view_id, &mut self.window.rendered_frame.dispatch_tree); + for view_id in grafted_view_ids { + assert!(self.window.next_frame.reused_views.insert(view_id)); + + // Reuse the previous input handler requested during painting of the reused view. + if self + .window + .rendered_frame + .requested_input_handler + .as_ref() + .map_or(false, |requested| requested.view_id == view_id) + { + self.window.next_frame.requested_input_handler = + self.window.rendered_frame.requested_input_handler.take(); + } + + // Reuse the tooltip previously requested during painting of the reused view. + if self + .window + .rendered_frame + .tooltip_request + .as_ref() + .map_or(false, |requested| requested.view_id == view_id) + { + self.window.next_frame.tooltip_request = + self.window.rendered_frame.tooltip_request.take(); + } + + // Reuse the cursor styles previously requested during painting of the reused view. + if let Some(style) = self.window.rendered_frame.cursor_styles.remove(&view_id) { + self.window.next_frame.cursor_styles.insert(view_id, style); + self.window.next_frame.requested_cursor_style = Some(style); + } + } + } + /// Draw pixels to the display for this window based on the contents of its scene. - pub(crate) fn draw(&mut self) -> Scene { + pub(crate) fn draw(&mut self) { self.window.dirty = false; self.window.drawing = true; @@ -1321,30 +1502,23 @@ impl<'a> WindowContext<'a> { self.window.focus_invalidated = false; } - self.text_system().start_frame(); - self.window.platform_window.clear_input_handler(); - self.window.layout_engine.as_mut().unwrap().clear(); - self.window.next_frame.clear(); - self.window.frame_arena.clear(); + if let Some(requested_handler) = self.window.rendered_frame.requested_input_handler.as_mut() + { + requested_handler.handler = self.window.platform_window.take_input_handler(); + } + let root_view = self.window.root_view.take().unwrap(); self.with_z_index(0, |cx| { cx.with_key_dispatch(Some(KeyContext::default()), None, |_, cx| { for (action_type, action_listeners) in &cx.app.global_action_listeners { for action_listener in action_listeners.iter().cloned() { - let listener = cx - .window - .frame_arena - .alloc(|| { - move |action: &dyn Any, phase, cx: &mut WindowContext<'_>| { - action_listener(action, phase, cx) - } - }) - .map(|listener| listener as _); - cx.window - .next_frame - .dispatch_tree - .on_action(*action_type, ArenaRef::from(listener)) + cx.window.next_frame.dispatch_tree.on_action( + *action_type, + Rc::new(move |action: &dyn Any, phase, cx: &mut WindowContext<'_>| { + action_listener(action, phase, cx) + }), + ) } } @@ -1360,14 +1534,18 @@ impl<'a> WindowContext<'a> { active_drag.view.draw(offset, available_space, cx); }); self.active_drag = Some(active_drag); - } else if let Some(active_tooltip) = self.app.active_tooltip.take() { + } else if let Some(tooltip_request) = self.window.next_frame.tooltip_request.take() { self.with_z_index(1, |cx| { let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent); - active_tooltip - .view - .draw(active_tooltip.cursor_offset, available_space, cx); + tooltip_request.tooltip.view.draw( + tooltip_request.tooltip.cursor_offset, + available_space, + cx, + ); }); + self.window.next_frame.tooltip_request = Some(tooltip_request); } + self.window.dirty_views.clear(); self.window .next_frame @@ -1377,35 +1555,13 @@ impl<'a> WindowContext<'a> { self.window.focus, ); self.window.next_frame.focus = self.window.focus; + self.window.next_frame.window_active = self.window.active; self.window.root_view = Some(root_view); - let previous_focus_path = self.window.rendered_frame.focus_path(); - mem::swap(&mut self.window.rendered_frame, &mut self.window.next_frame); - let current_focus_path = self.window.rendered_frame.focus_path(); - - if previous_focus_path != current_focus_path { - if !previous_focus_path.is_empty() && current_focus_path.is_empty() { - self.window - .blur_listeners - .clone() - .retain(&(), |listener| listener(self)); - } - - let event = FocusEvent { - previous_focus_path, - current_focus_path, - }; - self.window - .focus_listeners - .clone() - .retain(&(), |listener| listener(&event, self)); - } - - let scene = self.window.rendered_frame.scene_builder.build(); - // Set the cursor only if we're the active window. let cursor_style = self .window + .next_frame .requested_cursor_style .take() .unwrap_or(CursorStyle::Arrow); @@ -1413,14 +1569,65 @@ impl<'a> WindowContext<'a> { self.platform.set_cursor_style(cursor_style); } - self.window.drawing = false; + // Register requested input handler with the platform window. + if let Some(requested_input) = self.window.next_frame.requested_input_handler.as_mut() { + if let Some(handler) = requested_input.handler.take() { + self.window.platform_window.set_input_handler(handler); + } + } + + self.window.layout_engine.as_mut().unwrap().clear(); + self.text_system() + .finish_frame(&self.window.next_frame.reused_views); + self.window + .next_frame + .finish(&mut self.window.rendered_frame); ELEMENT_ARENA.with_borrow_mut(|element_arena| element_arena.clear()); - scene + let previous_focus_path = self.window.rendered_frame.focus_path(); + let previous_window_active = self.window.rendered_frame.window_active; + mem::swap(&mut self.window.rendered_frame, &mut self.window.next_frame); + self.window.next_frame.clear(); + let current_focus_path = self.window.rendered_frame.focus_path(); + let current_window_active = self.window.rendered_frame.window_active; + + if previous_focus_path != current_focus_path + || previous_window_active != current_window_active + { + if !previous_focus_path.is_empty() && current_focus_path.is_empty() { + self.window + .focus_lost_listeners + .clone() + .retain(&(), |listener| listener(self)); + } + + let event = FocusEvent { + previous_focus_path: if previous_window_active { + previous_focus_path + } else { + Default::default() + }, + current_focus_path: if current_window_active { + current_focus_path + } else { + Default::default() + }, + }; + self.window + .focus_listeners + .clone() + .retain(&(), |listener| listener(&event, self)); + } + + self.window + .platform_window + .draw(&self.window.rendered_frame.scene); + self.window.refreshing = false; + self.window.drawing = false; } /// Dispatch a mouse or keyboard event on the window. - pub fn dispatch_event(&mut self, event: InputEvent) -> bool { + pub fn dispatch_event(&mut self, event: PlatformInput) -> bool { // Handlers may set this to false by calling `stop_propagation`. self.app.propagate_event = true; // Handlers may set this to true by calling `prevent_default`. @@ -1429,39 +1636,37 @@ impl<'a> WindowContext<'a> { let event = match event { // Track the mouse position with our own state, since accessing the platform // API for the mouse position can only occur on the main thread. - InputEvent::MouseMove(mouse_move) => { + PlatformInput::MouseMove(mouse_move) => { self.window.mouse_position = mouse_move.position; self.window.modifiers = mouse_move.modifiers; - InputEvent::MouseMove(mouse_move) + PlatformInput::MouseMove(mouse_move) } - InputEvent::MouseDown(mouse_down) => { + PlatformInput::MouseDown(mouse_down) => { self.window.mouse_position = mouse_down.position; self.window.modifiers = mouse_down.modifiers; - InputEvent::MouseDown(mouse_down) + PlatformInput::MouseDown(mouse_down) } - InputEvent::MouseUp(mouse_up) => { + PlatformInput::MouseUp(mouse_up) => { self.window.mouse_position = mouse_up.position; self.window.modifiers = mouse_up.modifiers; - InputEvent::MouseUp(mouse_up) + PlatformInput::MouseUp(mouse_up) } - InputEvent::MouseExited(mouse_exited) => { - // todo!("Should we record that the mouse is outside of the window somehow? Or are these global pixels?") + PlatformInput::MouseExited(mouse_exited) => { self.window.modifiers = mouse_exited.modifiers; - - InputEvent::MouseExited(mouse_exited) + PlatformInput::MouseExited(mouse_exited) } - InputEvent::ModifiersChanged(modifiers_changed) => { + PlatformInput::ModifiersChanged(modifiers_changed) => { self.window.modifiers = modifiers_changed.modifiers; - InputEvent::ModifiersChanged(modifiers_changed) + PlatformInput::ModifiersChanged(modifiers_changed) } - InputEvent::ScrollWheel(scroll_wheel) => { + PlatformInput::ScrollWheel(scroll_wheel) => { self.window.mouse_position = scroll_wheel.position; self.window.modifiers = scroll_wheel.modifiers; - InputEvent::ScrollWheel(scroll_wheel) + PlatformInput::ScrollWheel(scroll_wheel) } // Translate dragging and dropping of external files from the operating system // to internal drag and drop events. - InputEvent::FileDrop(file_drop) => match file_drop { + PlatformInput::FileDrop(file_drop) => match file_drop { FileDropEvent::Entered { position, paths } => { self.window.mouse_position = position; if self.active_drag.is_none() { @@ -1471,7 +1676,7 @@ impl<'a> WindowContext<'a> { cursor_offset: position, }); } - InputEvent::MouseMove(MouseMoveEvent { + PlatformInput::MouseMove(MouseMoveEvent { position, pressed_button: Some(MouseButton::Left), modifiers: Modifiers::default(), @@ -1479,7 +1684,7 @@ impl<'a> WindowContext<'a> { } FileDropEvent::Pending { position } => { self.window.mouse_position = position; - InputEvent::MouseMove(MouseMoveEvent { + PlatformInput::MouseMove(MouseMoveEvent { position, pressed_button: Some(MouseButton::Left), modifiers: Modifiers::default(), @@ -1488,21 +1693,21 @@ impl<'a> WindowContext<'a> { FileDropEvent::Submit { position } => { self.activate(true); self.window.mouse_position = position; - InputEvent::MouseUp(MouseUpEvent { + PlatformInput::MouseUp(MouseUpEvent { button: MouseButton::Left, position, modifiers: Modifiers::default(), click_count: 1, }) } - FileDropEvent::Exited => InputEvent::MouseUp(MouseUpEvent { + FileDropEvent::Exited => PlatformInput::MouseUp(MouseUpEvent { button: MouseButton::Left, position: Point::default(), modifiers: Modifiers::default(), click_count: 1, }), }, - InputEvent::KeyDown(_) | InputEvent::KeyUp(_) => event, + PlatformInput::KeyDown(_) | PlatformInput::KeyUp(_) => event, }; if let Some(any_mouse_event) = event.mouse_event() { @@ -1522,11 +1727,11 @@ impl<'a> WindowContext<'a> { .remove(&event.type_id()) { // Because handlers may add other handlers, we sort every time. - handlers.sort_by(|(a, _), (b, _)| a.cmp(b)); + handlers.sort_by(|(a, _, _), (b, _, _)| a.cmp(b)); // Capture phase, events bubble from back to front. Handlers for this phase are used for // special purposes, such as detecting events outside of a given Bounds. - for (_, handler) in &mut handlers { + for (_, _, handler) in &mut handlers { handler(event, DispatchPhase::Capture, self); if !self.app.propagate_event { break; @@ -1535,7 +1740,7 @@ impl<'a> WindowContext<'a> { // Bubble phase, where most normal handlers do their work. if self.app.propagate_event { - for (_, handler) in handlers.iter_mut().rev() { + for (_, _, handler) in handlers.iter_mut().rev() { handler(event, DispatchPhase::Bubble, self); if !self.app.propagate_event { break; @@ -1553,12 +1758,12 @@ impl<'a> WindowContext<'a> { if event.is::() { // If this was a mouse move event, redraw the window so that the // active drag can follow the mouse cursor. - self.notify(); + self.refresh(); } else if event.is::() { // If this was a mouse up event, cancel the active drag and redraw // the window. self.active_drag = None; - self.notify(); + self.refresh(); } } } @@ -1649,6 +1854,7 @@ impl<'a> WindowContext<'a> { self.dispatch_keystroke_observers(event, None); } + /// Determine whether a potential multi-stroke key binding is in progress on this window. pub fn has_pending_keystrokes(&self) -> bool { self.window .rendered_frame @@ -1715,27 +1921,34 @@ impl<'a> WindowContext<'a> { subscription } + /// Focus the current window and bring it to the foreground at the platform level. pub fn activate_window(&self) { self.window.platform_window.activate(); } + /// Minimize the current window at the platform level. pub fn minimize_window(&self) { self.window.platform_window.minimize(); } + /// Toggle full screen status on the current window at the platform level. pub fn toggle_full_screen(&self) { self.window.platform_window.toggle_full_screen(); } + /// Present a platform dialog. + /// The provided message will be presented, along with buttons for each answer. + /// When a button is clicked, the returned Receiver will receive the index of the clicked button. pub fn prompt( &self, level: PromptLevel, - msg: &str, + message: &str, answers: &[&str], ) -> oneshot::Receiver { - self.window.platform_window.prompt(level, msg, answers) + self.window.platform_window.prompt(level, message, answers) } + /// Returns all available actions for the focused element. pub fn available_actions(&self) -> Vec> { let node_id = self .window @@ -1754,6 +1967,7 @@ impl<'a> WindowContext<'a> { .available_actions(node_id) } + /// Returns key bindings that invoke the given action on the currently focused element. pub fn bindings_for_action(&self, action: &dyn Action) -> Vec { self.window .rendered_frame @@ -1764,6 +1978,7 @@ impl<'a> WindowContext<'a> { ) } + /// Returns any bindings that would invoke the given action on the given focus handle if it were focused. pub fn bindings_for_action_in( &self, action: &dyn Action, @@ -1782,6 +1997,7 @@ impl<'a> WindowContext<'a> { dispatch_tree.bindings_for_action(action, &context_stack) } + /// Returns a generic event listener that invokes the given listener with the view and context associated with the given view handle. pub fn listener_for( &self, view: &View, @@ -1793,6 +2009,7 @@ impl<'a> WindowContext<'a> { } } + /// Returns a generic handler that invokes the given handler with the view and context associated with the given view handle. pub fn handler_for( &self, view: &View, @@ -1804,7 +2021,8 @@ impl<'a> WindowContext<'a> { } } - //========== ELEMENT RELATED FUNCTIONS =========== + /// Invoke the given function with the given focus handle present on the key dispatch stack. + /// If you want an element to participate in key dispatch, use this method to push its key context and focus handle into the stack during paint. pub fn with_key_dispatch( &mut self, context: Option, @@ -1812,13 +2030,12 @@ impl<'a> WindowContext<'a> { f: impl FnOnce(Option, &mut Self) -> R, ) -> R { let window = &mut self.window; - window.next_frame.dispatch_tree.push_node(context.clone()); - if let Some(focus_handle) = focus_handle.as_ref() { - window - .next_frame - .dispatch_tree - .make_focusable(focus_handle.id); - } + let focus_id = focus_handle.as_ref().map(|handle| handle.id); + window + .next_frame + .dispatch_tree + .push_node(context.clone(), focus_id, None); + let result = f(focus_handle, self); self.window.next_frame.dispatch_tree.pop_node(); @@ -1826,9 +2043,149 @@ impl<'a> WindowContext<'a> { result } + /// Invoke the given function with the given view id present on the view stack. + /// This is a fairly low-level method used to layout views. + pub fn with_view_id(&mut self, view_id: EntityId, f: impl FnOnce(&mut Self) -> R) -> R { + let text_system = self.text_system().clone(); + text_system.with_view(view_id, || { + if self.window.next_frame.view_stack.last() == Some(&view_id) { + return f(self); + } else { + self.window.next_frame.view_stack.push(view_id); + let result = f(self); + self.window.next_frame.view_stack.pop(); + result + } + }) + } + + /// Invoke the given function with the given view id present on the view stack. + /// This is a fairly low-level method used to paint views. + pub fn paint_view(&mut self, view_id: EntityId, f: impl FnOnce(&mut Self) -> R) -> R { + let text_system = self.text_system().clone(); + text_system.with_view(view_id, || { + if self.window.next_frame.view_stack.last() == Some(&view_id) { + return f(self); + } else { + self.window.next_frame.view_stack.push(view_id); + self.window + .next_frame + .dispatch_tree + .push_node(None, None, Some(view_id)); + let result = f(self); + self.window.next_frame.dispatch_tree.pop_node(); + self.window.next_frame.view_stack.pop(); + result + } + }) + } + + /// Update or initialize state for an element with the given id that lives across multiple + /// frames. If an element with this id existed in the rendered frame, its state will be passed + /// to the given closure. The state returned by the closure will be stored so it can be referenced + /// when drawing the next frame. + pub(crate) fn with_element_state( + &mut self, + id: ElementId, + f: impl FnOnce(Option, &mut Self) -> (R, S), + ) -> R + where + S: 'static, + { + self.with_element_id(Some(id), |cx| { + let global_id = cx.window().element_id_stack.clone(); + + if let Some(any) = cx + .window_mut() + .next_frame + .element_states + .remove(&global_id) + .or_else(|| { + cx.window_mut() + .rendered_frame + .element_states + .remove(&global_id) + }) + { + let ElementStateBox { + inner, + parent_view_id, + #[cfg(debug_assertions)] + type_name + } = any; + // Using the extra inner option to avoid needing to reallocate a new box. + let mut state_box = inner + .downcast::>() + .map_err(|_| { + #[cfg(debug_assertions)] + { + anyhow!( + "invalid element state type for id, requested_type {:?}, actual type: {:?}", + std::any::type_name::(), + type_name + ) + } + + #[cfg(not(debug_assertions))] + { + anyhow!( + "invalid element state type for id, requested_type {:?}", + std::any::type_name::(), + ) + } + }) + .unwrap(); + + // Actual: Option <- View + // Requested: () <- AnyElement + let state = state_box + .take() + .expect("element state is already on the stack"); + let (result, state) = f(Some(state), cx); + state_box.replace(state); + cx.window_mut() + .next_frame + .element_states + .insert(global_id, ElementStateBox { + inner: state_box, + parent_view_id, + #[cfg(debug_assertions)] + type_name + }); + result + } else { + let (result, state) = f(None, cx); + let parent_view_id = cx.parent_view_id(); + cx.window_mut() + .next_frame + .element_states + .insert(global_id, + ElementStateBox { + inner: Box::new(Some(state)), + parent_view_id, + #[cfg(debug_assertions)] + type_name: std::any::type_name::() + } + + ); + result + } + }) + } + + fn parent_view_id(&self) -> EntityId { + *self + .window + .next_frame + .view_stack + .last() + .expect("a view should always be on the stack while drawing") + } + /// Set an input handler, such as [`ElementInputHandler`][element_input_handler], which interfaces with the /// platform to receive textual input with proper integration with concerns such - /// as IME interactions. + /// as IME interactions. This handler will be active for the upcoming frame until the following frame is + /// rendered. /// /// [element_input_handler]: crate::ElementInputHandler pub fn handle_input( @@ -1837,17 +2194,31 @@ impl<'a> WindowContext<'a> { input_handler: impl PlatformInputHandler, ) { if focus_handle.is_focused(self) { - self.window - .platform_window - .set_input_handler(Box::new(input_handler)); + let view_id = self.parent_view_id(); + self.window.next_frame.requested_input_handler = Some(RequestedInputHandler { + view_id, + handler: Some(Box::new(input_handler)), + }) } } + /// Register a callback that can interrupt the closing of the current window based the returned boolean. + /// If the callback returns false, the window won't be closed. pub fn on_window_should_close(&mut self, f: impl Fn(&mut WindowContext) -> bool + 'static) { let mut this = self.to_async(); self.window .platform_window - .on_should_close(Box::new(move || this.update(|_, cx| f(cx)).unwrap_or(true))) + .on_should_close(Box::new(move || { + this.update(|_, cx| { + // Ensure that the window is removed from the app if it's been closed + // by always pre-empting the system close event. + if f(cx) { + cx.remove_window(); + } + false + }) + .unwrap_or(true) + })) } } @@ -1973,7 +2344,7 @@ impl VisualContext for WindowContext<'_> { { let view = self.new_view(build_view); self.window.root_view = Some(view.clone().into()); - self.notify(); + self.refresh(); view } @@ -2017,19 +2388,24 @@ impl<'a> BorrowMut for WindowContext<'a> { } } +/// This trait contains functionality that is shared across [`ViewContext`] and [`WindowContext`] pub trait BorrowWindow: BorrowMut + BorrowMut { + #[doc(hidden)] fn app_mut(&mut self) -> &mut AppContext { self.borrow_mut() } + #[doc(hidden)] fn app(&self) -> &AppContext { self.borrow() } + #[doc(hidden)] fn window(&self) -> &Window { self.borrow() } + #[doc(hidden)] fn window_mut(&mut self) -> &mut Window { self.borrow_mut() } @@ -2084,8 +2460,13 @@ pub trait BorrowWindow: BorrowMut + BorrowMut { }; let new_stacking_order_id = post_inc(&mut self.window_mut().next_frame.next_stacking_order_id); + let new_root_z_index = post_inc(&mut self.window_mut().next_frame.next_root_z_index); let old_stacking_order = mem::take(&mut self.window_mut().next_frame.z_index_stack); self.window_mut().next_frame.z_index_stack.id = new_stacking_order_id; + self.window_mut() + .next_frame + .z_index_stack + .push(new_root_z_index); self.window_mut().next_frame.content_mask_stack.push(mask); let result = f(self); self.window_mut().next_frame.content_mask_stack.pop(); @@ -2151,98 +2532,6 @@ pub trait BorrowWindow: BorrowMut + BorrowMut { .unwrap_or_default() } - /// Update or initialize state for an element with the given id that lives across multiple - /// frames. If an element with this id existed in the rendered frame, its state will be passed - /// to the given closure. The state returned by the closure will be stored so it can be referenced - /// when drawing the next frame. - fn with_element_state( - &mut self, - id: ElementId, - f: impl FnOnce(Option, &mut Self) -> (R, S), - ) -> R - where - S: 'static, - { - self.with_element_id(Some(id), |cx| { - let global_id = cx.window().element_id_stack.clone(); - - if let Some(any) = cx - .window_mut() - .next_frame - .element_states - .remove(&global_id) - .or_else(|| { - cx.window_mut() - .rendered_frame - .element_states - .remove(&global_id) - }) - { - let ElementStateBox { - inner, - - #[cfg(debug_assertions)] - type_name - } = any; - // Using the extra inner option to avoid needing to reallocate a new box. - let mut state_box = inner - .downcast::>() - .map_err(|_| { - #[cfg(debug_assertions)] - { - anyhow!( - "invalid element state type for id, requested_type {:?}, actual type: {:?}", - std::any::type_name::(), - type_name - ) - } - - #[cfg(not(debug_assertions))] - { - anyhow!( - "invalid element state type for id, requested_type {:?}", - std::any::type_name::(), - ) - } - }) - .unwrap(); - - // Actual: Option <- View - // Requested: () <- AnyElemet - let state = state_box - .take() - .expect("element state is already on the stack"); - let (result, state) = f(Some(state), cx); - state_box.replace(state); - cx.window_mut() - .next_frame - .element_states - .insert(global_id, ElementStateBox { - inner: state_box, - - #[cfg(debug_assertions)] - type_name - }); - result - } else { - let (result, state) = f(None, cx); - cx.window_mut() - .next_frame - .element_states - .insert(global_id, - ElementStateBox { - inner: Box::new(Some(state)), - - #[cfg(debug_assertions)] - type_name: std::any::type_name::() - } - - ); - result - } - }) - } - /// Obtain the current content mask. fn content_mask(&self) -> ContentMask { self.window() @@ -2279,6 +2568,10 @@ impl BorrowMut for WindowContext<'_> { impl BorrowWindow for T where T: BorrowMut + BorrowMut {} +/// Provides access to application state that is specialized for a particular [`View`]. +/// Allows you to interact with focus, emit events, etc. +/// ViewContext also derefs to [`WindowContext`], giving you access to all of its methods as well. +/// When you call [`View::update`], you're passed a `&mut V` and an `&mut ViewContext`. pub struct ViewContext<'a, V> { window_cx: WindowContext<'a>, view: &'a View, @@ -2316,14 +2609,17 @@ impl<'a, V: 'static> ViewContext<'a, V> { } } + /// Get the entity_id of this view. pub fn entity_id(&self) -> EntityId { self.view.entity_id() } + /// Get the view pointer underlying this context. pub fn view(&self) -> &View { self.view } + /// Get the model underlying this view. pub fn model(&self) -> &Model { &self.view.model } @@ -2333,6 +2629,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { &mut self.window_cx } + /// Set a given callback to be run on the next frame. pub fn on_next_frame(&mut self, f: impl FnOnce(&mut V, &mut ViewContext) + 'static) where V: 'static, @@ -2350,6 +2647,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { }); } + /// Observe another model or view for changes to its state, as tracked by [`ModelContext::notify`]. pub fn observe( &mut self, entity: &E, @@ -2383,6 +2681,9 @@ impl<'a, V: 'static> ViewContext<'a, V> { subscription } + /// Subscribe to events emitted by another model or view. + /// The entity to which you're subscribing must implement the [`EventEmitter`] trait. + /// The callback will be invoked with a reference to the current view, a handle to the emitting entity (either a [`View`] or [`Model`]), the event, and a view context for the current view. pub fn subscribe( &mut self, entity: &E, @@ -2440,6 +2741,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { subscription } + /// Register a callback to be invoked when the given Model or View is released. pub fn observe_release( &mut self, entity: &E, @@ -2466,15 +2768,31 @@ impl<'a, V: 'static> ViewContext<'a, V> { subscription } + /// Indicate that this view has changed, which will invoke any observers and also mark the window as dirty. + /// If this view or any of its ancestors are *cached*, notifying it will cause it or its ancestors to be redrawn. pub fn notify(&mut self) { + for view_id in self + .window + .rendered_frame + .dispatch_tree + .view_path(self.view.entity_id()) + .into_iter() + .rev() + { + if !self.window.dirty_views.insert(view_id) { + break; + } + } + if !self.window.drawing { - self.window_cx.notify(); + self.window_cx.window.dirty = true; self.window_cx.app.push_effect(Effect::Notify { emitter: self.view.model.entity_id, }); } } + /// Register a callback to be invoked when the window is resized. pub fn observe_window_bounds( &mut self, mut callback: impl FnMut(&mut V, &mut ViewContext) + 'static, @@ -2488,6 +2806,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { subscription } + /// Register a callback to be invoked when the window is activated or deactivated. pub fn observe_window_activation( &mut self, mut callback: impl FnMut(&mut V, &mut ViewContext) + 'static, @@ -2579,14 +2898,16 @@ impl<'a, V: 'static> ViewContext<'a, V> { subscription } - /// Register a listener to be called when the window loses focus. + /// Register a listener to be called when nothing in the window has focus. + /// This typically happens when the node that was focused is removed from the tree, + /// and this callback lets you chose a default place to restore the users focus. /// Returns a subscription and persists until the subscription is dropped. - pub fn on_blur_window( + pub fn on_focus_lost( &mut self, mut listener: impl FnMut(&mut V, &mut ViewContext) + 'static, ) -> Subscription { let view = self.view.downgrade(); - let (subscription, activate) = self.window.blur_listeners.insert( + let (subscription, activate) = self.window.focus_lost_listeners.insert( (), Box::new(move |cx| view.update(cx, |view, cx| listener(view, cx)).is_ok()), ); @@ -2620,6 +2941,10 @@ impl<'a, V: 'static> ViewContext<'a, V> { subscription } + /// Schedule a future to be run asynchronously. + /// The given callback is invoked with a [`WeakView`] to avoid leaking the view for a long-running process. + /// It's also given an [`AsyncWindowContext`], which can be used to access the state of the view across await points. + /// The returned future will be polled on the main thread. pub fn spawn( &mut self, f: impl FnOnce(WeakView, AsyncWindowContext) -> Fut, @@ -2632,6 +2957,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { self.window_cx.spawn(|cx| f(view, cx)) } + /// Update the global state of the given type. pub fn update_global(&mut self, f: impl FnOnce(&mut G, &mut Self) -> R) -> R where G: 'static, @@ -2642,6 +2968,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { result } + /// Register a callback to be invoked when the given global state changes. pub fn observe_global( &mut self, mut f: impl FnMut(&mut V, &mut ViewContext<'_, V>) + 'static, @@ -2660,7 +2987,10 @@ impl<'a, V: 'static> ViewContext<'a, V> { subscription } - pub fn on_mouse_event( + /// Add a listener for any mouse event that occurs in the window. + /// This is a fairly low level method. + /// Typically, you'll want to use methods on UI elements, which perform bounds checking etc. + pub fn on_mouse_event( &mut self, handler: impl Fn(&mut V, &Event, DispatchPhase, &mut ViewContext) + 'static, ) { @@ -2672,7 +3002,8 @@ impl<'a, V: 'static> ViewContext<'a, V> { }); } - pub fn on_key_event( + /// Register a callback to be invoked when the given Key Event is dispatched to the window. + pub fn on_key_event( &mut self, handler: impl Fn(&mut V, &Event, DispatchPhase, &mut ViewContext) + 'static, ) { @@ -2684,6 +3015,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { }); } + /// Register a callback to be invoked when the given Action type is dispatched to the window. pub fn on_action( &mut self, action_type: TypeId, @@ -2698,6 +3030,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { }); } + /// Emit an event to be handled any other views that have subscribed via [ViewContext::subscribe]. pub fn emit(&mut self, event: Evt) where Evt: 'static, @@ -2711,6 +3044,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { }); } + /// Move focus to the current view, assuming it implements [`FocusableView`]. pub fn focus_self(&mut self) where V: FocusableView, @@ -2718,6 +3052,11 @@ impl<'a, V: 'static> ViewContext<'a, V> { self.defer(|view, cx| view.focus_handle(cx).focus(cx)) } + /// Convenience method for accessing view state in an event callback. + /// + /// Many GPUI callbacks take the form of `Fn(&E, &mut WindowContext)`, + /// but it's often useful to be able to access view state in these + /// callbacks. This method provides a convenient way to do so. pub fn listener( &self, f: impl Fn(&mut V, &E, &mut ViewContext) + 'static, @@ -2827,14 +3166,20 @@ impl<'a, V> std::ops::DerefMut for ViewContext<'a, V> { } // #[derive(Clone, Copy, Eq, PartialEq, Hash)] -slotmap::new_key_type! { pub struct WindowId; } +slotmap::new_key_type! { + /// A unique identifier for a window. + pub struct WindowId; +} impl WindowId { + /// Converts this window ID to a `u64`. pub fn as_u64(&self) -> u64 { self.0.as_ffi() } } +/// A handle to a window with a specific root view type. +/// Note that this does not keep the window alive on its own. #[derive(Deref, DerefMut)] pub struct WindowHandle { #[deref] @@ -2844,6 +3189,8 @@ pub struct WindowHandle { } impl WindowHandle { + /// Create a new handle from a window ID. + /// This does not check if the root type of the window is `V`. pub fn new(id: WindowId) -> Self { WindowHandle { any_handle: AnyWindowHandle { @@ -2854,6 +3201,9 @@ impl WindowHandle { } } + /// Get the root view out of this window. + /// + /// This will fail if the window is closed or if the root view's type does not match `V`. pub fn root(&self, cx: &mut C) -> Result> where C: Context, @@ -2865,6 +3215,9 @@ impl WindowHandle { })) } + /// Update the root view of this window. + /// + /// This will fail if the window has been closed or if the root view's type does not match pub fn update( &self, cx: &mut C, @@ -2881,6 +3234,9 @@ impl WindowHandle { })? } + /// Read the root view out of this window. + /// + /// This will fail if the window is closed or if the root view's type does not match `V`. pub fn read<'a>(&self, cx: &'a AppContext) -> Result<&'a V> { let x = cx .windows @@ -2897,6 +3253,9 @@ impl WindowHandle { Ok(x.read(cx)) } + /// Read the root view out of this window, with a callback + /// + /// This will fail if the window is closed or if the root view's type does not match `V`. pub fn read_with(&self, cx: &C, read_with: impl FnOnce(&V, &AppContext) -> R) -> Result where C: Context, @@ -2904,6 +3263,9 @@ impl WindowHandle { cx.read_window(self, |root_view, cx| read_with(root_view.read(cx), cx)) } + /// Read the root view pointer off of this window. + /// + /// This will fail if the window is closed or if the root view's type does not match `V`. pub fn root_view(&self, cx: &C) -> Result> where C: Context, @@ -2911,6 +3273,9 @@ impl WindowHandle { cx.read_window(self, |root_view, _cx| root_view.clone()) } + /// Check if this window is 'active'. + /// + /// Will return `None` if the window is closed. pub fn is_active(&self, cx: &AppContext) -> Option { cx.windows .get(self.id) @@ -2946,6 +3311,7 @@ impl From> for AnyWindowHandle { } } +/// A handle to a window with any root view type, which can be downcast to a window with a specific root view type. #[derive(Copy, Clone, PartialEq, Eq, Hash)] pub struct AnyWindowHandle { pub(crate) id: WindowId, @@ -2953,10 +3319,13 @@ pub struct AnyWindowHandle { } impl AnyWindowHandle { + /// Get the ID of this window. pub fn window_id(&self) -> WindowId { self.id } + /// Attempt to convert this handle to a window handle with a specific root view type. + /// If the types do not match, this will return `None`. pub fn downcast(&self) -> Option> { if TypeId::of::() == self.state_type { Some(WindowHandle { @@ -2968,6 +3337,9 @@ impl AnyWindowHandle { } } + /// Update the state of the root view of this window. + /// + /// This will fail if the window has been closed. pub fn update( self, cx: &mut C, @@ -2979,6 +3351,9 @@ impl AnyWindowHandle { cx.update_window(self, update) } + /// Read the state of the root view of this window. + /// + /// This will fail if the window has been closed. pub fn read(self, cx: &C, read: impl FnOnce(View, &AppContext) -> R) -> Result where C: Context, @@ -2992,22 +3367,38 @@ impl AnyWindowHandle { } } -// #[cfg(any(test, feature = "test-support"))] -// impl From> for StackingOrder { -// fn from(small_vec: SmallVec<[u32; 16]>) -> Self { -// StackingOrder(small_vec) -// } -// } - +/// An identifier for an [`Element`](crate::Element). +/// +/// Can be constructed with a string, a number, or both, as well +/// as other internal representations. #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub enum ElementId { + /// The ID of a View element View(EntityId), + /// An integer ID. Integer(usize), + /// A string based ID. Name(SharedString), + /// An ID that's equated with a focus handle. FocusHandle(FocusId), + /// A combination of a name and an integer. NamedInteger(SharedString, usize), } +impl Display for ElementId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ElementId::View(entity_id) => write!(f, "view-{}", entity_id)?, + ElementId::Integer(ix) => write!(f, "{}", ix)?, + ElementId::Name(name) => write!(f, "{}", name)?, + ElementId::FocusHandle(__) => write!(f, "FocusHandle")?, + ElementId::NamedInteger(s, i) => write!(f, "{}-{}", s, i)?, + } + + Ok(()) + } +} + impl ElementId { pub(crate) fn from_entity_id(entity_id: EntityId) -> Self { ElementId::View(entity_id) @@ -3074,7 +3465,8 @@ impl From<(&'static str, u64)> for ElementId { } } -/// A rectangle, to be rendered on the screen by GPUI at the given position and size. +/// A rectangle to be rendered in the window at the given position and size. +/// Passed as an argument [`WindowContext::paint_quad`]. #[derive(Clone)] pub struct PaintQuad { bounds: Bounds, diff --git a/crates/gpui/tests/action_macros.rs b/crates/gpui/tests/action_macros.rs index 9e5f6dea16..99572a4b3c 100644 --- a/crates/gpui/tests/action_macros.rs +++ b/crates/gpui/tests/action_macros.rs @@ -18,33 +18,33 @@ fn test_action_macros() { impl gpui::Action for RegisterableAction { fn boxed_clone(&self) -> Box { - todo!() + unimplemented!() } fn as_any(&self) -> &dyn std::any::Any { - todo!() + unimplemented!() } fn partial_eq(&self, _action: &dyn gpui::Action) -> bool { - todo!() + unimplemented!() } fn name(&self) -> &str { - todo!() + unimplemented!() } fn debug_name() -> &'static str where Self: Sized, { - todo!() + unimplemented!() } fn build(_value: serde_json::Value) -> anyhow::Result> where Self: Sized, { - todo!() + unimplemented!() } } } diff --git a/crates/gpui_macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs index f0cd59908d..aef1785bb5 100644 --- a/crates/gpui_macros/src/gpui_macros.rs +++ b/crates/gpui_macros/src/gpui_macros.rs @@ -7,26 +7,56 @@ mod test; use proc_macro::TokenStream; #[proc_macro] +/// register_action! can be used to register an action with the GPUI runtime. +/// You should typically use `gpui::actions!` or `gpui::impl_actions!` instead, +/// but this can be used for fine grained customization. pub fn register_action(ident: TokenStream) -> TokenStream { register_action::register_action_macro(ident) } #[proc_macro_derive(IntoElement)] +// #[derive(IntoElement)] is used to create a Component out of anything that implements +// the `RenderOnce` trait. pub fn derive_into_element(input: TokenStream) -> TokenStream { derive_into_element::derive_into_element(input) } #[proc_macro_derive(Render)] +#[doc(hidden)] pub fn derive_render(input: TokenStream) -> TokenStream { derive_render::derive_render(input) } +// Used by gpui to generate the style helpers. #[proc_macro] +#[doc(hidden)] pub fn style_helpers(input: TokenStream) -> TokenStream { style_helpers::style_helpers(input) } #[proc_macro_attribute] +/// #[gpui::test] can be used to annotate test functions that run with GPUI support. +/// it supports both synchronous and asynchronous tests, and can provide you with +/// as many `TestAppContext` instances as you need. +/// The output contains a `#[test]` annotation so this can be used with any existing +/// test harness (`cargo test` or `cargo-nextest`). +/// +/// ``` +/// #[gpui::test] +/// async fn test_foo(mut cx: &TestAppContext) { } +/// ``` +/// +/// In addition to passing a TestAppContext, you can also ask for a `StdRnd` instance. +/// this will be seeded with the `SEED` environment variable and is used internally by +/// the ForegroundExecutor and BackgroundExecutor to run tasks deterministically in tests. +/// Using the same `StdRng` for behaviour in your test will allow you to exercise a wide +/// variety of scenarios and interleavings just by changing the seed. +/// +/// #[gpui::test] also takes three different arguments: +/// - `#[gpui::test(iterations=10)]` will run the test ten times with a different initial SEED. +/// - `#[gpui::test(retries=3)]` will run the test up to four times if it fails to try and make it pass. +/// - `#[gpui::test(on_failure="crate::test::report_failure")]` will call the specified function after the +/// tests fail so that you can write out more detail about the failure. pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { test::test(args, function) } diff --git a/crates/gpui_macros/src/register_action.rs b/crates/gpui_macros/src/register_action.rs index c18e4f4b89..2772ec9634 100644 --- a/crates/gpui_macros/src/register_action.rs +++ b/crates/gpui_macros/src/register_action.rs @@ -1,16 +1,3 @@ -// Input: -// -// struct FooBar {} - -// Output: -// -// struct FooBar {} -// -// #[allow(non_snake_case)] -// #[gpui2::ctor] -// fn register_foobar_builder() { -// gpui2::register_action_builder::() -// } use proc_macro::TokenStream; use proc_macro2::Ident; use quote::{format_ident, quote}; diff --git a/crates/gpui_macros/src/style_helpers.rs b/crates/gpui_macros/src/style_helpers.rs index b86bb2dfa6..00d3672033 100644 --- a/crates/gpui_macros/src/style_helpers.rs +++ b/crates/gpui_macros/src/style_helpers.rs @@ -85,6 +85,18 @@ fn generate_methods() -> Vec { } for (prefix, fields, prefix_doc_string) in border_prefixes() { + methods.push(generate_custom_value_setter( + // The plain method names (e.g., `border`, `border_t`, `border_r`, etc.) are special-cased + // versions of the 1px variants. This better matches Tailwind, but breaks our existing + // convention of the suffix-less variant of the method being the one that accepts a custom value + // + // To work around this, we're assigning a `_width` suffix here. + &format!("{prefix}_width"), + quote! { AbsoluteLength }, + &fields, + prefix_doc_string, + )); + for (suffix, width_tokens, suffix_doc_string) in border_suffixes() { methods.push(generate_predefined_setter( prefix, @@ -141,7 +153,7 @@ fn generate_predefined_setter( } fn generate_custom_value_setter( - prefix: &'static str, + prefix: &str, length_type: TokenStream2, fields: &[TokenStream2], doc_string: &str, diff --git a/crates/gpui_macros/src/test.rs b/crates/gpui_macros/src/test.rs index 70c6da22d5..ee3f8f7137 100644 --- a/crates/gpui_macros/src/test.rs +++ b/crates/gpui_macros/src/test.rs @@ -106,7 +106,8 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { let cx_varname = format_ident!("cx_{}", ix); cx_vars.extend(quote!( let mut #cx_varname = gpui::TestAppContext::new( - dispatcher.clone() + dispatcher.clone(), + Some(stringify!(#outer_fn_name)), ); )); cx_teardowns.extend(quote!( @@ -140,8 +141,7 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { executor.block_test(#inner_fn_name(#inner_fn_args)); #cx_teardowns }, - #on_failure_fn_name, - stringify!(#outer_fn_name).to_string(), + #on_failure_fn_name ); } } @@ -169,7 +169,8 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { let cx_varname_lock = format_ident!("cx_{}_lock", ix); cx_vars.extend(quote!( let mut #cx_varname = gpui::TestAppContext::new( - dispatcher.clone() + dispatcher.clone(), + Some(stringify!(#outer_fn_name)) ); let mut #cx_varname_lock = #cx_varname.app.borrow_mut(); )); @@ -186,7 +187,8 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { let cx_varname = format_ident!("cx_{}", ix); cx_vars.extend(quote!( let mut #cx_varname = gpui::TestAppContext::new( - dispatcher.clone() + dispatcher.clone(), + Some(stringify!(#outer_fn_name)) ); )); cx_teardowns.extend(quote!( @@ -222,7 +224,6 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { #cx_teardowns }, #on_failure_fn_name, - stringify!(#outer_fn_name).to_string(), ); } } diff --git a/crates/journal/src/journal.rs b/crates/journal/src/journal.rs index 1ffab2f3d3..b15da05e17 100644 --- a/crates/journal/src/journal.rs +++ b/crates/journal/src/journal.rs @@ -1,6 +1,6 @@ use anyhow::Result; use chrono::{Datelike, Local, NaiveTime, Timelike}; -use editor::scroll::autoscroll::Autoscroll; +use editor::scroll::Autoscroll; use editor::Editor; use gpui::{actions, AppContext, ViewContext, WindowContext}; use schemars::JsonSchema; diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 8feb566c7e..bfb37e91eb 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -60,7 +60,9 @@ pub use {tree_sitter_rust, tree_sitter_typescript}; pub use lsp::DiagnosticSeverity; lazy_static! { - static ref BUFFER_DIFF_TASK: TaskLabel = TaskLabel::new(); + /// A label for the background task spawned by the buffer to compute + /// a diff against the contents of its file. + pub static ref BUFFER_DIFF_TASK: TaskLabel = TaskLabel::new(); } /// Indicate whether a [Buffer] has permissions to edit. @@ -337,6 +339,8 @@ pub enum Event { Reparsed, /// The buffer's diagnostics were updated. DiagnosticsUpdated, + /// The buffer gained or lost editing capabilities. + CapabilityChanged, /// The buffer was explicitly requested to close. Closed, } @@ -711,6 +715,7 @@ impl Buffer { } } + #[cfg(test)] pub(crate) fn as_text_snapshot(&self) -> &text::BufferSnapshot { &self.text } @@ -731,6 +736,7 @@ impl Buffer { &self.saved_version } + /// The fingerprint of the buffer's text when the buffer was last saved or reloaded from disk. pub fn saved_version_fingerprint(&self) -> RopeFingerprint { self.file_fingerprint } @@ -756,6 +762,12 @@ impl Buffer { .set_language_registry(language_registry); } + /// Assign the buffer a new [Capability]. + pub fn set_capability(&mut self, capability: Capability, cx: &mut ModelContext) { + self.capability = capability; + cx.emit(Event::CapabilityChanged) + } + /// This method is called to signal that the buffer has been saved. pub fn did_save( &mut self, @@ -967,6 +979,8 @@ impl Buffer { self.parsing_in_background } + /// Indicates whether the buffer contains any regions that may be + /// written in a language that hasn't been loaded yet. pub fn contains_unknown_injections(&self) -> bool { self.syntax_map.lock().contains_unknown_injections() } diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 780483c5ca..6ad345d4e3 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -275,7 +275,7 @@ async fn test_normalize_whitespace(cx: &mut gpui::TestAppContext) { let version_before_format = format_diff.base_version.clone(); buffer.apply_diff(format_diff, cx); - // The outcome depends on the order of concurrent taks. + // The outcome depends on the order of concurrent tasks. // // If the edit occurred while searching for trailing whitespace ranges, // then the trailing whitespace region touched by the edit is left intact. diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index fdb086ebc6..9a292cbd88 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -1017,7 +1017,7 @@ impl LanguageRegistry { if language.fake_adapter.is_some() { let task = cx.spawn(|cx| async move { let (servers_tx, fake_adapter) = language.fake_adapter.as_ref().unwrap(); - let (server, mut fake_server) = lsp::LanguageServer::fake( + let (server, mut fake_server) = lsp::FakeLanguageServer::new( fake_adapter.name.to_string(), fake_adapter.capabilities.clone(), cx.clone(), diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index c1bd3aa57f..292e2ad9dc 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -14,7 +14,7 @@ use settings::Settings; use std::{num::NonZeroU32, path::Path, sync::Arc}; /// Initializes the language settings. -pub(crate) fn init(cx: &mut AppContext) { +pub fn init(cx: &mut AppContext) { AllLanguageSettings::register(cx); } diff --git a/crates/language/src/syntax_map/syntax_map_tests.rs b/crates/language/src/syntax_map/syntax_map_tests.rs index f20f481613..8b9169d1cc 100644 --- a/crates/language/src/syntax_map/syntax_map_tests.rs +++ b/crates/language/src/syntax_map/syntax_map_tests.rs @@ -258,19 +258,19 @@ fn test_typing_multiple_new_injections() { let (buffer, syntax_map) = test_edit_sequence( "Rust", &[ - "fn a() { dbg }", - "fn a() { dbg«!» }", - "fn a() { dbg!«()» }", - "fn a() { dbg!(«b») }", - "fn a() { dbg!(b«.») }", - "fn a() { dbg!(b.«c») }", - "fn a() { dbg!(b.c«()») }", - "fn a() { dbg!(b.c(«vec»)) }", - "fn a() { dbg!(b.c(vec«!»)) }", - "fn a() { dbg!(b.c(vec!«[]»)) }", - "fn a() { dbg!(b.c(vec![«d»])) }", - "fn a() { dbg!(b.c(vec![d«.»])) }", - "fn a() { dbg!(b.c(vec![d.«e»])) }", + "fn a() { test_macro }", + "fn a() { test_macro«!» }", + "fn a() { test_macro!«()» }", + "fn a() { test_macro!(«b») }", + "fn a() { test_macro!(b«.») }", + "fn a() { test_macro!(b.«c») }", + "fn a() { test_macro!(b.c«()») }", + "fn a() { test_macro!(b.c(«vec»)) }", + "fn a() { test_macro!(b.c(vec«!»)) }", + "fn a() { test_macro!(b.c(vec!«[]»)) }", + "fn a() { test_macro!(b.c(vec![«d»])) }", + "fn a() { test_macro!(b.c(vec![d«.»])) }", + "fn a() { test_macro!(b.c(vec![d.«e»])) }", ], ); @@ -278,7 +278,7 @@ fn test_typing_multiple_new_injections() { &syntax_map, &buffer, &["field"], - "fn a() { dbg!(b.«c»(vec![d.«e»])) }", + "fn a() { test_macro!(b.«c»(vec![d.«e»])) }", ); } diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index 33b2e48126..00ff809fc4 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -68,7 +68,7 @@ impl LanguageSelector { impl Render for LanguageSelector { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - v_stack().w(rems(34.)).child(self.picker.clone()) + v_flex().w(rems(34.)).child(self.picker.clone()) } } diff --git a/crates/language_tools/src/lsp_log.rs b/crates/language_tools/src/lsp_log.rs index e371826758..b4e2b37e83 100644 --- a/crates/language_tools/src/lsp_log.rs +++ b/crates/language_tools/src/lsp_log.rs @@ -1,5 +1,5 @@ use collections::{HashMap, VecDeque}; -use editor::{Editor, EditorEvent, MoveToEnd}; +use editor::{actions::MoveToEnd, Editor, EditorEvent}; use futures::{channel::mpsc, StreamExt}; use gpui::{ actions, div, AnchorCorner, AnyElement, AppContext, Context, EventEmitter, FocusHandle, @@ -405,8 +405,14 @@ impl LspLogView { { log_view.editor.update(cx, |editor, cx| { editor.set_read_only(false); - editor.handle_input(entry.trim(), cx); - editor.handle_input("\n", cx); + let last_point = editor.buffer().read(cx).len(cx); + editor.edit( + vec![ + (last_point..last_point, entry.trim()), + (last_point..last_point, "\n"), + ], + cx, + ); editor.set_read_only(true); }); } @@ -449,6 +455,7 @@ impl LspLogView { editor.set_text(log_contents, cx); editor.move_to_end(&MoveToEnd, cx); editor.set_read_only(true); + editor.set_show_copilot_suggestions(false); editor }); let editor_subscription = cx.subscribe( @@ -624,6 +631,10 @@ impl Item for LspLogView { .into_any_element() } + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + fn as_searchable(&self, handle: &View) -> Option> { Some(Box::new(handle.clone())) } @@ -784,7 +795,7 @@ impl Render for LspLogToolbarItemView { { let log_toolbar_view = log_toolbar_view.clone(); move |cx| { - h_stack() + h_flex() .w_full() .justify_between() .child(Label::new(RPC_MESSAGES)) @@ -836,7 +847,7 @@ impl Render for LspLogToolbarItemView { .into() }); - h_stack().size_full().child(lsp_menu).child( + h_flex().size_full().child(lsp_menu).child( div() .child( Button::new("clear_log_button", "Clear").on_click(cx.listener( diff --git a/crates/language_tools/src/syntax_tree_view.rs b/crates/language_tools/src/syntax_tree_view.rs index 493ce162cb..082e77fc36 100644 --- a/crates/language_tools/src/syntax_tree_view.rs +++ b/crates/language_tools/src/syntax_tree_view.rs @@ -1,16 +1,15 @@ -use editor::{scroll::autoscroll::Autoscroll, Anchor, Editor, ExcerptId}; +use editor::{scroll::Autoscroll, Anchor, Editor, ExcerptId}; use gpui::{ actions, canvas, div, rems, uniform_list, AnyElement, AppContext, AvailableSpace, Div, EventEmitter, FocusHandle, FocusableView, Hsla, InteractiveElement, IntoElement, Model, - MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Render, Styled, + MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Render, Styled, UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, WindowContext, }; use language::{Buffer, OwnedSyntaxLayer}; -use settings::Settings; use std::{mem, ops::Range}; -use theme::{ActiveTheme, ThemeSettings}; +use theme::ActiveTheme; use tree_sitter::{Node, TreeCursor}; -use ui::{h_stack, popover_menu, ButtonLike, Color, ContextMenu, Label, LabelCommon, PopoverMenu}; +use ui::{h_flex, popover_menu, ButtonLike, Color, ContextMenu, Label, LabelCommon, PopoverMenu}; use workspace::{ item::{Item, ItemHandle}, SplitDirection, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, @@ -34,8 +33,6 @@ pub fn init(cx: &mut AppContext) { pub struct SyntaxTreeView { workspace_handle: WeakView, editor: Option, - mouse_y: Option, - line_height: Option, list_scroll_handle: UniformListScrollHandle, selected_descendant_ix: Option, hovered_descendant_ix: Option, @@ -70,8 +67,6 @@ impl SyntaxTreeView { workspace_handle: workspace_handle.clone(), list_scroll_handle: UniformListScrollHandle::new(), editor: None, - mouse_y: None, - line_height: None, hovered_descendant_ix: None, selected_descendant_ix: None, focus_handle: cx.focus_handle(), @@ -208,39 +203,6 @@ impl SyntaxTreeView { Some(()) } - fn handle_click(&mut self, y: Pixels, cx: &mut ViewContext) -> Option<()> { - let line_height = self.line_height?; - let ix = ((self.list_scroll_handle.scroll_top() + y) / line_height) as usize; - - self.update_editor_with_range_for_descendant_ix(ix, cx, |editor, mut range, cx| { - // Put the cursor at the beginning of the node. - mem::swap(&mut range.start, &mut range.end); - - editor.change_selections(Some(Autoscroll::newest()), cx, |selections| { - selections.select_ranges(vec![range]); - }); - }); - Some(()) - } - - fn hover_state_changed(&mut self, cx: &mut ViewContext) { - if let Some((y, line_height)) = self.mouse_y.zip(self.line_height) { - let ix = ((self.list_scroll_handle.scroll_top() + y) / line_height) as usize; - if self.hovered_descendant_ix != Some(ix) { - self.hovered_descendant_ix = Some(ix); - self.update_editor_with_range_for_descendant_ix(ix, cx, |editor, range, cx| { - editor.clear_background_highlights::(cx); - editor.highlight_background::( - vec![range], - |theme| theme.editor_document_highlight_write_background, - cx, - ); - }); - cx.notify(); - } - } - } - fn update_editor_with_range_for_descendant_ix( &self, descendant_ix: usize, @@ -277,7 +239,7 @@ impl SyntaxTreeView { fn render_node(cursor: &TreeCursor, depth: u32, selected: bool, cx: &AppContext) -> Div { let colors = cx.theme().colors(); - let mut row = h_stack(); + let mut row = h_flex(); if let Some(field_name) = cursor.field_name() { row = row.children([Label::new(field_name).color(Color::Info), Label::new(": ")]); } @@ -306,15 +268,6 @@ impl SyntaxTreeView { impl Render for SyntaxTreeView { fn render(&mut self, cx: &mut gpui::ViewContext<'_, Self>) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let line_height = cx - .text_style() - .line_height_in_pixels(settings.buffer_font_size(cx)); - if Some(line_height) != self.line_height { - self.line_height = Some(line_height); - self.hover_state_changed(cx); - } - let mut rendered = div().flex_1(); if let Some(layer) = self @@ -345,12 +298,51 @@ impl Render for SyntaxTreeView { break; } } else { - items.push(Self::render_node( - &cursor, - depth, - Some(descendant_ix) == this.selected_descendant_ix, - cx, - )); + items.push( + Self::render_node( + &cursor, + depth, + Some(descendant_ix) == this.selected_descendant_ix, + cx, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |tree_view, _: &MouseDownEvent, cx| { + tree_view.update_editor_with_range_for_descendant_ix( + descendant_ix, + cx, + |editor, mut range, cx| { + // Put the cursor at the beginning of the node. + mem::swap(&mut range.start, &mut range.end); + + editor.change_selections( + Some(Autoscroll::newest()), + cx, + |selections| { + selections.select_ranges(vec![range]); + }, + ); + }, + ); + }), + ) + .on_mouse_move(cx.listener( + move |tree_view, _: &MouseMoveEvent, cx| { + if tree_view.hovered_descendant_ix != Some(descendant_ix) { + tree_view.hovered_descendant_ix = Some(descendant_ix); + tree_view.update_editor_with_range_for_descendant_ix(descendant_ix, cx, |editor, range, cx| { + editor.clear_background_highlights::(cx); + editor.highlight_background::( + vec![range], + |theme| theme.editor_document_highlight_write_background, + cx, + ); + }); + cx.notify(); + } + }, + )), + ); descendant_ix += 1; if cursor.goto_first_child() { depth += 1; @@ -364,16 +356,6 @@ impl Render for SyntaxTreeView { ) .size_full() .track_scroll(self.list_scroll_handle.clone()) - .on_mouse_move(cx.listener(move |tree_view, event: &MouseMoveEvent, cx| { - tree_view.mouse_y = Some(event.position.y); - tree_view.hover_state_changed(cx); - })) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |tree_view, event: &MouseDownEvent, cx| { - tree_view.handle_click(event.position.y, cx); - }), - ) .text_bg(cx.theme().colors().background); rendered = rendered.child( @@ -415,6 +397,10 @@ impl Item for SyntaxTreeView { .into_any_element() } + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + fn clone_on_split( &self, _: workspace::WorkspaceId, diff --git a/crates/live_kit_client/LiveKitBridge/Package.resolved b/crates/live_kit_client/LiveKitBridge/Package.resolved index bf17ef24c5..b925bc8f0d 100644 --- a/crates/live_kit_client/LiveKitBridge/Package.resolved +++ b/crates/live_kit_client/LiveKitBridge/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/livekit/client-sdk-swift.git", "state": { "branch": null, - "revision": "8b9cefed8d1669ec8fce41376b56dce3036a5f50", - "version": "1.1.4" + "revision": "7331b813a5ab8a95cfb81fb2b4ed10519428b9ff", + "version": "1.0.12" } }, { @@ -24,8 +24,8 @@ "repositoryURL": "https://github.com/webrtc-sdk/Specs.git", "state": { "branch": null, - "revision": "4fa8d6d647fc759cdd0265fd413d2f28ea2e0e08", - "version": "114.5735.8" + "revision": "2f6bab30c8df0fe59ab3e58bc99097f757f85f65", + "version": "104.5112.17" } }, { diff --git a/crates/live_kit_client/LiveKitBridge/Package.swift b/crates/live_kit_client/LiveKitBridge/Package.swift index abb38efca6..d7b5c271b9 100644 --- a/crates/live_kit_client/LiveKitBridge/Package.swift +++ b/crates/live_kit_client/LiveKitBridge/Package.swift @@ -15,7 +15,7 @@ let package = Package( targets: ["LiveKitBridge"]), ], dependencies: [ - .package(url: "https://github.com/livekit/client-sdk-swift.git", .exact("1.1.4")), + .package(url: "https://github.com/livekit/client-sdk-swift.git", .exact("1.0.12")), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. diff --git a/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift b/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift index 5f22acf581..7468c08791 100644 --- a/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift +++ b/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift @@ -12,6 +12,8 @@ class LKRoomDelegate: RoomDelegate { var onActiveSpeakersChanged: @convention(c) (UnsafeRawPointer, CFArray) -> Void var onDidSubscribeToRemoteVideoTrack: @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer) -> Void var onDidUnsubscribeFromRemoteVideoTrack: @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void + var onDidPublishOrUnpublishLocalAudioTrack: @convention(c) (UnsafeRawPointer, UnsafeRawPointer, Bool) -> Void + var onDidPublishOrUnpublishLocalVideoTrack: @convention(c) (UnsafeRawPointer, UnsafeRawPointer, Bool) -> Void init( data: UnsafeRawPointer, @@ -21,7 +23,10 @@ class LKRoomDelegate: RoomDelegate { onMuteChangedFromRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, Bool) -> Void, onActiveSpeakersChanged: @convention(c) (UnsafeRawPointer, CFArray) -> Void, onDidSubscribeToRemoteVideoTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer) -> Void, - onDidUnsubscribeFromRemoteVideoTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void) + onDidUnsubscribeFromRemoteVideoTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void, + onDidPublishOrUnpublishLocalAudioTrack: @escaping @convention(c) (UnsafeRawPointer, UnsafeRawPointer, Bool) -> Void, + onDidPublishOrUnpublishLocalVideoTrack: @escaping @convention(c) (UnsafeRawPointer, UnsafeRawPointer, Bool) -> Void + ) { self.data = data self.onDidDisconnect = onDidDisconnect @@ -31,6 +36,8 @@ class LKRoomDelegate: RoomDelegate { self.onDidUnsubscribeFromRemoteVideoTrack = onDidUnsubscribeFromRemoteVideoTrack self.onMuteChangedFromRemoteAudioTrack = onMuteChangedFromRemoteAudioTrack self.onActiveSpeakersChanged = onActiveSpeakersChanged + self.onDidPublishOrUnpublishLocalAudioTrack = onDidPublishOrUnpublishLocalAudioTrack + self.onDidPublishOrUnpublishLocalVideoTrack = onDidPublishOrUnpublishLocalVideoTrack } func room(_ room: Room, didUpdate connectionState: ConnectionState, oldValue: ConnectionState) { @@ -65,6 +72,22 @@ class LKRoomDelegate: RoomDelegate { self.onDidUnsubscribeFromRemoteAudioTrack(self.data, participant.identity as CFString, track.sid! as CFString) } } + + func room(_ room: Room, localParticipant: LocalParticipant, didPublish publication: LocalTrackPublication) { + if publication.kind == .video { + self.onDidPublishOrUnpublishLocalVideoTrack(self.data, Unmanaged.passUnretained(publication).toOpaque(), true) + } else if publication.kind == .audio { + self.onDidPublishOrUnpublishLocalAudioTrack(self.data, Unmanaged.passUnretained(publication).toOpaque(), true) + } + } + + func room(_ room: Room, localParticipant: LocalParticipant, didUnpublish publication: LocalTrackPublication) { + if publication.kind == .video { + self.onDidPublishOrUnpublishLocalVideoTrack(self.data, Unmanaged.passUnretained(publication).toOpaque(), false) + } else if publication.kind == .audio { + self.onDidPublishOrUnpublishLocalAudioTrack(self.data, Unmanaged.passUnretained(publication).toOpaque(), false) + } + } } class LKVideoRenderer: NSObject, VideoRenderer { @@ -109,7 +132,9 @@ public func LKRoomDelegateCreate( onMuteChangedFromRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, Bool) -> Void, onActiveSpeakerChanged: @escaping @convention(c) (UnsafeRawPointer, CFArray) -> Void, onDidSubscribeToRemoteVideoTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer) -> Void, - onDidUnsubscribeFromRemoteVideoTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void + onDidUnsubscribeFromRemoteVideoTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void, + onDidPublishOrUnpublishLocalAudioTrack: @escaping @convention(c) (UnsafeRawPointer, UnsafeRawPointer, Bool) -> Void, + onDidPublishOrUnpublishLocalVideoTrack: @escaping @convention(c) (UnsafeRawPointer, UnsafeRawPointer, Bool) -> Void ) -> UnsafeMutableRawPointer { let delegate = LKRoomDelegate( data: data, @@ -119,7 +144,9 @@ public func LKRoomDelegateCreate( onMuteChangedFromRemoteAudioTrack: onMuteChangedFromRemoteAudioTrack, onActiveSpeakersChanged: onActiveSpeakerChanged, onDidSubscribeToRemoteVideoTrack: onDidSubscribeToRemoteVideoTrack, - onDidUnsubscribeFromRemoteVideoTrack: onDidUnsubscribeFromRemoteVideoTrack + onDidUnsubscribeFromRemoteVideoTrack: onDidUnsubscribeFromRemoteVideoTrack, + onDidPublishOrUnpublishLocalAudioTrack: onDidPublishOrUnpublishLocalAudioTrack, + onDidPublishOrUnpublishLocalVideoTrack: onDidPublishOrUnpublishLocalVideoTrack ) return Unmanaged.passRetained(delegate).toOpaque() } @@ -259,6 +286,18 @@ public func LKRemoteAudioTrackGetSid(track: UnsafeRawPointer) -> CFString { return track.sid! as CFString } +@_cdecl("LKRemoteAudioTrackStart") +public func LKRemoteAudioTrackStart(track: UnsafeRawPointer) { + let track = Unmanaged.fromOpaque(track).takeUnretainedValue() + track.start() +} + +@_cdecl("LKRemoteAudioTrackStop") +public func LKRemoteAudioTrackStop(track: UnsafeRawPointer) { + let track = Unmanaged.fromOpaque(track).takeUnretainedValue() + track.stop() +} + @_cdecl("LKDisplaySources") public func LKDisplaySources(data: UnsafeRawPointer, callback: @escaping @convention(c) (UnsafeRawPointer, CFArray?, CFString?) -> Void) { MacOSScreenCapturer.sources(for: .display, includeCurrentApplication: false, preferredMethod: .legacy).then { displaySources in @@ -292,6 +331,14 @@ public func LKLocalTrackPublicationSetMute( } } +@_cdecl("LKLocalTrackPublicationIsMuted") +public func LKLocalTrackPublicationIsMuted( + publication: UnsafeRawPointer +) -> Bool { + let publication = Unmanaged.fromOpaque(publication).takeUnretainedValue() + return publication.muted +} + @_cdecl("LKRemoteTrackPublicationSetEnabled") public func LKRemoteTrackPublicationSetEnabled( publication: UnsafeRawPointer, @@ -325,3 +372,12 @@ public func LKRemoteTrackPublicationGetSid( return publication.sid as CFString } + +@_cdecl("LKLocalTrackPublicationGetSid") +public func LKLocalTrackPublicationGetSid( + publication: UnsafeRawPointer +) -> CFString { + let publication = Unmanaged.fromOpaque(publication).takeUnretainedValue() + + return publication.sid as CFString +} diff --git a/crates/live_kit_client/examples/test_app.rs b/crates/live_kit_client/examples/test_app.rs index 96407497ae..06f2970830 100644 --- a/crates/live_kit_client/examples/test_app.rs +++ b/crates/live_kit_client/examples/test_app.rs @@ -1,10 +1,8 @@ -use std::{sync::Arc, time::Duration}; +use std::time::Duration; use futures::StreamExt; -use gpui::{actions, KeyBinding}; -use live_kit_client::{ - LocalAudioTrack, LocalVideoTrack, RemoteAudioTrackUpdate, RemoteVideoTrackUpdate, Room, -}; +use gpui::{actions, KeyBinding, Menu, MenuItem}; +use live_kit_client::{LocalAudioTrack, LocalVideoTrack, Room, RoomUpdate}; use live_kit_server::token::{self, VideoGrant}; use log::LevelFilter; use simplelog::SimpleLogger; @@ -14,7 +12,7 @@ actions!(live_kit_client, [Quit]); fn main() { SimpleLogger::init(LevelFilter::Info, Default::default()).expect("could not initialize logger"); - gpui::App::production(Arc::new(())).run(|cx| { + gpui::App::new().run(|cx| { #[cfg(any(test, feature = "test-support"))] println!("USING TEST LIVEKIT"); @@ -26,15 +24,14 @@ fn main() { cx.on_action(quit); cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); - // todo!() - // cx.set_menus(vec![Menu { - // name: "Zed", - // items: vec![MenuItem::Action { - // name: "Quit", - // action: Box::new(Quit), - // os_action: None, - // }], - // }]); + cx.set_menus(vec![Menu { + name: "Zed", + items: vec![MenuItem::Action { + name: "Quit", + action: Box::new(Quit), + os_action: None, + }], + }]); let live_kit_url = std::env::var("LIVE_KIT_URL").unwrap_or("http://localhost:7880".into()); let live_kit_key = std::env::var("LIVE_KIT_KEY").unwrap_or("devkey".into()); @@ -61,12 +58,12 @@ fn main() { let room_b = Room::new(); room_b.connect(&live_kit_url, &user2_token).await.unwrap(); - let mut audio_track_updates = room_b.remote_audio_track_updates(); + let mut room_updates = room_b.updates(); let audio_track = LocalAudioTrack::create(); let audio_track_publication = room_a.publish_audio_track(audio_track).await.unwrap(); - if let RemoteAudioTrackUpdate::Subscribed(track, _) = - audio_track_updates.next().await.unwrap() + if let RoomUpdate::SubscribedToRemoteAudioTrack(track, _) = + room_updates.next().await.unwrap() { let remote_tracks = room_b.remote_audio_tracks("test-participant-1"); assert_eq!(remote_tracks.len(), 1); @@ -79,8 +76,8 @@ fn main() { audio_track_publication.set_mute(true).await.unwrap(); println!("waiting for mute changed!"); - if let RemoteAudioTrackUpdate::MuteChanged { track_id, muted } = - audio_track_updates.next().await.unwrap() + if let RoomUpdate::RemoteAudioTrackMuteChanged { track_id, muted } = + room_updates.next().await.unwrap() { let remote_tracks = room_b.remote_audio_tracks("test-participant-1"); assert_eq!(remote_tracks[0].sid(), track_id); @@ -91,8 +88,8 @@ fn main() { audio_track_publication.set_mute(false).await.unwrap(); - if let RemoteAudioTrackUpdate::MuteChanged { track_id, muted } = - audio_track_updates.next().await.unwrap() + if let RoomUpdate::RemoteAudioTrackMuteChanged { track_id, muted } = + room_updates.next().await.unwrap() { let remote_tracks = room_b.remote_audio_tracks("test-participant-1"); assert_eq!(remote_tracks[0].sid(), track_id); @@ -111,13 +108,13 @@ fn main() { room_a.unpublish_track(audio_track_publication); // Clear out any active speakers changed messages - let mut next = audio_track_updates.next().await.unwrap(); - while let RemoteAudioTrackUpdate::ActiveSpeakersChanged { speakers } = next { + let mut next = room_updates.next().await.unwrap(); + while let RoomUpdate::ActiveSpeakersChanged { speakers } = next { println!("Speakers changed: {:?}", speakers); - next = audio_track_updates.next().await.unwrap(); + next = room_updates.next().await.unwrap(); } - if let RemoteAudioTrackUpdate::Unsubscribed { + if let RoomUpdate::UnsubscribedFromRemoteAudioTrack { publisher_id, track_id, } = next @@ -129,7 +126,6 @@ fn main() { panic!("unexpected message"); } - let mut video_track_updates = room_b.remote_video_track_updates(); let displays = room_a.display_sources().await.unwrap(); let display = displays.into_iter().next().unwrap(); @@ -137,8 +133,8 @@ fn main() { let local_video_track_publication = room_a.publish_video_track(local_video_track).await.unwrap(); - if let RemoteVideoTrackUpdate::Subscribed(track) = - video_track_updates.next().await.unwrap() + if let RoomUpdate::SubscribedToRemoteVideoTrack(track) = + room_updates.next().await.unwrap() { let remote_video_tracks = room_b.remote_video_tracks("test-participant-1"); assert_eq!(remote_video_tracks.len(), 1); @@ -153,10 +149,10 @@ fn main() { .pop() .unwrap(); room_a.unpublish_track(local_video_track_publication); - if let RemoteVideoTrackUpdate::Unsubscribed { + if let RoomUpdate::UnsubscribedFromRemoteVideoTrack { publisher_id, track_id, - } = video_track_updates.next().await.unwrap() + } = room_updates.next().await.unwrap() { assert_eq!(publisher_id, "test-participant-1"); assert_eq!(remote_video_track.sid(), track_id); diff --git a/crates/live_kit_client/src/live_kit_client.rs b/crates/live_kit_client/src/live_kit_client.rs index 47cc3873ff..abec27462e 100644 --- a/crates/live_kit_client/src/live_kit_client.rs +++ b/crates/live_kit_client/src/live_kit_client.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + #[cfg(not(any(test, feature = "test-support")))] pub mod prod; @@ -9,3 +11,25 @@ pub mod test; #[cfg(any(test, feature = "test-support"))] pub use test::*; + +pub type Sid = String; + +#[derive(Clone, Eq, PartialEq)] +pub enum ConnectionState { + Disconnected, + Connected { url: String, token: String }, +} + +#[derive(Clone)] +pub enum RoomUpdate { + ActiveSpeakersChanged { speakers: Vec }, + RemoteAudioTrackMuteChanged { track_id: Sid, muted: bool }, + SubscribedToRemoteVideoTrack(Arc), + SubscribedToRemoteAudioTrack(Arc, Arc), + UnsubscribedFromRemoteVideoTrack { publisher_id: Sid, track_id: Sid }, + UnsubscribedFromRemoteAudioTrack { publisher_id: Sid, track_id: Sid }, + LocalAudioTrackPublished { publication: LocalTrackPublication }, + LocalAudioTrackUnpublished { publication: LocalTrackPublication }, + LocalVideoTrackPublished { publication: LocalTrackPublication }, + LocalVideoTrackUnpublished { publication: LocalTrackPublication }, +} diff --git a/crates/live_kit_client/src/prod.rs b/crates/live_kit_client/src/prod.rs index b2b83e95fc..a4bd9d4f07 100644 --- a/crates/live_kit_client/src/prod.rs +++ b/crates/live_kit_client/src/prod.rs @@ -1,3 +1,4 @@ +use crate::{ConnectionState, RoomUpdate, Sid}; use anyhow::{anyhow, Context, Result}; use core_foundation::{ array::{CFArray, CFArrayRef}, @@ -17,8 +18,6 @@ use std::{ sync::{Arc, Weak}, }; -// SAFETY: Most live kit types are threadsafe: -// https://github.com/livekit/client-sdk-swift#thread-safety macro_rules! pointer_type { ($pointer_name:ident) => { #[repr(transparent)] @@ -76,6 +75,16 @@ extern "C" { publisher_id: CFStringRef, track_id: CFStringRef, ), + on_did_publish_or_unpublish_local_audio_track: extern "C" fn( + callback_data: *mut c_void, + publication: swift::LocalTrackPublication, + is_published: bool, + ), + on_did_publish_or_unpublish_local_video_track: extern "C" fn( + callback_data: *mut c_void, + publication: swift::LocalTrackPublication, + is_published: bool, + ), ) -> swift::RoomDelegate; fn LKRoomCreate(delegate: swift::RoomDelegate) -> swift::Room; @@ -123,8 +132,10 @@ extern "C" { ) -> *const c_void; fn LKRemoteAudioTrackGetSid(track: swift::RemoteAudioTrack) -> CFStringRef; - fn LKVideoTrackAddRenderer(track: swift::RemoteVideoTrack, renderer: *const c_void); fn LKRemoteVideoTrackGetSid(track: swift::RemoteVideoTrack) -> CFStringRef; + fn LKRemoteAudioTrackStart(track: swift::RemoteAudioTrack); + fn LKRemoteAudioTrackStop(track: swift::RemoteAudioTrack); + fn LKVideoTrackAddRenderer(track: swift::RemoteVideoTrack, renderer: *const c_void); fn LKDisplaySources( callback_data: *mut c_void, @@ -151,42 +162,31 @@ extern "C" { callback_data: *mut c_void, ); + fn LKLocalTrackPublicationIsMuted(publication: swift::LocalTrackPublication) -> bool; fn LKRemoteTrackPublicationIsMuted(publication: swift::RemoteTrackPublication) -> bool; + fn LKLocalTrackPublicationGetSid(publication: swift::LocalTrackPublication) -> CFStringRef; fn LKRemoteTrackPublicationGetSid(publication: swift::RemoteTrackPublication) -> CFStringRef; } -pub type Sid = String; - -#[derive(Clone, Eq, PartialEq)] -pub enum ConnectionState { - Disconnected, - Connected { url: String, token: String }, -} - pub struct Room { - native_room: Mutex, + native_room: swift::Room, connection: Mutex<( watch::Sender, watch::Receiver, )>, - remote_audio_track_subscribers: Mutex>>, - remote_video_track_subscribers: Mutex>>, - _delegate: Mutex, + update_subscribers: Mutex>>, + _delegate: RoomDelegate, } -trait AssertSendSync: Send {} -impl AssertSendSync for Room {} - impl Room { pub fn new() -> Arc { Arc::new_cyclic(|weak_room| { let delegate = RoomDelegate::new(weak_room.clone()); Self { - native_room: Mutex::new(unsafe { LKRoomCreate(delegate.native_delegate) }), + native_room: unsafe { LKRoomCreate(delegate.native_delegate) }, connection: Mutex::new(watch::channel_with(ConnectionState::Disconnected)), - remote_audio_track_subscribers: Default::default(), - remote_video_track_subscribers: Default::default(), - _delegate: Mutex::new(delegate), + update_subscribers: Default::default(), + _delegate: delegate, } }) } @@ -201,7 +201,7 @@ impl Room { let (did_connect, tx, rx) = Self::build_done_callback(); unsafe { LKRoomConnect( - *self.native_room.lock(), + self.native_room, url.as_concrete_TypeRef(), token.as_concrete_TypeRef(), did_connect, @@ -271,7 +271,7 @@ impl Room { } unsafe { LKRoomPublishVideoTrack( - *self.native_room.lock(), + self.native_room, track.0, callback, Box::into_raw(Box::new(tx)) as *mut c_void, @@ -301,7 +301,7 @@ impl Room { } unsafe { LKRoomPublishAudioTrack( - *self.native_room.lock(), + self.native_room, track.0, callback, Box::into_raw(Box::new(tx)) as *mut c_void, @@ -312,14 +312,14 @@ impl Room { pub fn unpublish_track(&self, publication: LocalTrackPublication) { unsafe { - LKRoomUnpublishTrack(*self.native_room.lock(), publication.0); + LKRoomUnpublishTrack(self.native_room, publication.0); } } pub fn remote_video_tracks(&self, participant_id: &str) -> Vec> { unsafe { let tracks = LKRoomVideoTracksForRemoteParticipant( - *self.native_room.lock(), + self.native_room, CFString::new(participant_id).as_concrete_TypeRef(), ); @@ -348,7 +348,7 @@ impl Room { pub fn remote_audio_tracks(&self, participant_id: &str) -> Vec> { unsafe { let tracks = LKRoomAudioTracksForRemoteParticipant( - *self.native_room.lock(), + self.native_room, CFString::new(participant_id).as_concrete_TypeRef(), ); @@ -380,7 +380,7 @@ impl Room { ) -> Vec> { unsafe { let tracks = LKRoomAudioTrackPublicationsForRemoteParticipant( - *self.native_room.lock(), + self.native_room, CFString::new(participant_id).as_concrete_TypeRef(), ); @@ -400,15 +400,9 @@ impl Room { } } - pub fn remote_audio_track_updates(&self) -> mpsc::UnboundedReceiver { + pub fn updates(&self) -> mpsc::UnboundedReceiver { let (tx, rx) = mpsc::unbounded(); - self.remote_audio_track_subscribers.lock().push(tx); - rx - } - - pub fn remote_video_track_updates(&self) -> mpsc::UnboundedReceiver { - let (tx, rx) = mpsc::unbounded(); - self.remote_video_track_subscribers.lock().push(tx); + self.update_subscribers.lock().push(tx); rx } @@ -419,8 +413,8 @@ impl Room { ) { let track = Arc::new(track); let publication = Arc::new(publication); - self.remote_audio_track_subscribers.lock().retain(|tx| { - tx.unbounded_send(RemoteAudioTrackUpdate::Subscribed( + self.update_subscribers.lock().retain(|tx| { + tx.unbounded_send(RoomUpdate::SubscribedToRemoteAudioTrack( track.clone(), publication.clone(), )) @@ -429,8 +423,8 @@ impl Room { } fn did_unsubscribe_from_remote_audio_track(&self, publisher_id: String, track_id: String) { - self.remote_audio_track_subscribers.lock().retain(|tx| { - tx.unbounded_send(RemoteAudioTrackUpdate::Unsubscribed { + self.update_subscribers.lock().retain(|tx| { + tx.unbounded_send(RoomUpdate::UnsubscribedFromRemoteAudioTrack { publisher_id: publisher_id.clone(), track_id: track_id.clone(), }) @@ -439,8 +433,8 @@ impl Room { } fn mute_changed_from_remote_audio_track(&self, track_id: String, muted: bool) { - self.remote_audio_track_subscribers.lock().retain(|tx| { - tx.unbounded_send(RemoteAudioTrackUpdate::MuteChanged { + self.update_subscribers.lock().retain(|tx| { + tx.unbounded_send(RoomUpdate::RemoteAudioTrackMuteChanged { track_id: track_id.clone(), muted, }) @@ -448,29 +442,26 @@ impl Room { }); } - // A vec of publisher IDs fn active_speakers_changed(&self, speakers: Vec) { - self.remote_audio_track_subscribers - .lock() - .retain(move |tx| { - tx.unbounded_send(RemoteAudioTrackUpdate::ActiveSpeakersChanged { - speakers: speakers.clone(), - }) - .is_ok() - }); + self.update_subscribers.lock().retain(move |tx| { + tx.unbounded_send(RoomUpdate::ActiveSpeakersChanged { + speakers: speakers.clone(), + }) + .is_ok() + }); } fn did_subscribe_to_remote_video_track(&self, track: RemoteVideoTrack) { let track = Arc::new(track); - self.remote_video_track_subscribers.lock().retain(|tx| { - tx.unbounded_send(RemoteVideoTrackUpdate::Subscribed(track.clone())) + self.update_subscribers.lock().retain(|tx| { + tx.unbounded_send(RoomUpdate::SubscribedToRemoteVideoTrack(track.clone())) .is_ok() }); } fn did_unsubscribe_from_remote_video_track(&self, publisher_id: String, track_id: String) { - self.remote_video_track_subscribers.lock().retain(|tx| { - tx.unbounded_send(RemoteVideoTrackUpdate::Unsubscribed { + self.update_subscribers.lock().retain(|tx| { + tx.unbounded_send(RoomUpdate::UnsubscribedFromRemoteVideoTrack { publisher_id: publisher_id.clone(), track_id: track_id.clone(), }) @@ -508,23 +499,23 @@ impl Room { impl Drop for Room { fn drop(&mut self) { unsafe { - let native_room = &*self.native_room.lock(); - LKRoomDisconnect(*native_room); - CFRelease(native_room.0); + LKRoomDisconnect(self.native_room); + CFRelease(self.native_room.0); } } } struct RoomDelegate { native_delegate: swift::RoomDelegate, - _weak_room: Weak, + weak_room: *mut c_void, } impl RoomDelegate { fn new(weak_room: Weak) -> Self { + let weak_room = weak_room.into_raw() as *mut c_void; let native_delegate = unsafe { LKRoomDelegateCreate( - weak_room.as_ptr() as *mut c_void, + weak_room, Self::on_did_disconnect, Self::on_did_subscribe_to_remote_audio_track, Self::on_did_unsubscribe_from_remote_audio_track, @@ -532,11 +523,13 @@ impl RoomDelegate { Self::on_active_speakers_changed, Self::on_did_subscribe_to_remote_video_track, Self::on_did_unsubscribe_from_remote_video_track, + Self::on_did_publish_or_unpublish_local_audio_track, + Self::on_did_publish_or_unpublish_local_video_track, ) }; Self { native_delegate, - _weak_room: weak_room, + weak_room, } } @@ -645,12 +638,53 @@ impl RoomDelegate { } let _ = Weak::into_raw(room); } + + extern "C" fn on_did_publish_or_unpublish_local_audio_track( + room: *mut c_void, + publication: swift::LocalTrackPublication, + is_published: bool, + ) { + let room = unsafe { Weak::from_raw(room as *mut Room) }; + if let Some(room) = room.upgrade() { + let publication = LocalTrackPublication::new(publication); + let update = if is_published { + RoomUpdate::LocalAudioTrackPublished { publication } + } else { + RoomUpdate::LocalAudioTrackUnpublished { publication } + }; + room.update_subscribers + .lock() + .retain(|tx| tx.unbounded_send(update.clone()).is_ok()); + } + let _ = Weak::into_raw(room); + } + + extern "C" fn on_did_publish_or_unpublish_local_video_track( + room: *mut c_void, + publication: swift::LocalTrackPublication, + is_published: bool, + ) { + let room = unsafe { Weak::from_raw(room as *mut Room) }; + if let Some(room) = room.upgrade() { + let publication = LocalTrackPublication::new(publication); + let update = if is_published { + RoomUpdate::LocalVideoTrackPublished { publication } + } else { + RoomUpdate::LocalVideoTrackUnpublished { publication } + }; + room.update_subscribers + .lock() + .retain(|tx| tx.unbounded_send(update.clone()).is_ok()); + } + let _ = Weak::into_raw(room); + } } impl Drop for RoomDelegate { fn drop(&mut self) { unsafe { CFRelease(self.native_delegate.0); + let _ = Weak::from_raw(self.weak_room); } } } @@ -693,6 +727,10 @@ impl LocalTrackPublication { Self(native_track_publication) } + pub fn sid(&self) -> String { + unsafe { CFString::wrap_under_get_rule(LKLocalTrackPublicationGetSid(self.0)).to_string() } + } + pub fn set_mute(&self, muted: bool) -> impl Future> { let (tx, rx) = futures::channel::oneshot::channel(); @@ -717,6 +755,19 @@ impl LocalTrackPublication { async move { rx.await.unwrap() } } + + pub fn is_muted(&self) -> bool { + unsafe { LKLocalTrackPublicationIsMuted(self.0) } + } +} + +impl Clone for LocalTrackPublication { + fn clone(&self) -> Self { + unsafe { + CFRetain(self.0 .0); + } + Self(self.0) + } } impl Drop for LocalTrackPublication { @@ -725,31 +776,22 @@ impl Drop for LocalTrackPublication { } } -pub struct RemoteTrackPublication { - native_publication: Mutex, -} +pub struct RemoteTrackPublication(swift::RemoteTrackPublication); impl RemoteTrackPublication { pub fn new(native_track_publication: swift::RemoteTrackPublication) -> Self { unsafe { CFRetain(native_track_publication.0); } - Self { - native_publication: Mutex::new(native_track_publication), - } + Self(native_track_publication) } pub fn sid(&self) -> String { - unsafe { - CFString::wrap_under_get_rule(LKRemoteTrackPublicationGetSid( - *self.native_publication.lock(), - )) - .to_string() - } + unsafe { CFString::wrap_under_get_rule(LKRemoteTrackPublicationGetSid(self.0)).to_string() } } pub fn is_muted(&self) -> bool { - unsafe { LKRemoteTrackPublicationIsMuted(*self.native_publication.lock()) } + unsafe { LKRemoteTrackPublicationIsMuted(self.0) } } pub fn set_enabled(&self, enabled: bool) -> impl Future> { @@ -767,7 +809,7 @@ impl RemoteTrackPublication { unsafe { LKRemoteTrackPublicationSetEnabled( - *self.native_publication.lock(), + self.0, enabled, complete_callback, Box::into_raw(Box::new(tx)) as *mut c_void, @@ -780,13 +822,13 @@ impl RemoteTrackPublication { impl Drop for RemoteTrackPublication { fn drop(&mut self) { - unsafe { CFRelease((*self.native_publication.lock()).0) } + unsafe { CFRelease(self.0 .0) } } } #[derive(Debug)] pub struct RemoteAudioTrack { - native_track: Mutex, + native_track: swift::RemoteAudioTrack, sid: Sid, publisher_id: String, } @@ -797,7 +839,7 @@ impl RemoteAudioTrack { CFRetain(native_track.0); } Self { - native_track: Mutex::new(native_track), + native_track, sid, publisher_id, } @@ -811,24 +853,28 @@ impl RemoteAudioTrack { &self.publisher_id } - pub fn enable(&self) -> impl Future> { - async { Ok(()) } + pub fn start(&self) { + unsafe { LKRemoteAudioTrackStart(self.native_track) } } - pub fn disable(&self) -> impl Future> { - async { Ok(()) } + pub fn stop(&self) { + unsafe { LKRemoteAudioTrackStop(self.native_track) } } } impl Drop for RemoteAudioTrack { fn drop(&mut self) { - unsafe { CFRelease(self.native_track.lock().0) } + // todo: uncomment this `CFRelease`, unless we find that it was causing + // the crash in the `livekit.multicast` thread. + // + // unsafe { CFRelease(self.native_track.0) } + let _ = self.native_track; } } #[derive(Debug)] pub struct RemoteVideoTrack { - native_track: Mutex, + native_track: swift::RemoteVideoTrack, sid: Sid, publisher_id: String, } @@ -839,7 +885,7 @@ impl RemoteVideoTrack { CFRetain(native_track.0); } Self { - native_track: Mutex::new(native_track), + native_track, sid, publisher_id, } @@ -888,7 +934,7 @@ impl RemoteVideoTrack { on_frame, on_drop, ); - LKVideoTrackAddRenderer(*self.native_track.lock(), renderer); + LKVideoTrackAddRenderer(self.native_track, renderer); rx } } @@ -896,22 +942,10 @@ impl RemoteVideoTrack { impl Drop for RemoteVideoTrack { fn drop(&mut self) { - unsafe { CFRelease(self.native_track.lock().0) } + unsafe { CFRelease(self.native_track.0) } } } -pub enum RemoteVideoTrackUpdate { - Subscribed(Arc), - Unsubscribed { publisher_id: Sid, track_id: Sid }, -} - -pub enum RemoteAudioTrackUpdate { - ActiveSpeakersChanged { speakers: Vec }, - MuteChanged { track_id: Sid, muted: bool }, - Subscribed(Arc, Arc), - Unsubscribed { publisher_id: Sid, track_id: Sid }, -} - pub struct MacOSDisplay(swift::MacOSDisplay); impl MacOSDisplay { diff --git a/crates/live_kit_client/src/test.rs b/crates/live_kit_client/src/test.rs index 1106e66f31..96ca2b90dc 100644 --- a/crates/live_kit_client/src/test.rs +++ b/crates/live_kit_client/src/test.rs @@ -1,13 +1,21 @@ +use crate::{ConnectionState, RoomUpdate, Sid}; use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; -use collections::{BTreeMap, HashMap}; +use collections::{BTreeMap, HashMap, HashSet}; use futures::Stream; use gpui::BackgroundExecutor; -use live_kit_server::token; +use live_kit_server::{proto, token}; use media::core_video::CVImageBuffer; use parking_lot::Mutex; use postage::watch; -use std::{future::Future, mem, sync::Arc}; +use std::{ + future::Future, + mem, + sync::{ + atomic::{AtomicBool, Ordering::SeqCst}, + Arc, Weak, + }, +}; static SERVERS: Mutex>> = Mutex::new(BTreeMap::new()); @@ -104,9 +112,26 @@ impl TestServer { client_room .0 .lock() - .video_track_updates + .updates_tx + .try_broadcast(RoomUpdate::SubscribedToRemoteVideoTrack(Arc::new( + RemoteVideoTrack { + server_track: track.clone(), + }, + ))) + .unwrap(); + } + for track in &room.audio_tracks { + client_room .0 - .try_broadcast(RemoteVideoTrackUpdate::Subscribed(track.clone())) + .lock() + .updates_tx + .try_broadcast(RoomUpdate::SubscribedToRemoteAudioTrack( + Arc::new(RemoteAudioTrack { + server_track: track.clone(), + room: Arc::downgrade(&client_room), + }), + Arc::new(RemoteTrackPublication), + )) .unwrap(); } room.client_rooms.insert(identity, client_room); @@ -151,6 +176,21 @@ impl TestServer { 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(identity, permission); + Ok(()) + } + pub async fn disconnect_client(&self, client_identity: String) { self.executor.simulate_random_delay().await; let mut server_rooms = self.rooms.lock(); @@ -161,7 +201,11 @@ impl TestServer { } } - async fn publish_video_track(&self, token: String, local_track: LocalVideoTrack) -> Result<()> { + async fn publish_video_track( + &self, + token: String, + local_track: LocalVideoTrack, + ) -> Result { self.executor.simulate_random_delay().await; let claims = live_kit_server::token::validate(&token, &self.secret_key)?; let identity = claims.sub.unwrap().to_string(); @@ -172,8 +216,20 @@ impl TestServer { .get_mut(&*room_name) .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; - let track = Arc::new(RemoteVideoTrack { - sid: nanoid::nanoid!(17), + 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 = nanoid::nanoid!(17); + let track = Arc::new(TestServerVideoTrack { + sid: sid.clone(), publisher_id: identity.clone(), frames_rx: local_track.frames_rx.clone(), }); @@ -185,21 +241,24 @@ impl TestServer { let _ = client_room .0 .lock() - .video_track_updates - .0 - .try_broadcast(RemoteVideoTrackUpdate::Subscribed(track.clone())) + .updates_tx + .try_broadcast(RoomUpdate::SubscribedToRemoteVideoTrack(Arc::new( + RemoteVideoTrack { + server_track: track.clone(), + }, + ))) .unwrap(); } } - Ok(()) + Ok(sid) } async fn publish_audio_track( &self, token: String, _local_track: &LocalAudioTrack, - ) -> Result<()> { + ) -> Result { self.executor.simulate_random_delay().await; let claims = live_kit_server::token::validate(&token, &self.secret_key)?; let identity = claims.sub.unwrap().to_string(); @@ -210,9 +269,22 @@ impl TestServer { .get_mut(&*room_name) .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; - let track = Arc::new(RemoteAudioTrack { - sid: nanoid::nanoid!(17), + 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 = nanoid::nanoid!(17); + let track = Arc::new(TestServerAudioTrack { + sid: sid.clone(), publisher_id: identity.clone(), + muted: AtomicBool::new(false), }); let publication = Arc::new(RemoteTrackPublication); @@ -224,47 +296,136 @@ impl TestServer { let _ = client_room .0 .lock() - .audio_track_updates - .0 - .try_broadcast(RemoteAudioTrackUpdate::Subscribed( - track.clone(), + .updates_tx + .try_broadcast(RoomUpdate::SubscribedToRemoteAudioTrack( + Arc::new(RemoteAudioTrack { + server_track: track.clone(), + room: Arc::downgrade(&client_room), + }), publication.clone(), )) .unwrap(); } } + Ok(sid) + } + + fn set_track_muted(&self, token: &str, track_sid: &str, muted: bool) -> Result<()> { + let claims = live_kit_server::token::validate(&token, &self.secret_key)?; + let room_name = claims.video.room.unwrap(); + let identity = claims.sub.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))?; + 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 { + client_room + .0 + .lock() + .updates_tx + .try_broadcast(RoomUpdate::RemoteAudioTrackMuteChanged { + track_id: track_sid.to_string(), + muted, + }) + .unwrap(); + } + } + } Ok(()) } + fn is_track_muted(&self, token: &str, track_sid: &str) -> Option { + let claims = live_kit_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 = live_kit_server::token::validate(&token, &self.secret_key)?; let room_name = claims.video.room.unwrap(); + let identity = claims.sub.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))?; - Ok(room.video_tracks.clone()) + room.client_rooms + .get(identity.as_ref()) + .ok_or_else(|| anyhow!("not a participant in room"))?; + Ok(room + .video_tracks + .iter() + .map(|track| { + Arc::new(RemoteVideoTrack { + server_track: track.clone(), + }) + }) + .collect()) } fn audio_tracks(&self, token: String) -> Result>> { let claims = live_kit_server::token::validate(&token, &self.secret_key)?; let room_name = claims.video.room.unwrap(); + let identity = claims.sub.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))?; - Ok(room.audio_tracks.clone()) + let client_room = room + .client_rooms + .get(identity.as_ref()) + .ok_or_else(|| anyhow!("not a participant in room"))?; + Ok(room + .audio_tracks + .iter() + .map(|track| { + Arc::new(RemoteAudioTrack { + server_track: track.clone(), + room: Arc::downgrade(&client_room), + }) + }) + .collect()) } } #[derive(Default)] struct TestServerRoom { client_rooms: HashMap>, - video_tracks: Vec>, - audio_tracks: Vec>, + video_tracks: Vec>, + audio_tracks: Vec>, + participant_permissions: HashMap, +} + +#[derive(Debug)] +struct TestServerVideoTrack { + sid: Sid, + publisher_id: Sid, + frames_rx: async_broadcast::Receiver, +} + +#[derive(Debug)] +struct TestServerAudioTrack { + sid: Sid, + publisher_id: Sid, + muted: AtomicBool, } impl TestServerRoom {} @@ -297,6 +458,19 @@ impl live_kit_server::api::Client for TestApiClient { Ok(()) } + async fn update_participant( + &self, + room: String, + identity: String, + permission: live_kit_server::proto::ParticipantPermission, + ) -> Result<()> { + let server = TestServer::get(&self.url)?; + server + .update_participant(room, identity, permission) + .await?; + Ok(()) + } + fn room_token(&self, room: &str, identity: &str) -> Result { let server = TestServer::get(&self.url)?; token::create( @@ -318,39 +492,28 @@ impl live_kit_server::api::Client for TestApiClient { } } -pub type Sid = String; - struct RoomState { connection: ( watch::Sender, watch::Receiver, ), display_sources: Vec, - audio_track_updates: ( - async_broadcast::Sender, - async_broadcast::Receiver, - ), - video_track_updates: ( - async_broadcast::Sender, - async_broadcast::Receiver, - ), -} - -#[derive(Clone, Eq, PartialEq)] -pub enum ConnectionState { - Disconnected, - Connected { url: String, token: String }, + paused_audio_tracks: HashSet, + updates_tx: async_broadcast::Sender, + updates_rx: async_broadcast::Receiver, } pub struct Room(Mutex); impl Room { pub fn new() -> Arc { + let (updates_tx, updates_rx) = async_broadcast::broadcast(128); Arc::new(Self(Mutex::new(RoomState { connection: watch::channel_with(ConnectionState::Disconnected), display_sources: Default::default(), - video_track_updates: async_broadcast::broadcast(128), - audio_track_updates: async_broadcast::broadcast(128), + paused_audio_tracks: Default::default(), + updates_tx, + updates_rx, }))) } @@ -389,12 +552,17 @@ impl Room { let this = self.clone(); let track = track.clone(); async move { - this.test_server() + let sid = this + .test_server() .publish_video_track(this.token(), track) .await?; - Ok(LocalTrackPublication) + Ok(LocalTrackPublication { + room: Arc::downgrade(&this), + sid, + }) } } + pub fn publish_audio_track( self: &Arc, track: LocalAudioTrack, @@ -402,10 +570,14 @@ impl Room { let this = self.clone(); let track = track.clone(); async move { - this.test_server() + let sid = this + .test_server() .publish_audio_track(this.token(), &track) .await?; - Ok(LocalTrackPublication) + Ok(LocalTrackPublication { + room: Arc::downgrade(&this), + sid, + }) } } @@ -454,12 +626,8 @@ impl Room { .collect() } - pub fn remote_audio_track_updates(&self) -> impl Stream { - self.0.lock().audio_track_updates.1.clone() - } - - pub fn remote_video_track_updates(&self) -> impl Stream { - self.0.lock().video_track_updates.1.clone() + pub fn updates(&self) -> impl Stream { + self.0.lock().updates_rx.clone() } pub fn set_display_sources(&self, sources: Vec) { @@ -504,11 +672,38 @@ impl Drop for Room { } } -pub struct LocalTrackPublication; +#[derive(Clone)] +pub struct LocalTrackPublication { + sid: String, + room: Weak, +} impl LocalTrackPublication { - pub fn set_mute(&self, _mute: bool) -> impl Future> { - async { Ok(()) } + pub fn set_mute(&self, mute: bool) -> impl Future> { + let sid = self.sid.clone(); + let room = self.room.clone(); + async move { + if let Some(room) = room.upgrade() { + room.test_server() + .set_track_muted(&room.token(), &sid, mute) + } else { + Err(anyhow!("no such room")) + } + } + } + + pub fn is_muted(&self) -> bool { + if let Some(room) = self.room.upgrade() { + room.test_server() + .is_track_muted(&room.token(), &self.sid) + .unwrap_or(false) + } else { + false + } + } + + pub fn sid(&self) -> String { + self.sid.clone() } } @@ -552,61 +747,66 @@ impl LocalAudioTrack { #[derive(Debug)] pub struct RemoteVideoTrack { - sid: Sid, - publisher_id: Sid, - frames_rx: async_broadcast::Receiver, + server_track: Arc, } impl RemoteVideoTrack { pub fn sid(&self) -> &str { - &self.sid + &self.server_track.sid } pub fn publisher_id(&self) -> &str { - &self.publisher_id + &self.server_track.publisher_id } pub fn frames(&self) -> async_broadcast::Receiver { - self.frames_rx.clone() + self.server_track.frames_rx.clone() } } #[derive(Debug)] pub struct RemoteAudioTrack { - sid: Sid, - publisher_id: Sid, + server_track: Arc, + room: Weak, } impl RemoteAudioTrack { pub fn sid(&self) -> &str { - &self.sid + &self.server_track.sid } pub fn publisher_id(&self) -> &str { - &self.publisher_id + &self.server_track.publisher_id } - pub fn enable(&self) -> impl Future> { - async { Ok(()) } + pub fn start(&self) { + if let Some(room) = self.room.upgrade() { + room.0 + .lock() + .paused_audio_tracks + .remove(&self.server_track.sid); + } } - pub fn disable(&self) -> impl Future> { - async { Ok(()) } + pub fn stop(&self) { + if let Some(room) = self.room.upgrade() { + room.0 + .lock() + .paused_audio_tracks + .insert(self.server_track.sid.clone()); + } } -} -#[derive(Clone)] -pub enum RemoteVideoTrackUpdate { - Subscribed(Arc), - Unsubscribed { publisher_id: Sid, track_id: Sid }, -} - -#[derive(Clone)] -pub enum RemoteAudioTrackUpdate { - ActiveSpeakersChanged { speakers: Vec }, - MuteChanged { track_id: Sid, muted: bool }, - Subscribed(Arc, Arc), - Unsubscribed { publisher_id: Sid, track_id: Sid }, + pub fn is_playing(&self) -> bool { + !self + .room + .upgrade() + .unwrap() + .0 + .lock() + .paused_audio_tracks + .contains(&self.server_track.sid) + } } #[derive(Clone)] diff --git a/crates/live_kit_server/src/api.rs b/crates/live_kit_server/src/api.rs index 2c1e174fb4..e7e933c9c3 100644 --- a/crates/live_kit_server/src/api.rs +++ b/crates/live_kit_server/src/api.rs @@ -11,10 +11,18 @@ pub trait Client: Send + Sync { async fn create_room(&self, name: String) -> Result<()>; async fn delete_room(&self, name: String) -> Result<()>; async fn remove_participant(&self, room: String, identity: String) -> Result<()>; + async fn update_participant( + &self, + room: String, + identity: String, + permission: proto::ParticipantPermission, + ) -> Result<()>; fn room_token(&self, room: &str, identity: &str) -> Result; fn guest_token(&self, room: &str, identity: &str) -> Result; } +pub struct LiveKitParticipantUpdate {} + #[derive(Clone)] pub struct LiveKitClient { http: reqwest::Client, @@ -131,6 +139,27 @@ impl Client for LiveKitClient { Ok(()) } + async fn update_participant( + &self, + room: String, + identity: String, + permission: proto::ParticipantPermission, + ) -> Result<()> { + let _: proto::ParticipantInfo = self + .request( + "twirp/livekit.RoomService/UpdateParticipant", + token::VideoGrant::to_admin(&room), + proto::UpdateParticipantRequest { + room: room.clone(), + identity, + metadata: "".to_string(), + permission: Some(permission), + }, + ) + .await?; + Ok(()) + } + fn room_token(&self, room: &str, identity: &str) -> Result { token::create( &self.key, diff --git a/crates/live_kit_server/src/live_kit_server.rs b/crates/live_kit_server/src/live_kit_server.rs index 7471a96ec4..aa7c1f2fd0 100644 --- a/crates/live_kit_server/src/live_kit_server.rs +++ b/crates/live_kit_server/src/live_kit_server.rs @@ -1,3 +1,3 @@ pub mod api; -mod proto; +pub mod proto; pub mod token; diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 788c424373..a70422008c 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -39,6 +39,7 @@ type NotificationHandler = Box, &str, AsyncAppCon type ResponseHandler = Box)>; type IoHandler = Box; +/// Kind of language server stdio given to an IO handler. #[derive(Debug, Clone, Copy)] pub enum IoKind { StdOut, @@ -46,12 +47,15 @@ pub enum IoKind { StdErr, } +/// Represents a launchable language server. This can either be a standalone binary or the path +/// to a runtime with arguments to instruct it to launch the actual language server file. #[derive(Debug, Clone, Deserialize)] pub struct LanguageServerBinary { pub path: PathBuf, pub arguments: Vec, } +/// A running language server process. pub struct LanguageServer { server_id: LanguageServerId, next_id: AtomicUsize, @@ -70,10 +74,12 @@ pub struct LanguageServer { _server: Option>, } +/// Identifies a running language server. #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] #[repr(transparent)] pub struct LanguageServerId(pub usize); +/// Handle to a language server RPC activity subscription. pub enum Subscription { Notification { method: &'static str, @@ -85,6 +91,9 @@ pub enum Subscription { }, } +/// Language server protocol RPC request message. +/// +/// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#requestMessage) #[derive(Serialize, Deserialize)] pub struct Request<'a, T> { jsonrpc: &'static str, @@ -93,6 +102,7 @@ pub struct Request<'a, T> { params: T, } +/// Language server protocol RPC request response message before it is deserialized into a concrete type. #[derive(Serialize, Deserialize)] struct AnyResponse<'a> { jsonrpc: &'a str, @@ -103,6 +113,9 @@ struct AnyResponse<'a> { result: Option<&'a RawValue>, } +/// Language server protocol RPC request response message. +/// +/// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#responseMessage) #[derive(Serialize)] struct Response { jsonrpc: &'static str, @@ -111,6 +124,9 @@ struct Response { error: Option, } +/// Language server protocol RPC notification message. +/// +/// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#notificationMessage) #[derive(Serialize, Deserialize)] struct Notification<'a, T> { jsonrpc: &'static str, @@ -119,6 +135,7 @@ struct Notification<'a, T> { params: T, } +/// Language server RPC notification message before it is deserialized into a concrete type. #[derive(Debug, Clone, Deserialize)] struct AnyNotification<'a> { #[serde(default)] @@ -135,6 +152,7 @@ struct Error { } impl LanguageServer { + /// Starts a language server process. pub fn new( stderr_capture: Arc>>, server_id: LanguageServerId, @@ -277,6 +295,7 @@ impl LanguageServer { } } + /// List of code action kinds this language server reports being able to emit. pub fn code_action_kinds(&self) -> Option> { self.code_action_kinds.clone() } @@ -427,9 +446,10 @@ impl LanguageServer { Ok(()) } - /// Initializes a language server. - /// Note that `options` is used directly to construct [`InitializeParams`], - /// which is why it is owned. + /// Initializes a language server by sending the `Initialize` request. + /// Note that `options` is used directly to construct [`InitializeParams`], which is why it is owned. + /// + /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize) pub async fn initialize(mut self, options: Option) -> Result> { let root_uri = Url::from_file_path(&self.root_path).unwrap(); #[allow(deprecated)] @@ -564,6 +584,7 @@ impl LanguageServer { Ok(Arc::new(self)) } + /// Sends a shutdown request to the language server process and prepares the [`LanguageServer`] to be dropped. pub fn shutdown(&self) -> Option>> { if let Some(tasks) = self.io_tasks.lock().take() { let response_handlers = self.response_handlers.clone(); @@ -598,6 +619,9 @@ impl LanguageServer { } } + /// Register a handler to handle incoming LSP notifications. + /// + /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#notificationMessage) #[must_use] pub fn on_notification(&self, f: F) -> Subscription where @@ -607,6 +631,9 @@ impl LanguageServer { self.on_custom_notification(T::METHOD, f) } + /// Register a handler to handle incoming LSP requests. + /// + /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#requestMessage) #[must_use] pub fn on_request(&self, f: F) -> Subscription where @@ -618,6 +645,7 @@ impl LanguageServer { self.on_custom_request(T::METHOD, f) } + /// Registers a handler to inspect all language server process stdio. #[must_use] pub fn on_io(&self, f: F) -> Subscription where @@ -631,20 +659,23 @@ impl LanguageServer { } } + /// Removes a request handler registers via [`Self::on_request`]. pub fn remove_request_handler(&self) { self.notification_handlers.lock().remove(T::METHOD); } + /// Removes a notification handler registers via [`Self::on_notification`]. pub fn remove_notification_handler(&self) { self.notification_handlers.lock().remove(T::METHOD); } + /// Checks if a notification handler has been registered via [`Self::on_notification`]. pub fn has_notification_handler(&self) -> bool { self.notification_handlers.lock().contains_key(T::METHOD) } #[must_use] - pub fn on_custom_notification(&self, method: &'static str, mut f: F) -> Subscription + fn on_custom_notification(&self, method: &'static str, mut f: F) -> Subscription where F: 'static + FnMut(Params, AsyncAppContext) + Send, Params: DeserializeOwned, @@ -668,11 +699,7 @@ impl LanguageServer { } #[must_use] - pub fn on_custom_request( - &self, - method: &'static str, - mut f: F, - ) -> Subscription + fn on_custom_request(&self, method: &'static str, mut f: F) -> Subscription where F: 'static + FnMut(Params, AsyncAppContext) -> Fut + Send, Fut: 'static + Future>, @@ -750,22 +777,29 @@ impl LanguageServer { } } + /// Get the name of the running language server. pub fn name(&self) -> &str { &self.name } + /// Get the reported capabilities of the running language server. pub fn capabilities(&self) -> &ServerCapabilities { &self.capabilities } + /// Get the id of the running language server. pub fn server_id(&self) -> LanguageServerId { self.server_id } + /// Get the root path of the project the language server is running against. pub fn root_path(&self) -> &PathBuf { &self.root_path } + /// Sends a RPC request to the language server. + /// + /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#requestMessage) pub fn request( &self, params: T::Params, @@ -839,7 +873,7 @@ impl LanguageServer { futures::select! { response = rx.fuse() => { let elapsed = started.elapsed(); - log::trace!("Took {elapsed:?} to recieve response to {method:?} id {id}"); + log::trace!("Took {elapsed:?} to receive response to {method:?} id {id}"); response? } @@ -851,6 +885,9 @@ impl LanguageServer { } } + /// Sends a RPC notification to the language server. + /// + /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#notificationMessage) pub fn notify(&self, params: T::Params) -> Result<()> { Self::notify_internal::(&self.outbound_tx, params) } @@ -879,6 +916,7 @@ impl Drop for LanguageServer { } impl Subscription { + /// Detaching a subscription handle prevents it from unsubscribing on drop. pub fn detach(&mut self) { match self { Subscription::Notification { @@ -925,6 +963,7 @@ impl Drop for Subscription { } } +/// Mock language server for use in tests. #[cfg(any(test, feature = "test-support"))] #[derive(Clone)] pub struct FakeLanguageServer { @@ -933,29 +972,18 @@ pub struct FakeLanguageServer { } #[cfg(any(test, feature = "test-support"))] -impl LanguageServer { - pub fn full_capabilities() -> ServerCapabilities { - ServerCapabilities { - document_highlight_provider: Some(OneOf::Left(true)), - code_action_provider: Some(CodeActionProviderCapability::Simple(true)), - document_formatting_provider: Some(OneOf::Left(true)), - document_range_formatting_provider: Some(OneOf::Left(true)), - definition_provider: Some(OneOf::Left(true)), - type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)), - ..Default::default() - } - } - - pub fn fake( +impl FakeLanguageServer { + /// Construct a fake language server. + pub fn new( name: String, capabilities: ServerCapabilities, cx: AsyncAppContext, - ) -> (Self, FakeLanguageServer) { + ) -> (LanguageServer, FakeLanguageServer) { let (stdin_writer, stdin_reader) = async_pipe::pipe(); let (stdout_writer, stdout_reader) = async_pipe::pipe(); let (notifications_tx, notifications_rx) = channel::unbounded(); - let server = Self::new_internal( + let server = LanguageServer::new_internal( LanguageServerId(0), stdin_writer, stdout_reader, @@ -968,7 +996,7 @@ impl LanguageServer { |_| {}, ); let fake = FakeLanguageServer { - server: Arc::new(Self::new_internal( + server: Arc::new(LanguageServer::new_internal( LanguageServerId(0), stdout_writer, stdin_reader, @@ -1013,12 +1041,29 @@ impl LanguageServer { } } +#[cfg(any(test, feature = "test-support"))] +impl LanguageServer { + pub fn full_capabilities() -> ServerCapabilities { + ServerCapabilities { + document_highlight_provider: Some(OneOf::Left(true)), + code_action_provider: Some(CodeActionProviderCapability::Simple(true)), + document_formatting_provider: Some(OneOf::Left(true)), + document_range_formatting_provider: Some(OneOf::Left(true)), + definition_provider: Some(OneOf::Left(true)), + type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)), + ..Default::default() + } + } +} + #[cfg(any(test, feature = "test-support"))] impl FakeLanguageServer { + /// See [`LanguageServer::notify`]. pub fn notify(&self, params: T::Params) { self.server.notify::(params).ok(); } + /// See [`LanguageServer::request`]. pub async fn request(&self, params: T::Params) -> Result where T: request::Request, @@ -1028,11 +1073,13 @@ impl FakeLanguageServer { self.server.request::(params).await } + /// Attempts [`Self::try_receive_notification`], unwrapping if it has not received the specified type yet. pub async fn receive_notification(&mut self) -> T::Params { self.server.executor.start_waiting(); self.try_receive_notification::().await.unwrap() } + /// Consumes the notification channel until it finds a notification for the specified type. pub async fn try_receive_notification( &mut self, ) -> Option { @@ -1048,6 +1095,7 @@ impl FakeLanguageServer { } } + /// Registers a handler for a specific kind of request. Removes any existing handler for specified request type. pub fn handle_request( &self, mut handler: F, @@ -1076,6 +1124,7 @@ impl FakeLanguageServer { responded_rx } + /// Registers a handler for a specific kind of notification. Removes any existing handler for specified notification type. pub fn handle_notification( &self, mut handler: F, @@ -1096,6 +1145,7 @@ impl FakeLanguageServer { handled_rx } + /// Removes any existing handler for specified notification type. pub fn remove_request_handler(&mut self) where T: 'static + request::Request, @@ -1103,6 +1153,7 @@ impl FakeLanguageServer { self.server.remove_request_handler::(); } + /// Simulate that the server has started work and notifies about its progress with the specified token. pub async fn start_progress(&self, token: impl Into) { let token = token.into(); self.request::(WorkDoneProgressCreateParams { @@ -1116,6 +1167,7 @@ impl FakeLanguageServer { }); } + /// Simulate that the server has completed work and notifies about that with the specified token. pub fn end_progress(&self, token: impl Into) { self.notify::(ProgressParams { token: NumberOrString::String(token.into()), @@ -1139,7 +1191,7 @@ mod tests { #[gpui::test] async fn test_fake(cx: &mut TestAppContext) { let (server, mut fake) = - LanguageServer::fake("the-lsp".to_string(), Default::default(), cx.to_async()); + FakeLanguageServer::new("the-lsp".to_string(), Default::default(), cx.to_async()); let (message_tx, message_rx) = channel::unbounded(); let (diagnostics_tx, diagnostics_rx) = channel::unbounded(); diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index e02f19155b..1abbcb710f 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -72,7 +72,7 @@ pub enum Event { ids: Vec, }, Edited { - sigleton_buffer_edited: bool, + singleton_buffer_edited: bool, }, TransactionUndone { transaction_id: TransactionId, @@ -80,6 +80,7 @@ pub enum Event { Reloaded, DiffBaseChanged, LanguageChanged, + CapabilityChanged, Reparsed, Saved, FileHandleChanged, @@ -1111,7 +1112,7 @@ impl MultiBuffer { new: edit_start..edit_end, }]); cx.emit(Event::Edited { - sigleton_buffer_edited: false, + singleton_buffer_edited: false, }); cx.emit(Event::ExcerptsAdded { buffer, @@ -1137,7 +1138,7 @@ impl MultiBuffer { new: 0..0, }]); cx.emit(Event::Edited { - sigleton_buffer_edited: false, + singleton_buffer_edited: false, }); cx.emit(Event::ExcerptsRemoved { ids }); cx.notify(); @@ -1347,7 +1348,7 @@ impl MultiBuffer { self.subscriptions.publish_mut(edits); cx.emit(Event::Edited { - sigleton_buffer_edited: false, + singleton_buffer_edited: false, }); cx.emit(Event::ExcerptsRemoved { ids }); cx.notify(); @@ -1404,13 +1405,13 @@ impl MultiBuffer { fn on_buffer_event( &mut self, - _: Model, + buffer: Model, event: &language::Event, cx: &mut ModelContext, ) { cx.emit(match event { language::Event::Edited => Event::Edited { - sigleton_buffer_edited: true, + singleton_buffer_edited: true, }, language::Event::DirtyChanged => Event::DirtyChanged, language::Event::Saved => Event::Saved, @@ -1421,6 +1422,10 @@ impl MultiBuffer { language::Event::Reparsed => Event::Reparsed, language::Event::DiagnosticsUpdated => Event::DiagnosticsUpdated, language::Event::Closed => Event::Closed, + language::Event::CapabilityChanged => { + self.capability = buffer.read(cx).capability(); + Event::CapabilityChanged + } // language::Event::Operation(_) => return, @@ -4275,13 +4280,13 @@ mod tests { events.read().as_slice(), &[ Event::Edited { - sigleton_buffer_edited: false + singleton_buffer_edited: false }, Event::Edited { - sigleton_buffer_edited: false + singleton_buffer_edited: false }, Event::Edited { - sigleton_buffer_edited: false + singleton_buffer_edited: false } ] ); diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index a661a693b1..53f78b8fbd 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -1,6 +1,6 @@ use editor::{ - display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Anchor, AnchorRangeExt, - DisplayPoint, Editor, EditorMode, ToPoint, + display_map::ToDisplayPoint, scroll::Autoscroll, Anchor, AnchorRangeExt, DisplayPoint, Editor, + EditorMode, ToPoint, }; use fuzzy::StringMatch; use gpui::{ @@ -64,7 +64,7 @@ impl ModalView for OutlineView { impl Render for OutlineView { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - v_stack().w(rems(34.)).child(self.picker.clone()) + v_flex().w(rems(34.)).child(self.picker.clone()) } } diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index ad75520520..78d1c09e03 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -4,8 +4,8 @@ use gpui::{ FocusableView, Length, MouseButton, MouseDownEvent, Render, Task, UniformListScrollHandle, View, ViewContext, WindowContext, }; -use std::{cmp, sync::Arc}; -use ui::{prelude::*, v_stack, Color, Divider, Label, ListItem, ListItemSpacing, ListSeparator}; +use std::sync::Arc; +use ui::{prelude::*, v_flex, Color, Divider, Label, ListItem, ListItemSpacing, ListSeparator}; use workspace::ModalView; pub struct Picker { @@ -103,7 +103,7 @@ impl Picker { let count = self.delegate.match_count(); if count > 0 { let index = self.delegate.selected_index(); - let ix = cmp::min(index + 1, count - 1); + let ix = if index == count - 1 { 0 } else { index + 1 }; self.delegate.set_selected_index(ix, cx); self.scroll_handle.scroll_to_item(ix); cx.notify(); @@ -114,7 +114,7 @@ impl Picker { let count = self.delegate.match_count(); if count > 0 { let index = self.delegate.selected_index(); - let ix = index.saturating_sub(1); + let ix = if index == 0 { count - 1 } else { index - 1 }; self.delegate.set_selected_index(ix, cx); self.scroll_handle.scroll_to_item(ix); cx.notify(); @@ -236,7 +236,7 @@ impl ModalView for Picker {} impl Render for Picker { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let picker_editor = h_stack() + let picker_editor = h_flex() .overflow_hidden() .flex_none() .h_9() @@ -264,7 +264,7 @@ impl Render for Picker { .child(Divider::horizontal()) .when(self.delegate.match_count() > 0, |el| { el.child( - v_stack() + v_flex() .flex_grow() .py_2() .max_h(self.max_height.unwrap_or(rems(18.).into())) @@ -309,7 +309,7 @@ impl Render for Picker { }) .when(self.delegate.match_count() == 0, |el| { el.child( - v_stack().flex_grow().py_2().child( + v_flex().flex_grow().py_2().child( ListItem::new("empty_state") .inset(true) .spacing(ListItemSpacing::Sparse) diff --git a/crates/plugin_runtime/OPAQUE.md b/crates/plugin_runtime/OPAQUE.md index 52ee75dbf9..4d38409ec2 100644 --- a/crates/plugin_runtime/OPAQUE.md +++ b/crates/plugin_runtime/OPAQUE.md @@ -34,7 +34,7 @@ Rhai actually exposes a pretty nice interface for working with native Rust types > **Note**: Rhai uses strings, but I wonder if you could get away with something more compact using `TypeIds`. Maybe not, given that `TypeId`s are not deterministic across builds, and we'd need matching IDs both host-side and guest side. -In Rhai, we can alternatively use the method `Engine::register_type_with_name::(name: &str)` if we have a different type name host-side (in Rust) and guest-side (in Rhai). +In Rhai, we can alternatively use the method `Engine::register_type_with_name::(name: &str)` if we have a different type name host-side (in Rust) and guest-side (in Rhai). With respect to Wasm plugins, I think an interface like this is fairly important, because we don't know whether the original plugin was written in Rust. (This may not be true now, because we write all the plugins Zed uses, but once we allow packaging and shipping plugins, it's important to maintain a consistent interface, because even Rust changes over time.) @@ -72,15 +72,15 @@ Union::Variant(v, ..) => (*v).as_boxed_any().downcast().ok().map(|x| *x), Now Rhai can do this because it's implemented in Rust. In other words, unlike Wasm, Rhai scripts can, indirectly, hold references to places in host memory. For us to implement something like this for Wasm plugins, we'd have to keep track of a "`ResourcePool`"—alive for the duration of each function call—that we can check rust types into and out of. I think I've got a handle on how Rhai works now, so let's stop talking about Rhai and discuss what this opaque object system would look like if we implemented it in Rust. - + # Design Sketch - + First things first, we'd have to generalize the arguments we can pass to and return from functions host-side. Currently, we support anything that's `serde`able. We'd have to create a new trait, say `Value`, that has blanket implementations for both `serde` and `Clone` (or something like this; if a type is both `serde` and `clone`, we'd have to figure out a way to disambiguate). - - We'd also create a `ResourcePool` struct that essentially is a `Vec` of `Box`. When calling a function, all `Value` arguments that are resources (e.g. `Clone` instead of `serde`) would be typecasted to `dyn Any` and stored in the `ResourcePool`. - + + We'd also create a `ResourcePool` struct that essentially is a `Vec` of `Box`. When calling a function, all `Value` arguments that are resources (e.g. `Clone` instead of `serde`) would be typecasted to `dyn Any` and stored in the `ResourcePool`. + We'd probably also need a `Resource` trait that defines an associated handle for a resource. Something like this: - + ```rust pub trait Resource { type Handle: Serialize + DeserializeOwned; @@ -88,24 +88,24 @@ First things first, we'd have to generalize the arguments we can pass to and ret fn index(handle: Self) -> u32; } ``` - + Where a handle is just a dead-simple wrapper around a `u32`: - - ```rust + + ```rust #[derive(Serialize, Deserialize)] pub struct CoolHandle(u32); ``` - + It's important that this handle be accessible *both* host-side and plugin side. I don't know if this means that we have another crate, like `plugin_handles`, that contains a bunch of u32 wrappers, or something else. Because a `Resource::Handle` is just a u32, it's trivially `serde`, and can cross the ABI boundary. - - So when we add each `T: Resource` to the `ResourcePool`, the resource pool typecasts it to `Any`, appends it to the `Vec`, and returns the associated `Resource::Handle`. This handle is what we pass through to Wasm. - + + So when we add each `T: Resource` to the `ResourcePool`, the resource pool typecasts it to `Any`, appends it to the `Vec`, and returns the associated `Resource::Handle`. This handle is what we pass through to Wasm. + ```rust // Implementations and attributes omitted pub struct Rope { ... }; pub struct RopeHandle(u32); impl Resource for Arc> { ... } - + let builder: PluginBuilder = ...; let builder = builder .host_fn_async( @@ -127,7 +127,7 @@ use plugin_handles::RopeHandle; pub fn append(rope: RopeHandle, string: &str); ``` -This allows us to perform an operation on a `Rope`, but how do we get a `RopeHandle` into a plugin? Well, as plugins, we can only acquire resources to handles we're given, so we'd need to expose a function that takes a handle. +This allows us to perform an operation on a `Rope`, but how do we get a `RopeHandle` into a plugin? Well, as plugins, we can only acquire resources to handles we're given, so we'd need to expose a function that takes a handle. To illustrate that point, here's an example. First, we'd define a plugin-side function as follows: @@ -185,4 +185,4 @@ Using this approach, it should be possible to add fairly good support for resour This next week, I'll try to get a production-ready version of this working, using the `Language` resource required by some Language Server Adapters. -Hope this guide made sense! \ No newline at end of file +Hope this guide made sense! diff --git a/crates/plugin_runtime/README.md b/crates/plugin_runtime/README.md index 38d1c0bb5d..25524dd272 100644 --- a/crates/plugin_runtime/README.md +++ b/crates/plugin_runtime/README.md @@ -164,7 +164,7 @@ To call the functions that a plugin exports host-side, you need to have 'handles For example, let's suppose we're creating a plugin that: -1. formats a message +1. formats a message 2. processes a list of numbers somehow We could create a struct for this plugin as follows: @@ -179,7 +179,7 @@ pub struct CoolPlugin { } ``` -Note that this plugin also holds an owned reference to the runtime, which is stored in the `Plugin` type. In asynchronous or multithreaded contexts, it may be required to put `Plugin` behind an `Arc>`. Although plugins expose an asynchronous interface, the underlying Wasm engine can only execute a single function at a time. +Note that this plugin also holds an owned reference to the runtime, which is stored in the `Plugin` type. In asynchronous or multithreaded contexts, it may be required to put `Plugin` behind an `Arc>`. Although plugins expose an asynchronous interface, the underlying Wasm engine can only execute a single function at a time. > **Note**: This is a limitation of the WebAssembly standard itself. In the future, to work around this, we've been considering starting a pool of plugins, or instantiating a new plugin per call (this isn't as bad as it sounds, as instantiating a new plugin only takes about 30µs). @@ -203,7 +203,7 @@ To add a sync native function to a plugin, use the `.host_function` method: ```rust let builder = builder.host_function( - "add_f64", + "add_f64", |(a, b): (f64, f64)| a + b, ).unwrap(); ``` @@ -224,7 +224,7 @@ To add an async native function to a plugin, use the `.host_function_async` meth ```rust let builder = builder.host_function_async( - "half", + "half", |n: f64| async move { n / 2.0 }, ).unwrap(); ``` @@ -252,9 +252,9 @@ let plugin = builder .unwrap(); ``` -The `.init` method takes a single argument containing the plugin binary. +The `.init` method takes a single argument containing the plugin binary. -1. If not precompiled, use `PluginBinary::Wasm(bytes)`. This supports both the WebAssembly Textual format (`.wat`) and the WebAssembly Binary format (`.wasm`). +1. If not precompiled, use `PluginBinary::Wasm(bytes)`. This supports both the WebAssembly Textual format (`.wat`) and the WebAssembly Binary format (`.wasm`). 2. If precompiled, use `PluginBinary::Precompiled(bytes)`. This supports precompiled plugins ending in `.wasm.pre`. You need to be extra-careful when using precompiled plugins to ensure that the plugin target matches the target of the binary you are compiling. @@ -317,4 +317,4 @@ The `.call` method takes two arguments: This method is async, and must be `.await`ed. If something goes wrong (e.g. the plugin panics, or there is a type mismatch between the plugin and `WasiFn`), then this method will return an error. ## Last Notes -This has been a brief overview of how the plugin system currently works in Zed. We hope to implement higher-level affordances as time goes on, to make writing plugins easier, and providing tooling so that users of Zed may also write plugins to extend their own editors. \ No newline at end of file +This has been a brief overview of how the plugin system currently works in Zed. We hope to implement higher-level affordances as time goes on, to make writing plugins easier, and providing tooling so that users of Zed may also write plugins to extend their own editors. diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 52836f4c00..2c2bed8717 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -2253,7 +2253,7 @@ impl LspCommand for InlayHints { language_server_for_buffer(&project, &buffer, server_id, &mut cx)?; // `typescript-language-server` adds padding to the left for type hints, turning // `const foo: boolean` into `const foo : boolean` which looks odd. - // `rust-analyzer` does not have the padding for this case, and we have to accomodate both. + // `rust-analyzer` does not have the padding for this case, and we have to accommodate both. // // We could trim the whole string, but being pessimistic on par with the situation above, // there might be a hint with multiple whitespaces at the end(s) which we need to display properly. diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index fdaab47bc1..2088fcbdaa 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -99,20 +99,6 @@ pub trait Item { fn project_path(&self, cx: &AppContext) -> Option; } -// Language server state is stored across 3 collections: -// language_servers => -// a mapping from unique server id to LanguageServerState which can either be a task for a -// server in the process of starting, or a running server with adapter and language server arcs -// language_server_ids => a mapping from worktreeId and server name to the unique server id -// language_server_statuses => a mapping from unique server id to the current server status -// -// Multiple worktrees can map to the same language server for example when you jump to the definition -// of a file in the standard library. So language_server_ids is used to look up which server is active -// for a given worktree and language server name -// -// When starting a language server, first the id map is checked to make sure a server isn't already available -// for that worktree. If there is one, it finishes early. Otherwise, a new id is allocated and and -// the Starting variant of LanguageServerState is stored in the language_servers map. pub struct Project { worktrees: Vec, active_entry: Option, @@ -130,7 +116,7 @@ pub struct Project { next_diagnostic_group_id: usize, user_store: Model, fs: Arc, - client_state: Option, + client_state: ProjectClientState, collaborators: HashMap, client_subscriptions: Vec, _subscriptions: Vec, @@ -254,8 +240,10 @@ enum WorktreeHandle { Weak(WeakModel), } +#[derive(Debug)] enum ProjectClientState { - Local { + Local, + Shared { remote_id: u64, updates_tx: mpsc::UnboundedSender, _send_updates: Task>, @@ -657,7 +645,7 @@ impl Project { local_buffer_ids_by_entry_id: Default::default(), buffer_snapshots: Default::default(), join_project_response_message_id: 0, - client_state: None, + client_state: ProjectClientState::Local, opened_buffer: watch::channel(), client_subscriptions: Vec::new(), _subscriptions: vec![ @@ -756,12 +744,12 @@ impl Project { cx.on_app_quit(Self::shutdown_language_servers), ], client: client.clone(), - client_state: Some(ProjectClientState::Remote { + client_state: ProjectClientState::Remote { sharing_has_stopped: false, capability: Capability::ReadWrite, remote_id, replica_id, - }), + }, supplementary_language_servers: HashMap::default(), language_servers: Default::default(), language_server_ids: Default::default(), @@ -799,7 +787,7 @@ impl Project { prettiers_per_worktree: HashMap::default(), prettier_instances: HashMap::default(), }; - this.set_role(role); + this.set_role(role, cx); for worktree in worktrees { let _ = this.add_worktree(&worktree, cx); } @@ -828,16 +816,16 @@ impl Project { fn release(&mut self, cx: &mut AppContext) { match &self.client_state { - Some(ProjectClientState::Local { .. }) => { + ProjectClientState::Local => {} + ProjectClientState::Shared { .. } => { let _ = self.unshare_internal(cx); } - Some(ProjectClientState::Remote { remote_id, .. }) => { + ProjectClientState::Remote { remote_id, .. } => { let _ = self.client.send(proto::LeaveProject { project_id: *remote_id, }); self.disconnected_from_host_internal(cx); } - _ => {} } } @@ -1058,21 +1046,22 @@ impl Project { } pub fn remote_id(&self) -> Option { - match self.client_state.as_ref()? { - ProjectClientState::Local { remote_id, .. } - | ProjectClientState::Remote { remote_id, .. } => Some(*remote_id), + match self.client_state { + ProjectClientState::Local => None, + ProjectClientState::Shared { remote_id, .. } + | ProjectClientState::Remote { remote_id, .. } => Some(remote_id), } } pub fn replica_id(&self) -> ReplicaId { - match &self.client_state { - Some(ProjectClientState::Remote { replica_id, .. }) => *replica_id, + match self.client_state { + ProjectClientState::Remote { replica_id, .. } => replica_id, _ => 0, } } fn metadata_changed(&mut self, cx: &mut ModelContext) { - if let Some(ProjectClientState::Local { updates_tx, .. }) = &mut self.client_state { + if let ProjectClientState::Shared { updates_tx, .. } = &mut self.client_state { updates_tx .unbounded_send(LocalProjectUpdate::WorktreesChanged) .ok(); @@ -1362,7 +1351,7 @@ impl Project { } pub fn shared(&mut self, project_id: u64, cx: &mut ModelContext) -> Result<()> { - if self.client_state.is_some() { + if !matches!(self.client_state, ProjectClientState::Local) { return Err(anyhow!("project was already shared")); } self.client_subscriptions.push( @@ -1423,7 +1412,7 @@ impl Project { let (updates_tx, mut updates_rx) = mpsc::unbounded(); let client = self.client.clone(); - self.client_state = Some(ProjectClientState::Local { + self.client_state = ProjectClientState::Shared { remote_id: project_id, updates_tx, _send_updates: cx.spawn(move |this, mut cx| async move { @@ -1508,7 +1497,7 @@ impl Project { } Ok(()) }), - }); + }; self.metadata_changed(cx); cx.emit(Event::RemoteIdChanged(Some(project_id))); @@ -1578,7 +1567,8 @@ impl Project { return Err(anyhow!("attempted to unshare a remote project")); } - if let Some(ProjectClientState::Local { remote_id, .. }) = self.client_state.take() { + if let ProjectClientState::Shared { remote_id, .. } = self.client_state { + self.client_state = ProjectClientState::Local; self.collaborators.clear(); self.shared_buffers.clear(); self.client_subscriptions.clear(); @@ -1622,22 +1612,30 @@ impl Project { cx.notify(); } - pub fn set_role(&mut self, role: proto::ChannelRole) { - if let Some(ProjectClientState::Remote { capability, .. }) = &mut self.client_state { - *capability = if role == proto::ChannelRole::Member || role == proto::ChannelRole::Admin - { + pub fn set_role(&mut self, role: proto::ChannelRole, cx: &mut ModelContext) { + let new_capability = + if role == proto::ChannelRole::Member || role == proto::ChannelRole::Admin { Capability::ReadWrite } else { Capability::ReadOnly }; + if let ProjectClientState::Remote { capability, .. } = &mut self.client_state { + if *capability == new_capability { + return; + } + + *capability = new_capability; + for buffer in self.opened_buffers() { + buffer.update(cx, |buffer, cx| buffer.set_capability(new_capability, cx)); + } } } fn disconnected_from_host_internal(&mut self, cx: &mut AppContext) { - if let Some(ProjectClientState::Remote { + if let ProjectClientState::Remote { sharing_has_stopped, .. - }) = &mut self.client_state + } = &mut self.client_state { *sharing_has_stopped = true; @@ -1676,18 +1674,18 @@ impl Project { pub fn is_disconnected(&self) -> bool { match &self.client_state { - Some(ProjectClientState::Remote { + ProjectClientState::Remote { sharing_has_stopped, .. - }) => *sharing_has_stopped, + } => *sharing_has_stopped, _ => false, } } pub fn capability(&self) -> Capability { match &self.client_state { - Some(ProjectClientState::Remote { capability, .. }) => *capability, - Some(ProjectClientState::Local { .. }) | None => Capability::ReadWrite, + ProjectClientState::Remote { capability, .. } => *capability, + ProjectClientState::Shared { .. } | ProjectClientState::Local => Capability::ReadWrite, } } @@ -1697,8 +1695,8 @@ impl Project { pub fn is_local(&self) -> bool { match &self.client_state { - Some(ProjectClientState::Remote { .. }) => false, - _ => true, + ProjectClientState::Local | ProjectClientState::Shared { .. } => true, + ProjectClientState::Remote { .. } => false, } } @@ -5578,7 +5576,7 @@ impl Project { // 3. We run a scan over all the candidate buffers on multiple background threads. // We cannot assume that there will even be a match - while at least one match // is guaranteed for files obtained from FS, the buffers we got from memory (unsaved files/unnamed buffers) might not have a match at all. - // There is also an auxilliary background thread responsible for result gathering. + // There is also an auxiliary background thread responsible for result gathering. // This is where the sorted list of buffers comes into play to maintain sorted order; Whenever this background thread receives a notification (buffer has/doesn't have matches), // it keeps it around. It reports matches in sorted order, though it accepts them in unsorted order as well. // As soon as the match info on next position in sorted order becomes available, it reports it (if it's a match) or skips to the next @@ -6155,8 +6153,8 @@ impl Project { pub fn is_shared(&self) -> bool { match &self.client_state { - Some(ProjectClientState::Local { .. }) => true, - _ => false, + ProjectClientState::Shared { .. } => true, + ProjectClientState::Local | ProjectClientState::Remote { .. } => false, } } @@ -7944,7 +7942,7 @@ impl Project { cx: &mut AppContext, ) -> u64 { let buffer_id = buffer.read(cx).remote_id(); - if let Some(ProjectClientState::Local { updates_tx, .. }) = &self.client_state { + if let ProjectClientState::Shared { updates_tx, .. } = &self.client_state { updates_tx .unbounded_send(LocalProjectUpdate::CreateBufferForPeer { peer_id, buffer_id }) .ok(); @@ -7993,21 +7991,21 @@ impl Project { } fn synchronize_remote_buffers(&mut self, cx: &mut ModelContext) -> Task> { - let project_id = match self.client_state.as_ref() { - Some(ProjectClientState::Remote { + let project_id = match self.client_state { + ProjectClientState::Remote { sharing_has_stopped, remote_id, .. - }) => { - if *sharing_has_stopped { + } => { + if sharing_has_stopped { return Task::ready(Err(anyhow!( "can't synchronize remote buffers on a readonly project" ))); } else { - *remote_id + remote_id } } - Some(ProjectClientState::Local { .. }) | None => { + ProjectClientState::Shared { .. } | ProjectClientState::Local => { return Task::ready(Err(anyhow!( "can't synchronize remote buffers on a local project" ))) @@ -8550,7 +8548,7 @@ fn glob_literal_prefix<'a>(glob: &'a str) -> &'a str { break; } else { if i > 0 { - // Acount for separator prior to this part + // Account for separator prior to this part literal_end += path::MAIN_SEPARATOR.len_utf8(); } literal_end += part.len(); diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index 925109ac96..9ec07bc088 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -9,7 +9,7 @@ use std::sync::Arc; pub struct ProjectSettings { /// Configuration for language servers. /// - /// The following settings can be overriden for specific language servers: + /// The following settings can be overridden for specific language servers: /// - initialization_options /// To override settings for a language, add an entry for that language server's /// name to the lsp value. diff --git a/crates/project_panel/src/file_associations.rs b/crates/project_panel/src/file_associations.rs index 82aebe7913..783fae4c52 100644 --- a/crates/project_panel/src/file_associations.rs +++ b/crates/project_panel/src/file_associations.rs @@ -42,9 +42,9 @@ impl FileAssociations { } pub fn get_icon(path: &Path, cx: &AppContext) -> Option> { - let this = cx.has_global::().then(|| cx.global::())?; + let this = cx.try_global::()?; - // FIXME: Associate a type with the languages and have the file's langauge + // FIXME: Associate a type with the languages and have the file's language // override these associations maybe!({ let suffix = path.icon_suffix()?; @@ -58,7 +58,7 @@ impl FileAssociations { } pub fn get_folder_icon(expanded: bool, cx: &AppContext) -> Option> { - let this = cx.has_global::().then(|| cx.global::())?; + let this = cx.try_global::()?; let key = if expanded { EXPANDED_DIRECTORY_TYPE @@ -72,7 +72,7 @@ impl FileAssociations { } pub fn get_chevron_icon(expanded: bool, cx: &AppContext) -> Option> { - let this = cx.has_global::().then(|| cx.global::())?; + let this = cx.try_global::()?; let key = if expanded { EXPANDED_CHEVRON_TYPE diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index ee016f399e..79c158048e 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1,9 +1,9 @@ pub mod file_associations; mod project_panel_settings; -use settings::{Settings, SettingsStore}; +use settings::Settings; use db::kvp::KEY_VALUE_STORE; -use editor::{scroll::autoscroll::Autoscroll, Cancel, Editor}; +use editor::{actions::Cancel, scroll::Autoscroll, Editor}; use file_associations::FileAssociations; use anyhow::{anyhow, Result}; @@ -30,7 +30,7 @@ use std::{ sync::Arc, }; use theme::ThemeSettings; -use ui::{prelude::*, v_stack, ContextMenu, IconElement, KeyBinding, Label, ListItem}; +use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem}; use unicase::UniCase; use util::{maybe, ResultExt, TryFutureExt}; use workspace::{ @@ -58,6 +58,7 @@ pub struct ProjectPanel { workspace: WeakView, width: Option, pending_serialization: Task>, + was_deserialized: bool, } #[derive(Copy, Clone, Debug)] @@ -221,10 +222,10 @@ impl ProjectPanel { }) .detach(); - // cx.observe_global::(|_, cx| { - // cx.notify(); - // }) - // .detach(); + cx.observe_global::(|_, cx| { + cx.notify(); + }) + .detach(); let mut this = Self { project: project.clone(), @@ -243,21 +244,10 @@ impl ProjectPanel { workspace: workspace.weak_handle(), width: None, pending_serialization: Task::ready(None), + was_deserialized: false, }; this.update_visible_entries(None, cx); - // Update the dock position when the setting changes. - let mut old_dock_position = this.position(cx); - ProjectPanelSettings::register(cx); - cx.observe_global::(move |this, cx| { - let new_dock_position = this.position(cx); - if new_dock_position != old_dock_position { - old_dock_position = new_dock_position; - cx.emit(PanelEvent::ChangePosition); - } - }) - .detach(); - this }); @@ -292,16 +282,16 @@ impl ProjectPanel { } &Event::SplitEntry { entry_id } => { if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) { - if let Some(_entry) = worktree.read(cx).entry_for_id(entry_id) { - // workspace - // .split_path( - // ProjectPath { - // worktree_id: worktree.read(cx).id(), - // path: entry.path.clone(), - // }, - // cx, - // ) - // .detach_and_log_err(cx); + if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) { + workspace + .split_path( + ProjectPath { + worktree_id: worktree.read(cx).id(), + path: entry.path.clone(), + }, + cx, + ) + .detach_and_log_err(cx); } } } @@ -334,6 +324,7 @@ impl ProjectPanel { if let Some(serialized_panel) = serialized_panel { panel.update(cx, |panel, cx| { panel.width = serialized_panel.width; + panel.was_deserialized = true; cx.notify(); }); } @@ -788,10 +779,6 @@ impl ProjectPanel { cx.notify(); } } - - // cx.update_global(|drag_and_drop: &mut DragAndDrop, cx| { - // drag_and_drop.cancel_dragging::(cx); - // }) } } @@ -1403,7 +1390,7 @@ impl ProjectPanel { .indent_step_size(px(settings.indent_size)) .selected(is_selected) .child(if let Some(icon) = &icon { - div().child(IconElement::from_path(icon.to_string()).color(Color::Muted)) + div().child(Icon::from_path(icon.to_string()).color(Color::Muted)) } else { div().size(IconSize::default().rems()).invisible() }) @@ -1433,6 +1420,9 @@ impl ProjectPanel { })) .on_secondary_mouse_down(cx.listener( move |this, event: &MouseDownEvent, cx| { + // Stop propagation to prevent the catch-all context menu for the project + // panel from being deployed. + cx.stop_propagation(); this.deploy_context_menu(event.position, entry_id, cx); }, )), @@ -1478,6 +1468,9 @@ impl ProjectPanel { cx.notify(); } } + pub fn was_deserialized(&self) -> bool { + self.was_deserialized + } } impl Render for ProjectPanel { @@ -1554,7 +1547,7 @@ impl Render for ProjectPanel { .child(menu.clone()) })) } else { - v_stack() + v_flex() .id("empty-project_panel") .size_full() .p_4() @@ -1578,7 +1571,7 @@ impl Render for DraggedProjectEntryView { fn render(&mut self, cx: &mut ViewContext) -> impl Element { let settings = ProjectPanelSettings::get_global(cx); let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone(); - h_stack() + h_flex() .font(ui_font) .bg(cx.theme().colors().background) .w(self.width) @@ -1587,7 +1580,7 @@ impl Render for DraggedProjectEntryView { .indent_level(self.details.depth) .indent_step_size(px(settings.indent_size)) .child(if let Some(icon) = &self.details.icon { - div().child(IconElement::from_path(icon.to_string())) + div().child(Icon::from_path(icon.to_string())) } else { div() }) @@ -1637,8 +1630,8 @@ impl Panel for ProjectPanel { cx.notify(); } - fn icon(&self, _: &WindowContext) -> Option { - Some(ui::Icon::FileTree) + fn icon(&self, _: &WindowContext) -> Option { + Some(ui::IconName::FileTree) } fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> { diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index ed31ebd949..3d3e232895 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -1,4 +1,4 @@ -use editor::{scroll::autoscroll::Autoscroll, styled_runs_for_code_label, Bias, Editor}; +use editor::{scroll::Autoscroll, styled_runs_for_code_label, Bias, Editor}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ actions, rems, AppContext, DismissEvent, FontWeight, Model, ParentElement, StyledText, Task, @@ -11,7 +11,7 @@ use std::{borrow::Cow, cmp::Reverse, sync::Arc}; use theme::ActiveTheme; use util::ResultExt; use workspace::{ - ui::{v_stack, Color, Label, LabelCommon, LabelLike, ListItem, ListItemSpacing, Selectable}, + ui::{v_flex, Color, Label, LabelCommon, LabelLike, ListItem, ListItemSpacing, Selectable}, Workspace, }; @@ -242,8 +242,7 @@ impl PickerDelegate for ProjectSymbolsDelegate { .spacing(ListItemSpacing::Sparse) .selected(selected) .child( - // todo!() combine_syntax_and_fuzzy_match_highlights() - v_stack() + v_flex() .child( LabelLike::new().child( StyledText::new(label) diff --git a/crates/quick_action_bar/src/quick_action_bar.rs b/crates/quick_action_bar/src/quick_action_bar.rs index b40794c2fa..3e49328c13 100644 --- a/crates/quick_action_bar/src/quick_action_bar.rs +++ b/crates/quick_action_bar/src/quick_action_bar.rs @@ -6,7 +6,7 @@ use gpui::{ Subscription, View, ViewContext, WeakView, }; use search::{buffer_search, BufferSearchBar}; -use ui::{prelude::*, ButtonSize, ButtonStyle, Icon, IconButton, IconSize, Tooltip}; +use ui::{prelude::*, ButtonSize, ButtonStyle, IconButton, IconName, IconSize, Tooltip}; use workspace::{ item::ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, }; @@ -43,15 +43,15 @@ impl Render for QuickActionBar { let inlay_hints_button = Some(QuickActionBarButton::new( "toggle inlay hints", - Icon::InlayHint, + IconName::InlayHint, editor.read(cx).inlay_hints_enabled(), - Box::new(editor::ToggleInlayHints), + Box::new(editor::actions::ToggleInlayHints), "Toggle Inlay Hints", { let editor = editor.clone(); move |_, cx| { editor.update(cx, |editor, cx| { - editor.toggle_inlay_hints(&editor::ToggleInlayHints, cx); + editor.toggle_inlay_hints(&editor::actions::ToggleInlayHints, cx); }); } }, @@ -60,7 +60,7 @@ impl Render for QuickActionBar { let search_button = Some(QuickActionBarButton::new( "toggle buffer search", - Icon::MagnifyingGlass, + IconName::MagnifyingGlass, !self.buffer_search_bar.read(cx).is_dismissed(), Box::new(buffer_search::Deploy { focus: false }), "Buffer Search", @@ -77,7 +77,7 @@ impl Render for QuickActionBar { let assistant_button = QuickActionBarButton::new( "toggle inline assistant", - Icon::MagicWand, + IconName::MagicWand, false, Box::new(InlineAssist), "Inline Assist", @@ -93,7 +93,7 @@ impl Render for QuickActionBar { }, ); - h_stack() + h_flex() .id("quick action bar") .gap_2() .children(inlay_hints_button) @@ -107,7 +107,7 @@ impl EventEmitter for QuickActionBar {} #[derive(IntoElement)] struct QuickActionBarButton { id: ElementId, - icon: Icon, + icon: IconName, toggled: bool, action: Box, tooltip: SharedString, @@ -117,7 +117,7 @@ struct QuickActionBarButton { impl QuickActionBarButton { fn new( id: impl Into, - icon: Icon, + icon: IconName, toggled: bool, action: Box, tooltip: impl Into, diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index c3b2c21d52..1d8ddefcbc 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -32,7 +32,7 @@ impl RecentProjects { fn new(delegate: RecentProjectsDelegate, rem_width: f32, cx: &mut ViewContext) -> Self { let picker = cx.new_view(|cx| Picker::new(delegate, cx)); let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent)); - // We do not want to block the UI on a potentially lenghty call to DB, so we're gonna swap + // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap // out workspace locations once the future runs to completion. cx.spawn(|this, mut cx| async move { let workspaces = WORKSPACE_DB @@ -104,7 +104,7 @@ impl FocusableView for RecentProjects { impl Render for RecentProjects { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - v_stack() + v_flex() .w(rems(self.rem_width)) .child(self.picker.clone()) .on_mouse_down_out(cx.listener(|this, _, cx| { @@ -236,7 +236,7 @@ impl PickerDelegate for RecentProjectsDelegate { .spacing(ListItemSpacing::Sparse) .selected(selected) .child( - v_stack() + v_flex() .child(highlighted_location.names) .when(self.render_paths, |this| { this.children(highlighted_location.paths) diff --git a/crates/refineable/derive_refineable/src/derive_refineable.rs b/crates/refineable/derive_refineable/src/derive_refineable.rs index ad7678b58f..bc906daece 100644 --- a/crates/refineable/derive_refineable/src/derive_refineable.rs +++ b/crates/refineable/derive_refineable/src/derive_refineable.rs @@ -69,13 +69,6 @@ pub fn derive_refineable(input: TokenStream) -> TokenStream { path: parse_quote!(Clone), })); - // punctuated.push_punct(syn::token::Add::default()); - // punctuated.push_value(TypeParamBound::Trait(TraitBound { - // paren_token: None, - // modifier: syn::TraitBoundModifier::None, - // lifetimes: None, - // path: parse_quote!(Default), - // })); punctuated }, }) @@ -94,10 +87,6 @@ pub fn derive_refineable(input: TokenStream) -> TokenStream { }, }; - // refinable_refine_assignments - // refinable_refined_assignments - // refinement_refine_assignments - let refineable_refine_assignments: Vec = fields .iter() .map(|field| { @@ -152,7 +141,7 @@ pub fn derive_refineable(input: TokenStream) -> TokenStream { }) .collect(); - let refinement_refine_assigments: Vec = fields + let refinement_refine_assignments: Vec = fields .iter() .map(|field| { let name = &field.ident; @@ -172,7 +161,7 @@ pub fn derive_refineable(input: TokenStream) -> TokenStream { }) .collect(); - let refinement_refined_assigments: Vec = fields + let refinement_refined_assignments: Vec = fields .iter() .map(|field| { let name = &field.ident; @@ -192,7 +181,7 @@ pub fn derive_refineable(input: TokenStream) -> TokenStream { }) .collect(); - let from_refinement_assigments: Vec = fields + let from_refinement_assignments: Vec = fields .iter() .map(|field| { let name = &field.ident; @@ -283,11 +272,11 @@ pub fn derive_refineable(input: TokenStream) -> TokenStream { type Refinement = #refinement_ident #ty_generics; fn refine(&mut self, refinement: &Self::Refinement) { - #( #refinement_refine_assigments )* + #( #refinement_refine_assignments )* } fn refined(mut self, refinement: Self::Refinement) -> Self { - #( #refinement_refined_assigments )* + #( #refinement_refined_assignments )* self } } @@ -297,7 +286,7 @@ pub fn derive_refineable(input: TokenStream) -> TokenStream { { fn from(value: #refinement_ident #ty_generics) -> Self { Self { - #( #from_refinement_assigments )* + #( #from_refinement_assignments )* } } } diff --git a/crates/rich_text/src/rich_text.rs b/crates/rich_text/src/rich_text.rs index b4a87b1e5d..8bc0d73fd4 100644 --- a/crates/rich_text/src/rich_text.rs +++ b/crates/rich_text/src/rich_text.rs @@ -39,6 +39,7 @@ pub struct RichText { /// Allows one to specify extra links to the rendered markdown, which can be used /// for e.g. mentions. +#[derive(Debug)] pub struct Mention { pub range: Range, pub is_self_mention: bool, @@ -85,31 +86,6 @@ impl RichText { }) .into_any_element() } - - // pub fn add_mention( - // &mut self, - // range: Range, - // is_current_user: bool, - // mention_style: HighlightStyle, - // ) -> anyhow::Result<()> { - // if range.end > self.text.len() { - // bail!( - // "Mention in range {range:?} is outside of bounds for a message of length {}", - // self.text.len() - // ); - // } - - // if is_current_user { - // self.region_ranges.push(range.clone()); - // self.regions.push(RenderedRegion { - // background_kind: Some(BackgroundKind::Mention), - // link_url: None, - // }); - // } - // self.highlights - // .push((range, Highlight::Highlight(mention_style))); - // Ok(()) - // } } pub fn render_markdown_mut( @@ -138,20 +114,21 @@ pub fn render_markdown_mut( if let Some(language) = ¤t_language { render_code(text, highlights, t.as_ref(), language); } else { - if let Some(mention) = mentions.first() { - if source_range.contains_inclusive(&mention.range) { - mentions = &mentions[1..]; - let range = (prev_len + mention.range.start - source_range.start) - ..(prev_len + mention.range.end - source_range.start); - highlights.push(( - range.clone(), - if mention.is_self_mention { - Highlight::SelfMention - } else { - Highlight::Mention - }, - )); + while let Some(mention) = mentions.first() { + if !source_range.contains_inclusive(&mention.range) { + break; } + mentions = &mentions[1..]; + let range = (prev_len + mention.range.start - source_range.start) + ..(prev_len + mention.range.end - source_range.start); + highlights.push(( + range.clone(), + if mention.is_self_mention { + Highlight::SelfMention + } else { + Highlight::Mention + }, + )); } text.push_str(t.as_ref()); @@ -272,13 +249,6 @@ pub fn render_markdown( language_registry: &Arc, language: Option<&Arc>, ) -> RichText { - // let mut data = RichText { - // text: Default::default(), - // highlights: Default::default(), - // region_ranges: Default::default(), - // regions: Default::default(), - // }; - let mut text = String::new(); let mut highlights = Vec::new(); let mut link_ranges = Vec::new(); diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index adfcd19a6c..f9922bf517 100644 --- a/crates/rope/src/rope.rs +++ b/crates/rope/src/rope.rs @@ -25,10 +25,10 @@ const CHUNK_BASE: usize = 6; #[cfg(not(test))] const CHUNK_BASE: usize = 16; -/// Type alias to [HashMatrix], an implementation of a homomorphic hash function. Two [Rope] instances +/// Type alias to [`HashMatrix`], an implementation of a homomorphic hash function. Two [`Rope`] instances /// containing the same text will produce the same fingerprint. This hash function is special in that -/// it allows us to hash individual chunks and aggregate them up the [Rope]'s tree, with the resulting -/// hash being equivalent to hashing all the text contained in the [Rope] at once. +/// it allows us to hash individual chunks and aggregate them up the [`Rope`]'s tree, with the resulting +/// hash being equivalent to hashing all the text contained in the [`Rope`] at once. pub type RopeFingerprint = HashMatrix; #[derive(Clone, Default)] @@ -78,7 +78,7 @@ impl Rope { } pub fn slice_rows(&self, range: Range) -> Rope { - //This would be more efficient with a forward advance after the first, but it's fine + // This would be more efficient with a forward advance after the first, but it's fine. let start = self.point_to_offset(Point::new(range.start, 0)); let end = self.point_to_offset(Point::new(range.end, 0)); self.slice(start..end) diff --git a/crates/rpc/build.rs b/crates/rpc/build.rs index 66b289f1db..25dff9b007 100644 --- a/crates/rpc/build.rs +++ b/crates/rpc/build.rs @@ -1,6 +1,5 @@ fn main() { let mut build = prost_build::Config::new(); - // build.protoc_arg("--experimental_allow_proto3_optional"); build .type_attribute(".", "#[derive(serde::Serialize)]") .compile_protos(&["proto/zed.proto"], &["proto"]) diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 086deeef11..941053be53 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -180,7 +180,8 @@ message Envelope { DeleteNotification delete_notification = 152; MarkNotificationRead mark_notification_read = 153; LspExtExpandMacro lsp_ext_expand_macro = 154; - LspExtExpandMacroResponse lsp_ext_expand_macro_response = 155; // Current max + LspExtExpandMacroResponse lsp_ext_expand_macro_response = 155; + SetRoomParticipantRole set_room_participant_role = 156; // Current max } } @@ -1636,3 +1637,9 @@ message LspExtExpandMacroResponse { string name = 1; string expansion = 2; } + +message SetRoomParticipantRole { + uint64 room_id = 1; + uint64 user_id = 2; + ChannelRole role = 3; +} diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index c5476469be..57131d3421 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -76,30 +76,35 @@ impl Notification { } } -#[test] -fn test_notification() { - // Notifications can be serialized and deserialized. - for notification in [ - Notification::ContactRequest { sender_id: 1 }, - Notification::ContactRequestAccepted { responder_id: 2 }, - Notification::ChannelInvitation { - channel_id: 100, - channel_name: "the-channel".into(), - inviter_id: 50, - }, - Notification::ChannelMessageMention { - sender_id: 200, - channel_id: 30, - message_id: 1, - }, - ] { - let message = notification.to_proto(); - let deserialized = Notification::from_proto(&message).unwrap(); - assert_eq!(deserialized, notification); - } +#[cfg(test)] +mod tests { + use crate::Notification; - // When notifications are serialized, the `kind` and `actor_id` fields are - // stored separately, and do not appear redundantly in the JSON. - let notification = Notification::ContactRequest { sender_id: 1 }; - assert_eq!(notification.to_proto().content, "{}"); + #[test] + fn test_notification() { + // Notifications can be serialized and deserialized. + for notification in [ + Notification::ContactRequest { sender_id: 1 }, + Notification::ContactRequestAccepted { responder_id: 2 }, + Notification::ChannelInvitation { + channel_id: 100, + channel_name: "the-channel".into(), + inviter_id: 50, + }, + Notification::ChannelMessageMention { + sender_id: 200, + channel_id: 30, + message_id: 1, + }, + ] { + let message = notification.to_proto(); + let deserialized = Notification::from_proto(&message).unwrap(); + assert_eq!(deserialized, notification); + } + + // When notifications are serialized, the `kind` and `actor_id` fields are + // stored separately, and do not appear redundantly in the JSON. + let notification = Notification::ContactRequest { sender_id: 1 }; + assert_eq!(notification.to_proto().content, "{}"); + } } diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 25b8b00dae..5d0a994154 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -283,6 +283,7 @@ messages!( (UsersResponse, Foreground), (LspExtExpandMacro, Background), (LspExtExpandMacroResponse, Background), + (SetRoomParticipantRole, Foreground), ); request_messages!( @@ -367,6 +368,7 @@ request_messages!( (UpdateProject, Ack), (UpdateWorktree, Ack), (LspExtExpandMacro, LspExtExpandMacroResponse), + (SetRoomParticipantRole, Ack), ); entity_messages!( diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index c889f0a4a4..f4c9f7ef0f 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -7,7 +7,7 @@ use crate::{ ToggleCaseSensitive, ToggleReplace, ToggleWholeWord, }; use collections::HashMap; -use editor::{Editor, EditorElement, EditorStyle, Tab}; +use editor::{actions::Tab, Editor, EditorElement, EditorStyle}; use futures::channel::oneshot; use gpui::{ actions, div, impl_actions, Action, AppContext, ClickEvent, EventEmitter, FocusableView, @@ -21,7 +21,7 @@ use settings::Settings; use std::{any::Any, sync::Arc}; use theme::ThemeSettings; -use ui::{h_stack, prelude::*, Icon, IconButton, IconElement, ToggleButton, Tooltip}; +use ui::{h_flex, prelude::*, Icon, IconButton, IconName, ToggleButton, Tooltip}; use util::ResultExt; use workspace::{ item::ItemHandle, @@ -43,7 +43,7 @@ pub enum Event { } pub fn init(cx: &mut AppContext) { - cx.observe_new_views(|editor: &mut Workspace, _| BufferSearchBar::register(editor)) + cx.observe_new_views(|workspace: &mut Workspace, _| BufferSearchBar::register(workspace)) .detach(); } @@ -186,7 +186,7 @@ impl Render for BufferSearchBar { } else { cx.theme().colors().border }; - h_stack() + h_flex() .w_full() .gap_2() .key_context(key_context) @@ -216,7 +216,7 @@ impl Render for BufferSearchBar { this.on_action(cx.listener(Self::toggle_whole_word)) }) .child( - h_stack() + h_flex() .flex_1() .px_2() .py_1() @@ -225,7 +225,7 @@ impl Render for BufferSearchBar { .border_color(editor_border) .min_w(rems(384. / 16.)) .rounded_lg() - .child(IconElement::new(Icon::MagnifyingGlass)) + .child(Icon::new(IconName::MagnifyingGlass)) .child(self.render_text_input(&self.query_editor, cx)) .children(supported_options.case.then(|| { self.render_search_option_button( @@ -243,11 +243,11 @@ impl Render for BufferSearchBar { })), ) .child( - h_stack() + h_flex() .gap_2() .flex_none() .child( - h_stack() + h_flex() .child( ToggleButton::new("search-mode-text", SearchMode::Text.label()) .style(ButtonStyle::Filled) @@ -287,7 +287,7 @@ impl Render for BufferSearchBar { this.child( IconButton::new( "buffer-search-bar-toggle-replace-button", - Icon::Replace, + IconName::Replace, ) .style(ButtonStyle::Subtle) .when(self.replace_enabled, |button| { @@ -303,12 +303,12 @@ impl Render for BufferSearchBar { }), ) .child( - h_stack() + h_flex() .gap_0p5() .flex_1() .when(self.replace_enabled, |this| { this.child( - h_stack() + h_flex() .flex_1() // We're giving this a fixed height to match the height of the search input, // which has an icon inside that is increasing its height. @@ -323,7 +323,7 @@ impl Render for BufferSearchBar { ) .when(should_show_replace_input, |this| { this.child( - IconButton::new("search-replace-next", ui::Icon::ReplaceNext) + IconButton::new("search-replace-next", ui::IconName::ReplaceNext) .tooltip(move |cx| { Tooltip::for_action("Replace next", &ReplaceNext, cx) }) @@ -332,7 +332,7 @@ impl Render for BufferSearchBar { })), ) .child( - IconButton::new("search-replace-all", ui::Icon::ReplaceAll) + IconButton::new("search-replace-all", ui::IconName::ReplaceAll) .tooltip(move |cx| { Tooltip::for_action("Replace all", &ReplaceAll, cx) }) @@ -346,11 +346,11 @@ impl Render for BufferSearchBar { }), ) .child( - h_stack() + h_flex() .gap_0p5() .flex_none() .child( - IconButton::new("select-all", ui::Icon::SelectAll) + IconButton::new("select-all", ui::IconName::SelectAll) .on_click(|_, cx| cx.dispatch_action(SelectAllMatches.boxed_clone())) .tooltip(|cx| { Tooltip::for_action("Select all matches", &SelectAllMatches, cx) @@ -358,13 +358,13 @@ impl Render for BufferSearchBar { ) .children(match_count) .child(render_nav_button( - ui::Icon::ChevronLeft, + ui::IconName::ChevronLeft, self.active_match_index.is_some(), "Select previous match", &SelectPrevMatch, )) .child(render_nav_button( - ui::Icon::ChevronRight, + ui::IconName::ChevronRight, self.active_match_index.is_some(), "Select next match", &SelectNextMatch, @@ -423,12 +423,17 @@ impl ToolbarItemView for BufferSearchBar { } } -/// Registrar inverts the dependency between search and it's downstream user, allowing said downstream user to register search action without knowing exactly what those actions are. +/// Registrar inverts the dependency between search and its downstream user, allowing said downstream user to register search action without knowing exactly what those actions are. pub trait SearchActionsRegistrar { fn register_handler( &mut self, callback: fn(&mut BufferSearchBar, &A, &mut ViewContext), ); + + fn register_handler_for_dismissed_search( + &mut self, + callback: fn(&mut BufferSearchBar, &A, &mut ViewContext), + ); } type GetSearchBar = @@ -457,16 +462,62 @@ impl<'a, 'b, T: 'static> DivRegistrar<'a, 'b, T> { } impl SearchActionsRegistrar for DivRegistrar<'_, '_, T> { - fn register_handler( + fn register_handler( &mut self, callback: fn(&mut BufferSearchBar, &A, &mut ViewContext), ) { let getter = self.search_getter; self.div = self.div.take().map(|div| { div.on_action(self.cx.listener(move |this, action, cx| { - (getter)(this, cx) + let should_notify = (getter)(this, cx) .clone() - .map(|search_bar| search_bar.update(cx, |this, cx| callback(this, action, cx))); + .map(|search_bar| { + search_bar.update(cx, |search_bar, cx| { + if search_bar.is_dismissed() + || search_bar.active_searchable_item.is_none() + { + false + } else { + callback(search_bar, action, cx); + true + } + }) + }) + .unwrap_or(false); + if should_notify { + cx.notify(); + } else { + cx.propagate(); + } + })) + }); + } + + fn register_handler_for_dismissed_search( + &mut self, + callback: fn(&mut BufferSearchBar, &A, &mut ViewContext), + ) { + let getter = self.search_getter; + self.div = self.div.take().map(|div| { + div.on_action(self.cx.listener(move |this, action, cx| { + let should_notify = (getter)(this, cx) + .clone() + .map(|search_bar| { + search_bar.update(cx, |search_bar, cx| { + if search_bar.is_dismissed() { + callback(search_bar, action, cx); + true + } else { + false + } + }) + }) + .unwrap_or(false); + if should_notify { + cx.notify(); + } else { + cx.propagate(); + } })) }); } @@ -479,48 +530,95 @@ impl SearchActionsRegistrar for Workspace { callback: fn(&mut BufferSearchBar, &A, &mut ViewContext), ) { self.register_action(move |workspace, action: &A, cx| { + if workspace.has_active_modal(cx) { + cx.propagate(); + return; + } + let pane = workspace.active_pane(); pane.update(cx, move |this, cx| { this.toolbar().update(cx, move |this, cx| { if let Some(search_bar) = this.item_of_type::() { - search_bar.update(cx, move |this, cx| callback(this, action, cx)); - cx.notify(); + let should_notify = search_bar.update(cx, move |search_bar, cx| { + if search_bar.is_dismissed() + || search_bar.active_searchable_item.is_none() + { + false + } else { + callback(search_bar, action, cx); + true + } + }); + if should_notify { + cx.notify(); + } else { + cx.propagate(); + } + } + }) + }); + }); + } + + fn register_handler_for_dismissed_search( + &mut self, + callback: fn(&mut BufferSearchBar, &A, &mut ViewContext), + ) { + self.register_action(move |workspace, action: &A, cx| { + if workspace.has_active_modal(cx) { + cx.propagate(); + return; + } + + let pane = workspace.active_pane(); + pane.update(cx, move |this, cx| { + this.toolbar().update(cx, move |this, cx| { + if let Some(search_bar) = this.item_of_type::() { + let should_notify = search_bar.update(cx, move |search_bar, cx| { + if search_bar.is_dismissed() { + callback(search_bar, action, cx); + true + } else { + false + } + }); + if should_notify { + cx.notify(); + } else { + cx.propagate(); + } } }) }); }); } } + impl BufferSearchBar { - pub fn register_inner(registrar: &mut impl SearchActionsRegistrar) { + pub fn register(registrar: &mut impl SearchActionsRegistrar) { registrar.register_handler(|this, action: &ToggleCaseSensitive, cx| { if this.supported_options().case { this.toggle_case_sensitive(action, cx); } }); - registrar.register_handler(|this, action: &ToggleWholeWord, cx| { if this.supported_options().word { this.toggle_whole_word(action, cx); } }); - registrar.register_handler(|this, action: &ToggleReplace, cx| { if this.supported_options().replacement { this.toggle_replace(action, cx); } }); - registrar.register_handler(|this, _: &ActivateRegexMode, cx| { if this.supported_options().regex { this.activate_search_mode(SearchMode::Regex, cx); } }); - registrar.register_handler(|this, _: &ActivateTextMode, cx| { this.activate_search_mode(SearchMode::Text, cx); }); - registrar.register_handler(|this, action: &CycleMode, cx| { if this.supported_options().regex { // If regex is not supported then search has just one mode (text) - in that case there's no point in supporting @@ -528,7 +626,6 @@ impl BufferSearchBar { this.cycle_mode(action, cx) } }); - registrar.register_handler(|this, action: &SelectNextMatch, cx| { this.select_next_match(action, cx); }); @@ -538,20 +635,20 @@ impl BufferSearchBar { registrar.register_handler(|this, action: &SelectAllMatches, cx| { this.select_all_matches(action, cx); }); - registrar.register_handler(|this, _: &editor::Cancel, cx| { - if !this.dismissed { - this.dismiss(&Dismiss, cx); - return; - } - cx.propagate(); + registrar.register_handler(|this, _: &editor::actions::Cancel, cx| { + this.dismiss(&Dismiss, cx); }); + + // register deploy buffer search for both search bar states, since we want to focus into the search bar + // when the deploy action is triggered in the buffer. registrar.register_handler(|this, deploy, cx| { this.deploy(deploy, cx); + }); + registrar.register_handler_for_dismissed_search(|this, deploy, cx| { + this.deploy(deploy, cx); }) } - fn register(workspace: &mut Workspace) { - Self::register_inner(workspace); - } + pub fn new(cx: &mut ViewContext) -> Self { let query_editor = cx.new_view(|cx| Editor::single_line(cx)); cx.subscribe(&query_editor, Self::on_query_editor_event) @@ -1076,7 +1173,7 @@ mod tests { use super::*; use editor::{DisplayPoint, Editor}; - use gpui::{Context, EmptyView, Hsla, TestAppContext, VisualTestContext}; + use gpui::{Context, Hsla, TestAppContext, VisualTestContext}; use language::Buffer; use smol::stream::StreamExt as _; use unindent::Unindent as _; @@ -1109,7 +1206,7 @@ mod tests { .unindent(), ) }); - let (_, cx) = cx.add_window_view(|_| EmptyView {}); + let cx = cx.add_empty_window(); let editor = cx.new_view(|cx| Editor::for_buffer(buffer.clone(), None, cx)); let search_bar = cx.new_view(|cx| { @@ -1125,7 +1222,6 @@ mod tests { #[gpui::test] async fn test_search_simple(cx: &mut TestAppContext) { let (editor, search_bar, cx) = init_test(cx); - // todo! osiewicz: these tests asserted on background color as well, that should be brought back. let display_points_of = |background_highlights: Vec<(Range, Hsla)>| { background_highlights .into_iter() @@ -1390,7 +1486,6 @@ mod tests { }) .await .unwrap(); - // todo! osiewicz: these tests previously asserted on background color highlights; that should be introduced back. let display_points_of = |background_highlights: Vec<(Range, Hsla)>| { background_highlights .into_iter() @@ -1458,7 +1553,7 @@ mod tests { "Should pick a query with multiple results" ); let buffer = cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), buffer_text)); - let window = cx.add_window(|_| EmptyView {}); + let window = cx.add_window(|_| ()); let editor = window.build_view(cx, |cx| Editor::for_buffer(buffer.clone(), None, cx)); @@ -1645,7 +1740,6 @@ mod tests { #[gpui::test] async fn test_search_query_history(cx: &mut TestAppContext) { - //crate::project_search::tests::init_test(cx); init_globals(cx); let buffer_text = r#" A regular expression (shortened as regex or regexp;[1] also referred to as @@ -1655,7 +1749,7 @@ mod tests { "# .unindent(); let buffer = cx.new_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), buffer_text)); - let (_, cx) = cx.add_window_view(|_| EmptyView {}); + let cx = cx.add_empty_window(); let editor = cx.new_view(|cx| Editor::for_buffer(buffer.clone(), None, cx)); diff --git a/crates/search/src/history.rs b/crates/search/src/history.rs index 6b06c60293..5571313acb 100644 --- a/crates/search/src/history.rs +++ b/crates/search/src/history.rs @@ -85,7 +85,7 @@ mod tests { assert_eq!( search_history.current(), None, - "No current selection should be set fo the default search history" + "No current selection should be set for the default search history" ); search_history.add("rust".to_string()); diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index a370cca9f6..c1ebcfe0a6 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -7,15 +7,15 @@ use crate::{ use anyhow::{Context as _, Result}; use collections::HashMap; use editor::{ - items::active_match_index, scroll::autoscroll::Autoscroll, Anchor, Editor, EditorEvent, - MultiBuffer, SelectAll, MAX_TAB_TITLE_LEN, + actions::SelectAll, items::active_match_index, scroll::Autoscroll, Anchor, Editor, EditorEvent, + MultiBuffer, MAX_TAB_TITLE_LEN, }; use editor::{EditorElement, EditorStyle}; use gpui::{ - actions, div, AnyElement, AnyView, AppContext, Context as _, Element, EntityId, EventEmitter, - FocusHandle, FocusableView, FontStyle, FontWeight, Hsla, InteractiveElement, IntoElement, - KeyContext, Model, ModelContext, ParentElement, PromptLevel, Render, SharedString, Styled, - Subscription, Task, TextStyle, View, ViewContext, VisualContext, WeakModel, WeakView, + actions, div, Action, AnyElement, AnyView, AppContext, Context as _, Element, EntityId, + EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, Hsla, InteractiveElement, + IntoElement, KeyContext, Model, ModelContext, ParentElement, PromptLevel, Render, SharedString, + Styled, Subscription, Task, TextStyle, View, ViewContext, VisualContext, WeakModel, WeakView, WhiteSpace, WindowContext, }; use menu::Confirm; @@ -36,9 +36,10 @@ use std::{ time::{Duration, Instant}, }; use theme::ThemeSettings; +use workspace::{DeploySearch, NewSearch}; use ui::{ - h_stack, prelude::*, v_stack, Icon, IconButton, IconElement, Label, LabelCommon, LabelSize, + h_flex, prelude::*, v_flex, Icon, IconButton, IconName, Label, LabelCommon, LabelSize, Selectable, ToggleButton, Tooltip, }; use util::{paths::PathMatcher, ResultExt as _}; @@ -54,20 +55,68 @@ actions!( [SearchInNew, ToggleFocus, NextField, ToggleFilters] ); -#[derive(Default)] -struct ActiveSearches(HashMap, WeakView>); - #[derive(Default)] struct ActiveSettings(HashMap, ProjectSearchSettings>); pub fn init(cx: &mut AppContext) { - cx.set_global(ActiveSearches::default()); cx.set_global(ActiveSettings::default()); cx.observe_new_views(|workspace: &mut Workspace, _cx| { - workspace - .register_action(ProjectSearchView::new_search) - .register_action(ProjectSearchView::deploy_search) - .register_action(ProjectSearchBar::search_in_new); + register_workspace_action(workspace, move |search_bar, _: &ToggleFilters, cx| { + search_bar.toggle_filters(cx); + }); + register_workspace_action(workspace, move |search_bar, _: &ToggleCaseSensitive, cx| { + search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx); + }); + register_workspace_action(workspace, move |search_bar, _: &ToggleWholeWord, cx| { + search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx); + }); + register_workspace_action(workspace, move |search_bar, action: &ToggleReplace, cx| { + search_bar.toggle_replace(action, cx) + }); + register_workspace_action(workspace, move |search_bar, _: &ActivateRegexMode, cx| { + search_bar.activate_search_mode(SearchMode::Regex, cx) + }); + register_workspace_action(workspace, move |search_bar, _: &ActivateTextMode, cx| { + search_bar.activate_search_mode(SearchMode::Text, cx) + }); + register_workspace_action( + workspace, + move |search_bar, _: &ActivateSemanticMode, cx| { + search_bar.activate_search_mode(SearchMode::Semantic, cx) + }, + ); + register_workspace_action(workspace, move |search_bar, action: &CycleMode, cx| { + search_bar.cycle_mode(action, cx) + }); + register_workspace_action( + workspace, + move |search_bar, action: &SelectNextMatch, cx| { + search_bar.select_next_match(action, cx) + }, + ); + + // Only handle search_in_new if there is a search present + register_workspace_action_for_present_search(workspace, |workspace, action, cx| { + ProjectSearchView::search_in_new(workspace, action, cx) + }); + + // Both on present and dismissed search, we need to unconditionally handle those actions to focus from the editor. + workspace.register_action(move |workspace, action: &DeploySearch, cx| { + if workspace.has_active_modal(cx) { + cx.propagate(); + return; + } + ProjectSearchView::deploy_search(workspace, action, cx); + cx.notify(); + }); + workspace.register_action(move |workspace, action: &NewSearch, cx| { + if workspace.has_active_modal(cx) { + cx.propagate(); + return; + } + ProjectSearchView::new_search(workspace, action, cx); + cx.notify(); + }); }) .detach(); } @@ -282,6 +331,8 @@ impl EventEmitter for ProjectSearchView {} impl Render for ProjectSearchView { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + const PLEASE_AUTHENTICATE: &str = "API Key Missing: Please set 'OPENAI_API_KEY' in Environment Variables. If you authenticated using the Assistant Panel, please restart Zed to Authenticate."; + if self.has_matches() { div() .flex_1() @@ -303,40 +354,39 @@ impl Render for ProjectSearchView { let mut show_minor_text = true; let semantic_status = self.semantic_state.as_ref().and_then(|semantic| { let status = semantic.index_status; - match status { - SemanticIndexStatus::NotAuthenticated => { - major_text = Label::new("Not Authenticated"); - show_minor_text = false; - Some( - "API Key Missing: Please set 'OPENAI_API_KEY' in Environment Variables. If you authenticated using the Assistant Panel, please restart Zed to Authenticate.".to_string()) - } - SemanticIndexStatus::Indexed => Some("Indexing complete".to_string()), - SemanticIndexStatus::Indexing { - remaining_files, - rate_limit_expiry, - } => { - if remaining_files == 0 { - Some("Indexing...".to_string()) - } else { - if let Some(rate_limit_expiry) = rate_limit_expiry { - let remaining_seconds = - rate_limit_expiry.duration_since(Instant::now()); - if remaining_seconds > Duration::from_secs(0) { - Some(format!( - "Remaining files to index (rate limit resets in {}s): {}", - remaining_seconds.as_secs(), - remaining_files - )) - } else { - Some(format!("Remaining files to index: {}", remaining_files)) - } - } else { - Some(format!("Remaining files to index: {}", remaining_files)) - } - } - } - SemanticIndexStatus::NotIndexed => None, + match status { + SemanticIndexStatus::NotAuthenticated => { + major_text = Label::new("Not Authenticated"); + show_minor_text = false; + Some(PLEASE_AUTHENTICATE.to_string()) + } + SemanticIndexStatus::Indexed => Some("Indexing complete".to_string()), + SemanticIndexStatus::Indexing { + remaining_files, + rate_limit_expiry, + } => { + if remaining_files == 0 { + Some("Indexing...".to_string()) + } else { + if let Some(rate_limit_expiry) = rate_limit_expiry { + let remaining_seconds = + rate_limit_expiry.duration_since(Instant::now()); + if remaining_seconds > Duration::from_secs(0) { + Some(format!( + "Remaining files to index (rate limit resets in {}s): {}", + remaining_seconds.as_secs(), + remaining_files + )) + } else { + Some(format!("Remaining files to index: {}", remaining_files)) } + } else { + Some(format!("Remaining files to index: {}", remaining_files)) + } + } + } + SemanticIndexStatus::NotIndexed => None, + } }); let major_text = div().justify_center().max_w_96().child(major_text); @@ -360,19 +410,19 @@ impl Render for ProjectSearchView { .max_w_96() .child(Label::new(text).size(LabelSize::Small)) }); - v_stack() + v_flex() .flex_1() .size_full() .justify_center() .bg(cx.theme().colors().editor_background) .track_focus(&self.focus_handle) .child( - h_stack() + h_flex() .size_full() .justify_center() - .child(h_stack().flex_1()) - .child(v_stack().child(major_text).children(minor_text)) - .child(h_stack().flex_1()), + .child(h_flex().flex_1()) + .child(v_flex().child(major_text).children(minor_text)) + .child(h_flex().flex_1()), ) } } @@ -424,15 +474,16 @@ impl Item for ProjectSearchView { .current() .as_ref() .map(|query| { - let query_text = util::truncate_and_trailoff(query, MAX_TAB_TITLE_LEN); + let query = query.replace('\n', ""); + let query_text = util::truncate_and_trailoff(&query, MAX_TAB_TITLE_LEN); query_text.into() }); let tab_name = last_query .filter(|query| !query.is_empty()) .unwrap_or_else(|| "Project search".into()); - h_stack() + h_flex() .gap_2() - .child(IconElement::new(Icon::MagnifyingGlass).color(if selected { + .child(Icon::new(IconName::MagnifyingGlass).color(if selected { Color::Default } else { Color::Muted @@ -445,6 +496,10 @@ impl Item for ProjectSearchView { .into_any() } + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("project search") + } + fn for_each_project_item( &self, cx: &AppContext, @@ -942,28 +997,53 @@ impl ProjectSearchView { }); } - // Re-activate the most recently activated search or the most recent if it has been closed. + // Re-activate the most recently activated search in this pane or the most recent if it has been closed. // If no search exists in the workspace, create a new one. fn deploy_search( workspace: &mut Workspace, _: &workspace::DeploySearch, cx: &mut ViewContext, ) { - let active_search = cx - .global::() - .0 - .get(&workspace.project().downgrade()); - let existing = active_search - .and_then(|active_search| { - workspace - .items_of_type::(cx) - .filter(|search| &search.downgrade() == active_search) - .last() - }) - .or_else(|| workspace.item_of_type::(cx)); + let existing = workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()); + Self::existing_or_new_search(workspace, existing, cx) } + fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext) { + if let Some(search_view) = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + { + let new_query = search_view.update(cx, |search_view, cx| { + let new_query = search_view.build_search_query(cx); + if new_query.is_some() { + if let Some(old_query) = search_view.model.read(cx).active_query.clone() { + search_view.query_editor.update(cx, |editor, cx| { + editor.set_text(old_query.as_str(), cx); + }); + search_view.search_options = SearchOptions::from_query(&old_query); + } + } + new_query + }); + if let Some(new_query) = new_query { + let model = cx.new_model(|cx| { + let mut model = ProjectSearch::new(workspace.project().clone(), cx); + model.search(new_query, cx); + model + }); + workspace.add_item( + Box::new(cx.new_view(|cx| ProjectSearchView::new(model, cx, None))), + cx, + ); + } + } + } + // Add another search tab to the workspace. fn new_search( workspace: &mut Workspace, @@ -978,11 +1058,6 @@ impl ProjectSearchView { existing: Option>, cx: &mut ViewContext, ) { - // Clean up entries for dropped projects - cx.update_global(|state: &mut ActiveSearches, _cx| { - state.0.retain(|project, _| project.is_upgradable()) - }); - let query = workspace.active_item(cx).and_then(|item| { let editor = item.act_as::(cx)?; let query = editor.query_suggestion(cx); @@ -1015,8 +1090,6 @@ impl ProjectSearchView { view }; - workspace.add_item(Box::new(search.clone()), cx); - search.update(cx, |search, cx| { if let Some(query) = query { search.set_query(&query, cx); @@ -1273,17 +1346,11 @@ impl ProjectSearchView { } } -impl Default for ProjectSearchBar { - fn default() -> Self { - Self::new() - } -} - impl ProjectSearchBar { pub fn new() -> Self { Self { - active_project_search: Default::default(), - subscription: Default::default(), + active_project_search: None, + subscription: None, } } @@ -1314,42 +1381,11 @@ impl ProjectSearchBar { } } - fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext) { - if let Some(search_view) = workspace - .active_item(cx) - .and_then(|item| item.downcast::()) - { - let new_query = search_view.update(cx, |search_view, cx| { - let new_query = search_view.build_search_query(cx); - if new_query.is_some() { - if let Some(old_query) = search_view.model.read(cx).active_query.clone() { - search_view.query_editor.update(cx, |editor, cx| { - editor.set_text(old_query.as_str(), cx); - }); - search_view.search_options = SearchOptions::from_query(&old_query); - } - } - new_query - }); - if let Some(new_query) = new_query { - let model = cx.new_model(|cx| { - let mut model = ProjectSearch::new(workspace.project().clone(), cx); - model.search(new_query, cx); - model - }); - workspace.add_item( - Box::new(cx.new_view(|cx| ProjectSearchView::new(model, cx, None))), - cx, - ); - } - } - } - - fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext) { + fn tab(&mut self, _: &editor::actions::Tab, cx: &mut ViewContext) { self.cycle_field(Direction::Next, cx); } - fn tab_previous(&mut self, _: &editor::TabPrev, cx: &mut ViewContext) { + fn tab_previous(&mut self, _: &editor::actions::TabPrev, cx: &mut ViewContext) { self.cycle_field(Direction::Prev, cx); } @@ -1513,6 +1549,22 @@ impl ProjectSearchBar { } } + pub fn select_next_match(&mut self, _: &SelectNextMatch, cx: &mut ViewContext) { + if let Some(search) = self.active_project_search.as_ref() { + search.update(cx, |this, cx| { + this.select_match(Direction::Next, cx); + }) + } + } + + fn select_prev_match(&mut self, _: &SelectPrevMatch, cx: &mut ViewContext) { + if let Some(search) = self.active_project_search.as_ref() { + search.update(cx, |this, cx| { + this.select_match(Direction::Prev, cx); + }) + } + } + fn new_placeholder_text(&self, cx: &mut ViewContext) -> Option { let previous_query_keystrokes = cx .bindings_for_action(&PreviousHistoryQuery {}) @@ -1603,8 +1655,8 @@ impl Render for ProjectSearchBar { let search = search.read(cx); let semantic_is_available = SemanticIndex::enabled(cx); - let query_column = v_stack().child( - h_stack() + let query_column = v_flex().child( + h_flex() .min_w(rems(512. / 16.)) .px_2() .py_1() @@ -1616,12 +1668,12 @@ impl Render for ProjectSearchBar { .on_action(cx.listener(|this, action, cx| this.confirm(action, cx))) .on_action(cx.listener(|this, action, cx| this.previous_history_query(action, cx))) .on_action(cx.listener(|this, action, cx| this.next_history_query(action, cx))) - .child(IconElement::new(Icon::MagnifyingGlass)) + .child(Icon::new(IconName::MagnifyingGlass)) .child(self.render_text_input(&search.query_editor, cx)) .child( - h_stack() + h_flex() .child( - IconButton::new("project-search-filter-button", Icon::Filter) + IconButton::new("project-search-filter-button", IconName::Filter) .tooltip(|cx| { Tooltip::for_action("Toggle filters", &ToggleFilters, cx) }) @@ -1639,7 +1691,7 @@ impl Render for ProjectSearchBar { this.child( IconButton::new( "project-search-case-sensitive", - Icon::CaseSensitive, + IconName::CaseSensitive, ) .tooltip(|cx| { Tooltip::for_action( @@ -1659,7 +1711,7 @@ impl Render for ProjectSearchBar { )), ) .child( - IconButton::new("project-search-whole-word", Icon::WholeWord) + IconButton::new("project-search-whole-word", IconName::WholeWord) .tooltip(|cx| { Tooltip::for_action( "Toggle whole word", @@ -1676,11 +1728,11 @@ impl Render for ProjectSearchBar { ), ); - let mode_column = v_stack().items_start().justify_start().child( - h_stack() + let mode_column = v_flex().items_start().justify_start().child( + h_flex() .gap_2() .child( - h_stack() + h_flex() .child( ToggleButton::new("project-search-text-button", "Text") .style(ButtonStyle::Filled) @@ -1738,7 +1790,7 @@ impl Render for ProjectSearchBar { }), ) .child( - IconButton::new("project-search-toggle-replace", Icon::Replace) + IconButton::new("project-search-toggle-replace", IconName::Replace) .on_click(cx.listener(|this, _, cx| { this.toggle_replace(&ToggleReplace, cx); })) @@ -1746,7 +1798,7 @@ impl Render for ProjectSearchBar { ), ); let replace_column = if search.replace_enabled { - h_stack() + h_flex() .flex_1() .h_full() .gap_2() @@ -1755,16 +1807,16 @@ impl Render for ProjectSearchBar { .border_1() .border_color(cx.theme().colors().border) .rounded_lg() - .child(IconElement::new(Icon::Replace).size(ui::IconSize::Small)) + .child(Icon::new(IconName::Replace).size(ui::IconSize::Small)) .child(self.render_text_input(&search.replacement_editor, cx)) } else { // Fill out the space if we don't have a replacement editor. - h_stack().flex_1() + h_flex().flex_1() }; - let actions_column = h_stack() + let actions_column = h_flex() .when(search.replace_enabled, |this| { this.child( - IconButton::new("project-search-replace-next", Icon::ReplaceNext) + IconButton::new("project-search-replace-next", IconName::ReplaceNext) .on_click(cx.listener(|this, _, cx| { if let Some(search) = this.active_project_search.as_ref() { search.update(cx, |this, cx| { @@ -1775,7 +1827,7 @@ impl Render for ProjectSearchBar { .tooltip(|cx| Tooltip::for_action("Replace next match", &ReplaceNext, cx)), ) .child( - IconButton::new("project-search-replace-all", Icon::ReplaceAll) + IconButton::new("project-search-replace-all", IconName::ReplaceAll) .on_click(cx.listener(|this, _, cx| { if let Some(search) = this.active_project_search.as_ref() { search.update(cx, |this, cx| { @@ -1796,7 +1848,7 @@ impl Render for ProjectSearchBar { this }) .child( - IconButton::new("project-search-prev-match", Icon::ChevronLeft) + IconButton::new("project-search-prev-match", IconName::ChevronLeft) .disabled(search.active_match_index.is_none()) .on_click(cx.listener(|this, _, cx| { if let Some(search) = this.active_project_search.as_ref() { @@ -1810,7 +1862,7 @@ impl Render for ProjectSearchBar { }), ) .child( - IconButton::new("project-search-next-match", Icon::ChevronRight) + IconButton::new("project-search-next-match", IconName::ChevronRight) .disabled(search.active_match_index.is_none()) .on_click(cx.listener(|this, _, cx| { if let Some(search) = this.active_project_search.as_ref() { @@ -1822,7 +1874,7 @@ impl Render for ProjectSearchBar { .tooltip(|cx| Tooltip::for_action("Go to next match", &SelectNextMatch, cx)), ); - v_stack() + v_flex() .key_context(key_context) .flex_grow() .gap_2() @@ -1881,8 +1933,10 @@ impl Render for ProjectSearchBar { })) }) }) + .on_action(cx.listener(Self::select_next_match)) + .on_action(cx.listener(Self::select_prev_match)) .child( - h_stack() + h_flex() .justify_between() .gap_2() .child(query_column) @@ -1892,12 +1946,12 @@ impl Render for ProjectSearchBar { ) .when(search.filters_enabled, |this| { this.child( - h_stack() + h_flex() .flex_1() .gap_2() .justify_between() .child( - h_stack() + h_flex() .flex_1() .h_full() .px_2() @@ -1923,7 +1977,7 @@ impl Render for ProjectSearchBar { }), ) .child( - h_stack() + h_flex() .flex_1() .h_full() .px_2() @@ -1974,6 +2028,60 @@ impl ToolbarItemView for ProjectSearchBar { } } +fn register_workspace_action( + workspace: &mut Workspace, + callback: fn(&mut ProjectSearchBar, &A, &mut ViewContext), +) { + workspace.register_action(move |workspace, action: &A, cx| { + if workspace.has_active_modal(cx) { + cx.propagate(); + return; + } + + workspace.active_pane().update(cx, |pane, cx| { + pane.toolbar().update(cx, move |workspace, cx| { + if let Some(search_bar) = workspace.item_of_type::() { + search_bar.update(cx, move |search_bar, cx| { + if search_bar.active_project_search.is_some() { + callback(search_bar, action, cx); + cx.notify(); + } else { + cx.propagate(); + } + }); + } + }); + }) + }); +} + +fn register_workspace_action_for_present_search( + workspace: &mut Workspace, + callback: fn(&mut Workspace, &A, &mut ViewContext), +) { + workspace.register_action(move |workspace, action: &A, cx| { + if workspace.has_active_modal(cx) { + cx.propagate(); + return; + } + + let should_notify = workspace + .active_pane() + .read(cx) + .toolbar() + .read(cx) + .item_of_type::() + .map(|search_bar| search_bar.read(cx).active_project_search.is_some()) + .unwrap_or(false); + if should_notify { + callback(workspace, action, cx); + cx.notify(); + } else { + cx.propagate(); + } + }); +} + #[cfg(test)] pub mod tests { use super::*; @@ -1983,6 +2091,7 @@ pub mod tests { use semantic_index::semantic_index_settings::SemanticIndexSettings; use serde_json::json; use settings::{Settings, SettingsStore}; + use workspace::DeploySearch; #[gpui::test] async fn test_project_search(cx: &mut TestAppContext) { @@ -2589,7 +2698,7 @@ pub mod tests { ); assert!( search_view_2.query_editor.focus_handle(cx).is_focused(cx), - "Focus should be moved into query editor fo the new window" + "Focus should be moved into query editor of the new window" ); }); }).unwrap(); @@ -3110,11 +3219,161 @@ pub mod tests { .unwrap(); } + #[gpui::test] + async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) { + init_test(cx); + + // Setup 2 panes, both with a file open and one with a project search. + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/dir", + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + let worktree_id = project.update(cx, |this, cx| { + this.worktrees().next().unwrap().read(cx).id() + }); + let window = cx.add_window(|cx| Workspace::test_new(project, cx)); + let panes: Vec<_> = window + .update(cx, |this, _| this.panes().to_owned()) + .unwrap(); + assert_eq!(panes.len(), 1); + let first_pane = panes.get(0).cloned().unwrap(); + assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0); + window + .update(cx, |workspace, cx| { + workspace.open_path( + (worktree_id, "one.rs"), + Some(first_pane.downgrade()), + true, + cx, + ) + }) + .unwrap() + .await + .unwrap(); + assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1); + let second_pane = window + .update(cx, |workspace, cx| { + workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx) + }) + .unwrap() + .unwrap(); + assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1); + assert!(window + .update(cx, |_, cx| second_pane + .focus_handle(cx) + .contains_focused(cx)) + .unwrap()); + let search_bar = window.build_view(cx, |_| ProjectSearchBar::new()); + window + .update(cx, { + let search_bar = search_bar.clone(); + let pane = first_pane.clone(); + move |workspace, cx| { + assert_eq!(workspace.panes().len(), 2); + pane.update(cx, move |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx)) + }); + } + }) + .unwrap(); + + // Add a project search item to the second pane + window + .update(cx, { + let search_bar = search_bar.clone(); + let pane = second_pane.clone(); + move |workspace, cx| { + assert_eq!(workspace.panes().len(), 2); + pane.update(cx, move |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx)) + }); + + ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx) + } + }) + .unwrap(); + + cx.run_until_parked(); + assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2); + assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1); + + // Focus the first pane + window + .update(cx, |workspace, cx| { + assert_eq!(workspace.active_pane(), &second_pane); + second_pane.update(cx, |this, cx| { + assert_eq!(this.active_item_index(), 1); + this.activate_prev_item(false, cx); + assert_eq!(this.active_item_index(), 0); + }); + workspace.activate_pane_in_direction(workspace::SplitDirection::Left, cx); + }) + .unwrap(); + window + .update(cx, |workspace, cx| { + assert_eq!(workspace.active_pane(), &first_pane); + assert_eq!(first_pane.read(cx).items_len(), 1); + assert_eq!(second_pane.read(cx).items_len(), 2); + }) + .unwrap(); + + // Deploy a new search + cx.dispatch_action(window.into(), DeploySearch); + + // Both panes should now have a project search in them + window + .update(cx, |workspace, cx| { + assert_eq!(workspace.active_pane(), &first_pane); + first_pane.update(cx, |this, _| { + assert_eq!(this.active_item_index(), 1); + assert_eq!(this.items_len(), 2); + }); + second_pane.update(cx, |this, cx| { + assert!(!cx.focus_handle().contains_focused(cx)); + assert_eq!(this.items_len(), 2); + }); + }) + .unwrap(); + + // Focus the second pane's non-search item + window + .update(cx, |_workspace, cx| { + second_pane.update(cx, |pane, cx| pane.activate_next_item(true, cx)); + }) + .unwrap(); + + // Deploy a new search + cx.dispatch_action(window.into(), DeploySearch); + + // The project search view should now be focused in the second pane + // And the number of items should be unchanged. + window + .update(cx, |_workspace, cx| { + second_pane.update(cx, |pane, _cx| { + assert!(pane + .active_item() + .unwrap() + .downcast::() + .is_some()); + + assert_eq!(pane.items_len(), 2); + }); + }) + .unwrap(); + } + pub fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings = SettingsStore::test(cx); cx.set_global(settings); - cx.set_global(ActiveSearches::default()); + SemanticIndexSettings::register(cx); theme::init(theme::LoadThemes::JustBase, cx); diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index f0301a5bcc..748996c389 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -60,11 +60,11 @@ impl SearchOptions { } } - pub fn icon(&self) -> ui::Icon { + pub fn icon(&self) -> ui::IconName { match *self { - SearchOptions::WHOLE_WORD => ui::Icon::WholeWord, - SearchOptions::CASE_SENSITIVE => ui::Icon::CaseSensitive, - SearchOptions::INCLUDE_IGNORED => ui::Icon::FileGit, + SearchOptions::WHOLE_WORD => ui::IconName::WholeWord, + SearchOptions::CASE_SENSITIVE => ui::IconName::CaseSensitive, + SearchOptions::INCLUDE_IGNORED => ui::IconName::FileGit, _ => panic!("{:?} is not a named SearchOption", self), } } @@ -98,7 +98,7 @@ impl SearchOptions { IconButton::new(self.label(), self.icon()) .on_click(action) .style(ButtonStyle::Subtle) - .when(active, |button| button.style(ButtonStyle::Filled)) + .selected(active) .tooltip({ let action = self.to_toggle_action(); let label: SharedString = format!("Toggle {}", self.label()).into(); diff --git a/crates/search/src/search_bar.rs b/crates/search/src/search_bar.rs index 628be3112e..0594036c25 100644 --- a/crates/search/src/search_bar.rs +++ b/crates/search/src/search_bar.rs @@ -3,7 +3,7 @@ use ui::IconButton; use ui::{prelude::*, Tooltip}; pub(super) fn render_nav_button( - icon: ui::Icon, + icon: ui::IconName, active: bool, tooltip: &'static str, action: &'static dyn Action, diff --git a/crates/semantic_index/Cargo.toml b/crates/semantic_index/Cargo.toml index a437a596a5..41a3ab2d57 100644 --- a/crates/semantic_index/Cargo.toml +++ b/crates/semantic_index/Cargo.toml @@ -47,7 +47,7 @@ project = { path = "../project", features = ["test-support"] } rpc = { path = "../rpc", features = ["test-support"] } workspace = { path = "../workspace", features = ["test-support"] } settings = { path = "../settings", features = ["test-support"]} -rust-embed = { version = "8.0", features = ["include-exclude"] } +rust-embed.workspace = true client = { path = "../client" } node_runtime = { path = "../node_runtime"} diff --git a/crates/semantic_index/README.md b/crates/semantic_index/README.md index 85f83af121..75ccb41b84 100644 --- a/crates/semantic_index/README.md +++ b/crates/semantic_index/README.md @@ -10,7 +10,7 @@ nDCG@k: - "The relevance of result is represented by a score (also known as a 'grade') that is assigned to the search query. The scores of these results are then discounted based on their position in the search results -- did they get recommended first or last?" MRR@k: -- "Mean reciprocal rank quantifies the rank of the first relevant item found in teh recommendation list." +- "Mean reciprocal rank quantifies the rank of the first relevant item found in the recommendation list." MAP@k: - "Mean average precision averages the precision@k metric at each relevant item position in the recommendation list. diff --git a/crates/semantic_index/src/parsing.rs b/crates/semantic_index/src/parsing.rs index 427ac158c1..9f2db711ae 100644 --- a/crates/semantic_index/src/parsing.rs +++ b/crates/semantic_index/src/parsing.rs @@ -76,7 +76,7 @@ pub struct CodeContextRetriever { // Every match has an item, this represents the fundamental treesitter symbol and anchors the search // Every match has one or more 'name' captures. These indicate the display range of the item for deduplication. -// If there are preceeding comments, we track this with a context capture +// If there are preceding comments, we track this with a context capture // If there is a piece that should be collapsed in hierarchical queries, we capture it with a collapse capture // If there is a piece that should be kept inside a collapsed node, we capture it with a keep capture #[derive(Debug, Clone)] diff --git a/crates/semantic_index/src/semantic_index.rs b/crates/semantic_index/src/semantic_index.rs index 81c4fbbc3d..a556986f9b 100644 --- a/crates/semantic_index/src/semantic_index.rs +++ b/crates/semantic_index/src/semantic_index.rs @@ -275,11 +275,8 @@ pub struct SearchResult { impl SemanticIndex { pub fn global(cx: &mut AppContext) -> Option> { - if cx.has_global::>() { - Some(cx.global::>().clone()) - } else { - None - } + cx.try_global::>() + .map(|semantic_index| semantic_index.clone()) } pub fn authenticate(&mut self, cx: &mut AppContext) -> bool { @@ -1248,7 +1245,7 @@ impl SemanticIndex { impl Drop for JobHandle { fn drop(&mut self) { if let Some(inner) = Arc::get_mut(&mut self.tx) { - // This is the last instance of the JobHandle (regardless of it's origin - whether it was cloned or not) + // This is the last instance of the JobHandle (regardless of its origin - whether it was cloned or not) if let Some(tx) = inner.upgrade() { let mut tx = tx.lock(); *tx.borrow_mut() -= 1; diff --git a/crates/semantic_index/src/semantic_index_tests.rs b/crates/semantic_index/src/semantic_index_tests.rs index ced08f4cbc..9da92a15a8 100644 --- a/crates/semantic_index/src/semantic_index_tests.rs +++ b/crates/semantic_index/src/semantic_index_tests.rs @@ -110,7 +110,7 @@ async fn test_semantic_index(cx: &mut TestAppContext) { cx, ); - // Test Include Files Functonality + // Test Include Files Functionality let include_files = vec![PathMatcher::new("*.rs").unwrap()]; let exclude_files = vec![PathMatcher::new("*.rs").unwrap()]; let rust_only_search_results = semantic_index @@ -576,7 +576,7 @@ async fn test_code_context_retrieval_lua() { setmetatable(classdef, { __index = baseclass }) -- All class instances have a reference to the class object. classdef.class = classdef - --- Recursivly allocates the inheritance tree of the instance. + --- Recursively allocates the inheritance tree of the instance. -- @param mastertable The 'root' of the inheritance tree. -- @return Returns the instance with the allocated inheritance tree. function classdef.alloc(mastertable) @@ -607,7 +607,7 @@ async fn test_code_context_retrieval_lua() { setmetatable(classdef, { __index = baseclass }) -- All class instances have a reference to the class object. classdef.class = classdef - --- Recursivly allocates the inheritance tree of the instance. + --- Recursively allocates the inheritance tree of the instance. -- @param mastertable The 'root' of the inheritance tree. -- @return Returns the instance with the allocated inheritance tree. function classdef.alloc(mastertable) @@ -617,7 +617,7 @@ async fn test_code_context_retrieval_lua() { end"#.unindent(), 114), (r#" - --- Recursivly allocates the inheritance tree of the instance. + --- Recursively allocates the inheritance tree of the instance. -- @param mastertable The 'root' of the inheritance tree. -- @return Returns the instance with the allocated inheritance tree. function classdef.alloc(mastertable) @@ -626,7 +626,7 @@ async fn test_code_context_retrieval_lua() { -- Any functions this instance does not know of will 'look up' to the superclass definition. setmetatable(instance, { __index = classdef, __newindex = mastertable }) return instance - end"#.unindent(), 809), + end"#.unindent(), 810), ] ); } @@ -1677,8 +1677,6 @@ fn elixir_lang() -> Arc { #[gpui::test] fn test_subtract_ranges() { - // collapsed_ranges: Vec>, keep_ranges: Vec> - assert_eq!( subtract_ranges(&[0..5, 10..21], &[0..1, 4..5]), vec![1..4, 10..21] diff --git a/crates/sqlez/src/migrations.rs b/crates/sqlez/src/migrations.rs index c0d125d4df..f59b9dd40e 100644 --- a/crates/sqlez/src/migrations.rs +++ b/crates/sqlez/src/migrations.rs @@ -191,7 +191,7 @@ mod test { fn migrations_dont_rerun() { let connection = Connection::open_memory(Some("migrations_dont_rerun")); - // Create migration which clears a tabl + // Create migration which clears a table // Manually create the table for that migration with a row connection diff --git a/crates/story/src/story.rs b/crates/story/src/story.rs index 65bebfc423..f5448831cb 100644 --- a/crates/story/src/story.rs +++ b/crates/story/src/story.rs @@ -255,8 +255,8 @@ impl Story { .child(label.into()) } - /// Note: Not ui::v_stack() as the story crate doesn't depend on the ui crate. - pub fn v_stack() -> Div { + /// Note: Not `ui::v_flex` as the `story` crate doesn't depend on the `ui` crate. + pub fn v_flex() -> Div { div().flex().flex_col().gap_1() } } @@ -298,7 +298,7 @@ impl RenderOnce for StoryItem { .gap_4() .w_full() .child( - Story::v_stack() + Story::v_flex() .px_2() .w_1_2() .min_h_px() @@ -319,7 +319,7 @@ impl RenderOnce for StoryItem { }), ) .child( - Story::v_stack() + Story::v_flex() .px_2() .flex_none() .w_1_2() diff --git a/crates/storybook/Cargo.toml b/crates/storybook/Cargo.toml index 033b3fa8d9..fec2d42e59 100644 --- a/crates/storybook/Cargo.toml +++ b/crates/storybook/Cargo.toml @@ -14,6 +14,7 @@ anyhow.workspace = true backtrace-on-stack-overflow = "0.3.0" chrono = "0.4" clap = { version = "4.4", features = ["derive", "string"] } +collab_ui = { path = "../collab_ui", features = ["stories"] } strum = { version = "0.25.0", features = ["derive"] } dialoguer = { version = "0.11.0", features = ["fuzzy-select"] } editor = { path = "../editor" } @@ -34,6 +35,7 @@ menu = { path = "../menu" } ui = { path = "../ui", features = ["stories"] } util = { path = "../util" } picker = { path = "../picker" } +ctrlc = "3.4" [dev-dependencies] gpui = { path = "../gpui", features = ["test-support"] } diff --git a/crates/storybook/src/stories/auto_height_editor.rs b/crates/storybook/src/stories/auto_height_editor.rs index 6d83515562..7b3cee92c8 100644 --- a/crates/storybook/src/stories/auto_height_editor.rs +++ b/crates/storybook/src/stories/auto_height_editor.rs @@ -10,7 +10,11 @@ pub struct AutoHeightEditorStory { impl AutoHeightEditorStory { pub fn new(cx: &mut WindowContext) -> View { - cx.bind_keys([KeyBinding::new("enter", editor::Newline, Some("Editor"))]); + cx.bind_keys([KeyBinding::new( + "enter", + editor::actions::Newline, + Some("Editor"), + )]); cx.new_view(|cx| Self { editor: cx.new_view(|cx| { let mut editor = Editor::auto_height(3, cx); diff --git a/crates/storybook/src/stories/overflow_scroll.rs b/crates/storybook/src/stories/overflow_scroll.rs index 32ab940be5..ee2ea12c00 100644 --- a/crates/storybook/src/stories/overflow_scroll.rs +++ b/crates/storybook/src/stories/overflow_scroll.rs @@ -11,7 +11,7 @@ impl Render for OverflowScrollStory { .child(Story::title("Overflow Scroll")) .child(Story::label("`overflow_x_scroll`")) .child( - h_stack() + h_flex() .id("overflow_x_scroll") .gap_2() .overflow_x_scroll() @@ -24,7 +24,7 @@ impl Render for OverflowScrollStory { ) .child(Story::label("`overflow_y_scroll`")) .child( - v_stack() + v_flex() .id("overflow_y_scroll") .gap_2() .overflow_y_scroll() diff --git a/crates/storybook/src/stories/text.rs b/crates/storybook/src/stories/text.rs index 1c302cb48f..b7445ef95a 100644 --- a/crates/storybook/src/stories/text.rs +++ b/crates/storybook/src/stories/text.rs @@ -65,7 +65,7 @@ impl Render for TextStory { )) ) .usage(indoc! {r##" - // NOTE: When rendering text in a horizonal flex container, + // NOTE: When rendering text in a horizontal flex container, // Taffy will not pass width constraints down from the parent. // To fix this, render text in a parent with overflow: hidden @@ -117,7 +117,7 @@ impl Render for TextStory { // type Element = Div; // fn render(&mut self, cx: &mut gpui::ViewContext) -> Self::Element { -// v_stack() +// v_flex() // .bg(blue()) // .child( // div() @@ -149,7 +149,7 @@ impl Render for TextStory { // "Meanwhile, the lazy dog decided it was time for a change. ", // "He started daily workout routines, ate healthier and became the fastest dog in town.", // )))) -// // NOTE: When rendering text in a horizonal flex container, +// // NOTE: When rendering text in a horizontal flex container, // // Taffy will not pass width constraints down from the parent. // // To fix this, render text in a parent with overflow: hidden // .child(div().h_5()) diff --git a/crates/storybook/src/story_selector.rs b/crates/storybook/src/story_selector.rs index 27ddfe26ac..120e60d34a 100644 --- a/crates/storybook/src/story_selector.rs +++ b/crates/storybook/src/story_selector.rs @@ -16,6 +16,7 @@ pub enum ComponentStory { Avatar, Button, Checkbox, + CollabNotification, ContextMenu, Cursor, Disclosure, @@ -45,6 +46,9 @@ impl ComponentStory { Self::Avatar => cx.new_view(|_| ui::AvatarStory).into(), Self::Button => cx.new_view(|_| ui::ButtonStory).into(), Self::Checkbox => cx.new_view(|_| ui::CheckboxStory).into(), + Self::CollabNotification => cx + .new_view(|_| collab_ui::notifications::CollabNotificationStory) + .into(), Self::ContextMenu => cx.new_view(|_| ui::ContextMenuStory).into(), Self::Cursor => cx.new_view(|_| crate::stories::CursorStory).into(), Self::Disclosure => cx.new_view(|_| ui::DisclosureStory).into(), diff --git a/crates/storybook/src/storybook.rs b/crates/storybook/src/storybook.rs index 8d60e29a13..1c5ffb494b 100644 --- a/crates/storybook/src/storybook.rs +++ b/crates/storybook/src/storybook.rs @@ -21,11 +21,6 @@ use crate::assets::Assets; use crate::story_selector::{ComponentStory, StorySelector}; pub use indoc::indoc; -// gpui::actions! { -// storybook, -// [ToggleInspector] -// } - #[derive(Parser)] #[command(author, version, about, long_about = None)] struct Args { @@ -49,18 +44,23 @@ fn main() { let story_selector = args.story.clone().unwrap_or_else(|| { let stories = ComponentStory::iter().collect::>(); - let selection = FuzzySelect::new() + ctrlc::set_handler(move || {}).unwrap(); + + let result = FuzzySelect::new() .with_prompt("Choose a story to run:") .items(&stories) - .interact() - .unwrap(); + .interact(); + + let Ok(selection) = result else { + dialoguer::console::Term::stderr().show_cursor().unwrap(); + std::process::exit(0); + }; StorySelector::Component(stories[selection]) }); let theme_name = args.theme.unwrap_or("One Dark".to_string()); - let asset_source = Arc::new(Assets); - gpui::App::production(asset_source).run(move |cx| { + gpui::App::new().with_assets(Assets).run(move |cx| { load_embedded_fonts(cx).unwrap(); let mut store = SettingsStore::default(); diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index ce2a158e20..4dc253fd53 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -33,6 +33,7 @@ thiserror.workspace = true lazy_static.workspace = true serde.workspace = true serde_derive.workspace = true +serde_json.workspace = true [dev-dependencies] rand.workspace = true diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index fa8112bac7..0b87ed1d97 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -47,7 +47,7 @@ use std::{ os::unix::prelude::AsRawFd, path::PathBuf, sync::Arc, - time::{Duration, Instant}, + time::Duration, }; use thiserror::Error; @@ -385,8 +385,6 @@ impl TerminalBuilder { last_content: Default::default(), last_mouse: None, matches: Vec::new(), - last_synced: Instant::now(), - sync_task: None, selection_head: None, shell_fd: fd as u32, shell_pid, @@ -542,8 +540,6 @@ pub struct Terminal { last_mouse_position: Option>, pub matches: Vec>, pub last_content: TerminalContent, - last_synced: Instant, - sync_task: Option>, pub selection_head: Option, pub breadcrumb_text: String, shell_pid: u32, @@ -977,40 +973,15 @@ impl Terminal { self.input(paste_text); } - pub fn try_sync(&mut self, cx: &mut ModelContext) { + pub fn sync(&mut self, cx: &mut ModelContext) { let term = self.term.clone(); - - let mut terminal = if let Some(term) = term.try_lock_unfair() { - term - } else if self.last_synced.elapsed().as_secs_f32() > 0.25 { - term.lock_unfair() //It's been too long, force block - } else if let None = self.sync_task { - //Skip this frame - let delay = cx.background_executor().timer(Duration::from_millis(16)); - self.sync_task = Some(cx.spawn(|weak_handle, mut cx| async move { - delay.await; - if let Some(handle) = weak_handle.upgrade() { - handle - .update(&mut cx, |terminal, cx| { - terminal.sync_task.take(); - cx.notify(); - }) - .ok(); - } - })); - return; - } else { - //No lock and delayed rendering already scheduled, nothing to do - return; - }; - + let mut terminal = term.lock_unfair(); //Note that the ordering of events matters for event processing while let Some(e) = self.events.pop_front() { self.process_terminal_event(&e, &mut terminal, cx) } self.last_content = Self::make_content(&terminal, &self.last_content); - self.last_synced = Instant::now(); } fn make_content(term: &Term, last_content: &TerminalContent) -> TerminalContent { @@ -1402,9 +1373,9 @@ fn content_index_for_mouse(pos: Point, size: &TerminalSize) -> usize { clamped_row * size.columns() + clamped_col } -///Converts an 8 bit ANSI color to it's GPUI equivalent. -///Accepts usize for compatibility with the alacritty::Colors interface, -///Other than that use case, should only be called with values in the [0,255] range +/// Converts an 8 bit ANSI color to it's GPUI equivalent. +/// Accepts `usize` for compatibility with the `alacritty::Colors` interface, +/// Other than that use case, should only be called with values in the [0,255] range pub fn get_color_at_index(index: usize, theme: &Theme) -> Hsla { let colors = theme.colors(); diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index 14cff3b5a6..b9b79c9c6b 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -1,6 +1,12 @@ use gpui::{px, AbsoluteLength, AppContext, FontFeatures, Pixels}; -use schemars::JsonSchema; +use schemars::{ + gen::SchemaGenerator, + schema::{InstanceType, RootSchema, Schema, SchemaObject}, + JsonSchema, +}; use serde_derive::{Deserialize, Serialize}; +use serde_json::Value; +use settings::SettingsJsonSchemaParams; use std::{collections::HashMap, path::PathBuf}; #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -153,6 +159,39 @@ impl settings::Settings for TerminalSettings { ) -> anyhow::Result { Self::load_via_json_merge(default_value, user_values) } + fn json_schema( + generator: &mut SchemaGenerator, + _: &SettingsJsonSchemaParams, + cx: &AppContext, + ) -> RootSchema { + let mut root_schema = generator.root_schema_for::(); + let available_fonts = cx + .text_system() + .all_font_names() + .into_iter() + .map(Value::String) + .collect(); + let fonts_schema = SchemaObject { + instance_type: Some(InstanceType::String.into()), + enum_values: Some(available_fonts), + ..Default::default() + }; + root_schema + .definitions + .extend([("FontFamilies".into(), fonts_schema.into())]); + root_schema + .schema + .object + .as_mut() + .unwrap() + .properties + .extend([( + "font_family".to_owned(), + Schema::new_ref("#/definitions/FontFamilies".into()), + )]); + + root_schema + } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, Default)] diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index bcaf147af2..746a3716b8 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -6,7 +6,7 @@ use gpui::{ InteractiveElementState, Interactivity, IntoElement, LayoutId, Model, ModelContext, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, PlatformInputHandler, Point, ShapedLine, StatefulInteractiveElement, Styled, TextRun, TextStyle, TextSystem, UnderlineStyle, - WhiteSpace, WindowContext, + WeakView, WhiteSpace, WindowContext, }; use itertools::Itertools; use language::CursorShape; @@ -24,6 +24,7 @@ use terminal::{ }; use theme::{ActiveTheme, Theme, ThemeSettings}; use ui::Tooltip; +use workspace::Workspace; use std::mem; use std::{fmt::Debug, ops::RangeInclusive}; @@ -142,6 +143,7 @@ impl LayoutRect { ///We need to keep a reference to the view for mouse events, do we need it for any other terminal stuff, or can we move that to connection? pub struct TerminalElement { terminal: Model, + workspace: WeakView, focus: FocusHandle, focused: bool, cursor_visible: bool, @@ -160,6 +162,7 @@ impl StatefulInteractiveElement for TerminalElement {} impl TerminalElement { pub fn new( terminal: Model, + workspace: WeakView, focus: FocusHandle, focused: bool, cursor_visible: bool, @@ -167,6 +170,7 @@ impl TerminalElement { ) -> TerminalElement { TerminalElement { terminal, + workspace, focused, focus: focus.clone(), cursor_visible, @@ -246,8 +250,8 @@ impl TerminalElement { //Layout current cell text { - let cell_text = cell.c.to_string(); if !is_blank(&cell) { + let cell_text = cell.c.to_string(); let cell_style = TerminalElement::cell_style(&cell, fg, theme, text_style, hyperlink); @@ -424,7 +428,6 @@ impl TerminalElement { let line_height = font_pixels * line_height.to_pixels(rem_size); let font_id = cx.text_system().resolve_font(&text_style.font()); - // todo!(do we need to keep this unwrap?) let cell_width = text_system .advance(font_id, font_pixels, 'm') .unwrap() @@ -443,7 +446,7 @@ impl TerminalElement { let last_hovered_word = self.terminal.update(cx, |terminal, cx| { terminal.set_size(dimensions); - terminal.try_sync(cx); + terminal.sync(cx); if self.can_navigate_to_selected_word && terminal.can_navigate_to_selected_word() { terminal.last_content.last_hovered_word.clone() } else { @@ -524,7 +527,6 @@ impl TerminalElement { underline: Default::default(), }], ) - //todo!(do we need to keep this unwrap?) .unwrap() }; @@ -584,24 +586,6 @@ impl TerminalElement { } } - fn register_key_listeners(&self, cx: &mut WindowContext) { - cx.on_key_event({ - let this = self.terminal.clone(); - move |event: &ModifiersChangedEvent, phase, cx| { - if phase != DispatchPhase::Bubble { - return; - } - - let handled = - this.update(cx, |term, _| term.try_modifiers_change(&event.modifiers)); - - if handled { - cx.notify(); - } - } - }); - } - fn register_mouse_listeners( &mut self, origin: Point, @@ -664,21 +648,6 @@ impl TerminalElement { }, ), ); - self.interactivity.on_click({ - let terminal = terminal.clone(); - move |e, cx| { - if e.down.button == MouseButton::Right { - let mouse_mode = terminal.update(cx, |terminal, _cx| { - terminal.mouse_mode(e.down.modifiers.shift) - }); - - if !mouse_mode { - //todo!(context menu) - // view.deploy_context_menu(e.position, cx); - } - } - } - }); self.interactivity.on_scroll_wheel({ let terminal = terminal.clone(); move |e, cx| { @@ -779,57 +748,73 @@ impl Element for TerminalElement { .cursor .as_ref() .map(|cursor| cursor.bounding_rect(origin)), + workspace: self.workspace.clone(), }; self.register_mouse_listeners(origin, layout.mode, bounds, cx); - let mut interactivity = mem::take(&mut self.interactivity); - interactivity.paint(bounds, bounds.size, state, cx, |_, _, cx| { - cx.handle_input(&self.focus, terminal_input_handler); + self.interactivity + .paint(bounds, bounds.size, state, cx, |_, _, cx| { + cx.handle_input(&self.focus, terminal_input_handler); - self.register_key_listeners(cx); + cx.on_key_event({ + let this = self.terminal.clone(); + move |event: &ModifiersChangedEvent, phase, cx| { + if phase != DispatchPhase::Bubble { + return; + } - for rect in &layout.rects { - rect.paint(origin, &layout, cx); - } + let handled = + this.update(cx, |term, _| term.try_modifiers_change(&event.modifiers)); - cx.with_z_index(1, |cx| { - for (relative_highlighted_range, color) in layout.relative_highlighted_ranges.iter() - { - if let Some((start_y, highlighted_range_lines)) = - to_highlighted_range_lines(relative_highlighted_range, &layout, origin) - { - let hr = HighlightedRange { - start_y, //Need to change this - line_height: layout.dimensions.line_height, - lines: highlighted_range_lines, - color: color.clone(), - //Copied from editor. TODO: move to theme or something - corner_radius: 0.15 * layout.dimensions.line_height, - }; - hr.paint(bounds, cx); - } - } - }); - - cx.with_z_index(2, |cx| { - for cell in &layout.cells { - cell.paint(origin, &layout, bounds, cx); - } - }); - - if self.cursor_visible { - cx.with_z_index(3, |cx| { - if let Some(cursor) = &layout.cursor { - cursor.paint(origin, cx); + if handled { + cx.refresh(); + } } }); - } - if let Some(mut element) = layout.hyperlink_tooltip.take() { - element.draw(origin, bounds.size.map(AvailableSpace::Definite), cx) - } - }); + for rect in &layout.rects { + rect.paint(origin, &layout, cx); + } + + cx.with_z_index(1, |cx| { + for (relative_highlighted_range, color) in + layout.relative_highlighted_ranges.iter() + { + if let Some((start_y, highlighted_range_lines)) = + to_highlighted_range_lines(relative_highlighted_range, &layout, origin) + { + let hr = HighlightedRange { + start_y, //Need to change this + line_height: layout.dimensions.line_height, + lines: highlighted_range_lines, + color: color.clone(), + //Copied from editor. TODO: move to theme or something + corner_radius: 0.15 * layout.dimensions.line_height, + }; + hr.paint(bounds, cx); + } + } + }); + + cx.with_z_index(2, |cx| { + for cell in &layout.cells { + cell.paint(origin, &layout, bounds, cx); + } + }); + + if self.cursor_visible { + cx.with_z_index(3, |cx| { + if let Some(cursor) = &layout.cursor { + cursor.paint(origin, cx); + } + }); + } + + if let Some(mut element) = layout.hyperlink_tooltip.take() { + element.draw(origin, bounds.size.map(AvailableSpace::Definite), cx) + } + }); } } @@ -848,6 +833,7 @@ impl IntoElement for TerminalElement { struct TerminalInputHandler { cx: AsyncWindowContext, terminal: Model, + workspace: WeakView, cursor_bounds: Option>, } @@ -888,7 +874,14 @@ impl PlatformInputHandler for TerminalInputHandler { .update(|_, cx| { self.terminal.update(cx, |terminal, _| { terminal.input(text.into()); - }) + }); + + self.workspace + .update(cx, |this, cx| { + let telemetry = this.project().read(cx).client().telemetry().clone(); + telemetry.log_edit_event("terminal"); + }) + .ok(); }) .ok(); } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 9992953570..7a988851d8 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -11,15 +11,15 @@ use itertools::Itertools; use project::{Fs, ProjectEntryId}; use search::{buffer_search::DivRegistrar, BufferSearchBar}; use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore}; +use settings::Settings; use terminal::terminal_settings::{TerminalDockPosition, TerminalSettings}; -use ui::{h_stack, ButtonCommon, Clickable, IconButton, IconSize, Selectable, Tooltip}; +use ui::{h_flex, ButtonCommon, Clickable, IconButton, IconSize, Selectable, Tooltip}; use util::{ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, item::Item, pane, - ui::Icon, + ui::IconName, DraggedTab, Pane, Workspace, }; @@ -68,10 +68,10 @@ impl TerminalPanel { pane.display_nav_history_buttons(false); pane.set_render_tab_bar_buttons(cx, move |pane, cx| { let terminal_panel = terminal_panel.clone(); - h_stack() + h_flex() .gap_2() .child( - IconButton::new("plus", Icon::Plus) + IconButton::new("plus", IconName::Plus) .icon_size(IconSize::Small) .on_click(move |_, cx| { terminal_panel @@ -82,10 +82,10 @@ impl TerminalPanel { ) .child({ let zoomed = pane.is_zoomed(); - IconButton::new("toggle_zoom", Icon::Maximize) + IconButton::new("toggle_zoom", IconName::Maximize) .icon_size(IconSize::Small) .selected(zoomed) - .selected_icon(Icon::Minimize) + .selected_icon(IconName::Minimize) .on_click(cx.listener(|pane, _, cx| { pane.toggle_zoom(&workspace::ToggleZoom, cx); })) @@ -159,15 +159,6 @@ impl TerminalPanel { height: None, _subscriptions: subscriptions, }; - let mut old_dock_position = this.position(cx); - cx.observe_global::(move |this, cx| { - let new_dock_position = this.position(cx); - if new_dock_position != old_dock_position { - old_dock_position = new_dock_position; - cx.emit(PanelEvent::ChangePosition); - } - }) - .detach(); this } @@ -396,7 +387,7 @@ impl Render for TerminalPanel { }, cx, ); - BufferSearchBar::register_inner(&mut registrar); + BufferSearchBar::register(&mut registrar); registrar.into_div().size_full().child(self.pane.clone()) } } @@ -477,8 +468,8 @@ impl Panel for TerminalPanel { "TerminalPanel" } - fn icon(&self, _cx: &WindowContext) -> Option { - Some(Icon::Terminal) + fn icon(&self, _cx: &WindowContext) -> Option { + Some(IconName::Terminal) } fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> { diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index f4fb6105cb..fc5829ba00 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -2,7 +2,7 @@ mod persistence; pub mod terminal_element; pub mod terminal_panel; -use editor::{scroll::autoscroll::Autoscroll, Editor}; +use editor::{scroll::Autoscroll, Editor}; use gpui::{ div, impl_actions, overlay, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, KeyContext, KeyDownEvent, Keystroke, Model, MouseButton, MouseDownEvent, Pixels, @@ -20,7 +20,7 @@ use terminal::{ Clear, Copy, Event, MaybeNavigationTarget, Paste, ShowCharacterPalette, Terminal, }; use terminal_element::TerminalElement; -use ui::{h_stack, prelude::*, ContextMenu, Icon, IconElement, Label}; +use ui::{h_flex, prelude::*, ContextMenu, Icon, IconName, Label}; use util::{paths::PathLikeWithPosition, ResultExt}; use workspace::{ item::{BreadcrumbText, Item, ItemEvent}, @@ -73,6 +73,7 @@ pub fn init(cx: &mut AppContext) { ///A terminal view, maintains the PTY's file handles and communicates with the terminal pub struct TerminalView { terminal: Model, + workspace: WeakView, focus_handle: FocusHandle, has_new_content: bool, //Currently using iTerm bell, show bell emoji in tab until input is received @@ -135,6 +136,7 @@ impl TerminalView { workspace_id: WorkspaceId, cx: &mut ViewContext, ) -> Self { + let workspace_handle = workspace.clone(); cx.observe(&terminal, |_, _, cx| cx.notify()).detach(); cx.subscribe(&terminal, move |this, _, event, cx| match event { Event::Wakeup => { @@ -279,6 +281,7 @@ impl TerminalView { Self { terminal, + workspace: workspace_handle, has_new_content: true, has_bell: false, focus_handle: cx.focus_handle(), @@ -354,7 +357,7 @@ impl TerminalView { } } - fn select_all(&mut self, _: &editor::SelectAll, cx: &mut ViewContext) { + fn select_all(&mut self, _: &editor::actions::SelectAll, cx: &mut ViewContext) { self.terminal.update(cx, |term, _| term.select_all()); cx.notify(); } @@ -597,6 +600,9 @@ fn possible_open_targets( pub fn regex_search_for_query(query: &project::search::SearchQuery) -> Option { let query = query.as_str(); + if query == "." { + return None; + } let searcher = RegexSearch::new(&query); searcher.ok() } @@ -651,23 +657,26 @@ impl Render for TerminalView { .on_mouse_down( MouseButton::Right, cx.listener(|this, event: &MouseDownEvent, cx| { - this.deploy_context_menu(event.position, cx); - cx.notify(); + if !this.terminal.read(cx).mouse_mode(event.modifiers.shift) { + this.deploy_context_menu(event.position, cx); + cx.notify(); + } }), ) .child( // TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu div().size_full().child(TerminalElement::new( terminal_handle, + self.workspace.clone(), self.focus_handle.clone(), focused, self.should_show_cursor(focused, cx), self.can_navigate_to_selected_word, )), ) - .children(self.context_menu.as_ref().map(|(menu, positon, _)| { + .children(self.context_menu.as_ref().map(|(menu, position, _)| { overlay() - .position(*positon) + .position(*position) .anchor(gpui::AnchorCorner::TopLeft) .child(menu.clone()) })) @@ -688,9 +697,9 @@ impl Item for TerminalView { cx: &WindowContext, ) -> AnyElement { let title = self.terminal().read(cx).title(true); - h_stack() + h_flex() .gap_2() - .child(IconElement::new(Icon::Terminal)) + .child(Icon::new(IconName::Terminal)) .child(Label::new(title).color(if selected { Color::Default } else { @@ -699,6 +708,10 @@ impl Item for TerminalView { .into_any() } + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + fn clone_on_split( &self, _workspace_id: WorkspaceId, diff --git a/crates/text/src/anchor.rs b/crates/text/src/anchor.rs index 084be0e336..a65e3753d4 100644 --- a/crates/text/src/anchor.rs +++ b/crates/text/src/anchor.rs @@ -90,7 +90,7 @@ impl Anchor { content.summary_for_anchor(self) } - /// Returns true when the [Anchor] is located inside a visible fragment. + /// Returns true when the [`Anchor`] is located inside a visible fragment. pub fn is_valid(&self, buffer: &BufferSnapshot) -> bool { if *self == Anchor::MIN || *self == Anchor::MAX { true diff --git a/crates/text/src/selection.rs b/crates/text/src/selection.rs index 4f1f9a2922..480cb99d74 100644 --- a/crates/text/src/selection.rs +++ b/crates/text/src/selection.rs @@ -5,7 +5,7 @@ use std::ops::Range; #[derive(Copy, Clone, Debug, PartialEq)] pub enum SelectionGoal { None, - HorizontalPosition(f32), // todo!("Can we use pixels here without adding a runtime gpui dependency?") + HorizontalPosition(f32), HorizontalRange { start: f32, end: f32 }, WrappedHorizontalPosition((u32, f32)), } diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml index 1c30176b25..428bcaac10 100644 --- a/crates/theme/Cargo.toml +++ b/crates/theme/Cargo.toml @@ -34,6 +34,7 @@ story = { path = "../story", optional = true } toml.workspace = true uuid.workspace = true util = { path = "../util" } +color = {path = "../color"} itertools = { version = "0.11.0", optional = true } [dev-dependencies] diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index 8cbfc23fa3..fb88afb7da 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -8,6 +8,11 @@ pub(crate) fn neutral() -> ColorScaleSet { sand() } +// Note: We aren't currently making use of the default colors, as all of the +// themes have a value set for each color. +// +// We'll need to revisit these once we're ready to launch user themes, which may +// not specify a value for each color (and thus should fall back to the defaults). impl ThemeColors { pub fn light() -> Self { let system = SystemColors::default(); @@ -23,12 +28,12 @@ impl ThemeColors { surface_background: neutral().light().step_2(), background: neutral().light().step_1(), element_background: neutral().light().step_3(), - element_hover: neutral().light_alpha().step_4(), // todo!("pick the right colors") + element_hover: neutral().light_alpha().step_4(), element_active: neutral().light_alpha().step_5(), element_selected: neutral().light_alpha().step_5(), - element_disabled: neutral().light_alpha().step_3(), // todo!("pick the right colors") - drop_target_background: blue().light_alpha().step_2(), // todo!("pick the right colors") - ghost_element_background: system.transparent, // todo!("pick the right colors") + element_disabled: neutral().light_alpha().step_3(), + drop_target_background: blue().light_alpha().step_2(), + ghost_element_background: system.transparent, ghost_element_hover: neutral().light_alpha().step_3(), ghost_element_active: neutral().light_alpha().step_4(), ghost_element_selected: neutral().light_alpha().step_5(), @@ -59,7 +64,7 @@ impl ThemeColors { scrollbar_track_background: gpui::transparent_black(), scrollbar_track_border: neutral().light().step_5(), editor_foreground: neutral().light().step_12(), - editor_background: neutral().light().step_1(), // todo!(this was inserted by Mikayla) + editor_background: neutral().light().step_1(), editor_gutter_background: neutral().light().step_1(), editor_subheader_background: neutral().light().step_2(), editor_active_line_background: neutral().light_alpha().step_3(), @@ -106,17 +111,17 @@ impl ThemeColors { surface_background: neutral().dark().step_2(), background: neutral().dark().step_1(), element_background: neutral().dark().step_3(), - element_hover: neutral().dark_alpha().step_4(), // todo!("pick the right colors") + element_hover: neutral().dark_alpha().step_4(), element_active: neutral().dark_alpha().step_5(), - element_selected: neutral().dark_alpha().step_5(), // todo!("pick the right colors") - element_disabled: neutral().dark_alpha().step_3(), // todo!("pick the right colors") + element_selected: neutral().dark_alpha().step_5(), + element_disabled: neutral().dark_alpha().step_3(), drop_target_background: blue().dark_alpha().step_2(), ghost_element_background: system.transparent, - ghost_element_hover: neutral().dark_alpha().step_4(), // todo!("pick the right colors") - ghost_element_active: neutral().dark_alpha().step_5(), // todo!("pick the right colors") + ghost_element_hover: neutral().dark_alpha().step_4(), + ghost_element_active: neutral().dark_alpha().step_5(), ghost_element_selected: neutral().dark_alpha().step_5(), ghost_element_disabled: neutral().dark_alpha().step_3(), - text: neutral().dark().step_12(), // todo!("pick the right colors") + text: neutral().dark().step_12(), text_muted: neutral().dark().step_11(), text_placeholder: neutral().dark().step_10(), text_disabled: neutral().dark().step_9(), @@ -140,7 +145,7 @@ impl ThemeColors { scrollbar_thumb_hover_background: neutral().dark_alpha().step_4(), scrollbar_thumb_border: gpui::transparent_black(), scrollbar_track_background: gpui::transparent_black(), - scrollbar_track_border: neutral().dark().step_5(), // todo!(this was inserted by Mikayla) + scrollbar_track_border: neutral().dark().step_5(), editor_foreground: neutral().dark().step_12(), editor_background: neutral().dark().step_1(), editor_gutter_background: neutral().dark().step_1(), diff --git a/crates/theme/src/one_themes.rs b/crates/theme/src/one_themes.rs index d0bae590f6..fab3631d13 100644 --- a/crates/theme/src/one_themes.rs +++ b/crates/theme/src/one_themes.rs @@ -7,6 +7,10 @@ use crate::{ ThemeColors, ThemeFamily, ThemeStyles, }; +// Note: This theme family is not the one you see in Zed at the moment. +// This is a from-scratch rebuild that Nate started work on. We currently +// only use this in the tests, and the One family from the `themes/` directory +// is what gets loaded into Zed when running it. pub fn one_family() -> ThemeFamily { ThemeFamily { id: "one".to_string(), @@ -75,7 +79,7 @@ pub(crate) fn one_dark() -> Theme { tab_bar_background: bg, tab_inactive_background: bg, tab_active_background: editor, - search_match_background: bg, // todo!(this was inserted by Mikayla) + search_match_background: bg, editor_background: editor, editor_gutter_background: editor, diff --git a/crates/theme/src/scale.rs b/crates/theme/src/scale.rs index c1c2ff924c..1146090edc 100644 --- a/crates/theme/src/scale.rs +++ b/crates/theme/src/scale.rs @@ -2,7 +2,9 @@ use gpui::{AppContext, Hsla, SharedString}; use crate::{ActiveTheme, Appearance}; -/// A one-based step in a [`ColorScale`]. +/// A collection of colors that are used to style the UI. +/// +/// Each step has a semantic meaning, and is used to style different parts of the UI. #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] pub struct ColorScaleStep(usize); @@ -37,6 +39,10 @@ impl ColorScaleStep { ]; } +/// A scale of colors for a given [`ColorScaleSet`]. +/// +/// Each [`ColorScale`] contains exactly 12 colors. Refer to +/// [`ColorScaleStep`] for a reference of what each step is used for. pub struct ColorScale(Vec); impl FromIterator for ColorScale { @@ -229,6 +235,7 @@ impl IntoIterator for ColorScales { } } +/// Provides groups of [`ColorScale`]s for light and dark themes, as well as transparent versions of each scale. pub struct ColorScaleSet { name: SharedString, light: ColorScale, diff --git a/crates/theme/src/settings.rs b/crates/theme/src/settings.rs index 624b14fe33..bced187411 100644 --- a/crates/theme/src/settings.rs +++ b/crates/theme/src/settings.rs @@ -1,7 +1,9 @@ use crate::one_themes::one_dark; use crate::{Theme, ThemeRegistry}; use anyhow::Result; -use gpui::{px, AppContext, Font, FontFeatures, FontStyle, FontWeight, Pixels}; +use gpui::{ + px, AppContext, Font, FontFeatures, FontStyle, FontWeight, Pixels, Subscription, ViewContext, +}; use schemars::{ gen::SchemaGenerator, schema::{InstanceType, Schema, SchemaObject}, @@ -27,7 +29,7 @@ pub struct ThemeSettings { } #[derive(Default)] -pub struct AdjustedBufferFontSize(Pixels); +pub(crate) struct AdjustedBufferFontSize(Pixels); #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct ThemeSettingsContent { @@ -80,6 +82,13 @@ impl ThemeSettings { } } +pub fn observe_buffer_font_size_adjustment( + cx: &mut ViewContext, + f: impl 'static + Fn(&mut V, &mut ViewContext), +) -> Subscription { + cx.observe_global::(f) +} + pub fn adjusted_font_size(size: Pixels, cx: &mut AppContext) -> Pixels { if let Some(AdjustedBufferFontSize(adjusted_size)) = cx.try_global::() { let buffer_font_size = ThemeSettings::get_global(cx).buffer_font_size; @@ -194,9 +203,21 @@ impl settings::Settings for ThemeSettings { ..Default::default() }; - root_schema - .definitions - .extend([("ThemeName".into(), theme_name_schema.into())]); + let available_fonts = cx + .text_system() + .all_font_names() + .into_iter() + .map(Value::String) + .collect(); + let fonts_schema = SchemaObject { + instance_type: Some(InstanceType::String.into()), + enum_values: Some(available_fonts), + ..Default::default() + }; + root_schema.definitions.extend([ + ("ThemeName".into(), theme_name_schema.into()), + ("FontFamilies".into(), fonts_schema.into()), + ]); root_schema .schema @@ -204,10 +225,20 @@ impl settings::Settings for ThemeSettings { .as_mut() .unwrap() .properties - .extend([( - "theme".to_owned(), - Schema::new_ref("#/definitions/ThemeName".into()), - )]); + .extend([ + ( + "theme".to_owned(), + Schema::new_ref("#/definitions/ThemeName".into()), + ), + ( + "buffer_font_family".to_owned(), + Schema::new_ref("#/definitions/FontFamilies".into()), + ), + ( + "ui_font_family".to_owned(), + Schema::new_ref("#/definitions/FontFamilies".into()), + ), + ]); root_schema } diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index 5f22958665..51f3949897 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -2,11 +2,12 @@ use gpui::Hsla; use refineable::Refineable; use std::sync::Arc; -use crate::{PlayerColors, StatusColors, SyntaxTheme, SystemColors}; +use crate::{PlayerColors, StatusColors, StatusColorsRefinement, SyntaxTheme, SystemColors}; #[derive(Refineable, Clone, Debug)] #[refineable(Debug, serde::Deserialize)] pub struct ThemeColors { + /// Border color. Used for most borders, is usually a high contrast color. pub border: Hsla, /// Border color. Used for deemphasized borders, like a visual divider between two sections pub border_variant: Hsla, @@ -219,7 +220,10 @@ pub struct ThemeStyles { #[refineable] pub colors: ThemeColors, + + #[refineable] pub status: StatusColors, + pub player: PlayerColors, pub syntax: Arc, } diff --git a/crates/theme/src/styles/players.rs b/crates/theme/src/styles/players.rs index d4d27e7123..508b091b8b 100644 --- a/crates/theme/src/styles/players.rs +++ b/crates/theme/src/styles/players.rs @@ -22,7 +22,7 @@ pub struct PlayerColors(pub Vec); impl Default for PlayerColors { /// Don't use this! /// We have to have a default to be `[refineable::Refinable]`. - /// todo!("Find a way to not need this for Refinable") + /// TODO "Find a way to not need this for Refinable" fn default() -> Self { Self::dark() } @@ -122,12 +122,10 @@ impl PlayerColors { impl PlayerColors { pub fn local(&self) -> PlayerColor { - // todo!("use a valid color"); *self.0.first().unwrap() } pub fn absent(&self) -> PlayerColor { - // todo!("use a valid color"); *self.0.last().unwrap() } diff --git a/crates/theme/src/styles/status.rs b/crates/theme/src/styles/status.rs index 0ce166deb5..854b876ac2 100644 --- a/crates/theme/src/styles/status.rs +++ b/crates/theme/src/styles/status.rs @@ -78,15 +78,6 @@ pub struct StatusColors { pub warning_border: Hsla, } -impl Default for StatusColors { - /// Don't use this! - /// We have to have a default to be `[refineable::Refinable]`. - /// todo!("Find a way to not need this for Refinable") - fn default() -> Self { - Self::dark() - } -} - pub struct DiagnosticColors { pub error: Hsla, pub warning: Hsla, diff --git a/crates/theme/src/styles/syntax.rs b/crates/theme/src/styles/syntax.rs index 0f35bf60a7..d6189f73e3 100644 --- a/crates/theme/src/styles/syntax.rs +++ b/crates/theme/src/styles/syntax.rs @@ -127,7 +127,7 @@ impl SyntaxTheme { } } - // TOOD: Get this working with `#[cfg(test)]`. Why isn't it? + // TODO: Get this working with `#[cfg(test)]`. Why isn't it? pub fn new_test(colors: impl IntoIterator) -> Self { SyntaxTheme { highlights: colors diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 93253b95e7..92a5877f51 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -1,3 +1,11 @@ +//! # Theme +//! +//! This crate provides the theme system for Zed. +//! +//! ## Overview +//! +//! A theme is a collection of colors used to build a consistent appearance for UI components across the application. + mod default_colors; mod default_theme; mod one_themes; @@ -12,7 +20,7 @@ mod user_theme; use std::sync::Arc; -use ::settings::Settings; +use ::settings::{Settings, SettingsStore}; pub use default_colors::*; pub use default_theme::*; pub use registry::*; @@ -54,13 +62,21 @@ pub enum LoadThemes { pub fn init(themes_to_load: LoadThemes, cx: &mut AppContext) { cx.set_global(ThemeRegistry::default()); - match themes_to_load { LoadThemes::JustBase => (), LoadThemes::All => cx.global_mut::().load_user_themes(), } - ThemeSettings::register(cx); + + let mut prev_buffer_font_size = ThemeSettings::get_global(cx).buffer_font_size; + cx.observe_global::(move |cx| { + let buffer_font_size = ThemeSettings::get_global(cx).buffer_font_size; + if buffer_font_size != prev_buffer_font_size { + prev_buffer_font_size = buffer_font_size; + reset_font_size(cx); + } + }) + .detach(); } pub trait ActiveTheme { diff --git a/crates/theme/src/themes/andromeda.rs b/crates/theme/src/themes/andromeda.rs index effdfb85f9..45dc660945 100644 --- a/crates/theme/src/themes/andromeda.rs +++ b/crates/theme/src/themes/andromeda.rs @@ -69,7 +69,7 @@ pub fn andromeda() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x21242bff).into()), editor_line_number: Some(rgba(0xf7f7f859).into()), editor_active_line_number: Some(rgba(0xf7f7f8ff).into()), - editor_invisible: Some(rgba(0xaca8aeff).into()), + editor_invisible: Some(rgba(0x64646dff).into()), editor_wrap_guide: Some(rgba(0xf7f7f80d).into()), editor_active_wrap_guide: Some(rgba(0xf7f7f81a).into()), editor_document_highlight_read_background: Some(rgba(0x11a7931a).into()), diff --git a/crates/theme/src/themes/atelier.rs b/crates/theme/src/themes/atelier.rs index 6848676e00..e0682b217e 100644 --- a/crates/theme/src/themes/atelier.rs +++ b/crates/theme/src/themes/atelier.rs @@ -70,7 +70,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x221f26ff).into()), editor_line_number: Some(rgba(0xefecf459).into()), editor_active_line_number: Some(rgba(0xefecf4ff).into()), - editor_invisible: Some(rgba(0x898591ff).into()), + editor_invisible: Some(rgba(0x726c7aff).into()), editor_wrap_guide: Some(rgba(0xefecf40d).into()), editor_active_wrap_guide: Some(rgba(0xefecf41a).into()), editor_document_highlight_read_background: Some(rgba(0x576dda1a).into()), @@ -535,7 +535,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xe6e3ebff).into()), editor_line_number: Some(rgba(0x19171c59).into()), editor_active_line_number: Some(rgba(0x19171cff).into()), - editor_invisible: Some(rgba(0x5a5462ff).into()), + editor_invisible: Some(rgba(0x726c7aff).into()), editor_wrap_guide: Some(rgba(0x19171c0d).into()), editor_active_wrap_guide: Some(rgba(0x19171c1a).into()), editor_document_highlight_read_background: Some(rgba(0x586dda1a).into()), @@ -1000,7 +1000,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x262622ff).into()), editor_line_number: Some(rgba(0xfefbec59).into()), editor_active_line_number: Some(rgba(0xfefbecff).into()), - editor_invisible: Some(rgba(0xa4a08bff).into()), + editor_invisible: Some(rgba(0x8b8874ff).into()), editor_wrap_guide: Some(rgba(0xfefbec0d).into()), editor_active_wrap_guide: Some(rgba(0xfefbec1a).into()), editor_document_highlight_read_background: Some(rgba(0x6684e01a).into()), @@ -1465,7 +1465,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xeeebd7ff).into()), editor_line_number: Some(rgba(0x20201d59).into()), editor_active_line_number: Some(rgba(0x20201dff).into()), - editor_invisible: Some(rgba(0x706d5fff).into()), + editor_invisible: Some(rgba(0x8b8874ff).into()), editor_wrap_guide: Some(rgba(0x20201d0d).into()), editor_active_wrap_guide: Some(rgba(0x20201d1a).into()), editor_document_highlight_read_background: Some(rgba(0x6784e01a).into()), @@ -1930,7 +1930,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x2c2b23ff).into()), editor_line_number: Some(rgba(0xf4f3ec59).into()), editor_active_line_number: Some(rgba(0xf4f3ecff).into()), - editor_invisible: Some(rgba(0x91907fff).into()), + editor_invisible: Some(rgba(0x7a7867ff).into()), editor_wrap_guide: Some(rgba(0xf4f3ec0d).into()), editor_active_wrap_guide: Some(rgba(0xf4f3ec1a).into()), editor_document_highlight_read_background: Some(rgba(0x37a1661a).into()), @@ -2395,7 +2395,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xebeae3ff).into()), editor_line_number: Some(rgba(0x22221b59).into()), editor_active_line_number: Some(rgba(0x22221bff).into()), - editor_invisible: Some(rgba(0x61604fff).into()), + editor_invisible: Some(rgba(0x7a7867ff).into()), editor_wrap_guide: Some(rgba(0x22221b0d).into()), editor_active_wrap_guide: Some(rgba(0x22221b1a).into()), editor_document_highlight_read_background: Some(rgba(0x38a1661a).into()), @@ -2860,7 +2860,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x27211eff).into()), editor_line_number: Some(rgba(0xf1efee59).into()), editor_active_line_number: Some(rgba(0xf1efeeff).into()), - editor_invisible: Some(rgba(0xa79f9dff).into()), + editor_invisible: Some(rgba(0x89817eff).into()), editor_wrap_guide: Some(rgba(0xf1efee0d).into()), editor_active_wrap_guide: Some(rgba(0xf1efee1a).into()), editor_document_highlight_read_background: Some(rgba(0x417ee61a).into()), @@ -3325,7 +3325,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xe9e6e4ff).into()), editor_line_number: Some(rgba(0x1b191859).into()), editor_active_line_number: Some(rgba(0x1b1918ff).into()), - editor_invisible: Some(rgba(0x6a6360ff).into()), + editor_invisible: Some(rgba(0x89817eff).into()), editor_wrap_guide: Some(rgba(0x1b19180d).into()), editor_active_wrap_guide: Some(rgba(0x1b19181a).into()), editor_document_highlight_read_background: Some(rgba(0x417ee61a).into()), @@ -3790,7 +3790,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x252025ff).into()), editor_line_number: Some(rgba(0xf7f3f759).into()), editor_active_line_number: Some(rgba(0xf7f3f7ff).into()), - editor_invisible: Some(rgba(0xa99aa9ff).into()), + editor_invisible: Some(rgba(0x8b7c8bff).into()), editor_wrap_guide: Some(rgba(0xf7f3f70d).into()), editor_active_wrap_guide: Some(rgba(0xf7f3f71a).into()), editor_document_highlight_read_background: Some(rgba(0x526aeb1a).into()), @@ -4255,7 +4255,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xe1d6e1ff).into()), editor_line_number: Some(rgba(0x1b181b59).into()), editor_active_line_number: Some(rgba(0x1b181bff).into()), - editor_invisible: Some(rgba(0x6b5e6bff).into()), + editor_invisible: Some(rgba(0x8b7c8bff).into()), editor_wrap_guide: Some(rgba(0x1b181b0d).into()), editor_active_wrap_guide: Some(rgba(0x1b181b1a).into()), editor_document_highlight_read_background: Some(rgba(0x526aeb1a).into()), @@ -4720,7 +4720,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x1c2529ff).into()), editor_line_number: Some(rgba(0xebf8ff59).into()), editor_active_line_number: Some(rgba(0xebf8ffff).into()), - editor_invisible: Some(rgba(0x7ca0b3ff).into()), + editor_invisible: Some(rgba(0x66889aff).into()), editor_wrap_guide: Some(rgba(0xebf8ff0d).into()), editor_active_wrap_guide: Some(rgba(0xebf8ff1a).into()), editor_document_highlight_read_background: Some(rgba(0x277fad1a).into()), @@ -5185,7 +5185,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xcdeaf9ff).into()), editor_line_number: Some(rgba(0x161b1d59).into()), editor_active_line_number: Some(rgba(0x161b1dff).into()), - editor_invisible: Some(rgba(0x526f7dff).into()), + editor_invisible: Some(rgba(0x66889aff).into()), editor_wrap_guide: Some(rgba(0x161b1d0d).into()), editor_active_wrap_guide: Some(rgba(0x161b1d1a).into()), editor_document_highlight_read_background: Some(rgba(0x277fad1a).into()), @@ -5650,7 +5650,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x252020ff).into()), editor_line_number: Some(rgba(0xf4ecec59).into()), editor_active_line_number: Some(rgba(0xf4ececff).into()), - editor_invisible: Some(rgba(0x898383ff).into()), + editor_invisible: Some(rgba(0x726a6aff).into()), editor_wrap_guide: Some(rgba(0xf4ecec0d).into()), editor_active_wrap_guide: Some(rgba(0xf4ecec1a).into()), editor_document_highlight_read_background: Some(rgba(0x7272ca1a).into()), @@ -6115,7 +6115,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xebe3e3ff).into()), editor_line_number: Some(rgba(0x1b181859).into()), editor_active_line_number: Some(rgba(0x1b1818ff).into()), - editor_invisible: Some(rgba(0x5a5252ff).into()), + editor_invisible: Some(rgba(0x726a6aff).into()), editor_wrap_guide: Some(rgba(0x1b18180d).into()), editor_active_wrap_guide: Some(rgba(0x1b18181a).into()), editor_document_highlight_read_background: Some(rgba(0x7372ca1a).into()), @@ -6580,7 +6580,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x1f2621ff).into()), editor_line_number: Some(rgba(0xecf4ee59).into()), editor_active_line_number: Some(rgba(0xecf4eeff).into()), - editor_invisible: Some(rgba(0x859188ff).into()), + editor_invisible: Some(rgba(0x6c7a71ff).into()), editor_wrap_guide: Some(rgba(0xecf4ee0d).into()), editor_active_wrap_guide: Some(rgba(0xecf4ee1a).into()), editor_document_highlight_read_background: Some(rgba(0x478c901a).into()), @@ -7045,7 +7045,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xe3ebe6ff).into()), editor_line_number: Some(rgba(0x171c1959).into()), editor_active_line_number: Some(rgba(0x171c19ff).into()), - editor_invisible: Some(rgba(0x546259ff).into()), + editor_invisible: Some(rgba(0x6c7a71ff).into()), editor_wrap_guide: Some(rgba(0x171c190d).into()), editor_active_wrap_guide: Some(rgba(0x171c191a).into()), editor_document_highlight_read_background: Some(rgba(0x488c901a).into()), @@ -7510,7 +7510,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x1f231fff).into()), editor_line_number: Some(rgba(0xf4fbf459).into()), editor_active_line_number: Some(rgba(0xf4fbf4ff).into()), - editor_invisible: Some(rgba(0x8ba48bff).into()), + editor_invisible: Some(rgba(0x748b74ff).into()), editor_wrap_guide: Some(rgba(0xf4fbf40d).into()), editor_active_wrap_guide: Some(rgba(0xf4fbf41a).into()), editor_document_highlight_read_background: Some(rgba(0x3e62f41a).into()), @@ -7975,7 +7975,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xdaeedaff).into()), editor_line_number: Some(rgba(0x13151359).into()), editor_active_line_number: Some(rgba(0x131513ff).into()), - editor_invisible: Some(rgba(0x5f705fff).into()), + editor_invisible: Some(rgba(0x748b74ff).into()), editor_wrap_guide: Some(rgba(0x1315130d).into()), editor_active_wrap_guide: Some(rgba(0x1315131a).into()), editor_document_highlight_read_background: Some(rgba(0x3f62f41a).into()), @@ -8440,7 +8440,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x262f51ff).into()), editor_line_number: Some(rgba(0xf5f7ff59).into()), editor_active_line_number: Some(rgba(0xf5f7ffff).into()), - editor_invisible: Some(rgba(0x959bb2ff).into()), + editor_invisible: Some(rgba(0x7a819cff).into()), editor_wrap_guide: Some(rgba(0xf5f7ff0d).into()), editor_active_wrap_guide: Some(rgba(0xf5f7ff1a).into()), editor_document_highlight_read_background: Some(rgba(0x3e8fd01a).into()), @@ -8905,7 +8905,7 @@ pub fn atelier() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xe5e8f5ff).into()), editor_line_number: Some(rgba(0x20274659).into()), editor_active_line_number: Some(rgba(0x202746ff).into()), - editor_invisible: Some(rgba(0x606889ff).into()), + editor_invisible: Some(rgba(0x7a819cff).into()), editor_wrap_guide: Some(rgba(0x2027460d).into()), editor_active_wrap_guide: Some(rgba(0x2027461a).into()), editor_document_highlight_read_background: Some(rgba(0x3f8fd01a).into()), diff --git a/crates/theme/src/themes/ayu.rs b/crates/theme/src/themes/ayu.rs index a0402825c1..9c9030b2f2 100644 --- a/crates/theme/src/themes/ayu.rs +++ b/crates/theme/src/themes/ayu.rs @@ -70,7 +70,7 @@ pub fn ayu() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x1f2127ff).into()), editor_line_number: Some(rgba(0xbfbdb659).into()), editor_active_line_number: Some(rgba(0xbfbdb6ff).into()), - editor_invisible: Some(rgba(0x8a8986ff).into()), + editor_invisible: Some(rgba(0x666767ff).into()), editor_wrap_guide: Some(rgba(0xbfbdb60d).into()), editor_active_wrap_guide: Some(rgba(0xbfbdb61a).into()), editor_document_highlight_read_background: Some(rgba(0x5ac2fe1a).into()), @@ -514,7 +514,7 @@ pub fn ayu() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xececedff).into()), editor_line_number: Some(rgba(0x5c616659).into()), editor_active_line_number: Some(rgba(0x5c6166ff).into()), - editor_invisible: Some(rgba(0x8c8f93ff).into()), + editor_invisible: Some(rgba(0xacafb1ff).into()), editor_wrap_guide: Some(rgba(0x5c61660d).into()), editor_active_wrap_guide: Some(rgba(0x5c61661a).into()), editor_document_highlight_read_background: Some(rgba(0x3b9ee51a).into()), @@ -958,7 +958,7 @@ pub fn ayu() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x353944ff).into()), editor_line_number: Some(rgba(0xcccac259).into()), editor_active_line_number: Some(rgba(0xcccac2ff).into()), - editor_invisible: Some(rgba(0x9a9a98ff).into()), + editor_invisible: Some(rgba(0x787a7cff).into()), editor_wrap_guide: Some(rgba(0xcccac20d).into()), editor_active_wrap_guide: Some(rgba(0xcccac21a).into()), editor_document_highlight_read_background: Some(rgba(0x73cffe1a).into()), diff --git a/crates/theme/src/themes/gruvbox.rs b/crates/theme/src/themes/gruvbox.rs index 5a319aa37a..34ccefee11 100644 --- a/crates/theme/src/themes/gruvbox.rs +++ b/crates/theme/src/themes/gruvbox.rs @@ -70,7 +70,7 @@ pub fn gruvbox() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x3a3735ff).into()), editor_line_number: Some(rgba(0xfbf1c759).into()), editor_active_line_number: Some(rgba(0xfbf1c7ff).into()), - editor_invisible: Some(rgba(0xc5b597ff).into()), + editor_invisible: Some(rgba(0x928474ff).into()), editor_wrap_guide: Some(rgba(0xfbf1c70d).into()), editor_active_wrap_guide: Some(rgba(0xfbf1c71a).into()), editor_document_highlight_read_background: Some(rgba(0x83a5981a).into()), @@ -521,7 +521,7 @@ pub fn gruvbox() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x393634ff).into()), editor_line_number: Some(rgba(0xfbf1c759).into()), editor_active_line_number: Some(rgba(0xfbf1c7ff).into()), - editor_invisible: Some(rgba(0xc5b597ff).into()), + editor_invisible: Some(rgba(0x928474ff).into()), editor_wrap_guide: Some(rgba(0xfbf1c70d).into()), editor_active_wrap_guide: Some(rgba(0xfbf1c71a).into()), editor_document_highlight_read_background: Some(rgba(0x83a5981a).into()), @@ -972,7 +972,7 @@ pub fn gruvbox() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x3b3735ff).into()), editor_line_number: Some(rgba(0xfbf1c759).into()), editor_active_line_number: Some(rgba(0xfbf1c7ff).into()), - editor_invisible: Some(rgba(0xc5b597ff).into()), + editor_invisible: Some(rgba(0x928474ff).into()), editor_wrap_guide: Some(rgba(0xfbf1c70d).into()), editor_active_wrap_guide: Some(rgba(0xfbf1c71a).into()), editor_document_highlight_read_background: Some(rgba(0x83a5981a).into()), @@ -1423,7 +1423,7 @@ pub fn gruvbox() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xecddb4ff).into()), editor_line_number: Some(rgba(0x28282859).into()), editor_active_line_number: Some(rgba(0x282828ff).into()), - editor_invisible: Some(rgba(0x5f5650ff).into()), + editor_invisible: Some(rgba(0x928474ff).into()), editor_wrap_guide: Some(rgba(0x2828280d).into()), editor_active_wrap_guide: Some(rgba(0x2828281a).into()), editor_document_highlight_read_background: Some(rgba(0x0b66781a).into()), @@ -1874,7 +1874,7 @@ pub fn gruvbox() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xecddb5ff).into()), editor_line_number: Some(rgba(0x28282859).into()), editor_active_line_number: Some(rgba(0x282828ff).into()), - editor_invisible: Some(rgba(0x5f5650ff).into()), + editor_invisible: Some(rgba(0x928474ff).into()), editor_wrap_guide: Some(rgba(0x2828280d).into()), editor_active_wrap_guide: Some(rgba(0x2828281a).into()), editor_document_highlight_read_background: Some(rgba(0x0b66781a).into()), @@ -2325,7 +2325,7 @@ pub fn gruvbox() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xecdcb3ff).into()), editor_line_number: Some(rgba(0x28282859).into()), editor_active_line_number: Some(rgba(0x282828ff).into()), - editor_invisible: Some(rgba(0x5f5650ff).into()), + editor_invisible: Some(rgba(0x928474ff).into()), editor_wrap_guide: Some(rgba(0x2828280d).into()), editor_active_wrap_guide: Some(rgba(0x2828281a).into()), editor_document_highlight_read_background: Some(rgba(0x0b66781a).into()), diff --git a/crates/theme/src/themes/one.rs b/crates/theme/src/themes/one.rs index 3c8eb1085f..5928939f7a 100644 --- a/crates/theme/src/themes/one.rs +++ b/crates/theme/src/themes/one.rs @@ -70,7 +70,7 @@ pub fn one() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x2f343eff).into()), editor_line_number: Some(rgba(0xc8ccd459).into()), editor_active_line_number: Some(rgba(0xc8ccd4ff).into()), - editor_invisible: Some(rgba(0x838994ff).into()), + editor_invisible: Some(rgba(0x555a63ff).into()), editor_wrap_guide: Some(rgba(0xc8ccd40d).into()), editor_active_wrap_guide: Some(rgba(0xc8ccd41a).into()), editor_document_highlight_read_background: Some(rgba(0x74ade81a).into()), @@ -521,7 +521,7 @@ pub fn one() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xebebecff).into()), editor_line_number: Some(rgba(0x383a4159).into()), editor_active_line_number: Some(rgba(0x383a41ff).into()), - editor_invisible: Some(rgba(0x7f8188ff).into()), + editor_invisible: Some(rgba(0xa3a3a4ff).into()), editor_wrap_guide: Some(rgba(0x383a410d).into()), editor_active_wrap_guide: Some(rgba(0x383a411a).into()), editor_document_highlight_read_background: Some(rgba(0x5c79e21a).into()), diff --git a/crates/theme/src/themes/rose_pine.rs b/crates/theme/src/themes/rose_pine.rs index fe3bddb2d0..f654e4d995 100644 --- a/crates/theme/src/themes/rose_pine.rs +++ b/crates/theme/src/themes/rose_pine.rs @@ -70,7 +70,7 @@ pub fn rose_pine() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x1d1b2aff).into()), editor_line_number: Some(rgba(0xe0def459).into()), editor_active_line_number: Some(rgba(0xe0def4ff).into()), - editor_invisible: Some(rgba(0x75718eff).into()), + editor_invisible: Some(rgba(0x28253cff).into()), editor_wrap_guide: Some(rgba(0xe0def40d).into()), editor_active_wrap_guide: Some(rgba(0xe0def41a).into()), editor_document_highlight_read_background: Some(rgba(0x9cced71a).into()), @@ -528,7 +528,7 @@ pub fn rose_pine() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xfef9f2ff).into()), editor_line_number: Some(rgba(0x57527959).into()), editor_active_line_number: Some(rgba(0x575279ff).into()), - editor_invisible: Some(rgba(0x706c8cff).into()), + editor_invisible: Some(rgba(0x9691a4ff).into()), editor_wrap_guide: Some(rgba(0x5752790d).into()), editor_active_wrap_guide: Some(rgba(0x5752791a).into()), editor_document_highlight_read_background: Some(rgba(0x57949f1a).into()), @@ -986,7 +986,7 @@ pub fn rose_pine() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x28253cff).into()), editor_line_number: Some(rgba(0xe0def459).into()), editor_active_line_number: Some(rgba(0xe0def4ff).into()), - editor_invisible: Some(rgba(0x85819eff).into()), + editor_invisible: Some(rgba(0x595571ff).into()), editor_wrap_guide: Some(rgba(0xe0def40d).into()), editor_active_wrap_guide: Some(rgba(0xe0def41a).into()), editor_document_highlight_read_background: Some(rgba(0x9cced71a).into()), diff --git a/crates/theme/src/themes/sandcastle.rs b/crates/theme/src/themes/sandcastle.rs index bace9e936f..ccf4906101 100644 --- a/crates/theme/src/themes/sandcastle.rs +++ b/crates/theme/src/themes/sandcastle.rs @@ -69,7 +69,7 @@ pub fn sandcastle() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x2b3039ff).into()), editor_line_number: Some(rgba(0xfdf4c159).into()), editor_active_line_number: Some(rgba(0xfdf4c1ff).into()), - editor_invisible: Some(rgba(0xa69782ff).into()), + editor_invisible: Some(rgba(0x7c6f64ff).into()), editor_wrap_guide: Some(rgba(0xfdf4c10d).into()), editor_active_wrap_guide: Some(rgba(0xfdf4c11a).into()), editor_document_highlight_read_background: Some(rgba(0x528b8b1a).into()), diff --git a/crates/theme/src/themes/solarized.rs b/crates/theme/src/themes/solarized.rs index 8d4d7e1aa1..b903d64539 100644 --- a/crates/theme/src/themes/solarized.rs +++ b/crates/theme/src/themes/solarized.rs @@ -70,7 +70,7 @@ pub fn solarized() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x04313cff).into()), editor_line_number: Some(rgba(0xfdf6e359).into()), editor_active_line_number: Some(rgba(0xfdf6e3ff).into()), - editor_invisible: Some(rgba(0x93a1a1ff).into()), + editor_invisible: Some(rgba(0x6d8288ff).into()), editor_wrap_guide: Some(rgba(0xfdf6e30d).into()), editor_active_wrap_guide: Some(rgba(0xfdf6e31a).into()), editor_document_highlight_read_background: Some(rgba(0x288bd11a).into()), @@ -514,7 +514,7 @@ pub fn solarized() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0xf3eddaff).into()), editor_line_number: Some(rgba(0x002b3659).into()), editor_active_line_number: Some(rgba(0x002b36ff).into()), - editor_invisible: Some(rgba(0x34555eff).into()), + editor_invisible: Some(rgba(0x6d8288ff).into()), editor_wrap_guide: Some(rgba(0x002b360d).into()), editor_active_wrap_guide: Some(rgba(0x002b361a).into()), editor_document_highlight_read_background: Some(rgba(0x298bd11a).into()), diff --git a/crates/theme/src/themes/summercamp.rs b/crates/theme/src/themes/summercamp.rs index 2ee5f12394..0a580e6893 100644 --- a/crates/theme/src/themes/summercamp.rs +++ b/crates/theme/src/themes/summercamp.rs @@ -69,7 +69,7 @@ pub fn summercamp() -> UserThemeFamily { editor_highlighted_line_background: Some(rgba(0x231f16ff).into()), editor_line_number: Some(rgba(0xf8f5de59).into()), editor_active_line_number: Some(rgba(0xf8f5deff).into()), - editor_invisible: Some(rgba(0x736e55ff).into()), + editor_invisible: Some(rgba(0x494433ff).into()), editor_wrap_guide: Some(rgba(0xf8f5de0d).into()), editor_active_wrap_guide: Some(rgba(0xf8f5de1a).into()), editor_document_highlight_read_background: Some(rgba(0x499bef1a).into()), diff --git a/crates/theme/theme.md b/crates/theme/theme.md new file mode 100644 index 0000000000..f9a7a58178 --- /dev/null +++ b/crates/theme/theme.md @@ -0,0 +1,15 @@ + # Theme + + This crate provides the theme system for Zed. + + ## Overview + + A theme is a collection of colors used to build a consistent appearance for UI components across the application. + To produce a theme in Zed, + + A theme is made of of two parts: A [ThemeFamily] and one or more [Theme]s. + +// + A [ThemeFamily] contains metadata like theme name, author, and theme-specific [ColorScales] as well as a series of themes. + + - [ThemeColors] - A set of colors that are used to style the UI. Refer to the [ThemeColors] documentation for more information. diff --git a/crates/theme_importer/src/main.rs b/crates/theme_importer/src/main.rs index ff20d36a5d..0861b7efd8 100644 --- a/crates/theme_importer/src/main.rs +++ b/crates/theme_importer/src/main.rs @@ -188,7 +188,7 @@ fn main() -> Result<()> { let zed1_themes_path = PathBuf::from_str("assets/themes")?; - let zed1_theme_familes = [ + let zed1_theme_families = [ "Andromeda", "Atelier", "Ayu", @@ -207,7 +207,7 @@ fn main() -> Result<()> { ); let mut zed1_themes_by_family: IndexMap> = IndexMap::from_iter( - zed1_theme_familes + zed1_theme_families .into_iter() .map(|family| (family.to_string(), Vec::new())), ); diff --git a/crates/theme_importer/src/zed1/converter.rs b/crates/theme_importer/src/zed1/converter.rs index 9f40c3695f..2f640c799f 100644 --- a/crates/theme_importer/src/zed1/converter.rs +++ b/crates/theme_importer/src/zed1/converter.rs @@ -240,7 +240,7 @@ impl Zed1ThemeConverter { editor_highlighted_line_background: convert(editor.highlighted_line_background), editor_line_number: convert(editor.line_number), editor_active_line_number: convert(editor.line_number_active), - editor_invisible: convert(highest.variant.default.foreground), // TODO: Is this light enough? + editor_invisible: convert(editor.whitespace), editor_wrap_guide: convert(editor.wrap_guide), editor_active_wrap_guide: convert(editor.active_wrap_guide), editor_document_highlight_read_background: convert( diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 2bb8c6648c..df66c746de 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -10,7 +10,7 @@ use picker::{Picker, PickerDelegate}; use settings::{update_settings_file, SettingsStore}; use std::sync::Arc; use theme::{Theme, ThemeMeta, ThemeRegistry, ThemeSettings}; -use ui::{prelude::*, v_stack, ListItem, ListItemSpacing}; +use ui::{prelude::*, v_flex, ListItem, ListItemSpacing}; use util::ResultExt; use workspace::{ui::HighlightedLabel, ModalView, Workspace}; @@ -70,7 +70,7 @@ impl FocusableView for ThemeSelector { impl Render for ThemeSelector { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - v_stack().w(rems(34.)).child(self.picker.clone()) + v_flex().w(rems(34.)).child(self.picker.clone()) } } diff --git a/crates/ui/docs/hello-world.md b/crates/ui/docs/hello-world.md index 280763e27b..12ee4d7aba 100644 --- a/crates/ui/docs/hello-world.md +++ b/crates/ui/docs/hello-world.md @@ -74,7 +74,7 @@ As you start using the Tailwind-style conventions you will be surprised how quic **Why `50.0/360.0` in `hsla()`?** -gpui [gpui::Hsla] use `0.0-1.0` for all it's values, but it is common for tools to use `0-360` for hue. +gpui [gpui::Hsla] use `0.0-1.0` for all its values, but it is common for tools to use `0-360` for hue. This may change in the future, but this is a little trick that let's you use familiar looking values. diff --git a/crates/ui/src/clickable.rs b/crates/ui/src/clickable.rs index 44f40b4cd4..462d2c6099 100644 --- a/crates/ui/src/clickable.rs +++ b/crates/ui/src/clickable.rs @@ -1,6 +1,6 @@ use gpui::{ClickEvent, WindowContext}; -/// A trait for elements that can be clicked. +/// A trait for elements that can be clicked. Enables the use of the `on_click` method. pub trait Clickable { /// Sets the click handler that will fire whenever the element is clicked. fn on_click(self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self; diff --git a/crates/ui/src/components/avatar.rs b/crates/ui/src/components/avatar.rs index b1dba69520..6c2d88916e 100644 --- a/crates/ui/src/components/avatar.rs +++ b/crates/ui/src/components/avatar.rs @@ -1,91 +1,7 @@ -use crate::prelude::*; -use gpui::{img, Hsla, ImageSource, Img, IntoElement, Styled}; +mod avatar; +mod avatar_audio_status_indicator; +mod avatar_availability_indicator; -#[derive(Debug, Default, PartialEq, Clone)] -pub enum Shape { - #[default] - Circle, - RoundedRectangle, -} - -#[derive(IntoElement)] -pub struct Avatar { - image: Img, - border_color: Option, - is_available: Option, -} - -impl RenderOnce for Avatar { - fn render(mut self, cx: &mut WindowContext) -> impl IntoElement { - if self.image.style().corner_radii.top_left.is_none() { - self = self.shape(Shape::Circle); - } - - let size = cx.rem_size(); - - div() - .size(size + px(2.)) - .map(|mut div| { - div.style().corner_radii = self.image.style().corner_radii.clone(); - div - }) - .when_some(self.border_color, |this, color| { - this.border().border_color(color) - }) - .child( - self.image - .size(size) - .bg(cx.theme().colors().ghost_element_background), - ) - .children(self.is_available.map(|is_free| { - // HACK: non-integer sizes result in oval indicators. - let indicator_size = (size * 0.4).round(); - - div() - .absolute() - .z_index(1) - .bg(if is_free { - cx.theme().status().created - } else { - cx.theme().status().deleted - }) - .size(indicator_size) - .rounded(indicator_size) - .bottom_0() - .right_0() - })) - } -} - -impl Avatar { - pub fn new(src: impl Into) -> Self { - Avatar { - image: img(src), - is_available: None, - border_color: None, - } - } - - pub fn shape(mut self, shape: Shape) -> Self { - self.image = match shape { - Shape::Circle => self.image.rounded_full(), - Shape::RoundedRectangle => self.image.rounded_md(), - }; - self - } - - pub fn grayscale(mut self, grayscale: bool) -> Self { - self.image = self.image.grayscale(grayscale); - self - } - - pub fn border_color(mut self, color: impl Into) -> Self { - self.border_color = Some(color.into()); - self - } - - pub fn availability_indicator(mut self, is_available: impl Into>) -> Self { - self.is_available = is_available.into(); - self - } -} +pub use avatar::*; +pub use avatar_audio_status_indicator::*; +pub use avatar_availability_indicator::*; diff --git a/crates/ui/src/components/avatar/avatar.rs b/crates/ui/src/components/avatar/avatar.rs new file mode 100644 index 0000000000..932cc9e243 --- /dev/null +++ b/crates/ui/src/components/avatar/avatar.rs @@ -0,0 +1,130 @@ +use crate::prelude::*; + +use gpui::{img, AnyElement, Hsla, ImageSource, Img, IntoElement, Styled}; + +/// The shape of an [`Avatar`]. +#[derive(Debug, Default, PartialEq, Clone)] +pub enum AvatarShape { + /// The avatar is shown in a circle. + #[default] + Circle, + /// The avatar is shown in a rectangle with rounded corners. + RoundedRectangle, +} + +/// An element that renders a user avatar with customizable appearance options. +/// +/// # Examples +/// +/// ``` +/// use ui::{Avatar, AvatarShape}; +/// +/// Avatar::new("path/to/image.png") +/// .shape(AvatarShape::Circle) +/// .grayscale(true) +/// .border_color(gpui::red()); +/// ``` +#[derive(IntoElement)] +pub struct Avatar { + image: Img, + size: Option, + border_color: Option, + indicator: Option, +} + +impl Avatar { + pub fn new(src: impl Into) -> Self { + Avatar { + image: img(src), + size: None, + border_color: None, + indicator: None, + } + } + + /// Sets the shape of the avatar image. + /// + /// This method allows the shape of the avatar to be specified using a [`Shape`]. + /// It modifies the corner radius of the image to match the specified shape. + /// + /// # Examples + /// + /// ``` + /// use ui::{Avatar, AvatarShape}; + /// + /// Avatar::new("path/to/image.png").shape(AvatarShape::Circle); + /// ``` + pub fn shape(mut self, shape: AvatarShape) -> Self { + self.image = match shape { + AvatarShape::Circle => self.image.rounded_full(), + AvatarShape::RoundedRectangle => self.image.rounded_md(), + }; + self + } + + /// Applies a grayscale filter to the avatar image. + /// + /// # Examples + /// + /// ``` + /// use ui::{Avatar, AvatarShape}; + /// + /// let avatar = Avatar::new("path/to/image.png").grayscale(true); + /// ``` + pub fn grayscale(mut self, grayscale: bool) -> Self { + self.image = self.image.grayscale(grayscale); + self + } + + pub fn border_color(mut self, color: impl Into) -> Self { + self.border_color = Some(color.into()); + self + } + + /// Size overrides the avatar size. By default they are 1rem. + pub fn size(mut self, size: impl Into>) -> Self { + self.size = size.into(); + self + } + + pub fn indicator(mut self, indicator: impl Into>) -> Self { + self.indicator = indicator.into().map(IntoElement::into_any_element); + self + } +} + +impl RenderOnce for Avatar { + fn render(mut self, cx: &mut WindowContext) -> impl IntoElement { + if self.image.style().corner_radii.top_left.is_none() { + self = self.shape(AvatarShape::Circle); + } + + let border_width = if self.border_color.is_some() { + px(2.) + } else { + px(0.) + }; + + let image_size = self.size.unwrap_or_else(|| cx.rem_size()); + let container_size = image_size + border_width * 2.; + + div() + .size(container_size) + .map(|mut div| { + div.style().corner_radii = self.image.style().corner_radii.clone(); + div + }) + .when_some(self.border_color, |this, color| { + this.border_width(border_width).border_color(color) + }) + .child( + self.image + .size(image_size) + .bg(cx.theme().colors().ghost_element_background), + ) + .children( + self.indicator + .map(|indicator| div().z_index(1).child(indicator)), + ) + } +} diff --git a/crates/ui/src/components/avatar/avatar_audio_status_indicator.rs b/crates/ui/src/components/avatar/avatar_audio_status_indicator.rs new file mode 100644 index 0000000000..943a8d4826 --- /dev/null +++ b/crates/ui/src/components/avatar/avatar_audio_status_indicator.rs @@ -0,0 +1,65 @@ +use gpui::AnyView; + +use crate::prelude::*; + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +pub enum AudioStatus { + Muted, + Deafened, +} + +#[derive(IntoElement)] +pub struct AvatarAudioStatusIndicator { + audio_status: AudioStatus, + tooltip: Option AnyView>>, +} + +impl AvatarAudioStatusIndicator { + pub fn new(audio_status: AudioStatus) -> Self { + Self { + audio_status, + tooltip: None, + } + } + + pub fn tooltip(mut self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self { + self.tooltip = Some(Box::new(tooltip)); + self + } +} + +impl RenderOnce for AvatarAudioStatusIndicator { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let icon_size = IconSize::Indicator; + + let width_in_px = icon_size.rems() * cx.rem_size(); + let padding_x = px(4.); + + div() + .absolute() + .bottom(rems(-3. / 16.)) + .right(rems(-6. / 16.)) + .w(width_in_px + padding_x) + .h(icon_size.rems()) + .child( + h_flex() + .id("muted-indicator") + .justify_center() + .px(padding_x) + .py(px(2.)) + .bg(cx.theme().status().error_background) + .rounded_md() + .child( + Icon::new(match self.audio_status { + AudioStatus::Muted => IconName::MicMute, + AudioStatus::Deafened => IconName::AudioOff, + }) + .size(icon_size) + .color(Color::Error), + ) + .when_some(self.tooltip, |this, tooltip| { + this.tooltip(move |cx| tooltip(cx)) + }), + ) + } +} diff --git a/crates/ui/src/components/avatar/avatar_availability_indicator.rs b/crates/ui/src/components/avatar/avatar_availability_indicator.rs new file mode 100644 index 0000000000..3a033cd395 --- /dev/null +++ b/crates/ui/src/components/avatar/avatar_availability_indicator.rs @@ -0,0 +1,48 @@ +use crate::prelude::*; + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +pub enum Availability { + Free, + Busy, +} + +#[derive(IntoElement)] +pub struct AvatarAvailabilityIndicator { + availability: Availability, + avatar_size: Option, +} + +impl AvatarAvailabilityIndicator { + pub fn new(availability: Availability) -> Self { + Self { + availability, + avatar_size: None, + } + } + + /// Sets the size of the [`Avatar`] this indicator appears on. + pub fn avatar_size(mut self, size: impl Into>) -> Self { + self.avatar_size = size.into(); + self + } +} + +impl RenderOnce for AvatarAvailabilityIndicator { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let avatar_size = self.avatar_size.unwrap_or_else(|| cx.rem_size()); + + // HACK: non-integer sizes result in oval indicators. + let indicator_size = (avatar_size * 0.4).round(); + + div() + .absolute() + .bottom_0() + .right_0() + .size(indicator_size) + .rounded(indicator_size) + .bg(match self.availability { + Availability::Free => cx.theme().status().created, + Availability::Busy => cx.theme().status().deleted, + }) + } +} diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index 1e60aae03b..3ca6d28672 100644 --- a/crates/ui/src/components/button/button.rs +++ b/crates/ui/src/components/button/button.rs @@ -2,11 +2,78 @@ use gpui::{AnyView, DefiniteLength}; use crate::{prelude::*, IconPosition, KeyBinding}; use crate::{ - ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, IconSize, Label, LineHeightStyle, + ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize, Label, LineHeightStyle, }; use super::button_icon::ButtonIcon; +/// An element that creates a button with a label and an optional icon. +/// +/// Common buttons: +/// - Label, Icon + Label: [`Button`] (this component) +/// - Icon only: [`IconButton`] +/// - Custom: [`ButtonLike`] +/// +/// To create a more complex button than what the [`Button`] or [`IconButton`] components provide, use +/// [`ButtonLike`] directly. +/// +/// # Examples +/// +/// **A button with a label**, is typically used in scenarios such as a form, where the button's label +/// indicates what action will be performed when the button is clicked. +/// +/// ``` +/// use ui::prelude::*; +/// +/// Button::new("button_id", "Click me!") +/// .on_click(|event, cx| { +/// // Handle click event +/// }); +/// ``` +/// +/// **A toggleable button**, is typically used in scenarios such as a toolbar, +/// where the button's state indicates whether a feature is enabled or not, or +/// a trigger for a popover menu, where clicking the button toggles the visibility of the menu. +/// +/// ``` +/// use ui::prelude::*; +/// +/// Button::new("button_id", "Click me!") +/// .icon(IconName::Check) +/// .selected(true) +/// .on_click(|event, cx| { +/// // Handle click event +/// }); +/// ``` +/// +/// To change the style of the button when it is selected use the [`selected_style`][Button::selected_style] method. +/// +/// ``` +/// use ui::prelude::*; +/// use ui::TintColor; +/// +/// Button::new("button_id", "Click me!") +/// .selected(true) +/// .selected_style(ButtonStyle::Tinted(TintColor::Accent)) +/// .on_click(|event, cx| { +/// // Handle click event +/// }); +/// ``` +/// This will create a button with a blue tinted background when selected. +/// +/// **A full-width button**, is typically used in scenarios such as the bottom of a modal or form, where it occupies the entire width of its container. +/// The button's content, including text and icons, is centered by default. +/// +/// ``` +/// use ui::prelude::*; +/// +/// let button = Button::new("button_id", "Click me!") +/// .full_width() +/// .on_click(|event, cx| { +/// // Handle click event +/// }); +/// ``` +/// #[derive(IntoElement)] pub struct Button { base: ButtonLike, @@ -14,15 +81,21 @@ pub struct Button { label_color: Option, label_size: Option, selected_label: Option, - icon: Option, + icon: Option, icon_position: Option, icon_size: Option, icon_color: Option, - selected_icon: Option, + selected_icon: Option, key_binding: Option, } impl Button { + /// Creates a new [`Button`] with a specified identifier and label. + /// + /// This is the primary constructor for a [`Button`] component. It initializes + /// the button with the provided identifier and label text, setting all other + /// properties to their default values, which can be customized using the + /// builder pattern methods provided by this struct. pub fn new(id: impl Into, label: impl Into) -> Self { Self { base: ButtonLike::new(id), @@ -39,46 +112,55 @@ impl Button { } } + /// Sets the color of the button's label. pub fn color(mut self, label_color: impl Into>) -> Self { self.label_color = label_color.into(); self } + /// Defines the size of the button's label. pub fn label_size(mut self, label_size: impl Into>) -> Self { self.label_size = label_size.into(); self } + /// Sets the label used when the button is in a selected state. pub fn selected_label>(mut self, label: impl Into>) -> Self { self.selected_label = label.into().map(Into::into); self } - pub fn icon(mut self, icon: impl Into>) -> Self { + /// Assigns an icon to the button. + pub fn icon(mut self, icon: impl Into>) -> Self { self.icon = icon.into(); self } + /// Sets the position of the icon relative to the label. pub fn icon_position(mut self, icon_position: impl Into>) -> Self { self.icon_position = icon_position.into(); self } + /// Specifies the size of the button's icon. pub fn icon_size(mut self, icon_size: impl Into>) -> Self { self.icon_size = icon_size.into(); self } + /// Sets the color of the button's icon. pub fn icon_color(mut self, icon_color: impl Into>) -> Self { self.icon_color = icon_color.into(); self } - pub fn selected_icon(mut self, icon: impl Into>) -> Self { + /// Chooses an icon to display when the button is in a selected state. + pub fn selected_icon(mut self, icon: impl Into>) -> Self { self.selected_icon = icon.into(); self } + /// Binds a key combination to the button for keyboard shortcuts. pub fn key_binding(mut self, key_binding: impl Into>) -> Self { self.key_binding = key_binding.into(); self @@ -86,6 +168,24 @@ impl Button { } impl Selectable for Button { + /// Sets the selected state of the button. + /// + /// This method allows the selection state of the button to be specified. + /// It modifies the button's appearance to reflect its selected state. + /// + /// # Examples + /// + /// ``` + /// use ui::prelude::*; + /// + /// Button::new("button_id", "Click me!") + /// .selected(true) + /// .on_click(|event, cx| { + /// // Handle click event + /// }); + /// ``` + /// + /// Use [`selected_style`](Button::selected_style) to change the style of the button when it is selected. fn selected(mut self, selected: bool) -> Self { self.base = self.base.selected(selected); self @@ -93,6 +193,22 @@ impl Selectable for Button { } impl SelectableButton for Button { + /// Sets the style for the button when selected. + /// + /// # Examples + /// + /// ``` + /// use ui::prelude::*; + /// use ui::TintColor; + /// + /// Button::new("button_id", "Click me!") + /// .selected(true) + /// .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + /// .on_click(|event, cx| { + /// // Handle click event + /// }); + /// ``` + /// This results in a button with a blue tinted background when selected. fn selected_style(mut self, style: ButtonStyle) -> Self { self.base = self.base.selected_style(style); self @@ -100,6 +216,24 @@ impl SelectableButton for Button { } impl Disableable for Button { + /// Disables the button. + /// + /// This method allows the button to be disabled. When a button is disabled, + /// it doesn't react to user interactions and its appearance is updated to reflect this. + /// + /// # Examples + /// + /// ``` + /// use ui::prelude::*; + /// + /// Button::new("button_id", "Click me!") + /// .disabled(true) + /// .on_click(|event, cx| { + /// // Handle click event + /// }); + /// ``` + /// + /// This results in a button that is disabled and does not respond to click events. fn disabled(mut self, disabled: bool) -> Self { self.base = self.base.disabled(disabled); self @@ -107,6 +241,7 @@ impl Disableable for Button { } impl Clickable for Button { + /// Sets the click event handler for the button. fn on_click( mut self, handler: impl Fn(&gpui::ClickEvent, &mut WindowContext) + 'static, @@ -117,11 +252,44 @@ impl Clickable for Button { } impl FixedWidth for Button { + /// Sets a fixed width for the button. + /// + /// This function allows a button to have a fixed width instead of automatically growing or shrinking. + /// Sets a fixed width for the button. + /// + /// # Examples + /// + /// ``` + /// use ui::prelude::*; + /// + /// Button::new("button_id", "Click me!") + /// .width(px(100.).into()) + /// .on_click(|event, cx| { + /// // Handle click event + /// }); + /// ``` + /// + /// This sets the button's width to be exactly 100 pixels. fn width(mut self, width: DefiniteLength) -> Self { self.base = self.base.width(width); self } + /// Sets the button to occupy the full width of its container. + /// + /// # Examples + /// + /// ``` + /// use ui::prelude::*; + /// + /// Button::new("button_id", "Click me!") + /// .full_width() + /// .on_click(|event, cx| { + /// // Handle click event + /// }); + /// ``` + /// + /// This stretches the button to the full width of its container. fn full_width(mut self) -> Self { self.base = self.base.full_width(); self @@ -129,20 +297,45 @@ impl FixedWidth for Button { } impl ButtonCommon for Button { + /// Sets the button's id. fn id(&self) -> &ElementId { self.base.id() } + /// Sets the visual style of the button using a [`ButtonStyle`]. fn style(mut self, style: ButtonStyle) -> Self { self.base = self.base.style(style); self } + /// Sets the button's size using a [`ButtonSize`]. fn size(mut self, size: ButtonSize) -> Self { self.base = self.base.size(size); self } + /// Sets a tooltip for the button. + /// + /// This method allows a tooltip to be set for the button. The tooltip is a function that + /// takes a mutable reference to a [`WindowContext`] and returns an [`AnyView`]. The tooltip + /// is displayed when the user hovers over the button. + /// + /// # Examples + /// + /// ``` + /// use ui::prelude::*; + /// use ui::Tooltip; + /// + /// Button::new("button_id", "Click me!") + /// .tooltip(move |cx| { + /// Tooltip::text("This is a tooltip", cx) + /// }) + /// .on_click(|event, cx| { + /// // Handle click event + /// }); + /// ``` + /// + /// This will create a button with a tooltip that displays "This is a tooltip" when hovered over. fn tooltip(mut self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self { self.base = self.base.tooltip(tooltip); self @@ -169,7 +362,7 @@ impl RenderOnce for Button { }; self.base.child( - h_stack() + h_flex() .gap_1() .when(self.icon_position == Some(IconPosition::Start), |this| { this.children(self.icon.map(|icon| { @@ -182,7 +375,7 @@ impl RenderOnce for Button { })) }) .child( - h_stack() + h_flex() .gap_2() .justify_between() .child( diff --git a/crates/ui/src/components/button/button_icon.rs b/crates/ui/src/components/button/button_icon.rs index 15538bb24d..b8f5427d30 100644 --- a/crates/ui/src/components/button/button_icon.rs +++ b/crates/ui/src/components/button/button_icon.rs @@ -1,4 +1,4 @@ -use crate::{prelude::*, Icon, IconElement, IconSize}; +use crate::{prelude::*, Icon, IconName, IconSize}; /// An icon that appears within a button. /// @@ -6,17 +6,17 @@ use crate::{prelude::*, Icon, IconElement, IconSize}; /// or as a standalone icon, like in [`IconButton`](crate::IconButton). #[derive(IntoElement)] pub(super) struct ButtonIcon { - icon: Icon, + icon: IconName, size: IconSize, color: Color, disabled: bool, selected: bool, - selected_icon: Option, + selected_icon: Option, selected_style: Option, } impl ButtonIcon { - pub fn new(icon: Icon) -> Self { + pub fn new(icon: IconName) -> Self { Self { icon, size: IconSize::default(), @@ -44,7 +44,7 @@ impl ButtonIcon { self } - pub fn selected_icon(mut self, icon: impl Into>) -> Self { + pub fn selected_icon(mut self, icon: impl Into>) -> Self { self.selected_icon = icon.into(); self } @@ -88,6 +88,6 @@ impl RenderOnce for ButtonIcon { self.color }; - IconElement::new(icon).size(self.size).color(icon_color) + Icon::new(icon).size(self.size).color(icon_color) } } diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index 431286073f..aafb33cd6f 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -4,10 +4,12 @@ use smallvec::SmallVec; use crate::prelude::*; +/// A trait for buttons that can be Selected. Enables setting the [`ButtonStyle`] of a button when it is selected. pub trait SelectableButton: Selectable { fn selected_style(self, style: ButtonStyle) -> Self; } +/// A common set of traits all buttons must implement. pub trait ButtonCommon: Clickable + Disableable { /// A unique element ID to identify the button. fn id(&self) -> &ElementId; @@ -93,6 +95,7 @@ impl From for Color { } } +/// The visual appearance of a button. #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] pub enum ButtonStyle { /// A filled button with a solid background color. Provides emphasis versus @@ -108,7 +111,7 @@ pub enum ButtonStyle { #[default] Subtle, - /// Used for buttons that only change forground color on hover and active states. + /// Used for buttons that only change foreground color on hover and active states. /// /// TODO: Better docs for this. Transparent, @@ -260,8 +263,9 @@ impl ButtonStyle { } } -/// ButtonSize can also be used to help build non-button elements -/// that are consistently sized with buttons. +/// The height of a button. +/// +/// Can also be used to size non-button elements to align with [`Button`]s. #[derive(Default, PartialEq, Clone, Copy)] pub enum ButtonSize { Large, @@ -289,13 +293,14 @@ impl ButtonSize { /// This is also used to build the prebuilt buttons. #[derive(IntoElement)] pub struct ButtonLike { - base: Div, + pub base: Div, id: ElementId, pub(super) style: ButtonStyle, pub(super) disabled: bool, pub(super) selected: bool, pub(super) selected_style: Option, pub(super) width: Option, + pub(super) height: Option, size: ButtonSize, rounding: Option, tooltip: Option AnyView>>, @@ -313,6 +318,7 @@ impl ButtonLike { selected: false, selected_style: None, width: None, + height: None, size: ButtonSize::Default, rounding: Some(ButtonLikeRounding::All), tooltip: None, @@ -321,6 +327,11 @@ impl ButtonLike { } } + pub(crate) fn height(mut self, height: DefiniteLength) -> Self { + self.height = Some(height); + self + } + pub(crate) fn rounding(mut self, rounding: impl Into>) -> Self { self.rounding = rounding.into(); self @@ -413,7 +424,7 @@ impl RenderOnce for ButtonLike { .id(self.id.clone()) .group("") .flex_none() - .h(self.size.height()) + .h(self.height.unwrap_or(self.size.height().into())) .when_some(self.width, |this, width| this.w(width).justify_center()) .when_some(self.rounding, |this, rounding| match rounding { ButtonLikeRounding::All => this.rounded_md(), diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index d9ed6ccb5d..6de32c0eab 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -1,28 +1,44 @@ use gpui::{AnyView, DefiniteLength}; use crate::{prelude::*, SelectableButton}; -use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, IconSize}; +use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize}; use super::button_icon::ButtonIcon; +/// The shape of an [`IconButton`]. +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +pub enum IconButtonShape { + Square, + Wide, +} + #[derive(IntoElement)] pub struct IconButton { base: ButtonLike, - icon: Icon, + shape: IconButtonShape, + icon: IconName, icon_size: IconSize, icon_color: Color, - selected_icon: Option, + selected_icon: Option, } impl IconButton { - pub fn new(id: impl Into, icon: Icon) -> Self { - Self { + pub fn new(id: impl Into, icon: IconName) -> Self { + let mut this = Self { base: ButtonLike::new(id), + shape: IconButtonShape::Wide, icon, icon_size: IconSize::default(), icon_color: Color::Default, selected_icon: None, - } + }; + this.base.base = this.base.base.debug_selector(|| format!("ICON-{:?}", icon)); + this + } + + pub fn shape(mut self, shape: IconButtonShape) -> Self { + self.shape = shape; + self } pub fn icon_size(mut self, icon_size: IconSize) -> Self { @@ -35,7 +51,7 @@ impl IconButton { self } - pub fn selected_icon(mut self, icon: impl Into>) -> Self { + pub fn selected_icon(mut self, icon: impl Into>) -> Self { self.selected_icon = icon.into(); self } @@ -113,19 +129,35 @@ impl VisibleOnHover for IconButton { } impl RenderOnce for IconButton { - fn render(self, _cx: &mut WindowContext) -> impl IntoElement { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { let is_disabled = self.base.disabled; let is_selected = self.base.selected; let selected_style = self.base.selected_style; - self.base.child( - ButtonIcon::new(self.icon) - .disabled(is_disabled) - .selected(is_selected) - .selected_icon(self.selected_icon) - .when_some(selected_style, |this, style| this.selected_style(style)) - .size(self.icon_size) - .color(self.icon_color), - ) + self.base + .map(|this| match self.shape { + IconButtonShape::Square => { + let icon_size = self.icon_size.rems() * cx.rem_size(); + let padding = match self.icon_size { + IconSize::Indicator => px(0.), + IconSize::XSmall => px(0.), + IconSize::Small => px(2.), + IconSize::Medium => px(2.), + }; + + this.width((icon_size + padding * 2.).into()) + .height((icon_size + padding * 2.).into()) + } + IconButtonShape::Wide => this, + }) + .child( + ButtonIcon::new(self.icon) + .disabled(is_disabled) + .selected(is_selected) + .selected_icon(self.selected_icon) + .when_some(selected_style, |this, style| this.selected_style(style)) + .size(self.icon_size) + .color(self.icon_color), + ) } } diff --git a/crates/ui/src/components/checkbox.rs b/crates/ui/src/components/checkbox.rs index 3b77842029..4b66c7bbee 100644 --- a/crates/ui/src/components/checkbox.rs +++ b/crates/ui/src/components/checkbox.rs @@ -1,7 +1,7 @@ use gpui::{div, prelude::*, ElementId, IntoElement, Styled, WindowContext}; use crate::prelude::*; -use crate::{Color, Icon, IconElement, Selection}; +use crate::{Color, Icon, IconName, Selection}; pub type CheckHandler = Box; @@ -47,7 +47,7 @@ impl RenderOnce for Checkbox { let group_id = format!("checkbox_group_{:?}", self.id); let icon = match self.checked { - Selection::Selected => Some(IconElement::new(Icon::Check).size(IconSize::Small).color( + Selection::Selected => Some(Icon::new(IconName::Check).size(IconSize::Small).color( if self.disabled { Color::Disabled } else { @@ -55,7 +55,7 @@ impl RenderOnce for Checkbox { }, )), Selection::Indeterminate => Some( - IconElement::new(Icon::Dash) + Icon::new(IconName::Dash) .size(IconSize::Small) .color(if self.disabled { Color::Disabled @@ -73,7 +73,7 @@ impl RenderOnce for Checkbox { // - a previously agreed to license that has been updated // // For the sake of styles we treat the indeterminate state as selected, - // but it's icon will be different. + // but its icon will be different. let selected = self.checked == Selection::Selected || self.checked == Selection::Indeterminate; @@ -103,7 +103,7 @@ impl RenderOnce for Checkbox { ), }; - h_stack() + h_flex() .id(self.id) // Rather than adding `px_1()` to add some space around the checkbox, // we use a larger parent element to create a slightly larger diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 8666ec6565..470483cc0a 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -1,6 +1,6 @@ use crate::{ - h_stack, prelude::*, v_stack, Icon, IconElement, KeyBinding, Label, List, ListItem, - ListSeparator, ListSubHeader, + h_flex, prelude::*, v_flex, Icon, IconName, KeyBinding, Label, List, ListItem, ListSeparator, + ListSubHeader, }; use gpui::{ px, Action, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, @@ -14,7 +14,7 @@ enum ContextMenuItem { Header(SharedString), Entry { label: SharedString, - icon: Option, + icon: Option, handler: Rc, action: Option>, }, @@ -51,6 +51,7 @@ impl ContextMenu { let _on_blur_subscription = cx.on_blur(&focus_handle, |this: &mut ContextMenu, cx| { this.cancel(&menu::Cancel, cx) }); + cx.refresh(); f( Self { items: Default::default(), @@ -117,7 +118,7 @@ impl ContextMenu { label: label.into(), action: Some(action.boxed_clone()), handler: Rc::new(move |cx| cx.dispatch_action(action.boxed_clone())), - icon: Some(Icon::Link), + icon: Some(IconName::Link), }); self } @@ -234,7 +235,7 @@ impl ContextMenuItem { impl Render for ContextMenu { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { div().elevation_2(cx).flex().flex_row().child( - v_stack() + v_flex() .min_w(px(200.)) .track_focus(&self.focus_handle) .on_mouse_down_out(cx.listener(|this, _, cx| this.cancel(&menu::Cancel, cx))) @@ -277,10 +278,10 @@ impl Render for ContextMenu { let menu = cx.view().downgrade(); let label_element = if let Some(icon) = icon { - h_stack() + h_flex() .gap_1() .child(Label::new(label.clone())) - .child(IconElement::new(*icon)) + .child(Icon::new(*icon)) .into_any_element() } else { Label::new(label.clone()).into_any_element() @@ -298,10 +299,11 @@ impl Render for ContextMenu { .ok(); }) .child( - h_stack() + h_flex() .w_full() .justify_between() .child(label_element) + .debug_selector(|| format!("MENU_ITEM-{}", label)) .children(action.as_ref().and_then(|action| { KeyBinding::for_action(&**action, cx) .map(|binding| div().ml_1().child(binding)) diff --git a/crates/ui/src/components/disclosure.rs b/crates/ui/src/components/disclosure.rs index d4349f61a0..59651ddb0b 100644 --- a/crates/ui/src/components/disclosure.rs +++ b/crates/ui/src/components/disclosure.rs @@ -1,6 +1,6 @@ use gpui::ClickEvent; -use crate::{prelude::*, Color, Icon, IconButton, IconSize}; +use crate::{prelude::*, Color, IconButton, IconName, IconSize}; #[derive(IntoElement)] pub struct Disclosure { @@ -32,8 +32,8 @@ impl RenderOnce for Disclosure { IconButton::new( self.id, match self.is_open { - true => Icon::ChevronDown, - false => Icon::ChevronRight, + true => IconName::ChevronDown, + false => IconName::ChevronRight, }, ) .icon_color(Color::Muted) diff --git a/crates/ui/src/components/divider.rs b/crates/ui/src/components/divider.rs index 2567c3fc34..772fc1a81a 100644 --- a/crates/ui/src/components/divider.rs +++ b/crates/ui/src/components/divider.rs @@ -7,6 +7,7 @@ enum DividerDirection { Vertical, } +/// The color of a [`Divider`]. #[derive(Default)] pub enum DividerColor { Border, diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 4c6e48c0fc..bdc691dc9a 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -5,6 +5,7 @@ use crate::prelude::*; #[derive(Default, PartialEq, Copy, Clone)] pub enum IconSize { + Indicator, XSmall, Small, #[default] @@ -14,6 +15,7 @@ pub enum IconSize { impl IconSize { pub fn rems(self) -> Rems { match self { + IconSize::Indicator => rems(10. / 16.), IconSize::XSmall => rems(12. / 16.), IconSize::Small => rems(14. / 16.), IconSize::Medium => rems(16. / 16.), @@ -22,7 +24,7 @@ impl IconSize { } #[derive(Debug, PartialEq, Copy, Clone, EnumIter)] -pub enum Icon { +pub enum IconName { Ai, ArrowDown, ArrowLeft, @@ -111,118 +113,108 @@ pub enum Icon { ZedXCopilot, } -impl Icon { +impl IconName { pub fn path(self) -> &'static str { match self { - Icon::Ai => "icons/ai.svg", - Icon::ArrowDown => "icons/arrow_down.svg", - Icon::ArrowLeft => "icons/arrow_left.svg", - Icon::ArrowRight => "icons/arrow_right.svg", - Icon::ArrowUp => "icons/arrow_up.svg", - Icon::ArrowUpRight => "icons/arrow_up_right.svg", - Icon::ArrowCircle => "icons/arrow_circle.svg", - Icon::AtSign => "icons/at_sign.svg", - Icon::AudioOff => "icons/speaker_off.svg", - Icon::AudioOn => "icons/speaker_loud.svg", - Icon::Backspace => "icons/backspace.svg", - Icon::Bell => "icons/bell.svg", - Icon::BellOff => "icons/bell_off.svg", - Icon::BellRing => "icons/bell_ring.svg", - Icon::Bolt => "icons/bolt.svg", - Icon::CaseSensitive => "icons/case_insensitive.svg", - Icon::Check => "icons/check.svg", - Icon::ChevronDown => "icons/chevron_down.svg", - Icon::ChevronLeft => "icons/chevron_left.svg", - Icon::ChevronRight => "icons/chevron_right.svg", - Icon::ChevronUp => "icons/chevron_up.svg", - Icon::Close => "icons/x.svg", - Icon::Collab => "icons/user_group_16.svg", - Icon::Command => "icons/command.svg", - Icon::Control => "icons/control.svg", - Icon::Copilot => "icons/copilot.svg", - Icon::CopilotDisabled => "icons/copilot_disabled.svg", - Icon::CopilotError => "icons/copilot_error.svg", - Icon::CopilotInit => "icons/copilot_init.svg", - Icon::Copy => "icons/copy.svg", - Icon::Dash => "icons/dash.svg", - Icon::Delete => "icons/delete.svg", - Icon::Disconnected => "icons/disconnected.svg", - Icon::Ellipsis => "icons/ellipsis.svg", - Icon::Envelope => "icons/feedback.svg", - Icon::Escape => "icons/escape.svg", - Icon::ExclamationTriangle => "icons/warning.svg", - Icon::Exit => "icons/exit.svg", - Icon::ExternalLink => "icons/external_link.svg", - Icon::File => "icons/file.svg", - Icon::FileDoc => "icons/file_icons/book.svg", - Icon::FileGeneric => "icons/file_icons/file.svg", - Icon::FileGit => "icons/file_icons/git.svg", - Icon::FileLock => "icons/file_icons/lock.svg", - Icon::FileRust => "icons/file_icons/rust.svg", - Icon::FileToml => "icons/file_icons/toml.svg", - Icon::FileTree => "icons/project.svg", - Icon::Filter => "icons/filter.svg", - Icon::Folder => "icons/file_icons/folder.svg", - Icon::FolderOpen => "icons/file_icons/folder_open.svg", - Icon::FolderX => "icons/stop_sharing.svg", - Icon::Github => "icons/github.svg", - Icon::Hash => "icons/hash.svg", - Icon::InlayHint => "icons/inlay_hint.svg", - Icon::Link => "icons/link.svg", - Icon::MagicWand => "icons/magic_wand.svg", - Icon::MagnifyingGlass => "icons/magnifying_glass.svg", - Icon::MailOpen => "icons/mail_open.svg", - Icon::Maximize => "icons/maximize.svg", - Icon::Menu => "icons/menu.svg", - Icon::MessageBubbles => "icons/conversations.svg", - Icon::Mic => "icons/mic.svg", - Icon::MicMute => "icons/mic_mute.svg", - Icon::Minimize => "icons/minimize.svg", - Icon::Option => "icons/option.svg", - Icon::PageDown => "icons/page_down.svg", - Icon::PageUp => "icons/page_up.svg", - Icon::Plus => "icons/plus.svg", - Icon::Public => "icons/public.svg", - Icon::Quote => "icons/quote.svg", - Icon::Replace => "icons/replace.svg", - Icon::ReplaceAll => "icons/replace_all.svg", - Icon::ReplaceNext => "icons/replace_next.svg", - Icon::Return => "icons/return.svg", - Icon::Screen => "icons/desktop.svg", - Icon::SelectAll => "icons/select_all.svg", - Icon::Shift => "icons/shift.svg", - Icon::Snip => "icons/snip.svg", - Icon::Space => "icons/space.svg", - Icon::Split => "icons/split.svg", - Icon::Tab => "icons/tab.svg", - Icon::Terminal => "icons/terminal.svg", - Icon::Update => "icons/update.svg", - Icon::WholeWord => "icons/word_search.svg", - Icon::XCircle => "icons/error.svg", - Icon::ZedXCopilot => "icons/zed_x_copilot.svg", + IconName::Ai => "icons/ai.svg", + IconName::ArrowDown => "icons/arrow_down.svg", + IconName::ArrowLeft => "icons/arrow_left.svg", + IconName::ArrowRight => "icons/arrow_right.svg", + IconName::ArrowUp => "icons/arrow_up.svg", + IconName::ArrowUpRight => "icons/arrow_up_right.svg", + IconName::ArrowCircle => "icons/arrow_circle.svg", + IconName::AtSign => "icons/at_sign.svg", + IconName::AudioOff => "icons/speaker_off.svg", + IconName::AudioOn => "icons/speaker_loud.svg", + IconName::Backspace => "icons/backspace.svg", + IconName::Bell => "icons/bell.svg", + IconName::BellOff => "icons/bell_off.svg", + IconName::BellRing => "icons/bell_ring.svg", + IconName::Bolt => "icons/bolt.svg", + IconName::CaseSensitive => "icons/case_insensitive.svg", + IconName::Check => "icons/check.svg", + IconName::ChevronDown => "icons/chevron_down.svg", + IconName::ChevronLeft => "icons/chevron_left.svg", + IconName::ChevronRight => "icons/chevron_right.svg", + IconName::ChevronUp => "icons/chevron_up.svg", + IconName::Close => "icons/x.svg", + IconName::Collab => "icons/user_group_16.svg", + IconName::Command => "icons/command.svg", + IconName::Control => "icons/control.svg", + IconName::Copilot => "icons/copilot.svg", + IconName::CopilotDisabled => "icons/copilot_disabled.svg", + IconName::CopilotError => "icons/copilot_error.svg", + IconName::CopilotInit => "icons/copilot_init.svg", + IconName::Copy => "icons/copy.svg", + IconName::Dash => "icons/dash.svg", + IconName::Delete => "icons/delete.svg", + IconName::Disconnected => "icons/disconnected.svg", + IconName::Ellipsis => "icons/ellipsis.svg", + IconName::Envelope => "icons/feedback.svg", + IconName::Escape => "icons/escape.svg", + IconName::ExclamationTriangle => "icons/warning.svg", + IconName::Exit => "icons/exit.svg", + IconName::ExternalLink => "icons/external_link.svg", + IconName::File => "icons/file.svg", + IconName::FileDoc => "icons/file_icons/book.svg", + IconName::FileGeneric => "icons/file_icons/file.svg", + IconName::FileGit => "icons/file_icons/git.svg", + IconName::FileLock => "icons/file_icons/lock.svg", + IconName::FileRust => "icons/file_icons/rust.svg", + IconName::FileToml => "icons/file_icons/toml.svg", + IconName::FileTree => "icons/project.svg", + IconName::Filter => "icons/filter.svg", + IconName::Folder => "icons/file_icons/folder.svg", + IconName::FolderOpen => "icons/file_icons/folder_open.svg", + IconName::FolderX => "icons/stop_sharing.svg", + IconName::Github => "icons/github.svg", + IconName::Hash => "icons/hash.svg", + IconName::InlayHint => "icons/inlay_hint.svg", + IconName::Link => "icons/link.svg", + IconName::MagicWand => "icons/magic_wand.svg", + IconName::MagnifyingGlass => "icons/magnifying_glass.svg", + IconName::MailOpen => "icons/mail_open.svg", + IconName::Maximize => "icons/maximize.svg", + IconName::Menu => "icons/menu.svg", + IconName::MessageBubbles => "icons/conversations.svg", + IconName::Mic => "icons/mic.svg", + IconName::MicMute => "icons/mic_mute.svg", + IconName::Minimize => "icons/minimize.svg", + IconName::Option => "icons/option.svg", + IconName::PageDown => "icons/page_down.svg", + IconName::PageUp => "icons/page_up.svg", + IconName::Plus => "icons/plus.svg", + IconName::Public => "icons/public.svg", + IconName::Quote => "icons/quote.svg", + IconName::Replace => "icons/replace.svg", + IconName::ReplaceAll => "icons/replace_all.svg", + IconName::ReplaceNext => "icons/replace_next.svg", + IconName::Return => "icons/return.svg", + IconName::Screen => "icons/desktop.svg", + IconName::SelectAll => "icons/select_all.svg", + IconName::Shift => "icons/shift.svg", + IconName::Snip => "icons/snip.svg", + IconName::Space => "icons/space.svg", + IconName::Split => "icons/split.svg", + IconName::Tab => "icons/tab.svg", + IconName::Terminal => "icons/terminal.svg", + IconName::Update => "icons/update.svg", + IconName::WholeWord => "icons/word_search.svg", + IconName::XCircle => "icons/error.svg", + IconName::ZedXCopilot => "icons/zed_x_copilot.svg", } } } #[derive(IntoElement)] -pub struct IconElement { +pub struct Icon { path: SharedString, color: Color, size: IconSize, } -impl RenderOnce for IconElement { - fn render(self, cx: &mut WindowContext) -> impl IntoElement { - svg() - .size(self.size.rems()) - .flex_none() - .path(self.path) - .text_color(self.color.color(cx)) - } -} - -impl IconElement { - pub fn new(icon: Icon) -> Self { +impl Icon { + pub fn new(icon: IconName) -> Self { Self { path: icon.path().into(), color: Color::default(), @@ -248,3 +240,13 @@ impl IconElement { self } } + +impl RenderOnce for Icon { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + svg() + .size(self.size.rems()) + .flex_none() + .path(self.path) + .text_color(self.color.color(cx)) + } +} diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index 671f981083..d8077f0ffc 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -1,4 +1,4 @@ -use crate::{h_stack, prelude::*, Icon, IconElement, IconSize}; +use crate::{h_flex, prelude::*, Icon, IconName, IconSize}; use gpui::{relative, rems, Action, FocusHandle, IntoElement, Keystroke}; #[derive(IntoElement, Clone)] @@ -12,13 +12,13 @@ pub struct KeyBinding { impl RenderOnce for KeyBinding { fn render(self, cx: &mut WindowContext) -> impl IntoElement { - h_stack() + h_flex() .flex_none() .gap_2() .children(self.key_binding.keystrokes().iter().map(|keystroke| { let key_icon = Self::icon_for_key(&keystroke); - h_stack() + h_flex() .flex_none() .gap_0p5() .p_0p5() @@ -26,16 +26,16 @@ impl RenderOnce for KeyBinding { .text_color(cx.theme().colors().text_muted) .when(keystroke.modifiers.function, |el| el.child(Key::new("fn"))) .when(keystroke.modifiers.control, |el| { - el.child(KeyIcon::new(Icon::Control)) + el.child(KeyIcon::new(IconName::Control)) }) .when(keystroke.modifiers.alt, |el| { - el.child(KeyIcon::new(Icon::Option)) + el.child(KeyIcon::new(IconName::Option)) }) .when(keystroke.modifiers.command, |el| { - el.child(KeyIcon::new(Icon::Command)) + el.child(KeyIcon::new(IconName::Command)) }) .when(keystroke.modifiers.shift, |el| { - el.child(KeyIcon::new(Icon::Shift)) + el.child(KeyIcon::new(IconName::Shift)) }) .when_some(key_icon, |el, icon| el.child(KeyIcon::new(icon))) .when(key_icon.is_none(), |el| { @@ -62,21 +62,21 @@ impl KeyBinding { Some(Self::new(key_binding)) } - fn icon_for_key(keystroke: &Keystroke) -> Option { + fn icon_for_key(keystroke: &Keystroke) -> Option { match keystroke.key.as_str() { - "left" => Some(Icon::ArrowLeft), - "right" => Some(Icon::ArrowRight), - "up" => Some(Icon::ArrowUp), - "down" => Some(Icon::ArrowDown), - "backspace" => Some(Icon::Backspace), - "delete" => Some(Icon::Delete), - "return" => Some(Icon::Return), - "enter" => Some(Icon::Return), - "tab" => Some(Icon::Tab), - "space" => Some(Icon::Space), - "escape" => Some(Icon::Escape), - "pagedown" => Some(Icon::PageDown), - "pageup" => Some(Icon::PageUp), + "left" => Some(IconName::ArrowLeft), + "right" => Some(IconName::ArrowRight), + "up" => Some(IconName::ArrowUp), + "down" => Some(IconName::ArrowDown), + "backspace" => Some(IconName::Backspace), + "delete" => Some(IconName::Delete), + "return" => Some(IconName::Return), + "enter" => Some(IconName::Return), + "tab" => Some(IconName::Tab), + "space" => Some(IconName::Space), + "escape" => Some(IconName::Escape), + "pagedown" => Some(IconName::PageDown), + "pageup" => Some(IconName::PageUp), _ => None, } } @@ -120,13 +120,13 @@ impl Key { #[derive(IntoElement)] pub struct KeyIcon { - icon: Icon, + icon: IconName, } impl RenderOnce for KeyIcon { fn render(self, _cx: &mut WindowContext) -> impl IntoElement { div().w(rems(14. / 16.)).child( - IconElement::new(self.icon) + Icon::new(self.icon) .size(IconSize::Small) .color(Color::Muted), ) @@ -134,7 +134,7 @@ impl RenderOnce for KeyIcon { } impl KeyIcon { - pub fn new(icon: Icon) -> Self { + pub fn new(icon: IconName) -> Self { Self { icon } } } diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index 61f463a531..0ba67286a2 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -2,6 +2,34 @@ use gpui::WindowContext; use crate::{prelude::*, LabelCommon, LabelLike, LabelSize, LineHeightStyle}; +/// A struct representing a label element in the UI. +/// +/// The `Label` struct stores the label text and common properties for a label element. +/// It provides methods for modifying these properties. +/// +/// # Examples +/// +/// ``` +/// use ui::prelude::*; +/// +/// Label::new("Hello, World!"); +/// ``` +/// +/// **A colored label**, for example labeling a dangerous action: +/// +/// ``` +/// use ui::prelude::*; +/// +/// let my_label = Label::new("Delete").color(Color::Error); +/// ``` +/// +/// **A label with a strikethrough**, for example labeling something that has been deleted: +/// +/// ``` +/// use ui::prelude::*; +/// +/// let my_label = Label::new("Deleted").strikethrough(true); +/// ``` #[derive(IntoElement)] pub struct Label { base: LabelLike, @@ -9,6 +37,15 @@ pub struct Label { } impl Label { + /// Create a new [`Label`] with the given text. + /// + /// # Examples + /// + /// ``` + /// use ui::prelude::*; + /// + /// let my_label = Label::new("Hello, World!"); + /// ``` pub fn new(label: impl Into) -> Self { Self { base: LabelLike::new(), @@ -18,21 +55,57 @@ impl Label { } impl LabelCommon for Label { + /// Sets the size of the label using a [`LabelSize`]. + /// + /// # Examples + /// + /// ``` + /// use ui::prelude::*; + /// + /// let my_label = Label::new("Hello, World!").size(LabelSize::Small); + /// ``` fn size(mut self, size: LabelSize) -> Self { self.base = self.base.size(size); self } + /// Sets the line height style of the label using a [`LineHeightStyle`]. + /// + /// # Examples + /// + /// ``` + /// use ui::prelude::*; + /// + /// let my_label = Label::new("Hello, World!").line_height_style(LineHeightStyle::UiLabel); + /// ``` fn line_height_style(mut self, line_height_style: LineHeightStyle) -> Self { self.base = self.base.line_height_style(line_height_style); self } + /// Sets the color of the label using a [`Color`]. + /// + /// # Examples + /// + /// ``` + /// use ui::prelude::*; + /// + /// let my_label = Label::new("Hello, World!").color(Color::Accent); + /// ``` fn color(mut self, color: Color) -> Self { self.base = self.base.color(color); self } + /// Sets the strikethrough property of the label. + /// + /// # Examples + /// + /// ``` + /// use ui::prelude::*; + /// + /// let my_label = Label::new("Hello, World!").strikethrough(true); + /// ``` fn strikethrough(mut self, strikethrough: bool) -> Self { self.base = self.base.strikethrough(strikethrough); self diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index 436461fdde..6da07d81a3 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -19,10 +19,18 @@ pub enum LineHeightStyle { UiLabel, } +/// A common set of traits all labels must implement. pub trait LabelCommon { + /// Sets the size of the label using a [`LabelSize`]. fn size(self, size: LabelSize) -> Self; + + /// Sets the line height style of the label using a [`LineHeightStyle`]. fn line_height_style(self, line_height_style: LineHeightStyle) -> Self; + + /// Sets the color of the label using a [`Color`]. fn color(self, color: Color) -> Self; + + /// Sets the strikethrough property of the label. fn strikethrough(self, strikethrough: bool) -> Self; } diff --git a/crates/ui/src/components/list/list.rs b/crates/ui/src/components/list/list.rs index 8c657fdd92..436f3e034d 100644 --- a/crates/ui/src/components/list/list.rs +++ b/crates/ui/src/components/list/list.rs @@ -1,7 +1,7 @@ use gpui::AnyElement; use smallvec::SmallVec; -use crate::{prelude::*, v_stack, Label, ListHeader}; +use crate::{prelude::*, v_flex, Label, ListHeader}; #[derive(IntoElement)] pub struct List { @@ -47,7 +47,7 @@ impl ParentElement for List { impl RenderOnce for List { fn render(self, _cx: &mut WindowContext) -> impl IntoElement { - v_stack().w_full().py_1().children(self.header).map(|this| { + v_flex().w_full().py_1().children(self.header).map(|this| { match (self.children.is_empty(), self.toggle) { (false, _) => this.children(self.children), (true, Some(false)) => this, diff --git a/crates/ui/src/components/list/list_header.rs b/crates/ui/src/components/list/list_header.rs index 1bed116015..7d47f4d393 100644 --- a/crates/ui/src/components/list/list_header.rs +++ b/crates/ui/src/components/list/list_header.rs @@ -1,4 +1,4 @@ -use crate::{h_stack, prelude::*, Disclosure, Label}; +use crate::{h_flex, prelude::*, Disclosure, Label}; use gpui::{AnyElement, ClickEvent}; #[derive(IntoElement)] @@ -76,7 +76,7 @@ impl Selectable for ListHeader { impl RenderOnce for ListHeader { fn render(self, cx: &mut WindowContext) -> impl IntoElement { - h_stack() + h_flex() .id(self.label.clone()) .w_full() .relative() @@ -95,7 +95,7 @@ impl RenderOnce for ListHeader { .w_full() .gap_1() .child( - h_stack() + h_flex() .gap_1() .children(self.toggle.map(|is_open| { Disclosure::new("toggle", is_open).on_toggle(self.on_toggle) @@ -109,7 +109,7 @@ impl RenderOnce for ListHeader { .child(Label::new(self.label.clone()).color(Color::Muted)), ), ) - .child(h_stack().children(self.end_slot)) + .child(h_flex().children(self.end_slot)) .when_some(self.end_hover_slot, |this, end_hover_slot| { this.child( div() diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index d43de18f93..804e5191ab 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -146,7 +146,7 @@ impl ParentElement for ListItem { impl RenderOnce for ListItem { fn render(self, cx: &mut WindowContext) -> impl IntoElement { - h_stack() + h_flex() .id(self.id) .w_full() .relative() @@ -169,7 +169,7 @@ impl RenderOnce for ListItem { }) }) .child( - h_stack() + h_flex() .id("inner_list_item") .w_full() .relative() @@ -219,9 +219,9 @@ impl RenderOnce for ListItem { .child(Disclosure::new("toggle", is_open).on_toggle(self.on_toggle)) })) .child( - h_stack() + h_flex() // HACK: We need to set *any* width value here in order for this container to size correctly. - // Without this the `h_stack` will overflow the parent `inner_list_item`. + // Without this the `h_flex` will overflow the parent `inner_list_item`. .w_px() .flex_1() .gap_1() @@ -230,7 +230,7 @@ impl RenderOnce for ListItem { ) .when_some(self.end_slot, |this, end_slot| { this.justify_between().child( - h_stack() + h_flex() .when(self.end_hover_slot.is_some(), |this| { this.visible() .group_hover("list_item", |this| this.invisible()) @@ -240,7 +240,7 @@ impl RenderOnce for ListItem { }) .when_some(self.end_hover_slot, |this, end_hover_slot| { this.child( - h_stack() + h_flex() .h_full() .absolute() .right_2() diff --git a/crates/ui/src/components/list/list_sub_header.rs b/crates/ui/src/components/list/list_sub_header.rs index 2e976b3517..e607dcaaa8 100644 --- a/crates/ui/src/components/list/list_sub_header.rs +++ b/crates/ui/src/components/list/list_sub_header.rs @@ -1,10 +1,10 @@ use crate::prelude::*; -use crate::{h_stack, Icon, IconElement, IconSize, Label}; +use crate::{h_flex, Icon, IconName, IconSize, Label}; #[derive(IntoElement)] pub struct ListSubHeader { label: SharedString, - start_slot: Option, + start_slot: Option, inset: bool, } @@ -17,7 +17,7 @@ impl ListSubHeader { } } - pub fn left_icon(mut self, left_icon: Option) -> Self { + pub fn left_icon(mut self, left_icon: Option) -> Self { self.start_slot = left_icon; self } @@ -25,7 +25,7 @@ impl ListSubHeader { impl RenderOnce for ListSubHeader { fn render(self, _cx: &mut WindowContext) -> impl IntoElement { - h_stack().flex_1().w_full().relative().py_1().child( + h_flex().flex_1().w_full().relative().py_1().child( div() .h_6() .when(self.inset, |this| this.px_2()) @@ -40,11 +40,10 @@ impl RenderOnce for ListSubHeader { .flex() .gap_1() .items_center() - .children(self.start_slot.map(|i| { - IconElement::new(i) - .color(Color::Muted) - .size(IconSize::Small) - })) + .children( + self.start_slot + .map(|i| Icon::new(i).color(Color::Muted).size(IconSize::Small)), + ) .child(Label::new(self.label.clone()).color(Color::Muted)), ), ) diff --git a/crates/ui/src/components/popover.rs b/crates/ui/src/components/popover.rs index acab1e2087..ad72a1d9b6 100644 --- a/crates/ui/src/components/popover.rs +++ b/crates/ui/src/components/popover.rs @@ -1,5 +1,5 @@ use crate::prelude::*; -use crate::v_stack; +use crate::v_flex; use gpui::{ div, AnyElement, Element, IntoElement, ParentElement, RenderOnce, Styled, WindowContext, }; @@ -12,7 +12,7 @@ use smallvec::SmallVec; /// user's mouse.) /// /// Example: A "new" menu with options like "new file", "new folder", etc, -/// Linear's "Display" menu, a profile menu that appers when you click your avatar. +/// Linear's "Display" menu, a profile menu that appears when you click your avatar. /// /// Related elements: /// @@ -43,10 +43,10 @@ impl RenderOnce for Popover { div() .flex() .gap_1() - .child(v_stack().elevation_2(cx).px_1().children(self.children)) + .child(v_flex().elevation_2(cx).px_1().children(self.children)) .when_some(self.aside, |this, aside| { this.child( - v_stack() + v_flex() .elevation_2(cx) .bg(cx.theme().colors().surface_background) .px_1() diff --git a/crates/ui/src/components/popover_menu.rs b/crates/ui/src/components/popover_menu.rs index fb823b05db..39202bf7ef 100644 --- a/crates/ui/src/components/popover_menu.rs +++ b/crates/ui/src/components/popover_menu.rs @@ -55,7 +55,7 @@ impl PopoverMenu { } } *menu2.borrow_mut() = None; - cx.notify(); + cx.refresh(); }) .detach(); cx.focus_view(&new_menu); @@ -108,6 +108,7 @@ impl PopoverMenu { } } +/// Creates a [`PopoverMenu`] pub fn popover_menu(id: impl Into) -> PopoverMenu { PopoverMenu { id: id.into(), diff --git a/crates/ui/src/components/right_click_menu.rs b/crates/ui/src/components/right_click_menu.rs index 8bf40f61a8..9d32073dbd 100644 --- a/crates/ui/src/components/right_click_menu.rs +++ b/crates/ui/src/components/right_click_menu.rs @@ -1,9 +1,9 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ - overlay, AnchorCorner, AnyElement, Bounds, DismissEvent, DispatchPhase, Element, ElementId, - IntoElement, LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, - View, VisualContext, WindowContext, + overlay, AnchorCorner, AnyElement, BorrowWindow, Bounds, DismissEvent, DispatchPhase, Element, + ElementId, InteractiveBounds, IntoElement, LayoutId, ManagedView, MouseButton, MouseDownEvent, + ParentElement, Pixels, Point, View, VisualContext, WindowContext, }; pub struct RightClickMenu { @@ -39,6 +39,7 @@ impl RightClickMenu { } } +/// Creates a [`RightClickMenu`] pub fn right_click_menu(id: impl Into) -> RightClickMenu { RightClickMenu { id: id.into(), @@ -133,11 +134,16 @@ impl Element for RightClickMenu { let position = element_state.position.clone(); let attach = self.attach.clone(); let child_layout_id = element_state.child_layout_id.clone(); + let child_bounds = cx.layout_bounds(child_layout_id.unwrap()); + let interactive_bounds = InteractiveBounds { + bounds: bounds.intersect(&cx.content_mask().bounds), + stacking_order: cx.stacking_order().clone(), + }; cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| { if phase == DispatchPhase::Bubble && event.button == MouseButton::Right - && bounds.contains(&event.position) + && interactive_bounds.visibly_contains(&event.position, cx) { cx.stop_propagation(); cx.prevent_default(); @@ -153,20 +159,18 @@ impl Element for RightClickMenu { } } *menu2.borrow_mut() = None; - cx.notify(); + cx.refresh(); }) .detach(); cx.focus_view(&new_menu); *menu.borrow_mut() = Some(new_menu); *position.borrow_mut() = if attach.is_some() && child_layout_id.is_some() { - attach - .unwrap() - .corner(cx.layout_bounds(child_layout_id.unwrap())) + attach.unwrap().corner(child_bounds) } else { cx.mouse_position() }; - cx.notify(); + cx.refresh(); } }); } diff --git a/crates/ui/src/components/stack.rs b/crates/ui/src/components/stack.rs index a6321b93d7..74a5e80575 100644 --- a/crates/ui/src/components/stack.rs +++ b/crates/ui/src/components/stack.rs @@ -2,18 +2,14 @@ use gpui::{div, Div}; use crate::StyledExt; -/// Horizontally stacks elements. -/// -/// Sets `flex()`, `flex_row()`, `items_center()` +/// Horizontally stacks elements. Sets `flex()`, `flex_row()`, `items_center()` #[track_caller] -pub fn h_stack() -> Div { +pub fn h_flex() -> Div { div().h_flex() } -/// Vertically stacks elements. -/// -/// Sets `flex()`, `flex_col()` +/// Vertically stacks elements. Sets `flex()`, `flex_col()` #[track_caller] -pub fn v_stack() -> Div { +pub fn v_flex() -> Div { div().v_flex() } diff --git a/crates/ui/src/components/stories/avatar.rs b/crates/ui/src/components/stories/avatar.rs index e447486d69..9da475b0d9 100644 --- a/crates/ui/src/components/stories/avatar.rs +++ b/crates/ui/src/components/stories/avatar.rs @@ -1,29 +1,63 @@ use gpui::Render; -use story::Story; +use story::{StoryContainer, StoryItem, StorySection}; -use crate::prelude::*; -use crate::Avatar; +use crate::{prelude::*, AudioStatus, Availability, AvatarAvailabilityIndicator}; +use crate::{Avatar, AvatarAudioStatusIndicator}; pub struct AvatarStory; impl Render for AvatarStory { - fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - Story::container() - .child(Story::title_for::()) - .child(Story::label("Default")) - .child(Avatar::new( - "https://avatars.githubusercontent.com/u/1714999?v=4", - )) - .child(Avatar::new( - "https://avatars.githubusercontent.com/u/326587?v=4", - )) + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + StoryContainer::new("Avatar", "crates/ui/src/components/stories/avatar.rs") .child( - Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") - .availability_indicator(true), + StorySection::new() + .child(StoryItem::new( + "Default", + Avatar::new("https://avatars.githubusercontent.com/u/1714999?v=4"), + )) + .child(StoryItem::new( + "Default", + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4"), + )), ) .child( - Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") - .availability_indicator(false), + StorySection::new() + .child(StoryItem::new( + "With free availability indicator", + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") + .indicator(AvatarAvailabilityIndicator::new(Availability::Free)), + )) + .child(StoryItem::new( + "With busy availability indicator", + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") + .indicator(AvatarAvailabilityIndicator::new(Availability::Busy)), + )), + ) + .child( + StorySection::new() + .child(StoryItem::new( + "With info border", + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") + .border_color(cx.theme().status().info_border), + )) + .child(StoryItem::new( + "With error border", + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") + .border_color(cx.theme().status().error_border), + )), + ) + .child( + StorySection::new() + .child(StoryItem::new( + "With muted audio indicator", + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") + .indicator(AvatarAudioStatusIndicator::new(AudioStatus::Muted)), + )) + .child(StoryItem::new( + "With deafened audio indicator", + Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4") + .indicator(AvatarAudioStatusIndicator::new(AudioStatus::Deafened)), + )), ) } } diff --git a/crates/ui/src/components/stories/button.rs b/crates/ui/src/components/stories/button.rs index 7240812fa5..c3fcdc5ae9 100644 --- a/crates/ui/src/components/stories/button.rs +++ b/crates/ui/src/components/stories/button.rs @@ -1,7 +1,7 @@ use gpui::Render; use story::Story; -use crate::{prelude::*, Icon}; +use crate::{prelude::*, IconName}; use crate::{Button, ButtonStyle}; pub struct ButtonStory; @@ -23,12 +23,12 @@ impl Render for ButtonStory { .child(Story::label("With `label_color`")) .child(Button::new("filled_with_label_color", "Click me").color(Color::Created)) .child(Story::label("With `icon`")) - .child(Button::new("filled_with_icon", "Click me").icon(Icon::FileGit)) + .child(Button::new("filled_with_icon", "Click me").icon(IconName::FileGit)) .child(Story::label("Selected with `icon`")) .child( Button::new("filled_and_selected_with_icon", "Click me") .selected(true) - .icon(Icon::FileGit), + .icon(IconName::FileGit), ) .child(Story::label("Default (Subtle)")) .child(Button::new("default_subtle", "Click me").style(ButtonStyle::Subtle)) diff --git a/crates/ui/src/components/stories/checkbox.rs b/crates/ui/src/components/stories/checkbox.rs index a064c67393..b4a966b67e 100644 --- a/crates/ui/src/components/stories/checkbox.rs +++ b/crates/ui/src/components/stories/checkbox.rs @@ -2,7 +2,7 @@ use gpui::{Render, ViewContext}; use story::Story; use crate::prelude::*; -use crate::{h_stack, Checkbox}; +use crate::{h_flex, Checkbox}; pub struct CheckboxStory; @@ -12,7 +12,7 @@ impl Render for CheckboxStory { .child(Story::title_for::()) .child(Story::label("Default")) .child( - h_stack() + h_flex() .p_2() .gap_2() .rounded_md() @@ -27,7 +27,7 @@ impl Render for CheckboxStory { ) .child(Story::label("Disabled")) .child( - h_stack() + h_flex() .p_2() .gap_2() .rounded_md() diff --git a/crates/ui/src/components/stories/icon.rs b/crates/ui/src/components/stories/icon.rs index 83fc5980dd..f6e750de2a 100644 --- a/crates/ui/src/components/stories/icon.rs +++ b/crates/ui/src/components/stories/icon.rs @@ -3,17 +3,17 @@ use story::Story; use strum::IntoEnumIterator; use crate::prelude::*; -use crate::{Icon, IconElement}; +use crate::{Icon, IconName}; pub struct IconStory; impl Render for IconStory { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - let icons = Icon::iter(); + let icons = IconName::iter(); Story::container() - .child(Story::title_for::()) + .child(Story::title_for::()) .child(Story::label("All Icons")) - .child(div().flex().gap_3().children(icons.map(IconElement::new))) + .child(div().flex().gap_3().children(icons.map(Icon::new))) } } diff --git a/crates/ui/src/components/stories/icon_button.rs b/crates/ui/src/components/stories/icon_button.rs index 66fc4affb3..4b53a2c0f2 100644 --- a/crates/ui/src/components/stories/icon_button.rs +++ b/crates/ui/src/components/stories/icon_button.rs @@ -1,8 +1,8 @@ use gpui::Render; use story::{StoryContainer, StoryItem, StorySection}; -use crate::{prelude::*, Tooltip}; -use crate::{Icon, IconButton}; +use crate::{prelude::*, IconButtonShape, Tooltip}; +use crate::{IconButton, IconName}; pub struct IconButtonStory; @@ -10,7 +10,7 @@ impl Render for IconButtonStory { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { let default_button = StoryItem::new( "Default", - IconButton::new("default_icon_button", Icon::Hash), + IconButton::new("default_icon_button", IconName::Hash), ) .description("Displays an icon button.") .usage( @@ -21,7 +21,7 @@ impl Render for IconButtonStory { let selected_button = StoryItem::new( "Selected", - IconButton::new("selected_icon_button", Icon::Hash).selected(true), + IconButton::new("selected_icon_button", IconName::Hash).selected(true), ) .description("Displays an icon button that is selected.") .usage( @@ -32,9 +32,9 @@ impl Render for IconButtonStory { let selected_with_selected_icon = StoryItem::new( "Selected with `selected_icon`", - IconButton::new("selected_with_selected_icon_button", Icon::AudioOn) + IconButton::new("selected_with_selected_icon_button", IconName::AudioOn) .selected(true) - .selected_icon(Icon::AudioOff), + .selected_icon(IconName::AudioOff), ) .description( "Displays an icon button that is selected and shows a different icon when selected.", @@ -49,7 +49,7 @@ impl Render for IconButtonStory { let disabled_button = StoryItem::new( "Disabled", - IconButton::new("disabled_icon_button", Icon::Hash).disabled(true), + IconButton::new("disabled_icon_button", IconName::Hash).disabled(true), ) .description("Displays an icon button that is disabled.") .usage( @@ -60,7 +60,7 @@ impl Render for IconButtonStory { let with_on_click_button = StoryItem::new( "With `on_click`", - IconButton::new("with_on_click_button", Icon::Ai).on_click(|_event, _cx| { + IconButton::new("with_on_click_button", IconName::Ai).on_click(|_event, _cx| { println!("Clicked!"); }), ) @@ -75,7 +75,7 @@ impl Render for IconButtonStory { let with_tooltip_button = StoryItem::new( "With `tooltip`", - IconButton::new("with_tooltip_button", Icon::MessageBubbles) + IconButton::new("with_tooltip_button", IconName::MessageBubbles) .tooltip(|cx| Tooltip::text("Open messages", cx)), ) .description("Displays an icon button that has a tooltip when hovered.") @@ -88,7 +88,7 @@ impl Render for IconButtonStory { let selected_with_tooltip_button = StoryItem::new( "Selected with `tooltip`", - IconButton::new("selected_with_tooltip_button", Icon::InlayHint) + IconButton::new("selected_with_tooltip_button", IconName::InlayHint) .selected(true) .tooltip(|cx| Tooltip::text("Toggle inlay hints", cx)), ) @@ -113,59 +113,36 @@ impl Render for IconButtonStory { StoryContainer::new( "Icon Button", - "crates/ui2/src/components/stories/icon_button.rs", + "crates/ui/src/components/stories/icon_button.rs", + ) + .child(StorySection::new().children(buttons)) + .child( + StorySection::new().child(StoryItem::new( + "Square", + h_flex() + .gap_2() + .child( + IconButton::new("square-medium", IconName::Close) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Medium), + ) + .child( + IconButton::new("square-small", IconName::Close) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small), + ) + .child( + IconButton::new("square-xsmall", IconName::Close) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall), + ) + .child( + IconButton::new("square-indicator", IconName::Close) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Indicator), + ), + )), ) - .children(vec![StorySection::new().children(buttons)]) .into_element() - - // Story::container() - // .child(Story::title_for::()) - // .child(Story::label("Default")) - // .child(div().w_8().child(IconButton::new("icon_a", Icon::Hash))) - // .child(Story::label("Selected")) - // .child( - // div() - // .w_8() - // .child(IconButton::new("icon_a", Icon::Hash).selected(true)), - // ) - // .child(Story::label("Selected with `selected_icon`")) - // .child( - // div().w_8().child( - // IconButton::new("icon_a", Icon::AudioOn) - // .selected(true) - // .selected_icon(Icon::AudioOff), - // ), - // ) - // .child(Story::label("Disabled")) - // .child( - // div() - // .w_8() - // .child(IconButton::new("icon_a", Icon::Hash).disabled(true)), - // ) - // .child(Story::label("With `on_click`")) - // .child( - // div() - // .w_8() - // .child( - // IconButton::new("with_on_click", Icon::Ai).on_click(|_event, _cx| { - // println!("Clicked!"); - // }), - // ), - // ) - // .child(Story::label("With `tooltip`")) - // .child( - // div().w_8().child( - // IconButton::new("with_tooltip", Icon::MessageBubbles) - // .tooltip(|cx| Tooltip::text("Open messages", cx)), - // ), - // ) - // .child(Story::label("Selected with `tooltip`")) - // .child( - // div().w_8().child( - // IconButton::new("selected_with_tooltip", Icon::InlayHint) - // .selected(true) - // .tooltip(|cx| Tooltip::text("Toggle inlay hints", cx)), - // ), - // ) } } diff --git a/crates/ui/src/components/stories/list_header.rs b/crates/ui/src/components/stories/list_header.rs index ffbf7157f5..358dc26a87 100644 --- a/crates/ui/src/components/stories/list_header.rs +++ b/crates/ui/src/components/stories/list_header.rs @@ -2,7 +2,7 @@ use gpui::Render; use story::Story; use crate::{prelude::*, IconButton}; -use crate::{Icon, ListHeader}; +use crate::{IconName, ListHeader}; pub struct ListHeaderStory; @@ -13,19 +13,19 @@ impl Render for ListHeaderStory { .child(Story::label("Default")) .child(ListHeader::new("Section 1")) .child(Story::label("With left icon")) - .child(ListHeader::new("Section 2").start_slot(IconElement::new(Icon::Bell))) + .child(ListHeader::new("Section 2").start_slot(Icon::new(IconName::Bell))) .child(Story::label("With left icon and meta")) .child( ListHeader::new("Section 3") - .start_slot(IconElement::new(Icon::BellOff)) - .end_slot(IconButton::new("action_1", Icon::Bolt)), + .start_slot(Icon::new(IconName::BellOff)) + .end_slot(IconButton::new("action_1", IconName::Bolt)), ) .child(Story::label("With multiple meta")) .child( ListHeader::new("Section 4") - .end_slot(IconButton::new("action_1", Icon::Bolt)) - .end_slot(IconButton::new("action_2", Icon::ExclamationTriangle)) - .end_slot(IconButton::new("action_3", Icon::Plus)), + .end_slot(IconButton::new("action_1", IconName::Bolt)) + .end_slot(IconButton::new("action_2", IconName::ExclamationTriangle)) + .end_slot(IconButton::new("action_3", IconName::Plus)), ) } } diff --git a/crates/ui/src/components/stories/list_item.rs b/crates/ui/src/components/stories/list_item.rs index b3ff096d9d..f2af011db8 100644 --- a/crates/ui/src/components/stories/list_item.rs +++ b/crates/ui/src/components/stories/list_item.rs @@ -1,8 +1,8 @@ -use gpui::Render; +use gpui::{Render, SharedUrl}; use story::Story; use crate::{prelude::*, Avatar}; -use crate::{Icon, ListItem}; +use crate::{IconName, ListItem}; pub struct ListItemStory; @@ -18,13 +18,13 @@ impl Render for ListItemStory { ListItem::new("inset_list_item") .inset(true) .start_slot( - IconElement::new(Icon::Bell) + Icon::new(IconName::Bell) .size(IconSize::Small) .color(Color::Muted), ) .child("Hello, world!") .end_slot( - IconElement::new(Icon::Bell) + Icon::new(IconName::Bell) .size(IconSize::Small) .color(Color::Muted), ), @@ -34,7 +34,7 @@ impl Render for ListItemStory { ListItem::new("with start slot_icon") .child("Hello, world!") .start_slot( - IconElement::new(Icon::Bell) + Icon::new(IconName::Bell) .size(IconSize::Small) .color(Color::Muted), ), @@ -43,7 +43,7 @@ impl Render for ListItemStory { .child( ListItem::new("with_start slot avatar") .child("Hello, world!") - .start_slot(Avatar::new(SharedString::from( + .start_slot(Avatar::new(SharedUrl::from( "https://avatars.githubusercontent.com/u/1714999?v=4", ))), ) @@ -51,7 +51,7 @@ impl Render for ListItemStory { .child( ListItem::new("with_left_avatar") .child("Hello, world!") - .end_slot(Avatar::new(SharedString::from( + .end_slot(Avatar::new(SharedUrl::from( "https://avatars.githubusercontent.com/u/1714999?v=4", ))), ) @@ -60,25 +60,25 @@ impl Render for ListItemStory { ListItem::new("with_end_hover_slot") .child("Hello, world!") .end_slot( - h_stack() + h_flex() .gap_2() - .child(Avatar::new(SharedString::from( + .child(Avatar::new(SharedUrl::from( "https://avatars.githubusercontent.com/u/1789?v=4", ))) - .child(Avatar::new(SharedString::from( + .child(Avatar::new(SharedUrl::from( "https://avatars.githubusercontent.com/u/1789?v=4", ))) - .child(Avatar::new(SharedString::from( + .child(Avatar::new(SharedUrl::from( "https://avatars.githubusercontent.com/u/1789?v=4", ))) - .child(Avatar::new(SharedString::from( + .child(Avatar::new(SharedUrl::from( "https://avatars.githubusercontent.com/u/1789?v=4", ))) - .child(Avatar::new(SharedString::from( + .child(Avatar::new(SharedUrl::from( "https://avatars.githubusercontent.com/u/1789?v=4", ))), ) - .end_hover_slot(Avatar::new(SharedString::from( + .end_hover_slot(Avatar::new(SharedUrl::from( "https://avatars.githubusercontent.com/u/1714999?v=4", ))), ) diff --git a/crates/ui/src/components/stories/tab.rs b/crates/ui/src/components/stories/tab.rs index 4c63e593aa..541af75ba4 100644 --- a/crates/ui/src/components/stories/tab.rs +++ b/crates/ui/src/components/stories/tab.rs @@ -3,7 +3,7 @@ use std::cmp::Ordering; use gpui::Render; use story::Story; -use crate::{prelude::*, TabPosition}; +use crate::{prelude::*, IconButtonShape, TabPosition}; use crate::{Indicator, Tab}; pub struct TabStory; @@ -13,10 +13,10 @@ impl Render for TabStory { Story::container() .child(Story::title_for::()) .child(Story::label("Default")) - .child(h_stack().child(Tab::new("tab_1").child("Tab 1"))) + .child(h_flex().child(Tab::new("tab_1").child("Tab 1"))) .child(Story::label("With indicator")) .child( - h_stack().child( + h_flex().child( Tab::new("tab_1") .start_slot(Indicator::dot().color(Color::Warning)) .child("Tab 1"), @@ -24,10 +24,11 @@ impl Render for TabStory { ) .child(Story::label("With close button")) .child( - h_stack().child( + h_flex().child( Tab::new("tab_1") .end_slot( - IconButton::new("close_button", Icon::Close) + IconButton::new("close_button", IconName::Close) + .shape(IconButtonShape::Square) .icon_color(Color::Muted) .size(ButtonSize::None) .icon_size(IconSize::XSmall), @@ -37,13 +38,13 @@ impl Render for TabStory { ) .child(Story::label("List of tabs")) .child( - h_stack() + h_flex() .child(Tab::new("tab_1").child("Tab 1")) .child(Tab::new("tab_2").child("Tab 2")), ) .child(Story::label("List of tabs with first tab selected")) .child( - h_stack() + h_flex() .child( Tab::new("tab_1") .selected(true) @@ -64,7 +65,7 @@ impl Render for TabStory { ) .child(Story::label("List of tabs with last tab selected")) .child( - h_stack() + h_flex() .child( Tab::new("tab_1") .position(TabPosition::First) @@ -89,7 +90,7 @@ impl Render for TabStory { ) .child(Story::label("List of tabs with second tab selected")) .child( - h_stack() + h_flex() .child( Tab::new("tab_1") .position(TabPosition::First) diff --git a/crates/ui/src/components/stories/tab_bar.rs b/crates/ui/src/components/stories/tab_bar.rs index 805725315c..d6d42fa5e0 100644 --- a/crates/ui/src/components/stories/tab_bar.rs +++ b/crates/ui/src/components/stories/tab_bar.rs @@ -35,19 +35,22 @@ impl Render for TabBarStory { .child(Story::title_for::()) .child(Story::label("Default")) .child( - h_stack().child( + h_flex().child( TabBar::new("tab_bar_1") .start_child( - IconButton::new("navigate_backward", Icon::ArrowLeft) + IconButton::new("navigate_backward", IconName::ArrowLeft) .icon_size(IconSize::Small), ) .start_child( - IconButton::new("navigate_forward", Icon::ArrowRight) + IconButton::new("navigate_forward", IconName::ArrowRight) .icon_size(IconSize::Small), ) - .end_child(IconButton::new("new", Icon::Plus).icon_size(IconSize::Small)) .end_child( - IconButton::new("split_pane", Icon::Split).icon_size(IconSize::Small), + IconButton::new("new", IconName::Plus).icon_size(IconSize::Small), + ) + .end_child( + IconButton::new("split_pane", IconName::Split) + .icon_size(IconSize::Small), ) .children(tabs), ), diff --git a/crates/ui/src/components/stories/toggle_button.rs b/crates/ui/src/components/stories/toggle_button.rs index 518165345c..68789a5340 100644 --- a/crates/ui/src/components/stories/toggle_button.rs +++ b/crates/ui/src/components/stories/toggle_button.rs @@ -9,7 +9,7 @@ impl Render for ToggleButtonStory { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { StoryContainer::new( "Toggle Button", - "crates/ui2/src/components/stories/toggle_button.rs", + "crates/ui/src/components/stories/toggle_button.rs", ) .child( StorySection::new().child( @@ -25,7 +25,7 @@ impl Render for ToggleButtonStory { StorySection::new().child( StoryItem::new( "Toggle button group", - h_stack() + h_flex() .child( ToggleButton::new(1, "Apple") .style(ButtonStyle::Filled) @@ -59,7 +59,7 @@ impl Render for ToggleButtonStory { StorySection::new().child( StoryItem::new( "Toggle button group with selection", - h_stack() + h_flex() .child( ToggleButton::new(1, "Apple") .style(ButtonStyle::Filled) diff --git a/crates/ui/src/components/tab.rs b/crates/ui/src/components/tab.rs index 351c851bb9..7f1fcca721 100644 --- a/crates/ui/src/components/tab.rs +++ b/crates/ui/src/components/tab.rs @@ -37,8 +37,11 @@ pub struct Tab { impl Tab { pub fn new(id: impl Into) -> Self { + let id = id.into(); Self { - div: div().id(id), + div: div() + .id(id.clone()) + .debug_selector(|| format!("TAB-{}", id)), selected: false, position: TabPosition::First, close_side: TabCloseSide::End, @@ -48,7 +51,9 @@ impl Tab { } } - pub const HEIGHT_IN_REMS: f32 = 30. / 16.; + pub const CONTAINER_HEIGHT_IN_REMS: f32 = 29. / 16.; + + const CONTENT_HEIGHT_IN_REMS: f32 = 28. / 16.; pub fn position(mut self, position: TabPosition) -> Self { self.position = position; @@ -111,7 +116,7 @@ impl RenderOnce for Tab { }; self.div - .h(rems(Self::HEIGHT_IN_REMS)) + .h(rems(Self::CONTAINER_HEIGHT_IN_REMS)) .bg(tab_bg) .border_color(cx.theme().colors().border) .map(|this| match self.position { @@ -135,17 +140,17 @@ impl RenderOnce for Tab { }) .cursor_pointer() .child( - h_stack() + h_flex() .group("") .relative() - .h_full() + .h(rems(Self::CONTENT_HEIGHT_IN_REMS)) .px_5() .gap_1() .text_color(text_color) // .hover(|style| style.bg(tab_hover_bg)) // .active(|style| style.bg(tab_active_bg)) .child( - h_stack() + h_flex() .w_3() .h_3() .justify_center() @@ -157,7 +162,7 @@ impl RenderOnce for Tab { .children(self.start_slot), ) .child( - h_stack() + h_flex() .w_3() .h_3() .justify_center() diff --git a/crates/ui/src/components/tab_bar.rs b/crates/ui/src/components/tab_bar.rs index 0a86f1ae0c..adee8389e4 100644 --- a/crates/ui/src/components/tab_bar.rs +++ b/crates/ui/src/components/tab_bar.rs @@ -90,7 +90,7 @@ impl ParentElement for TabBar { impl RenderOnce for TabBar { fn render(self, cx: &mut WindowContext) -> impl IntoElement { - const HEIGHT_IN_REMS: f32 = 30. / 16.; + const HEIGHT_IN_REMS: f32 = 29. / 16.; div() .id(self.id) @@ -102,7 +102,7 @@ impl RenderOnce for TabBar { .bg(cx.theme().colors().tab_bar_background) .when(!self.start_children.is_empty(), |this| { this.child( - h_stack() + h_flex() .flex_none() .gap_1() .px_1() @@ -129,7 +129,7 @@ impl RenderOnce for TabBar { .border_color(cx.theme().colors().border), ) .child( - h_stack() + h_flex() .id("tabs") .z_index(2) .flex_grow() @@ -142,7 +142,7 @@ impl RenderOnce for TabBar { ) .when(!self.end_children.is_empty(), |this| { this.child( - h_stack() + h_flex() .flex_none() .gap_1() .px_1() diff --git a/crates/ui/src/components/tooltip.rs b/crates/ui/src/components/tooltip.rs index 77fd8d6c0b..f76085daa3 100644 --- a/crates/ui/src/components/tooltip.rs +++ b/crates/ui/src/components/tooltip.rs @@ -3,7 +3,7 @@ use settings::Settings; use theme::ThemeSettings; use crate::prelude::*; -use crate::{h_stack, v_stack, Color, KeyBinding, Label, LabelSize, StyledExt}; +use crate::{h_flex, v_flex, Color, KeyBinding, Label, LabelSize, StyledExt}; pub struct Tooltip { title: SharedString, @@ -73,7 +73,7 @@ impl Render for Tooltip { overlay().child( // padding to avoid mouse cursor div().pl_2().pt_2p5().child( - v_stack() + v_flex() .elevation_2(cx) .font(ui_font) .text_ui() @@ -81,7 +81,7 @@ impl Render for Tooltip { .py_1() .px_2() .child( - h_stack() + h_flex() .gap_4() .child(self.title.clone()) .when_some(self.key_binding.clone(), |this, key_binding| { diff --git a/crates/ui/src/disableable.rs b/crates/ui/src/disableable.rs index f9b4e5ba91..9f08ed8d12 100644 --- a/crates/ui/src/disableable.rs +++ b/crates/ui/src/disableable.rs @@ -1,4 +1,4 @@ -/// A trait for elements that can be disabled. +/// A trait for elements that can be disabled. Generally used to implement disabling an element's interactivity and changing its appearance to reflect that it is disabled. pub trait Disableable { /// Sets whether the element is disabled. fn disabled(self, disabled: bool) -> Self; diff --git a/crates/ui/src/fixed.rs b/crates/ui/src/fixed.rs index a2c3ed3edc..9ba64da090 100644 --- a/crates/ui/src/fixed.rs +++ b/crates/ui/src/fixed.rs @@ -1,6 +1,6 @@ use gpui::DefiniteLength; -/// A trait for elements that have a fixed with. +/// A trait for elements that can have a fixed with. Enables the use of the `width` and `full_width` methods. pub trait FixedWidth { /// Sets the width of the element. fn width(self, width: DefiniteLength) -> Self; diff --git a/crates/ui/src/prelude.rs b/crates/ui/src/prelude.rs index 48536f59b3..837d93db2d 100644 --- a/crates/ui/src/prelude.rs +++ b/crates/ui/src/prelude.rs @@ -1,3 +1,5 @@ +//! The prelude of this crate. When building UI in Zed you almost always want to import this. + pub use gpui::prelude::*; pub use gpui::{ div, px, relative, rems, AbsoluteLength, DefiniteLength, Div, Element, ElementId, @@ -11,10 +13,10 @@ pub use crate::fixed::*; pub use crate::selectable::*; pub use crate::styles::{vh, vw}; pub use crate::visible_on_hover::*; -pub use crate::{h_stack, v_stack}; +pub use crate::{h_flex, v_flex}; pub use crate::{Button, ButtonSize, ButtonStyle, IconButton, SelectableButton}; pub use crate::{ButtonCommon, Color, StyledExt}; pub use crate::{Headline, HeadlineSize}; -pub use crate::{Icon, IconElement, IconPosition, IconSize}; +pub use crate::{Icon, IconName, IconPosition, IconSize}; pub use crate::{Label, LabelCommon, LabelSize, LineHeightStyle}; pub use theme::ActiveTheme; diff --git a/crates/ui/src/selectable.rs b/crates/ui/src/selectable.rs index 34c66ab1fa..54da86d094 100644 --- a/crates/ui/src/selectable.rs +++ b/crates/ui/src/selectable.rs @@ -1,18 +1,27 @@ /// A trait for elements that can be selected. +/// +/// Generally used to enable "toggle" or "active" behavior and styles on an element through the [`Selection`] status. pub trait Selectable { /// Sets whether the element is selected. fn selected(self, selected: bool) -> Self; } +/// Represents the selection status of an element. #[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Copy)] pub enum Selection { + /// The element is not selected. #[default] Unselected, + /// The selection state of the element is indeterminate. Indeterminate, + /// The element is selected. Selected, } impl Selection { + /// Returns the inverse of the current selection status. + /// + /// Indeterminate states become selected if inverted. pub fn inverse(&self) -> Self { match self { Self::Unselected | Self::Indeterminate => Self::Selected, diff --git a/crates/ui/src/styled_ext.rs b/crates/ui/src/styled_ext.rs index a6381e604f..b2eaf75ed9 100644 --- a/crates/ui/src/styled_ext.rs +++ b/crates/ui/src/styled_ext.rs @@ -14,7 +14,7 @@ fn elevated(this: E, cx: &mut WindowContext, index: ElevationIndex) - .shadow(index.shadow()) } -/// Extends [`Styled`](gpui::Styled) with Zed specific styling methods. +/// Extends [`gpui::Styled`] with Zed-specific styling methods. pub trait StyledExt: Styled + Sized { /// Horizontally stacks elements. /// @@ -30,6 +30,7 @@ pub trait StyledExt: Styled + Sized { self.flex().flex_col() } + /// Sets the text size using a [`UiTextSize`]. fn text_ui_size(self, size: UiTextSize) -> Self { self.text_size(size.rems()) } @@ -40,7 +41,7 @@ pub trait StyledExt: Styled + Sized { /// /// Note: The absolute size of this text will change based on a user's `ui_scale` setting. /// - /// Use [`text_ui_sm`] for regular-sized text. + /// Use `text_ui_sm` for smaller text. fn text_ui(self) -> Self { self.text_size(UiTextSize::default().rems()) } @@ -51,7 +52,7 @@ pub trait StyledExt: Styled + Sized { /// /// Note: The absolute size of this text will change based on a user's `ui_scale` setting. /// - /// Use [`text_ui`] for regular-sized text. + /// Use `text_ui` for regular-sized text. fn text_ui_sm(self) -> Self { self.text_size(UiTextSize::Small.rems()) } @@ -62,7 +63,7 @@ pub trait StyledExt: Styled + Sized { /// /// Note: The absolute size of this text will change based on a user's `ui_scale` setting. /// - /// Use [`text_ui`] for regular-sized text. + /// Use `text_ui` for regular-sized text. fn text_ui_xs(self) -> Self { self.text_size(UiTextSize::XSmall.rems()) } @@ -78,7 +79,7 @@ pub trait StyledExt: Styled + Sized { self.text_size(settings.buffer_font_size(cx)) } - /// The [`Surface`](ui::ElevationIndex::Surface) elevation level, located above the app background, is the standard level for all elements + /// The [`Surface`](ElevationIndex::Surface) elevation level, located above the app background, is the standard level for all elements /// /// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()` /// @@ -87,7 +88,7 @@ pub trait StyledExt: Styled + Sized { elevated(self, cx, ElevationIndex::Surface) } - /// Non-Modal Elevated Surfaces appear above the [`Surface`](ui::ElevationIndex::Surface) layer and is used for things that should appear above most UI elements like an editor or panel, but not elements like popovers, context menus, modals, etc. + /// Non-Modal Elevated Surfaces appear above the [`Surface`](ElevationIndex::Surface) layer and is used for things that should appear above most UI elements like an editor or panel, but not elements like popovers, context menus, modals, etc. /// /// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()` /// @@ -100,7 +101,7 @@ pub trait StyledExt: Styled + Sized { /// /// Elements rendered at this layer should have an enforced behavior: Any interaction outside of the modal will either dismiss the modal or prompt an action (Save your progress, etc) then dismiss the modal. /// - /// If the element does not have this behavior, it should be rendered at the [`Elevated Surface`](ui::ElevationIndex::ElevatedSurface) layer. + /// If the element does not have this behavior, it should be rendered at the [`Elevated Surface`](ElevationIndex::ElevatedSurface) layer. /// /// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()` /// @@ -119,26 +120,32 @@ pub trait StyledExt: Styled + Sized { self.border_color(cx.theme().colors().border_variant) } + /// Sets the background color to red for debugging when building UI. fn debug_bg_red(self) -> Self { self.bg(hsla(0. / 360., 1., 0.5, 1.)) } + /// Sets the background color to green for debugging when building UI. fn debug_bg_green(self) -> Self { self.bg(hsla(120. / 360., 1., 0.5, 1.)) } + /// Sets the background color to blue for debugging when building UI. fn debug_bg_blue(self) -> Self { self.bg(hsla(240. / 360., 1., 0.5, 1.)) } + /// Sets the background color to yellow for debugging when building UI. fn debug_bg_yellow(self) -> Self { self.bg(hsla(60. / 360., 1., 0.5, 1.)) } + /// Sets the background color to cyan for debugging when building UI. fn debug_bg_cyan(self) -> Self { self.bg(hsla(160. / 360., 1., 0.5, 1.)) } + /// Sets the background color to magenta for debugging when building UI. fn debug_bg_magenta(self) -> Self { self.bg(hsla(300. / 360., 1., 0.5, 1.)) } diff --git a/crates/ui/src/styles/color.rs b/crates/ui/src/styles/color.rs index 977a26dedc..1c9fd789d9 100644 --- a/crates/ui/src/styles/color.rs +++ b/crates/ui/src/styles/color.rs @@ -1,6 +1,7 @@ use gpui::{Hsla, WindowContext}; use theme::ActiveTheme; +/// Sets a color that has a consistent meaning across all themes. #[derive(Debug, Default, PartialEq, Copy, Clone)] pub enum Color { #[default] @@ -36,7 +37,7 @@ impl Color { Color::Info => cx.theme().status().info, Color::Placeholder => cx.theme().colors().text_placeholder, Color::Accent => cx.theme().colors().text_accent, - Color::Player(i) => cx.theme().styles.player.0[i.clone() as usize].cursor, + Color::Player(i) => cx.theme().styles.player.color_for_participant(*i).cursor, Color::Error => cx.theme().status().error, Color::Selected => cx.theme().colors().text_accent, Color::Success => cx.theme().status().success, diff --git a/crates/ui/src/styles/elevation.rs b/crates/ui/src/styles/elevation.rs index 7b3835c2e5..0aa3786a27 100644 --- a/crates/ui/src/styles/elevation.rs +++ b/crates/ui/src/styles/elevation.rs @@ -85,6 +85,7 @@ impl LayerIndex { } } +/// An appropriate z-index for the given layer based on its intended usage. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ElementIndex { Effect, diff --git a/crates/ui/src/styles/typography.rs b/crates/ui/src/styles/typography.rs index 39937ebff1..70cd797d51 100644 --- a/crates/ui/src/styles/typography.rs +++ b/crates/ui/src/styles/typography.rs @@ -38,6 +38,7 @@ impl UiTextSize { } } +/// The size of a [`Headline`] element #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] pub enum HeadlineSize { XSmall, diff --git a/crates/ui/src/ui.rs b/crates/ui/src/ui.rs index b074f10dad..e8ee51818e 100644 --- a/crates/ui/src/ui.rs +++ b/crates/ui/src/ui.rs @@ -3,8 +3,6 @@ //! This crate provides a set of UI primitives and components that are used to build all of the elements in Zed's UI. //! -#![doc = include_str!("../docs/building-ui.md")] - mod clickable; mod components; mod disableable; diff --git a/crates/ui/src/utils.rs b/crates/ui/src/utils.rs index 573a1333ef..ed1fec690f 100644 --- a/crates/ui/src/utils.rs +++ b/crates/ui/src/utils.rs @@ -1,3 +1,5 @@ +//! UI-related utilities (e.g. converting dates to a human-readable form). + mod format_distance; pub use format_distance::*; diff --git a/crates/ui/src/utils/format_distance.rs b/crates/ui/src/utils/format_distance.rs index 17f6b7a654..03a0de3adb 100644 --- a/crates/ui/src/utils/format_distance.rs +++ b/crates/ui/src/utils/format_distance.rs @@ -7,10 +7,10 @@ pub enum DateTimeType { } impl DateTimeType { - /// Converts the DateTimeType to a NaiveDateTime. + /// Converts the [`DateTimeType`] to a [`NaiveDateTime`]. /// - /// If the DateTimeType is already a NaiveDateTime, it will be returned as is. - /// If the DateTimeType is a DateTime, it will be converted to a NaiveDateTime. + /// If the [`DateTimeType`] is already a [`NaiveDateTime`], it will be returned as is. + /// If the [`DateTimeType`] is a [`DateTime`], it will be converted to a [`NaiveDateTime`]. pub fn to_naive(&self) -> NaiveDateTime { match self { DateTimeType::Naive(naive) => *naive, @@ -68,13 +68,13 @@ impl FormatDistance { } } -/// Calculates the distance in seconds between two NaiveDateTime objects. +/// Calculates the distance in seconds between two [`NaiveDateTime`] objects. /// It returns a signed integer denoting the difference. If `date` is earlier than `base_date`, the returned value will be negative. /// /// ## Arguments /// -/// * `date` - A NaiveDateTime object representing the date of interest -/// * `base_date` - A NaiveDateTime object representing the base date against which the comparison is made +/// * `date` - A [NaiveDateTime`] object representing the date of interest +/// * `base_date` - A [NaiveDateTime`] object representing the base date against which the comparison is made fn distance_in_seconds(date: NaiveDateTime, base_date: NaiveDateTime) -> i64 { let duration = date.signed_duration_since(base_date); -duration.num_seconds() @@ -233,29 +233,7 @@ fn distance_string( /// /// For example, "less than a minute ago", "about 2 hours ago", "3 months from now", etc. /// -/// Use [naive_format_distance_from_now] to compare a NaiveDateTime against now. -/// -/// # Arguments -/// -/// * `date` - The NaiveDateTime to compare. -/// * `base_date` - The NaiveDateTime to compare against. -/// * `include_seconds` - A boolean. If true, distances less than a minute are more detailed -/// * `add_suffix` - A boolean. If true, result indicates if the time is in the past or future -/// -/// # Example -/// -/// ```rust -/// use chrono::DateTime; -/// use ui::utils::format_distance; -/// -/// fn time_between_moon_landings() -> String { -/// let date = DateTime::parse_from_rfc3339("1969-07-20T00:00:00Z").unwrap().naive_local(); -/// let base_date = DateTime::parse_from_rfc3339("1972-12-14T00:00:00Z").unwrap().naive_local(); -/// format!("There was {} between the first and last crewed moon landings.", naive_format_distance(date, base_date, false, false)) -/// } -/// ``` -/// -/// Output: `"There was about 3 years between the first and last crewed moon landings."` +/// Use [`format_distance_from_now`] to compare a NaiveDateTime against now. pub fn format_distance( date: DateTimeType, base_date: NaiveDateTime, @@ -271,26 +249,6 @@ pub fn format_distance( /// Get the time difference between a date and now as relative human readable string. /// /// For example, "less than a minute ago", "about 2 hours ago", "3 months from now", etc. -/// -/// # Arguments -/// -/// * `datetime` - The NaiveDateTime to compare with the current time. -/// * `include_seconds` - A boolean. If true, distances less than a minute are more detailed -/// * `add_suffix` - A boolean. If true, result indicates if the time is in the past or future -/// -/// # Example -/// -/// ```rust -/// use chrono::DateTime; -/// use ui::utils::naive_format_distance_from_now; -/// -/// fn time_since_first_moon_landing() -> String { -/// let date = DateTime::parse_from_rfc3339("1969-07-20T00:00:00Z").unwrap().naive_local(); -/// format!("It's been {} since Apollo 11 first landed on the moon.", naive_format_distance_from_now(date, false, false)) -/// } -/// ``` -/// -/// Output: `It's been over 54 years since Apollo 11 first landed on the moon.` pub fn format_distance_from_now( datetime: DateTimeType, include_seconds: bool, diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index ff1f6080f7..2df28def4c 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -15,6 +15,8 @@ lazy_static::lazy_static! { pub static ref COPILOT_DIR: PathBuf = HOME.join("Library/Application Support/Zed/copilot"); pub static ref DEFAULT_PRETTIER_DIR: PathBuf = HOME.join("Library/Application Support/Zed/prettier"); pub static ref DB_DIR: PathBuf = HOME.join("Library/Application Support/Zed/db"); + pub static ref CRASHES_DIR: PathBuf = HOME.join("Library/Logs/DiagnosticReports"); + pub static ref CRASHES_RETIRED_DIR: PathBuf = HOME.join("Library/Logs/DiagnosticReports/Retired"); pub static ref SETTINGS: PathBuf = CONFIG_DIR.join("settings.json"); pub static ref KEYMAP: PathBuf = CONFIG_DIR.join("keymap.json"); pub static ref LAST_USERNAME: PathBuf = CONFIG_DIR.join("last-username.txt"); diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 613a79b19e..a2f8b87fee 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -41,8 +41,8 @@ pub fn truncate(s: &str, max_chars: usize) -> &str { } } -/// Removes characters from the end of the string if it's length is greater than `max_chars` and -/// appends "..." to the string. Returns string unchanged if it's length is smaller than max_chars. +/// Removes characters from the end of the string if its length is greater than `max_chars` and +/// appends "..." to the string. Returns string unchanged if its length is smaller than max_chars. pub fn truncate_and_trailoff(s: &str, max_chars: usize) -> String { debug_assert!(max_chars >= 5); @@ -53,8 +53,8 @@ pub fn truncate_and_trailoff(s: &str, max_chars: usize) -> String { } } -/// Removes characters from the front of the string if it's length is greater than `max_chars` and -/// prepends the string with "...". Returns string unchanged if it's length is smaller than max_chars. +/// Removes characters from the front of the string if its length is greater than `max_chars` and +/// prepends the string with "...". Returns string unchanged if its length is smaller than max_chars. pub fn truncate_and_remove_front(s: &str, max_chars: usize) -> String { debug_assert!(max_chars >= 5); @@ -137,6 +137,8 @@ pub trait ResultExt { type Ok; fn log_err(self) -> Option; + /// 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 inspect_error(self, func: impl FnOnce(&E)) -> Self; } @@ -159,6 +161,14 @@ where } } + #[track_caller] + fn debug_assert_ok(self, reason: &str) -> Self { + if let Err(error) = &self { + debug_panic!("{reason} - {error:?}"); + } + self + } + fn warn_on_err(self) -> Option { match self { Ok(value) => Some(value), @@ -234,6 +244,7 @@ where } } +#[must_use] pub struct LogErrorFuture(F, log::Level, core::panic::Location<'static>); impl Future for LogErrorFuture diff --git a/crates/vcs_menu/src/lib.rs b/crates/vcs_menu/src/lib.rs index 2735f81677..44564ce878 100644 --- a/crates/vcs_menu/src/lib.rs +++ b/crates/vcs_menu/src/lib.rs @@ -9,7 +9,7 @@ use gpui::{ use picker::{Picker, PickerDelegate}; use std::{ops::Not, sync::Arc}; use ui::{ - h_stack, v_stack, Button, ButtonCommon, Clickable, HighlightedLabel, Label, LabelCommon, + h_flex, v_flex, Button, ButtonCommon, Clickable, HighlightedLabel, Label, LabelCommon, LabelSize, ListItem, ListItemSpacing, Selectable, }; use util::ResultExt; @@ -18,7 +18,6 @@ use workspace::{ModalView, Toast, Workspace}; actions!(branches, [OpenRecent]); pub fn init(cx: &mut AppContext) { - // todo!() po cx.observe_new_views(|workspace: &mut Workspace, _| { workspace.register_action(|workspace, action, cx| { BranchList::toggle_modal(workspace, action, cx).log_err(); @@ -66,7 +65,7 @@ impl FocusableView for BranchList { impl Render for BranchList { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - v_stack() + v_flex() .w(rems(self.rem_width)) .child(self.picker.clone()) .on_mouse_down_out(cx.listener(|this, _, cx| { @@ -291,7 +290,7 @@ impl PickerDelegate for BranchListDelegate { } fn render_header(&self, _: &mut ViewContext>) -> Option { let label = if self.last_query.is_empty() { - h_stack() + h_flex() .ml_3() .child(Label::new("Recent branches").size(LabelSize::Small)) } else { @@ -299,7 +298,7 @@ impl PickerDelegate for BranchListDelegate { let suffix = if self.matches.len() == 1 { "" } else { "es" }; Label::new(format!("{} match{}", self.matches.len(), suffix)).size(LabelSize::Small) }); - h_stack() + h_flex() .px_3() .h_full() .justify_between() @@ -314,7 +313,7 @@ impl PickerDelegate for BranchListDelegate { } Some( - h_stack().mr_3().pb_2().child(h_stack().w_full()).child( + h_flex().mr_3().pb_2().child(h_flex().w_full()).child( Button::new("branch-picker-create-branch-button", "Create branch").on_click( cx.listener(|_, _, cx| { cx.spawn(|picker, mut cx| async move { @@ -343,7 +342,7 @@ impl PickerDelegate for BranchListDelegate { } let status = repo.change_branch(¤t_pick); if status.is_err() { - this.delegate.display_error_toast(format!("Failed to chec branch '{current_pick}', check for conflicts or unstashed files"), cx); + this.delegate.display_error_toast(format!("Failed to check branch '{current_pick}', check for conflicts or unstashed files"), cx); status?; } this.cancel(&Default::default(), cx); diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 34d8658afe..f1b4853feb 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1,5 +1,5 @@ use command_palette::CommandInterceptResult; -use editor::{SortLinesCaseInsensitive, SortLinesCaseSensitive}; +use editor::actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive}; use gpui::{impl_actions, Action, AppContext, ViewContext}; use serde_derive::Deserialize; use workspace::{SaveIntent, Workspace}; @@ -204,25 +204,31 @@ pub fn command_interceptor(mut query: &str, _: &AppContext) -> Option ("clist", diagnostics::Deploy.boxed_clone()), - "cc" => ("cc", editor::Hover.boxed_clone()), - "ll" => ("ll", editor::Hover.boxed_clone()), - "cn" | "cne" | "cnex" | "cnext" => ("cnext", editor::GoToDiagnostic.boxed_clone()), - "lne" | "lnex" | "lnext" => ("cnext", editor::GoToDiagnostic.boxed_clone()), + "cc" => ("cc", editor::actions::Hover.boxed_clone()), + "ll" => ("ll", editor::actions::Hover.boxed_clone()), + "cn" | "cne" | "cnex" | "cnext" => ("cnext", editor::actions::GoToDiagnostic.boxed_clone()), + "lne" | "lnex" | "lnext" => ("cnext", editor::actions::GoToDiagnostic.boxed_clone()), - "cpr" | "cpre" | "cprev" | "cprevi" | "cprevio" | "cpreviou" | "cprevious" => { - ("cprevious", editor::GoToPrevDiagnostic.boxed_clone()) + "cpr" | "cpre" | "cprev" | "cprevi" | "cprevio" | "cpreviou" | "cprevious" => ( + "cprevious", + editor::actions::GoToPrevDiagnostic.boxed_clone(), + ), + "cN" | "cNe" | "cNex" | "cNext" => { + ("cNext", editor::actions::GoToPrevDiagnostic.boxed_clone()) } - "cN" | "cNe" | "cNex" | "cNext" => ("cNext", editor::GoToPrevDiagnostic.boxed_clone()), - "lp" | "lpr" | "lpre" | "lprev" | "lprevi" | "lprevio" | "lpreviou" | "lprevious" => { - ("lprevious", editor::GoToPrevDiagnostic.boxed_clone()) + "lp" | "lpr" | "lpre" | "lprev" | "lprevi" | "lprevio" | "lpreviou" | "lprevious" => ( + "lprevious", + editor::actions::GoToPrevDiagnostic.boxed_clone(), + ), + "lN" | "lNe" | "lNex" | "lNext" => { + ("lNext", editor::actions::GoToPrevDiagnostic.boxed_clone()) } - "lN" | "lNe" | "lNex" | "lNext" => ("lNext", editor::GoToPrevDiagnostic.boxed_clone()), // modify the buffer (should accept [range]) "j" | "jo" | "joi" | "join" => ("join", JoinLines.boxed_clone()), "d" | "de" | "del" | "dele" | "delet" | "delete" | "dl" | "dell" | "delel" | "deletl" | "deletel" | "dp" | "dep" | "delp" | "delep" | "deletp" | "deletep" => { - ("delete", editor::DeleteLine.boxed_clone()) + ("delete", editor::actions::DeleteLine.boxed_clone()) } "sor" | "sor " | "sort" | "sort " => ("sort", SortLinesCaseSensitive.boxed_clone()), "sor i" | "sort i" => ("sort i", SortLinesCaseInsensitive.boxed_clone()), diff --git a/crates/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs index 3c2f373f94..e325c529a7 100644 --- a/crates/vim/src/editor_events.rs +++ b/crates/vim/src/editor_events.rs @@ -1,6 +1,6 @@ -use crate::Vim; +use crate::{insert::NormalBefore, Vim}; use editor::{Editor, EditorEvent}; -use gpui::{AppContext, Entity, EntityId, View, ViewContext, WindowContext}; +use gpui::{Action, AppContext, Entity, EntityId, View, ViewContext, WindowContext}; pub fn init(cx: &mut AppContext) { cx.observe_new_views(|_, cx: &mut ViewContext| { @@ -34,8 +34,7 @@ fn focused(editor: View, cx: &mut WindowContext) { fn blurred(editor: View, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { - vim.workspace_state.recording = false; - vim.workspace_state.recorded_actions.clear(); + vim.stop_recording_immediately(NormalBefore.boxed_clone()); if let Some(previous_editor) = vim.active_editor.clone() { if previous_editor .upgrade() @@ -69,7 +68,7 @@ fn released(entity_id: EntityId, cx: &mut AppContext) { mod test { use crate::{test::VimTestContext, Vim}; use editor::Editor; - use gpui::{Context, Entity}; + use gpui::{Context, Entity, VisualTestContext}; use language::Buffer; // regression test for blur called with a different active editor @@ -82,11 +81,13 @@ mod test { let editor2 = cx .update(|cx| { window2.update(cx, |_, cx| { + cx.activate_window(); cx.focus_self(); cx.view().clone() }) }) .unwrap(); + cx.run_until_parked(); cx.update(|cx| { let vim = Vim::read(cx); @@ -101,4 +102,41 @@ mod test { editor1.handle_blur(cx); }); } + + // regression test for focus_in/focus_out being called on window activation + #[gpui::test] + async fn test_focus_across_windows(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + let mut cx1 = VisualTestContext::from_window(cx.window, &cx); + let editor1 = cx.editor.clone(); + + let buffer = cx.new_model(|_| Buffer::new(0, 0, "a = 1\nb = 2\n")); + let (editor2, cx2) = cx.add_window_view(|cx| Editor::for_buffer(buffer, None, cx)); + + editor2.update(cx2, |_, cx| { + cx.focus_self(); + cx.activate_window(); + }); + cx.run_until_parked(); + + cx1.update(|cx| { + assert_eq!( + Vim::read(cx).active_editor.as_ref().unwrap().entity_id(), + editor2.entity_id(), + ) + }); + + cx1.update(|cx| { + cx.activate_window(); + }); + cx.run_until_parked(); + + cx.update(|cx| { + assert_eq!( + Vim::read(cx).active_editor.as_ref().unwrap().entity_id(), + editor1.entity_id(), + ) + }); + } } diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index 7c27231828..a063d37475 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -1,5 +1,5 @@ use crate::{normal::repeat, state::Mode, Vim}; -use editor::{scroll::autoscroll::Autoscroll, Bias}; +use editor::{scroll::Autoscroll, Bias}; use gpui::{actions, Action, ViewContext}; use language::SelectionGoal; use workspace::Workspace; diff --git a/crates/vim/src/mode_indicator.rs b/crates/vim/src/mode_indicator.rs index 4cd8e66900..423ae0c4e1 100644 --- a/crates/vim/src/mode_indicator.rs +++ b/crates/vim/src/mode_indicator.rs @@ -4,12 +4,14 @@ use workspace::{item::ItemHandle, ui::prelude::*, StatusItemView}; use crate::{state::Mode, Vim}; +/// The ModeIndicator displays the current mode in the status bar. pub struct ModeIndicator { - pub mode: Option, + pub(crate) mode: Option, _subscriptions: Vec, } impl ModeIndicator { + /// Construct a new mode indicator in this window. pub fn new(cx: &mut ViewContext) -> Self { let _subscriptions = vec![ cx.observe_global::(|this, cx| this.update_mode(cx)), @@ -26,24 +28,16 @@ impl ModeIndicator { fn update_mode(&mut self, cx: &mut ViewContext) { // Vim doesn't exist in some tests - if !cx.has_global::() { + let Some(vim) = cx.try_global::() else { return; - } + }; - let vim = Vim::read(cx); if vim.enabled { self.mode = Some(vim.state().mode); } else { self.mode = None; } } - - pub fn set_mode(&mut self, mode: Mode, cx: &mut ViewContext) { - if self.mode != Some(mode) { - self.mode = Some(mode); - cx.notify(); - } - } } impl Render for ModeIndicator { diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 685c7a28f9..63c0c2a192 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -1,17 +1,17 @@ use editor::{ - char_kind, display_map::{DisplaySnapshot, FoldPoint, ToDisplayPoint}, movement::{self, find_boundary, find_preceding_boundary, FindRange, TextLayoutDetails}, - Bias, CharKind, DisplayPoint, ToOffset, + Bias, DisplayPoint, ToOffset, }; use gpui::{actions, impl_actions, px, ViewContext, WindowContext}; -use language::{Point, Selection, SelectionGoal}; +use language::{char_kind, CharKind, Point, Selection, SelectionGoal}; use serde::Deserialize; use workspace::Workspace; use crate::{ normal::normal_motion, state::{Mode, Operator}, + utils::coerce_punctuation, visual::visual_motion, Vim, }; @@ -712,7 +712,7 @@ fn next_word_end( } point = movement::find_boundary(map, point, FindRange::MultiLine, |left, right| { let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation); - let right_kind = ccoerce_punctuation(har_kind(&scope, right), ignore_punctuation); + let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation); left_kind != right_kind && left_kind != CharKind::Whitespace }); @@ -952,14 +952,6 @@ pub(crate) fn next_line_end( end_of_line(map, false, point) } -fn coerce_punctuation(kind: CharKind, treat_punctuation_as_word: bool) -> Self { - if treat_punctuation_as_word && kind == CharKind::Punctuation { - CharKind::Word - } else { - kind - } -} - #[cfg(test)] mod test { diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index a8f2e5fa5a..c21f54f2d3 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -18,7 +18,7 @@ use crate::{ Vim, }; use collections::HashSet; -use editor::scroll::autoscroll::Autoscroll; +use editor::scroll::Autoscroll; use editor::{Bias, DisplayPoint}; use gpui::{actions, ViewContext, WindowContext}; use language::SelectionGoal; diff --git a/crates/vim/src/normal/case.rs b/crates/vim/src/normal/case.rs index 22d09f8359..d94454891f 100644 --- a/crates/vim/src/normal/case.rs +++ b/crates/vim/src/normal/case.rs @@ -1,4 +1,4 @@ -use editor::scroll::autoscroll::Autoscroll; +use editor::scroll::Autoscroll; use gpui::ViewContext; use language::{Bias, Point}; use workspace::Workspace; diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index bf2a25a98d..52de1f7e0a 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -1,13 +1,18 @@ -use crate::{motion::Motion, object::Object, state::Mode, utils::copy_selections_content, Vim}; +use crate::{ + motion::Motion, + object::Object, + state::Mode, + utils::{coerce_punctuation, copy_selections_content}, + Vim, +}; use editor::{ - char_kind, display_map::DisplaySnapshot, movement::{self, FindRange, TextLayoutDetails}, - scroll::autoscroll::Autoscroll, - CharKind, DisplayPoint, + scroll::Autoscroll, + DisplayPoint, }; use gpui::WindowContext; -use language::Selection; +use language::{char_kind, CharKind, Selection}; pub fn change_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &mut WindowContext) { // Some motions ignore failure when switching to normal mode @@ -103,9 +108,9 @@ fn expand_changed_word_selection( if in_word { selection.end = movement::find_boundary(map, selection.end, FindRange::MultiLine, |left, right| { - let left_kind = char_kind(&scope, left).coerce_punctuation(ignore_punctuation); + let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation); let right_kind = - char_kind(&scope, right).coerce_punctuation(ignore_punctuation); + coerce_punctuation(char_kind(&scope, right), ignore_punctuation); left_kind != right_kind && left_kind != CharKind::Whitespace }); diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index b8105aeb8d..ed9cdf19fa 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -1,6 +1,6 @@ use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim}; use collections::{HashMap, HashSet}; -use editor::{display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Bias}; +use editor::{display_map::ToDisplayPoint, scroll::Autoscroll, Bias}; use gpui::WindowContext; use language::Point; diff --git a/crates/vim/src/normal/increment.rs b/crates/vim/src/normal/increment.rs index 9fa06c4851..6353a881ed 100644 --- a/crates/vim/src/normal/increment.rs +++ b/crates/vim/src/normal/increment.rs @@ -1,6 +1,6 @@ use std::ops::Range; -use editor::{scroll::autoscroll::Autoscroll, MultiBufferSnapshot, ToOffset, ToPoint}; +use editor::{scroll::Autoscroll, MultiBufferSnapshot, ToOffset, ToPoint}; use gpui::{impl_actions, ViewContext, WindowContext}; use language::{Bias, Point}; use serde::Deserialize; diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index 169c2c4728..a65a816654 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -1,8 +1,7 @@ use std::{borrow::Cow, cmp}; use editor::{ - display_map::ToDisplayPoint, movement, scroll::autoscroll::Autoscroll, ClipboardSelection, - DisplayPoint, + display_map::ToDisplayPoint, movement, scroll::Autoscroll, ClipboardSelection, DisplayPoint, }; use gpui::{impl_actions, ViewContext}; use language::{Bias, SelectionGoal}; diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index a643c126ef..796cfce7a3 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -12,7 +12,7 @@ actions!(vim, [Repeat, EndRepeat]); fn should_replay(action: &Box) -> bool { // skip so that we don't leave the character palette open - if editor::ShowCharacterPalette.partial_eq(&**action) { + if editor::actions::ShowCharacterPalette.partial_eq(&**action) { return false; } true @@ -152,7 +152,7 @@ pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) { let mut count = Vim::read(cx).workspace_state.recorded_count.unwrap_or(1); - // if we came from insert mode we're just doing repititions 2 onwards. + // if we came from insert mode we're just doing repetitions 2 onwards. if from_insert_mode { count -= 1; new_actions[0] = actions[0].clone(); @@ -493,4 +493,17 @@ mod test { cx.simulate_keystrokes(["escape"]); cx.assert_state("ˇjhello\n", Mode::Normal); } + + #[gpui::test] + async fn test_repeat_over_blur(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("ˇhello hello hello\n").await; + cx.simulate_shared_keystrokes(["c", "f", "o", "x", "escape"]) + .await; + cx.assert_shared_state("ˇx hello hello\n").await; + cx.simulate_shared_keystrokes([":", "escape"]).await; + cx.simulate_shared_keystrokes(["."]).await; + cx.assert_shared_state("ˇx hello\n").await; + } } diff --git a/crates/vim/src/normal/scroll.rs b/crates/vim/src/normal/scroll.rs index 84a27e20ce..8c06158231 100644 --- a/crates/vim/src/normal/scroll.rs +++ b/crates/vim/src/normal/scroll.rs @@ -1,7 +1,7 @@ use crate::Vim; use editor::{ display_map::ToDisplayPoint, - scroll::{scroll_amount::ScrollAmount, VERTICAL_SCROLL_MARGIN}, + scroll::{ScrollAmount, VERTICAL_SCROLL_MARGIN}, DisplayPoint, Editor, }; use gpui::{actions, ViewContext}; diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 7b5f4d3e59..f85e3d9ba9 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -278,7 +278,7 @@ fn parse_replace_all(query: &str) -> Replacement { return Replacement::default(); } - let Some(delimeter) = chars.next() else { + let Some(delimiter) = chars.next() else { return Replacement::default(); }; @@ -301,13 +301,13 @@ fn parse_replace_all(query: &str) -> Replacement { buffer.push('$') // unescape escaped parens } else if phase == 0 && c == '(' || c == ')' { - } else if c != delimeter { + } else if c != delimiter { buffer.push('\\') } buffer.push(c) } else if c == '\\' { escaped = true; - } else if c == delimeter { + } else if c == delimiter { if phase == 0 { buffer = &mut replacement; phase = 1; diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 47d1647dc7..03333c962e 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -1,17 +1,19 @@ use std::ops::Range; use editor::{ - char_kind, display_map::{DisplaySnapshot, ToDisplayPoint}, movement::{self, FindRange}, - Bias, CharKind, DisplayPoint, + Bias, DisplayPoint, }; use gpui::{actions, impl_actions, ViewContext, WindowContext}; -use language::Selection; +use language::{char_kind, CharKind, Selection}; use serde::Deserialize; use workspace::Workspace; -use crate::{motion::right, normal::normal_object, state::Mode, visual::visual_object, Vim}; +use crate::{ + motion::right, normal::normal_object, state::Mode, utils::coerce_punctuation, + visual::visual_object, Vim, +}; #[derive(Copy, Clone, Debug, PartialEq)] pub enum Object { @@ -213,14 +215,14 @@ fn in_word( right(map, relative_to, 1), movement::FindRange::SingleLine, |left, right| { - char_kind(&scope, left).coerce_punctuation(ignore_punctuation) - != char_kind(&scope, right).coerce_punctuation(ignore_punctuation) + coerce_punctuation(char_kind(&scope, left), ignore_punctuation) + != coerce_punctuation(char_kind(&scope, right), ignore_punctuation) }, ); let end = movement::find_boundary(map, relative_to, FindRange::SingleLine, |left, right| { - char_kind(&scope, left).coerce_punctuation(ignore_punctuation) - != char_kind(&scope, right).coerce_punctuation(ignore_punctuation) + coerce_punctuation(char_kind(&scope, left), ignore_punctuation) + != coerce_punctuation(char_kind(&scope, right), ignore_punctuation) }); Some(start..end) @@ -282,15 +284,15 @@ fn around_next_word( right(map, relative_to, 1), FindRange::SingleLine, |left, right| { - char_kind(&scope, left).coerce_punctuation(ignore_punctuation) - != char_kind(&scope, right).coerce_punctuation(ignore_punctuation) + coerce_punctuation(char_kind(&scope, left), ignore_punctuation) + != coerce_punctuation(char_kind(&scope, right), ignore_punctuation) }, ); let mut word_found = false; let end = movement::find_boundary(map, relative_to, FindRange::MultiLine, |left, right| { - let left_kind = char_kind(&scope, left).coerce_punctuation(ignore_punctuation); - let right_kind = char_kind(&scope, right).coerce_punctuation(ignore_punctuation); + let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation); + let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation); let found = (word_found && left_kind != right_kind) || right == '\n' && left == '\n'; diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index b23c49c9a3..fa2dcb45cd 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -71,6 +71,30 @@ async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) { assert_eq!(cx.mode(), Mode::Normal); } +#[gpui::test] +async fn test_cancel_selection(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {"The quick brown fox juˇmps over the lazy dog"}, + Mode::Normal, + ); + // jumps + cx.simulate_keystrokes(["v", "l", "l"]); + cx.assert_editor_state("The quick brown fox ju«mpsˇ» over the lazy dog"); + + cx.simulate_keystrokes(["escape"]); + cx.assert_editor_state("The quick brown fox jumpˇs over the lazy dog"); + + // go back to the same selection state + cx.simulate_keystrokes(["v", "h", "h"]); + cx.assert_editor_state("The quick brown fox ju«ˇmps» over the lazy dog"); + + // Ctrl-[ should behave like Esc + cx.simulate_keystrokes(["ctrl-["]); + cx.assert_editor_state("The quick brown fox juˇmps over the lazy dog"); +} + #[gpui::test] async fn test_buffer_search(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index fe5c5db62f..384722286c 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -62,6 +62,8 @@ pub struct NeovimBackedTestContext { impl NeovimBackedTestContext { pub async fn new(cx: &mut gpui::TestAppContext) -> NeovimBackedTestContext { + #[cfg(feature = "neovim")] + cx.executor().allow_parking(); // rust stores the name of the test on the current thread. // We use this to automatically name a file that will store // the neovim connection's requests/responses so that we can diff --git a/crates/vim/src/test/neovim_connection.rs b/crates/vim/src/test/neovim_connection.rs index 363f6d43e3..a2daf7499d 100644 --- a/crates/vim/src/test/neovim_connection.rs +++ b/crates/vim/src/test/neovim_connection.rs @@ -359,7 +359,7 @@ impl NeovimConnection { // to add one to the end in visual mode. match mode { Some(Mode::VisualBlock) if selection_row != cursor_row => { - // in zed we fake a block selecrtion by using multiple cursors (one per line) + // in zed we fake a block selection by using multiple cursors (one per line) // this code emulates that. // to deal with casees where the selection is not perfectly rectangular we extract // the content of the selection via the "a register to get the shape correctly. diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 5ed5296bff..0e41f5a036 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -6,7 +6,7 @@ use editor::test::{ use futures::Future; use gpui::{Context, View, VisualContext}; use lsp::request; -use search::BufferSearchBar; +use search::{project_search::ProjectSearchBar, BufferSearchBar}; use crate::{state::Operator, *}; @@ -17,7 +17,6 @@ pub struct VimTestContext { impl VimTestContext { pub fn init(cx: &mut gpui::TestAppContext) { if cx.has_global::() { - dbg!("OOPS"); return; } cx.update(|cx| { @@ -59,9 +58,9 @@ impl VimTestContext { pane.toolbar().update(cx, |toolbar, cx| { let buffer_search_bar = cx.new_view(BufferSearchBar::new); toolbar.add_item(buffer_search_bar, cx); - // todo!(); - // let project_search_bar = cx.add_view(|_| ProjectSearchBar::new()); - // toolbar.add_item(project_search_bar, cx); + + let project_search_bar = cx.new_view(|_| ProjectSearchBar::new()); + toolbar.add_item(project_search_bar, cx); }) }); workspace.status_bar().update(cx, |status_bar, cx| { diff --git a/crates/vim/src/utils.rs b/crates/vim/src/utils.rs index 797e94b0fa..0ff857af9c 100644 --- a/crates/vim/src/utils.rs +++ b/crates/vim/src/utils.rs @@ -1,6 +1,6 @@ use editor::{ClipboardSelection, Editor}; use gpui::{AppContext, ClipboardItem}; -use language::Point; +use language::{CharKind, Point}; pub fn copy_selections_content(editor: &mut Editor, linewise: bool, cx: &mut AppContext) { let selections = editor.selections.all_adjusted(cx); @@ -48,3 +48,11 @@ pub fn copy_selections_content(editor: &mut Editor, linewise: bool, cx: &mut App cx.write_to_clipboard(ClipboardItem::new(text).with_metadata(clipboard_selections)); } + +pub fn coerce_punctuation(kind: CharKind, treat_punctuation_as_word: bool) -> CharKind { + if treat_punctuation_as_word && kind == CharKind::Punctuation { + CharKind::Word + } else { + kind + } +} diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 3579bf36fe..0cb038807b 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -1,3 +1,5 @@ +//! Vim support for Zed. + #[cfg(test)] mod test; @@ -38,12 +40,18 @@ use crate::state::ReplayableAction; /// Default: false pub struct VimModeSetting(pub bool); +/// An Action to Switch between modes #[derive(Clone, Deserialize, PartialEq)] pub struct SwitchMode(pub Mode); +/// PushOperator is used to put vim into a "minor" mode, +/// where it's waiting for a specific next set of keystrokes. +/// For example 'd' needs a motion to complete. #[derive(Clone, Deserialize, PartialEq)] pub struct PushOperator(pub Operator); +/// Number is used to manage vim's count. Pushing a digit +/// multiplis the current value by 10 and adds the digit. #[derive(Clone, Deserialize, PartialEq)] struct Number(usize); @@ -51,11 +59,13 @@ actions!( vim, [Tab, Enter, Object, InnerObject, FindForward, FindBackward] ); + // in the workspace namespace so it's not filtered out when vim is disabled. actions!(workspace, [ToggleVimMode]); impl_actions!(vim, [SwitchMode, PushOperator, Number]); +/// Initializes the `vim` crate. pub fn init(cx: &mut AppContext) { cx.set_global(Vim::default()); VimModeSetting::register(cx); @@ -119,6 +129,7 @@ fn register(workspace: &mut Workspace, cx: &mut ViewContext) { visual::register(workspace, cx); } +/// Registers a keystroke observer to observe keystrokes for the Vim integration. pub fn observe_keystrokes(cx: &mut WindowContext) { cx.observe_keystrokes(|keystroke_event, cx| { if let Some(action) = keystroke_event @@ -160,6 +171,7 @@ pub fn observe_keystrokes(cx: &mut WindowContext) { .detach() } +/// The state pertaining to Vim mode. Stored as a global. #[derive(Default)] pub struct Vim { active_editor: Option>, @@ -251,6 +263,8 @@ impl Vim { Some(editor.update(cx, update)) } + /// When doing an action that modifies the buffer, we start recording so that `.` + /// will replay the action. pub fn start_recording(&mut self, cx: &mut WindowContext) { if !self.workspace_state.replaying { self.workspace_state.recording = true; @@ -295,12 +309,19 @@ impl Vim { } } + /// When finishing an action that modifies the buffer, stop recording. + /// as you usually call this within a keystroke handler we also ensure that + /// the current action is recorded. pub fn stop_recording(&mut self) { if self.workspace_state.recording { self.workspace_state.stop_recording_after_next_action = true; } } + /// Stops recording actions immediately rather than waiting until after the + /// next action to stop recording. + /// + /// This doesn't include the current action. pub fn stop_recording_immediately(&mut self, action: Box) { if self.workspace_state.recording { self.workspace_state @@ -311,6 +332,7 @@ impl Vim { } } + /// Explicitly record one action (equivalents to start_recording and stop_recording) pub fn record_current_action(&mut self, cx: &mut WindowContext) { self.start_recording(cx); self.stop_recording(); @@ -516,6 +538,7 @@ impl Vim { } } + /// Returns the state of the active editor. pub fn state(&self) -> &EditorState { if let Some(active_editor) = self.active_editor.as_ref() { if let Some(state) = self.editor_states.get(&active_editor.entity_id()) { @@ -526,6 +549,7 @@ impl Vim { &self.default_state } + /// Updates the state of the active editor. pub fn update_state(&mut self, func: impl FnOnce(&mut EditorState) -> T) -> T { let mut state = self.state().clone(); let ret = func(&mut state); diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 1fd11167c6..d082ccc138 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -5,7 +5,7 @@ use collections::HashMap; use editor::{ display_map::{DisplaySnapshot, ToDisplayPoint}, movement, - scroll::autoscroll::Autoscroll, + scroll::Autoscroll, Bias, DisplayPoint, Editor, }; use gpui::{actions, ViewContext, WindowContext}; @@ -201,15 +201,13 @@ pub fn visual_block_motion( let mut row = tail.row(); loop { - let layed_out_line = map.layout_row(row, &text_layout_details); + let laid_out_line = map.layout_row(row, &text_layout_details); let start = DisplayPoint::new( row, - layed_out_line.closest_index_for_x(positions.start) as u32, - ); - let mut end = DisplayPoint::new( - row, - layed_out_line.closest_index_for_x(positions.end) as u32, + laid_out_line.closest_index_for_x(positions.start) as u32, ); + let mut end = + DisplayPoint::new(row, laid_out_line.closest_index_for_x(positions.end) as u32); if end <= start { if start.column() == map.line_len(start.row()) { end = start; @@ -218,7 +216,7 @@ pub fn visual_block_motion( } } - if positions.start <= layed_out_line.width { + if positions.start <= laid_out_line.width { let selection = Selection { id: s.new_selection_id(), start: start.to_point(map), diff --git a/crates/vim/test_data/test_repeat_over_blur.json b/crates/vim/test_data/test_repeat_over_blur.json new file mode 100644 index 0000000000..3929711e49 --- /dev/null +++ b/crates/vim/test_data/test_repeat_over_blur.json @@ -0,0 +1,11 @@ +{"Put":{"state":"ˇhello hello hello\n"}} +{"Key":"c"} +{"Key":"f"} +{"Key":"o"} +{"Key":"x"} +{"Key":"escape"} +{"Get":{"state":"ˇx hello hello\n","mode":"Normal"}} +{"Key":":"} +{"Key":"escape"} +{"Key":"."} +{"Get":{"state":"ˇx hello\n","mode":"Normal"}} diff --git a/crates/welcome/src/base_keymap_picker.rs b/crates/welcome/src/base_keymap_picker.rs index e22c89cef8..7913e4df37 100644 --- a/crates/welcome/src/base_keymap_picker.rs +++ b/crates/welcome/src/base_keymap_picker.rs @@ -62,7 +62,7 @@ impl BaseKeymapSelector { impl Render for BaseKeymapSelector { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - v_stack().w(rems(34.)).child(self.picker.clone()) + v_flex().w(rems(34.)).child(self.picker.clone()) } } diff --git a/crates/welcome/src/base_keymap_setting.rs b/crates/welcome/src/base_keymap_setting.rs index e05a16c350..54af63007a 100644 --- a/crates/welcome/src/base_keymap_setting.rs +++ b/crates/welcome/src/base_keymap_setting.rs @@ -4,7 +4,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; -/// Base key bindings scheme. Base keymaps can be overriden with user keymaps. +/// Base key bindings scheme. Base keymaps can be overridden with user keymaps. /// /// Default: VSCode #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)] diff --git a/crates/welcome/src/welcome.rs b/crates/welcome/src/welcome.rs index 76988fadb0..53b78b917f 100644 --- a/crates/welcome/src/welcome.rs +++ b/crates/welcome/src/welcome.rs @@ -60,186 +60,197 @@ pub struct WelcomePage { impl Render for WelcomePage { fn render(&mut self, cx: &mut gpui::ViewContext) -> impl IntoElement { - h_stack().full().track_focus(&self.focus_handle).child( - v_stack() - .w_96() - .gap_4() - .mx_auto() - .child( - svg() - .path("icons/logo_96.svg") - .text_color(gpui::white()) - .w(px(96.)) - .h(px(96.)) - .mx_auto(), - ) - .child( - h_stack() - .justify_center() - .child(Label::new("Code at the speed of thought")), - ) - .child( - v_stack() - .gap_2() - .child( - Button::new("choose-theme", "Choose a theme") - .full_width() - .on_click(cx.listener(|this, _, cx| { - this.telemetry - .report_app_event("welcome page: change theme"); - this.workspace - .update(cx, |workspace, cx| { - theme_selector::toggle( - workspace, - &Default::default(), - cx, - ) - }) - .ok(); - })), - ) - .child( - Button::new("choose-keymap", "Choose a keymap") - .full_width() - .on_click(cx.listener(|this, _, cx| { - this.telemetry - .report_app_event("welcome page: change keymap"); - this.workspace - .update(cx, |workspace, cx| { - base_keymap_picker::toggle( - workspace, - &Default::default(), - cx, - ) - }) - .ok(); - })), - ) - .child( - Button::new("install-cli", "Install the CLI") - .full_width() - .on_click(cx.listener(|this, _, cx| { - this.telemetry.report_app_event("welcome page: install cli"); - cx.app_mut() - .spawn( - |cx| async move { install_cli::install_cli(&cx).await }, + h_flex() + .full() + .bg(cx.theme().colors().editor_background) + .track_focus(&self.focus_handle) + .child( + v_flex() + .w_96() + .gap_4() + .mx_auto() + .child( + svg() + .path("icons/logo_96.svg") + .text_color(gpui::white()) + .w(px(96.)) + .h(px(96.)) + .mx_auto(), + ) + .child( + h_flex() + .justify_center() + .child(Label::new("Code at the speed of thought")), + ) + .child( + v_flex() + .gap_2() + .child( + Button::new("choose-theme", "Choose a theme") + .full_width() + .on_click(cx.listener(|this, _, cx| { + this.telemetry.report_app_event( + "welcome page: change theme".to_string(), + ); + this.workspace + .update(cx, |workspace, cx| { + theme_selector::toggle( + workspace, + &Default::default(), + cx, + ) + }) + .ok(); + })), + ) + .child( + Button::new("choose-keymap", "Choose a keymap") + .full_width() + .on_click(cx.listener(|this, _, cx| { + this.telemetry.report_app_event( + "welcome page: change keymap".to_string(), + ); + this.workspace + .update(cx, |workspace, cx| { + base_keymap_picker::toggle( + workspace, + &Default::default(), + cx, + ) + }) + .ok(); + })), + ) + .child( + Button::new("install-cli", "Install the CLI") + .full_width() + .on_click(cx.listener(|this, _, cx| { + this.telemetry.report_app_event( + "welcome page: install cli".to_string(), + ); + cx.app_mut() + .spawn(|cx| async move { + install_cli::install_cli(&cx).await + }) + .detach_and_log_err(cx); + })), + ), + ) + .child( + v_flex() + .p_3() + .gap_2() + .bg(cx.theme().colors().elevated_surface_background) + .border_1() + .border_color(cx.theme().colors().border) + .rounded_md() + .child( + h_flex() + .gap_2() + .child( + Checkbox::new( + "enable-vim", + if VimModeSetting::get_global(cx).0 { + ui::Selection::Selected + } else { + ui::Selection::Unselected + }, ) - .detach_and_log_err(cx); - })), - ), - ) - .child( - v_stack() - .p_3() - .gap_2() - .bg(cx.theme().colors().elevated_surface_background) - .border_1() - .border_color(cx.theme().colors().border) - .rounded_md() - .child( - h_stack() - .gap_2() - .child( - Checkbox::new( - "enable-vim", - if VimModeSetting::get_global(cx).0 { - ui::Selection::Selected - } else { - ui::Selection::Unselected - }, + .on_click( + cx.listener(move |this, selection, cx| { + this.telemetry.report_app_event( + "welcome page: toggle vim".to_string(), + ); + this.update_settings::( + selection, + cx, + |setting, value| *setting = Some(value), + ); + }), + ), ) - .on_click(cx.listener( - move |this, selection, cx| { - this.telemetry - .report_app_event("welcome page: toggle vim"); - this.update_settings::( - selection, - cx, - |setting, value| *setting = Some(value), - ); - }, - )), - ) - .child(Label::new("Enable vim mode")), - ) - .child( - h_stack() - .gap_2() - .child( - Checkbox::new( - "enable-telemetry", - if TelemetrySettings::get_global(cx).metrics { - ui::Selection::Selected - } else { - ui::Selection::Unselected - }, + .child(Label::new("Enable vim mode")), + ) + .child( + h_flex() + .gap_2() + .child( + Checkbox::new( + "enable-telemetry", + if TelemetrySettings::get_global(cx).metrics { + ui::Selection::Selected + } else { + ui::Selection::Unselected + }, + ) + .on_click( + cx.listener(move |this, selection, cx| { + this.telemetry.report_app_event( + "welcome page: toggle metric telemetry" + .to_string(), + ); + this.update_settings::( + selection, + cx, + { + let telemetry = this.telemetry.clone(); + + move |settings, value| { + settings.metrics = Some(value); + + telemetry.report_setting_event( + "metric telemetry", + value.to_string(), + ); + } + }, + ); + }), + ), ) - .on_click(cx.listener( - move |this, selection, cx| { - this.telemetry.report_app_event( - "welcome page: toggle metric telemetry", - ); - this.update_settings::( - selection, - cx, - { - let telemetry = this.telemetry.clone(); + .child(Label::new("Send anonymous usage data")), + ) + .child( + h_flex() + .gap_2() + .child( + Checkbox::new( + "enable-crash", + if TelemetrySettings::get_global(cx).diagnostics { + ui::Selection::Selected + } else { + ui::Selection::Unselected + }, + ) + .on_click( + cx.listener(move |this, selection, cx| { + this.telemetry.report_app_event( + "welcome page: toggle diagnostic telemetry" + .to_string(), + ); + this.update_settings::( + selection, + cx, + { + let telemetry = this.telemetry.clone(); - move |settings, value| { - settings.metrics = Some(value); + move |settings, value| { + settings.diagnostics = Some(value); - telemetry.report_setting_event( - "metric telemetry", - value.to_string(), - ); - } - }, - ); - }, - )), - ) - .child(Label::new("Send anonymous usage data")), - ) - .child( - h_stack() - .gap_2() - .child( - Checkbox::new( - "enable-crash", - if TelemetrySettings::get_global(cx).diagnostics { - ui::Selection::Selected - } else { - ui::Selection::Unselected - }, + telemetry.report_setting_event( + "diagnostic telemetry", + value.to_string(), + ); + } + }, + ); + }), + ), ) - .on_click(cx.listener( - move |this, selection, cx| { - this.telemetry.report_app_event( - "welcome page: toggle diagnostic telemetry", - ); - this.update_settings::( - selection, - cx, - { - let telemetry = this.telemetry.clone(); - - move |settings, value| { - settings.diagnostics = Some(value); - - telemetry.report_setting_event( - "diagnostic telemetry", - value.to_string(), - ); - } - }, - ); - }, - )), - ) - .child(Label::new("Send crash reports")), - ), - ), - ) + .child(Label::new("Send crash reports")), + ), + ), + ) } } @@ -247,7 +258,8 @@ impl WelcomePage { pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> View { let this = cx.new_view(|cx| { cx.on_release(|this: &mut Self, _, _| { - this.telemetry.report_app_event("welcome page: close"); + this.telemetry + .report_app_event("welcome page: close".to_string()); }) .detach(); @@ -306,6 +318,10 @@ impl Item for WelcomePage { .into_any_element() } + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("welcome page") + } + fn show_toolbar(&self) -> bool { false } diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index c13a00b11c..4ae4089935 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -7,14 +7,14 @@ use gpui::{ }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use settings::SettingsStore; use std::sync::Arc; -use ui::{h_stack, ContextMenu, IconButton, Tooltip}; +use ui::{h_flex, ContextMenu, IconButton, Tooltip}; use ui::{prelude::*, right_click_menu}; const RESIZE_HANDLE_SIZE: Pixels = Pixels(6.); pub enum PanelEvent { - ChangePosition, ZoomIn, ZoomOut, Activate, @@ -28,7 +28,7 @@ pub trait Panel: FocusableView + EventEmitter { fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext); fn size(&self, cx: &WindowContext) -> Pixels; fn set_size(&mut self, size: Option, cx: &mut ViewContext); - fn icon(&self, cx: &WindowContext) -> Option; + fn icon(&self, cx: &WindowContext) -> Option; fn icon_tooltip(&self, cx: &WindowContext) -> Option<&'static str>; fn toggle_action(&self) -> Box; fn icon_label(&self, _: &WindowContext) -> Option { @@ -52,7 +52,7 @@ pub trait PanelHandle: Send + Sync { fn set_active(&self, active: bool, cx: &mut WindowContext); fn size(&self, cx: &WindowContext) -> Pixels; fn set_size(&self, size: Option, cx: &mut WindowContext); - fn icon(&self, cx: &WindowContext) -> Option; + fn icon(&self, cx: &WindowContext) -> Option; fn icon_tooltip(&self, cx: &WindowContext) -> Option<&'static str>; fn toggle_action(&self, cx: &WindowContext) -> Box; fn icon_label(&self, cx: &WindowContext) -> Option; @@ -104,7 +104,7 @@ where self.update(cx, |this, cx| this.set_size(size, cx)) } - fn icon(&self, cx: &WindowContext) -> Option { + fn icon(&self, cx: &WindowContext) -> Option { self.read(cx).icon(cx) } @@ -167,15 +167,6 @@ impl DockPosition { } } - // todo!() - // fn to_resize_handle_side(self) -> HandleSide { - // match self { - // Self::Left => HandleSide::Right, - // Self::Bottom => HandleSide::Top, - // Self::Right => HandleSide::Left, - // } - // } - pub fn axis(&self) -> Axis { match self { Self::Left | Self::Right => Axis::Horizontal, @@ -186,9 +177,7 @@ impl DockPosition { struct PanelEntry { panel: Arc, - // todo!() - // context_menu: View, - _subscriptions: [Subscription; 2], + _subscriptions: [Subscription; 3], } pub struct PanelButtons { @@ -265,12 +254,6 @@ impl Dock { self.is_open } - // todo!() - // pub fn has_focus(&self, cx: &WindowContext) -> bool { - // self.visible_panel() - // .map_or(false, |panel| panel.has_focus(cx)) - // } - pub fn panel(&self) -> Option> { self.panel_entries .iter() @@ -338,9 +321,15 @@ impl Dock { ) { let subscriptions = [ cx.observe(&panel, |_, _, cx| cx.notify()), - cx.subscribe(&panel, move |this, panel, event, cx| match event { - PanelEvent::ChangePosition => { + cx.observe_global::({ + let workspace = workspace.clone(); + let panel = panel.clone(); + + move |this, cx| { let new_position = panel.read(cx).position(cx); + if new_position == this.position { + return; + } let Ok(new_dock) = workspace.update(cx, |workspace, cx| { if panel.is_zoomed(cx) { @@ -371,6 +360,8 @@ impl Dock { } }); } + }), + cx.subscribe(&panel, move |this, panel, event, cx| match event { PanelEvent::ZoomIn => { this.set_panel_zoomed(&panel.to_any(), true, cx); if !panel.focus_handle(cx).contains_focused(cx) { @@ -395,7 +386,6 @@ impl Dock { }) .ok(); } - // todo!() we do not use this event in the production code (even in zed1), remove it PanelEvent::Activate => { if let Some(ix) = this .panel_entries @@ -418,16 +408,8 @@ impl Dock { }), ]; - // todo!() - // let dock_view_id = cx.view_id(); self.panel_entries.push(PanelEntry { panel: Arc::new(panel), - // todo!() - // context_menu: cx.add_view(|cx| { - // let mut menu = ContextMenu::new(dock_view_id, cx); - // menu.set_position_mode(OverlayPositionMode::Local); - // menu - // }), _subscriptions: subscriptions, }); cx.notify() @@ -601,7 +583,7 @@ impl Render for Dock { Axis::Horizontal => this.min_w(size).h_full(), Axis::Vertical => this.min_h(size).w_full(), }) - .child(entry.panel.to_any()), + .child(entry.panel.to_any().cached()), ) .child(handle) } else { @@ -619,7 +601,6 @@ impl PanelButtons { impl Render for PanelButtons { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - // todo!() let dock = self.dock.read(cx); let active_index = dock.active_panel_index; let is_open = dock.is_open; @@ -701,7 +682,7 @@ impl Render for PanelButtons { ) }); - h_stack().gap_0p5().children(buttons) + h_flex().gap_0p5().children(buttons) } } @@ -764,7 +745,7 @@ pub mod test { fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext) { self.position = position; - cx.emit(PanelEvent::ChangePosition); + cx.update_global::(|_, _| {}); } fn size(&self, _: &WindowContext) -> Pixels { @@ -775,7 +756,7 @@ pub mod test { self.size = size.unwrap_or(px(300.)); } - fn icon(&self, _: &WindowContext) -> Option { + fn icon(&self, _: &WindowContext) -> Option { None } diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index c629edc696..908ea1d168 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -114,6 +114,8 @@ pub trait Item: FocusableView + EventEmitter { } fn tab_content(&self, detail: Option, selected: bool, cx: &WindowContext) -> AnyElement; + fn telemetry_event_text(&self) -> Option<&'static str>; + /// (model id, Item) fn for_each_project_item( &self, @@ -225,6 +227,7 @@ pub trait ItemHandle: 'static + Send { fn tab_tooltip_text(&self, cx: &AppContext) -> Option; fn tab_description(&self, detail: usize, cx: &AppContext) -> Option; fn tab_content(&self, detail: Option, selected: bool, cx: &WindowContext) -> AnyElement; + fn telemetry_event_text(&self, cx: &WindowContext) -> Option<&'static str>; fn dragged_tab_content(&self, detail: Option, cx: &WindowContext) -> AnyElement; fn project_path(&self, cx: &AppContext) -> Option; fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>; @@ -313,6 +316,10 @@ impl ItemHandle for View { self.read(cx).tab_tooltip_text(cx) } + fn telemetry_event_text(&self, cx: &WindowContext) -> Option<&'static str> { + self.read(cx).telemetry_event_text() + } + fn tab_description(&self, detail: usize, cx: &AppContext) -> Option { self.read(cx).tab_description(detail, cx) } @@ -441,11 +448,13 @@ impl ItemHandle for View { workspace.unfollow(&pane, cx); } - if item.add_event_to_update_proto( - event, - &mut *pending_update.borrow_mut(), - cx, - ) && !pending_update_scheduled.load(Ordering::SeqCst) + if item.focus_handle(cx).contains_focused(cx) + && item.add_event_to_update_proto( + event, + &mut *pending_update.borrow_mut(), + cx, + ) + && !pending_update_scheduled.load(Ordering::SeqCst) { pending_update_scheduled.store(true, Ordering::SeqCst); cx.defer({ @@ -579,13 +588,9 @@ impl ItemHandle for View { } fn to_followable_item_handle(&self, cx: &AppContext) -> Option> { - if cx.has_global::() { - let builders = cx.global::(); - let item = self.to_any(); - Some(builders.get(&item.entity_type())?.1(&item)) - } else { - None - } + let builders = cx.try_global::()?; + let item = self.to_any(); + Some(builders.get(&item.entity_type())?.1(&item)) } fn on_release( @@ -809,27 +814,6 @@ pub mod test { Edit, } - // impl Clone for TestItem { - // fn clone(&self) -> Self { - // Self { - // state: self.state.clone(), - // label: self.label.clone(), - // save_count: self.save_count, - // save_as_count: self.save_as_count, - // reload_count: self.reload_count, - // is_dirty: self.is_dirty, - // is_singleton: self.is_singleton, - // has_conflict: self.has_conflict, - // project_items: self.project_items.clone(), - // nav_history: None, - // tab_descriptions: None, - // tab_detail: Default::default(), - // workspace_id: self.workspace_id, - // focus_handle: self.focus_handle.clone(), - // } - // } - // } - impl TestProjectItem { pub fn new(id: u64, path: &str, cx: &mut AppContext) -> Model { let entry_id = Some(ProjectEntryId::from_proto(id)); @@ -943,6 +927,10 @@ pub mod test { }) } + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + fn tab_content( &self, detail: Option, diff --git a/crates/workspace/src/modal_layer.rs b/crates/workspace/src/modal_layer.rs index ae105345cd..c30ca35a68 100644 --- a/crates/workspace/src/modal_layer.rs +++ b/crates/workspace/src/modal_layer.rs @@ -2,7 +2,7 @@ use gpui::{ div, prelude::*, px, AnyView, DismissEvent, FocusHandle, ManagedView, Render, Subscription, View, ViewContext, WindowContext, }; -use ui::{h_stack, v_stack}; +use ui::{h_flex, v_flex}; pub trait ModalView: ManagedView { fn on_before_dismiss(&mut self, _: &mut ViewContext) -> bool { @@ -27,7 +27,7 @@ impl ModalViewHandle for View { pub struct ActiveModal { modal: Box, - _subscription: Subscription, + _subscriptions: [Subscription; 2], previous_focus_handle: Option, focus_handle: FocusHandle, } @@ -61,13 +61,19 @@ impl ModalLayer { where V: ModalView, { + let focus_handle = cx.focus_handle(); self.active_modal = Some(ActiveModal { modal: Box::new(new_modal.clone()), - _subscription: cx.subscribe(&new_modal, |this, _, _: &DismissEvent, cx| { - this.hide_modal(cx); - }), + _subscriptions: [ + cx.subscribe(&new_modal, |this, _, _: &DismissEvent, cx| { + this.hide_modal(cx); + }), + cx.on_focus_out(&focus_handle, |this, cx| { + this.hide_modal(cx); + }), + ], previous_focus_handle: cx.focused(), - focus_handle: cx.focus_handle(), + focus_handle, }); cx.focus_view(&new_modal); cx.notify(); @@ -101,10 +107,14 @@ impl ModalLayer { let active_modal = self.active_modal.as_ref()?; active_modal.modal.view().downcast::().ok() } + + pub fn has_active_modal(&self) -> bool { + self.active_modal.is_some() + } } impl Render for ModalLayer { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + fn render(&mut self, _: &mut ViewContext) -> impl IntoElement { let Some(active_modal) = &self.active_modal else { return div(); }; @@ -116,20 +126,14 @@ impl Render for ModalLayer { .left_0() .z_index(169) .child( - v_stack() + v_flex() .h(px(0.0)) .top_20() .flex() .flex_col() .items_center() .track_focus(&active_modal.focus_handle) - .child( - h_stack() - .on_mouse_down_out(cx.listener(|this, _, cx| { - this.hide_modal(cx); - })) - .child(active_modal.modal.view()), - ), + .child(h_flex().child(active_modal.modal.view())), ) } } diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 394772b9c4..6e7590c7d3 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -2,14 +2,12 @@ use crate::{Toast, Workspace}; use collections::HashMap; use gpui::{ AnyView, AppContext, AsyncWindowContext, DismissEvent, Entity, EntityId, EventEmitter, Render, - View, ViewContext, VisualContext, + Task, View, ViewContext, VisualContext, WindowContext, }; use std::{any::TypeId, ops::DerefMut}; pub fn init(cx: &mut AppContext) { cx.set_global(NotificationTracker::new()); - // todo!() - // simple_message_notification::init(cx); } pub trait Notification: EventEmitter + Render {} @@ -175,7 +173,7 @@ pub mod simple_message_notification { }; use std::sync::Arc; use ui::prelude::*; - use ui::{h_stack, v_stack, Button, Icon, IconElement, Label, StyledExt}; + use ui::{h_flex, v_flex, Button, Icon, IconName, Label, StyledExt}; pub struct MessageNotification { message: SharedString, @@ -220,17 +218,17 @@ pub mod simple_message_notification { impl Render for MessageNotification { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - v_stack() + v_flex() .elevation_3(cx) .p_4() .child( - h_stack() + h_flex() .justify_between() .child(div().max_w_80().child(Label::new(self.message.clone()))) .child( div() .id("cancel") - .child(IconElement::new(Icon::Close)) + .child(Icon::new(IconName::Close)) .cursor_pointer() .on_click(cx.listener(|this, _, cx| this.dismiss(cx))), ), @@ -247,105 +245,6 @@ pub mod simple_message_notification { })) } } - // todo!() - // impl View for MessageNotification { - // fn ui_name() -> &'static str { - // "MessageNotification" - // } - - // fn render(&mut self, cx: &mut gpui::ViewContext) -> gpui::AnyElement { - // let theme = theme::current(cx).clone(); - // let theme = &theme.simple_message_notification; - - // enum MessageNotificationTag {} - - // let click_message = self.click_message.clone(); - // let message = match &self.message { - // NotificationMessage::Text(text) => { - // Text::new(text.to_owned(), theme.message.text.clone()).into_any() - // } - // NotificationMessage::Element(e) => e(theme.message.text.clone(), cx), - // }; - // let on_click = self.on_click.clone(); - // let has_click_action = on_click.is_some(); - - // Flex::column() - // .with_child( - // Flex::row() - // .with_child( - // message - // .contained() - // .with_style(theme.message.container) - // .aligned() - // .top() - // .left() - // .flex(1., true), - // ) - // .with_child( - // MouseEventHandler::new::(0, cx, |state, _| { - // let style = theme.dismiss_button.style_for(state); - // Svg::new("icons/x.svg") - // .with_color(style.color) - // .constrained() - // .with_width(style.icon_width) - // .aligned() - // .contained() - // .with_style(style.container) - // .constrained() - // .with_width(style.button_width) - // .with_height(style.button_width) - // }) - // .with_padding(Padding::uniform(5.)) - // .on_click(MouseButton::Left, move |_, this, cx| { - // this.dismiss(&Default::default(), cx); - // }) - // .with_cursor_style(CursorStyle::PointingHand) - // .aligned() - // .constrained() - // .with_height(cx.font_cache().line_height(theme.message.text.font_size)) - // .aligned() - // .top() - // .flex_float(), - // ), - // ) - // .with_children({ - // click_message - // .map(|click_message| { - // MouseEventHandler::new::( - // 0, - // cx, - // |state, _| { - // let style = theme.action_message.style_for(state); - - // Flex::row() - // .with_child( - // Text::new(click_message, style.text.clone()) - // .contained() - // .with_style(style.container), - // ) - // .contained() - // }, - // ) - // .on_click(MouseButton::Left, move |_, this, cx| { - // if let Some(on_click) = on_click.as_ref() { - // on_click(cx); - // this.dismiss(&Default::default(), cx); - // } - // }) - // // Since we're not using a proper overlay, we have to capture these extra events - // .on_down(MouseButton::Left, |_, _, _| {}) - // .on_up(MouseButton::Left, |_, _, _| {}) - // .with_cursor_style(if has_click_action { - // CursorStyle::PointingHand - // } else { - // CursorStyle::Arrow - // }) - // }) - // .into_iter() - // }) - // .into_any() - // } - // } } pub trait NotifyResultExt { @@ -393,3 +292,18 @@ where } } } + +pub trait NotifyTaskExt { + fn detach_and_notify_err(self, cx: &mut WindowContext); +} + +impl NotifyTaskExt for Task> +where + E: std::fmt::Debug + 'static, + R: 'static, +{ + fn detach_and_notify_err(self, cx: &mut WindowContext) { + cx.spawn(|mut cx| async move { self.await.notify_async_err(&mut cx) }) + .detach(); + } +} diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 04a51fc655..17001ed76f 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -6,6 +6,7 @@ use crate::{ }; use anyhow::Result; use collections::{HashMap, HashSet, VecDeque}; +use futures::{stream::FuturesUnordered, StreamExt}; use gpui::{ actions, impl_actions, overlay, prelude::*, Action, AnchorCorner, AnyElement, AppContext, AsyncWindowContext, DismissEvent, Div, DragMoveEvent, EntityId, EventEmitter, ExternalPaths, @@ -31,10 +32,10 @@ use std::{ use theme::ThemeSettings; use ui::{ - prelude::*, right_click_menu, ButtonSize, Color, Icon, IconButton, IconSize, Indicator, Label, - Tab, TabBar, TabPosition, Tooltip, + prelude::*, right_click_menu, ButtonSize, Color, IconButton, IconButtonShape, IconName, + IconSize, Indicator, Label, Tab, TabBar, TabPosition, Tooltip, }; -use ui::{v_stack, ContextMenu}; +use ui::{v_flex, ContextMenu}; use util::{maybe, truncate_and_remove_front, ResultExt}; #[derive(PartialEq, Clone, Copy, Deserialize, Debug)] @@ -59,24 +60,6 @@ pub enum SaveIntent { #[derive(Clone, Deserialize, PartialEq, Debug)] pub struct ActivateItem(pub usize); -// #[derive(Clone, PartialEq)] -// pub struct CloseItemById { -// pub item_id: usize, -// pub pane: WeakView, -// } - -// #[derive(Clone, PartialEq)] -// pub struct CloseItemsToTheLeftById { -// pub item_id: usize, -// pub pane: WeakView, -// } - -// #[derive(Clone, PartialEq)] -// pub struct CloseItemsToTheRightById { -// pub item_id: usize, -// pub pane: WeakView, -// } - #[derive(Clone, PartialEq, Debug, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct CloseActiveItem { @@ -116,6 +99,7 @@ actions!( CloseItemsToTheLeft, CloseItemsToTheRight, GoBack, + DeploySearch, GoForward, ReopenClosedItem, SplitLeft, @@ -211,7 +195,7 @@ struct NavHistoryState { next_timestamp: Arc, } -#[derive(Copy, Clone)] +#[derive(Debug, Copy, Clone)] pub enum NavigationMode { Normal, GoingBack, @@ -236,93 +220,12 @@ pub struct NavigationEntry { #[derive(Clone)] pub struct DraggedTab { pub pane: View, + pub item: Box, pub ix: usize, - pub item_id: EntityId, pub detail: usize, pub is_active: bool, } -// pub struct DraggedItem { -// pub handle: Box, -// pub pane: WeakView, -// } - -// pub enum ReorderBehavior { -// None, -// MoveAfterActive, -// MoveToIndex(usize), -// } - -// #[derive(Debug, Clone, Copy, PartialEq, Eq)] -// enum TabBarContextMenuKind { -// New, -// Split, -// } - -// struct TabBarContextMenu { -// kind: TabBarContextMenuKind, -// handle: View, -// } - -// impl TabBarContextMenu { -// fn handle_if_kind(&self, kind: TabBarContextMenuKind) -> Option> { -// if self.kind == kind { -// return Some(self.handle.clone()); -// } -// None -// } -// } - -// #[allow(clippy::too_many_arguments)] -// fn nav_button)>( -// svg_path: &'static str, -// style: theme::Interactive, -// nav_button_height: f32, -// tooltip_style: TooltipStyle, -// enabled: bool, -// on_click: F, -// tooltip_action: A, -// action_name: &str, -// cx: &mut ViewContext, -// ) -> AnyElement { -// MouseEventHandler::new::(0, cx, |state, _| { -// let style = if enabled { -// style.style_for(state) -// } else { -// style.disabled_style() -// }; -// Svg::new(svg_path) -// .with_color(style.color) -// .constrained() -// .with_width(style.icon_width) -// .aligned() -// .contained() -// .with_style(style.container) -// .constrained() -// .with_width(style.button_width) -// .with_height(nav_button_height) -// .aligned() -// .top() -// }) -// .with_cursor_style(if enabled { -// CursorStyle::PointingHand -// } else { -// CursorStyle::default() -// }) -// .on_click(MouseButton::Left, move |_, toolbar, cx| { -// on_click(toolbar, cx) -// }) -// .with_tooltip::( -// 0, -// action_name.to_string(), -// Some(Box::new(tooltip_action)), -// tooltip_style, -// cx, -// ) -// .contained() -// .into_any_named("nav button") -// } - impl EventEmitter for Pane {} impl Pane { @@ -333,18 +236,12 @@ impl Pane { can_drop_predicate: Option bool + 'static>>, cx: &mut ViewContext, ) -> Self { - // todo!("context menu") - // let pane_view_id = cx.view_id(); - // let context_menu = cx.build_view(|cx| ContextMenu::new(pane_view_id, cx)); - // context_menu.update(cx, |menu, _| { - // menu.set_position_mode(OverlayPositionMode::Local) - // }); - // let focus_handle = cx.focus_handle(); let subscriptions = vec![ - cx.on_focus_in(&focus_handle, move |this, cx| this.focus_in(cx)), - cx.on_focus_out(&focus_handle, move |this, cx| this.focus_out(cx)), + cx.on_focus(&focus_handle, Pane::focus_in), + cx.on_focus_in(&focus_handle, Pane::focus_in), + cx.on_focus_out(&focus_handle, Pane::focus_out), ]; let handle = cx.view().downgrade(); @@ -370,21 +267,16 @@ impl Pane { split_item_menu: None, tab_bar_scroll_handle: ScrollHandle::new(), drag_split_direction: None, - // tab_bar_context_menu: TabBarContextMenu { - // kind: TabBarContextMenuKind::New, - // handle: context_menu, - // }, - // tab_context_menu: cx.build_view(|_| ContextMenu::new(pane_view_id, cx)), workspace, project, can_drop_predicate, custom_drop_handle: None, can_split: true, render_tab_bar_buttons: Rc::new(move |pane, cx| { - h_stack() + h_flex() .gap_2() .child( - IconButton::new("plus", Icon::Plus) + IconButton::new("plus", IconName::Plus) .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(cx.listener(|pane, _, cx| { @@ -406,7 +298,7 @@ impl Pane { el.child(Self::render_menu_overlay(new_item_menu)) }) .child( - IconButton::new("split", Icon::Split) + IconButton::new("split", IconName::Split) .icon_size(IconSize::Small) .icon_color(Color::Muted) .on_click(cx.listener(|pane, _, cx| { @@ -427,11 +319,11 @@ impl Pane { ) .child({ let zoomed = pane.is_zoomed(); - IconButton::new("toggle_zoom", Icon::Maximize) + IconButton::new("toggle_zoom", IconName::Maximize) .icon_size(IconSize::Small) .icon_color(Color::Muted) .selected(zoomed) - .selected_icon(Icon::Minimize) + .selected_icon(IconName::Minimize) .on_click(cx.listener(|pane, _, cx| { pane.toggle_zoom(&crate::ToggleZoom, cx); })) @@ -450,7 +342,6 @@ impl Pane { } pub fn has_focus(&self, cx: &WindowContext) -> bool { - // todo!(); // inline this manually self.focus_handle.contains_focused(cx) } @@ -1319,125 +1210,6 @@ impl Pane { cx.emit(Event::Split(direction)); } - // fn deploy_split_menu(&mut self, cx: &mut ViewContext) { - // self.tab_bar_context_menu.handle.update(cx, |menu, cx| { - // menu.toggle( - // Default::default(), - // AnchorCorner::TopRight, - // vec![ - // ContextMenuItem::action("Split Right", SplitRight), - // ContextMenuItem::action("Split Left", SplitLeft), - // ContextMenuItem::action("Split Up", SplitUp), - // ContextMenuItem::action("Split Down", SplitDown), - // ], - // cx, - // ); - // }); - - // self.tab_bar_context_menu.kind = TabBarContextMenuKind::Split; - // } - - // fn deploy_new_menu(&mut self, cx: &mut ViewContext) { - // self.tab_bar_context_menu.handle.update(cx, |menu, cx| { - // menu.toggle( - // Default::default(), - // AnchorCorner::TopRight, - // vec![ - // ContextMenuItem::action("New File", NewFile), - // ContextMenuItem::action("New Terminal", NewCenterTerminal), - // ContextMenuItem::action("New Search", NewSearch), - // ], - // cx, - // ); - // }); - - // self.tab_bar_context_menu.kind = TabBarContextMenuKind::New; - // } - - // fn deploy_tab_context_menu( - // &mut self, - // position: Vector2F, - // target_item_id: usize, - // cx: &mut ViewContext, - // ) { - // let active_item_id = self.items[self.active_item_index].id(); - // let is_active_item = target_item_id == active_item_id; - // let target_pane = cx.weak_handle(); - - // // The `CloseInactiveItems` action should really be called "CloseOthers" and the behaviour should be dynamically based on the tab the action is ran on. Currently, this is a weird action because you can run it on a non-active tab and it will close everything by the actual active tab - - // self.tab_context_menu.update(cx, |menu, cx| { - // menu.show( - // position, - // AnchorCorner::TopLeft, - // if is_active_item { - // vec![ - // ContextMenuItem::action( - // "Close Active Item", - // CloseActiveItem { save_intent: None }, - // ), - // ContextMenuItem::action("Close Inactive Items", CloseInactiveItems), - // ContextMenuItem::action("Close Clean Items", CloseCleanItems), - // ContextMenuItem::action("Close Items To The Left", CloseItemsToTheLeft), - // ContextMenuItem::action("Close Items To The Right", CloseItemsToTheRight), - // ContextMenuItem::action( - // "Close All Items", - // CloseAllItems { save_intent: None }, - // ), - // ] - // } else { - // // In the case of the user right clicking on a non-active tab, for some item-closing commands, we need to provide the id of the tab, for the others, we can reuse the existing command. - // vec![ - // ContextMenuItem::handler("Close Inactive Item", { - // let pane = target_pane.clone(); - // move |cx| { - // if let Some(pane) = pane.upgrade(cx) { - // pane.update(cx, |pane, cx| { - // pane.close_item_by_id( - // target_item_id, - // SaveIntent::Close, - // cx, - // ) - // .detach_and_log_err(cx); - // }) - // } - // } - // }), - // ContextMenuItem::action("Close Inactive Items", CloseInactiveItems), - // ContextMenuItem::action("Close Clean Items", CloseCleanItems), - // ContextMenuItem::handler("Close Items To The Left", { - // let pane = target_pane.clone(); - // move |cx| { - // if let Some(pane) = pane.upgrade(cx) { - // pane.update(cx, |pane, cx| { - // pane.close_items_to_the_left_by_id(target_item_id, cx) - // .detach_and_log_err(cx); - // }) - // } - // } - // }), - // ContextMenuItem::handler("Close Items To The Right", { - // let pane = target_pane.clone(); - // move |cx| { - // if let Some(pane) = pane.upgrade(cx) { - // pane.update(cx, |pane, cx| { - // pane.close_items_to_the_right_by_id(target_item_id, cx) - // .detach_and_log_err(cx); - // }) - // } - // } - // }), - // ContextMenuItem::action( - // "Close All Items", - // CloseAllItems { save_intent: None }, - // ), - // ] - // }, - // cx, - // ); - // }); - // } - pub fn toolbar(&self) -> &View { &self.toolbar } @@ -1540,9 +1312,9 @@ impl Pane { ) .on_drag( DraggedTab { + item: item.boxed_clone(), pane: cx.view().clone(), detail, - item_id, is_active, ix, }, @@ -1570,7 +1342,8 @@ impl Pane { }) .start_slot::(indicator) .end_slot( - IconButton::new("close tab", Icon::Close) + IconButton::new("close tab", IconName::Close) + .shape(IconButtonShape::Square) .icon_color(Color::Muted) .size(ButtonSize::None) .icon_size(IconSize::XSmall) @@ -1673,10 +1446,10 @@ impl Pane { .track_scroll(self.tab_bar_scroll_handle.clone()) .when(self.display_nav_history_buttons, |tab_bar| { tab_bar.start_child( - h_stack() + h_flex() .gap_2() .child( - IconButton::new("navigate_backward", Icon::ArrowLeft) + IconButton::new("navigate_backward", IconName::ArrowLeft) .icon_size(IconSize::Small) .on_click({ let view = cx.view().clone(); @@ -1686,11 +1459,11 @@ impl Pane { .tooltip(|cx| Tooltip::for_action("Go Back", &GoBack, cx)), ) .child( - IconButton::new("navigate_forward", Icon::ArrowRight) + IconButton::new("navigate_forward", IconName::ArrowRight) .icon_size(IconSize::Small) .on_click({ let view = cx.view().clone(); - move |_, cx| view.update(cx, Self::navigate_backward) + move |_, cx| view.update(cx, Self::navigate_forward) }) .disabled(!self.can_navigate_forward()) .tooltip(|cx| Tooltip::for_action("Go Forward", &GoForward, cx)), @@ -1713,7 +1486,7 @@ impl Pane { .child( div() .min_w_6() - // HACK: This empty child is currently necessary to force the drop traget to appear + // HACK: This empty child is currently necessary to force the drop target to appear // despite us setting a min width above. .child("") .h_full() @@ -1832,7 +1605,7 @@ impl Pane { } let mut to_pane = cx.view().clone(); let split_direction = self.drag_split_direction; - let item_id = dragged_tab.item_id; + let item_id = dragged_tab.item.item_id(); let from_pane = dragged_tab.pane.clone(); self.workspace .update(cx, |_, cx| { @@ -1890,23 +1663,46 @@ impl Pane { } } let mut to_pane = cx.view().clone(); - let split_direction = self.drag_split_direction; + let mut split_direction = self.drag_split_direction; let paths = paths.paths().to_vec(); self.workspace - .update(cx, |_, cx| { - cx.defer(move |workspace, cx| { - if let Some(split_direction) = split_direction { - to_pane = workspace.split_pane(to_pane, split_direction, cx); + .update(cx, |workspace, cx| { + let fs = Arc::clone(workspace.project().read(cx).fs()); + cx.spawn(|workspace, mut cx| async move { + let mut is_file_checks = FuturesUnordered::new(); + for path in &paths { + is_file_checks.push(fs.is_file(path)) } - workspace - .open_paths( - paths, - OpenVisible::OnlyDirectories, - Some(to_pane.downgrade()), - cx, - ) - .detach(); - }); + let mut has_files_to_open = false; + while let Some(is_file) = is_file_checks.next().await { + if is_file { + has_files_to_open = true; + break; + } + } + drop(is_file_checks); + if !has_files_to_open { + split_direction = None; + } + + if let Some(open_task) = workspace + .update(&mut cx, |workspace, cx| { + if let Some(split_direction) = split_direction { + to_pane = workspace.split_pane(to_pane, split_direction, cx); + } + workspace.open_paths( + paths, + OpenVisible::OnlyDirectories, + Some(to_pane.downgrade()), + cx, + ) + }) + .ok() + { + let _opened_items: Vec<_> = open_task.await; + } + }) + .detach(); }) .log_err(); } @@ -1924,7 +1720,7 @@ impl FocusableView for Pane { impl Render for Pane { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - v_stack() + v_flex() .key_context("Pane") .track_focus(&self.focus_handle) .size_full() @@ -2809,8 +2605,7 @@ mod tests { impl Render for DraggedTab { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone(); - let item = &self.pane.read(cx).items[self.ix]; - let label = item.tab_content(Some(self.detail), false, cx); + let label = self.item.tab_content(Some(self.detail), false, cx); Tab::new("") .selected(self.is_active) .child(label) diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 68fd3c084f..476592f374 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -3,16 +3,16 @@ use anyhow::{anyhow, Result}; use call::{ActiveCall, ParticipantLocation}; use collections::HashMap; use gpui::{ - point, size, AnyWeakView, Axis, Bounds, Entity as _, IntoElement, Model, Pixels, Point, View, - ViewContext, + point, size, AnyView, AnyWeakView, Axis, Bounds, IntoElement, Model, MouseButton, Pixels, + Point, View, ViewContext, }; use parking_lot::Mutex; use project::Project; use serde::Deserialize; use std::sync::Arc; -use ui::{prelude::*, Button}; +use ui::prelude::*; -const HANDLE_HITBOX_SIZE: f32 = 10.0; //todo!(change this back to 4) +pub const HANDLE_HITBOX_SIZE: f32 = 4.0; const HORIZONTAL_MIN_SIZE: f32 = 80.; const VERTICAL_MIN_SIZE: f32 = 100.; @@ -183,6 +183,7 @@ impl Member { let mut leader_border = None; let mut leader_status_box = None; + let mut leader_join_data = None; if let Some(leader) = &leader { let mut leader_color = cx .theme() @@ -199,44 +200,21 @@ impl Member { if Some(leader_project_id) == project.read(cx).remote_id() { None } else { - let leader_user = leader.user.clone(); - let leader_user_id = leader.user.id; - Some( - Button::new( - ("leader-status", pane.entity_id()), - format!( - "Follow {} to their active project", - leader_user.github_login, - ), - ) - .on_click(cx.listener( - move |this, _, cx| { - crate::join_remote_project( - leader_project_id, - leader_user_id, - this.app_state().clone(), - cx, - ) - .detach_and_log_err(cx); - }, - )), - ) + leader_join_data = Some((leader_project_id, leader.user.id)); + Some(Label::new(format!( + "Follow {} to their active project", + leader.user.github_login, + ))) } } - ParticipantLocation::UnsharedProject => Some(Button::new( - ("leader-status", pane.entity_id()), - format!( - "{} is viewing an unshared Zed project", - leader.user.github_login - ), - )), - ParticipantLocation::External => Some(Button::new( - ("leader-status", pane.entity_id()), - format!( - "{} is viewing a window outside of Zed", - leader.user.github_login - ), - )), + ParticipantLocation::UnsharedProject => Some(Label::new(format!( + "{} is viewing an unshared Zed project", + leader.user.github_login + ))), + ParticipantLocation::External => Some(Label::new(format!( + "{} is viewing a window outside of Zed", + leader.user.github_login + ))), }; } @@ -244,7 +222,7 @@ impl Member { .relative() .flex_1() .size_full() - .child(pane.clone()) + .child(AnyView::from(pane.clone()).cached()) .when_some(leader_border, |this, color| { this.child( div() @@ -263,20 +241,30 @@ impl Member { .w_96() .bottom_3() .right_3() + .elevation_2(cx) + .p_1() .z_index(1) - .child(status_box), + .child(status_box) + .when_some( + leader_join_data, + |this, (leader_project_id, leader_user_id)| { + this.cursor_pointer().on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, cx| { + crate::join_remote_project( + leader_project_id, + leader_user_id, + this.app_state().clone(), + cx, + ) + .detach_and_log_err(cx); + }), + ) + }, + ), ) }) .into_any() - - // let el = div() - // .flex() - // .flex_1() - // .gap_px() - // .w_full() - // .h_full() - // .bg(cx.theme().colors().editor) - // .children(); } Member::Axis(axis) => axis .render( @@ -707,11 +695,11 @@ mod element { proposed_current_pixel_change -= current_pixel_change; } - // todo!(schedule serialize) workspace .update(cx, |this, cx| this.schedule_serialize(cx)) .log_err(); - cx.notify(); + cx.stop_propagation(); + cx.refresh(); } fn push_handle( @@ -767,8 +755,10 @@ mod element { workspace .update(cx, |this, cx| this.schedule_serialize(cx)) .log_err(); - cx.notify(); + + cx.refresh(); } + cx.stop_propagation(); } } }); diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index d03c7b3d0f..56aa6e4322 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -1,5 +1,3 @@ -//#![allow(dead_code)] - pub mod model; use std::path::Path; diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index 59202cbbaf..e1f93f31cb 100644 --- a/crates/workspace/src/searchable.rs +++ b/crates/workspace/src/searchable.rs @@ -1,8 +1,8 @@ use std::{any::Any, sync::Arc}; use gpui::{ - AnyView, AppContext, EventEmitter, Subscription, Task, View, ViewContext, WeakView, - WindowContext, + AnyView, AnyWeakView, AppContext, EventEmitter, Subscription, Task, View, ViewContext, + WeakView, WindowContext, }; use project::search::SearchQuery; @@ -127,7 +127,6 @@ pub trait SearchableItemHandle: ItemHandle { ) -> Option; } -// todo!("here is where we need to use AnyWeakView"); impl SearchableItemHandle for View { fn downgrade(&self) -> Box { Box::new(self.downgrade()) @@ -249,7 +248,7 @@ impl Eq for Box {} pub trait WeakSearchableItemHandle: WeakItemHandle { fn upgrade(&self, cx: &AppContext) -> Option>; - // fn into_any(self) -> AnyWeakView; + fn into_any(self) -> AnyWeakView; } impl WeakSearchableItemHandle for WeakView { @@ -257,9 +256,9 @@ impl WeakSearchableItemHandle for WeakView { Some(Box::new(self.upgrade()?)) } - // fn into_any(self) -> AnyView { - // self.into_any() - // } + fn into_any(self) -> AnyWeakView { + self.into() + } } impl PartialEq for Box { diff --git a/crates/workspace/src/shared_screen.rs b/crates/workspace/src/shared_screen.rs index edfabed60d..bfc1602274 100644 --- a/crates/workspace/src/shared_screen.rs +++ b/crates/workspace/src/shared_screen.rs @@ -12,7 +12,7 @@ use gpui::{ WindowContext, }; use std::sync::{Arc, Weak}; -use ui::{h_stack, prelude::*, Icon, IconElement, Label}; +use ui::{h_flex, prelude::*, Icon, IconName, Label}; pub enum Event { Close, @@ -98,9 +98,9 @@ impl Item for SharedScreen { selected: bool, _: &WindowContext<'_>, ) -> gpui::AnyElement { - h_stack() + h_flex() .gap_1() - .child(IconElement::new(Icon::Screen)) + .child(Icon::new(IconName::Screen)) .child( Label::new(format!("{}'s screen", self.user.github_login)).color(if selected { Color::Default @@ -111,6 +111,10 @@ impl Item for SharedScreen { .into_any() } + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + fn set_nav_history(&mut self, history: ItemNavHistory, _: &mut ViewContext) { self.nav_history = Some(history); } diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index bfa1a8f8ba..34a1412533 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -4,7 +4,7 @@ use gpui::{ WindowContext, }; use std::any::TypeId; -use ui::{h_stack, prelude::*}; +use ui::{h_flex, prelude::*}; use util::ResultExt; pub trait StatusItemView: Render { @@ -50,14 +50,14 @@ impl Render for StatusBar { impl StatusBar { fn render_left_tools(&self, _: &mut ViewContext) -> impl IntoElement { - h_stack() + h_flex() .items_center() .gap_2() .children(self.left_items.iter().map(|item| item.to_any())) } fn render_right_tools(&self, _: &mut ViewContext) -> impl IntoElement { - h_stack() + h_flex() .items_center() .gap_2() .children(self.right_items.iter().rev().map(|item| item.to_any())) diff --git a/crates/workspace/src/toolbar.rs b/crates/workspace/src/toolbar.rs index dc17cd3c19..3d5df3294e 100644 --- a/crates/workspace/src/toolbar.rs +++ b/crates/workspace/src/toolbar.rs @@ -4,7 +4,7 @@ use gpui::{ WindowContext, }; use ui::prelude::*; -use ui::{h_stack, v_stack}; +use ui::{h_flex, v_flex}; pub enum ToolbarItemEvent { ChangeLocation(ToolbarItemLocation), @@ -103,18 +103,18 @@ impl Render for Toolbar { let has_left_items = self.left_items().count() > 0; let has_right_items = self.right_items().count() > 0; - v_stack() + v_flex() .p_2() .when(has_left_items || has_right_items, |this| this.gap_2()) .border_b() .border_color(cx.theme().colors().border_variant) .bg(cx.theme().colors().toolbar_background) .child( - h_stack() + h_flex() .justify_between() .when(has_left_items, |this| { this.child( - h_stack() + h_flex() .flex_1() .justify_start() .children(self.left_items().map(|item| item.to_any())), @@ -122,7 +122,7 @@ impl Render for Toolbar { }) .when(has_right_items, |this| { this.child( - h_stack() + h_flex() .flex_1() .justify_end() .children(self.right_items().map(|item| item.to_any())), @@ -133,82 +133,6 @@ impl Render for Toolbar { } } -// todo!() -// impl View for Toolbar { -// fn ui_name() -> &'static str { -// "Toolbar" -// } - -// fn render(&mut self, cx: &mut ViewContext) -> AnyElement { -// let theme = &theme::current(cx).workspace.toolbar; - -// let mut primary_left_items = Vec::new(); -// let mut primary_right_items = Vec::new(); -// let mut secondary_item = None; -// let spacing = theme.item_spacing; -// let mut primary_items_row_count = 1; - -// for (item, position) in &self.items { -// match *position { -// ToolbarItemLocation::Hidden => {} - -// ToolbarItemLocation::PrimaryLeft { flex } => { -// primary_items_row_count = primary_items_row_count.max(item.row_count(cx)); -// let left_item = ChildView::new(item.as_any(), cx).aligned(); -// if let Some((flex, expanded)) = flex { -// primary_left_items.push(left_item.flex(flex, expanded).into_any()); -// } else { -// primary_left_items.push(left_item.into_any()); -// } -// } - -// ToolbarItemLocation::PrimaryRight { flex } => { -// primary_items_row_count = primary_items_row_count.max(item.row_count(cx)); -// let right_item = ChildView::new(item.as_any(), cx).aligned().flex_float(); -// if let Some((flex, expanded)) = flex { -// primary_right_items.push(right_item.flex(flex, expanded).into_any()); -// } else { -// primary_right_items.push(right_item.into_any()); -// } -// } - -// ToolbarItemLocation::Secondary => { -// secondary_item = Some( -// ChildView::new(item.as_any(), cx) -// .constrained() -// .with_height(theme.height * item.row_count(cx) as f32) -// .into_any(), -// ); -// } -// } -// } - -// let container_style = theme.container; -// let height = theme.height * primary_items_row_count as f32; - -// let mut primary_items = Flex::row().with_spacing(spacing); -// primary_items.extend(primary_left_items); -// primary_items.extend(primary_right_items); - -// let mut toolbar = Flex::column(); -// if !primary_items.is_empty() { -// toolbar.add_child(primary_items.constrained().with_height(height)); -// } -// if let Some(secondary_item) = secondary_item { -// toolbar.add_child(secondary_item); -// } - -// if toolbar.is_empty() { -// toolbar.into_any_named("toolbar") -// } else { -// toolbar -// .contained() -// .with_style(container_style) -// .into_any_named("toolbar") -// } -// } -// } - impl Toolbar { pub fn new() -> Self { Self { @@ -312,10 +236,3 @@ impl ToolbarItemViewHandle for View { self.read(cx).row_count(cx) } } - -// todo!() -// impl From<&dyn ToolbarItemViewHandle> for AnyViewHandle { -// fn from(val: &dyn ToolbarItemViewHandle) -> Self { -// val.as_any().clone() -// } -// } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index ad02637ae3..20c8bfc94a 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -25,7 +25,7 @@ use futures::{ Future, FutureExt, StreamExt, }; use gpui::{ - actions, canvas, div, impl_actions, point, size, Action, AnyElement, AnyModel, AnyView, + actions, canvas, div, impl_actions, point, px, size, Action, AnyElement, AnyModel, AnyView, AnyWeakView, AppContext, AsyncAppContext, AsyncWindowContext, BorrowWindow, Bounds, Context, Div, DragMoveEvent, Element, Entity, EntityId, EventEmitter, FocusHandle, FocusableView, GlobalPixels, InteractiveElement, IntoElement, KeyContext, LayoutId, ManagedView, Model, @@ -108,7 +108,6 @@ actions!( NewCenterTerminal, ToggleTerminalFocus, NewSearch, - DeploySearch, Feedback, Restart, Welcome, @@ -512,6 +511,11 @@ impl Workspace { project::Event::DisconnectedFromHost => { this.update_window_edited(cx); + let panes_to_unfollow: Vec> = + this.follower_states.keys().map(|k| k.clone()).collect(); + for pane in panes_to_unfollow { + this.unfollow(&pane, cx); + } cx.disable_focus(); } @@ -537,7 +541,7 @@ impl Workspace { }) .detach(); - cx.on_blur_window(|this, cx| { + cx.on_focus_lost(|this, cx| { let focus_handle = this.focus_handle(cx); cx.focus(&focus_handle); }) @@ -612,8 +616,8 @@ impl Workspace { let modal_layer = cx.new_view(|_| ModalLayer::new()); let mut active_call = None; - if cx.has_global::>() { - let call = cx.global::>().clone(); + if let Some(call) = cx.try_global::>() { + let call = call.clone(); let mut subscriptions = Vec::new(); subscriptions.push(cx.subscribe(&call, Self::on_active_call_event)); active_call = Some((call, subscriptions)); @@ -672,7 +676,7 @@ impl Workspace { // ); // this.show_notification(1, cx, |cx| { - // cx.build_view(|_cx| { + // cx.new_view(|_cx| { // simple_message_notification::MessageNotification::new(format!("Error:")) // .with_click_message("click here because!") // }) @@ -852,6 +856,10 @@ impl Workspace { &self.right_dock } + pub fn is_edited(&self) -> bool { + self.window_edited + } + pub fn add_panel(&mut self, panel: View, cx: &mut ViewContext) { let dock = match panel.position(cx) { DockPosition::Left => &self.left_dock, @@ -943,10 +951,8 @@ impl Workspace { cx: &mut ViewContext, ) -> Task> { let to_load = if let Some(pane) = pane.upgrade() { - // todo!("focus") - // cx.focus(&pane); - pane.update(cx, |pane, cx| { + pane.focus(cx); loop { // Retrieve the weak item handle from the history. let entry = pane.nav_history_mut().pop(mode, cx)?; @@ -1145,7 +1151,6 @@ impl Workspace { quitting: bool, cx: &mut ViewContext, ) -> Task> { - //todo!(saveing) let active_call = self.active_call().cloned(); let window = cx.window_handle(); @@ -1265,7 +1270,9 @@ impl Workspace { } pub fn open(&mut self, _: &Open, cx: &mut ViewContext) { - self.client().telemetry().report_app_event("open project"); + self.client() + .telemetry() + .report_app_event("open project".to_string()); let paths = cx.prompt_for_paths(PathPromptOptions { files: true, directories: true, @@ -1631,8 +1638,7 @@ impl Workspace { }); } - // todo!("focus") - // cx.focus_self(); + cx.focus_self(); cx.notify(); self.serialize_workspace(cx); } @@ -1697,27 +1703,6 @@ impl Workspace { None } - // todo!("implement zoom") - #[allow(unused)] - fn zoom_out(&mut self, cx: &mut ViewContext) { - for pane in &self.panes { - pane.update(cx, |pane, cx| pane.set_zoomed(false, cx)); - } - - self.left_dock.update(cx, |dock, cx| dock.zoom_out(cx)); - self.bottom_dock.update(cx, |dock, cx| dock.zoom_out(cx)); - self.right_dock.update(cx, |dock, cx| dock.zoom_out(cx)); - self.zoomed = None; - self.zoomed_position = None; - - cx.notify(); - } - - // #[cfg(any(test, feature = "test-support"))] - // pub fn zoomed_view(&self, cx: &AppContext) -> Option { - // self.zoomed.and_then(|view| view.upgrade(cx)) - // } - fn dismiss_zoomed_items_to_reveal( &mut self, dock_to_reveal: Option, @@ -1792,6 +1777,12 @@ impl Workspace { } pub fn add_item(&mut self, item: Box, cx: &mut ViewContext) { + if let Some(text) = item.telemetry_event_text(cx) { + self.client() + .telemetry() + .report_app_event(format!("{}: open", text)); + } + self.active_pane .update(cx, |pane, cx| pane.add_item(item, true, true, None, cx)); } @@ -2080,7 +2071,7 @@ impl Workspace { _ => bounding_box.center(), }; - let distance_to_next = 8.; //todo(pane dividers styling) + let distance_to_next = pane_group::HANDLE_HITBOX_SIZE; let target = match direction { SplitDirection::Left => { @@ -2266,17 +2257,16 @@ impl Workspace { destination_index: usize, cx: &mut ViewContext, ) { - let item_to_move = source + let Some((item_ix, item_handle)) = source .read(cx) .items() .enumerate() - .find(|(_, item_handle)| item_handle.item_id() == item_id_to_move); - - if item_to_move.is_none() { - log::warn!("Tried to move item handle which was not in `from` pane. Maybe tab was closed during drop"); + .find(|(_, item_handle)| item_handle.item_id() == item_id_to_move) + else { + // Tab was closed during drag return; - } - let (item_ix, item_handle) = item_to_move.unwrap(); + }; + let item_handle = item_handle.clone(); if source != destination { @@ -2624,11 +2614,20 @@ impl Workspace { let cx = &cx; move |item| { let item = item.to_followable_item_handle(cx)?; - if (project_id.is_none() || project_id != follower_project_id) - && item.is_project_item(cx) + + // If the item belongs to a particular project, then it should + // only be included if this project is shared, and the follower + // is in the project. + // + // Some items, like channel notes, do not belong to a particular + // project, so they should be included regardless of whether the + // current project is shared, or what project the follower is in. + if item.is_project_item(cx) + && (project_id.is_none() || project_id != follower_project_id) { return None; } + let id = item.remote_id(client, cx)?.to_proto(); let variant = item.to_state_proto(cx)?; Some(proto::View { @@ -2806,8 +2805,12 @@ impl Workspace { update: proto::update_followers::Variant, cx: &mut WindowContext, ) -> Option<()> { + // If this update only applies to for followers in the current project, + // then skip it unless this project is shared. If it applies to all + // followers, regardless of project, then set `project_id` to none, + // indicating that it goes to all followers. let project_id = if project_only { - self.project.read(cx).remote_id() + Some(self.project.read(cx).remote_id()?) } else { None }; @@ -2992,7 +2995,6 @@ impl Workspace { cx.notify(); } - #[allow(unused)] fn schedule_serialize(&mut self, cx: &mut ViewContext) { self._schedule_serialize = Some(cx.spawn(|this, mut cx| async move { cx.background_executor() @@ -3328,36 +3330,6 @@ impl Workspace { workspace } - // fn render_dock(&self, position: DockPosition, cx: &WindowContext) -> Option> { - // let dock = match position { - // DockPosition::Left => &self.left_dock, - // DockPosition::Right => &self.right_dock, - // DockPosition::Bottom => &self.bottom_dock, - // }; - // let active_panel = dock.read(cx).visible_panel()?; - // let element = if Some(active_panel.id()) == self.zoomed.as_ref().map(|zoomed| zoomed.id()) { - // dock.read(cx).render_placeholder(cx) - // } else { - // ChildView::new(dock, cx).into_any() - // }; - - // Some( - // element - // .constrained() - // .dynamically(move |constraint, _, cx| match position { - // DockPosition::Left | DockPosition::Right => SizeConstraint::new( - // Vector2F::new(20., constraint.min.y()), - // Vector2F::new(cx.window_size().x() * 0.8, constraint.max.y()), - // ), - // DockPosition::Bottom => SizeConstraint::new( - // Vector2F::new(constraint.min.x(), 20.), - // Vector2F::new(constraint.max.x(), cx.window_size().y() * 0.8), - // ), - // }) - // .into_any(), - // ) - // } - // } pub fn register_action( &mut self, callback: impl Fn(&mut Self, &A, &mut ViewContext) + 'static, @@ -3386,6 +3358,10 @@ impl Workspace { div } + pub fn has_active_modal(&self, cx: &WindowContext<'_>) -> bool { + self.modal_layer.read(cx).has_active_modal() + } + pub fn active_modal( &mut self, cx: &ViewContext, @@ -3710,11 +3686,8 @@ impl WorkspaceStore { update: proto::update_followers::Variant, cx: &AppContext, ) -> Option<()> { - if !cx.has_global::>() { - return None; - } - - let room_id = ActiveCall::global(cx).read(cx).room()?.read(cx).id(); + let active_call = cx.try_global::>()?; + let room_id = active_call.read(cx).room()?.read(cx).id(); let follower_ids: Vec<_> = self .followers .iter() @@ -4326,6 +4299,10 @@ fn parse_pixel_size_env_var(value: &str) -> Option> { Some(size((width as f64).into(), (height as f64).into())) } +pub fn titlebar_height(cx: &mut WindowContext) -> Pixels { + (1.75 * cx.rem_size()).max(px(32.)) +} + struct DisconnectedOverlay; impl Element for DisconnectedOverlay { @@ -4342,7 +4319,7 @@ impl Element for DisconnectedOverlay { .bg(background) .absolute() .left_0() - .top_0() + .top(titlebar_height(cx)) .size_full() .flex() .items_center() @@ -4381,12 +4358,15 @@ mod tests { use std::{cell::RefCell, rc::Rc}; use super::*; - use crate::item::{ - test::{TestItem, TestProjectItem}, - ItemEvent, + use crate::{ + dock::{test::TestPanel, PanelEvent}, + item::{ + test::{TestItem, TestProjectItem}, + ItemEvent, + }, }; use fs::FakeFs; - use gpui::TestAppContext; + use gpui::{px, DismissEvent, TestAppContext, VisualTestContext}; use project::{Project, ProjectEntryId}; use serde_json::json; use settings::SettingsStore; @@ -4953,362 +4933,405 @@ mod tests { }); } - // #[gpui::test] - // async fn test_toggle_docks_and_panels(cx: &mut gpui::TestAppContext) { - // init_test(cx); - // let fs = FakeFs::new(cx.executor()); + #[gpui::test] + async fn test_toggle_docks_and_panels(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); - // let project = Project::test(fs, [], cx).await; - // let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); - // let panel = workspace.update(cx, |workspace, cx| { - // let panel = cx.build_view(|cx| TestPanel::new(DockPosition::Right, cx)); - // workspace.add_panel(panel.clone(), cx); + let panel = workspace.update(cx, |workspace, cx| { + let panel = cx.new_view(|cx| TestPanel::new(DockPosition::Right, cx)); + workspace.add_panel(panel.clone(), cx); - // workspace - // .right_dock() - // .update(cx, |right_dock, cx| right_dock.set_open(true, cx)); + workspace + .right_dock() + .update(cx, |right_dock, cx| right_dock.set_open(true, cx)); - // panel - // }); + panel + }); - // let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); - // pane.update(cx, |pane, cx| { - // let item = cx.build_view(|cx| TestItem::new(cx)); - // pane.add_item(Box::new(item), true, true, None, cx); - // }); + let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); + pane.update(cx, |pane, cx| { + let item = cx.new_view(|cx| TestItem::new(cx)); + pane.add_item(Box::new(item), true, true, None, cx); + }); - // // Transfer focus from center to panel - // workspace.update(cx, |workspace, cx| { - // workspace.toggle_panel_focus::(cx); - // }); + // Transfer focus from center to panel + workspace.update(cx, |workspace, cx| { + workspace.toggle_panel_focus::(cx); + }); - // workspace.update(cx, |workspace, cx| { - // assert!(workspace.right_dock().read(cx).is_open()); - // assert!(!panel.is_zoomed(cx)); - // assert!(panel.read(cx).focus_handle(cx).contains_focused(cx)); - // }); + workspace.update(cx, |workspace, cx| { + assert!(workspace.right_dock().read(cx).is_open()); + assert!(!panel.is_zoomed(cx)); + assert!(panel.read(cx).focus_handle(cx).contains_focused(cx)); + }); - // // Transfer focus from panel to center - // workspace.update(cx, |workspace, cx| { - // workspace.toggle_panel_focus::(cx); - // }); + // Transfer focus from panel to center + workspace.update(cx, |workspace, cx| { + workspace.toggle_panel_focus::(cx); + }); - // workspace.update(cx, |workspace, cx| { - // assert!(workspace.right_dock().read(cx).is_open()); - // assert!(!panel.is_zoomed(cx)); - // assert!(!panel.read(cx).focus_handle(cx).contains_focused(cx)); - // }); + workspace.update(cx, |workspace, cx| { + assert!(workspace.right_dock().read(cx).is_open()); + assert!(!panel.is_zoomed(cx)); + assert!(!panel.read(cx).focus_handle(cx).contains_focused(cx)); + }); - // // Close the dock - // workspace.update(cx, |workspace, cx| { - // workspace.toggle_dock(DockPosition::Right, cx); - // }); + // Close the dock + workspace.update(cx, |workspace, cx| { + workspace.toggle_dock(DockPosition::Right, cx); + }); - // workspace.update(cx, |workspace, cx| { - // assert!(!workspace.right_dock().read(cx).is_open()); - // assert!(!panel.is_zoomed(cx)); - // assert!(!panel.read(cx).focus_handle(cx).contains_focused(cx)); - // }); + workspace.update(cx, |workspace, cx| { + assert!(!workspace.right_dock().read(cx).is_open()); + assert!(!panel.is_zoomed(cx)); + assert!(!panel.read(cx).focus_handle(cx).contains_focused(cx)); + }); - // // Open the dock - // workspace.update(cx, |workspace, cx| { - // workspace.toggle_dock(DockPosition::Right, cx); - // }); + // Open the dock + workspace.update(cx, |workspace, cx| { + workspace.toggle_dock(DockPosition::Right, cx); + }); - // workspace.update(cx, |workspace, cx| { - // assert!(workspace.right_dock().read(cx).is_open()); - // assert!(!panel.is_zoomed(cx)); - // assert!(panel.read(cx).focus_handle(cx).contains_focused(cx)); - // }); + workspace.update(cx, |workspace, cx| { + assert!(workspace.right_dock().read(cx).is_open()); + assert!(!panel.is_zoomed(cx)); + assert!(panel.read(cx).focus_handle(cx).contains_focused(cx)); + }); - // // Focus and zoom panel - // panel.update(cx, |panel, cx| { - // cx.focus_self(); - // panel.set_zoomed(true, cx) - // }); + // Focus and zoom panel + panel.update(cx, |panel, cx| { + cx.focus_self(); + panel.set_zoomed(true, cx) + }); - // workspace.update(cx, |workspace, cx| { - // assert!(workspace.right_dock().read(cx).is_open()); - // assert!(panel.is_zoomed(cx)); - // assert!(panel.read(cx).focus_handle(cx).contains_focused(cx)); - // }); + workspace.update(cx, |workspace, cx| { + assert!(workspace.right_dock().read(cx).is_open()); + assert!(panel.is_zoomed(cx)); + assert!(panel.read(cx).focus_handle(cx).contains_focused(cx)); + }); - // // Transfer focus to the center closes the dock - // workspace.update(cx, |workspace, cx| { - // workspace.toggle_panel_focus::(cx); - // }); + // Transfer focus to the center closes the dock + workspace.update(cx, |workspace, cx| { + workspace.toggle_panel_focus::(cx); + }); - // workspace.update(cx, |workspace, cx| { - // assert!(!workspace.right_dock().read(cx).is_open()); - // assert!(panel.is_zoomed(cx)); - // assert!(!panel.read(cx).focus_handle(cx).contains_focused(cx)); - // }); + workspace.update(cx, |workspace, cx| { + assert!(!workspace.right_dock().read(cx).is_open()); + assert!(panel.is_zoomed(cx)); + assert!(!panel.read(cx).focus_handle(cx).contains_focused(cx)); + }); - // // Transferring focus back to the panel keeps it zoomed - // workspace.update(cx, |workspace, cx| { - // workspace.toggle_panel_focus::(cx); - // }); + // Transferring focus back to the panel keeps it zoomed + workspace.update(cx, |workspace, cx| { + workspace.toggle_panel_focus::(cx); + }); - // workspace.update(cx, |workspace, cx| { - // assert!(workspace.right_dock().read(cx).is_open()); - // assert!(panel.is_zoomed(cx)); - // assert!(panel.read(cx).focus_handle(cx).contains_focused(cx)); - // }); + workspace.update(cx, |workspace, cx| { + assert!(workspace.right_dock().read(cx).is_open()); + assert!(panel.is_zoomed(cx)); + assert!(panel.read(cx).focus_handle(cx).contains_focused(cx)); + }); - // // Close the dock while it is zoomed - // workspace.update(cx, |workspace, cx| { - // workspace.toggle_dock(DockPosition::Right, cx) - // }); + // Close the dock while it is zoomed + workspace.update(cx, |workspace, cx| { + workspace.toggle_dock(DockPosition::Right, cx) + }); - // workspace.update(cx, |workspace, cx| { - // assert!(!workspace.right_dock().read(cx).is_open()); - // assert!(panel.is_zoomed(cx)); - // assert!(workspace.zoomed.is_none()); - // assert!(!panel.read(cx).focus_handle(cx).contains_focused(cx)); - // }); + workspace.update(cx, |workspace, cx| { + assert!(!workspace.right_dock().read(cx).is_open()); + assert!(panel.is_zoomed(cx)); + assert!(workspace.zoomed.is_none()); + assert!(!panel.read(cx).focus_handle(cx).contains_focused(cx)); + }); - // // Opening the dock, when it's zoomed, retains focus - // workspace.update(cx, |workspace, cx| { - // workspace.toggle_dock(DockPosition::Right, cx) - // }); + // Opening the dock, when it's zoomed, retains focus + workspace.update(cx, |workspace, cx| { + workspace.toggle_dock(DockPosition::Right, cx) + }); - // workspace.update(cx, |workspace, cx| { - // assert!(workspace.right_dock().read(cx).is_open()); - // assert!(panel.is_zoomed(cx)); - // assert!(workspace.zoomed.is_some()); - // assert!(panel.read(cx).focus_handle(cx).contains_focused(cx)); - // }); + workspace.update(cx, |workspace, cx| { + assert!(workspace.right_dock().read(cx).is_open()); + assert!(panel.is_zoomed(cx)); + assert!(workspace.zoomed.is_some()); + assert!(panel.read(cx).focus_handle(cx).contains_focused(cx)); + }); - // // Unzoom and close the panel, zoom the active pane. - // panel.update(cx, |panel, cx| panel.set_zoomed(false, cx)); - // workspace.update(cx, |workspace, cx| { - // workspace.toggle_dock(DockPosition::Right, cx) - // }); - // pane.update(cx, |pane, cx| pane.toggle_zoom(&Default::default(), cx)); + // Unzoom and close the panel, zoom the active pane. + panel.update(cx, |panel, cx| panel.set_zoomed(false, cx)); + workspace.update(cx, |workspace, cx| { + workspace.toggle_dock(DockPosition::Right, cx) + }); + pane.update(cx, |pane, cx| pane.toggle_zoom(&Default::default(), cx)); - // // Opening a dock unzooms the pane. - // workspace.update(cx, |workspace, cx| { - // workspace.toggle_dock(DockPosition::Right, cx) - // }); - // workspace.update(cx, |workspace, cx| { - // let pane = pane.read(cx); - // assert!(!pane.is_zoomed()); - // assert!(!pane.focus_handle(cx).is_focused(cx)); - // assert!(workspace.right_dock().read(cx).is_open()); - // assert!(workspace.zoomed.is_none()); - // }); - // } + // Opening a dock unzooms the pane. + workspace.update(cx, |workspace, cx| { + workspace.toggle_dock(DockPosition::Right, cx) + }); + workspace.update(cx, |workspace, cx| { + let pane = pane.read(cx); + assert!(!pane.is_zoomed()); + assert!(!pane.focus_handle(cx).is_focused(cx)); + assert!(workspace.right_dock().read(cx).is_open()); + assert!(workspace.zoomed.is_none()); + }); + } - // #[gpui::test] - // async fn test_panels(cx: &mut gpui::TestAppContext) { - // init_test(cx); - // let fs = FakeFs::new(cx.executor()); + struct TestModal(FocusHandle); - // let project = Project::test(fs, [], cx).await; - // let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); + impl TestModal { + fn new(cx: &mut ViewContext) -> Self { + Self(cx.focus_handle()) + } + } - // let (panel_1, panel_2) = workspace.update(cx, |workspace, cx| { - // // Add panel_1 on the left, panel_2 on the right. - // let panel_1 = cx.build_view(|cx| TestPanel::new(DockPosition::Left, cx)); - // workspace.add_panel(panel_1.clone(), cx); - // workspace - // .left_dock() - // .update(cx, |left_dock, cx| left_dock.set_open(true, cx)); - // let panel_2 = cx.build_view(|cx| TestPanel::new(DockPosition::Right, cx)); - // workspace.add_panel(panel_2.clone(), cx); - // workspace - // .right_dock() - // .update(cx, |right_dock, cx| right_dock.set_open(true, cx)); + impl EventEmitter for TestModal {} - // let left_dock = workspace.left_dock(); - // assert_eq!( - // left_dock.read(cx).visible_panel().unwrap().panel_id(), - // panel_1.panel_id() - // ); - // assert_eq!( - // left_dock.read(cx).active_panel_size(cx).unwrap(), - // panel_1.size(cx) - // ); + impl FocusableView for TestModal { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.0.clone() + } + } - // left_dock.update(cx, |left_dock, cx| { - // left_dock.resize_active_panel(Some(1337.), cx) - // }); - // assert_eq!( - // workspace - // .right_dock() - // .read(cx) - // .visible_panel() - // .unwrap() - // .panel_id(), - // panel_2.panel_id(), - // ); + impl ModalView for TestModal {} - // (panel_1, panel_2) - // }); + impl Render for TestModal { + fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { + div().track_focus(&self.0) + } + } - // // Move panel_1 to the right - // panel_1.update(cx, |panel_1, cx| { - // panel_1.set_position(DockPosition::Right, cx) - // }); + #[gpui::test] + async fn test_panels(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); - // workspace.update(cx, |workspace, cx| { - // // Since panel_1 was visible on the left, it should now be visible now that it's been moved to the right. - // // Since it was the only panel on the left, the left dock should now be closed. - // assert!(!workspace.left_dock().read(cx).is_open()); - // assert!(workspace.left_dock().read(cx).visible_panel().is_none()); - // let right_dock = workspace.right_dock(); - // assert_eq!( - // right_dock.read(cx).visible_panel().unwrap().panel_id(), - // panel_1.panel_id() - // ); - // assert_eq!(right_dock.read(cx).active_panel_size(cx).unwrap(), 1337.); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); - // // Now we move panel_2 to the left - // panel_2.set_position(DockPosition::Left, cx); - // }); + let (panel_1, panel_2) = workspace.update(cx, |workspace, cx| { + let panel_1 = cx.new_view(|cx| TestPanel::new(DockPosition::Left, cx)); + workspace.add_panel(panel_1.clone(), cx); + workspace + .left_dock() + .update(cx, |left_dock, cx| left_dock.set_open(true, cx)); + let panel_2 = cx.new_view(|cx| TestPanel::new(DockPosition::Right, cx)); + workspace.add_panel(panel_2.clone(), cx); + workspace + .right_dock() + .update(cx, |right_dock, cx| right_dock.set_open(true, cx)); - // workspace.update(cx, |workspace, cx| { - // // Since panel_2 was not visible on the right, we don't open the left dock. - // assert!(!workspace.left_dock().read(cx).is_open()); - // // And the right dock is unaffected in it's displaying of panel_1 - // assert!(workspace.right_dock().read(cx).is_open()); - // assert_eq!( - // workspace - // .right_dock() - // .read(cx) - // .visible_panel() - // .unwrap() - // .panel_id(), - // panel_1.panel_id(), - // ); - // }); + let left_dock = workspace.left_dock(); + assert_eq!( + left_dock.read(cx).visible_panel().unwrap().panel_id(), + panel_1.panel_id() + ); + assert_eq!( + left_dock.read(cx).active_panel_size(cx).unwrap(), + panel_1.size(cx) + ); - // // Move panel_1 back to the left - // panel_1.update(cx, |panel_1, cx| { - // panel_1.set_position(DockPosition::Left, cx) - // }); + left_dock.update(cx, |left_dock, cx| { + left_dock.resize_active_panel(Some(px(1337.)), cx) + }); + assert_eq!( + workspace + .right_dock() + .read(cx) + .visible_panel() + .unwrap() + .panel_id(), + panel_2.panel_id(), + ); - // workspace.update(cx, |workspace, cx| { - // // Since panel_1 was visible on the right, we open the left dock and make panel_1 active. - // let left_dock = workspace.left_dock(); - // assert!(left_dock.read(cx).is_open()); - // assert_eq!( - // left_dock.read(cx).visible_panel().unwrap().panel_id(), - // panel_1.panel_id() - // ); - // assert_eq!(left_dock.read(cx).active_panel_size(cx).unwrap(), 1337.); - // // And right the dock should be closed as it no longer has any panels. - // assert!(!workspace.right_dock().read(cx).is_open()); + (panel_1, panel_2) + }); - // // Now we move panel_1 to the bottom - // panel_1.set_position(DockPosition::Bottom, cx); - // }); + // Move panel_1 to the right + panel_1.update(cx, |panel_1, cx| { + panel_1.set_position(DockPosition::Right, cx) + }); - // workspace.update(cx, |workspace, cx| { - // // Since panel_1 was visible on the left, we close the left dock. - // assert!(!workspace.left_dock().read(cx).is_open()); - // // The bottom dock is sized based on the panel's default size, - // // since the panel orientation changed from vertical to horizontal. - // let bottom_dock = workspace.bottom_dock(); - // assert_eq!( - // bottom_dock.read(cx).active_panel_size(cx).unwrap(), - // panel_1.size(cx), - // ); - // // Close bottom dock and move panel_1 back to the left. - // bottom_dock.update(cx, |bottom_dock, cx| bottom_dock.set_open(false, cx)); - // panel_1.set_position(DockPosition::Left, cx); - // }); + workspace.update(cx, |workspace, cx| { + // Since panel_1 was visible on the left, it should now be visible now that it's been moved to the right. + // Since it was the only panel on the left, the left dock should now be closed. + assert!(!workspace.left_dock().read(cx).is_open()); + assert!(workspace.left_dock().read(cx).visible_panel().is_none()); + let right_dock = workspace.right_dock(); + assert_eq!( + right_dock.read(cx).visible_panel().unwrap().panel_id(), + panel_1.panel_id() + ); + assert_eq!( + right_dock.read(cx).active_panel_size(cx).unwrap(), + px(1337.) + ); - // // Emit activated event on panel 1 - // panel_1.update(cx, |_, cx| cx.emit(PanelEvent::Activate)); + // Now we move panel_2 to the left + panel_2.set_position(DockPosition::Left, cx); + }); - // // Now the left dock is open and panel_1 is active and focused. - // workspace.update(cx, |workspace, cx| { - // let left_dock = workspace.left_dock(); - // assert!(left_dock.read(cx).is_open()); - // assert_eq!( - // left_dock.read(cx).visible_panel().unwrap().panel_id(), - // panel_1.panel_id(), - // ); - // assert!(panel_1.focus_handle(cx).is_focused(cx)); - // }); + workspace.update(cx, |workspace, cx| { + // Since panel_2 was not visible on the right, we don't open the left dock. + assert!(!workspace.left_dock().read(cx).is_open()); + // And the right dock is unaffected in it's displaying of panel_1 + assert!(workspace.right_dock().read(cx).is_open()); + assert_eq!( + workspace + .right_dock() + .read(cx) + .visible_panel() + .unwrap() + .panel_id(), + panel_1.panel_id(), + ); + }); - // // Emit closed event on panel 2, which is not active - // panel_2.update(cx, |_, cx| cx.emit(PanelEvent::Close)); + // Move panel_1 back to the left + panel_1.update(cx, |panel_1, cx| { + panel_1.set_position(DockPosition::Left, cx) + }); - // // Wo don't close the left dock, because panel_2 wasn't the active panel - // workspace.update(cx, |workspace, cx| { - // let left_dock = workspace.left_dock(); - // assert!(left_dock.read(cx).is_open()); - // assert_eq!( - // left_dock.read(cx).visible_panel().unwrap().panel_id(), - // panel_1.panel_id(), - // ); - // }); + workspace.update(cx, |workspace, cx| { + // Since panel_1 was visible on the right, we open the left dock and make panel_1 active. + let left_dock = workspace.left_dock(); + assert!(left_dock.read(cx).is_open()); + assert_eq!( + left_dock.read(cx).visible_panel().unwrap().panel_id(), + panel_1.panel_id() + ); + assert_eq!(left_dock.read(cx).active_panel_size(cx).unwrap(), px(1337.)); + // And the right dock should be closed as it no longer has any panels. + assert!(!workspace.right_dock().read(cx).is_open()); - // // Emitting a ZoomIn event shows the panel as zoomed. - // panel_1.update(cx, |_, cx| cx.emit(PanelEvent::ZoomIn)); - // workspace.update(cx, |workspace, _| { - // assert_eq!(workspace.zoomed, Some(panel_1.to_any().downgrade())); - // assert_eq!(workspace.zoomed_position, Some(DockPosition::Left)); - // }); + // Now we move panel_1 to the bottom + panel_1.set_position(DockPosition::Bottom, cx); + }); - // // Move panel to another dock while it is zoomed - // panel_1.update(cx, |panel, cx| panel.set_position(DockPosition::Right, cx)); - // workspace.update(cx, |workspace, _| { - // assert_eq!(workspace.zoomed, Some(panel_1.to_any().downgrade())); + workspace.update(cx, |workspace, cx| { + // Since panel_1 was visible on the left, we close the left dock. + assert!(!workspace.left_dock().read(cx).is_open()); + // The bottom dock is sized based on the panel's default size, + // since the panel orientation changed from vertical to horizontal. + let bottom_dock = workspace.bottom_dock(); + assert_eq!( + bottom_dock.read(cx).active_panel_size(cx).unwrap(), + panel_1.size(cx), + ); + // Close bottom dock and move panel_1 back to the left. + bottom_dock.update(cx, |bottom_dock, cx| bottom_dock.set_open(false, cx)); + panel_1.set_position(DockPosition::Left, cx); + }); - // assert_eq!(workspace.zoomed_position, Some(DockPosition::Right)); - // }); + // Emit activated event on panel 1 + panel_1.update(cx, |_, cx| cx.emit(PanelEvent::Activate)); - // // If focus is transferred to another view that's not a panel or another pane, we still show - // // the panel as zoomed. - // let other_focus_handle = cx.update(|cx| cx.focus_handle()); - // cx.update(|cx| cx.focus(&other_focus_handle)); - // workspace.update(cx, |workspace, _| { - // assert_eq!(workspace.zoomed, Some(panel_1.to_any().downgrade())); - // assert_eq!(workspace.zoomed_position, Some(DockPosition::Right)); - // }); + // Now the left dock is open and panel_1 is active and focused. + workspace.update(cx, |workspace, cx| { + let left_dock = workspace.left_dock(); + assert!(left_dock.read(cx).is_open()); + assert_eq!( + left_dock.read(cx).visible_panel().unwrap().panel_id(), + panel_1.panel_id(), + ); + assert!(panel_1.focus_handle(cx).is_focused(cx)); + }); - // // If focus is transferred elsewhere in the workspace, the panel is no longer zoomed. - // workspace.update(cx, |_, cx| cx.focus_self()); - // workspace.update(cx, |workspace, _| { - // assert_eq!(workspace.zoomed, None); - // assert_eq!(workspace.zoomed_position, None); - // }); + // Emit closed event on panel 2, which is not active + panel_2.update(cx, |_, cx| cx.emit(PanelEvent::Close)); - // // If focus is transferred again to another view that's not a panel or a pane, we won't - // // show the panel as zoomed because it wasn't zoomed before. - // cx.update(|cx| cx.focus(&other_focus_handle)); - // workspace.update(cx, |workspace, _| { - // assert_eq!(workspace.zoomed, None); - // assert_eq!(workspace.zoomed_position, None); - // }); + // Wo don't close the left dock, because panel_2 wasn't the active panel + workspace.update(cx, |workspace, cx| { + let left_dock = workspace.left_dock(); + assert!(left_dock.read(cx).is_open()); + assert_eq!( + left_dock.read(cx).visible_panel().unwrap().panel_id(), + panel_1.panel_id(), + ); + }); - // // When focus is transferred back to the panel, it is zoomed again. - // panel_1.update(cx, |_, cx| cx.focus_self()); - // workspace.update(cx, |workspace, _| { - // assert_eq!(workspace.zoomed, Some(panel_1.to_any().downgrade())); - // assert_eq!(workspace.zoomed_position, Some(DockPosition::Right)); - // }); + // Emitting a ZoomIn event shows the panel as zoomed. + panel_1.update(cx, |_, cx| cx.emit(PanelEvent::ZoomIn)); + workspace.update(cx, |workspace, _| { + assert_eq!(workspace.zoomed, Some(panel_1.to_any().downgrade())); + assert_eq!(workspace.zoomed_position, Some(DockPosition::Left)); + }); - // // Emitting a ZoomOut event unzooms the panel. - // panel_1.update(cx, |_, cx| cx.emit(PanelEvent::ZoomOut)); - // workspace.update(cx, |workspace, _| { - // assert_eq!(workspace.zoomed, None); - // assert_eq!(workspace.zoomed_position, None); - // }); + // Move panel to another dock while it is zoomed + panel_1.update(cx, |panel, cx| panel.set_position(DockPosition::Right, cx)); + workspace.update(cx, |workspace, _| { + assert_eq!(workspace.zoomed, Some(panel_1.to_any().downgrade())); - // // Emit closed event on panel 1, which is active - // panel_1.update(cx, |_, cx| cx.emit(PanelEvent::Close)); + assert_eq!(workspace.zoomed_position, Some(DockPosition::Right)); + }); - // // Now the left dock is closed, because panel_1 was the active panel - // workspace.update(cx, |workspace, cx| { - // let right_dock = workspace.right_dock(); - // assert!(!right_dock.read(cx).is_open()); - // }); - // } + // This is a helper for getting a: + // - valid focus on an element, + // - that isn't a part of the panes and panels system of the Workspace, + // - and doesn't trigger the 'on_focus_lost' API. + let focus_other_view = { + let workspace = workspace.clone(); + move |cx: &mut VisualTestContext| { + workspace.update(cx, |workspace, cx| { + if let Some(_) = workspace.active_modal::(cx) { + workspace.toggle_modal(cx, TestModal::new); + workspace.toggle_modal(cx, TestModal::new); + } else { + workspace.toggle_modal(cx, TestModal::new); + } + }) + } + }; + + // If focus is transferred to another view that's not a panel or another pane, we still show + // the panel as zoomed. + focus_other_view(cx); + workspace.update(cx, |workspace, _| { + assert_eq!(workspace.zoomed, Some(panel_1.to_any().downgrade())); + assert_eq!(workspace.zoomed_position, Some(DockPosition::Right)); + }); + + // If focus is transferred elsewhere in the workspace, the panel is no longer zoomed. + workspace.update(cx, |_, cx| cx.focus_self()); + workspace.update(cx, |workspace, _| { + assert_eq!(workspace.zoomed, None); + assert_eq!(workspace.zoomed_position, None); + }); + + // If focus is transferred again to another view that's not a panel or a pane, we won't + // show the panel as zoomed because it wasn't zoomed before. + focus_other_view(cx); + workspace.update(cx, |workspace, _| { + assert_eq!(workspace.zoomed, None); + assert_eq!(workspace.zoomed_position, None); + }); + + // When the panel is activated, it is zoomed again. + cx.dispatch_action(ToggleRightDock); + workspace.update(cx, |workspace, _| { + assert_eq!(workspace.zoomed, Some(panel_1.to_any().downgrade())); + assert_eq!(workspace.zoomed_position, Some(DockPosition::Right)); + }); + + // Emitting a ZoomOut event unzooms the panel. + panel_1.update(cx, |_, cx| cx.emit(PanelEvent::ZoomOut)); + workspace.update(cx, |workspace, _| { + assert_eq!(workspace.zoomed, None); + assert_eq!(workspace.zoomed_position, None); + }); + + // Emit closed event on panel 1, which is active + panel_1.update(cx, |_, cx| cx.emit(PanelEvent::Close)); + + // Now the left dock is closed, because panel_1 was the active panel + workspace.update(cx, |workspace, cx| { + let right_dock = workspace.right_dock(); + assert!(!right_dock.read(cx).is_open()); + }); + } pub fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 734c225cb1..1e21648408 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -29,6 +29,7 @@ command_palette = { path = "../command_palette" } # component_test = { path = "../component_test" } client = { path = "../client" } # clock = { path = "../clock" } +color = { path = "../color" } copilot = { path = "../copilot" } copilot_ui = { path = "../copilot_ui" } diagnostics = { path = "../diagnostics" } diff --git a/crates/zed/src/app_menus.rs b/crates/zed/src/app_menus.rs index 2aff05d884..fc063a620f 100644 --- a/crates/zed/src/app_menus.rs +++ b/crates/zed/src/app_menus.rs @@ -53,39 +53,46 @@ pub fn app_menus() -> Vec> { Menu { name: "Edit", items: vec![ - MenuItem::os_action("Undo", editor::Undo, OsAction::Undo), - MenuItem::os_action("Redo", editor::Redo, OsAction::Redo), + MenuItem::os_action("Undo", editor::actions::Undo, OsAction::Undo), + MenuItem::os_action("Redo", editor::actions::Redo, OsAction::Redo), MenuItem::separator(), - MenuItem::os_action("Cut", editor::Cut, OsAction::Cut), - MenuItem::os_action("Copy", editor::Copy, OsAction::Copy), - MenuItem::os_action("Paste", editor::Paste, OsAction::Paste), + MenuItem::os_action("Cut", editor::actions::Cut, OsAction::Cut), + MenuItem::os_action("Copy", editor::actions::Copy, OsAction::Copy), + MenuItem::os_action("Paste", editor::actions::Paste, OsAction::Paste), MenuItem::separator(), MenuItem::action("Find", search::buffer_search::Deploy { focus: true }), MenuItem::action("Find In Project", workspace::NewSearch), MenuItem::separator(), - MenuItem::action("Toggle Line Comment", editor::ToggleComments::default()), - MenuItem::action("Emoji & Symbols", editor::ShowCharacterPalette), + MenuItem::action( + "Toggle Line Comment", + editor::actions::ToggleComments::default(), + ), + MenuItem::action("Emoji & Symbols", editor::actions::ShowCharacterPalette), ], }, Menu { name: "Selection", items: vec![ - MenuItem::os_action("Select All", editor::SelectAll, OsAction::SelectAll), - MenuItem::action("Expand Selection", editor::SelectLargerSyntaxNode), - MenuItem::action("Shrink Selection", editor::SelectSmallerSyntaxNode), + MenuItem::os_action( + "Select All", + editor::actions::SelectAll, + OsAction::SelectAll, + ), + MenuItem::action("Expand Selection", editor::actions::SelectLargerSyntaxNode), + MenuItem::action("Shrink Selection", editor::actions::SelectSmallerSyntaxNode), MenuItem::separator(), - MenuItem::action("Add Cursor Above", editor::AddSelectionAbove), - MenuItem::action("Add Cursor Below", editor::AddSelectionBelow), + MenuItem::action("Add Cursor Above", editor::actions::AddSelectionAbove), + MenuItem::action("Add Cursor Below", editor::actions::AddSelectionBelow), MenuItem::action( "Select Next Occurrence", - editor::SelectNext { + editor::actions::SelectNext { replace_newest: false, }, ), MenuItem::separator(), - MenuItem::action("Move Line Up", editor::MoveLineUp), - MenuItem::action("Move Line Down", editor::MoveLineDown), - MenuItem::action("Duplicate Selection", editor::DuplicateLine), + MenuItem::action("Move Line Up", editor::actions::MoveLineUp), + MenuItem::action("Move Line Down", editor::actions::MoveLineDown), + MenuItem::action("Duplicate Selection", editor::actions::DuplicateLine), ], }, Menu { @@ -124,13 +131,13 @@ pub fn app_menus() -> Vec> { MenuItem::action("Go to File", file_finder::Toggle), // MenuItem::action("Go to Symbol in Project", project_symbols::Toggle), MenuItem::action("Go to Symbol in Editor", outline::Toggle), - MenuItem::action("Go to Definition", editor::GoToDefinition), - MenuItem::action("Go to Type Definition", editor::GoToTypeDefinition), - MenuItem::action("Find All References", editor::FindAllReferences), + MenuItem::action("Go to Definition", editor::actions::GoToDefinition), + MenuItem::action("Go to Type Definition", editor::actions::GoToTypeDefinition), + MenuItem::action("Find All References", editor::actions::FindAllReferences), MenuItem::action("Go to Line/Column", go_to_line::Toggle), MenuItem::separator(), - MenuItem::action("Next Problem", editor::GoToDiagnostic), - MenuItem::action("Previous Problem", editor::GoToPrevDiagnostic), + MenuItem::action("Next Problem", editor::actions::GoToDiagnostic), + MenuItem::action("Previous Problem", editor::actions::GoToPrevDiagnostic), ], }, Menu { diff --git a/crates/zed/src/languages/python.rs b/crates/zed/src/languages/python.rs index d28cd9f6e4..8a30121d49 100644 --- a/crates/zed/src/languages/python.rs +++ b/crates/zed/src/languages/python.rs @@ -184,7 +184,7 @@ mod tests { #[gpui::test] async fn test_python_autoindent(cx: &mut TestAppContext) { - // cx.executor().set_block_on_ticks(usize::MAX..=usize::MAX); + cx.executor().set_block_on_ticks(usize::MAX..=usize::MAX); let language = crate::languages::language("python", tree_sitter_python::language(), None).await; cx.update(|cx| { diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index e10c52a175..be07ba6a5f 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -43,7 +43,8 @@ use util::{ async_maybe, channel::{parse_zed_link, AppCommitSha, ReleaseChannel, RELEASE_CHANNEL}, http::{self, HttpClient}, - paths, ResultExt, + paths::{self, CRASHES_DIR, CRASHES_RETIRED_DIR}, + ResultExt, }; use uuid::Uuid; use welcome::{show_welcome_view, BaseKeymap, FIRST_OPEN}; @@ -67,7 +68,7 @@ fn main() { } log::info!("========== starting zed =========="); - let app = App::production(Arc::new(Assets)); + let app = App::new().with_assets(Assets); let (installation_id, existing_installation_id_found) = app .background_executor() @@ -102,13 +103,15 @@ fn main() { let open_listener = listener.clone(); app.on_open_urls(move |urls, _| open_listener.open_urls(&urls)); app.on_reopen(move |cx| { - if cx.has_global::>() { - if let Some(app_state) = cx.global::>().upgrade() { - workspace::open_new(&app_state, cx, |workspace, cx| { - Editor::new_file(workspace, &Default::default(), cx) - }) - .detach(); - } + if let Some(app_state) = cx + .try_global::>() + .map(|app_state| app_state.upgrade()) + .flatten() + { + workspace::open_new(&app_state, cx, |workspace, cx| { + Editor::new_file(workspace, &Default::default(), cx) + }) + .detach(); } }); @@ -176,10 +179,13 @@ fn main() { telemetry.start(installation_id, session_id, cx); telemetry.report_setting_event("theme", cx.theme().name.to_string()); telemetry.report_setting_event("keymap", BaseKeymap::get_global(cx).to_string()); - telemetry.report_app_event(match existing_installation_id_found { - Some(false) => "first open", - _ => "open", - }); + telemetry.report_app_event( + match existing_installation_id_found { + Some(false) => "first open", + _ => "open", + } + .to_string(), + ); telemetry.flush_events(); let app_state = Arc::new(AppState { @@ -224,14 +230,14 @@ fn main() { initialize_workspace(app_state.clone(), cx); if stdout_is_a_pty() { + upload_panics_and_crashes(http.clone(), cx); cx.activate(true); let urls = collect_url_args(); if !urls.is_empty() { listener.open_urls(&urls) } } else { - upload_previous_panics(http.clone(), cx); - + upload_panics_and_crashes(http.clone(), cx); // TODO Development mode that forces the CLI mode usually runs Zed binary as is instead // of an *app, hence gets no specific callbacks run. Emulate them here, if needed. if std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_some() @@ -538,7 +544,22 @@ fn init_panic_hook(app: &App, installation_id: Option, session_id: Strin let mut backtrace = backtrace .frames() .iter() - .filter_map(|frame| Some(format!("{:#}", frame.symbols().first()?.name()?))) + .flat_map(|frame| { + frame.symbols().iter().filter_map(|symbol| { + let name = symbol.name()?; + let addr = symbol.addr()? as usize; + let position = if let (Some(path), Some(lineno)) = ( + symbol.filename().and_then(|path| path.file_name()), + symbol.lineno(), + ) { + format!("{}:{}", path.to_string_lossy(), lineno) + } else { + "?".to_string() + }; + + Some(format!("{:} ({:#x}) at {}", name, addr, position)) + }) + }) .collect::>(); // Strip out leading stack frames for rust panic-handling. @@ -594,77 +615,154 @@ fn init_panic_hook(app: &App, installation_id: Option, session_id: Strin })); } -fn upload_previous_panics(http: Arc, cx: &mut AppContext) { +fn upload_panics_and_crashes(http: Arc, cx: &mut AppContext) { let telemetry_settings = *client::TelemetrySettings::get_global(cx); - cx.background_executor() .spawn(async move { - let panic_report_url = format!("{}/api/panic", &*client::ZED_SERVER_URL); - let mut children = smol::fs::read_dir(&*paths::LOGS_DIR).await?; - while let Some(child) = children.next().await { - let child = child?; - let child_path = child.path(); - - if child_path.extension() != Some(OsStr::new("panic")) { - continue; - } - let filename = if let Some(filename) = child_path.file_name() { - filename.to_string_lossy() - } else { - continue; - }; - - if !filename.starts_with("zed") { - continue; - } - - if telemetry_settings.diagnostics { - let panic_file_content = smol::fs::read_to_string(&child_path) - .await - .context("error reading panic file")?; - - let panic = serde_json::from_str(&panic_file_content) - .ok() - .or_else(|| { - panic_file_content - .lines() - .next() - .and_then(|line| serde_json::from_str(line).ok()) - }) - .unwrap_or_else(|| { - log::error!( - "failed to deserialize panic file {:?}", - panic_file_content - ); - None - }); - - if let Some(panic) = panic { - let body = serde_json::to_string(&PanicRequest { - panic, - token: client::ZED_SECRET_CLIENT_TOKEN.into(), - }) - .unwrap(); - - let request = Request::post(&panic_report_url) - .redirect_policy(isahc::config::RedirectPolicy::Follow) - .header("Content-Type", "application/json") - .body(body.into())?; - let response = http.send(request).await.context("error sending panic")?; - if !response.status().is_success() { - log::error!("Error uploading panic to server: {}", response.status()); - } - } - } - - // We've done what we can, delete the file - std::fs::remove_file(child_path) - .context("error removing panic") - .log_err(); - } - Ok::<_, anyhow::Error>(()) + upload_previous_panics(http.clone(), telemetry_settings) + .await + .log_err(); + upload_previous_crashes(http, telemetry_settings) + .await + .log_err() }) - .detach_and_log_err(cx); + .detach() +} + +/// upload panics to us (via zed.dev) +async fn upload_previous_panics( + http: Arc, + telemetry_settings: client::TelemetrySettings, +) -> Result<()> { + let panic_report_url = format!("{}/api/panic", &*client::ZED_SERVER_URL); + let mut children = smol::fs::read_dir(&*paths::LOGS_DIR).await?; + while let Some(child) = children.next().await { + let child = child?; + let child_path = child.path(); + + if child_path.extension() != Some(OsStr::new("panic")) { + continue; + } + let filename = if let Some(filename) = child_path.file_name() { + filename.to_string_lossy() + } else { + continue; + }; + + if !filename.starts_with("zed") { + continue; + } + + if telemetry_settings.diagnostics { + let panic_file_content = smol::fs::read_to_string(&child_path) + .await + .context("error reading panic file")?; + + let panic = serde_json::from_str(&panic_file_content) + .ok() + .or_else(|| { + panic_file_content + .lines() + .next() + .and_then(|line| serde_json::from_str(line).ok()) + }) + .unwrap_or_else(|| { + log::error!("failed to deserialize panic file {:?}", panic_file_content); + None + }); + + if let Some(panic) = panic { + let body = serde_json::to_string(&PanicRequest { + panic, + token: client::ZED_SECRET_CLIENT_TOKEN.into(), + }) + .unwrap(); + + let request = Request::post(&panic_report_url) + .redirect_policy(isahc::config::RedirectPolicy::Follow) + .header("Content-Type", "application/json") + .body(body.into())?; + let response = http.send(request).await.context("error sending panic")?; + if !response.status().is_success() { + log::error!("Error uploading panic to server: {}", response.status()); + } + } + } + + // We've done what we can, delete the file + std::fs::remove_file(child_path) + .context("error removing panic") + .log_err(); + } + Ok::<_, anyhow::Error>(()) +} + +static LAST_CRASH_UPLOADED: &'static str = "LAST_CRASH_UPLOADED"; + +/// upload crashes from apple's diagnostic reports to our server. +/// (only if telemetry is enabled) +async fn upload_previous_crashes( + http: Arc, + telemetry_settings: client::TelemetrySettings, +) -> Result<()> { + if !telemetry_settings.diagnostics { + return Ok(()); + } + let last_uploaded = KEY_VALUE_STORE + .read_kvp(LAST_CRASH_UPLOADED)? + .unwrap_or("zed-2024-01-17-221900.ips".to_string()); // don't upload old crash reports from before we had this. + let mut uploaded = last_uploaded.clone(); + + let crash_report_url = format!("{}/api/crash", &*client::ZED_SERVER_URL); + + for dir in [&*CRASHES_DIR, &*CRASHES_RETIRED_DIR] { + let mut children = smol::fs::read_dir(&dir).await?; + while let Some(child) = children.next().await { + let child = child?; + let Some(filename) = child + .path() + .file_name() + .map(|f| f.to_string_lossy().to_lowercase()) + else { + continue; + }; + + if !filename.starts_with("zed-") || !filename.ends_with(".ips") { + continue; + } + + if filename <= last_uploaded { + continue; + } + + let body = smol::fs::read_to_string(&child.path()) + .await + .context("error reading crash file")?; + + let request = Request::post(&crash_report_url) + .redirect_policy(isahc::config::RedirectPolicy::Follow) + .header("Content-Type", "text/plain") + .header( + "Authorization", + format!("token {}", client::ZED_SECRET_CLIENT_TOKEN), + ) + .body(body.into())?; + + let response = http.send(request).await.context("error sending crash")?; + if !response.status().is_success() { + log::error!("Error uploading crash to server: {}", response.status()); + } + + if uploaded < filename { + uploaded = filename.clone(); + KEY_VALUE_STORE + .write_kvp(LAST_CRASH_UPLOADED.to_string(), filename) + .await?; + } + } + } + + Ok(()) } async fn load_login_shell_environment() -> Result<()> { diff --git a/crates/zed/src/open_listener.rs b/crates/zed/src/open_listener.rs index 6db020a785..f3a10208d0 100644 --- a/crates/zed/src/open_listener.rs +++ b/crates/zed/src/open_listener.rs @@ -1,7 +1,7 @@ use anyhow::{anyhow, Context, Result}; use cli::{ipc, IpcHandshake}; use cli::{ipc::IpcSender, CliRequest, CliResponse}; -use editor::scroll::autoscroll::Autoscroll; +use editor::scroll::Autoscroll; use editor::Editor; use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; use futures::channel::{mpsc, oneshot}; diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 73368a9883..112c219d2d 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -23,7 +23,7 @@ use quick_action_bar::QuickActionBar; use search::project_search::ProjectSearchBar; use settings::{initial_local_settings_content, KeymapFile, Settings, SettingsStore}; use std::{borrow::Cow, ops::Deref, sync::Arc}; -use terminal_view::terminal_panel::TerminalPanel; +use terminal_view::terminal_panel::{self, TerminalPanel}; use util::{ asset_str, channel::{AppCommitSha, ReleaseChannel}, @@ -113,12 +113,6 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { }) .detach(); - // cx.emit(workspace::Event::PaneAdded(workspace.active_pane().clone())); - - // let collab_titlebar_item = - // cx.add_view(|cx| CollabTitlebarItem::new(workspace, &workspace_handle, cx)); - // workspace.set_titlebar_item(collab_titlebar_item.into_any(), cx); - let copilot = cx.new_view(|cx| copilot_ui::CopilotButton::new(app_state.fs.clone(), cx)); let diagnostic_summary = cx.new_view(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx)); @@ -148,6 +142,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { cx.on_window_should_close(move |cx| { handle .update(cx, |workspace, cx| { + // We'll handle closing asynchoronously workspace.close_window(&Default::default(), cx); false }) @@ -183,7 +178,10 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { )?; workspace_handle.update(&mut cx, |workspace, cx| { - let position = project_panel.read(cx).position(cx); + let (position, was_deserialized) = { + let project_panel = project_panel.read(cx); + (project_panel.position(cx), project_panel.was_deserialized()) + }; workspace.add_panel(project_panel, cx); workspace.add_panel(terminal_panel, cx); workspace.add_panel(assistant_panel, cx); @@ -191,15 +189,16 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { workspace.add_panel(chat_panel, cx); workspace.add_panel(notification_panel, cx); - if workspace - .project() - .read(cx) - .visible_worktrees(cx) - .any(|tree| { - tree.read(cx) - .root_entry() - .map_or(false, |entry| entry.is_dir()) - }) + if !was_deserialized + && workspace + .project() + .read(cx) + .visible_worktrees(cx) + .any(|tree| { + tree.read(cx) + .root_entry() + .map_or(false, |entry| entry.is_dir()) + }) { workspace.toggle_dock(position, cx); } @@ -299,79 +298,42 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { ); }, ) - //todo!() - // cx.add_action({ - // move |workspace: &mut Workspace, _: &DebugElements, cx: &mut ViewContext| { - // let app_state = workspace.app_state().clone(); - // let markdown = app_state.languages.language_for_name("JSON"); - // let window = cx.window(); - // cx.spawn(|workspace, mut cx| async move { - // let markdown = markdown.await.log_err(); - // let content = to_string_pretty(&window.debug_elements(&cx).ok_or_else(|| { - // anyhow!("could not debug elements for window {}", window.id()) - // })?) - // .unwrap(); - // workspace - // .update(&mut cx, |workspace, cx| { - // workspace.with_local_workspace(cx, move |workspace, cx| { - // let project = workspace.project().clone(); - // let buffer = project - // .update(cx, |project, cx| { - // project.create_buffer(&content, markdown, cx) - // }) - // .expect("creating buffers on a local workspace always succeeds"); - // let buffer = cx.add_model(|cx| { - // MultiBuffer::singleton(buffer, cx) - // .with_title("Debug Elements".into()) - // }); - // workspace.add_item( - // Box::new(cx.add_view(|cx| { - // Editor::for_multibuffer(buffer, Some(project.clone()), cx) - // })), - // cx, - // ); - // }) - // })? - // .await - // }) - // .detach_and_log_err(cx); - // } - // }); - // .register_action( - // |workspace: &mut Workspace, - // _: &project_panel::ToggleFocus, - // cx: &mut ViewContext| { - // workspace.toggle_panel_focus::(cx); - // }, - // ); - // cx.add_action( - // |workspace: &mut Workspace, - // _: &collab_ui::collab_panel::ToggleFocus, - // cx: &mut ViewContext| { - // workspace.toggle_panel_focus::(cx); - // }, - // ); - // cx.add_action( - // |workspace: &mut Workspace, - // _: &collab_ui::chat_panel::ToggleFocus, - // cx: &mut ViewContext| { - // workspace.toggle_panel_focus::(cx); - // }, - // ); - // cx.add_action( - // |workspace: &mut Workspace, - // _: &collab_ui::notification_panel::ToggleFocus, - // cx: &mut ViewContext| { - // workspace.toggle_panel_focus::(cx); - // }, - // ); - // cx.add_action( - // |workspace: &mut Workspace, - // _: &terminal_panel::ToggleFocus, - // cx: &mut ViewContext| { - // workspace.toggle_panel_focus::(cx); - // }, - // ); + .register_action( + |workspace: &mut Workspace, + _: &project_panel::ToggleFocus, + cx: &mut ViewContext| { + workspace.toggle_panel_focus::(cx); + }, + ) + .register_action( + |workspace: &mut Workspace, + _: &collab_ui::collab_panel::ToggleFocus, + cx: &mut ViewContext| { + workspace.toggle_panel_focus::(cx); + }, + ) + .register_action( + |workspace: &mut Workspace, + _: &collab_ui::chat_panel::ToggleFocus, + cx: &mut ViewContext| { + workspace.toggle_panel_focus::(cx); + }, + ) + .register_action( + |workspace: &mut Workspace, + _: &collab_ui::notification_panel::ToggleFocus, + cx: &mut ViewContext| { + workspace + .toggle_panel_focus::(cx); + }, + ) + .register_action( + |workspace: &mut Workspace, + _: &terminal_panel::ToggleFocus, + cx: &mut ViewContext| { + workspace.toggle_panel_focus::(cx); + }, + ) .register_action({ let app_state = Arc::downgrade(&app_state); move |_, _: &NewWindow, cx| { @@ -764,12 +726,11 @@ fn open_bundled_file( .detach_and_log_err(cx); } -// todo!() #[cfg(test)] mod tests { use super::*; use assets::Assets; - use editor::{scroll::autoscroll::Autoscroll, DisplayPoint, Editor, EditorEvent}; + use editor::{scroll::Autoscroll, DisplayPoint, Editor, EditorEvent}; use gpui::{ actions, Action, AnyWindowHandle, AppContext, AssetSource, Entity, TestAppContext, VisualTestContext, WindowHandle, @@ -1659,8 +1620,8 @@ mod tests { }) .unwrap(); save_task.await.unwrap(); - // todo!() po - //assert!(!cx.did_prompt_for_new_path()); + + assert!(!cx.did_prompt_for_new_path()); window .update(cx, |_, cx| { editor.update(cx, |editor, cx| { @@ -1848,9 +1809,9 @@ mod tests { assert!(workspace.active_item(cx).is_none()); }) .unwrap(); - editor_1.assert_dropped(); - editor_2.assert_dropped(); - buffer.assert_dropped(); + editor_1.assert_released(); + editor_2.assert_released(); + buffer.assert_released(); } #[gpui::test] diff --git a/docs/old/building-zed.md b/docs/old/building-zed.md index ec4538cf85..79db4a36c9 100644 --- a/docs/old/building-zed.md +++ b/docs/old/building-zed.md @@ -36,7 +36,7 @@ Expect this to take 30min to an hour! Some of these steps will take quite a whil Unfortunately, unselecting `repo` scope and selecting every its inner scope instead does not allow the token users to read from private repositories - (not applicable) Fine-grained Tokens, at the moment of writing, did not allow any kind of access of non-owned private repos - Keep the token in the browser tab/editor for the next two steps -1. (Optional but reccomended) Add your GITHUB_TOKEN to your `.zshrc` or `.bashrc` like this: `export GITHUB_TOKEN=yourGithubAPIToken` +1. (Optional but recommended) Add your GITHUB_TOKEN to your `.zshrc` or `.bashrc` like this: `export GITHUB_TOKEN=yourGithubAPIToken` 1. Ensure the Zed.dev website is checked out in a sibling directory and install it's dependencies: ``` cd .. diff --git a/docs/old/release-process.md b/docs/old/release-process.md index ce43d647bd..fc237d9590 100644 --- a/docs/old/release-process.md +++ b/docs/old/release-process.md @@ -87,10 +87,14 @@ This means that when releasing a new version of Zed that has changes to the RPC 1. This script will make local changes only, and print out a shell command that you can use to push the branch and tag. 1. Pushing the new tag will trigger a CI build that, when finished will upload a new versioned docker image to the DigitalOcean docker registry. -1. Once that CI job completes, you will be able to run the following command to deploy that docker image. The script takes two arguments: an environment (`production`, `preview`, or `staging`), and a version number (e.g. `0.10.1`). +1. If needing a migration: + - First check that the migration is valid. The database serves both preview and stable simultaneously, so new columns need to have defaults and old tables or columns can't be dropped. + - Then use `script/deploy-migration` (production, staging, preview, nightly). ex: `script/deploy-migration preview 0.19.0` + - If there is an 'Error: container is waiting to start', you can review logs manually with: `kubectl --namespace logs ` to make sure the mgiration ran successfully. +1. Once that CI job completes, you will be able to run the following command to deploy that docker image. The script takes two arguments: an environment (`production`, `preview`, or `staging`), and a version number (e.g. `0.10.1`): - ``` - script/deploy preview 0.10.1 - ``` +``` +script/deploy preview 0.10.1 +``` -1. This command should complete quickly, updating the given environment to use the given version number of the `collab` server. +1. This command should complete quickly, updating the given environment to use the given version number of the `collab` server. \ No newline at end of file diff --git a/docs/old/tools.md b/docs/old/tools.md index 22810e3e07..56c3c0c963 100644 --- a/docs/old/tools.md +++ b/docs/old/tools.md @@ -56,7 +56,7 @@ We use Vercel for all of our web deployments and some backend things. If you sig ### Environment Variables -You can get access to many of our shared enviroment variables through 1Password and Vercel. For 1Password search the value you are looking for, or sort by passwords or API credentials. +You can get access to many of our shared environment variables through 1Password and Vercel. For 1Password search the value you are looking for, or sort by passwords or API credentials. For Vercel, go to `settings` -> `Environment Variables` (either on the entire org, or on a specific project depending on where it is shared.) For a given Vercel project if you have their CLI installed you can use `vercel pull` or `vercel env` to pull values down directly. More on those in their [CLI docs](https://vercel.com/docs/cli/env). diff --git a/docs/old/zed/syntax-highlighting.md b/docs/old/zed/syntax-highlighting.md index d4331ee367..846bf96876 100644 --- a/docs/old/zed/syntax-highlighting.md +++ b/docs/old/zed/syntax-highlighting.md @@ -4,7 +4,7 @@ This doc is a work in progress! ## Defining syntax highlighting rules -We use tree-sitter queries to match certian properties to highlight. +We use tree-sitter queries to match certain properties to highlight. ### Simple Example: diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index ad1cd6332c..e300e99069 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -4,15 +4,18 @@ [Feedback](./feedback.md) # Configuring Zed + - [Settings](./configuring_zed.md) - [Vim Mode](./configuring_zed__configuring_vim.md) # Using Zed + - [Workflows]() - [Collaboration]() - [Using AI]() # Contributing to Zed + - [How to Contribute]() - [Building from Source](./developing_zed__building_zed.md) - [Local Collaboration](./developing_zed__local_collaboration.md) diff --git a/docs/src/configuring_zed.md b/docs/src/configuring_zed.md index 9b9205f70c..46f0d35bec 100644 --- a/docs/src/configuring_zed.md +++ b/docs/src/configuring_zed.md @@ -4,7 +4,7 @@ Folder-specific settings are used to override Zed's global settings for files within a specific directory in the project panel. To get started, create a `.zed` subdirectory and add a `settings.json` within it. It should be noted that folder-specific settings don't need to live only a project's root, but can be defined at multiple levels in the project hierarchy. In setups like this, Zed will find the configuration nearest to the file you are working in and apply those settings to it. In most cases, this level of flexibility won't be needed and a single configuration for all files in a project is all that is required; the `Zed > Settings > Open Local Settings` menu action is built for this case. Running this action will look for a `.zed/settings.json` file at the root of the first top-level directory in your project panel. If it does not exist, it will create it. -The following global settings can be overriden with a folder-specific configuration: +The following global settings can be overridden with a folder-specific configuration: - `copilot` - `enable_language_server` diff --git a/docs/src/developing_zed__adding_languages.md b/docs/src/developing_zed__adding_languages.md index 2917b08422..7fce7e8544 100644 --- a/docs/src/developing_zed__adding_languages.md +++ b/docs/src/developing_zed__adding_languages.md @@ -8,7 +8,7 @@ Zed uses the [Language Server Protocol](https://microsoft.github.io/language-ser ### Defining syntax highlighting rules -We use tree-sitter queries to match certian properties to highlight. +We use tree-sitter queries to match certain properties to highlight. #### Simple Example: diff --git a/docs/src/developing_zed__building_zed.md b/docs/src/developing_zed__building_zed.md index cb30051ffa..a360be8397 100644 --- a/docs/src/developing_zed__building_zed.md +++ b/docs/src/developing_zed__building_zed.md @@ -1,107 +1,73 @@ # Building Zed -🚧 TODO: -- [ ] Tidy up & update instructions -- [ ] Remove ZI-specific things -- [ ] Rework any steps that currently require a ZI-specific account +## Dependencies -How to build Zed from source for the first time. +- Install [Rust](https://www.rust-lang.org/tools/install) +- Install [Xcode](https://apps.apple.com/us/app/xcode/id497799835?mt=12) from the macOS App Store -## Prerequisites +- Install [Xcode command line tools](https://developer.apple.com/xcode/resources/) -- Be added to the GitHub organization -- Be added to the Vercel team - -## Process - -Expect this to take 30min to an hour! Some of these steps will take quite a while based on your connection speed, and how long your first build will be. - -1. Install the [GitHub CLI](https://cli.github.com/): - - `brew install gh` -1. Clone the `zed` repo - - `gh repo clone zed-industries/zed` -1. Install Xcode from the macOS App Store -1. Install Xcode command line tools - - `xcode-select --install` - - If xcode-select --print-path prints /Library/Developer/CommandLineTools… run `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer.` -1. Install [Postgres](https://postgresapp.com) -1. Install rust/rustup - - `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` -1. Install the wasm toolchain - - `rustup target add wasm32-wasi` -1. Install Livekit & Foreman - - `brew install livekit` - - `brew install foreman` -1. Generate an GitHub API Key - - Go to https://github.com/settings/tokens and Generate new token - - GitHub currently provides two kinds of tokens: - - Classic Tokens, where only `repo` (Full control of private repositories) OAuth scope has to be selected - Unfortunately, unselecting `repo` scope and selecting every its inner scope instead does not allow the token users to read from private repositories - - (not applicable) Fine-grained Tokens, at the moment of writing, did not allow any kind of access of non-owned private repos - - Keep the token in the browser tab/editor for the next two steps -1. (Optional but reccomended) Add your GITHUB_TOKEN to your `.zshrc` or `.bashrc` like this: `export GITHUB_TOKEN=yourGithubAPIToken` -1. Ensure the Zed.dev website is checked out in a sibling directory and install it's dependencies: + ```bash + xcode-select --install ``` - cd .. - git clone https://github.com/zed-industries/zed.dev - cd zed.dev && npm install - npm install -g vercel + +- Ensure that the Xcode command line tools are using your newly installed copy of Xcode: + ``` -1. Link your zed.dev project to Vercel - - `vercel link` - - Select the `zed-industries` team. If you don't have this get someone on the team to add you to it. - - Select the `zed.dev` project -1. Run `vercel pull` to pull down the environment variables and project info from Vercel -1. Open Postgres.app -1. From `./path/to/zed/`: - - Run: - - `GITHUB_TOKEN={yourGithubAPIToken} script/bootstrap` - - Replace `{yourGithubAPIToken}` with the API token you generated above. - - You don't need to include the GITHUB_TOKEN if you exported it above. - - Consider removing the token (if it's fine for you to recreate such tokens during occasional migrations) or store this token somewhere safe (like your Zed 1Password vault). - - If you get: - - ```bash - Error: Cannot install in Homebrew on ARM processor in Intel default prefix (/usr/local)! - Please create a new installation in /opt/homebrew using one of the - "Alternative Installs" from: - https://docs.brew.sh/Installation - ``` - - In that case try: - - `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"` - - If Homebrew is not in your PATH: - - Replace `{username}` with your home folder name (usually your login name) - - `echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> /Users/{username}/.zprofile` - - `eval "$(/opt/homebrew/bin/brew shellenv)"` -1. To run the Zed app: - - If you are working on zed: - - `cargo run` - - If you are just using the latest version, but not working on zed: - - `cargo run --release` - - If you need to run the collaboration server locally: - - `script/zed-local` + sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer + ``` + +* Install the Rust wasm toolchain: + + ```bash + rustup target add wasm32-wasi + ``` + +## Backend Dependencies + +If you are developing collaborative features of Zed, you'll need to install the dependencies of zed's `collab` server: + +- Install [Postgres](https://postgresapp.com) +- Install [Livekit](https://formulae.brew.sh/formula/livekit) and [Foreman](https://formulae.brew.sh/formula/foreman) + + ```bash + brew install livekit foreman + ``` + +## Building Zed from Source + +Once you have the dependencies installed, you can build Zed using [Cargo](https://doc.rust-lang.org/cargo/). + +For a debug build: + +``` +cargo run +``` + +For a release build: + +``` +cargo run --release +``` + +And to run the tests: + +``` +cargo test --workspace +``` ## Troubleshooting -### `error: failed to run custom build command for gpui v0.1.0 (/Users/path/to/zed)` - -- Try `xcode-select --switch /Applications/Xcode.app/Contents/Developer` - -### `xcrun: error: unable to find utility "metal", not a developer tool or in PATH` - -### Seeding errors during `script/bootstrap` runs +### Error compiling metal shaders ``` -seeding database... -thread 'main' panicked at 'failed to deserialize github user from 'https://api.github.com/orgs/zed-industries/teams/staff/members': reqwest::Error { kind: Decode, source: Error("invalid type: map, expected a sequence", line: 1, column: 0) }', crates/collab/src/bin/seed.rs:111:10 +error: failed to run custom build command for gpui v0.1.0 (/Users/path/to/zed)`** + +xcrun: error: unable to find utility "metal", not a developer tool or in PATH ``` -Wrong permissions for `GITHUB_TOKEN` token used, the token needs to be able to read from private repos. -For Classic GitHub Tokens, that required OAuth scope `repo` (seacrh the scope name above for more details) +Try `xcode-select --switch /Applications/Xcode.app/Contents/Developer` -Same command +### Cargo errors claiming that a dependency is using unstable features -`sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer` - -### If you experience errors that mention some dependency is using unstable features - -Try `cargo clean` and `cargo build` +Try `cargo clean` and `cargo build`, diff --git a/docs/src/developing_zed__local_collaboration.md b/docs/src/developing_zed__local_collaboration.md index 7bbbda3645..0fc08ef767 100644 --- a/docs/src/developing_zed__local_collaboration.md +++ b/docs/src/developing_zed__local_collaboration.md @@ -1,22 +1,46 @@ # Local Collaboration -## Setting up the local collaboration server +First, make sure you've installed Zed's [backend dependencies](/developing_zed__building_zed.html#backend-dependencies). -### Setting up for the first time? +## Database setup -1. Make sure you have livekit installed (`brew install livekit`) -1. Install [Postgres](https://postgresapp.com) and run it. -1. Then, from the root of the repo, run `script/bootstrap`. +Before you can run the `collab` server locally, you'll need to set up a `zed` Postgres database. -### Have a db that is out of date? / Need to migrate? +``` +script/bootstrap +``` -1. Make sure you have livekit installed (`brew install livekit`) -1. Try `cd crates/collab && cargo run -- migrate` from the root of the repo. -1. Run `script/seed-db` +This script will set up the `zed` Postgres database, and populate it with some users. It requires internet access, because it fetches some users from the GitHub API. -## Testing collab locally +The script will create several *admin* users, who you'll sign in as by default when developing locally. The GitHub logins for these default admin users are specified in this file: -1. Run `foreman start` from the root of the repo. -1. In another terminal run `script/zed-local`. -1. Two copies of Zed will open. Add yourself as a contact in the one that is not you. -1. Start a collaboration session as normal with any open project. +``` +cat crates/collab/.admins.default.json +``` + +To use a different set of admin users, you can create a file called `.admins.json` in the same directory: + +``` +cat > crates/collab/.admins.json < (nightly is not yet supported)" + echo "Usage: $0 " exit 1 fi environment=$1 diff --git a/script/lib/squawk.toml b/script/lib/squawk.toml new file mode 100644 index 0000000000..1090e38286 --- /dev/null +++ b/script/lib/squawk.toml @@ -0,0 +1,4 @@ +excluded_rules = [ + "prefer-big-int", + "prefer-bigint-over-int", +] diff --git a/script/seed-db b/script/seed-db index 6bb0f96933..277ea89ba3 100755 --- a/script/seed-db +++ b/script/seed-db @@ -2,8 +2,4 @@ set -e cd crates/collab - -# Export contents of .env.toml -eval "$(cargo run --quiet --bin dotenv)" - cargo run --quiet --package=collab --features seed-support --bin seed -- $@ diff --git a/script/squawk b/script/squawk new file mode 100755 index 0000000000..68977645d0 --- /dev/null +++ b/script/squawk @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +# Squawk is a linter for database migrations. It helps identify dangerous patterns, and suggests alternatives. +# Squawk flagging an error does not mean that you need to take a different approach, but it does indicate you need to think about what you're doing. +# See also: https://squawkhq.com + +set -e + +if [ -z "$GITHUB_BASE_REF" ]; then + echo 'Not a pull request, skipping squawk modified migrations linting' + exit +fi + +SQUAWK_VERSION=0.26.0 +SQUAWK_BIN="./target/squawk-$SQUAWK_VERSION" +SQUAWK_ARGS="--assume-in-transaction --config script/lib/squawk.toml" + +if [ ! -f "$SQUAWK_BIN" ]; then + curl -L -o "$SQUAWK_BIN" "https://github.com/sbdchd/squawk/releases/download/v$SQUAWK_VERSION/squawk-darwin-x86_64" + chmod +x "$SQUAWK_BIN" +fi + +if [ -n "$SQUAWK_GITHUB_TOKEN" ]; then + export SQUAWK_GITHUB_REPO_OWNER=$(echo $GITHUB_REPOSITORY | awk -F/ '{print $1}') + export SQUAWK_GITHUB_REPO_NAME=$(echo $GITHUB_REPOSITORY | awk -F/ '{print $2}') + export SQUAWK_GITHUB_PR_NUMBER=$(echo $GITHUB_REF | awk 'BEGIN { FS = "/" } ; { print $3 }') + + $SQUAWK_BIN $SQUAWK_ARGS upload-to-github $(git diff --name-only origin/$GITHUB_BASE_REF...origin/$GITHUB_HEAD_REF 'crates/collab/migrations/*.sql') +else + $SQUAWK_BIN $SQUAWK_ARGS $(git ls-files --others crates/collab/migrations/*.sql) $(git diff --name-only main crates/collab/migrations/*.sql) +fi diff --git a/script/what-is-deployed b/script/what-is-deployed index b6a68dd3b3..c0f9b23487 100755 --- a/script/what-is-deployed +++ b/script/what-is-deployed @@ -4,16 +4,11 @@ set -eu source script/lib/deploy-helpers.sh if [[ $# < 1 ]]; then - echo "Usage: $0 (nightly is not yet supported)" + echo "Usage: $0 " exit 1 fi environment=$1 -if [[ ${environment} == "nightly" ]]; then - echo "nightly is not yet supported" - exit 1 -fi - export_vars_for_environment ${environment} target_zed_kube_cluster diff --git a/script/zed-local b/script/zed-local index 8ba1561bbd..068235730d 100755 --- a/script/zed-local +++ b/script/zed-local @@ -1,24 +1,62 @@ #!/usr/bin/env node +const HELP = ` +USAGE + zed-local [options] [zed args] + +SUMMARY + Runs 1-6 instances of Zed using a locally-running collaboration server. + Each instance of Zed will be signed in as a different user specified in + either \`.admins.json\` or \`.admins.default.json\`. + +OPTIONS + --help Print this help message + --release Build Zed in release mode + -2, -3, -4, ... Spawn multiple Zed instances, with their windows tiled. + --top Arrange the Zed windows so they take up the top half of the screen. +`.trim(); + const { spawn, execFileSync } = require("child_process"); +const assert = require("assert"); + +const defaultUsers = require("../crates/collab/.admins.default.json"); +let users = defaultUsers; +try { + const customUsers = require("../crates/collab/.admins.json"); + assert(customUsers.length > 0); + assert(customUsers.every((user) => typeof user === "string")); + users = customUsers.concat( + defaultUsers.filter((user) => !customUsers.includes(user)), + ); +} catch (_) {} const RESOLUTION_REGEX = /(\d+) x (\d+)/; const DIGIT_FLAG_REGEX = /^--?(\d+)$/; -const RELEASE_MODE = "--release"; + +let instanceCount = 1; +let isReleaseMode = false; +let isTop = false; const args = process.argv.slice(2); +while (args.length > 0) { + const arg = args[0]; + + const digitMatch = arg.match(DIGIT_FLAG_REGEX); + if (digitMatch) { + instanceCount = parseInt(digitMatch[1]); + } else if (arg === "--release") { + isReleaseMode = true; + } else if (arg === "--top") { + isTop = true; + } else if (arg === "--help") { + console.log(HELP); + process.exit(0); + } else { + break; + } -// Parse the number of Zed instances to spawn. -let instanceCount = 1; -const digitMatch = args[0]?.match(DIGIT_FLAG_REGEX); -if (digitMatch) { - instanceCount = parseInt(digitMatch[1]); args.shift(); } -const isReleaseMode = args.some((arg) => arg === RELEASE_MODE); -if (instanceCount > 4) { - throw new Error("Cannot spawn more than 4 instances"); -} // Parse the resolution of the main screen const displayInfo = JSON.parse( @@ -33,22 +71,36 @@ const mainDisplayResolution = if (!mainDisplayResolution) { throw new Error("Could not parse screen resolution"); } +const titleBarHeight = 24; const screenWidth = parseInt(mainDisplayResolution[1]); -const screenHeight = parseInt(mainDisplayResolution[2]); +let screenHeight = parseInt(mainDisplayResolution[2]) - titleBarHeight; -// Determine the window size for each instance -let instanceWidth = screenWidth; -let instanceHeight = screenHeight; -if (instanceCount > 1) { - instanceWidth = Math.floor(screenWidth / 2); - if (instanceCount > 2) { - instanceHeight = Math.floor(screenHeight / 2); - } +if (isTop) { + screenHeight = Math.floor(screenHeight / 2); } -let users = ["nathansobo", "as-cii", "maxbrunsfeld", "iamnbutler"]; +// Determine the window size for each instance +let rows; +let columns; +switch (instanceCount) { + case 1: + [rows, columns] = [1, 1]; + break; + case 2: + [rows, columns] = [1, 2]; + break; + case 3: + case 4: + [rows, columns] = [2, 2]; + break; + case 5: + case 6: + [rows, columns] = [2, 3]; + break; +} -const RUST_LOG = process.env.RUST_LOG || "info"; +const instanceWidth = Math.floor(screenWidth / columns); +const instanceHeight = Math.floor(screenHeight / rows); // If a user is specified, make sure it's first in the list const user = process.env.ZED_IMPERSONATE; @@ -56,41 +108,42 @@ if (user) { users = [user].concat(users.filter((u) => u !== user)); } -const positions = [ - "0,0", - `${instanceWidth},0`, - `0,${instanceHeight}`, - `${instanceWidth},${instanceHeight}`, -]; +let buildArgs = ["build"]; +let zedBinary = "target/debug/Zed"; +if (isReleaseMode) { + buildArgs.push("--release"); + zedBinary = "target/release/Zed"; +} -const buildArgs = (() => { - const buildArgs = ["build"]; - if (isReleaseMode) { - buildArgs.push("--release"); - } +try { + execFileSync("cargo", buildArgs, { stdio: "inherit" }); +} catch (e) { + process.exit(0); +} - return buildArgs; -})(); -const zedBinary = (() => { - const target = isReleaseMode ? "release" : "debug"; - return `target/${target}/Zed`; -})(); - -execFileSync("cargo", buildArgs, { stdio: "inherit" }); setTimeout(() => { for (let i = 0; i < instanceCount; i++) { + const row = Math.floor(i / columns); + const column = i % columns; + const position = [ + column * instanceWidth, + row * instanceHeight + titleBarHeight, + ].join(","); + const size = [instanceWidth, instanceHeight].join(","); + spawn(zedBinary, i == 0 ? args : [], { stdio: "inherit", env: { ZED_IMPERSONATE: users[i], - ZED_WINDOW_POSITION: positions[i], + ZED_WINDOW_POSITION: position, ZED_STATELESS: "1", ZED_ALWAYS_ACTIVE: "1", - ZED_SERVER_URL: "http://localhost:8080", + ZED_SERVER_URL: "http://localhost:3000", + ZED_RPC_URL: "http://localhost:8080/rpc", ZED_ADMIN_API_TOKEN: "secret", - ZED_WINDOW_SIZE: `${instanceWidth},${instanceHeight}`, + ZED_WINDOW_SIZE: size, PATH: process.env.PATH, - RUST_LOG, + RUST_LOG: process.env.RUST_LOG || "info", }, }); } diff --git a/typos.toml b/typos.toml new file mode 100644 index 0000000000..115cc14478 --- /dev/null +++ b/typos.toml @@ -0,0 +1,22 @@ +[files] +ignore-files = true +extend-exclude = [ + # glsl isn't recognized by this tool + "crates/zed/src/languages/glsl/*", + # File suffixes aren't typos + "assets/icons/file_icons/file_types.json", + # Not our typos + "assets/themes/src/vscode/*", + "crates/live_kit_server/*", + # Vim makes heavy use of partial typing tables + "crates/vim/*", + # Editor and file finder rely on partial typing and custom in-string syntax + "crates/file_finder/src/file_finder.rs", + "crates/editor/src/editor_tests.rs", + # :/ + "crates/collab/migrations/20231009181554_add_release_channel_to_rooms.sql", +] + +[default] +extend-ignore-re = ["ba"] +check-filename = true