Merge branch 'main' into language-api-docs

This commit is contained in:
Max Brunsfeld 2024-01-18 15:04:54 -08:00
commit b65cf6d2d9
382 changed files with 12764 additions and 7823 deletions

View file

@ -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

23
.github/actions/check_style/action.yml vendored Normal file
View file

@ -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

View file

@ -2,29 +2,26 @@ name: "Run tests"
description: "Runs the tests" description: "Runs the tests"
runs: runs:
using: "composite" using: "composite"
steps: steps:
- name: Install Rust - name: Install Rust
shell: bash -euxo pipefail {0} shell: bash -euxo pipefail {0}
run: | run: |
rustup set profile minimal cargo install cargo-nextest
rustup update stable
rustup target add wasm32-wasi
cargo install cargo-nextest
- name: Install Node - name: Install Node
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: "18" node-version: "18"
- name: Limit target directory size - name: Limit target directory size
shell: bash -euxo pipefail {0} shell: bash -euxo pipefail {0}
run: script/clear-target-dir-if-larger-than 100 run: script/clear-target-dir-if-larger-than 100
- name: Run check - name: Run check
shell: bash -euxo pipefail {0} shell: bash -euxo pipefail {0}
run: cargo check --tests --workspace run: cargo check --tests --workspace
- name: Run tests - name: Run tests
shell: bash -euxo pipefail {0} shell: bash -euxo pipefail {0}
run: cargo nextest run --workspace --no-fail-fast run: cargo nextest run --workspace --no-fail-fast

View file

@ -22,8 +22,8 @@ env:
RUST_BACKTRACE: 1 RUST_BACKTRACE: 1
jobs: jobs:
rustfmt: style:
name: Check formatting name: Check formatting, Clippy lints, and spelling
runs-on: runs-on:
- self-hosted - self-hosted
- test - test
@ -33,19 +33,27 @@ jobs:
with: with:
clean: false clean: false
submodules: "recursive" submodules: "recursive"
fetch-depth: 0
- name: Set up default .cargo/config.toml - name: Set up default .cargo/config.toml
run: cp ./.cargo/ci-config.toml ~/.cargo/config.toml run: cp ./.cargo/ci-config.toml ~/.cargo/config.toml
- name: Run rustfmt - name: Check spelling
uses: ./.github/actions/check_formatting run: |
if ! which typos > /dev/null; then
cargo install typos-cli
fi
typos
- name: Run style checks
uses: ./.github/actions/check_style
tests: tests:
name: Run tests name: Run tests
runs-on: runs-on:
- self-hosted - self-hosted
- test - test
needs: rustfmt needs: style
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v3
@ -75,14 +83,6 @@ jobs:
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }} APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
steps: 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 - name: Install Node
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:

View file

@ -3,41 +3,36 @@ name: Randomized Tests
concurrency: randomized-tests concurrency: randomized-tests
on: on:
push: push:
branches: branches:
- randomized-tests-runner - randomized-tests-runner
# schedule: # schedule:
# - cron: '0 * * * *' # - cron: '0 * * * *'
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0 CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1 RUST_BACKTRACE: 1
ZED_SERVER_URL: https://zed.dev ZED_SERVER_URL: https://zed.dev
ZED_CLIENT_SECRET_TOKEN: ${{ secrets.ZED_CLIENT_SECRET_TOKEN }} ZED_CLIENT_SECRET_TOKEN: ${{ secrets.ZED_CLIENT_SECRET_TOKEN }}
jobs: jobs:
tests: tests:
name: Run randomized tests name: Run randomized tests
runs-on: runs-on:
- self-hosted - self-hosted
- randomized-tests - randomized-tests
steps: steps:
- name: Install Rust - name: Install Node
run: | uses: actions/setup-node@v3
rustup set profile minimal with:
rustup update stable node-version: "18"
- name: Install Node - name: Checkout repo
uses: actions/setup-node@v3 uses: actions/checkout@v3
with: with:
node-version: '18' clean: false
submodules: "recursive"
- name: Checkout repo - name: Run randomized tests
uses: actions/checkout@v3 run: script/randomized-test-ci
with:
clean: false
submodules: 'recursive'
- name: Run randomized tests
run: script/randomized-test-ci

View file

@ -14,8 +14,8 @@ env:
RUST_BACKTRACE: 1 RUST_BACKTRACE: 1
jobs: jobs:
rustfmt: style:
name: Check formatting name: Check formatting and Clippy lints
runs-on: runs-on:
- self-hosted - self-hosted
- test - test
@ -25,16 +25,17 @@ jobs:
with: with:
clean: false clean: false
submodules: "recursive" submodules: "recursive"
fetch-depth: 0
- name: Run rustfmt - name: Run style checks
uses: ./.github/actions/check_formatting uses: ./.github/actions/check_style
tests: tests:
name: Run tests name: Run tests
runs-on: runs-on:
- self-hosted - self-hosted
- test - test
needs: rustfmt needs: style
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v3
@ -59,14 +60,6 @@ jobs:
DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
steps: 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 - name: Install Node
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:

1
.gitignore vendored
View file

@ -9,6 +9,7 @@
/styles/src/types/zed.ts /styles/src/types/zed.ts
/crates/theme/schemas/theme.json /crates/theme/schemas/theme.json
/crates/collab/static/styles.css /crates/collab/static/styles.css
/crates/collab/.admins.json
/vendor/bin /vendor/bin
/assets/themes/*.json /assets/themes/*.json
/assets/*licenses.md /assets/*licenses.md

39
.mailmap Normal file
View file

@ -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 <me@as-cii.com>
Antonio Scandurra <me@as-cii.com> <antonio@zed.dev>
Joseph T. Lyons <JosephTLyons@gmail.com>
Joseph T. Lyons <JosephTLyons@gmail.com> <JosephTLyons@users.noreply.github.com>
Julia <floc@unpromptedtirade.com>
Julia <floc@unpromptedtirade.com> <30666851+ForLoveOfCats@users.noreply.github.com>
Kaylee Simmons <kay@the-simmons.net>
Kaylee Simmons <kay@the-simmons.net> <kay@zed.dev>
Kaylee Simmons <kay@the-simmons.net> <keith@the-simmons.net>
Kaylee Simmons <kay@the-simmons.net> <keith@zed.dev>
Kirill Bulatov <kirill@zed.dev>
Kirill Bulatov <kirill@zed.dev> <mail4score@gmail.com>
Kyle Caverly <kylebcaverly@gmail.com>
Kyle Caverly <kylebcaverly@gmail.com> <kyle@zed.dev>
Marshall Bowers <elliott.codes@gmail.com>
Marshall Bowers <elliott.codes@gmail.com> <marshall@zed.dev>
Max Brunsfeld <maxbrunsfeld@gmail.com>
Max Brunsfeld <maxbrunsfeld@gmail.com> <max@zed.dev>
Mikayla Maki <mikayla@zed.dev>
Mikayla Maki <mikayla@zed.dev> <mikayla.c.maki@gmail.com>
Mikayla Maki <mikayla@zed.dev> <mikayla.c.maki@icloud.com>
Nate Butler <iamnbutler@gmail.com>
Nate Butler <iamnbutler@gmail.com> <nate@zed.dev>
Nathan Sobo <nathan@zed.dev>
Nathan Sobo <nathan@zed.dev> <nathan@warp.dev>
Nathan Sobo <nathan@zed.dev> <nathansobo@gmail.com>
Piotr Osiewicz <piotr@zed.dev>
Piotr Osiewicz <piotr@zed.dev> <24362066+osiewicz@users.noreply.github.com>

View file

@ -1,5 +1,6 @@
{ {
"JSON": { "JSON": {
"tab_size": 4 "tab_size": 4
} },
"formatter": "auto"
} }

57
CONTRIBUTING.md Normal file
View file

@ -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.

176
Cargo.lock generated
View file

@ -1452,7 +1452,7 @@ dependencies = [
[[package]] [[package]]
name = "collab" name = "collab"
version = "0.35.0" version = "0.37.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -1473,6 +1473,7 @@ dependencies = [
"editor", "editor",
"env_logger", "env_logger",
"envy", "envy",
"file_finder",
"fs", "fs",
"futures 0.3.28", "futures 0.3.28",
"git", "git",
@ -1486,6 +1487,7 @@ dependencies = [
"live_kit_server", "live_kit_server",
"log", "log",
"lsp", "lsp",
"menu",
"nanoid", "nanoid",
"node_runtime", "node_runtime",
"notifications", "notifications",
@ -1559,6 +1561,7 @@ dependencies = [
"serde_json", "serde_json",
"settings", "settings",
"smallvec", "smallvec",
"story",
"theme", "theme",
"theme_selector", "theme_selector",
"time", "time",
@ -1577,6 +1580,28 @@ dependencies = [
"rustc-hash", "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]] [[package]]
name = "color_quant" name = "color_quant"
version = "1.1.0" version = "1.1.0"
@ -1604,6 +1629,7 @@ name = "command_palette"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"client",
"collections", "collections",
"ctor", "ctor",
"editor", "editor",
@ -1946,6 +1972,16 @@ dependencies = [
"syn 2.0.37", "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]] [[package]]
name = "curl" name = "curl"
version = "0.4.44" version = "0.4.44"
@ -2519,6 +2555,7 @@ dependencies = [
name = "file_finder" name = "file_finder"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"collections", "collections",
"ctor", "ctor",
"editor", "editor",
@ -2606,7 +2643,7 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "font-kit" name = "font-kit"
version = "0.11.0" 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 = [ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"byteorder", "byteorder",
@ -4485,6 +4522,17 @@ dependencies = [
"libc", "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]] [[package]]
name = "node_runtime" name = "node_runtime"
version = "0.1.0" version = "0.1.0"
@ -4951,6 +4999,7 @@ dependencies = [
"approx", "approx",
"fast-srgb8", "fast-srgb8",
"palette_derive", "palette_derive",
"phf",
] ]
[[package]] [[package]]
@ -5139,6 +5188,48 @@ dependencies = [
"indexmap 2.0.0", "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]] [[package]]
name = "picker" name = "picker"
version = "0.1.0" version = "0.1.0"
@ -7048,6 +7139,12 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b8de496cf83d4ed58b6be86c3a275b8602f6ffe98d3024a869e124147a9a3ac" checksum = "0b8de496cf83d4ed58b6be86c3a275b8602f6ffe98d3024a869e124147a9a3ac"
[[package]]
name = "siphasher"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.9" version = "0.4.9"
@ -7447,6 +7544,8 @@ dependencies = [
"backtrace-on-stack-overflow", "backtrace-on-stack-overflow",
"chrono", "chrono",
"clap 4.4.4", "clap 4.4.4",
"collab_ui",
"ctrlc",
"dialoguer", "dialoguer",
"editor", "editor",
"fuzzy", "fuzzy",
@ -7616,7 +7715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c536faaff1a10837cfe373142583f6e27d81e96beba339147e77b67c9f260ff" checksum = "9c536faaff1a10837cfe373142583f6e27d81e96beba339147e77b67c9f260ff"
dependencies = [ dependencies = [
"float-cmp", "float-cmp",
"siphasher", "siphasher 0.2.3",
] ]
[[package]] [[package]]
@ -7747,6 +7846,7 @@ dependencies = [
"schemars", "schemars",
"serde", "serde",
"serde_derive", "serde_derive",
"serde_json",
"settings", "settings",
"shellexpand", "shellexpand",
"smallvec", "smallvec",
@ -7826,6 +7926,7 @@ name = "theme"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"color",
"fs", "fs",
"gpui", "gpui",
"indexmap 1.9.3", "indexmap 1.9.3",
@ -8829,7 +8930,7 @@ dependencies = [
"roxmltree", "roxmltree",
"rustybuzz", "rustybuzz",
"simplecss", "simplecss",
"siphasher", "siphasher 0.2.3",
"svgtypes", "svgtypes",
"ttf-parser 0.12.3", "ttf-parser 0.12.3",
"unicode-bidi", "unicode-bidi",
@ -9283,6 +9384,15 @@ dependencies = [
"windows-targets 0.48.5", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.42.2" version = "0.42.2"
@ -9313,6 +9423,21 @@ dependencies = [
"windows_x86_64_msvc 0.48.5", "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]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.42.2" version = "0.42.2"
@ -9325,6 +9450,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.42.2" version = "0.42.2"
@ -9337,6 +9468,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.42.2" version = "0.42.2"
@ -9349,6 +9486,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.42.2" version = "0.42.2"
@ -9361,6 +9504,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.42.2" version = "0.42.2"
@ -9373,6 +9522,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.42.2" version = "0.42.2"
@ -9385,6 +9540,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.42.2" version = "0.42.2"
@ -9397,6 +9558,12 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 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]] [[package]]
name = "winnow" name = "winnow"
version = "0.5.15" version = "0.5.15"
@ -9555,6 +9722,7 @@ dependencies = [
"client", "client",
"collab_ui", "collab_ui",
"collections", "collections",
"color",
"command_palette", "command_palette",
"copilot", "copilot",
"copilot_ui", "copilot_ui",

View file

@ -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 collab: cd crates/collab && RUST_LOG=${RUST_LOG:-warn,collab=info} cargo run serve
livekit: livekit-server --dev livekit: livekit-server --dev
postgrest: postgrest crates/collab/admin_api.conf

109
README.md
View file

@ -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 # Zed
[![CI](https://github.com/zed-industries/zed/actions/workflows/ci.yml/badge.svg)](https://github.com/zed-industries/zed/actions/workflows/ci.yml) [![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 - [Building Zed](./docs/src/developing_zed__building_zed.md)
- [Running Collaboration Locally](./docs/src/developing_zed__local_collaboration.md)
* 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.
### Licensing ### 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: 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 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 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). - 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`.

View file

@ -402,7 +402,7 @@
"cmd-r": "workspace::ToggleRightDock", "cmd-r": "workspace::ToggleRightDock",
"cmd-j": "workspace::ToggleBottomDock", "cmd-j": "workspace::ToggleBottomDock",
"alt-cmd-y": "workspace::CloseAllDocks", "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-t": "theme_selector::Toggle",
"cmd-k cmd-s": "zed::OpenKeymap", "cmd-k cmd-s": "zed::OpenKeymap",
"cmd-t": "project_symbols::Toggle", "cmd-t": "project_symbols::Toggle",
@ -412,7 +412,8 @@
"cmd-shift-e": "project_panel::ToggleFocus", "cmd-shift-e": "project_panel::ToggleFocus",
"cmd-?": "assistant::ToggleFocus", "cmd-?": "assistant::ToggleFocus",
"cmd-alt-s": "workspace::SaveAll", "cmd-alt-s": "workspace::SaveAll",
"cmd-k m": "language_selector::Toggle" "cmd-k m": "language_selector::Toggle",
"escape": "workspace::Unfollow"
} }
}, },
// Bindings from Sublime Text // Bindings from Sublime Text

View file

@ -99,7 +99,7 @@
"ctrl-i": "pane::GoForward", "ctrl-i": "pane::GoForward",
"ctrl-]": "editor::GoToDefinition", "ctrl-]": "editor::GoToDefinition",
"escape": ["vim::SwitchMode", "Normal"], "escape": ["vim::SwitchMode", "Normal"],
"ctrl+[": ["vim::SwitchMode", "Normal"], "ctrl-[": ["vim::SwitchMode", "Normal"],
"v": "vim::ToggleVisual", "v": "vim::ToggleVisual",
"shift-v": "vim::ToggleVisualLine", "shift-v": "vim::ToggleVisualLine",
"ctrl-v": "vim::ToggleVisualBlock", "ctrl-v": "vim::ToggleVisualBlock",
@ -288,7 +288,7 @@
"context": "Editor && vim_mode == normal && vim_operator == none && !VimWaiting", "context": "Editor && vim_mode == normal && vim_operator == none && !VimWaiting",
"bindings": { "bindings": {
"escape": "editor::Cancel", "escape": "editor::Cancel",
"ctrl+[": "editor::Cancel" "ctrl-[": "editor::Cancel"
} }
}, },
{ {
@ -441,7 +441,7 @@
"r": ["vim::PushOperator", "Replace"], "r": ["vim::PushOperator", "Replace"],
"ctrl-c": ["vim::SwitchMode", "Normal"], "ctrl-c": ["vim::SwitchMode", "Normal"],
"escape": ["vim::SwitchMode", "Normal"], "escape": ["vim::SwitchMode", "Normal"],
"ctrl+[": ["vim::SwitchMode", "Normal"], "ctrl-[": ["vim::SwitchMode", "Normal"],
">": "editor::Indent", ">": "editor::Indent",
"<": "editor::Outdent", "<": "editor::Outdent",
"i": [ "i": [
@ -481,7 +481,7 @@
"tab": "vim::Tab", "tab": "vim::Tab",
"enter": "vim::Enter", "enter": "vim::Enter",
"escape": ["vim::SwitchMode", "Normal"], "escape": ["vim::SwitchMode", "Normal"],
"ctrl+[": ["vim::SwitchMode", "Normal"] "ctrl-[": ["vim::SwitchMode", "Normal"]
} }
}, },
{ {

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

View file

@ -36,7 +36,7 @@
// }, // },
"buffer_line_height": "comfortable", "buffer_line_height": "comfortable",
// The name of a font to use for rendering text in the UI // 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 // The OpenType features to enable for text in the UI
"ui_font_features": { "ui_font_features": {
// Disable ligatures: // Disable ligatures:

View file

@ -77,9 +77,6 @@ impl ActivityIndicator {
cx.observe(auto_updater, |_, _, cx| cx.notify()).detach(); cx.observe(auto_updater, |_, _, cx| cx.notify()).detach();
} }
// cx.observe_active_labeled_tasks(|_, cx| cx.notify())
// .detach();
Self { Self {
statuses: Default::default(), statuses: Default::default(),
project: project.clone(), 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() Default::default()
} }
} }
@ -307,7 +295,7 @@ impl Render for ActivityIndicator {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let content = self.content_to_render(cx); let content = self.content_to_render(cx);
let mut result = h_stack() let mut result = h_flex()
.id("activity-indicator") .id("activity-indicator")
.on_action(cx.listener(Self::show_error_message)) .on_action(cx.listener(Self::show_error_message))
.on_action(cx.listener(Self::dismiss_error_message)); .on_action(cx.listener(Self::dismiss_error_message));

View file

@ -81,8 +81,8 @@ impl PromptChain {
pub fn generate(&self, truncate: bool) -> anyhow::Result<(String, usize)> { pub fn generate(&self, truncate: bool) -> anyhow::Result<(String, usize)> {
// Argsort based on Prompt Priority // Argsort based on Prompt Priority
let seperator = "\n"; let separator = "\n";
let seperator_tokens = self.args.model.count_tokens(seperator)?; let separator_tokens = self.args.model.count_tokens(separator)?;
let mut sorted_indices = (0..self.templates.len()).collect::<Vec<_>>(); let mut sorted_indices = (0..self.templates.len()).collect::<Vec<_>>();
sorted_indices.sort_by_key(|&i| Reverse(&self.templates[i].0)); sorted_indices.sort_by_key(|&i| Reverse(&self.templates[i].0));
@ -104,7 +104,7 @@ impl PromptChain {
prompts[idx] = template_prompt; prompts[idx] = template_prompt;
if let Some(remaining_tokens) = tokens_outstanding { 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 { tokens_outstanding = if remaining_tokens > new_tokens {
Some(remaining_tokens - new_tokens) Some(remaining_tokens - new_tokens)
} else { } else {
@ -117,9 +117,9 @@ impl PromptChain {
prompts.retain(|x| x != ""); 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)?; 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))
} }
} }

View file

@ -68,7 +68,7 @@ impl PromptTemplate for RepositoryContext {
let mut prompt = String::new(); let mut prompt = String::new();
let mut remaining_tokens = max_token_length.clone(); 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 { for snippet in &args.snippets {
let mut snippet_prompt = template.to_string(); let mut snippet_prompt = template.to_string();
let content = snippet.to_string(); let content = snippet.to_string();
@ -79,9 +79,9 @@ impl PromptTemplate for RepositoryContext {
if let Some(tokens_left) = remaining_tokens { if let Some(tokens_left) = remaining_tokens {
if tokens_left >= token_count { if tokens_left >= token_count {
writeln!(prompt, "{snippet_prompt}").unwrap(); 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 { } else {
Some(0) Some(0)
}; };

View file

@ -273,7 +273,7 @@ impl CompletionProvider for OpenAICompletionProvider {
) -> BoxFuture<'static, Result<BoxStream<'static, Result<String>>>> { ) -> BoxFuture<'static, Result<BoxStream<'static, Result<String>>>> {
// Currently the CompletionRequest for OpenAI, includes a 'model' parameter // Currently the CompletionRequest for OpenAI, includes a 'model' parameter
// This means that the model is determined by the CompletionRequest and not the CompletionProvider, // 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. // At some point in the future we should rectify this.
let credential = self.credential.read().clone(); let credential = self.credential.read().clone();
let request = stream_completion(credential, self.executor.clone(), prompt); let request = stream_completion(credential, self.executor.clone(), prompt);

View file

@ -19,12 +19,13 @@ use chrono::{DateTime, Local};
use client::telemetry::AssistantKind; use client::telemetry::AssistantKind;
use collections::{hash_map, HashMap, HashSet, VecDeque}; use collections::{hash_map, HashMap, HashSet, VecDeque};
use editor::{ use editor::{
actions::{MoveDown, MoveUp},
display_map::{ display_map::{
BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint, BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint,
}, },
scroll::autoscroll::{Autoscroll, AutoscrollStrategy}, scroll::{Autoscroll, AutoscrollStrategy},
Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MoveDown, MoveUp, MultiBufferSnapshot, Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MultiBufferSnapshot, ToOffset,
ToOffset, ToPoint, ToPoint,
}; };
use fs::Fs; use fs::Fs;
use futures::StreamExt; use futures::StreamExt;
@ -40,7 +41,7 @@ use language::{language_settings::SoftWrap, Buffer, LanguageRegistry, ToOffset a
use project::Project; use project::Project;
use search::{buffer_search::DivRegistrar, BufferSearchBar}; use search::{buffer_search::DivRegistrar, BufferSearchBar};
use semantic_index::{SemanticIndex, SemanticIndexStatus}; use semantic_index::{SemanticIndex, SemanticIndexStatus};
use settings::{Settings, SettingsStore}; use settings::Settings;
use std::{ use std::{
cell::Cell, cell::Cell,
cmp, cmp,
@ -165,7 +166,7 @@ impl AssistantPanel {
cx.on_focus_in(&focus_handle, Self::focus_in).detach(); cx.on_focus_in(&focus_handle, Self::focus_in).detach();
cx.on_focus_out(&focus_handle, Self::focus_out).detach(); cx.on_focus_out(&focus_handle, Self::focus_out).detach();
let mut this = Self { Self {
workspace: workspace_handle, workspace: workspace_handle,
active_editor_index: Default::default(), active_editor_index: Default::default(),
prev_active_editor_index: Default::default(), prev_active_editor_index: Default::default(),
@ -190,20 +191,7 @@ impl AssistantPanel {
_watch_saved_conversations, _watch_saved_conversations,
semantic_index, semantic_index,
retrieve_context_in_next_inline_assist: false, retrieve_context_in_next_inline_assist: false,
}; }
let mut old_dock_position = this.position(cx);
this.subscriptions =
vec![cx.observe_global::<SettingsStore>(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( fn cancel_last_inline_assist(
workspace: &mut Workspace, workspace: &mut Workspace,
_: &editor::Cancel, _: &editor::actions::Cancel,
cx: &mut ViewContext<Workspace>, cx: &mut ViewContext<Workspace>,
) { ) {
if let Some(panel) = workspace.panel::<AssistantPanel>(cx) { if let Some(panel) = workspace.panel::<AssistantPanel>(cx) {
@ -904,7 +892,7 @@ impl AssistantPanel {
} }
} }
fn handle_editor_cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext<Self>) { fn handle_editor_cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext<Self>) {
if let Some(search_bar) = self.toolbar.read(cx).item_of_type::<BufferSearchBar>() { if let Some(search_bar) = self.toolbar.read(cx).item_of_type::<BufferSearchBar>() {
if !search_bar.read(cx).is_dismissed() { if !search_bar.read(cx).is_dismissed() {
search_bar.update(cx, |search_bar, cx| { search_bar.update(cx, |search_bar, cx| {
@ -932,8 +920,41 @@ impl AssistantPanel {
self.editors.get(self.active_editor_index?) self.editors.get(self.active_editor_index?)
} }
fn render_api_key_editor(
&self,
editor: &View<Editor>,
cx: &mut ViewContext<Self>,
) -> 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<Self>) -> impl IntoElement { fn render_hamburger_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
IconButton::new("hamburger_button", Icon::Menu) IconButton::new("hamburger_button", IconName::Menu)
.on_click(cx.listener(|this, _event, cx| { .on_click(cx.listener(|this, _event, cx| {
if this.active_editor().is_some() { if this.active_editor().is_some() {
this.set_active_editor_index(None, cx); this.set_active_editor_index(None, cx);
@ -957,7 +978,7 @@ impl AssistantPanel {
} }
fn render_split_button(cx: &mut ViewContext<Self>) -> impl IntoElement { fn render_split_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
IconButton::new("split_button", Icon::Snip) IconButton::new("split_button", IconName::Snip)
.on_click(cx.listener(|this, _event, cx| { .on_click(cx.listener(|this, _event, cx| {
if let Some(active_editor) = this.active_editor() { if let Some(active_editor) = this.active_editor() {
active_editor.update(cx, |editor, cx| editor.split(&Default::default(), cx)); active_editor.update(cx, |editor, cx| editor.split(&Default::default(), cx));
@ -968,7 +989,7 @@ impl AssistantPanel {
} }
fn render_assist_button(cx: &mut ViewContext<Self>) -> impl IntoElement { fn render_assist_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
IconButton::new("assist_button", Icon::MagicWand) IconButton::new("assist_button", IconName::MagicWand)
.on_click(cx.listener(|this, _event, cx| { .on_click(cx.listener(|this, _event, cx| {
if let Some(active_editor) = this.active_editor() { if let Some(active_editor) = this.active_editor() {
active_editor.update(cx, |editor, cx| editor.assist(&Default::default(), cx)); active_editor.update(cx, |editor, cx| editor.assist(&Default::default(), cx));
@ -979,7 +1000,7 @@ impl AssistantPanel {
} }
fn render_quote_button(cx: &mut ViewContext<Self>) -> impl IntoElement { fn render_quote_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
IconButton::new("quote_button", Icon::Quote) IconButton::new("quote_button", IconName::Quote)
.on_click(cx.listener(|this, _event, cx| { .on_click(cx.listener(|this, _event, cx| {
if let Some(workspace) = this.workspace.upgrade() { if let Some(workspace) = this.workspace.upgrade() {
cx.window_context().defer(move |cx| { cx.window_context().defer(move |cx| {
@ -994,7 +1015,7 @@ impl AssistantPanel {
} }
fn render_plus_button(cx: &mut ViewContext<Self>) -> impl IntoElement { fn render_plus_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
IconButton::new("plus_button", Icon::Plus) IconButton::new("plus_button", IconName::Plus)
.on_click(cx.listener(|this, _event, cx| { .on_click(cx.listener(|this, _event, cx| {
this.new_conversation(cx); this.new_conversation(cx);
})) }))
@ -1004,12 +1025,12 @@ impl AssistantPanel {
fn render_zoom_button(&self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render_zoom_button(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let zoomed = self.zoomed; let zoomed = self.zoomed;
IconButton::new("zoom_button", Icon::Maximize) IconButton::new("zoom_button", IconName::Maximize)
.on_click(cx.listener(|this, _event, cx| { .on_click(cx.listener(|this, _event, cx| {
this.toggle_zoom(&ToggleZoom, cx); this.toggle_zoom(&ToggleZoom, cx);
})) }))
.selected(zoomed) .selected(zoomed)
.selected_icon(Icon::Minimize) .selected_icon(IconName::Minimize)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.tooltip(move |cx| { .tooltip(move |cx| {
Tooltip::for_action(if zoomed { "Zoom Out" } else { "Zoom In" }, &ToggleZoom, 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<AssistantPanel>) -> View<Editor> {
impl Render for AssistantPanel { impl Render for AssistantPanel {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
if let Some(api_key_editor) = self.api_key_editor.clone() { 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)) .on_action(cx.listener(AssistantPanel::save_credentials))
.track_focus(&self.focus_handle) .track_focus(&self.focus_handle)
.child(Label::new( .children(
"To use the assistant panel or inline assistant, you need to add your OpenAI api key.", INSTRUCTIONS.map(|instruction| Label::new(instruction).size(LabelSize::Small)),
)) )
.child(Label::new( .child(
" - Having a subscription for another service like GitHub Copilot won't work." h_flex()
)) .w_full()
.child(Label::new( .my_2()
" - You can create a api key at: platform.openai.com/api-keys" .px_2()
)) .py_1()
.child(Label::new( .bg(cx.theme().colors().editor_background)
" " .rounded_md()
)) .child(self.render_api_key_editor(&api_key_editor, cx)),
.child(Label::new( )
"Paste your OpenAI API key and press Enter to use the assistant" .child(
)) h_flex()
.child(api_key_editor) .gap_2()
.child(Label::new( .child(Label::new("Click on").size(LabelSize::Small))
"Click on the Z button in the status bar to close this panel." .child(Icon::new(IconName::Ai).size(IconSize::XSmall))
)) .child(
Label::new("in the status bar to close this panel.")
.size(LabelSize::Small),
),
)
} else { } else {
let header = TabBar::new("assistant_header") let header = TabBar::new("assistant_header")
.start_child( .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| { .children(self.active_editor().map(|editor| {
h_stack() h_flex()
.h(rems(Tab::HEIGHT_IN_REMS)) .h(rems(Tab::CONTAINER_HEIGHT_IN_REMS))
.flex_1() .flex_1()
.px_2() .px_2()
.child(Label::new(editor.read(cx).title(cx)).into_element()) .child(Label::new(editor.read(cx).title(cx)).into_element())
})) }))
.end_child(if self.focus_handle.contains_focused(cx) { .end_child(if self.focus_handle.contains_focused(cx) {
h_stack() h_flex()
.gap_2() .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( .child(
ui::Divider::vertical() ui::Divider::vertical()
.inset() .inset()
.color(ui::DividerColor::Border), .color(ui::DividerColor::Border),
) )
.child( .child(
h_stack() h_flex()
.gap_1() .gap_1()
.child(Self::render_plus_button(cx)) .child(Self::render_plus_button(cx))
.child(self.render_zoom_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::<BufferSearchBar>(), |panel, cx| panel.toolbar.read(cx).item_of_type::<BufferSearchBar>(),
cx, cx,
); );
BufferSearchBar::register_inner(&mut registrar); BufferSearchBar::register(&mut registrar);
registrar.into_div() registrar.into_div()
} else { } else {
div() div()
}; };
v_stack() v_flex()
.key_context("AssistantPanel") .key_context("AssistantPanel")
.size_full() .size_full()
.on_action(cx.listener(|this, _: &workspace::NewFile, cx| { .on_action(cx.listener(|this, _: &workspace::NewFile, cx| {
@ -1286,8 +1321,8 @@ impl Panel for AssistantPanel {
} }
} }
fn icon(&self, cx: &WindowContext) -> Option<Icon> { fn icon(&self, cx: &WindowContext) -> Option<IconName> {
Some(Icon::Ai).filter(|_| AssistantSettings::get_global(cx).button) Some(IconName::Ai).filter(|_| AssistantSettings::get_global(cx).button)
} }
fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> { 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<Self>) { fn cancel_last_assist(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext<Self>) {
if !self if !self
.conversation .conversation
.update(cx, |conversation, _| conversation.cancel_last_assist()) .update(cx, |conversation, _| conversation.cancel_last_assist())
@ -2324,8 +2359,7 @@ impl ConversationEditor {
} }
}); });
div() h_flex()
.h_flex()
.id(("message_header", message_id.0)) .id(("message_header", message_id.0))
.h_11() .h_11()
.relative() .relative()
@ -2341,6 +2375,7 @@ impl ConversationEditor {
.add_suffix(true) .add_suffix(true)
.to_string(), .to_string(),
) )
.size(LabelSize::XSmall)
.color(Color::Muted), .color(Color::Muted),
) )
.children( .children(
@ -2349,7 +2384,7 @@ impl ConversationEditor {
div() div()
.id("error") .id("error")
.tooltip(move |cx| Tooltip::text(error.clone(), cx)) .tooltip(move |cx| Tooltip::text(error.clone(), cx))
.child(IconElement::new(Icon::XCircle)), .child(Icon::new(IconName::XCircle)),
) )
} else { } else {
None None
@ -2430,7 +2465,7 @@ impl ConversationEditor {
} }
} }
fn copy(&mut self, _: &editor::Copy, cx: &mut ViewContext<Self>) { fn copy(&mut self, _: &editor::actions::Copy, cx: &mut ViewContext<Self>) {
let editor = self.editor.read(cx); let editor = self.editor.read(cx);
let conversation = self.conversation.read(cx); let conversation = self.conversation.read(cx);
if editor.selections.count() == 1 { if editor.selections.count() == 1 {
@ -2543,7 +2578,7 @@ impl Render for ConversationEditor {
.child(self.editor.clone()), .child(self.editor.clone()),
) )
.child( .child(
h_stack() h_flex()
.absolute() .absolute()
.gap_1() .gap_1()
.top_3() .top_3()
@ -2629,7 +2664,7 @@ impl EventEmitter<InlineAssistantEvent> for InlineAssistant {}
impl Render for InlineAssistant { impl Render for InlineAssistant {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
let measurements = self.measurements.get(); let measurements = self.measurements.get();
h_stack() h_flex()
.w_full() .w_full()
.py_2() .py_2()
.border_y_1() .border_y_1()
@ -2641,11 +2676,11 @@ impl Render for InlineAssistant {
.on_action(cx.listener(Self::move_up)) .on_action(cx.listener(Self::move_up))
.on_action(cx.listener(Self::move_down)) .on_action(cx.listener(Self::move_down))
.child( .child(
h_stack() h_flex()
.justify_center() .justify_center()
.w(measurements.gutter_width) .w(measurements.gutter_width)
.child( .child(
IconButton::new("include_conversation", Icon::Ai) IconButton::new("include_conversation", IconName::Ai)
.on_click(cx.listener(|this, _, cx| { .on_click(cx.listener(|this, _, cx| {
this.toggle_include_conversation(&ToggleIncludeConversation, cx) this.toggle_include_conversation(&ToggleIncludeConversation, cx)
})) }))
@ -2660,7 +2695,7 @@ impl Render for InlineAssistant {
) )
.children(if SemanticIndex::enabled(cx) { .children(if SemanticIndex::enabled(cx) {
Some( Some(
IconButton::new("retrieve_context", Icon::MagnifyingGlass) IconButton::new("retrieve_context", IconName::MagnifyingGlass)
.on_click(cx.listener(|this, _, cx| { .on_click(cx.listener(|this, _, cx| {
this.toggle_retrieve_context(&ToggleRetrieveContext, cx) this.toggle_retrieve_context(&ToggleRetrieveContext, cx)
})) }))
@ -2682,14 +2717,14 @@ impl Render for InlineAssistant {
div() div()
.id("error") .id("error")
.tooltip(move |cx| Tooltip::text(error_message.clone(), cx)) .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 { } else {
None None
}), }),
) )
.child( .child(
h_stack() h_flex()
.w_full() .w_full()
.ml(measurements.anchor_x - measurements.gutter_width) .ml(measurements.anchor_x - measurements.gutter_width)
.child(self.render_prompt_editor(cx)), .child(self.render_prompt_editor(cx)),
@ -2841,7 +2876,7 @@ impl InlineAssistant {
cx.notify(); cx.notify();
} }
fn cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext<Self>) { fn cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext<Self>) {
cx.emit(InlineAssistantEvent::Canceled); cx.emit(InlineAssistantEvent::Canceled);
} }
@ -2930,7 +2965,7 @@ impl InlineAssistant {
let semantic_permissioned = self.semantic_permissioned(cx); let semantic_permissioned = self.semantic_permissioned(cx);
if let Some(semantic_index) = SemanticIndex::global(cx) { if let Some(semantic_index) = SemanticIndex::global(cx) {
cx.spawn(|_, mut cx| async move { 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) { if semantic_permissioned.await.unwrap_or(false) {
semantic_index semantic_index
.update(&mut cx, |index, cx| index.index_project(project, cx))? .update(&mut cx, |index, cx| index.index_project(project, cx))?
@ -2957,7 +2992,7 @@ impl InlineAssistant {
div() div()
.id("error") .id("error")
.tooltip(|cx| Tooltip::text("Not Authenticated. Please ensure you have a valid 'OPENAI_API_KEY' in your environment variables.", cx)) .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() .into_any_element()
), ),
@ -2965,7 +3000,7 @@ impl InlineAssistant {
div() div()
.id("error") .id("error")
.tooltip(|cx| Tooltip::text("Not Indexed", cx)) .tooltip(|cx| Tooltip::text("Not Indexed", cx))
.child(IconElement::new(Icon::XCircle)) .child(Icon::new(IconName::XCircle))
.into_any_element() .into_any_element()
), ),
@ -2996,7 +3031,7 @@ impl InlineAssistant {
div() div()
.id("update") .id("update")
.tooltip(move |cx| Tooltip::text(status_text.clone(), cx)) .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() .into_any_element()
) )
} }
@ -3005,7 +3040,7 @@ impl InlineAssistant {
div() div()
.id("check") .id("check")
.tooltip(|cx| Tooltip::text("Index up to date", cx)) .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() .into_any_element()
), ),
} }
@ -3133,6 +3168,7 @@ mod tests {
use crate::MessageId; use crate::MessageId;
use ai::test::FakeCompletionProvider; use ai::test::FakeCompletionProvider;
use gpui::AppContext; use gpui::AppContext;
use settings::SettingsStore;
#[gpui::test] #[gpui::test]
fn test_inserting_and_removing_messages(cx: &mut AppContext) { fn test_inserting_and_removing_messages(cx: &mut AppContext) {

View file

@ -93,7 +93,9 @@ pub fn init(http_client: Arc<dyn HttpClient>, server_url: String, cx: &mut AppCo
cx.observe_new_views(|workspace: &mut Workspace, _cx| { cx.observe_new_views(|workspace: &mut Workspace, _cx| {
workspace.register_action(|_, action: &Check, cx| check(action, 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 // @nate - code to trigger update notification on launch
// todo!("remove this when Nate is done") // 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) { pub fn view_release_notes(_: &ViewReleaseNotes, cx: &mut AppContext) -> Option<()> {
if let Some(auto_updater) = AutoUpdater::get(cx) { let auto_updater = AutoUpdater::get(cx)?;
let release_channel = cx.try_global::<ReleaseChannel>()?;
if matches!(
release_channel,
ReleaseChannel::Stable | ReleaseChannel::Preview
) {
let auto_updater = auto_updater.read(cx); let auto_updater = auto_updater.read(cx);
let server_url = &auto_updater.server_url; let server_url = &auto_updater.server_url;
let release_channel = release_channel.dev_name();
let current_version = auto_updater.current_version; let current_version = auto_updater.current_version;
if cx.has_global::<ReleaseChannel>() { let url = format!("{server_url}/releases/{release_channel}/{current_version}");
match cx.global::<ReleaseChannel>() { cx.open_url(&url);
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}"))
}
}
}
} }
None
} }
pub fn notify_of_any_new_update(cx: &mut ViewContext<Workspace>) -> Option<()> { pub fn notify_of_any_new_update(cx: &mut ViewContext<Workspace>) -> Option<()> {
@ -257,11 +258,13 @@ impl AutoUpdater {
"{server_url}/api/releases/latest?token={ZED_SECRET_CLIENT_TOKEN}&asset=Zed.dmg" "{server_url}/api/releases/latest?token={ZED_SECRET_CLIENT_TOKEN}&asset=Zed.dmg"
); );
cx.update(|cx| { cx.update(|cx| {
if cx.has_global::<ReleaseChannel>() { if let Some(param) = cx
if let Some(param) = cx.global::<ReleaseChannel>().release_query_param() { .try_global::<ReleaseChannel>()
url_string += "&"; .map(|release_channel| release_channel.release_query_param())
url_string += 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, release_channel, telemetry) = cx.update(|cx| {
let installation_id = cx.global::<Arc<Client>>().telemetry().installation_id(); let installation_id = cx.global::<Arc<Client>>().telemetry().installation_id();
let release_channel = cx let release_channel = cx
.has_global::<ReleaseChannel>() .try_global::<ReleaseChannel>()
.then(|| cx.global::<ReleaseChannel>().display_name()); .map(|release_channel| release_channel.display_name());
let telemetry = TelemetrySettings::get_global(cx).metrics; let telemetry = TelemetrySettings::get_global(cx).metrics;
(installation_id, release_channel, telemetry) (installation_id, release_channel, telemetry)

View file

@ -4,7 +4,7 @@ use gpui::{
}; };
use menu::Cancel; use menu::Cancel;
use util::channel::ReleaseChannel; 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 { pub struct UpdateNotification {
version: SemanticVersion, version: SemanticVersion,
@ -16,12 +16,12 @@ impl Render for UpdateNotification {
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
let app_name = cx.global::<ReleaseChannel>().display_name(); let app_name = cx.global::<ReleaseChannel>().display_name();
v_stack() v_flex()
.on_action(cx.listener(UpdateNotification::dismiss)) .on_action(cx.listener(UpdateNotification::dismiss))
.elevation_3(cx) .elevation_3(cx)
.p_4() .p_4()
.child( .child(
h_stack() h_flex()
.justify_between() .justify_between()
.child(Label::new(format!( .child(Label::new(format!(
"Updated to {app_name} {}", "Updated to {app_name} {}",
@ -30,7 +30,7 @@ impl Render for UpdateNotification {
.child( .child(
div() div()
.id("cancel") .id("cancel")
.child(IconElement::new(Icon::Close)) .child(Icon::new(IconName::Close))
.cursor_pointer() .cursor_pointer()
.on_click(cx.listener(|this, _, cx| this.dismiss(&menu::Cancel, cx))), .on_click(cx.listener(|this, _, cx| this.dismiss(&menu::Cancel, cx))),
), ),
@ -40,7 +40,9 @@ impl Render for UpdateNotification {
.id("notes") .id("notes")
.child(Label::new("View the release notes")) .child(Label::new("View the release notes"))
.cursor_pointer() .cursor_pointer()
.on_click(|_, cx| crate::view_release_notes(&Default::default(), cx)), .on_click(|_, cx| {
crate::view_release_notes(&Default::default(), cx);
}),
) )
} }
} }

View file

@ -31,7 +31,7 @@ impl EventEmitter<ToolbarItemEvent> for Breadcrumbs {}
impl Render for Breadcrumbs { impl Render for Breadcrumbs {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let element = h_stack().text_ui(); let element = h_flex().text_ui();
let Some(active_item) = self.active_item.as_ref() else { let Some(active_item) = self.active_item.as_ref() else {
return element; return element;
}; };
@ -51,7 +51,7 @@ impl Render for Breadcrumbs {
Label::new("").color(Color::Muted).into_any_element() 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 match active_item
.downcast::<Editor>() .downcast::<Editor>()
.map(|editor| editor.downgrade()) .map(|editor| editor.downgrade())

View file

@ -239,7 +239,8 @@ impl ActiveCall {
if result.is_ok() { if result.is_ok() {
this.update(&mut cx, |this, cx| this.report_call_event("invite", cx))?; this.update(&mut cx, |this, cx| this.report_call_event("invite", cx))?;
} else { } else {
// TODO: Resport collaboration error //TODO: report collaboration error
log::error!("invite failed: {:?}", result);
} }
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| {
@ -282,7 +283,7 @@ impl ActiveCall {
return Task::ready(Err(anyhow!("cannot join while on another call"))); 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 call
} else { } else {
return Task::ready(Err(anyhow!("no incoming call"))); return Task::ready(Err(anyhow!("no incoming call")));
@ -441,6 +442,8 @@ impl ActiveCall {
.location .location
.as_ref() .as_ref()
.and_then(|location| location.upgrade()); .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)) room.update(cx, |room, cx| room.set_location(location.as_ref(), cx))
} }
} else { } else {

View file

@ -9,9 +9,12 @@ pub struct CallSettings {
pub mute_on_join: bool, pub mute_on_join: bool,
} }
/// Configuration of voice calls in Zed.
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
pub struct CallSettingsContent { pub struct CallSettingsContent {
/// Whether the microphone should be muted when joining a channel or a call. /// Whether the microphone should be muted when joining a channel or a call.
///
/// Default: false
pub mute_on_join: Option<bool>, pub mute_on_join: Option<bool>,
} }

View file

@ -15,10 +15,7 @@ use gpui::{
AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, Task, WeakModel, AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, Task, WeakModel,
}; };
use language::LanguageRegistry; use language::LanguageRegistry;
use live_kit_client::{ use live_kit_client::{LocalAudioTrack, LocalTrackPublication, LocalVideoTrack, RoomUpdate};
LocalAudioTrack, LocalTrackPublication, LocalVideoTrack, RemoteAudioTrackUpdate,
RemoteVideoTrackUpdate,
};
use postage::{sink::Sink, stream::Stream, watch}; use postage::{sink::Sink, stream::Stream, watch};
use project::Project; use project::Project;
use settings::Settings as _; use settings::Settings as _;
@ -29,6 +26,9 @@ pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum Event { pub enum Event {
RoomJoined {
channel_id: Option<u64>,
},
ParticipantLocationChanged { ParticipantLocationChanged {
participant_id: proto::PeerId, participant_id: proto::PeerId,
}, },
@ -52,7 +52,9 @@ pub enum Event {
RemoteProjectInvitationDiscarded { RemoteProjectInvitationDiscarded {
project_id: u64, project_id: u64,
}, },
Left, Left {
channel_id: Option<u64>,
},
} }
pub struct Room { pub struct Room {
@ -131,11 +133,11 @@ impl Room {
} }
}); });
let _maintain_video_tracks = cx.spawn({ let _handle_updates = cx.spawn({
let room = room.clone(); let room = room.clone();
move |this, mut cx| async move { move |this, mut cx| async move {
let mut track_video_changes = room.remote_video_track_updates(); let mut updates = room.updates();
while let Some(track_change) = track_video_changes.next().await { while let Some(update) = updates.next().await {
let this = if let Some(this) = this.upgrade() { let this = if let Some(this) = this.upgrade() {
this this
} else { } else {
@ -143,26 +145,7 @@ impl Room {
}; };
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| {
this.remote_video_track_updated(track_change, cx).log_err() this.live_kit_room_updated(update, 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()
}) })
.ok(); .ok();
} }
@ -172,13 +155,17 @@ impl Room {
let connect = room.connect(&connection_info.server_url, &connection_info.token); let connect = room.connect(&connection_info.server_url, &connection_info.token);
cx.spawn(|this, mut cx| async move { cx.spawn(|this, mut cx| async move {
connect.await?; connect.await?;
this.update(&mut cx, |this, cx| {
if !cx.update(|cx| Self::mute_on_join(cx))? { if !this.read_only() {
this.update(&mut cx, |this, cx| this.share_microphone(cx))? if let Some(live_kit) = &this.live_kit {
.await?; if !live_kit.muted_by_user && !live_kit.deafened {
} return this.share_microphone(cx);
}
anyhow::Ok(()) }
}
Task::ready(Ok(()))
})?
.await
}) })
.detach_and_log_err(cx); .detach_and_log_err(cx);
@ -187,11 +174,11 @@ impl Room {
screen_track: LocalTrack::None, screen_track: LocalTrack::None,
microphone_track: LocalTrack::None, microphone_track: LocalTrack::None,
next_publish_id: 0, next_publish_id: 0,
muted_by_user: false, muted_by_user: Self::mute_on_join(cx),
deafened: false, deafened: false,
speaking: false, speaking: false,
_maintain_room, _maintain_room,
_maintain_tracks: [_maintain_video_tracks, _maintain_audio_tracks], _handle_updates,
}) })
} else { } else {
None None
@ -375,7 +362,9 @@ impl Room {
pub(crate) fn leave(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> { pub(crate) fn leave(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
cx.notify(); cx.notify();
cx.emit(Event::Left); cx.emit(Event::Left {
channel_id: self.channel_id(),
});
self.leave_internal(cx) self.leave_internal(cx)
} }
@ -616,10 +605,39 @@ impl Room {
.map(|participant| participant.role) .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 { pub fn local_participant_is_admin(&self) -> bool {
self.local_participant.role == proto::ChannelRole::Admin self.local_participant.role == proto::ChannelRole::Admin
} }
pub fn set_participant_role(
&mut self,
user_id: u64,
role: proto::ChannelRole,
cx: &ModelContext<Self>,
) -> Task<Result<()>> {
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<User>] { pub fn pending_participants(&self) -> &[Arc<User>] {
&self.pending_participants &self.pending_participants
} }
@ -729,9 +747,21 @@ impl Room {
if this.local_participant.role != role { if this.local_participant.role != role {
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| { this.joined_projects.retain(|project| {
if let Some(project) = project.upgrade() { 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 true
} else { } else {
false false
@ -840,8 +870,8 @@ impl Room {
.remote_audio_track_publications(&user.id.to_string()); .remote_audio_track_publications(&user.id.to_string());
for track in video_tracks { for track in video_tracks {
this.remote_video_track_updated( this.live_kit_room_updated(
RemoteVideoTrackUpdate::Subscribed(track), RoomUpdate::SubscribedToRemoteVideoTrack(track),
cx, cx,
) )
.log_err(); .log_err();
@ -850,8 +880,8 @@ impl Room {
for (track, publication) in for (track, publication) in
audio_tracks.iter().zip(publications.iter()) audio_tracks.iter().zip(publications.iter())
{ {
this.remote_audio_track_updated( this.live_kit_room_updated(
RemoteAudioTrackUpdate::Subscribed( RoomUpdate::SubscribedToRemoteAudioTrack(
track.clone(), track.clone(),
publication.clone(), publication.clone(),
), ),
@ -942,13 +972,13 @@ impl Room {
} }
} }
fn remote_video_track_updated( fn live_kit_room_updated(
&mut self, &mut self,
change: RemoteVideoTrackUpdate, update: RoomUpdate,
cx: &mut ModelContext<Self>, cx: &mut ModelContext<Self>,
) -> Result<()> { ) -> Result<()> {
match change { match update {
RemoteVideoTrackUpdate::Subscribed(track) => { RoomUpdate::SubscribedToRemoteVideoTrack(track) => {
let user_id = track.publisher_id().parse()?; let user_id = track.publisher_id().parse()?;
let track_id = track.sid().to_string(); let track_id = track.sid().to_string();
let participant = self let participant = self
@ -960,7 +990,8 @@ impl Room {
participant_id: participant.peer_id, participant_id: participant.peer_id,
}); });
} }
RemoteVideoTrackUpdate::Unsubscribed {
RoomUpdate::UnsubscribedFromRemoteVideoTrack {
publisher_id, publisher_id,
track_id, track_id,
} => { } => {
@ -974,19 +1005,8 @@ impl Room {
participant_id: participant.peer_id, participant_id: participant.peer_id,
}); });
} }
}
cx.notify(); RoomUpdate::ActiveSpeakersChanged { speakers } => {
Ok(())
}
fn remote_audio_track_updated(
&mut self,
change: RemoteAudioTrackUpdate,
cx: &mut ModelContext<Self>,
) -> Result<()> {
match change {
RemoteAudioTrackUpdate::ActiveSpeakersChanged { speakers } => {
let mut speaker_ids = speakers let mut speaker_ids = speakers
.into_iter() .into_iter()
.filter_map(|speaker_sid| speaker_sid.parse().ok()) .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; let mut found = false;
for participant in &mut self.remote_participants.values_mut() { for participant in &mut self.remote_participants.values_mut() {
for track in participant.audio_tracks.values() { for track in participant.audio_tracks.values() {
@ -1024,10 +1044,18 @@ impl Room {
break; 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 user_id = track.publisher_id().parse()?;
let track_id = track.sid().to_string(); let track_id = track.sid().to_string();
let participant = self let participant = self
@ -1041,7 +1069,8 @@ impl Room {
participant_id: participant.peer_id, participant_id: participant.peer_id,
}); });
} }
RemoteAudioTrackUpdate::Unsubscribed {
RoomUpdate::UnsubscribedFromRemoteAudioTrack {
publisher_id, publisher_id,
track_id, track_id,
} => { } => {
@ -1055,6 +1084,28 @@ impl Room {
participant_id: participant.peer_id, 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(); cx.notify();
@ -1198,7 +1249,12 @@ impl Room {
}; };
self.client.send(proto::UnshareProject { project_id })?; 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( pub(crate) fn set_location(
@ -1254,15 +1310,12 @@ impl Room {
}) })
} }
pub fn is_muted(&self, cx: &AppContext) -> bool { pub fn is_muted(&self) -> bool {
self.live_kit self.live_kit.as_ref().map_or(false, |live_kit| {
.as_ref() matches!(live_kit.microphone_track, LocalTrack::None)
.and_then(|live_kit| match &live_kit.microphone_track { || live_kit.muted_by_user
LocalTrack::None => Some(Self::mute_on_join(cx)), || live_kit.deafened
LocalTrack::Pending { muted, .. } => Some(*muted), })
LocalTrack::Published { muted, .. } => Some(*muted),
})
.unwrap_or(false)
} }
pub fn read_only(&self) -> bool { pub fn read_only(&self) -> bool {
@ -1284,16 +1337,11 @@ impl Room {
pub fn share_microphone(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> { pub fn share_microphone(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
if self.status.is_offline() { if self.status.is_offline() {
return Task::ready(Err(anyhow!("room 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 = if let Some(live_kit) = self.live_kit.as_mut() {
let publish_id = post_inc(&mut live_kit.next_publish_id); let publish_id = post_inc(&mut live_kit.next_publish_id);
live_kit.microphone_track = LocalTrack::Pending { live_kit.microphone_track = LocalTrack::Pending { publish_id };
publish_id,
muted: false,
};
cx.notify(); cx.notify();
publish_id publish_id
} else { } else {
@ -1322,14 +1370,13 @@ impl Room {
.as_mut() .as_mut()
.ok_or_else(|| anyhow!("live-kit was not initialized"))?; .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, publish_id: cur_publish_id,
muted,
} = &live_kit.microphone_track } = &live_kit.microphone_track
{ {
(*cur_publish_id != publish_id, *muted) *cur_publish_id != publish_id
} else { } else {
(true, false) true
}; };
match publication { match publication {
@ -1337,14 +1384,13 @@ impl Room {
if canceled { if canceled {
live_kit.room.unpublish_track(publication); live_kit.room.unpublish_track(publication);
} else { } else {
if muted { if live_kit.muted_by_user || live_kit.deafened {
cx.background_executor() cx.background_executor()
.spawn(publication.set_mute(muted)) .spawn(publication.set_mute(true))
.detach(); .detach();
} }
live_kit.microphone_track = LocalTrack::Published { live_kit.microphone_track = LocalTrack::Published {
track_publication: publication, track_publication: publication,
muted,
}; };
cx.notify(); cx.notify();
} }
@ -1373,10 +1419,7 @@ impl Room {
let (displays, publish_id) = if let Some(live_kit) = self.live_kit.as_mut() { 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); let publish_id = post_inc(&mut live_kit.next_publish_id);
live_kit.screen_track = LocalTrack::Pending { live_kit.screen_track = LocalTrack::Pending { publish_id };
publish_id,
muted: false,
};
cx.notify(); cx.notify();
(live_kit.room.display_sources(), publish_id) (live_kit.room.display_sources(), publish_id)
} else { } else {
@ -1410,14 +1453,13 @@ impl Room {
.as_mut() .as_mut()
.ok_or_else(|| anyhow!("live-kit was not initialized"))?; .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, publish_id: cur_publish_id,
muted,
} = &live_kit.screen_track } = &live_kit.screen_track
{ {
(*cur_publish_id != publish_id, *muted) *cur_publish_id != publish_id
} else { } else {
(true, false) true
}; };
match publication { match publication {
@ -1425,14 +1467,8 @@ impl Room {
if canceled { if canceled {
live_kit.room.unpublish_track(publication); live_kit.room.unpublish_track(publication);
} else { } else {
if muted {
cx.background_executor()
.spawn(publication.set_mute(muted))
.detach();
}
live_kit.screen_track = LocalTrack::Published { live_kit.screen_track = LocalTrack::Published {
track_publication: publication, track_publication: publication,
muted,
}; };
cx.notify(); cx.notify();
} }
@ -1455,61 +1491,51 @@ impl Room {
}) })
} }
pub fn toggle_mute(&mut self, cx: &mut ModelContext<Self>) -> Result<Task<Result<()>>> { pub fn toggle_mute(&mut self, cx: &mut ModelContext<Self>) {
let should_mute = !self.is_muted(cx);
if let Some(live_kit) = self.live_kit.as_mut() { if let Some(live_kit) = self.live_kit.as_mut() {
if matches!(live_kit.microphone_track, LocalTrack::None) { // When unmuting, undeafen if the user was deafened before.
return Ok(self.share_microphone(cx)); 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)?; if should_undeafen {
live_kit.muted_by_user = should_mute; if let Some(task) = self.set_deafened(false, cx) {
task.detach_and_log_err(cx);
if old_muted == true && live_kit.deafened == true {
if let Some(task) = self.toggle_deafen(cx).ok() {
task.detach();
} }
} }
Ok(ret_task)
} else {
Err(anyhow!("LiveKit not started"))
} }
} }
pub fn toggle_deafen(&mut self, cx: &mut ModelContext<Self>) -> Result<Task<Result<()>>> { pub fn toggle_deafen(&mut self, cx: &mut ModelContext<Self>) {
if let Some(live_kit) = self.live_kit.as_mut() { 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()); if let Some(task) = self.set_deafened(deafened, cx) {
// Context notification is sent within set_mute itself. task.detach_and_log_err(cx);
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)));
}
} }
Ok(cx.foreground_executor().spawn(async move { if should_change_mute {
if let Some(mute_task) = mute_task { if let Some(task) = self.set_mute(deafened, cx) {
mute_task.await?; 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<Self>,
) -> Option<Task<Result<()>>> {
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<Room>,
) -> Option<Task<Result<()>>> {
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"))] #[cfg(any(test, feature = "test-support"))]
pub fn set_display_sources(&self, sources: Vec<live_kit_client::MacOSDisplay>) { pub fn set_display_sources(&self, sources: Vec<live_kit_client::MacOSDisplay>) {
self.live_kit self.live_kit
@ -1560,52 +1650,26 @@ struct LiveKitRoom {
speaking: bool, speaking: bool,
next_publish_id: usize, next_publish_id: usize,
_maintain_room: Task<()>, _maintain_room: Task<()>,
_maintain_tracks: [Task<()>; 2], _handle_updates: Task<()>,
} }
impl LiveKitRoom { impl LiveKitRoom {
fn set_mute( fn stop_publishing(&mut self, cx: &mut ModelContext<Room>) {
self: &mut LiveKitRoom, if let LocalTrack::Published {
should_mute: bool, track_publication, ..
cx: &mut ModelContext<Room>, } = mem::replace(&mut self.microphone_track, LocalTrack::None)
) -> Result<(Task<Result<()>>, bool)> { {
if !should_mute { self.room.unpublish_track(track_publication);
// clear user muting state. cx.notify();
self.muted_by_user = false;
} }
let (result, old_muted) = match &mut self.microphone_track { if let LocalTrack::Published {
LocalTrack::None => Err(anyhow!("microphone was not shared")), track_publication, ..
LocalTrack::Pending { muted, .. } => { } = mem::replace(&mut self.screen_track, LocalTrack::None)
let old_muted = *muted; {
*muted = should_mute; self.room.unpublish_track(track_publication);
cx.notify(); 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);
}
} }
Ok((result, old_muted))
} }
} }
@ -1613,11 +1677,9 @@ enum LocalTrack {
None, None,
Pending { Pending {
publish_id: usize, publish_id: usize,
muted: bool,
}, },
Published { Published {
track_publication: LocalTrackPublication, track_publication: LocalTrackPublication,
muted: bool,
}, },
} }

View file

@ -144,7 +144,7 @@ impl ChannelChat {
message: MessageParams, message: MessageParams,
cx: &mut ModelContext<Self>, cx: &mut ModelContext<Self>,
) -> Result<Task<Result<u64>>> { ) -> Result<Task<Result<u64>>> {
if message.text.is_empty() { if message.text.trim().is_empty() {
Err(anyhow!("message body can't be empty"))?; Err(anyhow!("message body can't be empty"))?;
} }
@ -174,6 +174,8 @@ impl ChannelChat {
let user_store = self.user_store.clone(); let user_store = self.user_store.clone();
let rpc = self.rpc.clone(); let rpc = self.rpc.clone();
let outgoing_messages_lock = self.outgoing_messages_lock.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 { Ok(cx.spawn(move |this, mut cx| async move {
let outgoing_message_guard = outgoing_messages_lock.lock().await; let outgoing_message_guard = outgoing_messages_lock.lock().await;
let request = rpc.request(proto::SendChannelMessage { let request = rpc.request(proto::SendChannelMessage {

View file

@ -52,6 +52,7 @@ pub use user::*;
lazy_static! { lazy_static! {
pub static ref ZED_SERVER_URL: String = pub static ref ZED_SERVER_URL: String =
std::env::var("ZED_SERVER_URL").unwrap_or_else(|_| "https://zed.dev".to_string()); std::env::var("ZED_SERVER_URL").unwrap_or_else(|_| "https://zed.dev".to_string());
pub static ref ZED_RPC_URL: Option<String> = std::env::var("ZED_RPC_URL").ok();
pub static ref IMPERSONATE_LOGIN: Option<String> = std::env::var("ZED_IMPERSONATE") pub static ref IMPERSONATE_LOGIN: Option<String> = std::env::var("ZED_IMPERSONATE")
.ok() .ok()
.and_then(|s| if s.is_empty() { None } else { Some(s) }); .and_then(|s| if s.is_empty() { None } else { Some(s) });
@ -933,6 +934,10 @@ impl Client {
http: Arc<dyn HttpClient>, http: Arc<dyn HttpClient>,
release_channel: Option<ReleaseChannel>, release_channel: Option<ReleaseChannel>,
) -> Result<Url> { ) -> Result<Url> {
if let Some(url) = &*ZED_RPC_URL {
return Url::parse(url).context("invalid rpc url");
}
let mut url = format!("{}/rpc", *ZED_SERVER_URL); let mut url = format!("{}/rpc", *ZED_SERVER_URL);
if let Some(preview_param) = if let Some(preview_param) =
release_channel.and_then(|channel| channel.release_query_param()) release_channel.and_then(|channel| channel.release_query_param())
@ -941,14 +946,6 @@ impl Client {
url += preview_param; url += preview_param;
} }
let response = http.get(&url, Default::default(), false).await?; 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() { let collab_url = if response.status().is_redirection() {
response response
.headers() .headers()
@ -957,8 +954,6 @@ impl Client {
.to_str() .to_str()
.map_err(EstablishConnectionError::other)? .map_err(EstablishConnectionError::other)?
.to_string() .to_string()
} else if response.status() == StatusCode::UNAUTHORIZED {
url
} else { } else {
Err(anyhow!( Err(anyhow!(
"unexpected /rpc response status {}", "unexpected /rpc response status {}",
@ -1371,10 +1366,7 @@ fn read_credentials_from_keychain(cx: &AsyncAppContext) -> Option<Credentials> {
}) })
} }
async fn write_credentials_to_keychain( fn write_credentials_to_keychain(credentials: Credentials, cx: &AsyncAppContext) -> Result<()> {
credentials: Credentials,
cx: &AsyncAppContext,
) -> Result<()> {
cx.update(move |cx| { cx.update(move |cx| {
cx.write_credentials( cx.write_credentials(
&ZED_SERVER_URL, &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))? cx.update(move |cx| cx.delete_credentials(&ZED_SERVER_URL))?
} }

View file

@ -1,3 +1,5 @@
mod event_coalescer;
use crate::{TelemetrySettings, ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL}; use crate::{TelemetrySettings, ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use futures::Future; use futures::Future;
@ -5,7 +7,6 @@ use gpui::{AppContext, AppMetadata, BackgroundExecutor, Task};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use parking_lot::Mutex; use parking_lot::Mutex;
use serde::Serialize; use serde::Serialize;
use serde_json;
use settings::{Settings, SettingsStore}; use settings::{Settings, SettingsStore};
use std::{env, io::Write, mem, path::PathBuf, sync::Arc, time::Duration}; use std::{env, io::Write, mem, path::PathBuf, sync::Arc, time::Duration};
use sysinfo::{ use sysinfo::{
@ -13,8 +14,12 @@ use sysinfo::{
}; };
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use util::http::HttpClient; use util::http::HttpClient;
#[cfg(not(debug_assertions))]
use util::ResultExt;
use util::{channel::ReleaseChannel, TryFutureExt}; use util::{channel::ReleaseChannel, TryFutureExt};
use self::event_coalescer::EventCoalescer;
pub struct Telemetry { pub struct Telemetry {
http_client: Arc<dyn HttpClient>, http_client: Arc<dyn HttpClient>,
executor: BackgroundExecutor, executor: BackgroundExecutor,
@ -34,6 +39,7 @@ struct TelemetryState {
log_file: Option<NamedTempFile>, log_file: Option<NamedTempFile>,
is_staff: Option<bool>, is_staff: Option<bool>,
first_event_datetime: Option<DateTime<Utc>>, first_event_datetime: Option<DateTime<Utc>>,
event_coalescer: EventCoalescer,
} }
const EVENTS_URL_PATH: &'static str = "/api/events"; const EVENTS_URL_PATH: &'static str = "/api/events";
@ -110,7 +116,7 @@ pub enum Event {
milliseconds_since_first_event: i64, milliseconds_since_first_event: i64,
}, },
App { App {
operation: &'static str, operation: String,
milliseconds_since_first_event: i64, milliseconds_since_first_event: i64,
}, },
Setting { Setting {
@ -118,27 +124,35 @@ pub enum Event {
value: String, value: String,
milliseconds_since_first_event: i64, 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)] #[cfg(debug_assertions)]
const MAX_QUEUE_LEN: usize = 1; const MAX_QUEUE_LEN: usize = 5;
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
const MAX_QUEUE_LEN: usize = 50; const MAX_QUEUE_LEN: usize = 50;
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(1); const FLUSH_INTERVAL: Duration = Duration::from_secs(1);
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(60 * 5); const FLUSH_INTERVAL: Duration = Duration::from_secs(60 * 5);
impl Telemetry { impl Telemetry {
pub fn new(client: Arc<dyn HttpClient>, cx: &mut AppContext) -> Arc<Self> { pub fn new(client: Arc<dyn HttpClient>, cx: &mut AppContext) -> Arc<Self> {
let release_channel = if cx.has_global::<ReleaseChannel>() { let release_channel = cx
Some(cx.global::<ReleaseChannel>().display_name()) .try_global::<ReleaseChannel>()
} else { .map(|release_channel| release_channel.display_name());
None
};
TelemetrySettings::register(cx); TelemetrySettings::register(cx);
@ -150,13 +164,28 @@ impl Telemetry {
installation_id: None, installation_id: None,
metrics_id: None, metrics_id: None,
session_id: None, session_id: None,
events_queue: Default::default(), events_queue: Vec::new(),
flush_events_task: Default::default(), flush_events_task: None,
log_file: None, log_file: None,
is_staff: None, is_staff: None,
first_event_datetime: 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::<SettingsStore>({ cx.observe_global::<SettingsStore>({
let state = state.clone(); 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 // TestAppContext ends up calling this function on shutdown and it panics when trying to find the TelemetrySettings
#[cfg(not(any(test, feature = "test-support")))] #[cfg(not(any(test, feature = "test-support")))]
fn shutdown_telemetry(self: &Arc<Self>) -> impl Future<Output = ()> { fn shutdown_telemetry(self: &Arc<Self>) -> impl Future<Output = ()> {
self.report_app_event("close"); self.report_app_event("close".to_string());
self.flush_events(); // TODO: close final edit period and make sure it's sent
Task::ready(()) Task::ready(())
} }
@ -359,7 +388,7 @@ impl Telemetry {
self.report_event(event) self.report_event(event)
} }
pub fn report_app_event(self: &Arc<Self>, operation: &'static str) { pub fn report_app_event(self: &Arc<Self>, operation: String) {
let event = Event::App { let event = Event::App {
operation, operation,
milliseconds_since_first_event: self.milliseconds_since_first_event(), milliseconds_since_first_event: self.milliseconds_since_first_event(),
@ -378,8 +407,35 @@ impl Telemetry {
self.report_event(event) self.report_event(event)
} }
pub fn log_edit_event(self: &Arc<Self>, 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<Self>, 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 { fn milliseconds_since_first_event(&self) -> i64 {
let mut state = self.state.lock(); let mut state = self.state.lock();
match state.first_event_datetime { match state.first_event_datetime {
Some(first_event_datetime) => { Some(first_event_datetime) => {
let now: DateTime<Utc> = Utc::now(); let now: DateTime<Utc> = Utc::now();
@ -399,6 +455,15 @@ impl Telemetry {
return; 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(); let signed_in = state.metrics_id.is_some();
state.events_queue.push(EventWrapper { signed_in, event }); state.events_queue.push(EventWrapper { signed_in, event });
@ -406,13 +471,6 @@ impl Telemetry {
if state.events_queue.len() >= MAX_QUEUE_LEN { if state.events_queue.len() >= MAX_QUEUE_LEN {
drop(state); drop(state);
self.flush_events(); 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); let mut events = mem::take(&mut state.events_queue);
state.flush_events_task.take(); state.flush_events_task.take();
drop(state); drop(state);
if events.is_empty() {
return;
}
let this = self.clone(); let this = self.clone();
self.executor self.executor

View file

@ -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<Utc>,
end: Option<DateTime<Utc>>,
}
pub struct EventCoalescer {
state: Option<PeriodData>,
}
impl EventCoalescer {
pub fn new() -> Self {
Self { state: None }
}
pub fn log_event(
&mut self,
environment: &'static str,
) -> Option<(DateTime<Utc>, DateTime<Utc>, &'static str)> {
self.log_event_with_time(Utc::now(), environment)
}
// pub fn close_current_period(&mut self) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
// self.environment.map(|env| self.log_event(env)).flatten()
// }
fn log_event_with_time(
&mut self,
log_time: DateTime<Utc>,
environment: &'static str,
) -> Option<(DateTime<Utc>, DateTime<Utc>, &'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
}

View file

@ -3,7 +3,7 @@ use anyhow::{anyhow, Context, Result};
use collections::{hash_map::Entry, HashMap, HashSet}; use collections::{hash_map::Entry, HashMap, HashSet};
use feature_flags::FeatureFlagAppExt; use feature_flags::FeatureFlagAppExt;
use futures::{channel::mpsc, Future, StreamExt}; 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 postage::{sink::Sink, watch};
use rpc::proto::{RequestMessage, UsersResponse}; use rpc::proto::{RequestMessage, UsersResponse};
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
@ -19,7 +19,7 @@ pub struct ParticipantIndex(pub u32);
pub struct User { pub struct User {
pub id: UserId, pub id: UserId,
pub github_login: String, pub github_login: String,
pub avatar_uri: SharedString, pub avatar_uri: SharedUrl,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]

View file

@ -0,0 +1,8 @@
[
"nathansobo",
"as-cii",
"maxbrunsfeld",
"iamnbutler",
"mikayla-maki",
"JosephTLyons"
]

View file

@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathan@zed.dev>"]
default-run = "collab" default-run = "collab"
edition = "2021" edition = "2021"
name = "collab" name = "collab"
version = "0.35.0" version = "0.37.0"
publish = false publish = false
[[bin]] [[bin]]
@ -74,6 +74,8 @@ live_kit_client = { path = "../live_kit_client", features = ["test-support"] }
lsp = { path = "../lsp", features = ["test-support"] } lsp = { path = "../lsp", features = ["test-support"] }
node_runtime = { path = "../node_runtime" } node_runtime = { path = "../node_runtime" }
notifications = { path = "../notifications", features = ["test-support"] } notifications = { path = "../notifications", features = ["test-support"] }
file_finder = { path = "../file_finder"}
menu = { path = "../menu"}
project = { path = "../project", features = ["test-support"] } project = { path = "../project", features = ["test-support"] }
rpc = { path = "../rpc", features = ["test-support"] } rpc = { path = "../rpc", features = ["test-support"] }

View file

@ -19,6 +19,7 @@ CREATE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id");
CREATE TABLE "access_tokens" ( CREATE TABLE "access_tokens" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT, "id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER REFERENCES users (id), "user_id" INTEGER REFERENCES users (id),
"impersonated_user_id" INTEGER REFERENCES users (id),
"hash" VARCHAR(128) "hash" VARCHAR(128)
); );
CREATE INDEX "index_access_tokens_user_id" ON "access_tokens" ("user_id"); 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" ( CREATE TABLE "rooms" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT, "id" INTEGER PRIMARY KEY AUTOINCREMENT,
"live_kit_room" VARCHAR NOT NULL, "live_kit_room" VARCHAR NOT NULL,
"enviroment" VARCHAR, "environment" VARCHAR,
"channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE "channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE
); );
CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id"); CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id");

View file

@ -0,0 +1 @@
ALTER TABLE rooms ADD COLUMN environment TEXT;

View file

@ -0,0 +1 @@
ALTER TABLE access_tokens ADD COLUMN impersonated_user_id integer;

View file

@ -156,11 +156,11 @@ async fn create_access_token(
.await? .await?
.ok_or_else(|| anyhow!("user not found"))?; .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 let Some(impersonate) = params.impersonate {
if user.admin { if user.admin {
if let Some(impersonated_user) = app.db.get_user_by_github_login(&impersonate).await? { 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 { } else {
return Err(Error::Http( return Err(Error::Http(
StatusCode::UNPROCESSABLE_ENTITY, 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 = let encrypted_access_token =
auth::encrypt_access_token(&access_token, params.public_key.clone())?; auth::encrypt_access_token(&access_token, params.public_key.clone())?;
Ok(Json(CreateAccessTokenResponse { Ok(Json(CreateAccessTokenResponse {
user_id, user_id: impersonated_user_id.unwrap_or(user_id),
encrypted_access_token, encrypted_access_token,
})) }))
} }

View file

@ -27,6 +27,11 @@ lazy_static! {
.unwrap(); .unwrap();
} }
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Impersonator(pub Option<db::User>);
/// 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<B>(mut req: Request<B>, next: Next<B>) -> impl IntoResponse { pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl IntoResponse {
let mut auth_header = req let mut auth_header = req
.headers() .headers()
@ -55,28 +60,50 @@ pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl Into
})?; })?;
let state = req.extensions().get::<Arc<AppState>>().unwrap(); let state = req.extensions().get::<Arc<AppState>>().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 { } else {
verify_access_token(&access_token, user_id, &state.db) verify_access_token(&access_token, user_id, &state.db).await
.await
.unwrap_or(false)
}; };
if credentials_valid { if let Ok(validate_result) = validate_result {
let user = state if validate_result.is_valid {
.db let user = state
.get_user_by_id(user_id) .db
.await? .get_user_by_id(user_id)
.ok_or_else(|| anyhow!("user {} not found", user_id))?; .await?
req.extensions_mut().insert(user); .ok_or_else(|| anyhow!("user {} not found", user_id))?;
Ok::<_, Error>(next.run(req).await)
} else { let impersonator = if let Some(impersonator_id) = validate_result.impersonator_id {
Err(Error::Http( let impersonator = state
StatusCode::UNAUTHORIZED, .db
"invalid credentials".to_string(), .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; const MAX_ACCESS_TOKENS_TO_STORE: usize = 8;
@ -88,13 +115,24 @@ struct AccessTokenJson {
token: String, token: String,
} }
pub async fn create_access_token(db: &db::Database, user_id: UserId) -> Result<String> { /// 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<UserId>,
) -> Result<String> {
const VERSION: usize = 1; const VERSION: usize = 1;
let access_token = rpc::auth::random_token(); let access_token = rpc::auth::random_token();
let access_token_hash = let access_token_hash =
hash_access_token(&access_token).context("failed to hash access token")?; hash_access_token(&access_token).context("failed to hash access token")?;
let id = db 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?; .await?;
Ok(serde_json::to_string(&AccessTokenJson { Ok(serde_json::to_string(&AccessTokenJson {
version: VERSION, version: VERSION,
@ -122,6 +160,8 @@ fn hash_access_token(token: &str) -> Result<String> {
.to_string()) .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<String> { pub fn encrypt_access_token(access_token: &str, public_key: String) -> Result<String> {
let native_app_public_key = let native_app_public_key =
rpc::auth::PublicKey::try_from(public_key).context("failed to parse 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<St
Ok(encrypted_access_token) Ok(encrypted_access_token)
} }
pub async fn verify_access_token(token: &str, user_id: UserId, db: &Arc<Database>) -> Result<bool> { pub struct VerifyAccessTokenResult {
pub is_valid: bool,
pub impersonator_id: Option<UserId>,
}
/// 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<Database>,
) -> Result<VerifyAccessTokenResult> {
let token: AccessTokenJson = serde_json::from_str(&token)?; let token: AccessTokenJson = serde_json::from_str(&token)?;
let db_token = db.get_access_token(token.id).await?; 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"))?; return Err(anyhow!("no such access token"))?;
} }
@ -147,5 +198,12 @@ pub async fn verify_access_token(token: &str, user_id: UserId, db: &Arc<Database
let duration = t0.elapsed(); let duration = t0.elapsed();
log::info!("hashed access token in {:?}", duration); log::info!("hashed access token in {:?}", duration);
METRIC_ACCESS_TOKEN_HASHING_TIME.observe(duration.as_millis() as f64); METRIC_ACCESS_TOKEN_HASHING_TIME.observe(duration.as_millis() as f64);
Ok(is_valid) Ok(VerifyAccessTokenResult {
is_valid,
impersonator_id: if db_token.impersonated_user_id.is_some() {
Some(db_token.user_id)
} else {
None
},
})
} }

View file

@ -1,7 +1,11 @@
use collab::{db, executor::Executor}; use collab::{
db::{self, NewUserParams},
env::load_dotenv,
executor::Executor,
};
use db::{ConnectOptions, Database}; use db::{ConnectOptions, Database};
use serde::{de::DeserializeOwned, Deserialize}; use serde::{de::DeserializeOwned, Deserialize};
use std::fmt::Write; use std::{fmt::Write, fs};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct GitHubUser { struct GitHubUser {
@ -12,90 +16,75 @@ struct GitHubUser {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
load_dotenv().expect("failed to load .env.toml file");
let mut admin_logins =
load_admins("./.admins.default.json").expect("failed to load default admins file");
if let Ok(other_admins) = load_admins("./.admins.json") {
admin_logins.extend(other_admins);
}
let database_url = std::env::var("DATABASE_URL").expect("missing DATABASE_URL env var"); let database_url = std::env::var("DATABASE_URL").expect("missing DATABASE_URL env var");
let db = Database::new(ConnectOptions::new(database_url), Executor::Production) let db = Database::new(ConnectOptions::new(database_url), Executor::Production)
.await .await
.expect("failed to connect to postgres database"); .expect("failed to connect to postgres database");
let github_token = std::env::var("GITHUB_TOKEN").expect("missing GITHUB_TOKEN env var");
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let mut current_user = // Create admin users for all of the users in `.admins.toml` or `.admins.default.toml`.
fetch_github::<GitHubUser>(&client, &github_token, "https://api.github.com/user").await; for admin_login in admin_logins {
current_user let user = fetch_github::<GitHubUser>(
.email &client,
.get_or_insert_with(|| "placeholder@example.com".to_string()); &format!("https://api.github.com/users/{admin_login}"),
let staff_users = fetch_github::<Vec<GitHubUser>>( )
&client, .await;
&github_token, db.create_user(
"https://api.github.com/orgs/zed-industries/teams/staff/members", &user.email.unwrap_or(format!("{admin_login}@example.com")),
) true,
.await; NewUserParams {
github_login: user.login,
github_user_id: user.id,
},
)
.await
.expect("failed to create admin user");
}
let mut zed_users = Vec::new(); // Fetch 100 other random users from GitHub and insert them into the database.
zed_users.push((current_user, true)); let mut user_count = db
zed_users.extend(staff_users.into_iter().map(|user| (user, true)));
let user_count = db
.get_all_users(0, 200) .get_all_users(0, 200)
.await .await
.expect("failed to load users from db") .expect("failed to load users from db")
.len(); .len();
if user_count < 100 { let mut last_user_id = None;
let mut last_user_id = None; while user_count < 100 {
for _ in 0..10 { let mut uri = "https://api.github.com/users?per_page=100".to_string();
let mut uri = "https://api.github.com/users?per_page=100".to_string(); if let Some(last_user_id) = last_user_id {
if let Some(last_user_id) = last_user_id { write!(&mut uri, "&since={}", last_user_id).unwrap();
write!(&mut uri, "&since={}", last_user_id).unwrap();
}
let users = fetch_github::<Vec<GitHubUser>>(&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 users = fetch_github::<Vec<GitHubUser>>(&client, &uri).await;
for (github_user, admin) in zed_users { for github_user in users {
if db last_user_id = Some(github_user.id);
.get_user_by_github_login(&github_user.login) user_count += 1;
db.get_or_create_user_by_github_account(
&github_user.login,
Some(github_user.id),
github_user.email.as_deref(),
)
.await .await
.expect("failed to fetch user") .expect("failed to insert 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");
}
} }
} }
} }
async fn fetch_github<T: DeserializeOwned>( fn load_admins(path: &str) -> anyhow::Result<Vec<String>> {
client: &reqwest::Client, let file_content = fs::read_to_string(path)?;
access_token: &str, Ok(serde_json::from_str(&file_content)?)
url: &str, }
) -> T {
async fn fetch_github<T: DeserializeOwned>(client: &reqwest::Client, url: &str) -> T {
let response = client let response = client
.get(url) .get(url)
.bearer_auth(&access_token)
.header("user-agent", "zed") .header("user-agent", "zed")
.send() .send()
.await .await

View file

@ -47,6 +47,8 @@ pub use ids::*;
pub use sea_orm::ConnectOptions; pub use sea_orm::ConnectOptions;
pub use tables::user::Model as User; 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 { pub struct Database {
options: ConnectOptions, options: ConnectOptions,
pool: DatabaseConnection, pool: DatabaseConnection,
@ -62,6 +64,7 @@ pub struct Database {
// The `Database` type has so many methods that its impl blocks are split into // The `Database` type has so many methods that its impl blocks are split into
// separate files in the `queries` folder. // separate files in the `queries` folder.
impl Database { impl Database {
/// Connects to the database with the given options
pub async fn new(options: ConnectOptions, executor: Executor) -> Result<Self> { pub async fn new(options: ConnectOptions, executor: Executor) -> Result<Self> {
sqlx::any::install_default_drivers(); sqlx::any::install_default_drivers();
Ok(Self { Ok(Self {
@ -82,6 +85,7 @@ impl Database {
self.rooms.clear(); self.rooms.clear();
} }
/// Runs the database migrations.
pub async fn migrate( pub async fn migrate(
&self, &self,
migrations_path: &Path, migrations_path: &Path,
@ -123,11 +127,15 @@ impl Database {
Ok(new_migrations) Ok(new_migrations)
} }
/// Initializes static data that resides in the database by upserting it.
pub async fn initialize_static_data(&mut self) -> Result<()> { pub async fn initialize_static_data(&mut self) -> Result<()> {
self.initialize_notification_kinds().await?; self.initialize_notification_kinds().await?;
Ok(()) 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<F, Fut, T>(&self, f: F) -> Result<T> pub async fn transaction<F, Fut, T>(&self, f: F) -> Result<T>
where where
F: Send + Fn(TransactionHandle) -> Fut, F: Send + Fn(TransactionHandle) -> Fut,
@ -160,6 +168,7 @@ impl Database {
self.run(body).await self.run(body).await
} }
/// The same as room_transaction, but if you need to only optionally return a Room.
async fn optional_room_transaction<F, Fut, T>(&self, f: F) -> Result<Option<RoomGuard<T>>> async fn optional_room_transaction<F, Fut, T>(&self, f: F) -> Result<Option<RoomGuard<T>>>
where where
F: Send + Fn(TransactionHandle) -> Fut, F: Send + Fn(TransactionHandle) -> Fut,
@ -210,6 +219,9 @@ impl Database {
self.run(body).await 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<F, Fut, T>(&self, room_id: RoomId, f: F) -> Result<RoomGuard<T>> async fn room_transaction<F, Fut, T>(&self, room_id: RoomId, f: F) -> Result<RoomGuard<T>>
where where
F: Send + Fn(TransactionHandle) -> Fut, 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<Option<DatabaseTransaction>>); pub struct TransactionHandle(Arc<Option<DatabaseTransaction>>);
impl Deref for TransactionHandle { 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<T> { pub struct RoomGuard<T> {
data: T, data: T,
_guard: OwnedMutexGuard<()>, _guard: OwnedMutexGuard<()>,
@ -361,6 +376,7 @@ impl<T> DerefMut for RoomGuard<T> {
} }
impl<T> RoomGuard<T> { impl<T> RoomGuard<T> {
/// Returns the inner value of the guard.
pub fn into_inner(self) -> T { pub fn into_inner(self) -> T {
self.data self.data
} }
@ -420,12 +436,14 @@ pub struct WaitlistSummary {
pub unknown_count: i64, pub unknown_count: i64,
} }
/// The parameters to create a new user.
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct NewUserParams { pub struct NewUserParams {
pub github_login: String, pub github_login: String,
pub github_user_id: i32, pub github_user_id: i32,
} }
/// The result of creating a new user.
#[derive(Debug)] #[derive(Debug)]
pub struct NewUserResult { pub struct NewUserResult {
pub user_id: UserId, pub user_id: UserId,
@ -434,6 +452,7 @@ pub struct NewUserResult {
pub signup_device_id: Option<String>, pub signup_device_id: Option<String>,
} }
/// The result of moving a channel.
#[derive(Debug)] #[derive(Debug)]
pub struct MoveChannelResult { pub struct MoveChannelResult {
pub participants_to_update: HashMap<UserId, ChannelsForUser>, pub participants_to_update: HashMap<UserId, ChannelsForUser>,
@ -441,18 +460,21 @@ pub struct MoveChannelResult {
pub moved_channels: HashSet<ChannelId>, pub moved_channels: HashSet<ChannelId>,
} }
/// The result of renaming a channel.
#[derive(Debug)] #[derive(Debug)]
pub struct RenameChannelResult { pub struct RenameChannelResult {
pub channel: Channel, pub channel: Channel,
pub participants_to_update: HashMap<UserId, Channel>, pub participants_to_update: HashMap<UserId, Channel>,
} }
/// The result of creating a channel.
#[derive(Debug)] #[derive(Debug)]
pub struct CreateChannelResult { pub struct CreateChannelResult {
pub channel: Channel, pub channel: Channel,
pub participants_to_update: Vec<(UserId, ChannelsForUser)>, pub participants_to_update: Vec<(UserId, ChannelsForUser)>,
} }
/// The result of setting a channel's visibility.
#[derive(Debug)] #[derive(Debug)]
pub struct SetChannelVisibilityResult { pub struct SetChannelVisibilityResult {
pub participants_to_update: HashMap<UserId, ChannelsForUser>, pub participants_to_update: HashMap<UserId, ChannelsForUser>,
@ -460,6 +482,7 @@ pub struct SetChannelVisibilityResult {
pub channels_to_remove: Vec<ChannelId>, pub channels_to_remove: Vec<ChannelId>,
} }
/// The result of updating a channel membership.
#[derive(Debug)] #[derive(Debug)]
pub struct MembershipUpdated { pub struct MembershipUpdated {
pub channel_id: ChannelId, pub channel_id: ChannelId,
@ -467,12 +490,14 @@ pub struct MembershipUpdated {
pub removed_channels: Vec<ChannelId>, pub removed_channels: Vec<ChannelId>,
} }
/// The result of setting a member's role.
#[derive(Debug)] #[derive(Debug)]
pub enum SetMemberRoleResult { pub enum SetMemberRoleResult {
InviteUpdated(Channel), InviteUpdated(Channel),
MembershipUpdated(MembershipUpdated), MembershipUpdated(MembershipUpdated),
} }
/// The result of inviting a member to a channel.
#[derive(Debug)] #[derive(Debug)]
pub struct InviteMemberResult { pub struct InviteMemberResult {
pub channel: Channel, pub channel: Channel,
@ -497,6 +522,7 @@ pub struct Channel {
pub name: String, pub name: String,
pub visibility: ChannelVisibility, pub visibility: ChannelVisibility,
pub role: ChannelRole, pub role: ChannelRole,
/// parent_path is the channel ids from the root to this one (not including this one)
pub parent_path: Vec<ChannelId>, pub parent_path: Vec<ChannelId>,
} }

View file

@ -19,19 +19,23 @@ macro_rules! id_type {
Deserialize, Deserialize,
DeriveValueType, DeriveValueType,
)] )]
#[allow(missing_docs)]
#[serde(transparent)] #[serde(transparent)]
pub struct $name(pub i32); pub struct $name(pub i32);
impl $name { impl $name {
#[allow(unused)] #[allow(unused)]
#[allow(missing_docs)]
pub const MAX: Self = Self(i32::MAX); pub const MAX: Self = Self(i32::MAX);
#[allow(unused)] #[allow(unused)]
#[allow(missing_docs)]
pub fn from_proto(value: u64) -> Self { pub fn from_proto(value: u64) -> Self {
Self(value as i32) Self(value as i32)
} }
#[allow(unused)] #[allow(unused)]
#[allow(missing_docs)]
pub fn to_proto(self) -> u64 { pub fn to_proto(self) -> u64 {
self.0 as u64 self.0 as u64
} }
@ -84,21 +88,28 @@ id_type!(FlagId);
id_type!(NotificationId); id_type!(NotificationId);
id_type!(NotificationKindId); id_type!(NotificationKindId);
/// ChannelRole gives you permissions for both channels and calls.
#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash)] #[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash)]
#[sea_orm(rs_type = "String", db_type = "String(None)")] #[sea_orm(rs_type = "String", db_type = "String(None)")]
pub enum ChannelRole { pub enum ChannelRole {
/// Admin can read/write and change permissions.
#[sea_orm(string_value = "admin")] #[sea_orm(string_value = "admin")]
Admin, Admin,
/// Member can read/write, but not change pemissions.
#[sea_orm(string_value = "member")] #[sea_orm(string_value = "member")]
#[default] #[default]
Member, Member,
/// Guest can read, but not write.
/// (thought they can use the channel chat)
#[sea_orm(string_value = "guest")] #[sea_orm(string_value = "guest")]
Guest, Guest,
/// Banned may not read.
#[sea_orm(string_value = "banned")] #[sea_orm(string_value = "banned")]
Banned, Banned,
} }
impl ChannelRole { impl ChannelRole {
/// Returns true if this role is more powerful than the other role.
pub fn should_override(&self, other: Self) -> bool { pub fn should_override(&self, other: Self) -> bool {
use ChannelRole::*; use ChannelRole::*;
match self { match self {
@ -109,6 +120,7 @@ impl ChannelRole {
} }
} }
/// Returns the maximal role between the two
pub fn max(&self, other: Self) -> Self { pub fn max(&self, other: Self) -> Self {
if self.should_override(other) { if self.should_override(other) {
*self *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 { pub fn can_see_all_descendants(&self) -> bool {
use ChannelRole::*; use ChannelRole::*;
match self { 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 { pub fn can_only_see_public_descendants(&self) -> bool {
use ChannelRole::*; use ChannelRole::*;
match self { 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::*; use ChannelRole::*;
match self { match self {
Admin | Member => true, Admin | Member => true,
@ -141,6 +156,7 @@ impl ChannelRole {
} }
} }
/// True if the role can edit shared projects.
pub fn can_edit_projects(&self) -> bool { pub fn can_edit_projects(&self) -> bool {
use ChannelRole::*; use ChannelRole::*;
match self { match self {
@ -149,6 +165,7 @@ impl ChannelRole {
} }
} }
/// True if the role can read shared projects.
pub fn can_read_projects(&self) -> bool { pub fn can_read_projects(&self) -> bool {
use ChannelRole::*; use ChannelRole::*;
match self { match self {
@ -187,11 +204,14 @@ impl Into<i32> for ChannelRole {
} }
} }
/// ChannelVisibility controls whether channels are public or private.
#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash)] #[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash)]
#[sea_orm(rs_type = "String", db_type = "String(None)")] #[sea_orm(rs_type = "String", db_type = "String(None)")]
pub enum ChannelVisibility { 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")] #[sea_orm(string_value = "public")]
Public, Public,
/// Members channels are only visible to members of this channel or its parents.
#[sea_orm(string_value = "members")] #[sea_orm(string_value = "members")]
#[default] #[default]
Members, Members,

View file

@ -2,9 +2,11 @@ use super::*;
use sea_orm::sea_query::Query; use sea_orm::sea_query::Query;
impl Database { impl Database {
/// Creates a new access token for the given user.
pub async fn create_access_token( pub async fn create_access_token(
&self, &self,
user_id: UserId, user_id: UserId,
impersonated_user_id: Option<UserId>,
access_token_hash: &str, access_token_hash: &str,
max_access_token_count: usize, max_access_token_count: usize,
) -> Result<AccessTokenId> { ) -> Result<AccessTokenId> {
@ -13,6 +15,7 @@ impl Database {
let token = access_token::ActiveModel { let token = access_token::ActiveModel {
user_id: ActiveValue::set(user_id), user_id: ActiveValue::set(user_id),
impersonated_user_id: ActiveValue::set(impersonated_user_id),
hash: ActiveValue::set(access_token_hash.into()), hash: ActiveValue::set(access_token_hash.into()),
..Default::default() ..Default::default()
} }
@ -39,6 +42,7 @@ impl Database {
.await .await
} }
/// Retrieves the access token with the given ID.
pub async fn get_access_token( pub async fn get_access_token(
&self, &self,
access_token_id: AccessTokenId, access_token_id: AccessTokenId,

View file

@ -9,6 +9,8 @@ pub struct LeftChannelBuffer {
} }
impl Database { 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( pub async fn join_channel_buffer(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -121,6 +123,7 @@ impl Database {
.await .await
} }
/// Rejoin a channel buffer (after a connection interruption)
pub async fn rejoin_channel_buffers( pub async fn rejoin_channel_buffers(
&self, &self,
buffers: &[proto::ChannelBufferVersion], buffers: &[proto::ChannelBufferVersion],
@ -149,7 +152,7 @@ impl Database {
.await?; .await?;
// If the buffer epoch hasn't changed since the client lost // 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. // the server's buffer.
if buffer.epoch as u64 != client_buffer.epoch { if buffer.epoch as u64 != client_buffer.epoch {
log::info!("can't rejoin buffer, epoch has changed"); log::info!("can't rejoin buffer, epoch has changed");
@ -232,6 +235,7 @@ impl Database {
.await .await
} }
/// Clear out any buffer collaborators who are no longer collaborating.
pub async fn clear_stale_channel_buffer_collaborators( pub async fn clear_stale_channel_buffer_collaborators(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -274,6 +278,7 @@ impl Database {
.await .await
} }
/// Close the channel buffer, and stop receiving updates for it.
pub async fn leave_channel_buffer( pub async fn leave_channel_buffer(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -286,6 +291,7 @@ impl Database {
.await .await
} }
/// Close the channel buffer, and stop receiving updates for it.
pub async fn channel_buffer_connection_lost( pub async fn channel_buffer_connection_lost(
&self, &self,
connection: ConnectionId, connection: ConnectionId,
@ -309,6 +315,7 @@ impl Database {
Ok(()) Ok(())
} }
/// Close all open channel buffers
pub async fn leave_channel_buffers( pub async fn leave_channel_buffers(
&self, &self,
connection: ConnectionId, connection: ConnectionId,
@ -342,7 +349,7 @@ impl Database {
.await .await
} }
pub async fn leave_channel_buffer_internal( async fn leave_channel_buffer_internal(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
connection: ConnectionId, connection: ConnectionId,
@ -798,6 +805,7 @@ impl Database {
Ok(changes) Ok(changes)
} }
/// Returns the latest operations for the buffers with the specified IDs.
pub async fn get_latest_operations_for_buffers( pub async fn get_latest_operations_for_buffers(
&self, &self,
buffer_ids: impl IntoIterator<Item = BufferId>, buffer_ids: impl IntoIterator<Item = BufferId>,
@ -962,7 +970,7 @@ fn version_from_storage(version: &Vec<storage::VectorClockEntry>) -> Vec<proto::
.collect() .collect()
} }
// This is currently a manual copy of the deserialization code in the client's langauge crate // This is currently a manual copy of the deserialization code in the client's language crate
pub fn operation_from_wire(operation: proto::Operation) -> Option<text::Operation> { pub fn operation_from_wire(operation: proto::Operation) -> Option<text::Operation> {
match operation.variant? { match operation.variant? {
proto::operation::Variant::Edit(edit) => Some(text::Operation::Edit(EditOperation { proto::operation::Variant::Edit(edit) => Some(text::Operation::Edit(EditOperation {

View file

@ -40,6 +40,7 @@ impl Database {
.id) .id)
} }
/// Creates a new channel.
pub async fn create_channel( pub async fn create_channel(
&self, &self,
name: &str, name: &str,
@ -97,6 +98,7 @@ impl Database {
.await .await
} }
/// Adds a user to the specified channel.
pub async fn join_channel( pub async fn join_channel(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -179,6 +181,7 @@ impl Database {
.await .await
} }
/// Sets the visibiltity of the given channel.
pub async fn set_channel_visibility( pub async fn set_channel_visibility(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -258,6 +261,7 @@ impl Database {
.await .await
} }
/// Deletes the channel with the specified ID.
pub async fn delete_channel( pub async fn delete_channel(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -294,6 +298,7 @@ impl Database {
.await .await
} }
/// Invites a user to a channel as a member.
pub async fn invite_channel_member( pub async fn invite_channel_member(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -349,6 +354,7 @@ impl Database {
Ok(new_name) Ok(new_name)
} }
/// Renames the specified channel.
pub async fn rename_channel( pub async fn rename_channel(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -387,6 +393,7 @@ impl Database {
.await .await
} }
/// accept or decline an invite to join a channel
pub async fn respond_to_channel_invite( pub async fn respond_to_channel_invite(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -486,6 +493,7 @@ impl Database {
}) })
} }
/// Removes a channel member.
pub async fn remove_channel_member( pub async fn remove_channel_member(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -530,6 +538,7 @@ impl Database {
.await .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<Vec<Channel>> { pub async fn get_channel_invites_for_user(&self, user_id: UserId) -> Result<Vec<Channel>> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
let mut role_for_channel: HashMap<ChannelId, ChannelRole> = HashMap::default(); let mut role_for_channel: HashMap<ChannelId, ChannelRole> = HashMap::default();
@ -565,6 +574,7 @@ impl Database {
.await .await
} }
/// Returns all channels for the user with the given ID.
pub async fn get_channels_for_user(&self, user_id: UserId) -> Result<ChannelsForUser> { pub async fn get_channels_for_user(&self, user_id: UserId) -> Result<ChannelsForUser> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
let tx = tx; let tx = tx;
@ -574,6 +584,8 @@ impl Database {
.await .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( pub async fn get_user_channels(
&self, &self,
user_id: UserId, user_id: UserId,
@ -743,6 +755,7 @@ impl Database {
Ok(results) Ok(results)
} }
/// Sets the role for the specified channel member.
pub async fn set_channel_member_role( pub async fn set_channel_member_role(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -786,6 +799,7 @@ impl Database {
.await .await
} }
/// Returns the details for the specified channel member.
pub async fn get_channel_participant_details( pub async fn get_channel_participant_details(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -911,6 +925,7 @@ impl Database {
.collect()) .collect())
} }
/// Returns the participants in the given channel.
pub async fn get_channel_participants( pub async fn get_channel_participants(
&self, &self,
channel: &channel::Model, channel: &channel::Model,
@ -925,6 +940,7 @@ impl Database {
.collect()) .collect())
} }
/// Returns whether the given user is an admin in the specified channel.
pub async fn check_user_is_channel_admin( pub async fn check_user_is_channel_admin(
&self, &self,
channel: &channel::Model, 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( pub async fn check_user_is_channel_member(
&self, &self,
channel: &channel::Model, 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( pub async fn check_user_is_channel_participant(
&self, &self,
channel: &channel::Model, 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( pub async fn pending_invite_for_channel(
&self, &self,
channel: &channel::Model, channel: &channel::Model,
@ -991,7 +1010,7 @@ impl Database {
Ok(row) Ok(row)
} }
pub async fn public_parent_channel( async fn public_parent_channel(
&self, &self,
channel: &channel::Model, channel: &channel::Model,
tx: &DatabaseTransaction, tx: &DatabaseTransaction,
@ -1003,7 +1022,7 @@ impl Database {
Ok(path.pop()) Ok(path.pop())
} }
pub async fn public_ancestors_including_self( pub(crate) async fn public_ancestors_including_self(
&self, &self,
channel: &channel::Model, channel: &channel::Model,
tx: &DatabaseTransaction, tx: &DatabaseTransaction,
@ -1018,6 +1037,7 @@ impl Database {
Ok(visible_channels) Ok(visible_channels)
} }
/// Returns the role for a user in the given channel.
pub async fn channel_role_for_user( pub async fn channel_role_for_user(
&self, &self,
channel: &channel::Model, channel: &channel::Model,
@ -1143,7 +1163,7 @@ impl Database {
.await?) .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<Channel> { pub async fn get_channel(&self, channel_id: ChannelId, user_id: UserId) -> Result<Channel> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
let channel = self.get_channel_internal(channel_id, &*tx).await?; let channel = self.get_channel_internal(channel_id, &*tx).await?;
@ -1156,7 +1176,7 @@ impl Database {
.await .await
} }
pub async fn get_channel_internal( pub(crate) async fn get_channel_internal(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
tx: &DatabaseTransaction, tx: &DatabaseTransaction,
@ -1180,7 +1200,7 @@ impl Database {
.await?; .await?;
let room_id = if let Some(room) = room { let room_id = if let Some(room) = room {
if let Some(env) = room.enviroment { if let Some(env) = room.environment {
if &env != environment { if &env != environment {
Err(anyhow!("must join using the {} release", env))?; Err(anyhow!("must join using the {} release", env))?;
} }
@ -1190,7 +1210,7 @@ impl Database {
let result = room::Entity::insert(room::ActiveModel { let result = room::Entity::insert(room::ActiveModel {
channel_id: ActiveValue::Set(Some(channel_id)), channel_id: ActiveValue::Set(Some(channel_id)),
live_kit_room: ActiveValue::Set(live_kit_room.to_string()), 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() ..Default::default()
}) })
.exec(&*tx) .exec(&*tx)

View file

@ -1,6 +1,7 @@
use super::*; use super::*;
impl Database { impl Database {
/// Retrieves the contacts for the user with the given ID.
pub async fn get_contacts(&self, user_id: UserId) -> Result<Vec<Contact>> { pub async fn get_contacts(&self, user_id: UserId) -> Result<Vec<Contact>> {
#[derive(Debug, FromQueryResult)] #[derive(Debug, FromQueryResult)]
struct ContactWithUserBusyStatuses { struct ContactWithUserBusyStatuses {
@ -86,6 +87,7 @@ impl Database {
.await .await
} }
/// Returns whether the given user is a busy (on a call).
pub async fn is_user_busy(&self, user_id: UserId) -> Result<bool> { pub async fn is_user_busy(&self, user_id: UserId) -> Result<bool> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
let participant = room_participant::Entity::find() let participant = room_participant::Entity::find()
@ -97,6 +99,9 @@ impl Database {
.await .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<bool> { pub async fn has_contact(&self, user_id_1: UserId, user_id_2: UserId) -> Result<bool> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
let (id_a, id_b) = if user_id_1 < user_id_2 { let (id_a, id_b) = if user_id_1 < user_id_2 {
@ -119,6 +124,7 @@ impl Database {
.await .await
} }
/// Invite the user with `receiver_id` to be a contact of the user with `sender_id`.
pub async fn send_contact_request( pub async fn send_contact_request(
&self, &self,
sender_id: UserId, sender_id: UserId,
@ -231,6 +237,7 @@ impl Database {
.await .await
} }
/// Dismisses a contact notification for the given user.
pub async fn dismiss_contact_notification( pub async fn dismiss_contact_notification(
&self, &self,
user_id: UserId, user_id: UserId,
@ -272,6 +279,7 @@ impl Database {
.await .await
} }
/// Accept or decline a contact request
pub async fn respond_to_contact_request( pub async fn respond_to_contact_request(
&self, &self,
responder_id: UserId, responder_id: UserId,

View file

@ -4,6 +4,7 @@ use sea_orm::TryInsertResult;
use time::OffsetDateTime; use time::OffsetDateTime;
impl Database { impl Database {
/// Inserts a record representing a user joining the chat for a given channel.
pub async fn join_channel_chat( pub async fn join_channel_chat(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -28,6 +29,7 @@ impl Database {
.await .await
} }
/// Removes `channel_chat_participant` records associated with the given connection ID.
pub async fn channel_chat_connection_lost( pub async fn channel_chat_connection_lost(
&self, &self,
connection_id: ConnectionId, connection_id: ConnectionId,
@ -47,6 +49,8 @@ impl Database {
Ok(()) 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( pub async fn leave_channel_chat(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -72,6 +76,9 @@ impl Database {
.await .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( pub async fn get_channel_messages(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -103,6 +110,7 @@ impl Database {
.await .await
} }
/// Returns the channel messages with the given IDs.
pub async fn get_channel_messages_by_id( pub async fn get_channel_messages_by_id(
&self, &self,
user_id: UserId, user_id: UserId,
@ -190,6 +198,7 @@ impl Database {
Ok(messages) Ok(messages)
} }
/// Creates a new channel message.
pub async fn create_channel_message( pub async fn create_channel_message(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,
@ -256,6 +265,7 @@ impl Database {
message_id = result.last_insert_id; message_id = result.last_insert_id;
let mentioned_user_ids = let mentioned_user_ids =
mentions.iter().map(|m| m.user_id).collect::<HashSet<_>>(); mentions.iter().map(|m| m.user_id).collect::<HashSet<_>>();
let mentions = mentions let mentions = mentions
.iter() .iter()
.filter_map(|mention| { .filter_map(|mention| {
@ -375,6 +385,7 @@ impl Database {
Ok(()) Ok(())
} }
/// Returns the unseen messages for the given user in the specified channels.
pub async fn unseen_channel_messages( pub async fn unseen_channel_messages(
&self, &self,
user_id: UserId, user_id: UserId,
@ -448,6 +459,7 @@ impl Database {
Ok(changes) Ok(changes)
} }
/// Removes the channel message with the given ID.
pub async fn remove_channel_message( pub async fn remove_channel_message(
&self, &self,
channel_id: ChannelId, channel_id: ChannelId,

View file

@ -2,6 +2,7 @@ use super::*;
use rpc::Notification; use rpc::Notification;
impl Database { impl Database {
/// Initializes the different kinds of notifications by upserting records for them.
pub async fn initialize_notification_kinds(&mut self) -> Result<()> { pub async fn initialize_notification_kinds(&mut self) -> Result<()> {
notification_kind::Entity::insert_many(Notification::all_variant_names().iter().map( notification_kind::Entity::insert_many(Notification::all_variant_names().iter().map(
|kind| notification_kind::ActiveModel { |kind| notification_kind::ActiveModel {
@ -28,6 +29,7 @@ impl Database {
Ok(()) Ok(())
} }
/// Returns the notifications for the given recipient.
pub async fn get_notifications( pub async fn get_notifications(
&self, &self,
recipient_id: UserId, recipient_id: UserId,
@ -140,6 +142,7 @@ impl Database {
.await .await
} }
/// Marks the given notification as read.
pub async fn mark_notification_as_read( pub async fn mark_notification_as_read(
&self, &self,
recipient_id: UserId, recipient_id: UserId,
@ -150,6 +153,7 @@ impl Database {
.await .await
} }
/// Marks the notification with the given ID as read.
pub async fn mark_notification_as_read_by_id( pub async fn mark_notification_as_read_by_id(
&self, &self,
recipient_id: UserId, recipient_id: UserId,

View file

@ -1,6 +1,7 @@
use super::*; use super::*;
impl Database { impl Database {
/// Returns the count of all projects, excluding ones marked as admin.
pub async fn project_count_excluding_admins(&self) -> Result<usize> { pub async fn project_count_excluding_admins(&self) -> Result<usize> {
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryAs { enum QueryAs {
@ -21,6 +22,7 @@ impl Database {
.await .await
} }
/// Shares a project with the given room.
pub async fn share_project( pub async fn share_project(
&self, &self,
room_id: RoomId, room_id: RoomId,
@ -49,7 +51,7 @@ impl Database {
if !participant if !participant
.role .role
.unwrap_or(ChannelRole::Member) .unwrap_or(ChannelRole::Member)
.can_share_projects() .can_publish_to_rooms()
{ {
return Err(anyhow!("guests cannot share projects"))?; return Err(anyhow!("guests cannot share projects"))?;
} }
@ -100,6 +102,7 @@ impl Database {
.await .await
} }
/// Unshares the given project.
pub async fn unshare_project( pub async fn unshare_project(
&self, &self,
project_id: ProjectId, project_id: ProjectId,
@ -126,6 +129,7 @@ impl Database {
.await .await
} }
/// Updates the worktrees associated with the given project.
pub async fn update_project( pub async fn update_project(
&self, &self,
project_id: ProjectId, project_id: ProjectId,
@ -346,6 +350,7 @@ impl Database {
.await .await
} }
/// Updates the diagnostic summary for the given connection.
pub async fn update_diagnostic_summary( pub async fn update_diagnostic_summary(
&self, &self,
update: &proto::UpdateDiagnosticSummary, update: &proto::UpdateDiagnosticSummary,
@ -401,6 +406,7 @@ impl Database {
.await .await
} }
/// Starts the language server for the given connection.
pub async fn start_language_server( pub async fn start_language_server(
&self, &self,
update: &proto::StartLanguageServer, update: &proto::StartLanguageServer,
@ -447,6 +453,7 @@ impl Database {
.await .await
} }
/// Updates the worktree settings for the given connection.
pub async fn update_worktree_settings( pub async fn update_worktree_settings(
&self, &self,
update: &proto::UpdateWorktreeSettings, update: &proto::UpdateWorktreeSettings,
@ -499,6 +506,7 @@ impl Database {
.await .await
} }
/// Adds the given connection to the specified project.
pub async fn join_project( pub async fn join_project(
&self, &self,
project_id: ProjectId, project_id: ProjectId,
@ -704,6 +712,7 @@ impl Database {
.await .await
} }
/// Removes the given connection from the specified project.
pub async fn leave_project( pub async fn leave_project(
&self, &self,
project_id: ProjectId, project_id: ProjectId,
@ -805,6 +814,7 @@ impl Database {
.map(|guard| guard.into_inner()) .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( pub async fn host_for_read_only_project_request(
&self, &self,
project_id: ProjectId, project_id: ProjectId,
@ -842,6 +852,7 @@ impl Database {
.map(|guard| guard.into_inner()) .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( pub async fn host_for_mutating_project_request(
&self, &self,
project_id: ProjectId, project_id: ProjectId,
@ -883,6 +894,7 @@ impl Database {
&self, &self,
project_id: ProjectId, project_id: ProjectId,
connection_id: ConnectionId, connection_id: ConnectionId,
requires_write: bool,
) -> Result<RoomGuard<Vec<ProjectCollaborator>>> { ) -> Result<RoomGuard<Vec<ProjectCollaborator>>> {
let room_id = self.room_id_for_project(project_id).await?; let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move { self.room_transaction(room_id, |tx| async move {
@ -893,9 +905,10 @@ impl Database {
.await? .await?
.ok_or_else(|| anyhow!("no such room"))?; .ok_or_else(|| anyhow!("no such room"))?;
if !current_participant if requires_write
.role && !current_participant
.map_or(false, |role| role.can_edit_projects()) .role
.map_or(false, |role| role.can_edit_projects())
{ {
Err(anyhow!("not authorized to edit projects"))?; Err(anyhow!("not authorized to edit projects"))?;
} }
@ -925,6 +938,10 @@ impl Database {
.await .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( pub async fn project_connection_ids(
&self, &self,
project_id: ProjectId, project_id: ProjectId,
@ -974,6 +991,7 @@ impl Database {
Ok(guest_connection_ids) Ok(guest_connection_ids)
} }
/// Returns the [`RoomId`] for the given project.
pub async fn room_id_for_project(&self, project_id: ProjectId) -> Result<RoomId> { pub async fn room_id_for_project(&self, project_id: ProjectId) -> Result<RoomId> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
let project = project::Entity::find_by_id(project_id) let project = project::Entity::find_by_id(project_id)
@ -1018,6 +1036,7 @@ impl Database {
.await .await
} }
/// Adds the given follower connection as a follower of the given leader connection.
pub async fn follow( pub async fn follow(
&self, &self,
room_id: RoomId, room_id: RoomId,
@ -1048,6 +1067,7 @@ impl Database {
.await .await
} }
/// Removes the given follower connection as a follower of the given leader connection.
pub async fn unfollow( pub async fn unfollow(
&self, &self,
room_id: RoomId, room_id: RoomId,

View file

@ -1,6 +1,7 @@
use super::*; use super::*;
impl Database { impl Database {
/// Clears all room participants in rooms attached to a stale server.
pub async fn clear_stale_room_participants( pub async fn clear_stale_room_participants(
&self, &self,
room_id: RoomId, room_id: RoomId,
@ -78,6 +79,7 @@ impl Database {
.await .await
} }
/// Returns the incoming calls for user with the given ID.
pub async fn incoming_call_for_user( pub async fn incoming_call_for_user(
&self, &self,
user_id: UserId, user_id: UserId,
@ -102,6 +104,7 @@ impl Database {
.await .await
} }
/// Creates a new room.
pub async fn create_room( pub async fn create_room(
&self, &self,
user_id: UserId, user_id: UserId,
@ -112,7 +115,7 @@ impl Database {
self.transaction(|tx| async move { self.transaction(|tx| async move {
let room = room::ActiveModel { let room = room::ActiveModel {
live_kit_room: ActiveValue::set(live_kit_room.into()), 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() ..Default::default()
} }
.insert(&*tx) .insert(&*tx)
@ -299,28 +302,28 @@ impl Database {
room_id: RoomId, room_id: RoomId,
user_id: UserId, user_id: UserId,
connection: ConnectionId, connection: ConnectionId,
enviroment: &str, environment: &str,
) -> Result<RoomGuard<JoinRoom>> { ) -> Result<RoomGuard<JoinRoom>> {
self.room_transaction(room_id, |tx| async move { self.room_transaction(room_id, |tx| async move {
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryChannelIdAndEnviroment { enum QueryChannelIdAndEnvironment {
ChannelId, ChannelId,
Enviroment, Environment,
} }
let (channel_id, release_channel): (Option<ChannelId>, Option<String>) = let (channel_id, release_channel): (Option<ChannelId>, Option<String>) =
room::Entity::find() room::Entity::find()
.select_only() .select_only()
.column(room::Column::ChannelId) .column(room::Column::ChannelId)
.column(room::Column::Enviroment) .column(room::Column::Environment)
.filter(room::Column::Id.eq(room_id)) .filter(room::Column::Id.eq(room_id))
.into_values::<_, QueryChannelIdAndEnviroment>() .into_values::<_, QueryChannelIdAndEnvironment>()
.one(&*tx) .one(&*tx)
.await? .await?
.ok_or_else(|| anyhow!("no such room"))?; .ok_or_else(|| anyhow!("no such room"))?;
if let Some(release_channel) = release_channel { if let Some(release_channel) = release_channel {
if &release_channel != enviroment { if &release_channel != environment {
Err(anyhow!("must join using the {} release", release_channel))?; Err(anyhow!("must join using the {} release", release_channel))?;
} }
} }
@ -394,6 +397,7 @@ impl Database {
Ok(participant_index) 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<Option<ChannelId>> { pub async fn channel_id_for_room(&self, room_id: RoomId) -> Result<Option<ChannelId>> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
let room: Option<room::Model> = room::Entity::find() let room: Option<room::Model> = room::Entity::find()
@ -944,6 +948,7 @@ impl Database {
.await .await
} }
/// Updates the location of a participant in the given room.
pub async fn update_room_participant_location( pub async fn update_room_participant_location(
&self, &self,
room_id: RoomId, room_id: RoomId,
@ -1004,6 +1009,47 @@ impl Database {
.await .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<RoomGuard<proto::Room>> {
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<()> { pub async fn connection_lost(&self, connection: ConnectionId) -> Result<()> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
self.room_connection_lost(connection, &*tx).await?; self.room_connection_lost(connection, &*tx).await?;

View file

@ -1,6 +1,7 @@
use super::*; use super::*;
impl Database { impl Database {
/// Creates a new server in the given environment.
pub async fn create_server(&self, environment: &str) -> Result<ServerId> { pub async fn create_server(&self, environment: &str) -> Result<ServerId> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
let server = server::ActiveModel { let server = server::ActiveModel {
@ -14,6 +15,10 @@ impl Database {
.await .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( pub async fn stale_server_resource_ids(
&self, &self,
environment: &str, environment: &str,
@ -61,6 +66,7 @@ impl Database {
.await .await
} }
/// Deletes any stale servers in the environment that don't match the `new_server_id`.
pub async fn delete_stale_servers( pub async fn delete_stale_servers(
&self, &self,
environment: &str, environment: &str,

View file

@ -1,6 +1,7 @@
use super::*; use super::*;
impl Database { impl Database {
/// Creates a new user.
pub async fn create_user( pub async fn create_user(
&self, &self,
email_address: &str, email_address: &str,
@ -19,7 +20,11 @@ impl Database {
}) })
.on_conflict( .on_conflict(
OnConflict::column(user::Column::GithubLogin) OnConflict::column(user::Column::GithubLogin)
.update_column(user::Column::GithubLogin) .update_columns([
user::Column::Admin,
user::Column::EmailAddress,
user::Column::GithubUserId,
])
.to_owned(), .to_owned(),
) )
.exec_with_returning(&*tx) .exec_with_returning(&*tx)
@ -35,11 +40,13 @@ impl Database {
.await .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<Option<user::Model>> { pub async fn get_user_by_id(&self, id: UserId) -> Result<Option<user::Model>> {
self.transaction(|tx| async move { Ok(user::Entity::find_by_id(id).one(&*tx).await?) }) self.transaction(|tx| async move { Ok(user::Entity::find_by_id(id).one(&*tx).await?) })
.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<UserId>) -> Result<Vec<user::Model>> { pub async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<user::Model>> {
self.transaction(|tx| async { self.transaction(|tx| async {
let tx = tx; let tx = tx;
@ -51,6 +58,7 @@ impl Database {
.await .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<Option<User>> { pub async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
Ok(user::Entity::find() Ok(user::Entity::find()
@ -111,6 +119,8 @@ impl Database {
.await .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<Vec<User>> { pub async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<User>> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
Ok(user::Entity::find() Ok(user::Entity::find()
@ -123,6 +133,7 @@ impl Database {
.await .await
} }
/// Returns the metrics id for the user.
pub async fn get_user_metrics_id(&self, id: UserId) -> Result<String> { pub async fn get_user_metrics_id(&self, id: UserId) -> Result<String> {
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryAs { enum QueryAs {
@ -142,6 +153,7 @@ impl Database {
.await .await
} }
/// Set "connected_once" on the user for analytics.
pub async fn set_user_connected_once(&self, id: UserId, connected_once: bool) -> Result<()> { pub async fn set_user_connected_once(&self, id: UserId, connected_once: bool) -> Result<()> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
user::Entity::update_many() user::Entity::update_many()
@ -157,6 +169,7 @@ impl Database {
.await .await
} }
/// hard delete the user.
pub async fn destroy_user(&self, id: UserId) -> Result<()> { pub async fn destroy_user(&self, id: UserId) -> Result<()> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
access_token::Entity::delete_many() access_token::Entity::delete_many()
@ -169,6 +182,7 @@ impl Database {
.await .await
} }
/// Find users where github_login ILIKE name_query.
pub async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result<Vec<User>> { pub async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result<Vec<User>> {
self.transaction(|tx| async { self.transaction(|tx| async {
let tx = tx; let tx = tx;
@ -193,6 +207,8 @@ impl Database {
.await .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 { pub fn fuzzy_like_string(string: &str) -> String {
let mut result = String::with_capacity(string.len() * 2 + 1); let mut result = String::with_capacity(string.len() * 2 + 1);
for c in string.chars() { for c in string.chars() {
@ -205,6 +221,7 @@ impl Database {
result result
} }
/// Creates a new feature flag.
pub async fn create_user_flag(&self, flag: &str) -> Result<FlagId> { pub async fn create_user_flag(&self, flag: &str) -> Result<FlagId> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
let flag = feature_flag::Entity::insert(feature_flag::ActiveModel { let flag = feature_flag::Entity::insert(feature_flag::ActiveModel {
@ -220,6 +237,7 @@ impl Database {
.await .await
} }
/// Add the given user to the feature flag
pub async fn add_user_flag(&self, user: UserId, flag: FlagId) -> Result<()> { pub async fn add_user_flag(&self, user: UserId, flag: FlagId) -> Result<()> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
user_feature::Entity::insert(user_feature::ActiveModel { user_feature::Entity::insert(user_feature::ActiveModel {
@ -234,6 +252,7 @@ impl Database {
.await .await
} }
/// Return the active flags for the user.
pub async fn get_user_flags(&self, user: UserId) -> Result<Vec<String>> { pub async fn get_user_flags(&self, user: UserId) -> Result<Vec<String>> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]

View file

@ -7,6 +7,7 @@ pub struct Model {
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: AccessTokenId, pub id: AccessTokenId,
pub user_id: UserId, pub user_id: UserId,
pub impersonated_user_id: Option<UserId>,
pub hash: String, pub hash: String,
} }

View file

@ -8,7 +8,7 @@ pub struct Model {
pub id: RoomId, pub id: RoomId,
pub live_kit_room: String, pub live_kit_room: String,
pub channel_id: Option<ChannelId>, pub channel_id: Option<ChannelId>,
pub enviroment: Option<String>, pub environment: Option<String>,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View file

@ -2,6 +2,7 @@ use crate::db::UserId;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use serde::Serialize; use serde::Serialize;
/// A user model.
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)] #[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)]
#[sea_orm(table_name = "users")] #[sea_orm(table_name = "users")]
pub struct Model { pub struct Model {

View file

@ -146,7 +146,7 @@ test_both_dbs!(
); );
async fn test_create_access_tokens(db: &Arc<Database>) { async fn test_create_access_tokens(db: &Arc<Database>) {
let user = db let user_1 = db
.create_user( .create_user(
"u1@example.com", "u1@example.com",
false, false,
@ -158,14 +158,27 @@ async fn test_create_access_tokens(db: &Arc<Database>) {
.await .await
.unwrap() .unwrap()
.user_id; .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_1 = db.create_access_token(user_1, None, "h1", 2).await.unwrap();
let token_2 = db.create_access_token(user, "h2", 2).await.unwrap(); let token_2 = db.create_access_token(user_1, None, "h2", 2).await.unwrap();
assert_eq!( assert_eq!(
db.get_access_token(token_1).await.unwrap(), db.get_access_token(token_1).await.unwrap(),
access_token::Model { access_token::Model {
id: token_1, id: token_1,
user_id: user, user_id: user_1,
impersonated_user_id: None,
hash: "h1".into(), hash: "h1".into(),
} }
); );
@ -173,17 +186,19 @@ async fn test_create_access_tokens(db: &Arc<Database>) {
db.get_access_token(token_2).await.unwrap(), db.get_access_token(token_2).await.unwrap(),
access_token::Model { access_token::Model {
id: token_2, id: token_2,
user_id: user, user_id: user_1,
impersonated_user_id: None,
hash: "h2".into() 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!( assert_eq!(
db.get_access_token(token_3).await.unwrap(), db.get_access_token(token_3).await.unwrap(),
access_token::Model { access_token::Model {
id: token_3, id: token_3,
user_id: user, user_id: user_1,
impersonated_user_id: None,
hash: "h3".into() hash: "h3".into()
} }
); );
@ -191,18 +206,20 @@ async fn test_create_access_tokens(db: &Arc<Database>) {
db.get_access_token(token_2).await.unwrap(), db.get_access_token(token_2).await.unwrap(),
access_token::Model { access_token::Model {
id: token_2, id: token_2,
user_id: user, user_id: user_1,
impersonated_user_id: None,
hash: "h2".into() hash: "h2".into()
} }
); );
assert!(db.get_access_token(token_1).await.is_err()); 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!( assert_eq!(
db.get_access_token(token_4).await.unwrap(), db.get_access_token(token_4).await.unwrap(),
access_token::Model { access_token::Model {
id: token_4, id: token_4,
user_id: user, user_id: user_1,
impersonated_user_id: None,
hash: "h4".into() hash: "h4".into()
} }
); );
@ -210,12 +227,77 @@ async fn test_create_access_tokens(db: &Arc<Database>) {
db.get_access_token(token_3).await.unwrap(), db.get_access_token(token_3).await.unwrap(),
access_token::Model { access_token::Model {
id: token_3, id: token_3,
user_id: user, user_id: user_1,
impersonated_user_id: None,
hash: "h3".into() hash: "h3".into()
} }
); );
assert!(db.get_access_token(token_2).await.is_err()); assert!(db.get_access_token(token_2).await.is_err());
assert!(db.get_access_token(token_1).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!( test_both_dbs!(

View file

@ -103,6 +103,12 @@ pub struct Config {
pub zed_environment: Arc<str>, pub zed_environment: Arc<str>,
} }
impl Config {
pub fn is_development(&self) -> bool {
self.zed_environment == "development".into()
}
}
#[derive(Default, Deserialize)] #[derive(Default, Deserialize)]
pub struct MigrateConfig { pub struct MigrateConfig {
pub database_url: String, pub database_url: String,

View file

@ -53,6 +53,25 @@ async fn main() -> Result<()> {
let config = envy::from_env::<Config>().expect("error loading config"); let config = envy::from_env::<Config>().expect("error loading config");
init_tracing(&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 state = AppState::new(config).await?;
let listener = TcpListener::bind(&format!("0.0.0.0:{}", state.config.http_port)) let listener = TcpListener::bind(&format!("0.0.0.0:{}", state.config.http_port))

View file

@ -1,7 +1,7 @@
mod connection_pool; mod connection_pool;
use crate::{ use crate::{
auth, auth::{self, Impersonator},
db::{ db::{
self, BufferId, ChannelId, ChannelRole, ChannelsForUser, CreateChannelResult, self, BufferId, ChannelId, ChannelRole, ChannelsForUser, CreateChannelResult,
CreatedChannelMessage, Database, InviteMemberResult, MembershipUpdated, MessageId, CreatedChannelMessage, Database, InviteMemberResult, MembershipUpdated, MessageId,
@ -65,7 +65,7 @@ use std::{
use time::OffsetDateTime; use time::OffsetDateTime;
use tokio::sync::{watch, Semaphore}; use tokio::sync::{watch, Semaphore};
use tower::ServiceBuilder; 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 RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10); pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10);
@ -202,6 +202,7 @@ impl Server {
.add_request_handler(join_room) .add_request_handler(join_room)
.add_request_handler(rejoin_room) .add_request_handler(rejoin_room)
.add_request_handler(leave_room) .add_request_handler(leave_room)
.add_request_handler(set_room_participant_role)
.add_request_handler(call) .add_request_handler(call)
.add_request_handler(cancel_call) .add_request_handler(cancel_call)
.add_message_handler(decline_call) .add_message_handler(decline_call)
@ -560,13 +561,17 @@ impl Server {
connection: Connection, connection: Connection,
address: String, address: String,
user: User, user: User,
impersonator: Option<User>,
mut send_connection_id: Option<oneshot::Sender<ConnectionId>>, mut send_connection_id: Option<oneshot::Sender<ConnectionId>>,
executor: Executor, executor: Executor,
) -> impl Future<Output = Result<()>> { ) -> impl Future<Output = Result<()>> {
let this = self.clone(); let this = self.clone();
let user_id = user.id; let user_id = user.id;
let login = user.github_login; 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(); let mut teardown = self.teardown.subscribe();
async move { async move {
let (connection_id, handle_io, mut incoming_rx) = this let (connection_id, handle_io, mut incoming_rx) = this
@ -838,6 +843,7 @@ pub async fn handle_websocket_request(
ConnectInfo(socket_address): ConnectInfo<SocketAddr>, ConnectInfo(socket_address): ConnectInfo<SocketAddr>,
Extension(server): Extension<Arc<Server>>, Extension(server): Extension<Arc<Server>>,
Extension(user): Extension<User>, Extension(user): Extension<User>,
Extension(impersonator): Extension<Impersonator>,
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
) -> axum::response::Response { ) -> axum::response::Response {
if protocol_version != rpc::PROTOCOL_VERSION { if protocol_version != rpc::PROTOCOL_VERSION {
@ -857,7 +863,14 @@ pub async fn handle_websocket_request(
let connection = Connection::new(Box::pin(socket)); let connection = Connection::new(Box::pin(socket));
async move { async move {
server server
.handle_connection(connection, socket_address, user, None, Executor::Production) .handle_connection(
connection,
socket_address,
user,
impersonator.0,
None,
Executor::Production,
)
.await .await
.log_err(); .log_err();
} }
@ -931,11 +944,13 @@ async fn connection_lost(
Ok(()) Ok(())
} }
/// Acknowledges a ping from a client, used to keep the connection alive.
async fn ping(_: proto::Ping, response: Response<proto::Ping>, _session: Session) -> Result<()> { async fn ping(_: proto::Ping, response: Response<proto::Ping>, _session: Session) -> Result<()> {
response.send(proto::Ack {})?; response.send(proto::Ack {})?;
Ok(()) Ok(())
} }
/// Create a new room for calling (outside of channels)
async fn create_room( async fn create_room(
_request: proto::CreateRoom, _request: proto::CreateRoom,
response: Response<proto::CreateRoom>, response: Response<proto::CreateRoom>,
@ -983,6 +998,7 @@ async fn create_room(
Ok(()) Ok(())
} }
/// Join a room from an invitation. Equivalent to joining a channel if there is one.
async fn join_room( async fn join_room(
request: proto::JoinRoom, request: proto::JoinRoom,
response: Response<proto::JoinRoom>, response: Response<proto::JoinRoom>,
@ -1057,6 +1073,7 @@ async fn join_room(
Ok(()) Ok(())
} }
/// Rejoin room is used to reconnect to a room after connection errors.
async fn rejoin_room( async fn rejoin_room(
request: proto::RejoinRoom, request: proto::RejoinRoom,
response: Response<proto::RejoinRoom>, response: Response<proto::RejoinRoom>,
@ -1248,6 +1265,7 @@ async fn rejoin_room(
Ok(()) Ok(())
} }
/// leave room disonnects from the room.
async fn leave_room( async fn leave_room(
_: proto::LeaveRoom, _: proto::LeaveRoom,
response: Response<proto::LeaveRoom>, response: Response<proto::LeaveRoom>,
@ -1258,6 +1276,52 @@ async fn leave_room(
Ok(()) Ok(())
} }
/// Update the permissions of someone else in the room.
async fn set_room_participant_role(
request: proto::SetRoomParticipantRole,
response: Response<proto::SetRoomParticipantRole>,
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( async fn call(
request: proto::Call, request: proto::Call,
response: Response<proto::Call>, response: Response<proto::Call>,
@ -1326,6 +1390,7 @@ async fn call(
Err(anyhow!("failed to ring user"))? Err(anyhow!("failed to ring user"))?
} }
/// Cancel an outgoing call.
async fn cancel_call( async fn cancel_call(
request: proto::CancelCall, request: proto::CancelCall,
response: Response<proto::CancelCall>, response: Response<proto::CancelCall>,
@ -1363,6 +1428,7 @@ async fn cancel_call(
Ok(()) Ok(())
} }
/// Decline an incoming call.
async fn decline_call(message: proto::DeclineCall, session: Session) -> Result<()> { async fn decline_call(message: proto::DeclineCall, session: Session) -> Result<()> {
let room_id = RoomId::from_proto(message.room_id); let room_id = RoomId::from_proto(message.room_id);
{ {
@ -1394,6 +1460,7 @@ async fn decline_call(message: proto::DeclineCall, session: Session) -> Result<(
Ok(()) Ok(())
} }
/// Update other participants in the room with your current location.
async fn update_participant_location( async fn update_participant_location(
request: proto::UpdateParticipantLocation, request: proto::UpdateParticipantLocation,
response: Response<proto::UpdateParticipantLocation>, response: Response<proto::UpdateParticipantLocation>,
@ -1414,6 +1481,7 @@ async fn update_participant_location(
Ok(()) Ok(())
} }
/// Share a project into the room.
async fn share_project( async fn share_project(
request: proto::ShareProject, request: proto::ShareProject,
response: Response<proto::ShareProject>, response: Response<proto::ShareProject>,
@ -1436,6 +1504,7 @@ async fn share_project(
Ok(()) Ok(())
} }
/// Unshare a project from the room.
async fn unshare_project(message: proto::UnshareProject, session: Session) -> Result<()> { async fn unshare_project(message: proto::UnshareProject, session: Session) -> Result<()> {
let project_id = ProjectId::from_proto(message.project_id); let project_id = ProjectId::from_proto(message.project_id);
@ -1455,6 +1524,7 @@ async fn unshare_project(message: proto::UnshareProject, session: Session) -> Re
Ok(()) Ok(())
} }
/// Join someone elses shared project.
async fn join_project( async fn join_project(
request: proto::JoinProject, request: proto::JoinProject,
response: Response<proto::JoinProject>, response: Response<proto::JoinProject>,
@ -1580,6 +1650,7 @@ async fn join_project(
Ok(()) Ok(())
} }
/// Leave someone elses shared project.
async fn leave_project(request: proto::LeaveProject, session: Session) -> Result<()> { async fn leave_project(request: proto::LeaveProject, session: Session) -> Result<()> {
let sender_id = session.connection_id; let sender_id = session.connection_id;
let project_id = ProjectId::from_proto(request.project_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(()) Ok(())
} }
/// Update other participants with changes to the project
async fn update_project( async fn update_project(
request: proto::UpdateProject, request: proto::UpdateProject,
response: Response<proto::UpdateProject>, response: Response<proto::UpdateProject>,
@ -1628,6 +1700,7 @@ async fn update_project(
Ok(()) Ok(())
} }
/// Update other participants with changes to the worktree
async fn update_worktree( async fn update_worktree(
request: proto::UpdateWorktree, request: proto::UpdateWorktree,
response: Response<proto::UpdateWorktree>, response: Response<proto::UpdateWorktree>,
@ -1652,6 +1725,7 @@ async fn update_worktree(
Ok(()) Ok(())
} }
/// Update other participants with changes to the diagnostics
async fn update_diagnostic_summary( async fn update_diagnostic_summary(
message: proto::UpdateDiagnosticSummary, message: proto::UpdateDiagnosticSummary,
session: Session, session: Session,
@ -1675,6 +1749,7 @@ async fn update_diagnostic_summary(
Ok(()) Ok(())
} }
/// Update other participants with changes to the worktree settings
async fn update_worktree_settings( async fn update_worktree_settings(
message: proto::UpdateWorktreeSettings, message: proto::UpdateWorktreeSettings,
session: Session, session: Session,
@ -1698,6 +1773,7 @@ async fn update_worktree_settings(
Ok(()) Ok(())
} }
/// Notify other participants that a language server has started.
async fn start_language_server( async fn start_language_server(
request: proto::StartLanguageServer, request: proto::StartLanguageServer,
session: Session, session: Session,
@ -1720,6 +1796,7 @@ async fn start_language_server(
Ok(()) Ok(())
} }
/// Notify other participants that a language server has changed.
async fn update_language_server( async fn update_language_server(
request: proto::UpdateLanguageServer, request: proto::UpdateLanguageServer,
session: Session, session: Session,
@ -1742,6 +1819,8 @@ async fn update_language_server(
Ok(()) 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<T>( async fn forward_read_only_project_request<T>(
request: T, request: T,
response: Response<T>, response: Response<T>,
@ -1764,6 +1843,8 @@ where
Ok(()) Ok(())
} }
/// forward a project request to the host. These requests are disallowed
/// for guests.
async fn forward_mutating_project_request<T>( async fn forward_mutating_project_request<T>(
request: T, request: T,
response: Response<T>, response: Response<T>,
@ -1786,6 +1867,7 @@ where
Ok(()) Ok(())
} }
/// Notify other participants that a new buffer has been created
async fn create_buffer_for_peer( async fn create_buffer_for_peer(
request: proto::CreateBufferForPeer, request: proto::CreateBufferForPeer,
session: Session, session: Session,
@ -1805,6 +1887,8 @@ async fn create_buffer_for_peer(
Ok(()) 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( async fn update_buffer(
request: proto::UpdateBuffer, request: proto::UpdateBuffer,
response: Response<proto::UpdateBuffer>, response: Response<proto::UpdateBuffer>,
@ -1814,11 +1898,24 @@ async fn update_buffer(
let mut guest_connection_ids; let mut guest_connection_ids;
let mut host_connection_id = None; 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 let collaborators = session
.db() .db()
.await .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?; .await?;
guest_connection_ids = Vec::with_capacity(collaborators.len() - 1); guest_connection_ids = Vec::with_capacity(collaborators.len() - 1);
for collaborator in collaborators.iter() { for collaborator in collaborators.iter() {
@ -1851,6 +1948,7 @@ async fn update_buffer(
Ok(()) Ok(())
} }
/// Notify other participants that a project has been updated.
async fn broadcast_project_message_from_host<T: EntityMessage<Entity = ShareProject>>( async fn broadcast_project_message_from_host<T: EntityMessage<Entity = ShareProject>>(
request: T, request: T,
session: Session, session: Session,
@ -1874,6 +1972,7 @@ async fn broadcast_project_message_from_host<T: EntityMessage<Entity = ShareProj
Ok(()) Ok(())
} }
/// Start following another user in a call.
async fn follow( async fn follow(
request: proto::Follow, request: proto::Follow,
response: Response<proto::Follow>, response: Response<proto::Follow>,
@ -1911,6 +2010,7 @@ async fn follow(
Ok(()) Ok(())
} }
/// Stop following another user in a call.
async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> { async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> {
let room_id = RoomId::from_proto(request.room_id); let room_id = RoomId::from_proto(request.room_id);
let project_id = request.project_id.map(ProjectId::from_proto); let project_id = request.project_id.map(ProjectId::from_proto);
@ -1942,6 +2042,7 @@ async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> {
Ok(()) Ok(())
} }
/// Notify everyone following you of your current location.
async fn update_followers(request: proto::UpdateFollowers, session: Session) -> Result<()> { async fn update_followers(request: proto::UpdateFollowers, session: Session) -> Result<()> {
let room_id = RoomId::from_proto(request.room_id); let room_id = RoomId::from_proto(request.room_id);
let database = session.db.lock().await; let database = session.db.lock().await;
@ -1978,6 +2079,7 @@ async fn update_followers(request: proto::UpdateFollowers, session: Session) ->
Ok(()) Ok(())
} }
/// Get public data about users.
async fn get_users( async fn get_users(
request: proto::GetUsers, request: proto::GetUsers,
response: Response<proto::GetUsers>, response: Response<proto::GetUsers>,
@ -2004,6 +2106,7 @@ async fn get_users(
Ok(()) Ok(())
} }
/// Search for users (to invite) buy Github login
async fn fuzzy_search_users( async fn fuzzy_search_users(
request: proto::FuzzySearchUsers, request: proto::FuzzySearchUsers,
response: Response<proto::FuzzySearchUsers>, response: Response<proto::FuzzySearchUsers>,
@ -2034,6 +2137,7 @@ async fn fuzzy_search_users(
Ok(()) Ok(())
} }
/// Send a contact request to another user.
async fn request_contact( async fn request_contact(
request: proto::RequestContact, request: proto::RequestContact,
response: Response<proto::RequestContact>, response: Response<proto::RequestContact>,
@ -2080,6 +2184,7 @@ async fn request_contact(
Ok(()) Ok(())
} }
/// Accept or decline a contact request
async fn respond_to_contact_request( async fn respond_to_contact_request(
request: proto::RespondToContactRequest, request: proto::RespondToContactRequest,
response: Response<proto::RespondToContactRequest>, response: Response<proto::RespondToContactRequest>,
@ -2137,6 +2242,7 @@ async fn respond_to_contact_request(
Ok(()) Ok(())
} }
/// Remove a contact.
async fn remove_contact( async fn remove_contact(
request: proto::RemoveContact, request: proto::RemoveContact,
response: Response<proto::RemoveContact>, response: Response<proto::RemoveContact>,
@ -2187,6 +2293,7 @@ async fn remove_contact(
Ok(()) Ok(())
} }
/// Create a new channel.
async fn create_channel( async fn create_channel(
request: proto::CreateChannel, request: proto::CreateChannel,
response: Response<proto::CreateChannel>, response: Response<proto::CreateChannel>,
@ -2221,6 +2328,7 @@ async fn create_channel(
Ok(()) Ok(())
} }
/// Delete a channel
async fn delete_channel( async fn delete_channel(
request: proto::DeleteChannel, request: proto::DeleteChannel,
response: Response<proto::DeleteChannel>, response: Response<proto::DeleteChannel>,
@ -2250,6 +2358,7 @@ async fn delete_channel(
Ok(()) Ok(())
} }
/// Invite someone to join a channel.
async fn invite_channel_member( async fn invite_channel_member(
request: proto::InviteChannelMember, request: proto::InviteChannelMember,
response: Response<proto::InviteChannelMember>, response: Response<proto::InviteChannelMember>,
@ -2286,6 +2395,7 @@ async fn invite_channel_member(
Ok(()) Ok(())
} }
/// remove someone from a channel
async fn remove_channel_member( async fn remove_channel_member(
request: proto::RemoveChannelMember, request: proto::RemoveChannelMember,
response: Response<proto::RemoveChannelMember>, response: Response<proto::RemoveChannelMember>,
@ -2327,6 +2437,7 @@ async fn remove_channel_member(
Ok(()) Ok(())
} }
/// Toggle the channel between public and private
async fn set_channel_visibility( async fn set_channel_visibility(
request: proto::SetChannelVisibility, request: proto::SetChannelVisibility,
response: Response<proto::SetChannelVisibility>, response: Response<proto::SetChannelVisibility>,
@ -2365,6 +2476,7 @@ async fn set_channel_visibility(
Ok(()) Ok(())
} }
/// Alter the role for a user in the channel
async fn set_channel_member_role( async fn set_channel_member_role(
request: proto::SetChannelMemberRole, request: proto::SetChannelMemberRole,
response: Response<proto::SetChannelMemberRole>, response: Response<proto::SetChannelMemberRole>,
@ -2412,6 +2524,7 @@ async fn set_channel_member_role(
Ok(()) Ok(())
} }
/// Change the name of a channel
async fn rename_channel( async fn rename_channel(
request: proto::RenameChannel, request: proto::RenameChannel,
response: Response<proto::RenameChannel>, response: Response<proto::RenameChannel>,
@ -2445,6 +2558,7 @@ async fn rename_channel(
Ok(()) Ok(())
} }
/// Move a channel to a new parent.
async fn move_channel( async fn move_channel(
request: proto::MoveChannel, request: proto::MoveChannel,
response: Response<proto::MoveChannel>, response: Response<proto::MoveChannel>,
@ -2497,6 +2611,7 @@ async fn notify_channel_moved(result: Option<MoveChannelResult>, session: Sessio
Ok(()) Ok(())
} }
/// Get the list of channel members
async fn get_channel_members( async fn get_channel_members(
request: proto::GetChannelMembers, request: proto::GetChannelMembers,
response: Response<proto::GetChannelMembers>, response: Response<proto::GetChannelMembers>,
@ -2511,6 +2626,7 @@ async fn get_channel_members(
Ok(()) Ok(())
} }
/// Accept or decline a channel invitation.
async fn respond_to_channel_invite( async fn respond_to_channel_invite(
request: proto::RespondToChannelInvite, request: proto::RespondToChannelInvite,
response: Response<proto::RespondToChannelInvite>, response: Response<proto::RespondToChannelInvite>,
@ -2551,6 +2667,7 @@ async fn respond_to_channel_invite(
Ok(()) Ok(())
} }
/// Join the channels' room
async fn join_channel( async fn join_channel(
request: proto::JoinChannel, request: proto::JoinChannel,
response: Response<proto::JoinChannel>, response: Response<proto::JoinChannel>,
@ -2655,6 +2772,7 @@ async fn join_channel_internal(
Ok(()) Ok(())
} }
/// Start editing the channel notes
async fn join_channel_buffer( async fn join_channel_buffer(
request: proto::JoinChannelBuffer, request: proto::JoinChannelBuffer,
response: Response<proto::JoinChannelBuffer>, response: Response<proto::JoinChannelBuffer>,
@ -2686,6 +2804,7 @@ async fn join_channel_buffer(
Ok(()) Ok(())
} }
/// Edit the channel notes
async fn update_channel_buffer( async fn update_channel_buffer(
request: proto::UpdateChannelBuffer, request: proto::UpdateChannelBuffer,
session: Session, session: Session,
@ -2732,6 +2851,7 @@ async fn update_channel_buffer(
Ok(()) Ok(())
} }
/// Rejoin the channel notes after a connection blip
async fn rejoin_channel_buffers( async fn rejoin_channel_buffers(
request: proto::RejoinChannelBuffers, request: proto::RejoinChannelBuffers,
response: Response<proto::RejoinChannelBuffers>, response: Response<proto::RejoinChannelBuffers>,
@ -2766,6 +2886,7 @@ async fn rejoin_channel_buffers(
Ok(()) Ok(())
} }
/// Stop editing the channel notes
async fn leave_channel_buffer( async fn leave_channel_buffer(
request: proto::LeaveChannelBuffer, request: proto::LeaveChannelBuffer,
response: Response<proto::LeaveChannelBuffer>, response: Response<proto::LeaveChannelBuffer>,
@ -2827,6 +2948,7 @@ fn send_notifications(
} }
} }
/// Send a message to the channel
async fn send_channel_message( async fn send_channel_message(
request: proto::SendChannelMessage, request: proto::SendChannelMessage,
response: Response<proto::SendChannelMessage>, response: Response<proto::SendChannelMessage>,
@ -2915,6 +3037,7 @@ async fn send_channel_message(
Ok(()) Ok(())
} }
/// Delete a channel message
async fn remove_channel_message( async fn remove_channel_message(
request: proto::RemoveChannelMessage, request: proto::RemoveChannelMessage,
response: Response<proto::RemoveChannelMessage>, response: Response<proto::RemoveChannelMessage>,
@ -2934,6 +3057,7 @@ async fn remove_channel_message(
Ok(()) Ok(())
} }
/// Mark a channel message as read
async fn acknowledge_channel_message( async fn acknowledge_channel_message(
request: proto::AckChannelMessage, request: proto::AckChannelMessage,
session: Session, session: Session,
@ -2953,6 +3077,7 @@ async fn acknowledge_channel_message(
Ok(()) Ok(())
} }
/// Mark a buffer version as synced
async fn acknowledge_buffer_version( async fn acknowledge_buffer_version(
request: proto::AckBufferOperation, request: proto::AckBufferOperation,
session: Session, session: Session,
@ -2971,6 +3096,7 @@ async fn acknowledge_buffer_version(
Ok(()) Ok(())
} }
/// Start receiving chat updates for a channel
async fn join_channel_chat( async fn join_channel_chat(
request: proto::JoinChannelChat, request: proto::JoinChannelChat,
response: Response<proto::JoinChannelChat>, response: Response<proto::JoinChannelChat>,
@ -2991,6 +3117,7 @@ async fn join_channel_chat(
Ok(()) Ok(())
} }
/// Stop receiving chat updates for a channel
async fn leave_channel_chat(request: proto::LeaveChannelChat, session: Session) -> Result<()> { async fn leave_channel_chat(request: proto::LeaveChannelChat, session: Session) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id); let channel_id = ChannelId::from_proto(request.channel_id);
session session
@ -3001,6 +3128,7 @@ async fn leave_channel_chat(request: proto::LeaveChannelChat, session: Session)
Ok(()) Ok(())
} }
/// Retrieve the chat history for a channel
async fn get_channel_messages( async fn get_channel_messages(
request: proto::GetChannelMessages, request: proto::GetChannelMessages,
response: Response<proto::GetChannelMessages>, response: Response<proto::GetChannelMessages>,
@ -3024,6 +3152,7 @@ async fn get_channel_messages(
Ok(()) Ok(())
} }
/// Retrieve specific chat messages
async fn get_channel_messages_by_id( async fn get_channel_messages_by_id(
request: proto::GetChannelMessagesById, request: proto::GetChannelMessagesById,
response: Response<proto::GetChannelMessagesById>, response: Response<proto::GetChannelMessagesById>,
@ -3046,6 +3175,7 @@ async fn get_channel_messages_by_id(
Ok(()) Ok(())
} }
/// Retrieve the current users notifications
async fn get_notifications( async fn get_notifications(
request: proto::GetNotifications, request: proto::GetNotifications,
response: Response<proto::GetNotifications>, response: Response<proto::GetNotifications>,
@ -3069,6 +3199,7 @@ async fn get_notifications(
Ok(()) Ok(())
} }
/// Mark notifications as read
async fn mark_notification_as_read( async fn mark_notification_as_read(
request: proto::MarkNotificationRead, request: proto::MarkNotificationRead,
response: Response<proto::MarkNotificationRead>, response: Response<proto::MarkNotificationRead>,
@ -3090,6 +3221,7 @@ async fn mark_notification_as_read(
Ok(()) Ok(())
} }
/// Get the current users information
async fn get_private_user_info( async fn get_private_user_info(
_request: proto::GetPrivateUserInfo, _request: proto::GetPrivateUserInfo,
response: Response<proto::GetPrivateUserInfo>, response: Response<proto::GetPrivateUserInfo>,

View file

@ -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::<ChannelView>()
.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::<usize>(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::<ChannelView>()
.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] #[gpui::test]
async fn test_channel_buffer_changes( async fn test_channel_buffer_changes(
deterministic: BackgroundExecutor, deterministic: BackgroundExecutor,

View file

@ -1,8 +1,8 @@
use crate::tests::TestServer; use crate::tests::TestServer;
use call::ActiveCall; use call::ActiveCall;
use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; use editor::Editor;
use gpui::{BackgroundExecutor, TestAppContext};
use rpc::proto; use rpc::proto;
use workspace::Workspace;
#[gpui::test] #[gpui::test]
async fn test_channel_guests( async fn test_channel_guests(
@ -13,37 +13,18 @@ async fn test_channel_guests(
let mut server = TestServer::start(executor.clone()).await; let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").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_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 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 // Client A shares a project in the channel
let project_a = client_a.build_test_project(cx_a).await;
active_call_a active_call_a
.update(cx_a, |call, cx| call.join_channel(channel_id, cx)) .update(cx_a, |call, cx| call.join_channel(channel_id, cx))
.await .await
.unwrap(); .unwrap();
let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
let project_id = active_call_a let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await .await
@ -57,38 +38,124 @@ async fn test_channel_guests(
// b should be following a in the shared project. // b should be following a in the shared project.
// B is a guest, // B is a guest,
cx_a.executor().run_until_parked(); executor.run_until_parked();
// todo!() the test window does not call activation handlers let active_call_b = cx_b.read(ActiveCall::global);
// correctly yet, so this API does not work. let project_b =
// let project_b = active_call_b.read_with(cx_b, |call, _| { active_call_b.read_with(cx_b, |call, _| call.location().unwrap().upgrade().unwrap());
// call.location() let room_b = active_call_b.update(cx_b, |call, _| call.room().unwrap().clone());
// .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::<Workspace>()
.unwrap()
.root_view(cx_b)
.unwrap();
let project_b = workspace_b.update(cx_b, |workspace, _| workspace.project().clone());
assert_eq!( assert_eq!(
project_b.read_with(cx_b, |project, _| project.remote_id()), project_b.read_with(cx_b, |project, _| project.remote_id()),
Some(project_id), Some(project_id),
); );
assert!(project_b.read_with(cx_b, |project, _| project.is_read_only())); assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
assert!(project_b assert!(project_b
.update(cx_b, |project, cx| { .update(cx_b, |project, cx| {
let worktree_id = project.worktrees().next().unwrap().read(cx).id(); let worktree_id = project.worktrees().next().unwrap().read(cx).id();
project.create_entry((worktree_id, "b.txt"), false, cx) project.create_entry((worktree_id, "b.txt"), false, cx)
}) })
.await .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::<Editor>(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());
} }

View file

@ -1,7 +1,9 @@
use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer};
use channel::{ChannelChat, ChannelMessageId, MessageParams}; use channel::{ChannelChat, ChannelMessageId, MessageParams};
use collab_ui::chat_panel::ChatPanel;
use gpui::{BackgroundExecutor, Model, TestAppContext}; use gpui::{BackgroundExecutor, Model, TestAppContext};
use rpc::Notification; use rpc::Notification;
use workspace::dock::Panel;
#[gpui::test] #[gpui::test]
async fn test_basic_channel_messages( async fn test_basic_channel_messages(
@ -262,7 +264,6 @@ async fn test_remove_channel_message(
#[track_caller] #[track_caller]
fn assert_messages(chat: &Model<ChannelChat>, messages: &[&str], cx: &mut TestAppContext) { fn assert_messages(chat: &Model<ChannelChat>, messages: &[&str], cx: &mut TestAppContext) {
// todo!(don't directly borrow here)
assert_eq!( assert_eq!(
chat.read_with(cx, |chat, _| { chat.read_with(cx, |chat, _| {
chat.messages() chat.messages()
@ -274,135 +275,135 @@ fn assert_messages(chat: &Model<ChannelChat>, messages: &[&str], cx: &mut TestAp
); );
} }
//todo!(collab_ui) #[gpui::test]
// #[gpui::test] async fn test_channel_message_changes(
// async fn test_channel_message_changes( executor: BackgroundExecutor,
// executor: BackgroundExecutor, cx_a: &mut TestAppContext,
// cx_a: &mut TestAppContext, cx_b: &mut TestAppContext,
// cx_b: &mut TestAppContext, ) {
// ) { let mut server = TestServer::start(executor.clone()).await;
// let mut server = TestServer::start(&executor).await; let client_a = server.create_client(cx_a, "user_a").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_b = server.create_client(cx_b, "user_b").await;
// let channel_id = server let channel_id = server
// .make_channel( .make_channel(
// "the-channel", "the-channel",
// None, None,
// (&client_a, cx_a), (&client_a, cx_a),
// &mut [(&client_b, cx_b)], &mut [(&client_b, cx_b)],
// ) )
// .await; .await;
// // Client A sends a message, client B should see that there is a new message. // Client A sends a message, client B should see that there is a new message.
// let channel_chat_a = client_a let channel_chat_a = client_a
// .channel_store() .channel_store()
// .update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx)) .update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
// .await .await
// .unwrap(); .unwrap();
// channel_chat_a channel_chat_a
// .update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap()) .update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap())
// .await .await
// .unwrap(); .unwrap();
// executor.run_until_parked(); executor.run_until_parked();
// let b_has_messages = cx_b.read_with(|cx| { let b_has_messages = cx_b.update(|cx| {
// client_b client_b
// .channel_store() .channel_store()
// .read(cx) .read(cx)
// .has_new_messages(channel_id) .has_new_messages(channel_id)
// .unwrap() .unwrap()
// }); });
// assert!(b_has_messages); assert!(b_has_messages);
// // Opening the chat should clear the changed flag. // Opening the chat should clear the changed flag.
// cx_b.update(|cx| { cx_b.update(|cx| {
// collab_ui::init(&client_b.app_state, cx); collab_ui::init(&client_b.app_state, cx);
// }); });
// let project_b = client_b.build_empty_local_project(cx_b); 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 (workspace_b, cx_b) = client_b.build_workspace(&project_b, 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();
// 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| { executor.run_until_parked();
// client_b
// .channel_store()
// .read(cx)
// .has_new_messages(channel_id)
// .unwrap()
// });
// 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. assert!(!b_has_messages);
// channel_chat_a
// .update(cx_a, |c, cx| c.send_message("two".into(), cx).unwrap())
// .await
// .unwrap();
// 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| { executor.run_until_parked();
// client_b
// .channel_store()
// .read(cx)
// .has_new_messages(channel_id)
// .unwrap()
// });
// 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. assert!(!b_has_messages);
// chat_panel_b.update(cx_b, |chat_panel, cx| {
// chat_panel.set_active(false, cx);
// });
// // Sending a message while the chat is open should not change the flag. // Sending a message while the chat is closed should change the flag.
// channel_chat_a chat_panel_b.update(cx_b, |chat_panel, cx| {
// .update(cx_a, |c, cx| c.send_message("three".into(), cx).unwrap()) chat_panel.set_active(false, cx);
// .await });
// .unwrap();
// 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| { executor.run_until_parked();
// client_b
// .channel_store()
// .read(cx)
// .has_new_messages(channel_id)
// .unwrap()
// });
// 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 assert!(b_has_messages);
// cx_b.update(|_| drop(chat_panel_b));
// channel_chat_a // Closing the chat should re-enable change tracking
// .update(cx_a, |c, cx| c.send_message("four".into(), cx).unwrap()) cx_b.update(|_| drop(chat_panel_b));
// .await
// .unwrap();
// 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| { executor.run_until_parked();
// client_b
// .channel_store()
// .read(cx)
// .has_new_messages(channel_id)
// .unwrap()
// });
// 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);
}

View file

@ -203,7 +203,7 @@ async fn test_core_channels(
executor.run_until_parked(); executor.run_until_parked();
// Observe that client B is now an admin of channel A, and that // 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_channel_invitations(client_b.channel_store(), cx_b, &[]);
assert_channels( assert_channels(
client_b.channel_store(), client_b.channel_store(),
@ -1337,6 +1337,7 @@ async fn test_guest_access(
}) })
.await .await
.unwrap(); .unwrap();
executor.run_until_parked();
assert_channels_list_shape(client_b.channel_store(), cx_b, &[]); 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 mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").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 let channels = server
.make_channel_tree( .make_channel_tree(

View file

@ -8,9 +8,11 @@ use std::{
use call::ActiveCall; use call::ActiveCall;
use editor::{ use editor::{
actions::{
ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Redo, Rename, ToggleCodeActions, Undo,
},
test::editor_test_context::{AssertionContextManager, EditorTestContext}, test::editor_test_context::{AssertionContextManager, EditorTestContext},
ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Editor, Redo, Rename, ToggleCodeActions, Editor,
Undo,
}; };
use futures::StreamExt; use futures::StreamExt;
use gpui::{TestAppContext, VisualContext, VisualTestContext}; use gpui::{TestAppContext, VisualContext, VisualTestContext};
@ -71,6 +73,7 @@ async fn test_host_disconnect(
let workspace_b = let workspace_b =
cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx)); 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 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 let editor_b = workspace_b
.update(cx_b, |workspace, cx| { .update(cx_b, |workspace, cx| {
@ -85,8 +88,10 @@ async fn test_host_disconnect(
//TODO: focus //TODO: focus
assert!(cx_b.update_view(&editor_b, |editor, cx| editor.is_focused(cx))); assert!(cx_b.update_view(&editor_b, |editor, cx| editor.is_focused(cx)));
editor_b.update(cx_b, |editor, cx| editor.insert("X", 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. // Drop client A's connection. Collaborators should disappear and the project should not be shown as shared.
server.forbid_connections(); 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. // Ensure client B's edited state is reset and that the whole window is blurred.
workspace_b workspace_b
.update(cx_b, |_, cx| { .update(cx_b, |workspace, cx| {
assert_eq!(cx.focused(), None); assert_eq!(cx.focused(), None);
assert!(!workspace.is_edited())
}) })
.unwrap(); .unwrap();
// assert!(!workspace_b.is_edited(cx_b));
// Ensure client B is not prompted to save edits when closing window after disconnecting. // Ensure client B is not prompted to save edits when closing window after disconnecting.
let can_close = workspace_b 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)) .update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
.await .await
.unwrap(); .unwrap();
let window_a = cx_a.add_empty_window(); let cx_a = cx_a.add_empty_window();
let editor_a = let editor_a = cx_a.new_view(|cx| Editor::for_buffer(buffer_a, Some(project_a), cx));
window_a.build_view(cx_a, |cx| Editor::for_buffer(buffer_a, Some(project_a), cx));
let mut editor_cx_a = EditorTestContext { let mut editor_cx_a = EditorTestContext {
cx: VisualTestContext::from_window(window_a, cx_a), cx: cx_a.clone(),
window: window_a.into(), window: cx_a.handle(),
editor: editor_a, editor: editor_a,
assertion_cx: AssertionContextManager::new(), assertion_cx: AssertionContextManager::new(),
}; };
let window_b = cx_b.add_empty_window(); let cx_b = cx_b.add_empty_window();
let mut cx_b = VisualTestContext::from_window(window_b, cx_b);
// Open a buffer as client B // Open a buffer as client B
let buffer_b = project_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 .await
.unwrap(); .unwrap();
let editor_b = window_b.build_view(&mut cx_b, |cx| { let editor_b = cx_b.new_view(|cx| Editor::for_buffer(buffer_b, Some(project_b), cx));
Editor::for_buffer(buffer_b, Some(project_b), cx)
});
let mut editor_cx_b = EditorTestContext { let mut editor_cx_b = EditorTestContext {
cx: cx_b, cx: cx_b.clone(),
window: window_b.into(), window: cx_b.handle(),
editor: editor_b, editor: editor_b,
assertion_cx: AssertionContextManager::new(), 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! {" editor_cx_b.set_selections_state(indoc! {"
Some textˇ 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(); executor.run_until_parked();
editor_cx_a.assert_editor_state(indoc! {" 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ˇ 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(); executor.run_until_parked();
editor_cx_a.assert_editor_state(indoc! {" 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)) .update(cx_b, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx))
.await .await
.unwrap(); .unwrap();
let window_b = cx_b.add_empty_window(); let cx_b = cx_b.add_empty_window();
let editor_b = window_b.build_view(cx_b, |cx| { let editor_b =
Editor::for_buffer(buffer_b.clone(), Some(project_b.clone()), cx) 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(); let fake_language_server = fake_language_servers.next().await.unwrap();
cx_a.background_executor.run_until_parked(); 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()) 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. // 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.change_selections(None, cx, |s| s.select_ranges([13..13]));
editor.handle_input(".", cx); 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. // Confirm a completion on the guest.
editor_b.update(cx_b, |editor, cx| {
editor_b.update(&mut cx_b, |editor, cx| {
assert!(editor.context_menu_visible()); assert!(editor.context_menu_visible());
editor.confirm_completion(&ConfirmCompletion { item_ix: Some(0) }, cx); editor.confirm_completion(&ConfirmCompletion { item_ix: Some(0) }, cx);
assert_eq!(editor.text(cx), "fn main() { a.first_method() }"); 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!( assert_eq!(
buffer.text(), buffer.text(),
"use d::SomeTrait;\nfn main() { a.first_method() }" "use d::SomeTrait;\nfn main() { a.first_method() }"
@ -957,7 +956,7 @@ async fn test_share_project(
cx_c: &mut TestAppContext, cx_c: &mut TestAppContext,
) { ) {
let executor = cx_a.executor(); 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 mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").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_b = server.create_client(cx_b, "user_b").await;
@ -1072,7 +1071,7 @@ async fn test_share_project(
.await .await
.unwrap(); .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 // Client A sees client B's selection
executor.run_until_parked(); 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. // 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(cx_b, |editor, cx| editor.handle_input("ok, ", cx));
editor_b.update(&mut cx_b, |editor, cx| editor.handle_input("ok, ", cx));
executor.run_until_parked(); executor.run_until_parked();
buffer_a.read_with(cx_a, |buffer, _| { 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. // Client B can invite client C on a project shared by client A.
active_call_b 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) call.invite(client_c.user_id().unwrap(), Some(project_b.clone()), cx)
}) })
.await .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)) .update(cx_a, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx))
.await .await
.unwrap(); .unwrap();
let window_a = cx_a.add_empty_window(); let cx_a = cx_a.add_empty_window();
let editor_a = window_a let editor_a = cx_a.new_view(|cx| Editor::for_buffer(buffer_a, Some(project_a.clone()), cx));
.update(cx_a, |_, cx| {
cx.new_view(|cx| Editor::for_buffer(buffer_a, Some(project_a.clone()), cx))
})
.unwrap();
let fake_language_server = fake_language_servers.next().await.unwrap(); let fake_language_server = fake_language_servers.next().await.unwrap();
executor.run_until_parked(); executor.run_until_parked();
// Receive an OnTypeFormatting request as the host's language server. // 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::<lsp::request::OnTypeFormatting, _, _>( fake_language_server.handle_request::<lsp::request::OnTypeFormatting, _, _>(
|params, _| async move { |params, _| async move {
assert_eq!( 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 let buffer_b = project_b
.update(cx_b, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx)) .update(cx_b, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx))
.await .await
.unwrap(); .unwrap();
let mut cx_a = VisualTestContext::from_window(window_a, cx_a);
// Type a on type formatting trigger character as the guest. // Type a on type formatting trigger character as the guest.
cx_a.focus_view(&editor_a); 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.change_selections(None, cx, |s| s.select_ranges([13..13]));
editor.handle_input(">", cx); editor.handle_input(">", cx);
}); });
@ -1238,7 +1231,7 @@ async fn test_on_input_format_from_host_to_guest(
}); });
// Undo should remove LSP edits first // 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>~< }"); assert_eq!(editor.text(cx), "fn main() { a>~< }");
editor.undo(&Undo, cx); editor.undo(&Undo, cx);
assert_eq!(editor.text(cx), "fn main() { a> }"); 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> }") 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> }"); assert_eq!(editor.text(cx), "fn main() { a> }");
editor.undo(&Undo, cx); editor.undo(&Undo, cx);
assert_eq!(editor.text(cx), "fn main() { a }"); 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)) .update(cx_b, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx))
.await .await
.unwrap(); .unwrap();
let window_b = cx_b.add_empty_window(); let cx_b = cx_b.add_empty_window();
let editor_b = window_b.build_view(cx_b, |cx| { let editor_b = cx_b.new_view(|cx| Editor::for_buffer(buffer_b, Some(project_b.clone()), cx));
Editor::for_buffer(buffer_b, Some(project_b.clone()), cx)
});
let fake_language_server = fake_language_servers.next().await.unwrap(); let fake_language_server = fake_language_servers.next().await.unwrap();
executor.run_until_parked(); 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. // Type a on type formatting trigger character as the guest.
cx_b.focus_view(&editor_b); 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.change_selections(None, cx, |s| s.select_ranges([13..13]));
editor.handle_input(":", cx); editor.handle_input(":", cx);
}); });
// Receive an OnTypeFormatting request as the host's language server. // 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(); executor.start_waiting();
fake_language_server fake_language_server
.handle_request::<lsp::request::OnTypeFormatting, _, _>(|params, _| async move { .handle_request::<lsp::request::OnTypeFormatting, _, _>(|params, _| async move {
@ -1359,7 +1350,7 @@ async fn test_on_input_format_from_guest_to_host(
.unwrap(); .unwrap();
executor.finish_waiting(); 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 let buffer_a = project_a
.update(cx_a, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx)) .update(cx_a, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx))
.await .await
@ -1371,7 +1362,7 @@ async fn test_on_input_format_from_guest_to_host(
}); });
// Undo should remove LSP edits first // 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:~: }"); assert_eq!(editor.text(cx), "fn main() { a:~: }");
editor.undo(&Undo, cx); editor.undo(&Undo, cx);
assert_eq!(editor.text(cx), "fn main() { a: }"); 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: }") 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: }"); assert_eq!(editor.text(cx), "fn main() { a: }");
editor.undo(&Undo, cx); editor.undo(&Undo, cx);
assert_eq!(editor.text(cx), "fn main() { a }"); assert_eq!(editor.text(cx), "fn main() { a }");
@ -1833,7 +1824,7 @@ async fn test_inlay_hint_refresh_is_forwarded(
assert_eq!( assert_eq!(
inlay_cache.version(), inlay_cache.version(),
1, 1,
"Should update cache verison after first hints" "Should update cache version after first hints"
); );
}); });

View file

@ -1,9 +1,12 @@
use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer};
use call::ActiveCall; use call::{ActiveCall, ParticipantLocation};
use collab_ui::notifications::project_shared_notification::ProjectSharedNotification; use collab_ui::{
channel_view::ChannelView,
notifications::project_shared_notification::ProjectSharedNotification,
};
use editor::{Editor, ExcerptRange, MultiBuffer}; use editor::{Editor, ExcerptRange, MultiBuffer};
use gpui::{ use gpui::{
point, BackgroundExecutor, Context, SharedString, TestAppContext, View, VisualContext, point, BackgroundExecutor, Context, Entity, SharedString, TestAppContext, View, VisualContext,
VisualTestContext, VisualTestContext,
}; };
use language::Capability; 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_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_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. // Client A opens some editors.
let pane_a = workspace_a.update(cx_a, |workspace, _| workspace.active_pane().clone()); let pane_a = workspace_a.update(cx_a, |workspace, _| workspace.active_pane().clone());
let editor_a1 = workspace_a 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)) .update(cx_c, |call, cx| call.set_location(Some(&project_c), cx))
.await .await
.unwrap(); .unwrap();
let weak_project_c = project_c.downgrade();
drop(project_c); drop(project_c);
// Client C also follows client A. // Client C also follows client A.
@ -234,17 +240,16 @@ async fn test_basic_following(
workspace_c.update(cx_c, |workspace, cx| { workspace_c.update(cx_c, |workspace, cx| {
workspace.close_window(&Default::default(), cx); workspace.close_window(&Default::default(), cx);
}); });
cx_c.update(|_| { executor.run_until_parked();
drop(workspace_c);
});
cx_b.executor().run_until_parked();
// are you sure you want to leave the call? // are you sure you want to leave the call?
cx_c.simulate_prompt_answer(0); cx_c.simulate_prompt_answer(0);
cx_b.executor().run_until_parked(); cx_c.cx.update(|_| {
drop(workspace_c);
});
executor.run_until_parked(); executor.run_until_parked();
cx_c.cx.update(|_| {});
weak_workspace_c.assert_dropped(); weak_workspace_c.assert_released();
weak_project_c.assert_dropped();
// Clients A and B see that client B is following A, and client C is not present in the followers. // Clients A and B see that client B is following A, and client C is not present in the followers.
executor.run_until_parked(); 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. // 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!( assert_eq!(
workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)),
None 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 mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").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_b = server.create_client(cx_b, "user_b").await;
cx_a.update(editor::init);
cx_b.update(editor::init);
client_a client_a
.fs() .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_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_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 active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await .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::<Editor>(cx).unwrap()
});
let editor_b = workspace_b.update(cx_b, |workspace, cx| {
workspace.active_item_as::<Editor>(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] #[gpui::test]
async fn test_following_into_excluded_file( async fn test_following_into_excluded_file(
mut cx_a: &mut TestAppContext, 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 active_call_b = cx_b.read(ActiveCall::global);
let peer_id_a = client_a.peer_id().unwrap(); let peer_id_a = client_a.peer_id().unwrap();
cx_a.update(editor::init);
cx_b.update(editor::init);
client_a client_a
.fs() .fs()
.insert_tree( .insert_tree(
@ -1685,6 +1737,11 @@ async fn test_following_into_excluded_file(
vec![18..17] 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 // Changes from B to the excluded file are replicated in A's editor
editor_for_excluded_b.update(cx_b, |editor, cx| { editor_for_excluded_b.update(cx_b, |editor, cx| {
editor.handle_input("\nCo-Authored-By: B <b@b.b>", cx); editor.handle_input("\nCo-Authored-By: B <b@b.b>", cx);
@ -1693,7 +1750,7 @@ async fn test_following_into_excluded_file(
editor_for_excluded_a.update(cx_a, |editor, cx| { editor_for_excluded_a.update(cx_a, |editor, cx| {
assert_eq!( assert_eq!(
editor.text(cx), editor.text(cx),
"new commit messag\nCo-Authored-By: B <b@b.b>" "new commit message\nCo-Authored-By: B <b@b.b>"
); );
}); });
} }
@ -1775,3 +1832,167 @@ fn pane_summaries(workspace: &View<Workspace>, cx: &mut VisualTestContext) -> Ve
.collect() .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::<ChannelView>()
.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::<usize>(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::<ChannelView>()
.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::<Editor>()
.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()
);
});
}

View file

@ -7,7 +7,10 @@ use client::{User, RECEIVE_TIMEOUT};
use collections::{HashMap, HashSet}; use collections::{HashMap, HashSet};
use fs::{repository::GitFileStatus, FakeFs, Fs as _, RemoveOptions}; use fs::{repository::GitFileStatus, FakeFs, Fs as _, RemoveOptions};
use futures::StreamExt as _; use futures::StreamExt as _;
use gpui::{AppContext, BackgroundExecutor, Model, TestAppContext}; use gpui::{
px, size, AppContext, BackgroundExecutor, Model, Modifiers, MouseButton, MouseDownEvent,
TestAppContext,
};
use language::{ use language::{
language_settings::{AllLanguageSettings, Formatter}, language_settings::{AllLanguageSettings, Formatter},
tree_sitter_rust, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language, LanguageConfig, tree_sitter_rust, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language, LanguageConfig,
@ -1876,6 +1879,186 @@ fn active_call_events(cx: &mut TestAppContext) -> Rc<RefCell<Vec<room::Event>>>
events 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<bool>,
}
fn participant_audio_state(
room: &Model<Room>,
cx: &TestAppContext,
) -> Vec<ParticipantAudioState> {
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::<Vec<_>>()
})
}
}
#[gpui::test(iterations = 10)] #[gpui::test(iterations = 10)]
async fn test_room_location( async fn test_room_location(
executor: BackgroundExecutor, executor: BackgroundExecutor,
@ -3065,6 +3248,7 @@ async fn test_local_settings(
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await .await
.unwrap(); .unwrap();
executor.run_until_parked();
// As client B, join that project and observe the local settings. // As client B, join that project and observe the local settings.
let project_b = client_b.build_remote_project(project_id, cx_b).await; 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());
}

View file

@ -20,7 +20,11 @@ use node_runtime::FakeNodeRuntime;
use notifications::NotificationStore; use notifications::NotificationStore;
use parking_lot::Mutex; use parking_lot::Mutex;
use project::{Project, WorktreeId}; 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 settings::SettingsStore;
use std::{ use std::{
cell::{Ref, RefCell, RefMut}, 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) { pub async fn reset(&self) {
self.app_state.db.reset(); self.app_state.db.reset();
let epoch = self let epoch = self
@ -195,6 +213,7 @@ impl TestServer {
server_conn, server_conn,
client_name, client_name,
user, user,
None,
Some(connection_id_tx), Some(connection_id_tx),
Executor::Deterministic(cx.background_executor().clone()), Executor::Deterministic(cx.background_executor().clone()),
)) ))
@ -228,12 +247,15 @@ impl TestServer {
Project::init(&client, cx); Project::init(&client, cx);
client::init(&client, cx); client::init(&client, cx);
language::init(cx); language::init(cx);
editor::init_settings(cx); editor::init(cx);
workspace::init(app_state.clone(), cx); workspace::init(app_state.clone(), cx);
audio::init((), cx);
call::init(client.clone(), user_store.clone(), cx); call::init(client.clone(), user_store.clone(), cx);
channel::init(&client, user_store.clone(), cx); channel::init(&client, user_store.clone(), cx);
notifications::init(client.clone(), user_store, 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 client
@ -351,6 +373,31 @@ impl TestServer {
channel_id 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( pub async fn make_channel_tree(
&self, &self,
channels: &[(&str, Option<&str>)], channels: &[(&str, Option<&str>)],
@ -580,6 +627,55 @@ impl TestClient {
(project, worktree.read_with(cx, |tree, _| tree.id())) (project, worktree.read_with(cx, |tree, _| tree.id()))
} }
pub async fn build_test_project(&self, cx: &mut TestAppContext) -> Model<Project> {
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<Workspace>,
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<Workspace>, &'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<Project> { pub fn build_empty_local_project(&self, cx: &mut TestAppContext) -> Model<Project> {
cx.update(|cx| { cx.update(|cx| {
Project::local( Project::local(
@ -617,7 +713,33 @@ impl TestClient {
project: &Model<Project>, project: &Model<Project>,
cx: &'a mut TestAppContext, cx: &'a mut TestAppContext,
) -> (View<Workspace>, &'a mut VisualTestContext) { ) -> (View<Workspace>, &'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<Workspace>, &'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<Workspace>, &'a mut VisualTestContext) {
let window = cx.update(|cx| cx.active_window().unwrap().downcast::<Workspace>().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))
} }
} }

View file

@ -9,6 +9,8 @@ path = "src/collab_ui.rs"
doctest = false doctest = false
[features] [features]
default = []
stories = ["dep:story"]
test-support = [ test-support = [
"call/test-support", "call/test-support",
"client/test-support", "client/test-support",
@ -44,6 +46,7 @@ project = { path = "../project" }
recent_projects = { path = "../recent_projects" } recent_projects = { path = "../recent_projects" }
rpc = { path = "../rpc" } rpc = { path = "../rpc" }
settings = { path = "../settings" } settings = { path = "../settings" }
story = { path = "../story", optional = true }
feature_flags = { path = "../feature_flags"} feature_flags = { path = "../feature_flags"}
theme = { path = "../theme" } theme = { path = "../theme" }
theme_selector = { path = "../theme_selector" } theme_selector = { path = "../theme_selector" }

View file

@ -266,6 +266,10 @@ impl Item for ChannelView {
.into_any_element() .into_any_element()
} }
fn telemetry_event_text(&self) -> Option<&'static str> {
None
}
fn clone_on_split(&self, _: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<View<Self>> { fn clone_on_split(&self, _: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<View<Self>> {
Some(cx.new_view(|cx| { Some(cx.new_view(|cx| {
Self::new( Self::new(

View file

@ -1,15 +1,15 @@
use crate::{channel_view::ChannelView, is_channels_feature_enabled, ChatPanelSettings}; use crate::{collab_panel, ChatPanelSettings};
use anyhow::Result; use anyhow::Result;
use call::ActiveCall; use call::{room, ActiveCall};
use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore}; use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore};
use client::Client; use client::Client;
use collections::HashMap; use collections::HashMap;
use db::kvp::KEY_VALUE_STORE; use db::kvp::KEY_VALUE_STORE;
use editor::Editor; use editor::Editor;
use gpui::{ use gpui::{
actions, div, list, prelude::*, px, AnyElement, AppContext, AsyncWindowContext, ClickEvent, actions, div, list, prelude::*, px, Action, AppContext, AsyncWindowContext, DismissEvent,
ElementId, EventEmitter, FocusableView, ListOffset, ListScrollEvent, ListState, Model, Render, ElementId, EventEmitter, FocusHandle, FocusableView, FontWeight, ListOffset, ListScrollEvent,
Subscription, Task, View, ViewContext, VisualContext, WeakView, ListState, Model, Render, Subscription, Task, View, ViewContext, VisualContext, WeakView,
}; };
use language::LanguageRegistry; use language::LanguageRegistry;
use menu::Confirm; use menu::Confirm;
@ -17,10 +17,13 @@ use message_editor::MessageEditor;
use project::Fs; use project::Fs;
use rich_text::RichText; use rich_text::RichText;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore}; use settings::Settings;
use std::sync::Arc; use std::sync::Arc;
use time::{OffsetDateTime, UtcOffset}; 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 util::{ResultExt, TryFutureExt};
use workspace::{ use workspace::{
dock::{DockPosition, Panel, PanelEvent}, dock::{DockPosition, Panel, PanelEvent},
@ -54,9 +57,10 @@ pub struct ChatPanel {
active: bool, active: bool,
pending_serialization: Task<Option<()>>, pending_serialization: Task<Option<()>>,
subscriptions: Vec<gpui::Subscription>, subscriptions: Vec<gpui::Subscription>,
workspace: WeakView<Workspace>,
is_scrolled_to_bottom: bool, is_scrolled_to_bottom: bool,
markdown_data: HashMap<ChannelMessageId, RichText>, markdown_data: HashMap<ChannelMessageId, RichText>,
focus_handle: FocusHandle,
open_context_menu: Option<(u64, Subscription)>,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -64,13 +68,6 @@ struct SerializedChatPanel {
width: Option<Pixels>, width: Option<Pixels>,
} }
#[derive(Debug)]
pub enum Event {
DockPositionChanged,
Focus,
Dismissed,
}
actions!(chat_panel, [ToggleFocus]); actions!(chat_panel, [ToggleFocus]);
impl ChatPanel { impl ChatPanel {
@ -89,8 +86,6 @@ impl ChatPanel {
) )
}); });
let workspace_handle = workspace.weak_handle();
cx.new_view(|cx: &mut ViewContext<Self>| { cx.new_view(|cx: &mut ViewContext<Self>| {
let view = cx.view().downgrade(); let view = cx.view().downgrade();
let message_list = let message_list =
@ -108,7 +103,7 @@ impl ChatPanel {
if event.visible_range.start < MESSAGE_LOADING_THRESHOLD { if event.visible_range.start < MESSAGE_LOADING_THRESHOLD {
this.load_more_messages(cx); 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 { let mut this = Self {
@ -122,22 +117,54 @@ impl ChatPanel {
message_editor: input_editor, message_editor: input_editor,
local_timezone: cx.local_timezone(), local_timezone: cx.local_timezone(),
subscriptions: Vec::new(), subscriptions: Vec::new(),
workspace: workspace_handle,
is_scrolled_to_bottom: true, is_scrolled_to_bottom: true,
active: false, active: false,
width: None, width: None,
markdown_data: Default::default(), markdown_data: Default::default(),
focus_handle: cx.focus_handle(),
open_context_menu: None,
}; };
let mut old_dock_position = this.position(cx); if let Some(channel_id) = ActiveCall::global(cx)
this.subscriptions.push(cx.observe_global::<SettingsStore>( .read(cx)
move |this: &mut Self, cx| { .room()
let new_dock_position = this.position(cx); .and_then(|room| room.read(cx).channel_id())
if new_dock_position != old_dock_position { {
old_dock_position = new_dock_position; this.select_channel(channel_id, None, cx)
cx.emit(Event::DockPositionChanged); .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<u64> {
self.active_chat
.as_ref()
.map(|(chat, _)| chat.read(cx).channel_id)
}
pub fn is_scrolled_to_bottom(&self) -> bool { pub fn is_scrolled_to_bottom(&self) -> bool {
self.is_scrolled_to_bottom self.is_scrolled_to_bottom
} }
@ -259,53 +292,9 @@ impl ChatPanel {
} }
} }
fn render_channel(&self, cx: &mut ViewContext<Self>) -> 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<Self>) -> impl IntoElement { fn render_message(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> impl IntoElement {
let active_chat = &self.active_chat.as_ref().unwrap().0; 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| { active_chat.update(cx, |active_chat, cx| {
let is_admin = self let is_admin = self
.channel_store .channel_store
@ -314,13 +303,9 @@ impl ChatPanel {
let last_message = active_chat.message(ix.saturating_sub(1)); let last_message = active_chat.message(ix.saturating_sub(1));
let this_message = active_chat.message(ix).clone(); 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 let is_continuation_from_previous = last_message.id != this_message.id
&& last_message.sender.id == this_message.sender.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 let ChannelMessageId::Saved(id) = this_message.id {
if this_message if this_message
@ -332,12 +317,7 @@ impl ChatPanel {
} }
} }
( (this_message, is_continuation_from_previous, is_admin)
this_message,
is_continuation_from_previous,
is_continuation_to_next,
is_admin,
)
}); });
let _is_pending = message.is_pending(); let _is_pending = message.is_pending();
@ -360,50 +340,100 @@ impl ChatPanel {
ChannelMessageId::Saved(id) => ("saved-message", id).into(), ChannelMessageId::Saved(id) => ("saved-message", id).into(),
ChannelMessageId::Pending(id) => ("pending-message", id).into(), ChannelMessageId::Pending(id) => ("pending-message", id).into(),
}; };
let this = cx.view().clone();
v_stack() v_flex()
.w_full() .w_full()
.id(element_id)
.relative() .relative()
.overflow_hidden() .overflow_hidden()
.group("")
.when(!is_continuation_from_previous, |this| { .when(!is_continuation_from_previous, |this| {
this.child( this.pt_3().child(
h_stack() h_flex()
.gap_2() .child(
.child(Avatar::new(message.sender.avatar_uri.clone())) div().absolute().child(
.child(Label::new(message.sender.github_login.clone())) 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( .child(
Label::new(format_timestamp( Label::new(format_timestamp(
message.timestamp, message.timestamp,
now, now,
self.local_timezone, self.local_timezone,
)) ))
.size(LabelSize::Small)
.color(Color::Muted), .color(Color::Muted),
), ),
) )
}) })
.when(!is_continuation_to_next, |this| .when(is_continuation_from_previous, |this| this.pt_1())
// HACK: This should really be a margin, but margins seem to get collapsed.
this.pb_2())
.child(text.element("body".into(), cx))
.child( .child(
div() v_flex()
.absolute() .w_full()
.top_1() .text_ui_sm()
.right_2() .id(element_id)
.w_8() .group("")
.visible_on_hover("") .child(text.element("body".into(), cx))
.children(message_id_to_remove.map(|message_id| { .child(
IconButton::new(("remove", message_id), Icon::XCircle).on_click( div()
cx.listener(move |this, _, cx| { .absolute()
this.remove_message(message_id, cx); .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<u64>) -> bool {
match self.open_context_menu.as_ref() {
Some((id, _)) => Some(*id) == message_id,
None => false,
}
}
fn render_message_menu(
this: &View<Self>,
message_id: u64,
cx: &mut WindowContext,
) -> View<ContextMenu> {
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( fn render_markdown_with_mentions(
language_registry: &Arc<LanguageRegistry>, language_registry: &Arc<LanguageRegistry>,
current_user_id: u64, current_user_id: u64,
@ -421,44 +451,6 @@ impl ChatPanel {
rich_text::render_markdown(message.body.clone(), &mentions, language_registry, None) rich_text::render_markdown(message.body.clone(), &mentions, language_registry, None)
} }
fn render_sign_in_prompt(&self, cx: &mut ViewContext<Self>) -> 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<Self>) { fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
if let Some((chat, _)) = self.active_chat.as_ref() { if let Some((chat, _)) = self.active_chat.as_ref() {
let message = self let message = self
@ -535,50 +527,93 @@ impl ChatPanel {
Ok(()) Ok(())
}) })
} }
fn open_notes(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
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<Self>) {
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<Event> for ChatPanel {}
impl Render for ChatPanel { impl Render for ChatPanel {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_stack() v_flex()
.size_full() .track_focus(&self.focus_handle)
.map(|this| match (self.client.user_id(), self.active_chat()) { .full()
(Some(_), Some(_)) => this.child(self.render_channel(cx)), .on_action(cx.listener(Self::send))
(Some(_), None) => this.child( .child(
div().p_4().child( h_flex().z_index(1).child(
Label::new("Select a channel to chat in.") TabBar::new("chat_header").child(
.size(LabelSize::Small) h_flex()
.color(Color::Muted), .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)), )
}) .child(div().flex_grow().px_2().pt_1().map(|this| {
.min_w(px(150.)) 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 { impl FocusableView for ChatPanel {
fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle { 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; self.active = active;
if active { if active {
self.acknowledge_last_message(cx); self.acknowledge_last_message(cx);
if !is_channels_feature_enabled(cx) {
cx.emit(Event::Dismissed);
}
} }
} }
@ -622,12 +654,8 @@ impl Panel for ChatPanel {
"ChatPanel" "ChatPanel"
} }
fn icon(&self, cx: &WindowContext) -> Option<ui::Icon> { fn icon(&self, cx: &WindowContext) -> Option<ui::IconName> {
if !is_channels_feature_enabled(cx) { Some(ui::IconName::MessageBubbles).filter(|_| ChatPanelSettings::get_global(cx).button)
return None;
}
Some(ui::Icon::MessageBubbles).filter(|_| ChatPanelSettings::get_global(cx).button)
} }
fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> { fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {

File diff suppressed because it is too large Load diff

View file

@ -111,7 +111,7 @@ impl ChannelModal {
.detach(); .detach();
} }
fn set_channel_visiblity(&mut self, selection: &Selection, cx: &mut ViewContext<Self>) { fn set_channel_visibility(&mut self, selection: &Selection, cx: &mut ViewContext<Self>) {
self.channel_store.update(cx, |channel_store, cx| { self.channel_store.update(cx, |channel_store, cx| {
channel_store channel_store
.set_channel_visibility( .set_channel_visibility(
@ -152,33 +152,33 @@ impl Render for ChannelModal {
let visibility = channel.visibility; let visibility = channel.visibility;
let mode = self.picker.read(cx).delegate.mode; let mode = self.picker.read(cx).delegate.mode;
v_stack() v_flex()
.key_context("ChannelModal") .key_context("ChannelModal")
.on_action(cx.listener(Self::toggle_mode)) .on_action(cx.listener(Self::toggle_mode))
.on_action(cx.listener(Self::dismiss)) .on_action(cx.listener(Self::dismiss))
.elevation_3(cx) .elevation_3(cx)
.w(rems(34.)) .w(rems(34.))
.child( .child(
v_stack() v_flex()
.px_2() .px_2()
.py_1() .py_1()
.gap_2() .gap_2()
.child( .child(
h_stack() h_flex()
.w_px() .w_px()
.flex_1() .flex_1()
.gap_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(Label::new(channel_name)),
) )
.child( .child(
h_stack() h_flex()
.w_full() .w_full()
.h(rems(22. / 16.)) .h(rems(22. / 16.))
.justify_between() .justify_between()
.line_height(rems(1.25)) .line_height(rems(1.25))
.child( .child(
h_stack() h_flex()
.gap_2() .gap_2()
.child( .child(
Checkbox::new( Checkbox::new(
@ -189,7 +189,7 @@ impl Render for ChannelModal {
ui::Selection::Unselected 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)), .child(Label::new("Public").size(LabelSize::Small)),
) )
@ -212,7 +212,7 @@ impl Render for ChannelModal {
), ),
) )
.child( .child(
h_stack() h_flex()
.child( .child(
div() div()
.id("manage-members") .id("manage-members")
@ -348,6 +348,10 @@ impl PickerDelegate for ChannelModalDelegate {
fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) { fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
if let Some((selected_user, role)) = self.user_at_index(self.selected_index) { 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 { match self.mode {
Mode::ManageMembers => { Mode::ManageMembers => {
self.show_context_menu(selected_user, role.unwrap_or(ChannelRole::Member), cx) self.show_context_menu(selected_user, role.unwrap_or(ChannelRole::Member), cx)
@ -383,6 +387,7 @@ impl PickerDelegate for ChannelModalDelegate {
) -> Option<Self::ListItem> { ) -> Option<Self::ListItem> {
let (user, role) = self.user_at_index(ix)?; let (user, role) = self.user_at_index(ix)?;
let request_status = self.member_status(user.id, cx); 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( Some(
ListItem::new(ix) ListItem::new(ix)
@ -391,7 +396,7 @@ impl PickerDelegate for ChannelModalDelegate {
.selected(selected) .selected(selected)
.start_slot(Avatar::new(user.avatar_uri.clone())) .start_slot(Avatar::new(user.avatar_uri.clone()))
.child(Label::new(user.github_login.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 { match self.mode {
Mode::ManageMembers => slot Mode::ManageMembers => slot
.children( .children(
@ -406,7 +411,10 @@ impl PickerDelegate for ChannelModalDelegate {
Some(ChannelRole::Guest) => Some(Label::new("Guest")), Some(ChannelRole::Guest) => Some(Label::new("Guest")),
_ => None, _ => 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( .children(
if let (Some((menu, _)), true) = (&self.context_menu, selected) { if let (Some((menu, _)), true) = (&self.context_menu, selected) {
Some( Some(

View file

@ -36,17 +36,17 @@ impl ContactFinder {
impl Render for ContactFinder { impl Render for ContactFinder {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_stack() v_flex()
.elevation_3(cx) .elevation_3(cx)
.child( .child(
v_stack() v_flex()
.px_2() .px_2()
.py_1() .py_1()
.bg(cx.theme().colors().element_background) .bg(cx.theme().colors().element_background)
// HACK: Prevent the background color from overflowing the parent container. // HACK: Prevent the background color from overflowing the parent container.
.rounded_t(px(8.)) .rounded_t(px(8.))
.child(Label::new("Contacts")) .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()) .child(self.picker.clone())
.w(rems(34.)) .w(rems(34.))
@ -155,9 +155,7 @@ impl PickerDelegate for ContactFinderDelegate {
.selected(selected) .selected(selected)
.start_slot(Avatar::new(user.avatar_uri.clone())) .start_slot(Avatar::new(user.avatar_uri.clone()))
.child(Label::new(user.github_login.clone())) .child(Label::new(user.github_login.clone()))
.end_slot::<IconElement>( .end_slot::<Icon>(icon_path.map(|icon_path| Icon::from_path(icon_path))),
icon_path.map(|icon_path| IconElement::from_path(icon_path)),
),
) )
} }
} }

View file

@ -1,9 +1,9 @@
use crate::face_pile::FacePile; use crate::face_pile::FacePile;
use auto_update::AutoUpdateStatus; use auto_update::AutoUpdateStatus;
use call::{ActiveCall, ParticipantLocation, Room}; use call::{ActiveCall, ParticipantLocation, Room};
use client::{proto::PeerId, Client, ParticipantIndex, User, UserStore}; use client::{proto::PeerId, Client, User, UserStore};
use gpui::{ 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, InteractiveElement, IntoElement, Model, ParentElement, Path, Render,
StatefulInteractiveElement, Styled, Subscription, View, ViewContext, VisualContext, WeakView, StatefulInteractiveElement, Styled, Subscription, View, ViewContext, VisualContext, WeakView,
WindowBounds, WindowBounds,
@ -12,14 +12,14 @@ use project::{Project, RepositoryEntry};
use recent_projects::RecentProjects; use recent_projects::RecentProjects;
use rpc::proto; use rpc::proto;
use std::sync::Arc; use std::sync::Arc;
use theme::{ActiveTheme, PlayerColors}; use theme::ActiveTheme;
use ui::{ use ui::{
h_stack, popover_menu, prelude::*, Avatar, Button, ButtonLike, ButtonStyle, ContextMenu, Icon, h_flex, popover_menu, prelude::*, Avatar, AvatarAudioStatusIndicator, Button, ButtonLike,
IconButton, IconElement, TintColor, Tooltip, ButtonStyle, ContextMenu, Icon, IconButton, IconName, TintColor, Tooltip,
}; };
use util::ResultExt; use util::ResultExt;
use vcs_menu::{build_branch_list, BranchList, OpenRecent as ToggleVcsMenu}; 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_PROJECT_NAME_LENGTH: usize = 40;
const MAX_BRANCH_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) workspace.set_titlebar_item(titlebar_item.into(), cx)
}) })
.detach(); .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 { pub struct CollabTitlebarItem {
@ -63,15 +57,13 @@ impl Render for CollabTitlebarItem {
let current_user = self.user_store.read(cx).current_user(); let current_user = self.user_store.read(cx).current_user();
let client = self.client.clone(); let client = self.client.clone();
let project_id = self.project.read(cx).remote_id(); let project_id = self.project.read(cx).remote_id();
let workspace = self.workspace.upgrade();
h_stack() h_flex()
.id("titlebar") .id("titlebar")
.justify_between() .justify_between()
.w_full() .w_full()
.h(rems(1.75)) .h(titlebar_height(cx))
// 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.))
.map(|this| { .map(|this| {
if matches!(cx.window_bounds(), WindowBounds::Fullscreen) { if matches!(cx.window_bounds(), WindowBounds::Fullscreen) {
this.pl_2() this.pl_2()
@ -89,7 +81,7 @@ impl Render for CollabTitlebarItem {
}) })
// left side // left side
.child( .child(
h_stack() h_flex()
.gap_1() .gap_1()
.children(self.render_project_host(cx)) .children(self.render_project_host(cx))
.child(self.render_project_name(cx)) .child(self.render_project_name(cx))
@ -103,19 +95,32 @@ impl Render for CollabTitlebarItem {
room.remote_participants().values().collect::<Vec<_>>(); room.remote_participants().values().collect::<Vec<_>>();
remote_participants.sort_by_key(|p| p.participant_index.0); remote_participants.sort_by_key(|p| p.participant_index.0);
this.children(self.render_collaborator( let current_user_face_pile = self.render_collaborator(
&current_user, &current_user,
peer_id, peer_id,
true, true,
room.is_speaking(), room.is_speaking(),
room.is_muted(cx), room.is_muted(),
None,
&room, &room,
project_id, project_id,
&current_user, &current_user,
cx, cx,
)) );
this.children(current_user_face_pile.map(|face_pile| {
v_flex()
.child(face_pile)
.child(render_color_ribbon(player_colors.local().cursor))
}))
.children( .children(
remote_participants.iter().filter_map(|collaborator| { 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| { let is_present = project_id.map_or(false, |project_id| {
collaborator.location collaborator.location
== ParticipantLocation::SharedProject { project_id } == ParticipantLocation::SharedProject { project_id }
@ -127,6 +132,7 @@ impl Render for CollabTitlebarItem {
is_present, is_present,
collaborator.speaking, collaborator.speaking,
collaborator.muted, collaborator.muted,
is_following.then_some(player_color.selection),
&room, &room,
project_id, project_id,
&current_user, &current_user,
@ -134,13 +140,10 @@ impl Render for CollabTitlebarItem {
)?; )?;
Some( Some(
v_stack() v_flex()
.id(("collaborator", collaborator.user.id)) .id(("collaborator", collaborator.user.id))
.child(face_pile) .child(face_pile)
.child(render_color_ribbon( .child(render_color_ribbon(player_color.cursor))
collaborator.participant_index,
player_colors,
))
.cursor_pointer() .cursor_pointer()
.on_click({ .on_click({
let peer_id = collaborator.peer_id; let peer_id = collaborator.peer_id;
@ -166,7 +169,7 @@ impl Render for CollabTitlebarItem {
) )
// right side // right side
.child( .child(
h_stack() h_flex()
.gap_1() .gap_1()
.pr_1() .pr_1()
.when_some(room, |this, room| { .when_some(room, |this, room| {
@ -174,7 +177,7 @@ impl Render for CollabTitlebarItem {
let project = self.project.read(cx); let project = self.project.read(cx);
let is_local = project.is_local(); let is_local = project.is_local();
let is_shared = is_local && project.is_shared(); 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_deafened = room.is_deafened().unwrap_or(false);
let is_screen_sharing = room.is_screen_sharing(); let is_screen_sharing = room.is_screen_sharing();
let read_only = room.read_only(); let read_only = room.read_only();
@ -213,7 +216,7 @@ impl Render for CollabTitlebarItem {
.child( .child(
div() div()
.child( .child(
IconButton::new("leave-call", ui::Icon::Exit) IconButton::new("leave-call", ui::IconName::Exit)
.style(ButtonStyle::Subtle) .style(ButtonStyle::Subtle)
.tooltip(|cx| Tooltip::text("Leave call", cx)) .tooltip(|cx| Tooltip::text("Leave call", cx))
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
@ -230,9 +233,9 @@ impl Render for CollabTitlebarItem {
IconButton::new( IconButton::new(
"mute-microphone", "mute-microphone",
if is_muted { if is_muted {
ui::Icon::MicMute ui::IconName::MicMute
} else { } else {
ui::Icon::Mic ui::IconName::Mic
}, },
) )
.tooltip(move |cx| { .tooltip(move |cx| {
@ -256,9 +259,9 @@ impl Render for CollabTitlebarItem {
IconButton::new( IconButton::new(
"mute-sound", "mute-sound",
if is_deafened { if is_deafened {
ui::Icon::AudioOff ui::IconName::AudioOff
} else { } else {
ui::Icon::AudioOn ui::IconName::AudioOn
}, },
) )
.style(ButtonStyle::Subtle) .style(ButtonStyle::Subtle)
@ -281,7 +284,7 @@ impl Render for CollabTitlebarItem {
) )
.when(!read_only, |this| { .when(!read_only, |this| {
this.child( this.child(
IconButton::new("screen-share", ui::Icon::Screen) IconButton::new("screen-share", ui::IconName::Screen)
.style(ButtonStyle::Subtle) .style(ButtonStyle::Subtle)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.selected(is_screen_sharing) .selected(is_screen_sharing)
@ -318,8 +321,7 @@ impl Render for CollabTitlebarItem {
} }
} }
fn render_color_ribbon(participant_index: ParticipantIndex, colors: &PlayerColors) -> gpui::Canvas { fn render_color_ribbon(color: Hsla) -> gpui::Canvas {
let color = colors.color_for_participant(participant_index.0).cursor;
canvas(move |bounds, cx| { canvas(move |bounds, cx| {
let height = bounds.size.height; let height = bounds.size.height;
let horizontal_offset = height; let horizontal_offset = height;
@ -469,43 +471,86 @@ impl CollabTitlebarItem {
is_present: bool, is_present: bool,
is_speaking: bool, is_speaking: bool,
is_muted: bool, is_muted: bool,
leader_selection_color: Option<Hsla>,
room: &Room, room: &Room,
project_id: Option<u64>, project_id: Option<u64>,
current_user: &Arc<User>, current_user: &Arc<User>,
cx: &ViewContext<Self>, cx: &ViewContext<Self>,
) -> Option<FacePile> { ) -> Option<Div> {
if room.role_for_user(user.id) == Some(proto::ChannelRole::Guest) { if room.role_for_user(user.id) == Some(proto::ChannelRole::Guest) {
return None; return None;
} }
const FACEPILE_LIMIT: usize = 3;
let followers = project_id.map_or(&[] as &[_], |id| room.followers_for(peer_id, id)); 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() Some(
.child( div()
Avatar::new(user.avatar_uri.clone()) .m_0p5()
.grayscale(!is_present) .p_0p5()
.border_color(if is_speaking { // When the collaborator is not followed, still draw this wrapper div, but leave
cx.theme().status().info_border // it transparent, so that it does not shift the layout when following.
} else if is_muted { .when_some(leader_selection_color, |div, color| {
cx.theme().status().error_border div.rounded_md().bg(color)
} else { })
Hsla::default() .child(
}), FacePile::default()
) .child(
.children(followers.iter().filter_map(|follower_peer_id| { Avatar::new(user.avatar_uri.clone())
let follower = room .grayscale(!is_present)
.remote_participants() .border_color(if is_speaking {
.values() cx.theme().status().info
.find_map(|p| (p.peer_id == *follower_peer_id).then_some(&p.user)) } else {
.or_else(|| { // We draw the border in a transparent color rather to avoid
(self.client.peer_id() == Some(*follower_peer_id)).then_some(current_user) // the layout shift that would come with adding/removing the border.
})? gpui::transparent_black()
.clone(); })
.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(Avatar::new(follower.avatar_uri.clone()))
})); },
))
Some(pile) .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<Self>) { fn window_activation_changed(&mut self, cx: &mut ViewContext<Self>) {
@ -573,7 +618,7 @@ impl CollabTitlebarItem {
| client::Status::ReconnectionError { .. } => Some( | client::Status::ReconnectionError { .. } => Some(
div() div()
.id("disconnected") .id("disconnected")
.child(IconElement::new(Icon::Disconnected).size(IconSize::Small)) .child(Icon::new(IconName::Disconnected).size(IconSize::Small))
.tooltip(|cx| Tooltip::text("Disconnected", cx)) .tooltip(|cx| Tooltip::text("Disconnected", cx))
.into_any_element(), .into_any_element(),
), ),
@ -640,10 +685,10 @@ impl CollabTitlebarItem {
.trigger( .trigger(
ButtonLike::new("user-menu") ButtonLike::new("user-menu")
.child( .child(
h_stack() h_flex()
.gap_0p5() .gap_0p5()
.child(Avatar::new(user.avatar_uri.clone())) .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) .style(ButtonStyle::Subtle)
.tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)), .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)),
@ -663,9 +708,9 @@ impl CollabTitlebarItem {
.trigger( .trigger(
ButtonLike::new("user-menu") ButtonLike::new("user-menu")
.child( .child(
h_stack() h_flex()
.gap_0p5() .gap_0p5()
.child(IconElement::new(Icon::ChevronDown).color(Color::Muted)), .child(Icon::new(IconName::ChevronDown).color(Color::Muted)),
) )
.style(ButtonStyle::Subtle) .style(ButtonStyle::Subtle)
.tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)), .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)),

View file

@ -9,10 +9,9 @@ mod panel_settings;
use std::{rc::Rc, sync::Arc}; 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_panel::CollabPanel;
pub use collab_titlebar_item::CollabTitlebarItem; pub use collab_titlebar_item::CollabTitlebarItem;
use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
use gpui::{ use gpui::{
actions, point, AppContext, GlobalPixels, Pixels, PlatformDisplay, Size, Task, WindowBounds, actions, point, AppContext, GlobalPixels, Pixels, PlatformDisplay, Size, Task, WindowBounds,
WindowKind, WindowOptions, WindowKind, WindowOptions,
@ -21,7 +20,6 @@ pub use panel_settings::{
ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings, ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings,
}; };
use settings::Settings; use settings::Settings;
use util::ResultExt;
use workspace::AppState; use workspace::AppState;
actions!( actions!(
@ -41,10 +39,6 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
chat_panel::init(cx); chat_panel::init(cx);
notification_panel::init(cx); notification_panel::init(cx);
notifications::init(&app_state, 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) { 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() { if let Some(room) = call.room().cloned() {
let client = call.client(); let client = call.client();
room.update(cx, |room, cx| { room.update(cx, |room, cx| {
let operation = if room.is_muted(cx) { let operation = if room.is_muted() {
"enable microphone" "enable microphone"
} else { } else {
"disable microphone" "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); report_call_event_for_room(operation, room.id(), room.channel_id(), &client);
room.toggle_mute(cx) room.toggle_mute(cx)
}) });
.map(|task| task.detach_and_log_err(cx))
.log_err();
} }
} }
pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) { pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) {
if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
room.update(cx, Room::toggle_deafen) room.update(cx, |room, cx| room.toggle_deafen(cx));
.map(|task| task.detach_and_log_err(cx))
.log_err();
} }
} }
@ -111,7 +101,6 @@ fn notification_window_options(
let screen_bounds = screen.bounds(); let screen_bounds = screen.bounds();
let size: Size<GlobalPixels> = window_size.into(); let size: Size<GlobalPixels> = 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::<GlobalPixels> { let bounds = gpui::Bounds::<GlobalPixels> {
origin: screen_bounds.upper_right() origin: screen_bounds.upper_right()
- point( - point(
@ -131,35 +120,3 @@ fn notification_window_options(
display_id: Some(screen.id()), display_id: Some(screen.id()),
} }
} }
// fn render_avatar<T: 'static>(
// avatar: Option<Arc<ImageData>>,
// avatar_style: &AvatarStyle,
// container: ContainerStyle,
// ) -> AnyElement<T> {
// 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::<ChannelsAlpha>()
}

View file

@ -1,13 +1,13 @@
use gpui::{div, AnyElement, IntoElement, ParentElement, RenderOnce, Styled, WindowContext}; use gpui::{div, AnyElement, Div, IntoElement, ParentElement, Styled};
use smallvec::SmallVec; use smallvec::SmallVec;
#[derive(Default, IntoElement)] #[derive(Default)]
pub struct FacePile { pub struct FacePile {
pub faces: SmallVec<[AnyElement; 2]>, pub faces: SmallVec<[AnyElement; 2]>,
} }
impl RenderOnce for FacePile { impl FacePile {
fn render(self, _: &mut WindowContext) -> impl IntoElement { pub fn render(self) -> Div {
let player_count = self.faces.len(); let player_count = self.faces.len();
let player_list = self.faces.into_iter().enumerate().map(|(ix, player)| { let player_list = self.faces.into_iter().enumerate().map(|(ix, player)| {
let isnt_last = ix < player_count - 1; let isnt_last = ix < player_count - 1;
@ -17,7 +17,7 @@ impl RenderOnce for FacePile {
.when(isnt_last, |div| div.neg_mr_1()) .when(isnt_last, |div| div.neg_mr_1())
.child(player) .child(player)
}); });
div().p_1().flex().items_center().children(player_list) div().flex().items_center().children(player_list)
} }
} }

View file

@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore}; use settings::{Settings, SettingsStore};
use std::{sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use time::{OffsetDateTime, UtcOffset}; 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 util::{ResultExt, TryFutureExt};
use workspace::{ use workspace::{
dock::{DockPosition, Panel, PanelEvent}, dock::{DockPosition, Panel, PanelEvent},
@ -251,13 +251,13 @@ impl NotificationPanel {
.rounded_full() .rounded_full()
})) }))
.child( .child(
v_stack() v_flex()
.gap_1() .gap_1()
.size_full() .size_full()
.overflow_hidden() .overflow_hidden()
.child(Label::new(text.clone())) .child(Label::new(text.clone()))
.child( .child(
h_stack() h_flex()
.child( .child(
Label::new(format_timestamp( Label::new(format_timestamp(
timestamp, timestamp,
@ -276,7 +276,7 @@ impl NotificationPanel {
))) )))
} else if needs_response { } else if needs_response {
Some( Some(
h_stack() h_flex()
.flex_grow() .flex_grow()
.justify_end() .justify_end()
.child(Button::new("decline", "Decline").on_click({ .child(Button::new("decline", "Decline").on_click({
@ -541,30 +541,30 @@ impl NotificationPanel {
impl Render for NotificationPanel { impl Render for NotificationPanel {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_stack() v_flex()
.size_full() .size_full()
.child( .child(
h_stack() h_flex()
.justify_between() .justify_between()
.px_2() .px_2()
.py_1() .py_1()
// Match the height of the tab bar so they line up. // 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_b_1()
.border_color(cx.theme().colors().border) .border_color(cx.theme().colors().border)
.child(Label::new("Notifications")) .child(Label::new("Notifications"))
.child(IconElement::new(Icon::Envelope)), .child(Icon::new(IconName::Envelope)),
) )
.map(|this| { .map(|this| {
if self.client.user_id().is_none() { if self.client.user_id().is_none() {
this.child( this.child(
v_stack() v_flex()
.gap_2() .gap_2()
.p_4() .p_4()
.child( .child(
Button::new("sign_in_prompt_button", "Sign in") Button::new("sign_in_prompt_button", "Sign in")
.icon_color(Color::Muted) .icon_color(Color::Muted)
.icon(Icon::Github) .icon(IconName::Github)
.icon_position(IconPosition::Start) .icon_position(IconPosition::Start)
.style(ButtonStyle::Filled) .style(ButtonStyle::Filled)
.full_width() .full_width()
@ -592,7 +592,7 @@ impl Render for NotificationPanel {
) )
} else if self.notification_list.item_count() == 0 { } else if self.notification_list.item_count() == 0 {
this.child( this.child(
v_stack().p_4().child( v_flex().p_4().child(
div().flex().w_full().items_center().child( div().flex().w_full().items_center().child(
Label::new("You have no notifications.") Label::new("You have no notifications.")
.color(Color::Muted) .color(Color::Muted)
@ -655,10 +655,10 @@ impl Panel for NotificationPanel {
} }
} }
fn icon(&self, cx: &gpui::WindowContext) -> Option<Icon> { fn icon(&self, cx: &gpui::WindowContext) -> Option<IconName> {
(NotificationPanelSettings::get_global(cx).button (NotificationPanelSettings::get_global(cx).button
&& self.notification_store.read(cx).notification_count() > 0) && self.notification_store.read(cx).notification_count() > 0)
.then(|| Icon::Bell) .then(|| IconName::Bell)
} }
fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> { fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
@ -711,12 +711,12 @@ impl Render for NotificationToast {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let user = self.actor.clone(); let user = self.actor.clone();
h_stack() h_flex()
.id("notification_panel_toast") .id("notification_panel_toast")
.children(user.map(|user| Avatar::new(user.avatar_uri.clone()))) .children(user.map(|user| Avatar::new(user.avatar_uri.clone())))
.child(Label::new(self.text.clone())) .child(Label::new(self.text.clone()))
.child( .child(
IconButton::new("close", Icon::Close) IconButton::new("close", IconName::Close)
.on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))), .on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))),
) )
.on_click(cx.listener(|this, _, cx| { .on_click(cx.listener(|this, _, cx| {

View file

@ -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 gpui::AppContext;
use std::sync::Arc; use std::sync::Arc;
use workspace::AppState; use workspace::AppState;
pub mod incoming_call_notification; #[cfg(feature = "stories")]
pub mod project_shared_notification; pub use stories::*;
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) { pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
incoming_call_notification::init(app_state, cx); incoming_call_notification::init(app_state, cx);

View file

@ -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<SharedUrl>,
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),
)
}
}

View file

@ -1,15 +1,12 @@
use crate::notification_window_options; use crate::notification_window_options;
use crate::notifications::collab_notification::CollabNotification;
use call::{ActiveCall, IncomingCall}; use call::{ActiveCall, IncomingCall};
use futures::StreamExt; use futures::StreamExt;
use gpui::{ use gpui::{prelude::*, AppContext, WindowHandle};
img, px, AppContext, ParentElement, Render, RenderOnce, Styled, ViewContext,
VisualContext as _, WindowHandle,
};
use settings::Settings; use settings::Settings;
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
use theme::ThemeSettings; use theme::ThemeSettings;
use ui::prelude::*; use ui::{prelude::*, Button, Label};
use ui::{h_stack, v_stack, Button, Label};
use util::ResultExt; use util::ResultExt;
use workspace::AppState; use workspace::AppState;
@ -22,7 +19,6 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
for window in notification_windows.drain(..) { for window in notification_windows.drain(..) {
window window
.update(&mut cx, |_, cx| { .update(&mut cx, |_, cx| {
// todo!()
cx.remove_window(); cx.remove_window();
}) })
.log_err(); .log_err();
@ -31,8 +27,8 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
if let Some(incoming_call) = incoming_call { if let Some(incoming_call) = incoming_call {
let unique_screens = cx.update(|cx| cx.displays()).unwrap(); let unique_screens = cx.update(|cx| cx.displays()).unwrap();
let window_size = gpui::Size { let window_size = gpui::Size {
width: px(380.), width: px(400.),
height: px(64.), height: px(72.),
}; };
for screen in unique_screens { for screen in unique_screens {
@ -129,35 +125,22 @@ impl Render for IncomingCallNotification {
cx.set_rem_size(ui_font_size); cx.set_rem_size(ui_font_size);
h_stack() div().size_full().font(ui_font).child(
.font(ui_font) CollabNotification::new(
.text_ui() self.state.call.calling_user.avatar_uri.clone(),
.justify_between() Button::new("accept", "Accept").on_click({
.size_full() let state = self.state.clone();
.overflow_hidden() move |_, cx| state.respond(true, cx)
.elevation_3(cx) }),
.p_2() Button::new("decline", "Decline").on_click({
.gap_2() let state = self.state.clone();
.child( move |_, cx| state.respond(false, cx)
img(self.state.call.calling_user.avatar_uri.clone()) }),
.w_12()
.h_12()
.rounded_full(),
) )
.child(v_stack().overflow_hidden().child(Label::new(format!( .child(v_flex().overflow_hidden().child(Label::new(format!(
"{} is sharing a project in Zed", "{} is sharing a project in Zed",
self.state.call.calling_user.github_login 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)
})),
)
} }
} }

View file

@ -1,12 +1,13 @@
use crate::notification_window_options; use crate::notification_window_options;
use crate::notifications::collab_notification::CollabNotification;
use call::{room, ActiveCall}; use call::{room, ActiveCall};
use client::User; use client::User;
use collections::HashMap; use collections::HashMap;
use gpui::{img, px, AppContext, ParentElement, Render, Size, Styled, ViewContext, VisualContext}; use gpui::{AppContext, Size};
use settings::Settings; use settings::Settings;
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
use theme::ThemeSettings; use theme::ThemeSettings;
use ui::{h_stack, prelude::*, v_stack, Button, Label}; use ui::{prelude::*, Button, Label};
use workspace::AppState; use workspace::AppState;
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) { pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
@ -50,7 +51,6 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
for window in windows { for window in windows {
window window
.update(cx, |_, cx| { .update(cx, |_, cx| {
// todo!()
cx.remove_window(); cx.remove_window();
}) })
.ok(); .ok();
@ -58,12 +58,11 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
} }
} }
room::Event::Left => { room::Event::Left { .. } => {
for (_, windows) in notification_windows.drain() { for (_, windows) in notification_windows.drain() {
for window in windows { for window in windows {
window window
.update(cx, |_, cx| { .update(cx, |_, cx| {
// todo!()
cx.remove_window(); cx.remove_window();
}) })
.ok(); .ok();
@ -130,51 +129,30 @@ impl Render for ProjectSharedNotification {
cx.set_rem_size(ui_font_size); cx.set_rem_size(ui_font_size);
h_stack() div().size_full().font(ui_font).child(
.font(ui_font) CollabNotification::new(
.text_ui() self.owner.avatar_uri.clone(),
.justify_between() Button::new("open", "Open").on_click(cx.listener(move |this, _event, cx| {
.size_full() this.join(cx);
.overflow_hidden() })),
.elevation_3(cx) Button::new("dismiss", "Dismiss").on_click(cx.listener(move |this, _event, cx| {
.p_2() this.dismiss(cx);
.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);
},
))),
) )
.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(", ")))
}),
)
} }
} }

View file

@ -0,0 +1,3 @@
mod collab_notification;
pub use collab_notification::*;

View file

@ -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<Self>) -> 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")),
),
)),
)
}
}

32
crates/color/Cargo.toml Normal file
View file

@ -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"

234
crates/color/src/color.rs Normal file
View file

@ -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<RGBAColor, String> {
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::<String>(),
4 => {
let (rgb, alpha) = hex.split_at(3);
let rgb = rgb
.chars()
.map(|c| c.to_string().repeat(2))
.collect::<String>();
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<RGBAColor> for RGBAColor {
fn from_color_unclamped(color: RGBAColor) -> RGBAColor {
color
}
}
impl<S> FromColorUnclamped<Rgb<S, f32>> for RGBAColor
where
Srgb: FromColorUnclamped<Rgb<S, f32>>,
{
fn from_color_unclamped(color: Rgb<S, f32>) -> RGBAColor {
let srgb = Srgb::from_color_unclamped(color);
RGBAColor {
r: srgb.red,
g: srgb.green,
b: srgb.blue,
a: 1.0,
}
}
}
impl<S> FromColorUnclamped<RGBAColor> for Rgb<S, f32>
where
Rgb<S, f32>: FromColorUnclamped<Srgb>,
{
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),
}
}

View file

@ -9,6 +9,7 @@ path = "src/command_palette.rs"
doctest = false doctest = false
[dependencies] [dependencies]
client = { path = "../client" }
collections = { path = "../collections" } collections = { path = "../collections" }
editor = { path = "../editor" } editor = { path = "../editor" }
fuzzy = { path = "../fuzzy" } fuzzy = { path = "../fuzzy" }
@ -16,9 +17,9 @@ gpui = { path = "../gpui" }
picker = { path = "../picker" } picker = { path = "../picker" }
project = { path = "../project" } project = { path = "../project" }
settings = { path = "../settings" } settings = { path = "../settings" }
theme = { path = "../theme" }
ui = { path = "../ui" } ui = { path = "../ui" }
util = { path = "../util" } util = { path = "../util" }
theme = { path = "../theme" }
workspace = { path = "../workspace" } workspace = { path = "../workspace" }
zed_actions = { path = "../zed_actions" } zed_actions = { path = "../zed_actions" }
anyhow.workspace = true anyhow.workspace = true

View file

@ -3,6 +3,7 @@ use std::{
sync::Arc, sync::Arc,
}; };
use client::telemetry::Telemetry;
use collections::{CommandPaletteFilter, HashMap}; use collections::{CommandPaletteFilter, HashMap};
use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{ use gpui::{
@ -11,7 +12,7 @@ use gpui::{
}; };
use picker::{Picker, PickerDelegate}; 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::{ use util::{
channel::{parse_zed_link, ReleaseChannel, RELEASE_CHANNEL}, channel::{parse_zed_link, ReleaseChannel, RELEASE_CHANNEL},
ResultExt, ResultExt,
@ -39,11 +40,18 @@ impl CommandPalette {
let Some(previous_focus_handle) = cx.focused() else { let Some(previous_focus_handle) = cx.focused() else {
return; 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>) -> Self { fn new(
previous_focus_handle: FocusHandle,
telemetry: Arc<Telemetry>,
cx: &mut ViewContext<Self>,
) -> Self {
let filter = cx.try_global::<CommandPaletteFilter>(); let filter = cx.try_global::<CommandPaletteFilter>();
let commands = cx let commands = cx
@ -66,8 +74,12 @@ impl CommandPalette {
}) })
.collect(); .collect();
let delegate = let delegate = CommandPaletteDelegate::new(
CommandPaletteDelegate::new(cx.view().downgrade(), commands, previous_focus_handle); cx.view().downgrade(),
commands,
telemetry,
previous_focus_handle,
);
let picker = cx.new_view(|cx| Picker::new(delegate, cx)); let picker = cx.new_view(|cx| Picker::new(delegate, cx));
Self { picker } Self { picker }
@ -84,7 +96,7 @@ impl FocusableView for CommandPalette {
impl Render for CommandPalette { impl Render for CommandPalette {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, _cx: &mut ViewContext<Self>) -> 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<Command>, commands: Vec<Command>,
matches: Vec<StringMatch>, matches: Vec<StringMatch>,
selected_ix: usize, selected_ix: usize,
telemetry: Arc<Telemetry>,
previous_focus_handle: FocusHandle, previous_focus_handle: FocusHandle,
} }
@ -130,6 +143,7 @@ impl CommandPaletteDelegate {
fn new( fn new(
command_palette: WeakView<CommandPalette>, command_palette: WeakView<CommandPalette>,
commands: Vec<Command>, commands: Vec<Command>,
telemetry: Arc<Telemetry>,
previous_focus_handle: FocusHandle, previous_focus_handle: FocusHandle,
) -> Self { ) -> Self {
Self { Self {
@ -138,6 +152,7 @@ impl CommandPaletteDelegate {
matches: vec![], matches: vec![],
commands, commands,
selected_ix: 0, selected_ix: 0,
telemetry,
previous_focus_handle, previous_focus_handle,
} }
} }
@ -284,6 +299,10 @@ impl PickerDelegate for CommandPaletteDelegate {
} }
let action_ix = self.matches[self.selected_ix].candidate_id; let action_ix = self.matches[self.selected_ix].candidate_id;
let command = self.commands.swap_remove(action_ix); let command = self.commands.swap_remove(action_ix);
self.telemetry
.report_action_event("command palette", command.name.clone());
self.matches.clear(); self.matches.clear();
self.commands.clear(); self.commands.clear();
cx.update_global(|hit_counts: &mut HitCounts, _| { cx.update_global(|hit_counts: &mut HitCounts, _| {
@ -311,7 +330,7 @@ impl PickerDelegate for CommandPaletteDelegate {
.spacing(ListItemSpacing::Sparse) .spacing(ListItemSpacing::Sparse)
.selected(selected) .selected(selected)
.child( .child(
h_stack() h_flex()
.w_full() .w_full()
.justify_between() .justify_between()
.child(HighlightedLabel::new( .child(HighlightedLabel::new(

View file

@ -308,11 +308,7 @@ impl EventEmitter<Event> for Copilot {}
impl Copilot { impl Copilot {
pub fn global(cx: &AppContext) -> Option<Model<Self>> { pub fn global(cx: &AppContext) -> Option<Model<Self>> {
if cx.has_global::<Model<Self>>() { cx.try_global::<Model<Self>>().map(|model| model.clone())
Some(cx.global::<Model<Self>>().clone())
} else {
None
}
} }
fn start( fn start(
@ -373,10 +369,11 @@ impl Copilot {
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub fn fake(cx: &mut gpui::TestAppContext) -> (Model<Self>, lsp::FakeLanguageServer) { pub fn fake(cx: &mut gpui::TestAppContext) -> (Model<Self>, lsp::FakeLanguageServer) {
use lsp::FakeLanguageServer;
use node_runtime::FakeNodeRuntime; use node_runtime::FakeNodeRuntime;
let (server, fake_server) = 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 http = util::http::FakeHttpClient::create(|_| async { unreachable!() });
let node_runtime = FakeNodeRuntime::new(); let node_runtime = FakeNodeRuntime::new();
let this = cx.new_model(|cx| Self { let this = cx.new_model(|cx| Self {

View file

@ -1,7 +1,7 @@
use crate::sign_in::CopilotCodeVerification; use crate::sign_in::CopilotCodeVerification;
use anyhow::Result; use anyhow::Result;
use copilot::{Copilot, SignOut, Status}; use copilot::{Copilot, SignOut, Status};
use editor::{scroll::autoscroll::Autoscroll, Editor}; use editor::{scroll::Autoscroll, Editor};
use fs::Fs; use fs::Fs;
use gpui::{ use gpui::{
div, Action, AnchorCorner, AppContext, AsyncWindowContext, Entity, IntoElement, ParentElement, div, Action, AnchorCorner, AppContext, AsyncWindowContext, Entity, IntoElement, ParentElement,
@ -17,7 +17,9 @@ use util::{paths, ResultExt};
use workspace::{ use workspace::{
create_and_open_local_file, create_and_open_local_file,
item::ItemHandle, 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, StatusItemView, Toast, Workspace,
}; };
use zed_actions::OpenBrowser; use zed_actions::OpenBrowser;
@ -51,15 +53,15 @@ impl Render for CopilotButton {
.unwrap_or_else(|| all_language_settings.copilot_enabled(None, None)); .unwrap_or_else(|| all_language_settings.copilot_enabled(None, None));
let icon = match status { let icon = match status {
Status::Error(_) => Icon::CopilotError, Status::Error(_) => IconName::CopilotError,
Status::Authorized => { Status::Authorized => {
if enabled { if enabled {
Icon::Copilot IconName::Copilot
} else { } else {
Icon::CopilotDisabled IconName::CopilotDisabled
} }
} }
_ => Icon::CopilotInit, _ => IconName::CopilotInit,
}; };
if let Status::Error(e) = status { if let Status::Error(e) = status {

View file

@ -4,7 +4,7 @@ use gpui::{
FocusableView, InteractiveElement, IntoElement, Model, ParentElement, Render, Styled, FocusableView, InteractiveElement, IntoElement, Model, ParentElement, Render, Styled,
Subscription, ViewContext, Subscription, ViewContext,
}; };
use ui::{prelude::*, Button, Icon, Label}; use ui::{prelude::*, Button, IconName, Label};
use workspace::ModalView; use workspace::ModalView;
const COPILOT_SIGN_UP_URL: &'static str = "https://github.com/features/copilot"; const COPILOT_SIGN_UP_URL: &'static str = "https://github.com/features/copilot";
@ -57,7 +57,7 @@ impl CopilotCodeVerification {
.read_from_clipboard() .read_from_clipboard()
.map(|item| item.text() == &data.user_code) .map(|item| item.text() == &data.user_code)
.unwrap_or(false); .unwrap_or(false);
h_stack() h_flex()
.w_full() .w_full()
.p_1() .p_1()
.border() .border()
@ -69,7 +69,7 @@ impl CopilotCodeVerification {
let user_code = data.user_code.clone(); let user_code = data.user_code.clone();
move |_, cx| { move |_, cx| {
cx.write_to_clipboard(ClipboardItem::new(user_code.clone())); cx.write_to_clipboard(ClipboardItem::new(user_code.clone()));
cx.notify(); cx.refresh();
} }
}) })
.child(div().flex_1().child(Label::new(data.user_code.clone()))) .child(div().flex_1().child(Label::new(data.user_code.clone())))
@ -90,13 +90,13 @@ impl CopilotCodeVerification {
} else { } else {
"Connect to Github" "Connect to Github"
}; };
v_stack() v_flex()
.flex_1() .flex_1()
.gap_2() .gap_2()
.items_center() .items_center()
.child(Headline::new("Use Github Copilot in Zed.").size(HeadlineSize::Large)) .child(Headline::new("Use Github Copilot in Zed.").size(HeadlineSize::Large))
.child( .child(
Label::new("Using Copilot requres an active subscription on Github.") Label::new("Using Copilot requires an active subscription on Github.")
.color(Color::Muted), .color(Color::Muted),
) )
.child(Self::render_device_code(data, cx)) .child(Self::render_device_code(data, cx))
@ -118,7 +118,7 @@ impl CopilotCodeVerification {
) )
} }
fn render_enabled_modal(cx: &mut ViewContext<Self>) -> impl Element { fn render_enabled_modal(cx: &mut ViewContext<Self>) -> impl Element {
v_stack() v_flex()
.gap_2() .gap_2()
.child(Headline::new("Copilot Enabled!").size(HeadlineSize::Large)) .child(Headline::new("Copilot Enabled!").size(HeadlineSize::Large))
.child(Label::new( .child(Label::new(
@ -132,14 +132,14 @@ impl CopilotCodeVerification {
} }
fn render_unauthorized_modal() -> impl Element { 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(Headline::new("You must have an active GitHub Copilot subscription.").size(HeadlineSize::Large))
.child(Label::new( .child(Label::new(
"You can enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.", "You can enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.",
).color(Color::Warning)) ).color(Color::Warning))
.child( .child(
Button::new("copilot-subscribe-button", "Subscibe on Github") Button::new("copilot-subscribe-button", "Subscribe on Github")
.full_width() .full_width()
.on_click(|_, cx| cx.open_url(COPILOT_SIGN_UP_URL)), .on_click(|_, cx| cx.open_url(COPILOT_SIGN_UP_URL)),
) )
@ -163,7 +163,7 @@ impl Render for CopilotCodeVerification {
_ => div().into_any_element(), _ => div().into_any_element(),
}; };
v_stack() v_flex()
.id("copilot code verification") .id("copilot code verification")
.elevation_3(cx) .elevation_3(cx)
.w_96() .w_96()
@ -175,7 +175,7 @@ impl Render for CopilotCodeVerification {
.w_32() .w_32()
.h_16() .h_16()
.flex_none() .flex_none()
.path(Icon::ZedXCopilot.path()) .path(IconName::ZedXCopilot.path())
.text_color(cx.theme().colors().icon), .text_color(cx.theme().colors().icon),
) )
.child(prompt) .child(prompt)

View file

@ -8,7 +8,7 @@ use editor::{
diagnostic_block_renderer, diagnostic_block_renderer,
display_map::{BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock}, display_map::{BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock},
highlight_diagnostic_message, highlight_diagnostic_message,
scroll::autoscroll::Autoscroll, scroll::Autoscroll,
Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, ToOffset, Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, ToOffset,
}; };
use futures::future::try_join_all; use futures::future::try_join_all;
@ -36,7 +36,7 @@ use std::{
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
pub use toolbar_controls::ToolbarControls; 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 util::TryFutureExt;
use workspace::{ use workspace::{
item::{BreadcrumbText, Item, ItemEvent, ItemHandle}, item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
@ -654,13 +654,13 @@ impl Item for ProjectDiagnosticsEditor {
}) })
.into_any_element() .into_any_element()
} else { } else {
h_stack() h_flex()
.gap_1() .gap_1()
.when(self.summary.error_count > 0, |then| { .when(self.summary.error_count > 0, |then| {
then.child( then.child(
h_stack() h_flex()
.gap_1() .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( .child(Label::new(self.summary.error_count.to_string()).color(
if selected { if selected {
Color::Default Color::Default
@ -672,11 +672,9 @@ impl Item for ProjectDiagnosticsEditor {
}) })
.when(self.summary.warning_count > 0, |then| { .when(self.summary.warning_count > 0, |then| {
then.child( then.child(
h_stack() h_flex()
.gap_1() .gap_1()
.child( .child(Icon::new(IconName::ExclamationTriangle).color(Color::Warning))
IconElement::new(Icon::ExclamationTriangle).color(Color::Warning),
)
.child(Label::new(self.summary.warning_count.to_string()).color( .child(Label::new(self.summary.warning_count.to_string()).color(
if selected { if selected {
Color::Default 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( fn for_each_project_item(
&self, &self,
cx: &AppContext, cx: &AppContext,
@ -798,7 +800,7 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
let message: SharedString = message.into(); let message: SharedString = message.into();
Arc::new(move |cx| { Arc::new(move |cx| {
let highlight_style: HighlightStyle = cx.theme().colors().text_accent.into(); let highlight_style: HighlightStyle = cx.theme().colors().text_accent.into();
h_stack() h_flex()
.id("diagnostic header") .id("diagnostic header")
.py_2() .py_2()
.pl_10() .pl_10()
@ -807,7 +809,7 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
.justify_between() .justify_between()
.gap_2() .gap_2()
.child( .child(
h_stack() h_flex()
.gap_3() .gap_3()
.map(|stack| { .map(|stack| {
stack.child( stack.child(
@ -816,17 +818,17 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
.flex_none() .flex_none()
.map(|icon| { .map(|icon| {
if diagnostic.severity == DiagnosticSeverity::ERROR { if diagnostic.severity == DiagnosticSeverity::ERROR {
icon.path(Icon::XCircle.path()) icon.path(IconName::XCircle.path())
.text_color(Color::Error.color(cx)) .text_color(Color::Error.color(cx))
} else { } else {
icon.path(Icon::ExclamationTriangle.path()) icon.path(IconName::ExclamationTriangle.path())
.text_color(Color::Warning.color(cx)) .text_color(Color::Warning.color(cx))
} }
}), }),
) )
}) })
.child( .child(
h_stack() h_flex()
.gap_1() .gap_1()
.child( .child(
StyledText::new(message.clone()).with_highlights( StyledText::new(message.clone()).with_highlights(
@ -846,7 +848,7 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
), ),
) )
.child( .child(
h_stack() h_flex()
.gap_1() .gap_1()
.when_some(diagnostic.source.as_ref(), |stack, source| { .when_some(diagnostic.source.as_ref(), |stack, source| {
stack.child( stack.child(

View file

@ -6,7 +6,7 @@ use gpui::{
}; };
use language::Diagnostic; use language::Diagnostic;
use lsp::LanguageServerId; 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 workspace::{item::ItemHandle, StatusItemView, ToolbarItemEvent, Workspace};
use crate::{Deploy, ProjectDiagnosticsEditor}; use crate::{Deploy, ProjectDiagnosticsEditor};
@ -23,39 +23,39 @@ pub struct DiagnosticIndicator {
impl Render for DiagnosticIndicator { impl Render for DiagnosticIndicator {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let diagnostic_indicator = match (self.summary.error_count, self.summary.warning_count) { 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( this.child(
IconElement::new(Icon::Check) Icon::new(IconName::Check)
.size(IconSize::Small) .size(IconSize::Small)
.color(Color::Default), .color(Color::Default),
) )
}), }),
(0, warning_count) => h_stack() (0, warning_count) => h_flex()
.gap_1() .gap_1()
.child( .child(
IconElement::new(Icon::ExclamationTriangle) Icon::new(IconName::ExclamationTriangle)
.size(IconSize::Small) .size(IconSize::Small)
.color(Color::Warning), .color(Color::Warning),
) )
.child(Label::new(warning_count.to_string()).size(LabelSize::Small)), .child(Label::new(warning_count.to_string()).size(LabelSize::Small)),
(error_count, 0) => h_stack() (error_count, 0) => h_flex()
.gap_1() .gap_1()
.child( .child(
IconElement::new(Icon::XCircle) Icon::new(IconName::XCircle)
.size(IconSize::Small) .size(IconSize::Small)
.color(Color::Error), .color(Color::Error),
) )
.child(Label::new(error_count.to_string()).size(LabelSize::Small)), .child(Label::new(error_count.to_string()).size(LabelSize::Small)),
(error_count, warning_count) => h_stack() (error_count, warning_count) => h_flex()
.gap_1() .gap_1()
.child( .child(
IconElement::new(Icon::XCircle) Icon::new(IconName::XCircle)
.size(IconSize::Small) .size(IconSize::Small)
.color(Color::Error), .color(Color::Error),
) )
.child(Label::new(error_count.to_string()).size(LabelSize::Small)) .child(Label::new(error_count.to_string()).size(LabelSize::Small))
.child( .child(
IconElement::new(Icon::ExclamationTriangle) Icon::new(IconName::ExclamationTriangle)
.size(IconSize::Small) .size(IconSize::Small)
.color(Color::Warning), .color(Color::Warning),
) )
@ -64,9 +64,9 @@ impl Render for DiagnosticIndicator {
let status = if !self.in_progress_checks.is_empty() { let status = if !self.in_progress_checks.is_empty() {
Some( Some(
h_stack() h_flex()
.gap_2() .gap_2()
.child(IconElement::new(Icon::ArrowCircle).size(IconSize::Small)) .child(Icon::new(IconName::ArrowCircle).size(IconSize::Small))
.child( .child(
Label::new("Checking…") Label::new("Checking…")
.size(LabelSize::Small) .size(LabelSize::Small)
@ -80,7 +80,7 @@ impl Render for DiagnosticIndicator {
Button::new("diagnostic_message", message) Button::new("diagnostic_message", message)
.label_size(LabelSize::Small) .label_size(LabelSize::Small)
.tooltip(|cx| { .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| { .on_click(cx.listener(|this, _, cx| {
this.go_to_next_diagnostic(cx); this.go_to_next_diagnostic(cx);
@ -91,7 +91,7 @@ impl Render for DiagnosticIndicator {
None None
}; };
h_stack() h_flex()
.h(rems(1.375)) .h(rems(1.375))
.gap_2() .gap_2()
.child( .child(

View file

@ -1,7 +1,7 @@
use crate::ProjectDiagnosticsEditor; use crate::ProjectDiagnosticsEditor;
use gpui::{div, EventEmitter, ParentElement, Render, ViewContext, WeakView}; use gpui::{div, EventEmitter, ParentElement, Render, ViewContext, WeakView};
use ui::prelude::*; use ui::prelude::*;
use ui::{Icon, IconButton, Tooltip}; use ui::{IconButton, IconName, Tooltip};
use workspace::{item::ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView}; use workspace::{item::ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView};
pub struct ToolbarControls { pub struct ToolbarControls {
@ -24,7 +24,7 @@ impl Render for ToolbarControls {
}; };
div().child( div().child(
IconButton::new("toggle-warnings", Icon::ExclamationTriangle) IconButton::new("toggle-warnings", IconName::ExclamationTriangle)
.tooltip(move |cx| Tooltip::text(tooltip, cx)) .tooltip(move |cx| Tooltip::text(tooltip, cx))
.on_click(cx.listener(|this, _, cx| { .on_click(cx.listener(|this, _, cx| {
if let Some(editor) = this.editor.as_ref().and_then(|editor| editor.upgrade()) { if let Some(editor) = this.editor.as_ref().and_then(|editor| editor.upgrade()) {

View file

@ -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<usize>,
}
#[derive(PartialEq, Clone, Deserialize, Default)]
pub struct ConfirmCodeAction {
#[serde(default)]
pub item_ix: Option<usize>,
}
#[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,
]
);

View file

@ -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 block_map;
mod fold_map; mod fold_map;
mod inlay_map; mod inlay_map;
@ -30,7 +49,8 @@ pub use block_map::{
}; };
pub use self::fold_map::{Fold, FoldPoint}; 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)] #[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum FoldStatus { pub enum FoldStatus {
@ -220,7 +240,7 @@ impl DisplayMap {
.insert(Some(type_id), Arc::new((style, ranges))); .insert(Some(type_id), Arc::new((style, ranges)));
} }
pub fn highlight_inlays( pub(crate) fn highlight_inlays(
&mut self, &mut self,
type_id: TypeId, type_id: TypeId,
highlights: Vec<InlayHighlight>, highlights: Vec<InlayHighlight>,
@ -258,11 +278,11 @@ impl DisplayMap {
.update(cx, |map, cx| map.set_wrap_width(width, cx)) .update(cx, |map, cx| map.set_wrap_width(width, cx))
} }
pub fn current_inlays(&self) -> impl Iterator<Item = &Inlay> { pub(crate) fn current_inlays(&self) -> impl Iterator<Item = &Inlay> {
self.inlay_map.current_inlays() self.inlay_map.current_inlays()
} }
pub fn splice_inlays( pub(crate) fn splice_inlays(
&mut self, &mut self,
to_remove: Vec<InlayId>, to_remove: Vec<InlayId>,
to_insert: Vec<Inlay>, to_insert: Vec<Inlay>,
@ -306,7 +326,7 @@ impl DisplayMap {
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct Highlights<'a> { pub(crate) struct Highlights<'a> {
pub text_highlights: Option<&'a TextHighlights>, pub text_highlights: Option<&'a TextHighlights>,
pub inlay_highlights: Option<&'a InlayHighlights>, pub inlay_highlights: Option<&'a InlayHighlights>,
pub inlay_highlight_style: Option<HighlightStyle>, pub inlay_highlight_style: Option<HighlightStyle>,
@ -880,8 +900,9 @@ impl DisplaySnapshot {
self.text_highlights.get(&Some(type_id)).cloned() self.text_highlights.get(&Some(type_id)).cloned()
} }
#[allow(unused)]
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub fn inlay_highlights<Tag: ?Sized + 'static>( pub(crate) fn inlay_highlights<Tag: ?Sized + 'static>(
&self, &self,
) -> Option<&HashMap<InlayId, (HighlightStyle, InlayHighlight)>> { ) -> Option<&HashMap<InlayId, (HighlightStyle, InlayHighlight)>> {
let type_id = TypeId::of::<Tag>(); let type_id = TypeId::of::<Tag>();
@ -969,24 +990,6 @@ impl ToDisplayPoint for Anchor {
} }
} }
pub fn next_rows(display_row: u32, display_map: &DisplaySnapshot) -> impl Iterator<Item = u32> {
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)] #[cfg(test)]
pub mod tests { pub mod tests {
use super::*; use super::*;
@ -1015,7 +1018,6 @@ pub mod tests {
.map(|i| i.parse().expect("invalid `OPERATIONS` variable")) .map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
.unwrap_or(10); .unwrap_or(10);
let _test_platform = &cx.test_platform;
let mut tab_size = rng.gen_range(1..=4); let mut tab_size = rng.gen_range(1..=4);
let buffer_start_excerpt_header_height = rng.gen_range(1..=5); let buffer_start_excerpt_header_height = rng.gen_range(1..=5);
let excerpt_header_height = rng.gen_range(1..=5); let excerpt_header_height = rng.gen_range(1..=5);

View file

@ -582,7 +582,7 @@ impl BlockSnapshot {
.collect() .collect()
} }
pub fn chunks<'a>( pub(crate) fn chunks<'a>(
&'a self, &'a self,
rows: Range<u32>, rows: Range<u32>,
language_aware: bool, language_aware: bool,

View file

@ -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> { impl<'a> FoldMapWriter<'a> {
pub fn fold<T: ToOffset>( pub(crate) fn fold<T: ToOffset>(
&mut self, &mut self,
ranges: impl IntoIterator<Item = Range<T>>, ranges: impl IntoIterator<Item = Range<T>>,
) -> (FoldSnapshot, Vec<FoldEdit>) { ) -> (FoldSnapshot, Vec<FoldEdit>) {
@ -129,7 +129,7 @@ impl<'a> FoldMapWriter<'a> {
(self.0.snapshot.clone(), edits) (self.0.snapshot.clone(), edits)
} }
pub fn unfold<T: ToOffset>( pub(crate) fn unfold<T: ToOffset>(
&mut self, &mut self,
ranges: impl IntoIterator<Item = Range<T>>, ranges: impl IntoIterator<Item = Range<T>>,
inclusive: bool, inclusive: bool,
@ -178,14 +178,14 @@ impl<'a> FoldMapWriter<'a> {
} }
} }
pub struct FoldMap { pub(crate) struct FoldMap {
snapshot: FoldSnapshot, snapshot: FoldSnapshot,
ellipses_color: Option<Hsla>, ellipses_color: Option<Hsla>,
next_fold_id: FoldId, next_fold_id: FoldId,
} }
impl FoldMap { impl FoldMap {
pub fn new(inlay_snapshot: InlaySnapshot) -> (Self, FoldSnapshot) { pub(crate) fn new(inlay_snapshot: InlaySnapshot) -> (Self, FoldSnapshot) {
let this = Self { let this = Self {
snapshot: FoldSnapshot { snapshot: FoldSnapshot {
folds: Default::default(), folds: Default::default(),
@ -655,7 +655,7 @@ impl FoldSnapshot {
} }
} }
pub fn chunks<'a>( pub(crate) fn chunks<'a>(
&'a self, &'a self,
range: Range<FoldOffset>, range: Range<FoldOffset>,
language_aware: bool, language_aware: bool,

View file

@ -35,8 +35,8 @@ enum Transform {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Inlay { pub(crate) struct Inlay {
pub id: InlayId, pub(crate) id: InlayId,
pub position: Anchor, pub position: Anchor,
pub text: text::Rope, pub text: text::Rope,
} }
@ -1016,7 +1016,7 @@ impl InlaySnapshot {
(line_end - line_start) as u32 (line_end - line_start) as u32
} }
pub fn chunks<'a>( pub(crate) fn chunks<'a>(
&'a self, &'a self,
range: Range<InlayOffset>, range: Range<InlayOffset>,
language_aware: bool, language_aware: bool,

View file

@ -11,7 +11,6 @@ use smol::future::yield_now;
use std::{cmp, collections::VecDeque, mem, ops::Range, time::Duration}; use std::{cmp, collections::VecDeque, mem, ops::Range, time::Duration};
use sum_tree::{Bias, Cursor, SumTree}; use sum_tree::{Bias, Cursor, SumTree};
use text::Patch; use text::Patch;
use util::ResultExt;
pub use super::tab_map::TextSummary; pub use super::tab_map::TextSummary;
pub type WrapEdit = text::Edit<u32>; pub type WrapEdit = text::Edit<u32>;
@ -154,26 +153,24 @@ impl WrapMap {
if let Some(wrap_width) = self.wrap_width { if let Some(wrap_width) = self.wrap_width {
let mut new_snapshot = self.snapshot.clone(); let mut new_snapshot = self.snapshot.clone();
let mut edits = Patch::default();
let text_system = cx.text_system().clone(); let text_system = cx.text_system().clone();
let (font, font_size) = self.font_with_size.clone(); let (font, font_size) = self.font_with_size.clone();
let task = cx.background_executor().spawn(async move { let task = cx.background_executor().spawn(async move {
if let Some(mut line_wrapper) = text_system.line_wrapper(font, font_size).log_err() let mut line_wrapper = text_system.line_wrapper(font, font_size);
{ let tab_snapshot = new_snapshot.tab_snapshot.clone();
let tab_snapshot = new_snapshot.tab_snapshot.clone(); let range = TabPoint::zero()..tab_snapshot.max_point();
let range = TabPoint::zero()..tab_snapshot.max_point(); let edits = new_snapshot
edits = new_snapshot .update(
.update( tab_snapshot,
tab_snapshot, &[TabEdit {
&[TabEdit { old: range.clone(),
old: range.clone(), new: range.clone(),
new: range.clone(), }],
}], wrap_width,
wrap_width, &mut line_wrapper,
&mut line_wrapper, )
) .await;
.await;
}
(new_snapshot, edits) (new_snapshot, edits)
}); });
@ -245,15 +242,12 @@ impl WrapMap {
let (font, font_size) = self.font_with_size.clone(); let (font, font_size) = self.font_with_size.clone();
let update_task = cx.background_executor().spawn(async move { let update_task = cx.background_executor().spawn(async move {
let mut edits = Patch::default(); let mut edits = Patch::default();
if let Some(mut line_wrapper) = let mut line_wrapper = text_system.line_wrapper(font, font_size);
text_system.line_wrapper(font, font_size).log_err() for (tab_snapshot, tab_edits) in pending_edits {
{ let wrap_edits = snapshot
for (tab_snapshot, tab_edits) in pending_edits { .update(tab_snapshot, &tab_edits, wrap_width, &mut line_wrapper)
let wrap_edits = snapshot .await;
.update(tab_snapshot, &tab_edits, wrap_width, &mut line_wrapper) edits = edits.compose(&wrap_edits);
.await;
edits = edits.compose(&wrap_edits);
}
} }
(snapshot, edits) (snapshot, edits)
}); });
@ -574,7 +568,7 @@ impl WrapSnapshot {
Patch::new(wrap_edits) Patch::new(wrap_edits)
} }
pub fn chunks<'a>( pub(crate) fn chunks<'a>(
&'a self, &'a self,
rows: Range<u32>, rows: Range<u32>,
language_aware: bool, language_aware: bool,
@ -1043,7 +1037,7 @@ mod tests {
#[gpui::test(iterations = 100)] #[gpui::test(iterations = 100)]
async fn test_random_wraps(cx: &mut gpui::TestAppContext, mut rng: StdRng) { 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); init_test(cx);
cx.background_executor.set_block_on_ticks(0..=50); 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 tab_size = NonZeroU32::new(rng.gen_range(1..=4)).unwrap();
let font = font("Helvetica"); 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); let font_size = px(14.0);
log::info!("Tab size: {}", tab_size); log::info!("Tab size: {}", tab_size);
@ -1086,7 +1080,7 @@ mod tests {
let tabs_snapshot = tab_map.set_max_expansion_column(32); let tabs_snapshot = tab_map.set_max_expansion_column(32);
log::info!("TabMap text: {:?}", tabs_snapshot.text()); 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 unwrapped_text = tabs_snapshot.text();
let expected_text = wrap_text(&unwrapped_text, wrap_width, &mut line_wrapper); let expected_text = wrap_text(&unwrapped_text, wrap_width, &mut line_wrapper);

Some files were not shown because too many files have changed in this diff Show more