Compare commits
85 Commits
post-layou
...
access-kit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63e9a7069f | ||
|
|
1c361ac579 | ||
|
|
bea36918f4 | ||
|
|
43e8fdbe82 | ||
|
|
5df1318e75 | ||
|
|
577b244b03 | ||
|
|
2e0d18ee76 | ||
|
|
c5a23faf7c | ||
|
|
86f81c4db3 | ||
|
|
07501d9cfa | ||
|
|
07c7778cff | ||
|
|
aa6926e57a | ||
|
|
ae577c9d5c | ||
|
|
f4f72a1136 | ||
|
|
4310b0b8de | ||
|
|
a161a7d0c9 | ||
|
|
ab6b9196e1 | ||
|
|
ef551cedef | ||
|
|
9ef83a2557 | ||
|
|
32fdff0285 | ||
|
|
4094562321 | ||
|
|
6869b62af3 | ||
|
|
21a7421ee0 | ||
|
|
96dcc385dd | ||
|
|
e6766e102e | ||
|
|
694e18417e | ||
|
|
94426c4393 | ||
|
|
bf1bcd027c | ||
|
|
23132b5ab1 | ||
|
|
a8d5864524 | ||
|
|
ea322e1d1c | ||
|
|
e1ae0d46da | ||
|
|
bdc2558eac | ||
|
|
a41fb29e01 | ||
|
|
aa319ccfd0 | ||
|
|
f01763a1fa | ||
|
|
2dffc5f6e1 | ||
|
|
ed791c4fc1 | ||
|
|
7c6b34cb73 | ||
|
|
3921259b6c | ||
|
|
e93dca5ec3 | ||
|
|
c6626627c2 | ||
|
|
db86f4006e | ||
|
|
41372a96ed | ||
|
|
f62baeda64 | ||
|
|
6e6ae0ef21 | ||
|
|
a3300aed31 | ||
|
|
cbbc8cad84 | ||
|
|
8e52cf1495 | ||
|
|
65a1938e52 | ||
|
|
855acb948c | ||
|
|
54f82eb166 | ||
|
|
ac59b9b02f | ||
|
|
8f7a26f397 | ||
|
|
181f556269 | ||
|
|
a02bdd0a9f | ||
|
|
6876ea44ac | ||
|
|
16849f48e6 | ||
|
|
7a6d01e113 | ||
|
|
b14fbd4ddc | ||
|
|
b47aff4c14 | ||
|
|
7ac055627e | ||
|
|
db0455beb0 | ||
|
|
017b2db630 | ||
|
|
75eac4783b | ||
|
|
7956a9a547 | ||
|
|
c357e37dde | ||
|
|
c3176392c6 | ||
|
|
e9b95fde68 | ||
|
|
e73e93f333 | ||
|
|
a2144faf9c | ||
|
|
d744aa896f | ||
|
|
2294d99046 | ||
|
|
ecd9b93cb1 | ||
|
|
fecb5a82f1 | ||
|
|
33f713a8ab | ||
|
|
798c9a7d8b | ||
|
|
98fff014da | ||
|
|
ea51536e0f | ||
|
|
a1899bac4e | ||
|
|
04fc0dde1a | ||
|
|
b800fe96d2 | ||
|
|
21d2b5fe50 | ||
|
|
d13a731cd6 | ||
|
|
ede9600ab4 |
38
.github/ISSUE_TEMPLATE/0_feature_request.yml
vendored
38
.github/ISSUE_TEMPLATE/0_feature_request.yml
vendored
@@ -2,23 +2,23 @@ name: Feature Request
|
||||
description: "Tip: open this issue template from within Zed with the `request feature` command palette action"
|
||||
labels: ["admin read", "triage", "enhancement"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Check for existing issues
|
||||
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it.
|
||||
options:
|
||||
- label: Completed
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the feature
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Check for existing issues
|
||||
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it.
|
||||
options:
|
||||
- label: Completed
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: |
|
||||
If applicable, add mockups / screenshots to help present your vision of the feature
|
||||
description: Drag images into the text input below
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the feature
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: |
|
||||
If applicable, add mockups / screenshots to help present your vision of the feature
|
||||
description: Drag images into the text input below
|
||||
validations:
|
||||
required: false
|
||||
|
||||
82
.github/ISSUE_TEMPLATE/1_language_support.yml
vendored
82
.github/ISSUE_TEMPLATE/1_language_support.yml
vendored
@@ -2,46 +2,46 @@ name: Language Support
|
||||
description: Request language support
|
||||
title: "<name_of_language> support"
|
||||
labels:
|
||||
[
|
||||
"admin read",
|
||||
"triage",
|
||||
"enhancement",
|
||||
"language",
|
||||
"unsupported language",
|
||||
"potential plugin",
|
||||
]
|
||||
[
|
||||
"admin read",
|
||||
"triage",
|
||||
"enhancement",
|
||||
"language",
|
||||
"unsupported language",
|
||||
"potential extension",
|
||||
]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Check for existing issues
|
||||
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it.
|
||||
options:
|
||||
- label: Completed
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Language
|
||||
description: What language do you want support for?
|
||||
placeholder: HTML
|
||||
validations:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Check for existing issues
|
||||
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it.
|
||||
options:
|
||||
- label: Completed
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Tree Sitter parser link
|
||||
description: If applicable, provide a link to the appropriate tree sitter parser. Look here first - https://tree-sitter.github.io/tree-sitter/#available-parsers
|
||||
placeholder: https://github.com/tree-sitter/tree-sitter-html
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
attributes:
|
||||
label: Language server link
|
||||
description: If applicable, provide a link to the appropriate language server. Look here first - https://microsoft.github.io/language-server-protocol/implementors/servers/
|
||||
placeholder: https://github.com/Microsoft/vscode/tree/main/extensions/html-language-features/server
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Misc notes
|
||||
description: Provide any additional things the team should consider when adding support for this language
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
attributes:
|
||||
label: Language
|
||||
description: What language do you want support for?
|
||||
placeholder: HTML
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Tree Sitter parser link
|
||||
description: If applicable, provide a link to the appropriate tree sitter parser. Look here first - https://tree-sitter.github.io/tree-sitter/#available-parsers
|
||||
placeholder: https://github.com/tree-sitter/tree-sitter-html
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
attributes:
|
||||
label: Language server link
|
||||
description: If applicable, provide a link to the appropriate language server. Look here first - https://microsoft.github.io/language-server-protocol/implementors/servers/
|
||||
placeholder: https://github.com/Microsoft/vscode/tree/main/extensions/html-language-features/server
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Misc notes
|
||||
description: Provide any additional things the team should consider when adding support for this language
|
||||
validations:
|
||||
required: false
|
||||
|
||||
66
.github/ISSUE_TEMPLATE/2_bug_report.yml
vendored
66
.github/ISSUE_TEMPLATE/2_bug_report.yml
vendored
@@ -2,37 +2,37 @@ name: Bug Report
|
||||
description: "Tip: open this issue template from within Zed with the `file bug report` command palette action"
|
||||
labels: ["admin read", "triage", "defect"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Check for existing issues
|
||||
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it.
|
||||
options:
|
||||
- label: Completed
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug / provide steps to reproduce it
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Check for existing issues
|
||||
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` (👍) on it.
|
||||
options:
|
||||
- label: Completed
|
||||
required: true
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Run the `copy system specs into clipboard` command palette action and paste the output in the field below.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: If applicable, add mockups / screenshots to help explain present your vision of the feature
|
||||
description: Drag issues into the text input below
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: |
|
||||
If applicable, attach your `~/Library/Logs/Zed/Zed.log` file to this issue.
|
||||
If you only need the most recent lines, you can run the `zed: open log` command palette action to see the last 1000.
|
||||
description: Drag Zed.log into the text input below
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug / provide steps to reproduce it
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Run the `copy system specs into clipboard` command palette action and paste the output in the field below.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: If applicable, add mockups / screenshots to help explain present your vision of the feature
|
||||
description: Drag issues into the text input below
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: |
|
||||
If applicable, attach your `~/Library/Logs/Zed/Zed.log` file to this issue.
|
||||
If you only need the most recent lines, you can run the `zed: open log` command palette action to see the last 1000.
|
||||
description: Drag Zed.log into the text input below
|
||||
validations:
|
||||
required: false
|
||||
|
||||
21
.github/ISSUE_TEMPLATE/config.yml
vendored
21
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,10 +1,13 @@
|
||||
contact_links:
|
||||
- name: Top-Ranking Issues
|
||||
url: https://github.com/zed-industries/zed/issues/5393
|
||||
about: See an overview of the most popular Zed issues
|
||||
- name: Platform Support
|
||||
url: https://github.com/zed-industries/zed/issues/5391
|
||||
about: A quick note on platform support
|
||||
- name: Positive Feedback
|
||||
url: https://github.com/zed-industries/zed/discussions/5397
|
||||
about: A central location for kind words about Zed
|
||||
- name: Theme Request
|
||||
url: https://github.com/zed-industries/extensions/issues/new/choose
|
||||
about: Request a theme in the extensions repository
|
||||
- name: Top-Ranking Issues
|
||||
url: https://github.com/zed-industries/zed/issues/5393
|
||||
about: See an overview of the most popular Zed issues
|
||||
- name: Platform Support
|
||||
url: https://github.com/zed-industries/zed/issues/5391
|
||||
about: A quick note on platform support
|
||||
- name: Positive Feedback
|
||||
url: https://github.com/zed-industries/zed/discussions/5397
|
||||
about: A central location for kind words about Zed
|
||||
|
||||
20
.github/actions/check_style/action.yml
vendored
20
.github/actions/check_style/action.yml
vendored
@@ -2,14 +2,14 @@ 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
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: cargo fmt
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Find modified migrations
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: |
|
||||
export SQUAWK_GITHUB_TOKEN=${{ github.token }}
|
||||
. ./script/squawk
|
||||
- name: Find modified migrations
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: |
|
||||
export SQUAWK_GITHUB_TOKEN=${{ github.token }}
|
||||
. ./script/squawk
|
||||
|
||||
32
.github/actions/run_tests/action.yml
vendored
32
.github/actions/run_tests/action.yml
vendored
@@ -2,22 +2,22 @@ name: "Run tests"
|
||||
description: "Runs the tests"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install Rust
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: |
|
||||
cargo install cargo-nextest
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install Rust
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: |
|
||||
cargo install cargo-nextest
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
|
||||
- name: Limit target directory size
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: script/clear-target-dir-if-larger-than 100
|
||||
- name: Limit target directory size
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: script/clear-target-dir-if-larger-than 100
|
||||
|
||||
- name: Run tests
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: cargo nextest run --workspace --no-fail-fast
|
||||
- name: Run tests
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: cargo nextest run --workspace --no-fail-fast
|
||||
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -7,3 +7,5 @@ Release Notes:
|
||||
**or**
|
||||
|
||||
- N/A
|
||||
|
||||
Optionally, include screenshots / media showcasing your addition that can be included in the release notes.
|
||||
|
||||
363
.github/workflows/ci.yml
vendored
363
.github/workflows/ci.yml
vendored
@@ -1,204 +1,209 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "v[0-9]+.[0-9]+.x"
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "v[0-9]+.[0-9]+.x"
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
concurrency:
|
||||
# Allow only one workflow per any non-`main` branch.
|
||||
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
|
||||
cancel-in-progress: true
|
||||
# Allow only one workflow per any non-`main` branch.
|
||||
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
style:
|
||||
name: Check formatting and spelling
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- test
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
fetch-depth: 0
|
||||
style:
|
||||
name: Check formatting and spelling
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- test
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up default .cargo/config.toml
|
||||
run: cp ./.cargo/ci-config.toml ~/.cargo/config.toml
|
||||
- name: Remove untracked files
|
||||
run: git clean -df
|
||||
|
||||
- name: Check spelling
|
||||
run: |
|
||||
if ! which typos > /dev/null; then
|
||||
cargo install typos-cli
|
||||
fi
|
||||
typos
|
||||
- name: Set up default .cargo/config.toml
|
||||
run: cp ./.cargo/ci-config.toml ~/.cargo/config.toml
|
||||
|
||||
- name: Run style checks
|
||||
uses: ./.github/actions/check_style
|
||||
- name: Check spelling
|
||||
run: |
|
||||
if ! which typos > /dev/null; then
|
||||
cargo install typos-cli
|
||||
fi
|
||||
typos
|
||||
|
||||
- name: Ensure fresh merge
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: |
|
||||
if [ -z "$GITHUB_BASE_REF" ];
|
||||
then
|
||||
echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> $GITHUB_ENV
|
||||
else
|
||||
git checkout -B temp
|
||||
git merge -q origin/$GITHUB_BASE_REF -m "merge main into temp"
|
||||
echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> $GITHUB_ENV
|
||||
fi
|
||||
- name: Run style checks
|
||||
uses: ./.github/actions/check_style
|
||||
|
||||
- uses: bufbuild/buf-setup-action@v1
|
||||
- uses: bufbuild/buf-breaking-action@v1
|
||||
with:
|
||||
input: "crates/rpc/proto/"
|
||||
against: "https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/rpc/proto/"
|
||||
- name: Ensure fresh merge
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: |
|
||||
if [ -z "$GITHUB_BASE_REF" ];
|
||||
then
|
||||
echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> $GITHUB_ENV
|
||||
else
|
||||
git checkout -B temp
|
||||
git merge -q origin/$GITHUB_BASE_REF -m "merge main into temp"
|
||||
echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
macos_tests:
|
||||
name: (macOS) Run Clippy and tests
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- test
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
- uses: bufbuild/buf-setup-action@v1
|
||||
- uses: bufbuild/buf-breaking-action@v1
|
||||
with:
|
||||
input: "crates/rpc/proto/"
|
||||
against: "https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/rpc/proto/"
|
||||
|
||||
- name: cargo clippy
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: script/clippy
|
||||
macos_tests:
|
||||
name: (macOS) Run Clippy and tests
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- test
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Run tests
|
||||
uses: ./.github/actions/run_tests
|
||||
- name: cargo clippy
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: script/clippy
|
||||
|
||||
- name: Build collab
|
||||
run: cargo build -p collab
|
||||
- name: Run tests
|
||||
uses: ./.github/actions/run_tests
|
||||
|
||||
- name: Build other binaries
|
||||
run: cargo build --workspace --bins --all-features
|
||||
- name: Build collab
|
||||
run: cargo build -p collab
|
||||
|
||||
# todo!(linux): Actually run the tests
|
||||
linux_tests:
|
||||
name: (Linux) Run Clippy and tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
- name: Build other binaries
|
||||
run: cargo build --workspace --bins --all-features
|
||||
|
||||
- name: Restore from cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/rust-toolchain.toml') }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-${{ hashFiles('**/rust-toolchain.toml') }}-
|
||||
# todo!(linux): Actually run the tests
|
||||
linux_tests:
|
||||
name: (Linux) Run Clippy and tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
|
||||
- name: configure linux
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: script/linux
|
||||
- name: Restore from cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/rust-toolchain.toml') }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-${{ hashFiles('**/rust-toolchain.toml') }}-
|
||||
|
||||
- name: cargo clippy
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: script/clippy
|
||||
- name: configure linux
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: script/linux
|
||||
|
||||
- name: Build Zed
|
||||
run: cargo build -p zed
|
||||
bundle:
|
||||
name: Bundle app
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- bundle
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
|
||||
needs: [macos_tests, linux_tests]
|
||||
- name: cargo clippy
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: script/clippy
|
||||
|
||||
- name: Build Zed
|
||||
run: cargo build -p zed
|
||||
bundle:
|
||||
name: Bundle macOS app
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- bundle
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
|
||||
needs: [macos_tests]
|
||||
env:
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
|
||||
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
|
||||
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
|
||||
DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
|
||||
DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
|
||||
steps:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Limit target directory size
|
||||
run: script/clear-target-dir-if-larger-than 100
|
||||
|
||||
- name: Determine version and release channel
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
version=$(script/get-crate-version zed)
|
||||
channel=$(cat crates/zed/RELEASE_CHANNEL)
|
||||
echo "Publishing version: ${version} on release channel ${channel}"
|
||||
echo "RELEASE_CHANNEL=${channel}" >> $GITHUB_ENV
|
||||
|
||||
expected_tag_name=""
|
||||
case ${channel} in
|
||||
stable)
|
||||
expected_tag_name="v${version}";;
|
||||
preview)
|
||||
expected_tag_name="v${version}-pre";;
|
||||
nightly)
|
||||
expected_tag_name="v${version}-nightly";;
|
||||
*)
|
||||
echo "can't publish a release on channel ${channel}"
|
||||
exit 1;;
|
||||
esac
|
||||
if [[ $GITHUB_REF_NAME != $expected_tag_name ]]; then
|
||||
echo "invalid release tag ${GITHUB_REF_NAME}. expected ${expected_tag_name}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Generate license file
|
||||
run: script/generate-licenses
|
||||
|
||||
- name: Create app bundle
|
||||
run: script/bundle
|
||||
|
||||
- name: Upload app bundle to workflow run if main branch or specific label
|
||||
uses: actions/upload-artifact@v3
|
||||
if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
|
||||
with:
|
||||
name: Zed_${{ github.event.pull_request.head.sha || github.sha }}.dmg
|
||||
path: target/release/Zed.dmg
|
||||
|
||||
- uses: softprops/action-gh-release@v1
|
||||
name: Upload app bundle to release
|
||||
if: ${{ env.RELEASE_CHANNEL == 'preview' || env.RELEASE_CHANNEL == 'stable' }}
|
||||
with:
|
||||
draft: true
|
||||
prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }}
|
||||
files: target/release/Zed.dmg
|
||||
body: ""
|
||||
env:
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
|
||||
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
|
||||
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
|
||||
steps:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Limit target directory size
|
||||
run: script/clear-target-dir-if-larger-than 100
|
||||
|
||||
- name: Determine version and release channel
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
version=$(script/get-crate-version zed)
|
||||
channel=$(cat crates/zed/RELEASE_CHANNEL)
|
||||
echo "Publishing version: ${version} on release channel ${channel}"
|
||||
echo "RELEASE_CHANNEL=${channel}" >> $GITHUB_ENV
|
||||
|
||||
expected_tag_name=""
|
||||
case ${channel} in
|
||||
stable)
|
||||
expected_tag_name="v${version}";;
|
||||
preview)
|
||||
expected_tag_name="v${version}-pre";;
|
||||
nightly)
|
||||
expected_tag_name="v${version}-nightly";;
|
||||
*)
|
||||
echo "can't publish a release on channel ${channel}"
|
||||
exit 1;;
|
||||
esac
|
||||
if [[ $GITHUB_REF_NAME != $expected_tag_name ]]; then
|
||||
echo "invalid release tag ${GITHUB_REF_NAME}. expected ${expected_tag_name}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Generate license file
|
||||
run: script/generate-licenses
|
||||
|
||||
- name: Create app bundle
|
||||
run: script/bundle
|
||||
|
||||
- name: Upload app bundle to workflow run if main branch or specific label
|
||||
uses: actions/upload-artifact@v3
|
||||
if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
|
||||
with:
|
||||
name: Zed_${{ github.event.pull_request.head.sha || github.sha }}.dmg
|
||||
path: target/release/Zed.dmg
|
||||
|
||||
- uses: softprops/action-gh-release@v1
|
||||
name: Upload app bundle to release
|
||||
if: ${{ env.RELEASE_CHANNEL == 'preview' || env.RELEASE_CHANNEL == 'stable' }}
|
||||
with:
|
||||
draft: true
|
||||
prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }}
|
||||
files: target/release/Zed.dmg
|
||||
body: ""
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
50
.github/workflows/danger.yml
vendored
50
.github/workflows/danger.yml
vendored
@@ -1,35 +1,35 @@
|
||||
name: Danger
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- edited
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- edited
|
||||
|
||||
jobs:
|
||||
danger:
|
||||
runs-on: ubuntu-latest
|
||||
danger:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "script/danger/pnpm-lock.yaml"
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "script/danger/pnpm-lock.yaml"
|
||||
|
||||
- run: pnpm install --dir script/danger
|
||||
- run: pnpm install --dir script/danger
|
||||
|
||||
- name: Run Danger
|
||||
run: pnpm run --dir script/danger danger ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
- name: Run Danger
|
||||
run: pnpm run --dir script/danger danger ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
40
.github/workflows/deploy_collab.yml
vendored
40
.github/workflows/deploy_collab.yml
vendored
@@ -45,8 +45,18 @@ jobs:
|
||||
submodules: "recursive"
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install cargo nextest
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: |
|
||||
cargo install cargo-nextest
|
||||
|
||||
- name: Limit target directory size
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: script/clear-target-dir-if-larger-than 100
|
||||
|
||||
- name: Run tests
|
||||
uses: ./.github/actions/run_tests
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: cargo nextest run --package collab --no-fail-fast
|
||||
|
||||
publish:
|
||||
name: Publish collab server image
|
||||
@@ -63,9 +73,6 @@ jobs:
|
||||
- name: Sign into DigitalOcean docker registry
|
||||
run: doctl registry login
|
||||
|
||||
- name: Prune Docker system
|
||||
run: docker system prune --filter 'until=720h' -f
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -78,6 +85,9 @@ jobs:
|
||||
- name: Publish docker image
|
||||
run: docker push registry.digitalocean.com/zed/collab:${GITHUB_SHA}
|
||||
|
||||
- name: Prune Docker system
|
||||
run: docker system prune --filter 'until=72h' -f
|
||||
|
||||
deploy:
|
||||
name: Deploy new server image
|
||||
needs:
|
||||
@@ -90,22 +100,26 @@ jobs:
|
||||
- name: Sign into Kubernetes
|
||||
run: doctl kubernetes cluster kubeconfig save --expiry-seconds 600 ${{ secrets.CLUSTER_NAME }}
|
||||
|
||||
- name: Determine namespace
|
||||
- name: Start rollout
|
||||
run: |
|
||||
set -eu
|
||||
if [[ $GITHUB_REF_NAME = "collab-production" ]]; then
|
||||
echo "Deploying collab:$GITHUB_SHA to production"
|
||||
echo "KUBE_NAMESPACE=production" >> $GITHUB_ENV
|
||||
export ZED_KUBE_NAMESPACE=production
|
||||
elif [[ $GITHUB_REF_NAME = "collab-staging" ]]; then
|
||||
echo "Deploying collab:$GITHUB_SHA to staging"
|
||||
echo "KUBE_NAMESPACE=staging" >> $GITHUB_ENV
|
||||
export ZED_KUBE_NAMESPACE=staging
|
||||
else
|
||||
echo "cowardly refusing to deploy from an unknown branch"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Start rollout
|
||||
run: kubectl -n "$KUBE_NAMESPACE" set image deployment/collab collab=registry.digitalocean.com/zed/collab:${GITHUB_SHA}
|
||||
echo "Deploying collab:$GITHUB_SHA to $ZED_KUBE_NAMESPACE"
|
||||
|
||||
- name: Wait for rollout to finish
|
||||
run: kubectl -n "$KUBE_NAMESPACE" rollout status deployment/collab
|
||||
source script/lib/deploy-helpers.sh
|
||||
export_vars_for_environment $ZED_KUBE_NAMESPACE
|
||||
|
||||
export ZED_DO_CERTIFICATE_ID=$(doctl compute certificate list --format ID --no-header)
|
||||
export ZED_IMAGE_ID="registry.digitalocean.com/zed/collab:${GITHUB_SHA}"
|
||||
|
||||
envsubst < crates/collab/k8s/collab.template.yml | kubectl apply -f -
|
||||
kubectl -n "$ZED_KUBE_NAMESPACE" rollout status deployment/collab --watch
|
||||
echo "deployed collab.template.yml to ${ZED_KUBE_NAMESPACE}"
|
||||
|
||||
52
.github/workflows/randomized_tests.yml
vendored
52
.github/workflows/randomized_tests.yml
vendored
@@ -3,35 +3,35 @@ name: Randomized Tests
|
||||
concurrency: randomized-tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- randomized-tests-runner
|
||||
# schedule:
|
||||
# - cron: '0 * * * *'
|
||||
push:
|
||||
branches:
|
||||
- randomized-tests-runner
|
||||
# schedule:
|
||||
# - cron: '0 * * * *'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
ZED_SERVER_URL: https://zed.dev
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
ZED_SERVER_URL: https://zed.dev
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Run randomized tests
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- randomized-tests
|
||||
steps:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
tests:
|
||||
name: Run randomized tests
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- randomized-tests
|
||||
steps:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Run randomized tests
|
||||
run: script/randomized-test-ci
|
||||
- name: Run randomized tests
|
||||
run: script/randomized-test-ci
|
||||
|
||||
162
.github/workflows/release_nightly.yml
vendored
162
.github/workflows/release_nightly.yml
vendored
@@ -1,98 +1,98 @@
|
||||
name: Release Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Fire every day at 7:00am UTC (Roughly before EU workday and after US workday)
|
||||
- cron: "0 7 * * *"
|
||||
push:
|
||||
tags:
|
||||
- "nightly"
|
||||
schedule:
|
||||
# Fire every day at 7:00am UTC (Roughly before EU workday and after US workday)
|
||||
- cron: "0 7 * * *"
|
||||
push:
|
||||
tags:
|
||||
- "nightly"
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
style:
|
||||
name: Check formatting and Clippy lints
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- test
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
fetch-depth: 0
|
||||
style:
|
||||
name: Check formatting and Clippy lints
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- test
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run style checks
|
||||
uses: ./.github/actions/check_style
|
||||
- name: Run style checks
|
||||
uses: ./.github/actions/check_style
|
||||
|
||||
- name: Run clippy
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: script/clippy
|
||||
tests:
|
||||
name: Run tests
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- test
|
||||
needs: style
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
- name: Run clippy
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: script/clippy
|
||||
tests:
|
||||
name: Run tests
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- test
|
||||
needs: style
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Run tests
|
||||
uses: ./.github/actions/run_tests
|
||||
- name: Run tests
|
||||
uses: ./.github/actions/run_tests
|
||||
|
||||
bundle:
|
||||
name: Bundle app
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- bundle
|
||||
needs: tests
|
||||
env:
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
|
||||
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
|
||||
DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
|
||||
DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
|
||||
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
|
||||
steps:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
bundle:
|
||||
name: Bundle app
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- bundle
|
||||
needs: tests
|
||||
env:
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
|
||||
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
|
||||
DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
|
||||
DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
|
||||
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
|
||||
steps:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Limit target directory size
|
||||
run: script/clear-target-dir-if-larger-than 100
|
||||
- name: Limit target directory size
|
||||
run: script/clear-target-dir-if-larger-than 100
|
||||
|
||||
- name: Set release channel to nightly
|
||||
run: |
|
||||
set -eu
|
||||
version=$(git rev-parse --short HEAD)
|
||||
echo "Publishing version: ${version} on release channel nightly"
|
||||
echo "nightly" > crates/zed/RELEASE_CHANNEL
|
||||
- name: Set release channel to nightly
|
||||
run: |
|
||||
set -eu
|
||||
version=$(git rev-parse --short HEAD)
|
||||
echo "Publishing version: ${version} on release channel nightly"
|
||||
echo "nightly" > crates/zed/RELEASE_CHANNEL
|
||||
|
||||
- name: Generate license file
|
||||
run: script/generate-licenses
|
||||
- name: Generate license file
|
||||
run: script/generate-licenses
|
||||
|
||||
- name: Create app bundle
|
||||
run: script/bundle
|
||||
- name: Create app bundle
|
||||
run: script/bundle
|
||||
|
||||
- name: Upload Zed Nightly
|
||||
run: script/upload-nightly
|
||||
- name: Upload Zed Nightly
|
||||
run: script/upload-nightly
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 */12 * * *"
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 */12 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update_top_ranking_issues:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10.5"
|
||||
architecture: "x64"
|
||||
cache: "pip"
|
||||
- run: pip install -r script/update_top_ranking_issues/requirements.txt
|
||||
- run: python script/update_top_ranking_issues/main.py 5393 --github-token ${{ secrets.GITHUB_TOKEN }} --prod
|
||||
update_top_ranking_issues:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10.5"
|
||||
architecture: "x64"
|
||||
cache: "pip"
|
||||
- run: pip install -r script/update_top_ranking_issues/requirements.txt
|
||||
- run: python script/update_top_ranking_issues/main.py 5393 --github-token ${{ secrets.GITHUB_TOKEN }} --prod
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 15 * * *"
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 15 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update_top_ranking_issues:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10.5"
|
||||
architecture: "x64"
|
||||
cache: "pip"
|
||||
- run: pip install -r script/update_top_ranking_issues/requirements.txt
|
||||
- run: python script/update_top_ranking_issues/main.py 6952 --github-token ${{ secrets.GITHUB_TOKEN }} --prod --query-day-interval 7
|
||||
update_top_ranking_issues:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10.5"
|
||||
architecture: "x64"
|
||||
cache: "pip"
|
||||
- run: pip install -r script/update_top_ranking_issues/requirements.txt
|
||||
- run: python script/update_top_ranking_issues/main.py 6952 --github-token ${{ secrets.GITHUB_TOKEN }} --prod --query-day-interval 7
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -5,12 +5,8 @@
|
||||
.DS_Store
|
||||
/plugins/bin
|
||||
/script/node_modules
|
||||
/styles/node_modules
|
||||
/styles/src/types/zed.ts
|
||||
/crates/theme/schemas/theme.json
|
||||
/crates/collab/static/styles.css
|
||||
/crates/collab/.admins.json
|
||||
/vendor/bin
|
||||
/assets/*licenses.md
|
||||
**/venv
|
||||
.build
|
||||
@@ -25,3 +21,4 @@ DerivedData/
|
||||
**/*.db
|
||||
.pytest_cache
|
||||
.venv
|
||||
.blob_store
|
||||
|
||||
2
.mailmap
2
.mailmap
@@ -11,6 +11,8 @@
|
||||
|
||||
Antonio Scandurra <me@as-cii.com>
|
||||
Antonio Scandurra <me@as-cii.com> <antonio@zed.dev>
|
||||
Christian Bergschneider <christian.bergschneider@gmx.de>
|
||||
Christian Bergschneider <christian.bergschneider@gmx.de> <magiclake@gmx.de>
|
||||
Conrad Irwin <conrad@zed.dev>
|
||||
Conrad Irwin <conrad@zed.dev> <conrad.irwin@gmail.com>
|
||||
Greg Morenz <greg-morenz@droid.cafe>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"JSON": {
|
||||
"tab_size": 4
|
||||
"languages": {
|
||||
"TOML": {
|
||||
"formatter": "prettier",
|
||||
"format_on_save": "off"
|
||||
},
|
||||
"YAML": {
|
||||
"formatter": "prettier"
|
||||
}
|
||||
},
|
||||
"formatter": "auto"
|
||||
}
|
||||
|
||||
1410
Cargo.lock
generated
1410
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
20
Cargo.toml
20
Cargo.toml
@@ -22,6 +22,7 @@ members = [
|
||||
"crates/diagnostics",
|
||||
"crates/editor",
|
||||
"crates/extension",
|
||||
"crates/extensions_ui",
|
||||
"crates/feature_flags",
|
||||
"crates/feedback",
|
||||
"crates/file_finder",
|
||||
@@ -113,6 +114,7 @@ db = { path = "crates/db" }
|
||||
diagnostics = { path = "crates/diagnostics" }
|
||||
editor = { path = "crates/editor" }
|
||||
extension = { path = "crates/extension" }
|
||||
extensions_ui = { path = "crates/extensions_ui" }
|
||||
feature_flags = { path = "crates/feature_flags" }
|
||||
feedback = { path = "crates/feedback" }
|
||||
file_finder = { path = "crates/file_finder" }
|
||||
@@ -177,6 +179,7 @@ zed_actions = { path = "crates/zed_actions" }
|
||||
|
||||
anyhow = "1.0.57"
|
||||
async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
|
||||
async-tar = "0.4.2"
|
||||
async-trait = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
ctor = "0.2.6"
|
||||
@@ -187,10 +190,7 @@ git2 = { version = "0.15", default-features = false }
|
||||
globset = "0.4"
|
||||
indoc = "1"
|
||||
# We explicitly disable a http2 support in isahc.
|
||||
isahc = { version = "1.7.2", default-features = false, features = [
|
||||
"static-curl",
|
||||
"text-decoding",
|
||||
] }
|
||||
isahc = { version = "1.7.2", default-features = false, features = ["static-curl", "text-decoding"] }
|
||||
lazy_static = "1.4.0"
|
||||
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
|
||||
ordered-float = "2.1.1"
|
||||
@@ -205,13 +205,11 @@ regex = "1.5"
|
||||
rusqlite = { version = "0.29.0", features = ["blob", "array", "modern_sqlite"] }
|
||||
rust-embed = { version = "8.0", features = ["include-exclude"] }
|
||||
schemars = "0.8"
|
||||
semver = "1.0"
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
serde_derive = { version = "1.0", features = ["deserialize_in_place"] }
|
||||
serde_json = { version = "1.0", features = ["preserve_order", "raw_value"] }
|
||||
serde_json_lenient = { version = "0.1", features = [
|
||||
"preserve_order",
|
||||
"raw_value",
|
||||
] }
|
||||
serde_json_lenient = { version = "0.1", features = ["preserve_order", "raw_value"] }
|
||||
serde_repr = "0.1"
|
||||
smallvec = { version = "1.6", features = ["union"] }
|
||||
smol = "1.2"
|
||||
@@ -220,14 +218,14 @@ sysinfo = "0.29.10"
|
||||
tempfile = "3.9.0"
|
||||
thiserror = "1.0.29"
|
||||
tiktoken-rs = "0.5.7"
|
||||
time = { version = "0.3", features = ["serde", "serde-well-known"] }
|
||||
time = { version = "0.3", features = ["serde", "serde-well-known", "formatting"] }
|
||||
toml = "0.5"
|
||||
tree-sitter = { version = "0.20", features = ["wasm"] }
|
||||
tree-sitter-astro = { git = "https://github.com/virchau13/tree-sitter-astro.git", rev = "e924787e12e8a03194f36a113290ac11d6dc10f3" }
|
||||
tree-sitter-bash = { git = "https://github.com/tree-sitter/tree-sitter-bash", rev = "7331995b19b8f8aba2d5e26deb51d2195c18bc94" }
|
||||
tree-sitter-beancount = { git = "https://github.com/polarmutex/tree-sitter-beancount", rev = "da1bf8c6eb0ae7a97588affde7227630bcd678b6" }
|
||||
tree-sitter-c = "0.20.1"
|
||||
tree-sitter-clojure = { git = "https://github.com/prcastro/tree-sitter-clojure", branch = "update-ts"}
|
||||
tree-sitter-clojure = { git = "https://github.com/prcastro/tree-sitter-clojure", branch = "update-ts" }
|
||||
tree-sitter-c-sharp = { git = "https://github.com/tree-sitter/tree-sitter-c-sharp", rev = "dd5e59721a5f8dae34604060833902b882023aaf" }
|
||||
tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "f44509141e7e483323d2ec178f2d2e6c0fc041c1" }
|
||||
tree-sitter-css = { git = "https://github.com/tree-sitter/tree-sitter-css", rev = "769203d0f9abe1a9a691ac2b9fe4bb4397a73c51" }
|
||||
@@ -275,6 +273,8 @@ wasmtime = "16"
|
||||
[patch.crates-io]
|
||||
tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "1d8975319c2d5de1bf710e7e21db25b0eee4bc66" }
|
||||
wasmtime = { git = "https://github.com/bytecodealliance/wasmtime", rev = "v16.0.0" }
|
||||
# Workaround for a broken nightly build of gpui: See #7644 and revisit once 0.5.3 is released.
|
||||
pathfinder_simd = { git = "https://github.com/servo/pathfinder.git", rev = "e4fcda0d5259d0acf902aee6de7d2501f2bd6629" }
|
||||
|
||||
[profile.dev]
|
||||
split-debuginfo = "unpacked"
|
||||
|
||||
@@ -11,6 +11,7 @@ ARG GITHUB_SHA
|
||||
ENV GITHUB_SHA=$GITHUB_SHA
|
||||
RUN --mount=type=cache,target=./script/node_modules \
|
||||
--mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=./target \
|
||||
cargo build --release --package collab --bin collab
|
||||
|
||||
|
||||
3
Procfile
3
Procfile
@@ -1,2 +1,3 @@
|
||||
collab: cd crates/collab && RUST_LOG=${RUST_LOG:-warn,collab=info} cargo run serve
|
||||
collab: RUST_LOG=${RUST_LOG:-warn,collab=info} cargo run --package=collab serve
|
||||
livekit: livekit-server --dev
|
||||
blob_store: MINIO_ROOT_USER=the-blob-store-access-key MINIO_ROOT_PASSWORD=the-blob-store-secret-key minio server .blob_store
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
"bmp": "image",
|
||||
"c": "code",
|
||||
"cc": "code",
|
||||
"cjs": "code",
|
||||
"conf": "settings",
|
||||
"cpp": "code",
|
||||
"css": "css",
|
||||
"csv": "storage",
|
||||
"cts": "typescript",
|
||||
"dat": "storage",
|
||||
"db": "storage",
|
||||
"dbf": "storage",
|
||||
@@ -80,12 +82,14 @@
|
||||
"mdf": "storage",
|
||||
"mdx": "document",
|
||||
"mkv": "video",
|
||||
"mjs": "code",
|
||||
"mka": "audio",
|
||||
"ml": "ocaml",
|
||||
"mli": "ocaml",
|
||||
"mov": "video",
|
||||
"mp3": "audio",
|
||||
"mp4": "video",
|
||||
"mts": "typescript",
|
||||
"myd": "storage",
|
||||
"myi": "storage",
|
||||
"odp": "document",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"ctrl-e": "vim::LineDown",
|
||||
"ctrl-y": "vim::LineUp",
|
||||
// "g" commands
|
||||
"g e": "vim::PreviousWordEnd",
|
||||
"g shift-e": ["vim::PreviousWordEnd", { "ignorePunctuation": true }],
|
||||
"g g": "vim::StartOfDocument",
|
||||
"g h": "editor::Hover",
|
||||
"g t": "pane::ActivateNextItem",
|
||||
@@ -485,7 +487,9 @@
|
||||
"ctrl-x ctrl-a": "assistant::InlineAssist", // zed specific
|
||||
"ctrl-x ctrl-c": "copilot::Suggest", // zed specific
|
||||
"ctrl-x ctrl-l": "editor::ToggleCodeActions", // zed specific
|
||||
"ctrl-x ctrl-z": "editor::Cancel"
|
||||
"ctrl-x ctrl-z": "editor::Cancel",
|
||||
"ctrl-w": "editor::DeleteToPreviousWordStart",
|
||||
"ctrl-u": "editor::DeleteToBeginningOfLine"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -503,5 +507,33 @@
|
||||
"enter": "vim::SearchSubmit",
|
||||
"escape": "buffer_search::Dismiss"
|
||||
}
|
||||
},
|
||||
{
|
||||
// Directory expansion
|
||||
"context": "ProjectPanel && not_editing",
|
||||
"bindings": {
|
||||
"escape": "project_panel::ToggleFocus",
|
||||
"enter": "project_panel::Open",
|
||||
"o": "project_panel::Open",
|
||||
"t": "project_panel::Open",
|
||||
"v": "project_panel::Open",
|
||||
"d": "project_panel::NewDirectory",
|
||||
"%": "project_panel::NewFile",
|
||||
"shift-r": "project_panel::Rename",
|
||||
"m m": "project_panel::Cut",
|
||||
"m c": "project_panel::Copy",
|
||||
"m t": "project_panel::Paste",
|
||||
"x": "project_panel::RevealInFinder",
|
||||
"l": "project_panel::ExpandSelectedEntry",
|
||||
"h": "project_panel::CollapseSelectedEntry",
|
||||
// Move up and down
|
||||
"j": "menu::SelectNext",
|
||||
"k": "menu::SelectPrev",
|
||||
"shift-d": "project_panel::Delete",
|
||||
"/": "project_panel::NewSearchInDirectory",
|
||||
// zed specific
|
||||
"q p": "project_panel::CopyPath",
|
||||
"q r": "project_panel::CopyRelativePath"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -104,8 +104,10 @@
|
||||
"show_whitespaces": "selection",
|
||||
// Settings related to calls in Zed
|
||||
"calls": {
|
||||
// Join calls with the microphone muted by default
|
||||
"mute_on_join": false
|
||||
// Join calls with the microphone live by default
|
||||
"mute_on_join": false,
|
||||
// Share your project when you are the first to join a channel
|
||||
"share_on_join": true
|
||||
},
|
||||
// Toolbar related settings
|
||||
"toolbar": {
|
||||
@@ -480,6 +482,7 @@
|
||||
"deno": {
|
||||
"enable": false
|
||||
},
|
||||
"code_actions_on_format": {},
|
||||
// Different settings for specific languages.
|
||||
"languages": {
|
||||
"Plain Text": {
|
||||
@@ -490,7 +493,10 @@
|
||||
},
|
||||
"Go": {
|
||||
"tab_size": 4,
|
||||
"hard_tabs": true
|
||||
"hard_tabs": true,
|
||||
"code_actions_on_format": {
|
||||
"source.organizeImports": true
|
||||
}
|
||||
},
|
||||
"Markdown": {
|
||||
"soft_wrap": "preferred_line_length"
|
||||
|
||||
@@ -84,7 +84,6 @@ pub struct ActiveCall {
|
||||
),
|
||||
client: Arc<Client>,
|
||||
user_store: Model<UserStore>,
|
||||
pending_channel_id: Option<u64>,
|
||||
_subscriptions: Vec<client::Subscription>,
|
||||
}
|
||||
|
||||
@@ -98,7 +97,6 @@ impl ActiveCall {
|
||||
location: None,
|
||||
pending_invites: Default::default(),
|
||||
incoming_call: watch::channel(),
|
||||
pending_channel_id: None,
|
||||
_join_debouncer: OneAtATime { cancel: None },
|
||||
_subscriptions: vec![
|
||||
client.add_request_handler(cx.weak_model(), Self::handle_incoming_call),
|
||||
@@ -113,10 +111,6 @@ impl ActiveCall {
|
||||
self.room()?.read(cx).channel_id()
|
||||
}
|
||||
|
||||
pub fn pending_channel_id(&self) -> Option<u64> {
|
||||
self.pending_channel_id
|
||||
}
|
||||
|
||||
async fn handle_incoming_call(
|
||||
this: Model<Self>,
|
||||
envelope: TypedEnvelope<proto::IncomingCall>,
|
||||
@@ -345,13 +339,11 @@ impl ActiveCall {
|
||||
channel_id: u64,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<Option<Model<Room>>>> {
|
||||
let mut leave = None;
|
||||
if let Some(room) = self.room().cloned() {
|
||||
if room.read(cx).channel_id() == Some(channel_id) {
|
||||
return Task::ready(Ok(Some(room)));
|
||||
} else {
|
||||
let (room, _) = self.room.take().unwrap();
|
||||
leave = room.update(cx, |room, cx| Some(room.leave(cx)));
|
||||
room.update(cx, |room, cx| room.clear_state(cx));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,21 +353,14 @@ impl ActiveCall {
|
||||
|
||||
let client = self.client.clone();
|
||||
let user_store = self.user_store.clone();
|
||||
self.pending_channel_id = Some(channel_id);
|
||||
let join = self._join_debouncer.spawn(cx, move |cx| async move {
|
||||
if let Some(task) = leave {
|
||||
task.await?
|
||||
}
|
||||
Room::join_channel(channel_id, client, user_store, cx).await
|
||||
});
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let room = join.await?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.pending_channel_id.take();
|
||||
this.set_room(room.clone(), cx)
|
||||
})?
|
||||
.await?;
|
||||
this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))?
|
||||
.await?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.report_call_event("join channel", cx)
|
||||
})?;
|
||||
|
||||
@@ -7,6 +7,7 @@ use settings::Settings;
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CallSettings {
|
||||
pub mute_on_join: bool,
|
||||
pub share_on_join: bool,
|
||||
}
|
||||
|
||||
/// Configuration of voice calls in Zed.
|
||||
@@ -16,6 +17,11 @@ pub struct CallSettingsContent {
|
||||
///
|
||||
/// Default: false
|
||||
pub mute_on_join: Option<bool>,
|
||||
|
||||
/// Whether your current project should be shared when joining an empty channel.
|
||||
///
|
||||
/// Default: true
|
||||
pub share_on_join: Option<bool>,
|
||||
}
|
||||
|
||||
impl Settings for CallSettings {
|
||||
|
||||
@@ -49,7 +49,6 @@ pub struct RemoteParticipant {
|
||||
pub participant_index: ParticipantIndex,
|
||||
pub muted: bool,
|
||||
pub speaking: bool,
|
||||
pub in_call: bool,
|
||||
pub video_tracks: HashMap<live_kit_client::Sid, Arc<RemoteVideoTrack>>,
|
||||
pub audio_tracks: HashMap<live_kit_client::Sid, Arc<RemoteAudioTrack>>,
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ pub struct Room {
|
||||
id: u64,
|
||||
channel_id: Option<u64>,
|
||||
live_kit: Option<LiveKitRoom>,
|
||||
live_kit_connection_info: Option<proto::LiveKitConnectionInfo>,
|
||||
status: RoomStatus,
|
||||
shared_projects: HashSet<WeakModel<Project>>,
|
||||
joined_projects: HashSet<WeakModel<Project>>,
|
||||
@@ -113,18 +112,91 @@ impl Room {
|
||||
user_store: Model<UserStore>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let live_kit_room = if let Some(connection_info) = live_kit_connection_info {
|
||||
let room = live_kit_client::Room::new();
|
||||
let mut status = room.status();
|
||||
// Consume the initial status of the room.
|
||||
let _ = status.try_recv();
|
||||
let _maintain_room = cx.spawn(|this, mut cx| async move {
|
||||
while let Some(status) = status.next().await {
|
||||
let this = if let Some(this) = this.upgrade() {
|
||||
this
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
|
||||
if status == live_kit_client::ConnectionState::Disconnected {
|
||||
this.update(&mut cx, |this, cx| this.leave(cx).log_err())
|
||||
.ok();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _handle_updates = cx.spawn({
|
||||
let room = room.clone();
|
||||
move |this, mut cx| async move {
|
||||
let mut updates = room.updates();
|
||||
while let Some(update) = updates.next().await {
|
||||
let this = if let Some(this) = this.upgrade() {
|
||||
this
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.live_kit_room_updated(update, cx).log_err()
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let connect = room.connect(&connection_info.server_url, &connection_info.token);
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
connect.await?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
if !this.read_only() {
|
||||
if let Some(live_kit) = &this.live_kit {
|
||||
if !live_kit.muted_by_user && !live_kit.deafened {
|
||||
return this.share_microphone(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::ready(Ok(()))
|
||||
})?
|
||||
.await
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
Some(LiveKitRoom {
|
||||
room,
|
||||
screen_track: LocalTrack::None,
|
||||
microphone_track: LocalTrack::None,
|
||||
next_publish_id: 0,
|
||||
muted_by_user: Self::mute_on_join(cx),
|
||||
deafened: false,
|
||||
speaking: false,
|
||||
_maintain_room,
|
||||
_handle_updates,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let maintain_connection = cx.spawn({
|
||||
let client = client.clone();
|
||||
move |this, cx| Self::maintain_connection(this, client.clone(), cx).log_err()
|
||||
});
|
||||
|
||||
Audio::play_sound(Sound::Joined, cx);
|
||||
|
||||
let (room_update_completed_tx, room_update_completed_rx) = watch::channel();
|
||||
|
||||
let mut this = Self {
|
||||
Self {
|
||||
id,
|
||||
channel_id,
|
||||
live_kit: None,
|
||||
live_kit_connection_info,
|
||||
live_kit: live_kit_room,
|
||||
status: RoomStatus::Online,
|
||||
shared_projects: Default::default(),
|
||||
joined_projects: Default::default(),
|
||||
@@ -148,11 +220,7 @@ impl Room {
|
||||
maintain_connection: Some(maintain_connection),
|
||||
room_update_completed_tx,
|
||||
room_update_completed_rx,
|
||||
};
|
||||
if this.live_kit_connection_info.is_some() {
|
||||
this.join_call(cx).detach_and_log_err(cx);
|
||||
}
|
||||
this
|
||||
}
|
||||
|
||||
pub(crate) fn create(
|
||||
@@ -211,7 +279,7 @@ impl Room {
|
||||
cx: AsyncAppContext,
|
||||
) -> Result<Model<Self>> {
|
||||
Self::from_join_response(
|
||||
client.request(proto::JoinChannel2 { channel_id }).await?,
|
||||
client.request(proto::JoinChannel { channel_id }).await?,
|
||||
client,
|
||||
user_store,
|
||||
cx,
|
||||
@@ -256,7 +324,7 @@ impl Room {
|
||||
}
|
||||
|
||||
pub fn mute_on_join(cx: &AppContext) -> bool {
|
||||
CallSettings::get_global(cx).mute_on_join
|
||||
CallSettings::get_global(cx).mute_on_join || client::IMPERSONATE_LOGIN.is_some()
|
||||
}
|
||||
|
||||
fn from_join_response(
|
||||
@@ -306,9 +374,7 @@ impl Room {
|
||||
}
|
||||
|
||||
log::info!("leaving room");
|
||||
if self.live_kit.is_some() {
|
||||
Audio::play_sound(Sound::Leave, cx);
|
||||
}
|
||||
Audio::play_sound(Sound::Leave, cx);
|
||||
|
||||
self.clear_state(cx);
|
||||
|
||||
@@ -527,24 +593,6 @@ impl Room {
|
||||
&self.remote_participants
|
||||
}
|
||||
|
||||
pub fn call_participants(&self, cx: &AppContext) -> Vec<Arc<User>> {
|
||||
self.remote_participants()
|
||||
.values()
|
||||
.filter_map(|participant| {
|
||||
if participant.in_call {
|
||||
Some(participant.user.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.chain(if self.in_call() {
|
||||
self.user_store.read(cx).current_user()
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn remote_participant_for_peer_id(&self, peer_id: PeerId) -> Option<&RemoteParticipant> {
|
||||
self.remote_participants
|
||||
.values()
|
||||
@@ -569,6 +617,10 @@ impl Room {
|
||||
self.local_participant.role == proto::ChannelRole::Admin
|
||||
}
|
||||
|
||||
pub fn local_participant_is_guest(&self) -> bool {
|
||||
self.local_participant.role == proto::ChannelRole::Guest
|
||||
}
|
||||
|
||||
pub fn set_participant_role(
|
||||
&mut self,
|
||||
user_id: u64,
|
||||
@@ -776,7 +828,6 @@ impl Room {
|
||||
}
|
||||
|
||||
let role = participant.role();
|
||||
let in_call = participant.in_call;
|
||||
let location = ParticipantLocation::from_proto(participant.location)
|
||||
.unwrap_or(ParticipantLocation::External);
|
||||
if let Some(remote_participant) =
|
||||
@@ -787,15 +838,9 @@ impl Room {
|
||||
remote_participant.participant_index = participant_index;
|
||||
if location != remote_participant.location
|
||||
|| role != remote_participant.role
|
||||
|| in_call != remote_participant.in_call
|
||||
{
|
||||
if in_call && !remote_participant.in_call {
|
||||
Audio::play_sound(Sound::Joined, cx);
|
||||
}
|
||||
remote_participant.location = location;
|
||||
remote_participant.role = role;
|
||||
remote_participant.in_call = participant.in_call;
|
||||
|
||||
cx.emit(Event::ParticipantLocationChanged {
|
||||
participant_id: peer_id,
|
||||
});
|
||||
@@ -812,15 +857,12 @@ impl Room {
|
||||
role,
|
||||
muted: true,
|
||||
speaking: false,
|
||||
in_call: participant.in_call,
|
||||
video_tracks: Default::default(),
|
||||
audio_tracks: Default::default(),
|
||||
},
|
||||
);
|
||||
|
||||
if participant.in_call {
|
||||
Audio::play_sound(Sound::Joined, cx);
|
||||
}
|
||||
Audio::play_sound(Sound::Joined, cx);
|
||||
|
||||
if let Some(live_kit) = this.live_kit.as_ref() {
|
||||
let video_tracks =
|
||||
@@ -1009,6 +1051,15 @@ impl Room {
|
||||
}
|
||||
|
||||
RoomUpdate::SubscribedToRemoteAudioTrack(track, publication) => {
|
||||
if let Some(live_kit) = &self.live_kit {
|
||||
if live_kit.deafened {
|
||||
track.stop();
|
||||
cx.foreground_executor()
|
||||
.spawn(publication.set_enabled(false))
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
let user_id = track.publisher_id().parse()?;
|
||||
let track_id = track.sid().to_string();
|
||||
let participant = self
|
||||
@@ -1155,7 +1206,7 @@ impl Room {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn share_project(
|
||||
pub fn share_project(
|
||||
&mut self,
|
||||
project: Model<Project>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
@@ -1257,14 +1308,18 @@ impl Room {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_sharing_mic(&self) -> bool {
|
||||
self.live_kit.as_ref().map_or(false, |live_kit| {
|
||||
!matches!(live_kit.microphone_track, LocalTrack::None)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_muted(&self) -> bool {
|
||||
self.live_kit
|
||||
.as_ref()
|
||||
.map_or(true, |live_kit| match &live_kit.microphone_track {
|
||||
LocalTrack::None => true,
|
||||
LocalTrack::Pending { .. } => true,
|
||||
LocalTrack::Published { track_publication } => track_publication.is_muted(),
|
||||
})
|
||||
self.live_kit.as_ref().map_or(false, |live_kit| {
|
||||
matches!(live_kit.microphone_track, LocalTrack::None)
|
||||
|| live_kit.muted_by_user
|
||||
|| live_kit.deafened
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_only(&self) -> bool {
|
||||
@@ -1278,8 +1333,8 @@ impl Room {
|
||||
.map_or(false, |live_kit| live_kit.speaking)
|
||||
}
|
||||
|
||||
pub fn in_call(&self) -> bool {
|
||||
self.live_kit.is_some()
|
||||
pub fn is_deafened(&self) -> Option<bool> {
|
||||
self.live_kit.as_ref().map(|live_kit| live_kit.deafened)
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
@@ -1332,8 +1387,12 @@ impl Room {
|
||||
Ok(publication) => {
|
||||
if canceled {
|
||||
live_kit.room.unpublish_track(publication);
|
||||
live_kit.microphone_track = LocalTrack::None;
|
||||
} else {
|
||||
if live_kit.muted_by_user || live_kit.deafened {
|
||||
cx.background_executor()
|
||||
.spawn(publication.set_mute(true))
|
||||
.detach();
|
||||
}
|
||||
live_kit.microphone_track = LocalTrack::Published {
|
||||
track_publication: publication,
|
||||
};
|
||||
@@ -1437,140 +1496,50 @@ impl Room {
|
||||
}
|
||||
|
||||
pub fn toggle_mute(&mut self, cx: &mut ModelContext<Self>) {
|
||||
let muted = !self.is_muted();
|
||||
if let Some(task) = self.set_mute(muted, cx) {
|
||||
task.detach_and_log_err(cx);
|
||||
if let Some(live_kit) = self.live_kit.as_mut() {
|
||||
// When unmuting, undeafen if the user was deafened before.
|
||||
let was_deafened = live_kit.deafened;
|
||||
if live_kit.muted_by_user
|
||||
|| live_kit.deafened
|
||||
|| matches!(live_kit.microphone_track, LocalTrack::None)
|
||||
{
|
||||
live_kit.muted_by_user = false;
|
||||
live_kit.deafened = false;
|
||||
} else {
|
||||
live_kit.muted_by_user = true;
|
||||
}
|
||||
let muted = live_kit.muted_by_user;
|
||||
let should_undeafen = was_deafened && !live_kit.deafened;
|
||||
|
||||
if let Some(task) = self.set_mute(muted, cx) {
|
||||
task.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
if should_undeafen {
|
||||
if let Some(task) = self.set_deafened(false, cx) {
|
||||
task.detach_and_log_err(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn join_call(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
|
||||
if self.live_kit.is_some() {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
pub fn toggle_deafen(&mut self, cx: &mut ModelContext<Self>) {
|
||||
if let Some(live_kit) = self.live_kit.as_mut() {
|
||||
// When deafening, mute the microphone if it was not already muted.
|
||||
// When un-deafening, unmute the microphone, unless it was explicitly muted.
|
||||
let deafened = !live_kit.deafened;
|
||||
live_kit.deafened = deafened;
|
||||
let should_change_mute = !live_kit.muted_by_user;
|
||||
|
||||
let room = live_kit_client::Room::new();
|
||||
let mut status = room.status();
|
||||
// Consume the initial status of the room.
|
||||
let _ = status.try_recv();
|
||||
let _maintain_room = cx.spawn(|this, mut cx| async move {
|
||||
while let Some(status) = status.next().await {
|
||||
let this = if let Some(this) = this.upgrade() {
|
||||
this
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
if let Some(task) = self.set_deafened(deafened, cx) {
|
||||
task.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
if status == live_kit_client::ConnectionState::Disconnected {
|
||||
this.update(&mut cx, |this, cx| this.leave(cx).log_err())
|
||||
.ok();
|
||||
break;
|
||||
if should_change_mute {
|
||||
if let Some(task) = self.set_mute(deafened, cx) {
|
||||
task.detach_and_log_err(cx);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _handle_updates = cx.spawn({
|
||||
let room = room.clone();
|
||||
move |this, mut cx| async move {
|
||||
let mut updates = room.updates();
|
||||
while let Some(update) = updates.next().await {
|
||||
let this = if let Some(this) = this.upgrade() {
|
||||
this
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.live_kit_room_updated(update, cx).log_err()
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
self.live_kit = Some(LiveKitRoom {
|
||||
room: room.clone(),
|
||||
screen_track: LocalTrack::None,
|
||||
microphone_track: LocalTrack::None,
|
||||
next_publish_id: 0,
|
||||
speaking: false,
|
||||
_maintain_room,
|
||||
_handle_updates,
|
||||
});
|
||||
|
||||
cx.spawn({
|
||||
let client = self.client.clone();
|
||||
let share_microphone = !self.read_only() && !Self::mute_on_join(cx);
|
||||
let connection_info = self.live_kit_connection_info.clone();
|
||||
let channel_id = self.channel_id;
|
||||
|
||||
move |this, mut cx| async move {
|
||||
let connection_info = if let Some(connection_info) = connection_info {
|
||||
connection_info.clone()
|
||||
} else if let Some(channel_id) = channel_id {
|
||||
if let Some(connection_info) = client
|
||||
.request(proto::JoinChannelCall { channel_id })
|
||||
.await?
|
||||
.live_kit_connection_info
|
||||
{
|
||||
connection_info
|
||||
} else {
|
||||
return Err(anyhow!("failed to get connection info from server"));
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"tried to connect to livekit without connection info"
|
||||
));
|
||||
};
|
||||
room.connect(&connection_info.server_url, &connection_info.token)
|
||||
.await?;
|
||||
|
||||
let track_updates = this.update(&mut cx, |this, cx| {
|
||||
Audio::play_sound(Sound::Joined, cx);
|
||||
let Some(live_kit) = this.live_kit.as_mut() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let mut track_updates = Vec::new();
|
||||
for participant in this.remote_participants.values() {
|
||||
for publication in live_kit
|
||||
.room
|
||||
.remote_audio_track_publications(&participant.user.id.to_string())
|
||||
{
|
||||
track_updates.push(publication.set_enabled(true));
|
||||
}
|
||||
|
||||
for track in participant.audio_tracks.values() {
|
||||
track.start();
|
||||
}
|
||||
}
|
||||
track_updates
|
||||
})?;
|
||||
|
||||
if share_microphone {
|
||||
this.update(&mut cx, |this, cx| this.share_microphone(cx))?
|
||||
.await?
|
||||
};
|
||||
|
||||
for result in futures::future::join_all(track_updates).await {
|
||||
result?;
|
||||
}
|
||||
anyhow::Ok(())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn leave_call(&mut self, cx: &mut ModelContext<Self>) {
|
||||
Audio::play_sound(Sound::Leave, cx);
|
||||
if let Some(channel_id) = self.channel_id() {
|
||||
let client = self.client.clone();
|
||||
cx.background_executor()
|
||||
.spawn(client.request(proto::LeaveChannelCall { channel_id }))
|
||||
.detach_and_log_err(cx);
|
||||
self.live_kit.take();
|
||||
self.live_kit_connection_info.take();
|
||||
cx.notify();
|
||||
} else {
|
||||
self.leave(cx).detach_and_log_err(cx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1601,6 +1570,40 @@ 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,
|
||||
@@ -1645,6 +1648,9 @@ struct LiveKitRoom {
|
||||
room: Arc<live_kit_client::Room>,
|
||||
screen_track: LocalTrack,
|
||||
microphone_track: LocalTrack,
|
||||
/// Tracks whether we're currently in a muted state due to auto-mute from deafening or manual mute performed by user.
|
||||
muted_by_user: bool,
|
||||
deafened: bool,
|
||||
speaking: bool,
|
||||
next_publish_id: usize,
|
||||
_maintain_room: Task<()>,
|
||||
|
||||
@@ -7,6 +7,11 @@ ZED_ENVIRONMENT = "development"
|
||||
LIVE_KIT_SERVER = "http://localhost:7880"
|
||||
LIVE_KIT_KEY = "devkey"
|
||||
LIVE_KIT_SECRET = "secret"
|
||||
BLOB_STORE_ACCESS_KEY = "the-blob-store-access-key"
|
||||
BLOB_STORE_SECRET_KEY = "the-blob-store-secret-key"
|
||||
BLOB_STORE_BUCKET = "the-extensions-bucket"
|
||||
BLOB_STORE_URL = "http://127.0.0.1:9000"
|
||||
BLOB_STORE_REGION = "the-region"
|
||||
|
||||
# RUST_LOG=info
|
||||
# LOG_JSON=true
|
||||
|
||||
@@ -17,6 +17,8 @@ required-features = ["seed-support"]
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-tungstenite = "0.16"
|
||||
aws-config = { version = "1.1.5" }
|
||||
aws-sdk-s3 = { version = "1.15.0" }
|
||||
axum = { version = "0.5", features = ["json", "headers", "ws"] }
|
||||
axum-extra = { version = "0.3", features = ["erased-json"] }
|
||||
base64 = "0.13"
|
||||
@@ -41,6 +43,7 @@ reqwest = { version = "0.11", features = ["json"], optional = true }
|
||||
rpc.workspace = true
|
||||
scrypt = "0.7"
|
||||
sea-orm = { version = "0.12.x", features = ["sqlx-postgres", "postgres-array", "runtime-tokio-rustls", "with-uuid"] }
|
||||
semver.workspace = true
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -61,7 +64,6 @@ util.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
release_channel.workspace = true
|
||||
async-trait.workspace = true
|
||||
audio.workspace = true
|
||||
call = { workspace = true, features = ["test-support"] }
|
||||
@@ -86,6 +88,7 @@ node_runtime.workspace = true
|
||||
notifications = { workspace = true, features = ["test-support"] }
|
||||
pretty_assertions.workspace = true
|
||||
project = { workspace = true, features = ["test-support"] }
|
||||
release_channel.workspace = true
|
||||
rpc = { workspace = true, features = ["test-support"] }
|
||||
sea-orm = { version = "0.12.x", features = ["sqlx-sqlite"] }
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -105,6 +105,31 @@ spec:
|
||||
secretKeyRef:
|
||||
name: livekit
|
||||
key: secret
|
||||
- name: BLOB_STORE_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: blob-store
|
||||
key: access_key
|
||||
- name: BLOB_STORE_SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: blob-store
|
||||
key: secret_key
|
||||
- name: BLOB_STORE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: blob-store
|
||||
key: url
|
||||
- name: BLOB_STORE_REGION
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: blob-store
|
||||
key: region
|
||||
- name: BLOB_STORE_BUCKET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: blob-store
|
||||
key: bucket
|
||||
- name: INVITE_LINK_PREFIX
|
||||
value: ${INVITE_LINK_PREFIX}
|
||||
- name: RUST_BACKTRACE
|
||||
|
||||
@@ -353,3 +353,25 @@ CREATE TABLE contributors (
|
||||
signed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE extensions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
external_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
latest_version TEXT NOT NULL,
|
||||
total_download_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE extension_versions (
|
||||
extension_id INTEGER REFERENCES extensions(id),
|
||||
version TEXT NOT NULL,
|
||||
published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
authors TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
download_count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (extension_id, version)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "index_extensions_external_id" ON "extensions" ("external_id");
|
||||
CREATE INDEX "index_extensions_total_download_count" ON "extensions" ("total_download_count");
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Add migration script here
|
||||
ALTER TABLE rooms DROP COLUMN enviroment;
|
||||
ALTER TABLE rooms DROP COLUMN environment;
|
||||
ALTER TABLE room_participants DROP COLUMN in_call;
|
||||
22
crates/collab/migrations/20240214102900_add_extensions.sql
Normal file
22
crates/collab/migrations/20240214102900_add_extensions.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
external_id TEXT NOT NULL,
|
||||
latest_version TEXT NOT NULL,
|
||||
total_download_count BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extension_versions (
|
||||
extension_id INTEGER REFERENCES extensions(id),
|
||||
version TEXT NOT NULL,
|
||||
published_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
authors TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
download_count BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(extension_id, version)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "index_extensions_external_id" ON "extensions" ("external_id");
|
||||
CREATE INDEX "trigram_index_extensions_name" ON "extensions" USING GIN(name gin_trgm_ops);
|
||||
CREATE INDEX "index_extensions_total_download_count" ON "extensions" ("total_download_count");
|
||||
@@ -1,3 +1,5 @@
|
||||
mod extensions;
|
||||
|
||||
use crate::{
|
||||
auth,
|
||||
db::{ContributorSelector, User, UserId},
|
||||
@@ -20,6 +22,8 @@ use std::sync::Arc;
|
||||
use tower::ServiceBuilder;
|
||||
use tracing::instrument;
|
||||
|
||||
pub use extensions::fetch_extensions_from_blob_store_periodically;
|
||||
|
||||
pub fn routes(rpc_server: Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body> {
|
||||
Router::new()
|
||||
.route("/user", get(get_authenticated_user))
|
||||
@@ -28,6 +32,7 @@ pub fn routes(rpc_server: Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body
|
||||
.route("/rpc_server_snapshot", get(get_rpc_server_snapshot))
|
||||
.route("/contributors", get(get_contributors).post(add_contributor))
|
||||
.route("/contributor", get(check_is_contributor))
|
||||
.merge(extensions::router())
|
||||
.layer(
|
||||
ServiceBuilder::new()
|
||||
.layer(Extension(state))
|
||||
|
||||
237
crates/collab/src/api/extensions.rs
Normal file
237
crates/collab/src/api/extensions.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
use crate::{
|
||||
db::{ExtensionMetadata, NewExtensionVersion},
|
||||
executor::Executor,
|
||||
AppState, Error, Result,
|
||||
};
|
||||
use anyhow::{anyhow, Context as _};
|
||||
use aws_sdk_s3::presigning::PresigningConfig;
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
response::Redirect,
|
||||
routing::get,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use collections::HashMap;
|
||||
use hyper::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use time::PrimitiveDateTime;
|
||||
use util::ResultExt;
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/extensions", get(get_extensions))
|
||||
.route(
|
||||
"/extensions/:extension_id/:version/download",
|
||||
get(download_extension),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GetExtensionsParams {
|
||||
filter: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DownloadExtensionParams {
|
||||
extension_id: String,
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GetExtensionsResponse {
|
||||
pub data: Vec<ExtensionMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExtensionManifest {
|
||||
name: String,
|
||||
version: String,
|
||||
description: Option<String>,
|
||||
authors: Vec<String>,
|
||||
repository: String,
|
||||
}
|
||||
|
||||
async fn get_extensions(
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
Query(params): Query<GetExtensionsParams>,
|
||||
) -> Result<Json<GetExtensionsResponse>> {
|
||||
let extensions = app.db.get_extensions(params.filter.as_deref(), 30).await?;
|
||||
Ok(Json(GetExtensionsResponse { data: extensions }))
|
||||
}
|
||||
|
||||
async fn download_extension(
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
Path(params): Path<DownloadExtensionParams>,
|
||||
) -> Result<Redirect> {
|
||||
let Some((blob_store_client, bucket)) = app
|
||||
.blob_store_client
|
||||
.clone()
|
||||
.zip(app.config.blob_store_bucket.clone())
|
||||
else {
|
||||
Err(Error::Http(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"not supported".into(),
|
||||
))?
|
||||
};
|
||||
|
||||
let DownloadExtensionParams {
|
||||
extension_id,
|
||||
version,
|
||||
} = params;
|
||||
|
||||
let version_exists = app
|
||||
.db
|
||||
.record_extension_download(&extension_id, &version)
|
||||
.await?;
|
||||
|
||||
if !version_exists {
|
||||
Err(Error::Http(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown extension version".into(),
|
||||
))?;
|
||||
}
|
||||
|
||||
let url = blob_store_client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(format!(
|
||||
"extensions/{extension_id}/{version}/archive.tar.gz"
|
||||
))
|
||||
.presigned(PresigningConfig::expires_in(EXTENSION_DOWNLOAD_URL_LIFETIME).unwrap())
|
||||
.await
|
||||
.map_err(|e| anyhow!("failed to create presigned extension download url {e}"))?;
|
||||
|
||||
Ok(Redirect::temporary(url.uri()))
|
||||
}
|
||||
|
||||
const EXTENSION_FETCH_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
const EXTENSION_DOWNLOAD_URL_LIFETIME: Duration = Duration::from_secs(3 * 60);
|
||||
|
||||
pub fn fetch_extensions_from_blob_store_periodically(app_state: Arc<AppState>, executor: Executor) {
|
||||
let Some(blob_store_client) = app_state.blob_store_client.clone() else {
|
||||
log::info!("no blob store client");
|
||||
return;
|
||||
};
|
||||
let Some(blob_store_bucket) = app_state.config.blob_store_bucket.clone() else {
|
||||
log::info!("no blob store bucket");
|
||||
return;
|
||||
};
|
||||
|
||||
executor.spawn_detached({
|
||||
let executor = executor.clone();
|
||||
async move {
|
||||
loop {
|
||||
fetch_extensions_from_blob_store(
|
||||
&blob_store_client,
|
||||
&blob_store_bucket,
|
||||
&app_state,
|
||||
)
|
||||
.await
|
||||
.log_err();
|
||||
executor.sleep(EXTENSION_FETCH_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn fetch_extensions_from_blob_store(
|
||||
blob_store_client: &aws_sdk_s3::Client,
|
||||
blob_store_bucket: &String,
|
||||
app_state: &Arc<AppState>,
|
||||
) -> anyhow::Result<()> {
|
||||
let list = blob_store_client
|
||||
.list_objects()
|
||||
.bucket(blob_store_bucket)
|
||||
.prefix("extensions/")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let objects = list
|
||||
.contents
|
||||
.ok_or_else(|| anyhow!("missing bucket contents"))?;
|
||||
|
||||
let mut published_versions = HashMap::<&str, Vec<&str>>::default();
|
||||
for object in &objects {
|
||||
let Some(key) = object.key.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let mut parts = key.split('/');
|
||||
let Some(_) = parts.next().filter(|part| *part == "extensions") else {
|
||||
continue;
|
||||
};
|
||||
let Some(extension_id) = parts.next() else {
|
||||
continue;
|
||||
};
|
||||
let Some(version) = parts.next() else {
|
||||
continue;
|
||||
};
|
||||
published_versions
|
||||
.entry(extension_id)
|
||||
.or_default()
|
||||
.push(version);
|
||||
}
|
||||
|
||||
let known_versions = app_state.db.get_known_extension_versions().await?;
|
||||
|
||||
let mut new_versions = HashMap::<&str, Vec<NewExtensionVersion>>::default();
|
||||
let empty = Vec::new();
|
||||
for (extension_id, published_versions) in published_versions {
|
||||
let known_versions = known_versions.get(extension_id).unwrap_or(&empty);
|
||||
|
||||
for published_version in published_versions {
|
||||
if known_versions
|
||||
.binary_search_by_key(&published_version, String::as_str)
|
||||
.is_err()
|
||||
{
|
||||
let object = blob_store_client
|
||||
.get_object()
|
||||
.bucket(blob_store_bucket)
|
||||
.key(format!(
|
||||
"extensions/{extension_id}/{published_version}/manifest.json"
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
let manifest_bytes = object
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map(|data| data.into_bytes())
|
||||
.with_context(|| format!("failed to download manifest for extension {extension_id} version {published_version}"))?
|
||||
.to_vec();
|
||||
let manifest = serde_json::from_slice::<ExtensionManifest>(&manifest_bytes)
|
||||
.with_context(|| format!("invalid manifest for extension {extension_id} version {published_version}: {}", String::from_utf8_lossy(&manifest_bytes)))?;
|
||||
|
||||
let published_at = object.last_modified.ok_or_else(|| anyhow!("missing last modified timestamp for extension {extension_id} version {published_version}"))?;
|
||||
let published_at =
|
||||
time::OffsetDateTime::from_unix_timestamp_nanos(published_at.as_nanos())?;
|
||||
let published_at = PrimitiveDateTime::new(published_at.date(), published_at.time());
|
||||
|
||||
let version = semver::Version::parse(&manifest.version).with_context(|| {
|
||||
format!(
|
||||
"invalid version for extension {extension_id} version {published_version}"
|
||||
)
|
||||
})?;
|
||||
|
||||
new_versions
|
||||
.entry(extension_id)
|
||||
.or_default()
|
||||
.push(NewExtensionVersion {
|
||||
name: manifest.name,
|
||||
version,
|
||||
description: manifest.description.unwrap_or_default(),
|
||||
authors: manifest.authors,
|
||||
repository: manifest.repository,
|
||||
published_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app_state
|
||||
.db
|
||||
.insert_extension_versions(&new_versions)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
#[cfg(test)]
|
||||
pub mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
pub use tests::TestDb;
|
||||
|
||||
mod ids;
|
||||
mod queries;
|
||||
mod tables;
|
||||
#[cfg(test)]
|
||||
pub mod tests;
|
||||
|
||||
use crate::{executor::Executor, Error, Result};
|
||||
use anyhow::anyhow;
|
||||
@@ -25,7 +21,7 @@ use sea_orm::{
|
||||
FromQueryResult, IntoActiveModel, IsolationLevel, JoinType, QueryOrder, QuerySelect, Statement,
|
||||
TransactionTrait,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{ser::Error as _, Deserialize, Serialize, Serializer};
|
||||
use sqlx::{
|
||||
migrate::{Migrate, Migration, MigrationSource},
|
||||
Connection,
|
||||
@@ -40,13 +36,17 @@ use std::{
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
pub use tables::*;
|
||||
use time::{format_description::well_known::iso8601, PrimitiveDateTime};
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard};
|
||||
|
||||
#[cfg(test)]
|
||||
pub use tests::TestDb;
|
||||
|
||||
pub use ids::*;
|
||||
pub use queries::contributors::ContributorSelector;
|
||||
pub use sea_orm::ConnectOptions;
|
||||
pub use tables::user::Model as User;
|
||||
pub use tables::*;
|
||||
|
||||
/// Database gives you a handle that lets you access the database.
|
||||
/// It handles pooling internally.
|
||||
@@ -717,3 +717,43 @@ pub struct WorktreeSettingsFile {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub struct NewExtensionVersion {
|
||||
pub name: String,
|
||||
pub version: semver::Version,
|
||||
pub description: String,
|
||||
pub authors: Vec<String>,
|
||||
pub repository: String,
|
||||
pub published_at: PrimitiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct ExtensionMetadata {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub authors: Vec<String>,
|
||||
pub description: String,
|
||||
pub repository: String,
|
||||
#[serde(serialize_with = "serialize_iso8601")]
|
||||
pub published_at: PrimitiveDateTime,
|
||||
pub download_count: u64,
|
||||
}
|
||||
|
||||
pub fn serialize_iso8601<S: Serializer>(
|
||||
datetime: &PrimitiveDateTime,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error> {
|
||||
const SERDE_CONFIG: iso8601::EncodedConfig = iso8601::Config::DEFAULT
|
||||
.set_year_is_six_digits(false)
|
||||
.set_time_precision(iso8601::TimePrecision::Second {
|
||||
decimal_digits: None,
|
||||
})
|
||||
.encode();
|
||||
|
||||
datetime
|
||||
.assume_utc()
|
||||
.format(&time::format_description::well_known::Iso8601::<SERDE_CONFIG>)
|
||||
.map_err(S::Error::custom)?
|
||||
.serialize(serializer)
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ id_type!(SignupId);
|
||||
id_type!(UserId);
|
||||
id_type!(ChannelBufferCollaboratorId);
|
||||
id_type!(FlagId);
|
||||
id_type!(ExtensionId);
|
||||
id_type!(NotificationId);
|
||||
id_type!(NotificationKindId);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod buffers;
|
||||
pub mod channels;
|
||||
pub mod contacts;
|
||||
pub mod contributors;
|
||||
pub mod extensions;
|
||||
pub mod messages;
|
||||
pub mod notifications;
|
||||
pub mod projects;
|
||||
|
||||
@@ -97,59 +97,12 @@ impl Database {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_in_channel_call(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
in_call: bool,
|
||||
) -> Result<(proto::Room, ChannelRole)> {
|
||||
self.transaction(move |tx| async move {
|
||||
let channel = self.get_channel_internal(channel_id, &*tx).await?;
|
||||
let role = self.channel_role_for_user(&channel, user_id, &*tx).await?;
|
||||
if role.is_none() || role == Some(ChannelRole::Banned) {
|
||||
Err(ErrorCode::Forbidden.anyhow())?
|
||||
}
|
||||
let role = role.unwrap();
|
||||
|
||||
let Some(room) = room::Entity::find()
|
||||
.filter(room::Column::ChannelId.eq(channel_id))
|
||||
.one(&*tx)
|
||||
.await?
|
||||
else {
|
||||
Err(anyhow!("no room exists"))?
|
||||
};
|
||||
|
||||
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 {
|
||||
in_call: ActiveValue::Set(in_call),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected != 1 {
|
||||
Err(anyhow!("not in channel"))?
|
||||
}
|
||||
|
||||
let room = self.get_room(room.id, &*tx).await?;
|
||||
Ok((room, role))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Adds a user to the specified channel.
|
||||
pub async fn join_channel(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
autojoin: bool,
|
||||
connection: ConnectionId,
|
||||
environment: &str,
|
||||
) -> Result<(JoinRoom, Option<MembershipUpdated>, ChannelRole)> {
|
||||
self.transaction(move |tx| async move {
|
||||
let channel = self.get_channel_internal(channel_id, &*tx).await?;
|
||||
@@ -209,10 +162,10 @@ impl Database {
|
||||
|
||||
let live_kit_room = format!("channel-{}", nanoid::nanoid!(30));
|
||||
let room_id = self
|
||||
.get_or_create_channel_room(channel_id, &live_kit_room, environment, &*tx)
|
||||
.get_or_create_channel_room(channel_id, &live_kit_room, &*tx)
|
||||
.await?;
|
||||
|
||||
self.join_channel_room_internal(room_id, user_id, autojoin, connection, role, &*tx)
|
||||
self.join_channel_room_internal(room_id, user_id, connection, role, &*tx)
|
||||
.await
|
||||
.map(|jr| (jr, accept_invite_result, role))
|
||||
})
|
||||
@@ -979,7 +932,6 @@ impl Database {
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
live_kit_room: &str,
|
||||
environment: &str,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<RoomId> {
|
||||
let room = room::Entity::find()
|
||||
@@ -988,19 +940,11 @@ impl Database {
|
||||
.await?;
|
||||
|
||||
let room_id = if let Some(room) = room {
|
||||
if let Some(env) = room.environment {
|
||||
if &env != environment {
|
||||
Err(ErrorCode::WrongReleaseChannel
|
||||
.with_tag("required", &env)
|
||||
.anyhow())?;
|
||||
}
|
||||
}
|
||||
room.id
|
||||
} else {
|
||||
let result = room::Entity::insert(room::ActiveModel {
|
||||
channel_id: ActiveValue::Set(Some(channel_id)),
|
||||
live_kit_room: ActiveValue::Set(live_kit_room.to_string()),
|
||||
environment: ActiveValue::Set(Some(environment.to_string())),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
|
||||
206
crates/collab/src/db/queries/extensions.rs
Normal file
206
crates/collab/src/db/queries/extensions.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use super::*;
|
||||
|
||||
impl Database {
|
||||
pub async fn get_extensions(
|
||||
&self,
|
||||
filter: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ExtensionMetadata>> {
|
||||
self.transaction(|tx| async move {
|
||||
let mut condition = Condition::all();
|
||||
if let Some(filter) = filter {
|
||||
let fuzzy_name_filter = Self::fuzzy_like_string(filter);
|
||||
condition = condition.add(Expr::cust_with_expr("name ILIKE $1", fuzzy_name_filter));
|
||||
}
|
||||
|
||||
let extensions = extension::Entity::find()
|
||||
.filter(condition)
|
||||
.order_by_desc(extension::Column::TotalDownloadCount)
|
||||
.order_by_asc(extension::Column::Name)
|
||||
.limit(Some(limit as u64))
|
||||
.filter(
|
||||
extension::Column::LatestVersion
|
||||
.into_expr()
|
||||
.eq(extension_version::Column::Version.into_expr()),
|
||||
)
|
||||
.inner_join(extension_version::Entity)
|
||||
.select_also(extension_version::Entity)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(extensions
|
||||
.into_iter()
|
||||
.filter_map(|(extension, latest_version)| {
|
||||
let version = latest_version?;
|
||||
Some(ExtensionMetadata {
|
||||
id: extension.external_id,
|
||||
name: extension.name,
|
||||
version: version.version,
|
||||
authors: version
|
||||
.authors
|
||||
.split(',')
|
||||
.map(|author| author.trim().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
description: version.description,
|
||||
repository: version.repository,
|
||||
published_at: version.published_at,
|
||||
download_count: extension.total_download_count as u64,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_known_extension_versions<'a>(&self) -> Result<HashMap<String, Vec<String>>> {
|
||||
self.transaction(|tx| async move {
|
||||
let mut extension_external_ids_by_id = HashMap::default();
|
||||
|
||||
let mut rows = extension::Entity::find().stream(&*tx).await?;
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
extension_external_ids_by_id.insert(row.id, row.external_id);
|
||||
}
|
||||
drop(rows);
|
||||
|
||||
let mut known_versions_by_extension_id: HashMap<String, Vec<String>> =
|
||||
HashMap::default();
|
||||
let mut rows = extension_version::Entity::find().stream(&*tx).await?;
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
|
||||
let Some(extension_id) = extension_external_ids_by_id.get(&row.extension_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let versions = known_versions_by_extension_id
|
||||
.entry(extension_id.clone())
|
||||
.or_default();
|
||||
if let Err(ix) = versions.binary_search(&row.version) {
|
||||
versions.insert(ix, row.version);
|
||||
}
|
||||
}
|
||||
drop(rows);
|
||||
|
||||
Ok(known_versions_by_extension_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert_extension_versions(
|
||||
&self,
|
||||
versions_by_extension_id: &HashMap<&str, Vec<NewExtensionVersion>>,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
for (external_id, versions) in versions_by_extension_id {
|
||||
if versions.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let latest_version = versions
|
||||
.iter()
|
||||
.max_by_key(|version| &version.version)
|
||||
.unwrap();
|
||||
|
||||
let insert = extension::Entity::insert(extension::ActiveModel {
|
||||
name: ActiveValue::Set(latest_version.name.clone()),
|
||||
external_id: ActiveValue::Set(external_id.to_string()),
|
||||
id: ActiveValue::NotSet,
|
||||
latest_version: ActiveValue::Set(latest_version.version.to_string()),
|
||||
total_download_count: ActiveValue::NotSet,
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([extension::Column::ExternalId])
|
||||
.update_column(extension::Column::ExternalId)
|
||||
.to_owned(),
|
||||
);
|
||||
|
||||
let extension = if tx.support_returning() {
|
||||
insert.exec_with_returning(&*tx).await?
|
||||
} else {
|
||||
// Sqlite
|
||||
insert.exec_without_returning(&*tx).await?;
|
||||
extension::Entity::find()
|
||||
.filter(extension::Column::ExternalId.eq(*external_id))
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("failed to insert extension"))?
|
||||
};
|
||||
|
||||
extension_version::Entity::insert_many(versions.iter().map(|version| {
|
||||
extension_version::ActiveModel {
|
||||
extension_id: ActiveValue::Set(extension.id),
|
||||
published_at: ActiveValue::Set(version.published_at),
|
||||
version: ActiveValue::Set(version.version.to_string()),
|
||||
authors: ActiveValue::Set(version.authors.join(", ")),
|
||||
repository: ActiveValue::Set(version.repository.clone()),
|
||||
description: ActiveValue::Set(version.description.clone()),
|
||||
download_count: ActiveValue::NotSet,
|
||||
}
|
||||
}))
|
||||
.on_conflict(OnConflict::new().do_nothing().to_owned())
|
||||
.exec_without_returning(&*tx)
|
||||
.await?;
|
||||
|
||||
if let Ok(db_version) = semver::Version::parse(&extension.latest_version) {
|
||||
if db_version >= latest_version.version {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let mut extension = extension.into_active_model();
|
||||
extension.latest_version = ActiveValue::Set(latest_version.version.to_string());
|
||||
extension.name = ActiveValue::set(latest_version.name.clone());
|
||||
extension::Entity::update(extension).exec(&*tx).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn record_extension_download(&self, extension: &str, version: &str) -> Result<bool> {
|
||||
self.transaction(|tx| async move {
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryId {
|
||||
Id,
|
||||
}
|
||||
|
||||
let extension_id: Option<ExtensionId> = extension::Entity::find()
|
||||
.filter(extension::Column::ExternalId.eq(extension))
|
||||
.select_only()
|
||||
.column(extension::Column::Id)
|
||||
.into_values::<_, QueryId>()
|
||||
.one(&*tx)
|
||||
.await?;
|
||||
let Some(extension_id) = extension_id else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
extension_version::Entity::update_many()
|
||||
.col_expr(
|
||||
extension_version::Column::DownloadCount,
|
||||
extension_version::Column::DownloadCount.into_expr().add(1),
|
||||
)
|
||||
.filter(
|
||||
extension_version::Column::ExtensionId
|
||||
.eq(extension_id)
|
||||
.and(extension_version::Column::Version.eq(version)),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
extension::Entity::update_many()
|
||||
.col_expr(
|
||||
extension::Column::TotalDownloadCount,
|
||||
extension::Column::TotalDownloadCount.into_expr().add(1),
|
||||
)
|
||||
.filter(extension::Column::Id.eq(extension_id))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -110,12 +110,10 @@ impl Database {
|
||||
user_id: UserId,
|
||||
connection: ConnectionId,
|
||||
live_kit_room: &str,
|
||||
release_channel: &str,
|
||||
) -> Result<proto::Room> {
|
||||
self.transaction(|tx| async move {
|
||||
let room = room::ActiveModel {
|
||||
live_kit_room: ActiveValue::set(live_kit_room.into()),
|
||||
environment: ActiveValue::set(Some(release_channel.to_string())),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&*tx)
|
||||
@@ -135,7 +133,6 @@ impl Database {
|
||||
))),
|
||||
participant_index: ActiveValue::set(Some(0)),
|
||||
role: ActiveValue::set(Some(ChannelRole::Admin)),
|
||||
in_call: ActiveValue::set(true),
|
||||
|
||||
id: ActiveValue::NotSet,
|
||||
location_kind: ActiveValue::NotSet,
|
||||
@@ -188,7 +185,6 @@ impl Database {
|
||||
))),
|
||||
initial_project_id: ActiveValue::set(initial_project_id),
|
||||
role: ActiveValue::set(Some(called_user_role)),
|
||||
in_call: ActiveValue::set(true),
|
||||
|
||||
id: ActiveValue::NotSet,
|
||||
answering_connection_id: ActiveValue::NotSet,
|
||||
@@ -304,31 +300,21 @@ impl Database {
|
||||
room_id: RoomId,
|
||||
user_id: UserId,
|
||||
connection: ConnectionId,
|
||||
environment: &str,
|
||||
) -> Result<RoomGuard<JoinRoom>> {
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryChannelIdAndEnvironment {
|
||||
enum QueryChannelId {
|
||||
ChannelId,
|
||||
Environment,
|
||||
}
|
||||
|
||||
let (channel_id, release_channel): (Option<ChannelId>, Option<String>) =
|
||||
room::Entity::find()
|
||||
.select_only()
|
||||
.column(room::Column::ChannelId)
|
||||
.column(room::Column::Environment)
|
||||
.filter(room::Column::Id.eq(room_id))
|
||||
.into_values::<_, QueryChannelIdAndEnvironment>()
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such room"))?;
|
||||
|
||||
if let Some(release_channel) = release_channel {
|
||||
if &release_channel != environment {
|
||||
Err(anyhow!("must join using the {} release", release_channel))?;
|
||||
}
|
||||
}
|
||||
let channel_id: Option<ChannelId> = room::Entity::find()
|
||||
.select_only()
|
||||
.column(room::Column::ChannelId)
|
||||
.filter(room::Column::Id.eq(room_id))
|
||||
.into_values::<_, QueryChannelId>()
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such room"))?;
|
||||
|
||||
if channel_id.is_some() {
|
||||
Err(anyhow!("tried to join channel call directly"))?
|
||||
@@ -416,7 +402,6 @@ impl Database {
|
||||
&self,
|
||||
room_id: RoomId,
|
||||
user_id: UserId,
|
||||
autojoin: bool,
|
||||
connection: ConnectionId,
|
||||
role: ChannelRole,
|
||||
tx: &DatabaseTransaction,
|
||||
@@ -440,8 +425,6 @@ impl Database {
|
||||
))),
|
||||
participant_index: ActiveValue::Set(Some(participant_index)),
|
||||
role: ActiveValue::set(Some(role)),
|
||||
in_call: ActiveValue::set(autojoin),
|
||||
|
||||
id: ActiveValue::NotSet,
|
||||
location_kind: ActiveValue::NotSet,
|
||||
location_project_id: ActiveValue::NotSet,
|
||||
@@ -1263,7 +1246,6 @@ impl Database {
|
||||
location: Some(proto::ParticipantLocation { variant: location }),
|
||||
participant_index: participant_index as u32,
|
||||
role: db_participant.role.unwrap_or(ChannelRole::Member).into(),
|
||||
in_call: db_participant.in_call,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -10,6 +10,8 @@ pub mod channel_message;
|
||||
pub mod channel_message_mention;
|
||||
pub mod contact;
|
||||
pub mod contributor;
|
||||
pub mod extension;
|
||||
pub mod extension_version;
|
||||
pub mod feature_flag;
|
||||
pub mod follower;
|
||||
pub mod language_server;
|
||||
|
||||
27
crates/collab/src/db/tables/extension.rs
Normal file
27
crates/collab/src/db/tables/extension.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use crate::db::ExtensionId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "extensions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: ExtensionId,
|
||||
pub external_id: String,
|
||||
pub name: String,
|
||||
pub latest_version: String,
|
||||
pub total_download_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_one = "super::extension_version::Entity")]
|
||||
LatestVersion,
|
||||
}
|
||||
|
||||
impl Related<super::extension_version::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::LatestVersion.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
36
crates/collab/src/db/tables/extension_version.rs
Normal file
36
crates/collab/src/db/tables/extension_version.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use crate::db::ExtensionId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use time::PrimitiveDateTime;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "extension_versions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub extension_id: ExtensionId,
|
||||
#[sea_orm(primary_key)]
|
||||
pub version: String,
|
||||
pub published_at: PrimitiveDateTime,
|
||||
pub authors: String,
|
||||
pub repository: String,
|
||||
pub description: String,
|
||||
pub download_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::extension::Entity",
|
||||
from = "Column::ExtensionId",
|
||||
to = "super::extension::Column::Id"
|
||||
on_condition = r#"super::extension::Column::LatestVersion.into_expr().eq(Column::Version.into_expr())"#
|
||||
)]
|
||||
Extension,
|
||||
}
|
||||
|
||||
impl Related<super::extension::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Extension.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -8,7 +8,6 @@ pub struct Model {
|
||||
pub id: RoomId,
|
||||
pub live_kit_room: String,
|
||||
pub channel_id: Option<ChannelId>,
|
||||
pub environment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -20,7 +20,6 @@ pub struct Model {
|
||||
pub calling_connection_server_id: Option<ServerId>,
|
||||
pub participant_index: Option<i32>,
|
||||
pub role: Option<ChannelRole>,
|
||||
pub in_call: bool,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
|
||||
@@ -2,6 +2,7 @@ mod buffer_tests;
|
||||
mod channel_tests;
|
||||
mod contributor_tests;
|
||||
mod db_tests;
|
||||
mod extension_tests;
|
||||
mod feature_flag_tests;
|
||||
mod message_tests;
|
||||
|
||||
@@ -15,8 +16,6 @@ use std::sync::{
|
||||
Arc,
|
||||
};
|
||||
|
||||
const TEST_RELEASE_CHANNEL: &'static str = "test";
|
||||
|
||||
pub struct TestDb {
|
||||
pub db: Option<Arc<Database>>,
|
||||
pub connection: Option<sqlx::AnyConnection>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
db::{
|
||||
tests::{channel_tree, new_test_connection, new_test_user, TEST_RELEASE_CHANNEL},
|
||||
tests::{channel_tree, new_test_connection, new_test_user},
|
||||
Channel, ChannelId, ChannelRole, Database, NewUserParams, RoomId,
|
||||
},
|
||||
test_both_dbs,
|
||||
@@ -135,13 +135,7 @@ async fn test_joining_channels(db: &Arc<Database>) {
|
||||
|
||||
// can join a room with membership to its channel
|
||||
let (joined_room, _, _) = db
|
||||
.join_channel(
|
||||
channel_1,
|
||||
user_1,
|
||||
false,
|
||||
ConnectionId { owner_id, id: 1 },
|
||||
TEST_RELEASE_CHANNEL,
|
||||
)
|
||||
.join_channel(channel_1, user_1, ConnectionId { owner_id, id: 1 })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(joined_room.room.participants.len(), 1);
|
||||
@@ -150,12 +144,7 @@ async fn test_joining_channels(db: &Arc<Database>) {
|
||||
drop(joined_room);
|
||||
// cannot join a room without membership to its channel
|
||||
assert!(db
|
||||
.join_room(
|
||||
room_id,
|
||||
user_2,
|
||||
ConnectionId { owner_id, id: 1 },
|
||||
TEST_RELEASE_CHANNEL
|
||||
)
|
||||
.join_room(room_id, user_2, ConnectionId { owner_id, id: 1 },)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
@@ -733,15 +722,9 @@ async fn test_guest_access(db: &Arc<Database>) {
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
db.join_channel(
|
||||
zed_channel,
|
||||
guest,
|
||||
false,
|
||||
guest_connection,
|
||||
TEST_RELEASE_CHANNEL,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.join_channel(zed_channel, guest, guest_connection)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(db
|
||||
.join_channel_chat(zed_channel, guest_connection, guest)
|
||||
|
||||
@@ -517,7 +517,7 @@ async fn test_project_count(db: &Arc<Database>) {
|
||||
.unwrap();
|
||||
|
||||
let room_id = RoomId::from_proto(
|
||||
db.create_room(user1.user_id, ConnectionId { owner_id, id: 0 }, "", "test")
|
||||
db.create_room(user1.user_id, ConnectionId { owner_id, id: 0 }, "")
|
||||
.await
|
||||
.unwrap()
|
||||
.id,
|
||||
@@ -531,14 +531,9 @@ async fn test_project_count(db: &Arc<Database>) {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.join_room(
|
||||
room_id,
|
||||
user2.user_id,
|
||||
ConnectionId { owner_id, id: 1 },
|
||||
"test",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.join_room(room_id, user2.user_id, ConnectionId { owner_id, id: 1 })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0);
|
||||
|
||||
db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[])
|
||||
@@ -616,80 +611,3 @@ async fn test_fuzzy_search_users(cx: &mut TestAppContext) {
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_non_matching_release_channels,
|
||||
test_non_matching_release_channels_postgres,
|
||||
test_non_matching_release_channels_sqlite
|
||||
);
|
||||
|
||||
async fn test_non_matching_release_channels(db: &Arc<Database>) {
|
||||
let owner_id = db.create_server("test").await.unwrap().0 as u32;
|
||||
|
||||
let user1 = db
|
||||
.create_user(
|
||||
&format!("admin@example.com"),
|
||||
true,
|
||||
NewUserParams {
|
||||
github_login: "admin".into(),
|
||||
github_user_id: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let user2 = db
|
||||
.create_user(
|
||||
&format!("user@example.com"),
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: "user".into(),
|
||||
github_user_id: 1,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let room = db
|
||||
.create_room(
|
||||
user1.user_id,
|
||||
ConnectionId { owner_id, id: 0 },
|
||||
"",
|
||||
"stable",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.call(
|
||||
RoomId::from_proto(room.id),
|
||||
user1.user_id,
|
||||
ConnectionId { owner_id, id: 0 },
|
||||
user2.user_id,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// User attempts to join from preview
|
||||
let result = db
|
||||
.join_room(
|
||||
RoomId::from_proto(room.id),
|
||||
user2.user_id,
|
||||
ConnectionId { owner_id, id: 1 },
|
||||
"preview",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
|
||||
// User switches to stable
|
||||
let result = db
|
||||
.join_room(
|
||||
RoomId::from_proto(room.id),
|
||||
user2.user_id,
|
||||
ConnectionId { owner_id, id: 1 },
|
||||
"stable",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok())
|
||||
}
|
||||
|
||||
225
crates/collab/src/db/tests/extension_tests.rs
Normal file
225
crates/collab/src/db/tests/extension_tests.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
use super::Database;
|
||||
use crate::{
|
||||
db::{ExtensionMetadata, NewExtensionVersion},
|
||||
test_both_dbs,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use time::{OffsetDateTime, PrimitiveDateTime};
|
||||
|
||||
test_both_dbs!(
|
||||
test_extensions,
|
||||
test_extensions_postgres,
|
||||
test_extensions_sqlite
|
||||
);
|
||||
|
||||
async fn test_extensions(db: &Arc<Database>) {
|
||||
let versions = db.get_known_extension_versions().await.unwrap();
|
||||
assert!(versions.is_empty());
|
||||
|
||||
let extensions = db.get_extensions(None, 5).await.unwrap();
|
||||
assert!(extensions.is_empty());
|
||||
|
||||
let t0 = OffsetDateTime::from_unix_timestamp_nanos(0).unwrap();
|
||||
let t0 = PrimitiveDateTime::new(t0.date(), t0.time());
|
||||
|
||||
db.insert_extension_versions(
|
||||
&[
|
||||
(
|
||||
"ext1",
|
||||
vec![
|
||||
NewExtensionVersion {
|
||||
name: "Extension 1".into(),
|
||||
version: semver::Version::parse("0.0.1").unwrap(),
|
||||
description: "an extension".into(),
|
||||
authors: vec!["max".into()],
|
||||
repository: "ext1/repo".into(),
|
||||
published_at: t0,
|
||||
},
|
||||
NewExtensionVersion {
|
||||
name: "Extension One".into(),
|
||||
version: semver::Version::parse("0.0.2").unwrap(),
|
||||
description: "a good extension".into(),
|
||||
authors: vec!["max".into(), "marshall".into()],
|
||||
repository: "ext1/repo".into(),
|
||||
published_at: t0,
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
"ext2",
|
||||
vec![NewExtensionVersion {
|
||||
name: "Extension Two".into(),
|
||||
version: semver::Version::parse("0.2.0").unwrap(),
|
||||
description: "a great extension".into(),
|
||||
authors: vec!["marshall".into()],
|
||||
repository: "ext2/repo".into(),
|
||||
published_at: t0,
|
||||
}],
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let versions = db.get_known_extension_versions().await.unwrap();
|
||||
assert_eq!(
|
||||
versions,
|
||||
[
|
||||
("ext1".into(), vec!["0.0.1".into(), "0.0.2".into()]),
|
||||
("ext2".into(), vec!["0.2.0".into()])
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
|
||||
// The latest version of each extension is returned.
|
||||
let extensions = db.get_extensions(None, 5).await.unwrap();
|
||||
assert_eq!(
|
||||
extensions,
|
||||
&[
|
||||
ExtensionMetadata {
|
||||
id: "ext1".into(),
|
||||
name: "Extension One".into(),
|
||||
version: "0.0.2".into(),
|
||||
authors: vec!["max".into(), "marshall".into()],
|
||||
description: "a good extension".into(),
|
||||
repository: "ext1/repo".into(),
|
||||
published_at: t0,
|
||||
download_count: 0,
|
||||
},
|
||||
ExtensionMetadata {
|
||||
id: "ext2".into(),
|
||||
name: "Extension Two".into(),
|
||||
version: "0.2.0".into(),
|
||||
authors: vec!["marshall".into()],
|
||||
description: "a great extension".into(),
|
||||
repository: "ext2/repo".into(),
|
||||
published_at: t0,
|
||||
download_count: 0
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// Record extensions being downloaded.
|
||||
for _ in 0..7 {
|
||||
assert!(db.record_extension_download("ext2", "0.0.2").await.unwrap());
|
||||
}
|
||||
|
||||
for _ in 0..3 {
|
||||
assert!(db.record_extension_download("ext1", "0.0.1").await.unwrap());
|
||||
}
|
||||
|
||||
for _ in 0..2 {
|
||||
assert!(db.record_extension_download("ext1", "0.0.2").await.unwrap());
|
||||
}
|
||||
|
||||
// Record download returns false if the extension does not exist.
|
||||
assert!(!db
|
||||
.record_extension_download("no-such-extension", "0.0.2")
|
||||
.await
|
||||
.unwrap());
|
||||
|
||||
// Extensions are returned in descending order of total downloads.
|
||||
let extensions = db.get_extensions(None, 5).await.unwrap();
|
||||
assert_eq!(
|
||||
extensions,
|
||||
&[
|
||||
ExtensionMetadata {
|
||||
id: "ext2".into(),
|
||||
name: "Extension Two".into(),
|
||||
version: "0.2.0".into(),
|
||||
authors: vec!["marshall".into()],
|
||||
description: "a great extension".into(),
|
||||
repository: "ext2/repo".into(),
|
||||
published_at: t0,
|
||||
download_count: 7
|
||||
},
|
||||
ExtensionMetadata {
|
||||
id: "ext1".into(),
|
||||
name: "Extension One".into(),
|
||||
version: "0.0.2".into(),
|
||||
authors: vec!["max".into(), "marshall".into()],
|
||||
description: "a good extension".into(),
|
||||
repository: "ext1/repo".into(),
|
||||
published_at: t0,
|
||||
download_count: 5,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// Add more extensions, including a new version of `ext1`, and backfilling
|
||||
// an older version of `ext2`.
|
||||
db.insert_extension_versions(
|
||||
&[
|
||||
(
|
||||
"ext1",
|
||||
vec![NewExtensionVersion {
|
||||
name: "Extension One".into(),
|
||||
version: semver::Version::parse("0.0.3").unwrap(),
|
||||
description: "a real good extension".into(),
|
||||
authors: vec!["max".into(), "marshall".into()],
|
||||
repository: "ext1/repo".into(),
|
||||
published_at: t0,
|
||||
}],
|
||||
),
|
||||
(
|
||||
"ext2",
|
||||
vec![NewExtensionVersion {
|
||||
name: "Extension Two".into(),
|
||||
version: semver::Version::parse("0.1.0").unwrap(),
|
||||
description: "an old extension".into(),
|
||||
authors: vec!["marshall".into()],
|
||||
repository: "ext2/repo".into(),
|
||||
published_at: t0,
|
||||
}],
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let versions = db.get_known_extension_versions().await.unwrap();
|
||||
assert_eq!(
|
||||
versions,
|
||||
[
|
||||
(
|
||||
"ext1".into(),
|
||||
vec!["0.0.1".into(), "0.0.2".into(), "0.0.3".into()]
|
||||
),
|
||||
("ext2".into(), vec!["0.1.0".into(), "0.2.0".into()])
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
|
||||
let extensions = db.get_extensions(None, 5).await.unwrap();
|
||||
assert_eq!(
|
||||
extensions,
|
||||
&[
|
||||
ExtensionMetadata {
|
||||
id: "ext2".into(),
|
||||
name: "Extension Two".into(),
|
||||
version: "0.2.0".into(),
|
||||
authors: vec!["marshall".into()],
|
||||
description: "a great extension".into(),
|
||||
repository: "ext2/repo".into(),
|
||||
published_at: t0,
|
||||
download_count: 7
|
||||
},
|
||||
ExtensionMetadata {
|
||||
id: "ext1".into(),
|
||||
name: "Extension One".into(),
|
||||
version: "0.0.3".into(),
|
||||
authors: vec!["max".into(), "marshall".into()],
|
||||
description: "a real good extension".into(),
|
||||
repository: "ext1/repo".into(),
|
||||
published_at: t0,
|
||||
download_count: 5,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,8 @@ use std::fs;
|
||||
|
||||
pub fn load_dotenv() -> anyhow::Result<()> {
|
||||
let env: toml::map::Map<String, toml::Value> = toml::de::from_str(
|
||||
&fs::read_to_string("./.env.toml").map_err(|_| anyhow!("no .env.toml file found"))?,
|
||||
&fs::read_to_string("./crates/collab/.env.toml")
|
||||
.map_err(|_| anyhow!("no .env.toml file found"))?,
|
||||
)?;
|
||||
|
||||
for (key, value) in env {
|
||||
|
||||
@@ -8,11 +8,14 @@ pub mod rpc;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use aws_config::{BehaviorVersion, Region};
|
||||
use axum::{http::StatusCode, response::IntoResponse};
|
||||
use db::Database;
|
||||
use executor::Executor;
|
||||
use serde::Deserialize;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use util::ResultExt;
|
||||
|
||||
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||
|
||||
@@ -100,6 +103,11 @@ pub struct Config {
|
||||
pub live_kit_secret: Option<String>,
|
||||
pub rust_log: Option<String>,
|
||||
pub log_json: Option<bool>,
|
||||
pub blob_store_url: Option<String>,
|
||||
pub blob_store_region: Option<String>,
|
||||
pub blob_store_access_key: Option<String>,
|
||||
pub blob_store_secret_key: Option<String>,
|
||||
pub blob_store_bucket: Option<String>,
|
||||
pub zed_environment: Arc<str>,
|
||||
}
|
||||
|
||||
@@ -118,6 +126,7 @@ pub struct MigrateConfig {
|
||||
pub struct AppState {
|
||||
pub db: Arc<Database>,
|
||||
pub live_kit_client: Option<Arc<dyn live_kit_server::api::Client>>,
|
||||
pub blob_store_client: Option<aws_sdk_s3::Client>,
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
@@ -146,8 +155,44 @@ impl AppState {
|
||||
let this = Self {
|
||||
db: Arc::new(db),
|
||||
live_kit_client,
|
||||
blob_store_client: build_blob_store_client(&config).await.log_err(),
|
||||
config,
|
||||
};
|
||||
Ok(Arc::new(this))
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_blob_store_client(config: &Config) -> anyhow::Result<aws_sdk_s3::Client> {
|
||||
let keys = aws_sdk_s3::config::Credentials::new(
|
||||
config
|
||||
.blob_store_access_key
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("missing blob_store_access_key"))?,
|
||||
config
|
||||
.blob_store_secret_key
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("missing blob_store_secret_key"))?,
|
||||
None,
|
||||
None,
|
||||
"env",
|
||||
);
|
||||
|
||||
let s3_config = aws_config::defaults(BehaviorVersion::latest())
|
||||
.endpoint_url(
|
||||
config
|
||||
.blob_store_url
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("missing blob_store_url"))?,
|
||||
)
|
||||
.region(Region::new(
|
||||
config
|
||||
.blob_store_region
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("missing blob_store_region"))?,
|
||||
))
|
||||
.credentials_provider(keys)
|
||||
.load()
|
||||
.await;
|
||||
|
||||
Ok(aws_sdk_s3::Client::new(&s3_config))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use anyhow::anyhow;
|
||||
use axum::{routing::get, Extension, Router};
|
||||
use collab::{db, env, executor::Executor, AppState, Config, MigrateConfig, Result};
|
||||
use collab::{
|
||||
api::fetch_extensions_from_blob_store_periodically, db, env, executor::Executor, AppState,
|
||||
Config, MigrateConfig, Result,
|
||||
};
|
||||
use db::Database;
|
||||
use std::{
|
||||
env::args,
|
||||
@@ -50,6 +53,8 @@ async fn main() -> Result<()> {
|
||||
let rpc_server = collab::rpc::Server::new(epoch, state.clone(), Executor::Production);
|
||||
rpc_server.start().await?;
|
||||
|
||||
fetch_extensions_from_blob_store_periodically(state.clone(), Executor::Production);
|
||||
|
||||
let app = collab::api::routes(rpc_server.clone(), state.clone())
|
||||
.merge(collab::rpc::routes(rpc_server.clone()))
|
||||
.merge(
|
||||
|
||||
@@ -102,10 +102,8 @@ impl<R: RequestMessage> Response<R> {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Session {
|
||||
zed_environment: Arc<str>,
|
||||
user_id: UserId,
|
||||
connection_id: ConnectionId,
|
||||
zed_version: SemanticVersion,
|
||||
db: Arc<tokio::sync::Mutex<DbHandle>>,
|
||||
peer: Arc<Peer>,
|
||||
connection_pool: Arc<parking_lot::Mutex<ConnectionPool>>,
|
||||
@@ -132,19 +130,6 @@ impl Session {
|
||||
_not_send: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_removed_in(&self, endpoint: &str, version: SemanticVersion) -> anyhow::Result<()> {
|
||||
if self.zed_version > version {
|
||||
Err(anyhow!(
|
||||
"{} was removed in {} (you're on {})",
|
||||
endpoint,
|
||||
version,
|
||||
self.zed_version
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Session {
|
||||
@@ -288,11 +273,8 @@ impl Server {
|
||||
.add_request_handler(get_channel_members)
|
||||
.add_request_handler(respond_to_channel_invite)
|
||||
.add_request_handler(join_channel)
|
||||
.add_request_handler(join_channel2)
|
||||
.add_request_handler(join_channel_chat)
|
||||
.add_message_handler(leave_channel_chat)
|
||||
.add_request_handler(join_channel_call)
|
||||
.add_request_handler(leave_channel_call)
|
||||
.add_request_handler(send_channel_message)
|
||||
.add_request_handler(remove_channel_message)
|
||||
.add_request_handler(get_channel_messages)
|
||||
@@ -576,7 +558,6 @@ impl Server {
|
||||
connection: Connection,
|
||||
address: String,
|
||||
user: User,
|
||||
zed_version: SemanticVersion,
|
||||
impersonator: Option<User>,
|
||||
mut send_connection_id: Option<oneshot::Sender<ConnectionId>>,
|
||||
executor: Executor,
|
||||
@@ -634,9 +615,7 @@ impl Server {
|
||||
let session = Session {
|
||||
user_id,
|
||||
connection_id,
|
||||
zed_version,
|
||||
db: Arc::new(tokio::sync::Mutex::new(DbHandle(this.app_state.db.clone()))),
|
||||
zed_environment: this.app_state.config.zed_environment.clone(),
|
||||
peer: this.peer.clone(),
|
||||
connection_pool: this.connection_pool.clone(),
|
||||
live_kit_client: this.app_state.live_kit_client.clone(),
|
||||
@@ -900,11 +879,18 @@ pub async fn handle_websocket_request(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// zed 0.122.x was the first version that sent an app header, so once that hits stable
|
||||
// we can return UPGRADE_REQUIRED instead of unwrap_or_default();
|
||||
let app_version = app_version_header
|
||||
.map(|header| header.0 .0)
|
||||
.unwrap_or_default();
|
||||
// the first version of zed that sent this header was 0.121.x
|
||||
if let Some(version) = app_version_header.map(|header| header.0 .0) {
|
||||
// 0.123.0 was a nightly version with incompatible collab changes
|
||||
// that were reverted.
|
||||
if version == "0.123.0".parse().unwrap() {
|
||||
return (
|
||||
StatusCode::UPGRADE_REQUIRED,
|
||||
"client must be upgraded".to_string(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let socket_address = socket_address.to_string();
|
||||
ws.on_upgrade(move |socket| {
|
||||
@@ -920,7 +906,6 @@ pub async fn handle_websocket_request(
|
||||
connection,
|
||||
socket_address,
|
||||
user,
|
||||
app_version,
|
||||
impersonator.0,
|
||||
None,
|
||||
Executor::Production,
|
||||
@@ -1035,12 +1020,7 @@ async fn create_room(
|
||||
let room = session
|
||||
.db()
|
||||
.await
|
||||
.create_room(
|
||||
session.user_id,
|
||||
session.connection_id,
|
||||
&live_kit_room,
|
||||
&session.zed_environment,
|
||||
)
|
||||
.create_room(session.user_id, session.connection_id, &live_kit_room)
|
||||
.await?;
|
||||
|
||||
response.send(proto::CreateRoomResponse {
|
||||
@@ -1063,19 +1043,14 @@ async fn join_room(
|
||||
let channel_id = session.db().await.channel_id_for_room(room_id).await?;
|
||||
|
||||
if let Some(channel_id) = channel_id {
|
||||
return join_channel_internal(channel_id, true, Box::new(response), session).await;
|
||||
return join_channel_internal(channel_id, Box::new(response), session).await;
|
||||
}
|
||||
|
||||
let joined_room = {
|
||||
let room = session
|
||||
.db()
|
||||
.await
|
||||
.join_room(
|
||||
room_id,
|
||||
session.user_id,
|
||||
session.connection_id,
|
||||
session.zed_environment.as_ref(),
|
||||
)
|
||||
.join_room(room_id, session.user_id, session.connection_id)
|
||||
.await?;
|
||||
room_updated(&room.room, &session.peer);
|
||||
room.into_inner()
|
||||
@@ -2726,67 +2701,14 @@ async fn respond_to_channel_invite(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Join the channels' call
|
||||
/// Join the channels' room
|
||||
async fn join_channel(
|
||||
request: proto::JoinChannel,
|
||||
response: Response<proto::JoinChannel>,
|
||||
session: Session,
|
||||
) -> Result<()> {
|
||||
session.endpoint_removed_in("join_channel", "0.123.0".parse().unwrap())?;
|
||||
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
join_channel_internal(channel_id, true, Box::new(response), session).await
|
||||
}
|
||||
|
||||
async fn join_channel2(
|
||||
request: proto::JoinChannel2,
|
||||
response: Response<proto::JoinChannel2>,
|
||||
session: Session,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
join_channel_internal(channel_id, false, Box::new(response), session).await
|
||||
}
|
||||
|
||||
async fn join_channel_call(
|
||||
request: proto::JoinChannelCall,
|
||||
response: Response<proto::JoinChannelCall>,
|
||||
session: Session,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let db = session.db().await;
|
||||
let (joined_room, role) = db
|
||||
.set_in_channel_call(channel_id, session.user_id, true)
|
||||
.await?;
|
||||
|
||||
let Some(connection_info) = session.live_kit_client.as_ref().and_then(|live_kit| {
|
||||
live_kit_info_for_user(live_kit, &session.user_id, role, &joined_room.live_kit_room)
|
||||
}) else {
|
||||
Err(anyhow!("no live kit token info"))?
|
||||
};
|
||||
|
||||
room_updated(&joined_room, &session.peer);
|
||||
response.send(proto::JoinChannelCallResponse {
|
||||
live_kit_connection_info: Some(connection_info),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn leave_channel_call(
|
||||
request: proto::LeaveChannelCall,
|
||||
response: Response<proto::LeaveChannelCall>,
|
||||
session: Session,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let db = session.db().await;
|
||||
let (joined_room, _) = db
|
||||
.set_in_channel_call(channel_id, session.user_id, false)
|
||||
.await?;
|
||||
|
||||
room_updated(&joined_room, &session.peer);
|
||||
response.send(proto::Ack {})?;
|
||||
|
||||
Ok(())
|
||||
join_channel_internal(channel_id, Box::new(response), session).await
|
||||
}
|
||||
|
||||
trait JoinChannelInternalResponse {
|
||||
@@ -2802,15 +2724,9 @@ impl JoinChannelInternalResponse for Response<proto::JoinRoom> {
|
||||
Response::<proto::JoinRoom>::send(self, result)
|
||||
}
|
||||
}
|
||||
impl JoinChannelInternalResponse for Response<proto::JoinChannel2> {
|
||||
fn send(self, result: proto::JoinRoomResponse) -> Result<()> {
|
||||
Response::<proto::JoinChannel2>::send(self, result)
|
||||
}
|
||||
}
|
||||
|
||||
async fn join_channel_internal(
|
||||
channel_id: ChannelId,
|
||||
autojoin: bool,
|
||||
response: Box<impl JoinChannelInternalResponse>,
|
||||
session: Session,
|
||||
) -> Result<()> {
|
||||
@@ -2819,25 +2735,37 @@ async fn join_channel_internal(
|
||||
let db = session.db().await;
|
||||
|
||||
let (joined_room, membership_updated, role) = db
|
||||
.join_channel(
|
||||
channel_id,
|
||||
session.user_id,
|
||||
autojoin,
|
||||
session.connection_id,
|
||||
session.zed_environment.as_ref(),
|
||||
)
|
||||
.join_channel(channel_id, session.user_id, session.connection_id)
|
||||
.await?;
|
||||
|
||||
let live_kit_connection_info = session.live_kit_client.as_ref().and_then(|live_kit| {
|
||||
if !autojoin {
|
||||
return None;
|
||||
}
|
||||
live_kit_info_for_user(
|
||||
live_kit,
|
||||
&session.user_id,
|
||||
role,
|
||||
&joined_room.room.live_kit_room,
|
||||
)
|
||||
let (can_publish, token) = if role == ChannelRole::Guest {
|
||||
(
|
||||
false,
|
||||
live_kit
|
||||
.guest_token(
|
||||
&joined_room.room.live_kit_room,
|
||||
&session.user_id.to_string(),
|
||||
)
|
||||
.trace_err()?,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
true,
|
||||
live_kit
|
||||
.room_token(
|
||||
&joined_room.room.live_kit_room,
|
||||
&session.user_id.to_string(),
|
||||
)
|
||||
.trace_err()?,
|
||||
)
|
||||
};
|
||||
|
||||
Some(LiveKitConnectionInfo {
|
||||
server_url: live_kit.url().into(),
|
||||
token,
|
||||
can_publish,
|
||||
})
|
||||
});
|
||||
|
||||
response.send(proto::JoinRoomResponse {
|
||||
@@ -2873,35 +2801,6 @@ async fn join_channel_internal(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn live_kit_info_for_user(
|
||||
live_kit: &Arc<dyn live_kit_server::api::Client>,
|
||||
user_id: &UserId,
|
||||
role: ChannelRole,
|
||||
live_kit_room: &String,
|
||||
) -> Option<LiveKitConnectionInfo> {
|
||||
let (can_publish, token) = if role == ChannelRole::Guest {
|
||||
(
|
||||
false,
|
||||
live_kit
|
||||
.guest_token(live_kit_room, &user_id.to_string())
|
||||
.trace_err()?,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
true,
|
||||
live_kit
|
||||
.room_token(live_kit_room, &user_id.to_string())
|
||||
.trace_err()?,
|
||||
)
|
||||
};
|
||||
|
||||
Some(LiveKitConnectionInfo {
|
||||
server_url: live_kit.url().into(),
|
||||
token,
|
||||
can_publish,
|
||||
})
|
||||
}
|
||||
|
||||
/// Start editing the channel notes
|
||||
async fn join_channel_buffer(
|
||||
request: proto::JoinChannelBuffer,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use crate::{
|
||||
db::ChannelId,
|
||||
tests::{test_server::join_channel_call, TestServer},
|
||||
};
|
||||
use crate::{db::ChannelId, tests::TestServer};
|
||||
use call::ActiveCall;
|
||||
use editor::Editor;
|
||||
use gpui::{BackgroundExecutor, TestAppContext};
|
||||
@@ -35,7 +32,7 @@ async fn test_channel_guests(
|
||||
cx_a.executor().run_until_parked();
|
||||
|
||||
// Client B joins channel A as a guest
|
||||
cx_b.update(|cx| workspace::open_channel(channel_id, client_b.app_state.clone(), None, cx))
|
||||
cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -75,7 +72,7 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
|
||||
.await;
|
||||
|
||||
let project_a = client_a.build_test_project(cx_a).await;
|
||||
cx_a.update(|cx| workspace::open_channel(channel_id, client_a.app_state.clone(), None, cx))
|
||||
cx_a.update(|cx| workspace::join_channel(channel_id, client_a.app_state.clone(), None, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -87,13 +84,11 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
|
||||
cx_a.run_until_parked();
|
||||
|
||||
// Client B joins channel A as a guest
|
||||
cx_b.update(|cx| workspace::open_channel(channel_id, client_b.app_state.clone(), None, cx))
|
||||
cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
cx_a.run_until_parked();
|
||||
|
||||
join_channel_call(cx_b).await.unwrap();
|
||||
|
||||
// client B opens 1.txt as a guest
|
||||
let (workspace_b, cx_b) = client_b.active_workspace(cx_b);
|
||||
let room_b = cx_b
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
db::{self, UserId},
|
||||
rpc::RECONNECT_TIMEOUT,
|
||||
tests::{room_participants, test_server::join_channel_call, RoomParticipants, TestServer},
|
||||
tests::{room_participants, RoomParticipants, TestServer},
|
||||
};
|
||||
use call::ActiveCall;
|
||||
use channel::{ChannelId, ChannelMembership, ChannelStore};
|
||||
@@ -382,7 +382,6 @@ async fn test_channel_room(
|
||||
.update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
join_channel_call(cx_a).await.unwrap();
|
||||
|
||||
// Give everyone a chance to observe user A joining
|
||||
executor.run_until_parked();
|
||||
@@ -430,7 +429,7 @@ async fn test_channel_room(
|
||||
.update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
join_channel_call(cx_b).await.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
cx_a.read(|cx| {
|
||||
@@ -553,9 +552,6 @@ async fn test_channel_room(
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
join_channel_call(cx_a).await.unwrap();
|
||||
join_channel_call(cx_b).await.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let room_a =
|
||||
|
||||
@@ -24,7 +24,7 @@ use workspace::{
|
||||
|
||||
use super::TestClient;
|
||||
|
||||
#[gpui::test]
|
||||
#[gpui::test(iterations = 10)]
|
||||
async fn test_basic_following(
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
@@ -437,7 +437,6 @@ async fn test_basic_following(
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
let shared_screen = workspace_a.update(cx_a, |workspace, cx| {
|
||||
workspace
|
||||
@@ -523,7 +522,6 @@ async fn test_basic_following(
|
||||
workspace_a.update(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)),
|
||||
None
|
||||
);
|
||||
executor.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -2006,7 +2004,7 @@ async fn join_channel(
|
||||
client: &TestClient,
|
||||
cx: &mut TestAppContext,
|
||||
) -> anyhow::Result<()> {
|
||||
cx.update(|cx| workspace::open_channel(channel_id, client.app_state.clone(), None, cx))
|
||||
cx.update(|cx| workspace::join_channel(channel_id, client.app_state.clone(), None, cx))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -2079,3 +2077,66 @@ async fn test_following_to_channel_notes_other_workspace(
|
||||
assert_eq!(editor.tab_description(0, cx).unwrap(), "1.txt");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_following_while_deactivated(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
|
||||
let (_, client_a, client_b, channel) = TestServer::start2(cx_a, cx_b).await;
|
||||
|
||||
let mut cx_a2 = cx_a.clone();
|
||||
let (workspace_a, cx_a) = client_a.build_test_workspace(cx_a).await;
|
||||
join_channel(channel, &client_a, cx_a).await.unwrap();
|
||||
share_workspace(&workspace_a, cx_a).await.unwrap();
|
||||
|
||||
// a opens 1.txt
|
||||
cx_a.simulate_keystrokes("cmd-p 1 enter");
|
||||
cx_a.run_until_parked();
|
||||
workspace_a.update(cx_a, |workspace, cx| {
|
||||
let editor = workspace.active_item(cx).unwrap();
|
||||
assert_eq!(editor.tab_description(0, cx).unwrap(), "1.txt");
|
||||
});
|
||||
|
||||
// b joins channel and is following a
|
||||
join_channel(channel, &client_b, cx_b).await.unwrap();
|
||||
cx_b.run_until_parked();
|
||||
let (workspace_b, cx_b) = client_b.active_workspace(cx_b);
|
||||
workspace_b.update(cx_b, |workspace, cx| {
|
||||
let editor = workspace.active_item(cx).unwrap();
|
||||
assert_eq!(editor.tab_description(0, cx).unwrap(), "1.txt");
|
||||
});
|
||||
|
||||
// stop following
|
||||
cx_b.simulate_keystrokes("down");
|
||||
|
||||
// a opens a different file while not followed
|
||||
cx_a.simulate_keystrokes("cmd-p 2 enter");
|
||||
|
||||
workspace_b.update(cx_b, |workspace, cx| {
|
||||
let editor = workspace.active_item_as::<Editor>(cx).unwrap();
|
||||
assert_eq!(editor.tab_description(0, cx).unwrap(), "1.txt");
|
||||
});
|
||||
|
||||
// a opens a file in a new window
|
||||
let (_, cx_a2) = client_a.build_test_workspace(&mut cx_a2).await;
|
||||
cx_a2.update(|cx| cx.activate_window());
|
||||
cx_a2.simulate_keystrokes("cmd-p 3 enter");
|
||||
cx_a2.run_until_parked();
|
||||
|
||||
// b starts following a again
|
||||
cx_b.simulate_keystrokes("cmd-ctrl-alt-f");
|
||||
cx_a.run_until_parked();
|
||||
|
||||
// a returns to the shared project
|
||||
cx_a.update(|cx| cx.activate_window());
|
||||
cx_a.run_until_parked();
|
||||
|
||||
workspace_a.update(cx_a, |workspace, cx| {
|
||||
let editor = workspace.active_item(cx).unwrap();
|
||||
assert_eq!(editor.tab_description(0, cx).unwrap(), "2.js");
|
||||
});
|
||||
|
||||
// b should follow a back
|
||||
workspace_b.update(cx_b, |workspace, cx| {
|
||||
let editor = workspace.active_item_as::<Editor>(cx).unwrap();
|
||||
assert_eq!(editor.tab_description(0, cx).unwrap(), "2.js");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1881,7 +1881,7 @@ fn active_call_events(cx: &mut TestAppContext) -> Rc<RefCell<Vec<room::Event>>>
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_mute(
|
||||
async fn test_mute_deafen(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
@@ -1920,7 +1920,7 @@ async fn test_mute(
|
||||
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 unmuted.
|
||||
// Users A and B are both muted.
|
||||
assert_eq!(
|
||||
participant_audio_state(&room_a, cx_a),
|
||||
&[ParticipantAudioState {
|
||||
@@ -1962,6 +1962,30 @@ async fn test_mute(
|
||||
}]
|
||||
);
|
||||
|
||||
// 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| {
|
||||
@@ -1976,6 +2000,22 @@ async fn test_mute(
|
||||
.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),
|
||||
&[
|
||||
|
||||
@@ -37,7 +37,7 @@ use std::{
|
||||
Arc,
|
||||
},
|
||||
};
|
||||
use util::{http::FakeHttpClient, SemanticVersion};
|
||||
use util::http::FakeHttpClient;
|
||||
use workspace::{Workspace, WorkspaceStore};
|
||||
|
||||
pub struct TestServer {
|
||||
@@ -231,7 +231,6 @@ impl TestServer {
|
||||
server_conn,
|
||||
client_name,
|
||||
user,
|
||||
SemanticVersion::default(),
|
||||
None,
|
||||
Some(connection_id_tx),
|
||||
Executor::Deterministic(cx.background_executor().clone()),
|
||||
@@ -480,6 +479,7 @@ impl TestServer {
|
||||
Arc::new(AppState {
|
||||
db: test_db.db().clone(),
|
||||
live_kit_client: Some(Arc::new(fake_server.create_api_client())),
|
||||
blob_store_client: None,
|
||||
config: Config {
|
||||
http_port: 0,
|
||||
database_url: "".into(),
|
||||
@@ -492,6 +492,11 @@ impl TestServer {
|
||||
rust_log: None,
|
||||
log_json: None,
|
||||
zed_environment: "test".into(),
|
||||
blob_store_url: None,
|
||||
blob_store_region: None,
|
||||
blob_store_access_key: None,
|
||||
blob_store_secret_key: None,
|
||||
blob_store_bucket: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -687,7 +692,7 @@ impl TestClient {
|
||||
channel_id: u64,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (View<Workspace>, &'a mut VisualTestContext) {
|
||||
cx.update(|cx| workspace::open_channel(channel_id, self.app_state.clone(), None, cx))
|
||||
cx.update(|cx| workspace::join_channel(channel_id, self.app_state.clone(), None, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
@@ -762,11 +767,6 @@ impl TestClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn join_channel_call(cx: &mut TestAppContext) -> Task<anyhow::Result<()>> {
|
||||
let room = cx.read(|cx| ActiveCall::global(cx).read(cx).room().cloned());
|
||||
room.unwrap().update(cx, |room, cx| room.join_call(cx))
|
||||
}
|
||||
|
||||
pub fn open_channel_notes(
|
||||
channel_id: u64,
|
||||
cx: &mut VisualTestContext,
|
||||
|
||||
@@ -733,7 +733,7 @@ impl Render for ChatPanel {
|
||||
v_flex()
|
||||
.key_context("ChatPanel")
|
||||
.track_focus(&self.focus_handle)
|
||||
.full()
|
||||
.size_full()
|
||||
.on_action(cx.listener(Self::send))
|
||||
.child(
|
||||
h_flex().z_index(1).child(
|
||||
@@ -755,11 +755,11 @@ impl Render for ChatPanel {
|
||||
)
|
||||
.child(div().flex_grow().px_2().map(|this| {
|
||||
if self.active_chat.is_some() {
|
||||
this.child(list(self.message_list.clone()).full())
|
||||
this.child(list(self.message_list.clone()).size_full())
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.full()
|
||||
.size_full()
|
||||
.p_4()
|
||||
.child(
|
||||
Label::new("Select a channel to chat in.")
|
||||
|
||||
@@ -40,7 +40,7 @@ use util::{maybe, ResultExt, TryFutureExt};
|
||||
use workspace::{
|
||||
dock::{DockPosition, Panel, PanelEvent},
|
||||
notifications::{DetachAndPromptErr, NotifyResultExt, NotifyTaskExt},
|
||||
Workspace,
|
||||
OpenChannelNotes, Workspace,
|
||||
};
|
||||
|
||||
actions!(
|
||||
@@ -69,6 +69,19 @@ pub fn init(cx: &mut AppContext) {
|
||||
workspace.register_action(|workspace, _: &ToggleFocus, cx| {
|
||||
workspace.toggle_panel_focus::<CollabPanel>(cx);
|
||||
});
|
||||
workspace.register_action(|_, _: &OpenChannelNotes, cx| {
|
||||
let channel_id = ActiveCall::global(cx)
|
||||
.read(cx)
|
||||
.room()
|
||||
.and_then(|room| room.read(cx).channel_id());
|
||||
|
||||
if let Some(channel_id) = channel_id {
|
||||
let workspace = cx.view().clone();
|
||||
cx.window_context().defer(move |cx| {
|
||||
ChannelView::open(channel_id, None, workspace, cx).detach_and_log_err(cx)
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -162,9 +175,6 @@ enum ListEntry {
|
||||
depth: usize,
|
||||
has_children: bool,
|
||||
},
|
||||
ChannelCall {
|
||||
channel_id: ChannelId,
|
||||
},
|
||||
ChannelNotes {
|
||||
channel_id: ChannelId,
|
||||
},
|
||||
@@ -372,7 +382,6 @@ impl CollabPanel {
|
||||
|
||||
if query.is_empty() {
|
||||
if let Some(channel_id) = room.channel_id() {
|
||||
self.entries.push(ListEntry::ChannelCall { channel_id });
|
||||
self.entries.push(ListEntry::ChannelNotes { channel_id });
|
||||
self.entries.push(ListEntry::ChannelChat { channel_id });
|
||||
}
|
||||
@@ -470,7 +479,7 @@ impl CollabPanel {
|
||||
&& participant.video_tracks.is_empty(),
|
||||
});
|
||||
}
|
||||
if room.in_call() && !participant.video_tracks.is_empty() {
|
||||
if !participant.video_tracks.is_empty() {
|
||||
self.entries.push(ListEntry::ParticipantScreen {
|
||||
peer_id: Some(participant.peer_id),
|
||||
is_last: true,
|
||||
@@ -504,20 +513,6 @@ impl CollabPanel {
|
||||
role: proto::ChannelRole::Member,
|
||||
}));
|
||||
}
|
||||
} else if let Some(channel_id) = ActiveCall::global(cx).read(cx).pending_channel_id() {
|
||||
self.entries.push(ListEntry::Header(Section::ActiveCall));
|
||||
if !old_entries
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall)))
|
||||
{
|
||||
scroll_to_top = true;
|
||||
}
|
||||
|
||||
if query.is_empty() {
|
||||
self.entries.push(ListEntry::ChannelCall { channel_id });
|
||||
self.entries.push(ListEntry::ChannelNotes { channel_id });
|
||||
self.entries.push(ListEntry::ChannelChat { channel_id });
|
||||
}
|
||||
}
|
||||
|
||||
let mut request_entries = Vec::new();
|
||||
@@ -837,6 +832,8 @@ impl CollabPanel {
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> ListItem {
|
||||
let user_id = user.id;
|
||||
let is_current_user =
|
||||
self.user_store.read(cx).current_user().map(|user| user.id) == Some(user_id);
|
||||
let tooltip = format!("Follow {}", user.github_login);
|
||||
|
||||
let is_call_admin = ActiveCall::global(cx).read(cx).room().is_some_and(|room| {
|
||||
@@ -849,6 +846,12 @@ impl CollabPanel {
|
||||
.selected(is_selected)
|
||||
.end_slot(if is_pending {
|
||||
Label::new("Calling").color(Color::Muted).into_any_element()
|
||||
} else if is_current_user {
|
||||
IconButton::new("leave-call", IconName::Exit)
|
||||
.style(ButtonStyle::Subtle)
|
||||
.on_click(move |_, cx| Self::leave_call(cx))
|
||||
.tooltip(|cx| Tooltip::text("Leave Call", cx))
|
||||
.into_any_element()
|
||||
} else if role == proto::ChannelRole::Guest {
|
||||
Label::new("Guest").color(Color::Muted).into_any_element()
|
||||
} else {
|
||||
@@ -950,88 +953,12 @@ impl CollabPanel {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_channel_call(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
is_selected: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> impl IntoElement {
|
||||
let (is_in_call, call_participants) = ActiveCall::global(cx)
|
||||
.read(cx)
|
||||
.room()
|
||||
.map(|room| (room.read(cx).in_call(), room.read(cx).call_participants(cx)))
|
||||
.unwrap_or_default();
|
||||
|
||||
const FACEPILE_LIMIT: usize = 3;
|
||||
|
||||
let face_pile = if !call_participants.is_empty() {
|
||||
let extra_count = call_participants.len().saturating_sub(FACEPILE_LIMIT);
|
||||
let result = FacePile::new(
|
||||
call_participants
|
||||
.iter()
|
||||
.map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
|
||||
.take(FACEPILE_LIMIT)
|
||||
.chain(if extra_count > 0 {
|
||||
Some(
|
||||
div()
|
||||
.ml_2()
|
||||
.child(Label::new(format!("+{extra_count}")))
|
||||
.into_any_element(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.collect::<SmallVec<_>>(),
|
||||
);
|
||||
|
||||
Some(result)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
ListItem::new("channel-call")
|
||||
.selected(is_selected)
|
||||
.start_slot(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(render_tree_branch(false, true, cx))
|
||||
.child(IconButton::new(0, IconName::AudioOn)),
|
||||
)
|
||||
.when(is_in_call, |el| {
|
||||
el.end_slot(
|
||||
IconButton::new(1, IconName::Exit)
|
||||
.style(ButtonStyle::Filled)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.tooltip(|cx| Tooltip::text("Leave call", cx))
|
||||
.on_click(cx.listener(|this, _, cx| this.leave_channel_call(cx))),
|
||||
)
|
||||
})
|
||||
.when(!is_in_call, |el| {
|
||||
el.tooltip(move |cx| Tooltip::text("Join audio call", cx))
|
||||
.on_click(cx.listener(move |this, _, cx| {
|
||||
this.join_channel_call(channel_id, cx);
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.text_ui()
|
||||
.when(!call_participants.is_empty(), |el| {
|
||||
el.font_weight(FontWeight::SEMIBOLD)
|
||||
})
|
||||
.child("call"),
|
||||
)
|
||||
.children(face_pile)
|
||||
}
|
||||
|
||||
fn render_channel_notes(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
is_selected: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> impl IntoElement {
|
||||
let channel_store = self.channel_store.read(cx);
|
||||
let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id);
|
||||
|
||||
ListItem::new("channel-notes")
|
||||
.selected(is_selected)
|
||||
.on_click(cx.listener(move |this, _, cx| {
|
||||
@@ -1043,14 +970,7 @@ impl CollabPanel {
|
||||
.child(render_tree_branch(false, true, cx))
|
||||
.child(IconButton::new(0, IconName::File)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_ui()
|
||||
.when(has_notes_notification, |el| {
|
||||
el.font_weight(FontWeight::SEMIBOLD)
|
||||
})
|
||||
.child("notes"),
|
||||
)
|
||||
.child(Label::new("notes"))
|
||||
.tooltip(move |cx| Tooltip::text("Open Channel Notes", cx))
|
||||
}
|
||||
|
||||
@@ -1060,8 +980,6 @@ impl CollabPanel {
|
||||
is_selected: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> impl IntoElement {
|
||||
let channel_store = self.channel_store.read(cx);
|
||||
let has_messages_notification = channel_store.has_new_messages(channel_id);
|
||||
ListItem::new("channel-chat")
|
||||
.selected(is_selected)
|
||||
.on_click(cx.listener(move |this, _, cx| {
|
||||
@@ -1073,14 +991,7 @@ impl CollabPanel {
|
||||
.child(render_tree_branch(false, false, cx))
|
||||
.child(IconButton::new(0, IconName::MessageBubbles)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_ui()
|
||||
.when(has_messages_notification, |el| {
|
||||
el.font_weight(FontWeight::SEMIBOLD)
|
||||
})
|
||||
.child("chat"),
|
||||
)
|
||||
.child(Label::new("chat"))
|
||||
.tooltip(move |cx| Tooltip::text("Open Chat", cx))
|
||||
}
|
||||
|
||||
@@ -1338,14 +1249,12 @@ impl CollabPanel {
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let this = cx.view().clone();
|
||||
let room = ActiveCall::global(cx).read(cx).room();
|
||||
let in_room = room.is_some();
|
||||
let in_call = room.is_some_and(|room| room.read(cx).in_call());
|
||||
let in_room = ActiveCall::global(cx).read(cx).room().is_some();
|
||||
|
||||
let context_menu = ContextMenu::build(cx, |mut context_menu, _| {
|
||||
let user_id = contact.user.id;
|
||||
|
||||
if contact.online && !contact.busy && (!in_room || in_call) {
|
||||
if contact.online && !contact.busy {
|
||||
let label = if in_room {
|
||||
format!("Invite {} to join", contact.user.github_login)
|
||||
} else {
|
||||
@@ -1493,7 +1402,7 @@ impl CollabPanel {
|
||||
if is_active {
|
||||
self.open_channel_notes(channel.id, cx)
|
||||
} else {
|
||||
self.open_channel(channel.id, cx)
|
||||
self.join_channel(channel.id, cx)
|
||||
}
|
||||
}
|
||||
ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
|
||||
@@ -1512,9 +1421,6 @@ impl CollabPanel {
|
||||
ListEntry::ChannelInvite(channel) => {
|
||||
self.respond_to_channel_invite(channel.id, true, cx)
|
||||
}
|
||||
ListEntry::ChannelCall { channel_id } => {
|
||||
self.join_channel_call(*channel_id, cx)
|
||||
}
|
||||
ListEntry::ChannelNotes { channel_id } => {
|
||||
self.open_channel_notes(*channel_id, cx)
|
||||
}
|
||||
@@ -1977,14 +1883,14 @@ impl CollabPanel {
|
||||
.detach_and_prompt_err("Call failed", cx, |_, _| None);
|
||||
}
|
||||
|
||||
fn open_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
|
||||
fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(handle) = cx.window_handle().downcast::<Workspace>() else {
|
||||
return;
|
||||
};
|
||||
workspace::open_channel(
|
||||
workspace::join_channel(
|
||||
channel_id,
|
||||
workspace.read(cx).app_state().clone(),
|
||||
Some(handle),
|
||||
@@ -1993,23 +1899,6 @@ impl CollabPanel {
|
||||
.detach_and_prompt_err("Failed to join channel", cx, |_, _| None)
|
||||
}
|
||||
|
||||
fn join_channel_call(&mut self, _channel_id: ChannelId, cx: &mut ViewContext<Self>) {
|
||||
let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
room.update(cx, |room, cx| room.join_call(cx))
|
||||
.detach_and_prompt_err("Failed to join call", cx, |_, _| None)
|
||||
}
|
||||
|
||||
fn leave_channel_call(&mut self, cx: &mut ViewContext<Self>) {
|
||||
let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
room.update(cx, |room, cx| room.leave_call(cx));
|
||||
}
|
||||
|
||||
fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return;
|
||||
@@ -2135,9 +2024,6 @@ impl CollabPanel {
|
||||
ListEntry::ParticipantScreen { peer_id, is_last } => self
|
||||
.render_participant_screen(*peer_id, *is_last, is_selected, cx)
|
||||
.into_any_element(),
|
||||
ListEntry::ChannelCall { channel_id } => self
|
||||
.render_channel_call(*channel_id, is_selected, cx)
|
||||
.into_any_element(),
|
||||
ListEntry::ChannelNotes { channel_id } => self
|
||||
.render_channel_notes(*channel_id, is_selected, cx)
|
||||
.into_any_element(),
|
||||
@@ -2150,7 +2036,7 @@ impl CollabPanel {
|
||||
fn render_signed_in(&mut self, cx: &mut ViewContext<Self>) -> Div {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.child(list(self.list_state.clone()).full())
|
||||
.child(list(self.list_state.clone()).size_full())
|
||||
.child(
|
||||
v_flex()
|
||||
.child(div().mx_2().border_primary(cx).border_t())
|
||||
@@ -2203,25 +2089,24 @@ impl CollabPanel {
|
||||
is_collapsed: bool,
|
||||
cx: &ViewContext<Self>,
|
||||
) -> impl IntoElement {
|
||||
let mut channel_link = None;
|
||||
let mut channel_tooltip_text = None;
|
||||
let mut channel_icon = None;
|
||||
|
||||
let text = match section {
|
||||
Section::ActiveCall => {
|
||||
let channel_name = maybe!({
|
||||
let channel_id = ActiveCall::global(cx)
|
||||
.read(cx)
|
||||
.channel_id(cx)
|
||||
.or_else(|| ActiveCall::global(cx).read(cx).pending_channel_id())?;
|
||||
let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
|
||||
|
||||
let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
|
||||
|
||||
channel_link = Some(channel.link());
|
||||
(channel_icon, channel_tooltip_text) = match channel.visibility {
|
||||
proto::ChannelVisibility::Public => {
|
||||
(Some(IconName::Public), Some("Close Channel"))
|
||||
(Some("icons/public.svg"), Some("Copy public channel link."))
|
||||
}
|
||||
proto::ChannelVisibility::Members => {
|
||||
(Some(IconName::Hash), Some("Close Channel"))
|
||||
(Some("icons/hash.svg"), Some("Copy private channel link."))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2243,10 +2128,17 @@ impl CollabPanel {
|
||||
};
|
||||
|
||||
let button = match section {
|
||||
Section::ActiveCall => channel_icon.map(|_| {
|
||||
IconButton::new("channel-link", IconName::Close)
|
||||
.on_click(move |_, cx| Self::leave_call(cx))
|
||||
.tooltip(|cx| Tooltip::text("Close channel", cx))
|
||||
Section::ActiveCall => channel_link.map(|channel_link| {
|
||||
let channel_link_copy = channel_link.clone();
|
||||
IconButton::new("channel-link", IconName::Copy)
|
||||
.icon_size(IconSize::Small)
|
||||
.size(ButtonSize::None)
|
||||
.visible_on_hover("section-header")
|
||||
.on_click(move |_, cx| {
|
||||
let item = ClipboardItem::new(channel_link_copy.clone());
|
||||
cx.write_to_clipboard(item)
|
||||
})
|
||||
.tooltip(|cx| Tooltip::text("Copy channel link", cx))
|
||||
.into_any_element()
|
||||
}),
|
||||
Section::Contacts => Some(
|
||||
@@ -2281,9 +2173,6 @@ impl CollabPanel {
|
||||
this.toggle_section_expanded(section, cx);
|
||||
}))
|
||||
})
|
||||
.when_some(channel_icon, |el, channel_icon| {
|
||||
el.start_slot(Icon::new(channel_icon).color(Color::Muted))
|
||||
})
|
||||
.inset(true)
|
||||
.end_slot::<AnyElement>(button)
|
||||
.selected(is_selected),
|
||||
@@ -2589,9 +2478,11 @@ impl CollabPanel {
|
||||
}),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, cx| {
|
||||
this.open_channel(channel_id, cx);
|
||||
this.open_channel_notes(channel_id, cx);
|
||||
this.join_channel_chat(channel_id, cx);
|
||||
if is_active {
|
||||
this.open_channel_notes(channel_id, cx)
|
||||
} else {
|
||||
this.join_channel(channel_id, cx)
|
||||
}
|
||||
}))
|
||||
.on_secondary_mouse_down(cx.listener(
|
||||
move |this, event: &MouseDownEvent, cx| {
|
||||
@@ -2608,24 +2499,61 @@ impl CollabPanel {
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
h_flex().id(channel_id as usize).child(
|
||||
div()
|
||||
.text_ui()
|
||||
.when(has_messages_notification || has_notes_notification, |el| {
|
||||
el.font_weight(FontWeight::SEMIBOLD)
|
||||
})
|
||||
.child(channel.name.clone()),
|
||||
),
|
||||
h_flex()
|
||||
.id(channel_id as usize)
|
||||
.child(Label::new(channel.name.clone()))
|
||||
.children(face_pile.map(|face_pile| face_pile.p_1())),
|
||||
),
|
||||
)
|
||||
.children(face_pile.map(|face_pile| {
|
||||
.child(
|
||||
h_flex()
|
||||
.absolute()
|
||||
.right(rems(0.))
|
||||
.z_index(1)
|
||||
.h_full()
|
||||
.child(face_pile.p_1())
|
||||
}))
|
||||
.child(
|
||||
h_flex()
|
||||
.h_full()
|
||||
.gap_1()
|
||||
.px_1()
|
||||
.child(
|
||||
IconButton::new("channel_chat", IconName::MessageBubbles)
|
||||
.style(ButtonStyle::Filled)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(if has_messages_notification {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, cx| {
|
||||
this.join_channel_chat(channel_id, cx)
|
||||
}))
|
||||
.tooltip(|cx| Tooltip::text("Open channel chat", cx))
|
||||
.when(!has_messages_notification, |this| {
|
||||
this.visible_on_hover("")
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("channel_notes", IconName::File)
|
||||
.style(ButtonStyle::Filled)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(if has_notes_notification {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, cx| {
|
||||
this.open_channel_notes(channel_id, cx)
|
||||
}))
|
||||
.tooltip(|cx| Tooltip::text("Open channel notes", cx))
|
||||
.when(!has_notes_notification, |this| {
|
||||
this.visible_on_hover("")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.tooltip({
|
||||
let channel_store = self.channel_store.clone();
|
||||
move |cx| {
|
||||
@@ -2829,14 +2757,6 @@ impl PartialEq for ListEntry {
|
||||
return channel_1.id == channel_2.id;
|
||||
}
|
||||
}
|
||||
ListEntry::ChannelCall { channel_id } => {
|
||||
if let ListEntry::ChannelCall {
|
||||
channel_id: other_id,
|
||||
} = other
|
||||
{
|
||||
return channel_id == other_id;
|
||||
}
|
||||
}
|
||||
ListEntry::ChannelNotes { channel_id } => {
|
||||
if let ListEntry::ChannelNotes {
|
||||
channel_id: other_id,
|
||||
@@ -2935,7 +2855,7 @@ impl Render for JoinChannelTooltip {
|
||||
.read(cx)
|
||||
.channel_participants(self.channel_id);
|
||||
|
||||
div.child(Label::new("Open Channel"))
|
||||
div.child(Label::new("Join Channel"))
|
||||
.children(participants.iter().map(|participant| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
|
||||
@@ -11,7 +11,7 @@ use gpui::{
|
||||
};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use std::sync::Arc;
|
||||
use ui::{prelude::*, Avatar, Checkbox, ContextMenu, ListItem, ListItemSpacing};
|
||||
use ui::{prelude::*, Avatar, CheckboxWithLabel, ContextMenu, ListItem, ListItemSpacing};
|
||||
use util::TryFutureExt;
|
||||
use workspace::{notifications::DetachAndPromptErr, ModalView};
|
||||
|
||||
@@ -177,22 +177,16 @@ impl Render for ChannelModal {
|
||||
.h(rems(22. / 16.))
|
||||
.justify_between()
|
||||
.line_height(rems(1.25))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Checkbox::new(
|
||||
"is-public",
|
||||
if visibility == ChannelVisibility::Public {
|
||||
ui::Selection::Selected
|
||||
} else {
|
||||
ui::Selection::Unselected
|
||||
},
|
||||
)
|
||||
.on_click(cx.listener(Self::set_channel_visibility)),
|
||||
)
|
||||
.child(Label::new("Public").size(LabelSize::Small)),
|
||||
)
|
||||
.child(CheckboxWithLabel::new(
|
||||
"is-public",
|
||||
Label::new("Public").size(LabelSize::Small),
|
||||
if visibility == ChannelVisibility::Public {
|
||||
ui::Selection::Selected
|
||||
} else {
|
||||
ui::Selection::Unselected
|
||||
},
|
||||
cx.listener(Self::set_channel_visibility),
|
||||
))
|
||||
.children(
|
||||
Some(
|
||||
Button::new("copy-link", "Copy Link")
|
||||
|
||||
@@ -102,10 +102,6 @@ impl Render for CollabTitlebarItem {
|
||||
room.remote_participants().values().collect::<Vec<_>>();
|
||||
remote_participants.sort_by_key(|p| p.participant_index.0);
|
||||
|
||||
if !room.in_call() {
|
||||
return this;
|
||||
}
|
||||
|
||||
let current_user_face_pile = self.render_collaborator(
|
||||
¤t_user,
|
||||
peer_id,
|
||||
@@ -137,10 +133,6 @@ impl Render for CollabTitlebarItem {
|
||||
== ParticipantLocation::SharedProject { project_id }
|
||||
});
|
||||
|
||||
if !collaborator.in_call {
|
||||
return None;
|
||||
}
|
||||
|
||||
let face_pile = self.render_collaborator(
|
||||
&collaborator.user,
|
||||
collaborator.peer_id,
|
||||
@@ -193,7 +185,7 @@ impl Render for CollabTitlebarItem {
|
||||
let is_local = project.is_local();
|
||||
let is_shared = is_local && project.is_shared();
|
||||
let is_muted = room.is_muted();
|
||||
let is_connected_to_livekit = room.in_call();
|
||||
let is_deafened = room.is_deafened().unwrap_or(false);
|
||||
let is_screen_sharing = room.is_screen_sharing();
|
||||
let read_only = room.read_only();
|
||||
|
||||
@@ -228,28 +220,22 @@ impl Render for CollabTitlebarItem {
|
||||
)),
|
||||
)
|
||||
})
|
||||
.when(is_connected_to_livekit, |el| {
|
||||
el.child(
|
||||
div()
|
||||
.child(
|
||||
IconButton::new("leave-call", ui::IconName::Exit)
|
||||
.style(ButtonStyle::Subtle)
|
||||
.tooltip(|cx| Tooltip::text("Leave call", cx))
|
||||
.icon_size(IconSize::Small)
|
||||
.on_click(move |_, cx| {
|
||||
ActiveCall::global(cx).update(cx, |call, cx| {
|
||||
if let Some(room) = call.room() {
|
||||
room.update(cx, |room, cx| {
|
||||
room.leave_call(cx)
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
.pl_2(),
|
||||
)
|
||||
})
|
||||
.when(!read_only && is_connected_to_livekit, |this| {
|
||||
.child(
|
||||
div()
|
||||
.child(
|
||||
IconButton::new("leave-call", ui::IconName::Exit)
|
||||
.style(ButtonStyle::Subtle)
|
||||
.tooltip(|cx| Tooltip::text("Leave call", cx))
|
||||
.icon_size(IconSize::Small)
|
||||
.on_click(move |_, cx| {
|
||||
ActiveCall::global(cx)
|
||||
.update(cx, |call, cx| call.hang_up(cx))
|
||||
.detach_and_log_err(cx);
|
||||
}),
|
||||
)
|
||||
.pr_2(),
|
||||
)
|
||||
.when(!read_only, |this| {
|
||||
this.child(
|
||||
IconButton::new(
|
||||
"mute-microphone",
|
||||
@@ -276,7 +262,34 @@ impl Render for CollabTitlebarItem {
|
||||
.on_click(move |_, cx| crate::toggle_mute(&Default::default(), cx)),
|
||||
)
|
||||
})
|
||||
.when(!read_only && is_connected_to_livekit, |this| {
|
||||
.child(
|
||||
IconButton::new(
|
||||
"mute-sound",
|
||||
if is_deafened {
|
||||
ui::IconName::AudioOff
|
||||
} else {
|
||||
ui::IconName::AudioOn
|
||||
},
|
||||
)
|
||||
.style(ButtonStyle::Subtle)
|
||||
.selected_style(ButtonStyle::Tinted(TintColor::Negative))
|
||||
.icon_size(IconSize::Small)
|
||||
.selected(is_deafened)
|
||||
.tooltip(move |cx| {
|
||||
if !read_only {
|
||||
Tooltip::with_meta(
|
||||
"Deafen Audio",
|
||||
None,
|
||||
"Mic will be muted",
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
Tooltip::text("Deafen Audio", cx)
|
||||
}
|
||||
})
|
||||
.on_click(move |_, cx| crate::toggle_deafen(&Default::default(), cx)),
|
||||
)
|
||||
.when(!read_only, |this| {
|
||||
this.child(
|
||||
IconButton::new("screen-share", ui::IconName::Screen)
|
||||
.style(ButtonStyle::Subtle)
|
||||
@@ -553,10 +566,7 @@ impl CollabTitlebarItem {
|
||||
ActiveCall::global(cx)
|
||||
.update(cx, |call, cx| call.set_location(Some(&self.project), cx))
|
||||
.detach_and_log_err(cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if cx.active_window().is_none() {
|
||||
} else if cx.active_window().is_none() {
|
||||
ActiveCall::global(cx)
|
||||
.update(cx, |call, cx| call.set_location(None, cx))
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
@@ -22,7 +22,10 @@ pub use panel_settings::{
|
||||
use settings::Settings;
|
||||
use workspace::{notifications::DetachAndPromptErr, AppState};
|
||||
|
||||
actions!(collab, [ToggleScreenSharing, ToggleMute, LeaveCall]);
|
||||
actions!(
|
||||
collab,
|
||||
[ToggleScreenSharing, ToggleMute, ToggleDeafen, LeaveCall]
|
||||
);
|
||||
|
||||
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
|
||||
CollaborationPanelSettings::register(cx);
|
||||
@@ -82,6 +85,12 @@ pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) {
|
||||
if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
|
||||
room.update(cx, |room, cx| room.toggle_deafen(cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn notification_window_options(
|
||||
screen: Rc<dyn PlatformDisplay>,
|
||||
window_size: Size<Pixels>,
|
||||
|
||||
@@ -317,10 +317,8 @@ impl PickerDelegate for CommandPaletteDelegate {
|
||||
});
|
||||
let action = command.action;
|
||||
cx.focus(&self.previous_focus_handle);
|
||||
cx.window_context()
|
||||
.spawn(move |mut cx| async move { cx.update(|cx| cx.dispatch_action(action)) })
|
||||
.detach_and_log_err(cx);
|
||||
self.dismissed(cx);
|
||||
cx.dispatch_action(action);
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
|
||||
@@ -116,6 +116,11 @@ impl CopilotCodeVerification {
|
||||
.full_width()
|
||||
.style(ButtonStyle::Filled),
|
||||
)
|
||||
.child(
|
||||
Button::new("copilot-enable-cancel-button", "Cancel")
|
||||
.full_width()
|
||||
.on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))),
|
||||
)
|
||||
}
|
||||
fn render_enabled_modal(cx: &mut ViewContext<Self>) -> impl Element {
|
||||
v_flex()
|
||||
@@ -131,7 +136,7 @@ impl CopilotCodeVerification {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_unauthorized_modal() -> impl Element {
|
||||
fn render_unauthorized_modal(cx: &mut ViewContext<Self>) -> impl Element {
|
||||
v_flex()
|
||||
.child(Headline::new("You must have an active GitHub Copilot subscription.").size(HeadlineSize::Large))
|
||||
|
||||
@@ -143,6 +148,11 @@ impl CopilotCodeVerification {
|
||||
.full_width()
|
||||
.on_click(|_, cx| cx.open_url(COPILOT_SIGN_UP_URL)),
|
||||
)
|
||||
.child(
|
||||
Button::new("copilot-subscribe-cancel-button", "Cancel")
|
||||
.full_width()
|
||||
.on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_disabled_modal() -> impl Element {
|
||||
@@ -160,7 +170,7 @@ impl Render for CopilotCodeVerification {
|
||||
} => Self::render_prompting_modal(self.connect_clicked, &prompt, cx).into_any_element(),
|
||||
Status::Unauthorized => {
|
||||
self.connect_clicked = false;
|
||||
Self::render_unauthorized_modal().into_any_element()
|
||||
Self::render_unauthorized_modal(cx).into_any_element()
|
||||
}
|
||||
Status::Authorized => {
|
||||
self.connect_clicked = false;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 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)
|
||||
//! 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.
|
||||
|
||||
@@ -399,6 +399,7 @@ pub struct Editor {
|
||||
workspace: Option<(WeakView<Workspace>, i64)>,
|
||||
keymap_context_layers: BTreeMap<TypeId, KeyContext>,
|
||||
input_enabled: bool,
|
||||
use_modal_editing: bool,
|
||||
read_only: bool,
|
||||
leader_peer_id: Option<PeerId>,
|
||||
remote_id: Option<ViewId>,
|
||||
@@ -773,11 +774,11 @@ impl CompletionsMenu {
|
||||
cx,
|
||||
);
|
||||
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
return cx.spawn(move |this, mut cx| async move {
|
||||
if let Some(true) = resolve_task.await.log_err() {
|
||||
this.update(&mut cx, |_, cx| cx.notify()).ok();
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn attempt_resolve_selected_completion_documentation(
|
||||
@@ -1016,12 +1017,53 @@ impl CompletionsMenu {
|
||||
|
||||
let completions = self.completions.read();
|
||||
matches.sort_unstable_by_key(|mat| {
|
||||
// We do want to strike a balance here between what the language server tells us
|
||||
// to sort by (the sort_text) and what are "obvious" good matches (i.e. when you type
|
||||
// `Creat` and there is a local variable called `CreateComponent`).
|
||||
// So what we do is: we bucket all matches into two buckets
|
||||
// - Strong matches
|
||||
// - Weak matches
|
||||
// Strong matches are the ones with a high fuzzy-matcher score (the "obvious" matches)
|
||||
// and the Weak matches are the rest.
|
||||
//
|
||||
// For the strong matches, we sort by the language-servers score first and for the weak
|
||||
// matches, we prefer our fuzzy finder first.
|
||||
//
|
||||
// The thinking behind that: it's useless to take the sort_text the language-server gives
|
||||
// us into account when it's obviously a bad match.
|
||||
|
||||
#[derive(PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum MatchScore<'a> {
|
||||
Strong {
|
||||
sort_text: Option<&'a str>,
|
||||
score: Reverse<OrderedFloat<f64>>,
|
||||
sort_key: (usize, &'a str),
|
||||
},
|
||||
Weak {
|
||||
score: Reverse<OrderedFloat<f64>>,
|
||||
sort_text: Option<&'a str>,
|
||||
sort_key: (usize, &'a str),
|
||||
},
|
||||
}
|
||||
|
||||
let completion = &completions[mat.candidate_id];
|
||||
(
|
||||
completion.lsp_completion.sort_text.as_ref(),
|
||||
Reverse(OrderedFloat(mat.score)),
|
||||
completion.sort_key(),
|
||||
)
|
||||
let sort_key = completion.sort_key();
|
||||
let sort_text = completion.lsp_completion.sort_text.as_deref();
|
||||
let score = Reverse(OrderedFloat(mat.score));
|
||||
|
||||
if mat.score >= 0.2 {
|
||||
MatchScore::Strong {
|
||||
sort_text,
|
||||
score,
|
||||
sort_key,
|
||||
}
|
||||
} else {
|
||||
MatchScore::Weak {
|
||||
score,
|
||||
sort_text,
|
||||
sort_key,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for mat in &mut matches {
|
||||
@@ -1482,6 +1524,7 @@ impl Editor {
|
||||
workspace: None,
|
||||
keymap_context_layers: Default::default(),
|
||||
input_enabled: true,
|
||||
use_modal_editing: mode == EditorMode::Full,
|
||||
read_only: false,
|
||||
use_autoclose: true,
|
||||
leader_peer_id: None,
|
||||
@@ -1782,6 +1825,14 @@ impl Editor {
|
||||
self.show_copilot_suggestions = show_copilot_suggestions;
|
||||
}
|
||||
|
||||
pub fn set_use_modal_editing(&mut self, to: bool) {
|
||||
self.use_modal_editing = to;
|
||||
}
|
||||
|
||||
pub fn use_modal_editing(&self) -> bool {
|
||||
self.use_modal_editing
|
||||
}
|
||||
|
||||
fn selections_did_change(
|
||||
&mut self,
|
||||
local: bool,
|
||||
@@ -7609,8 +7660,9 @@ impl Editor {
|
||||
let snapshot = cursor_buffer.read(cx).snapshot();
|
||||
let cursor_buffer_offset = cursor_buffer_position.to_offset(&snapshot);
|
||||
let prepare_rename = project.update(cx, |project, cx| {
|
||||
project.prepare_rename(cursor_buffer, cursor_buffer_offset, cx)
|
||||
project.prepare_rename(cursor_buffer.clone(), cursor_buffer_offset, cx)
|
||||
});
|
||||
drop(snapshot);
|
||||
|
||||
Some(cx.spawn(|this, mut cx| async move {
|
||||
let rename_range = if let Some(range) = prepare_rename.await? {
|
||||
@@ -7630,11 +7682,12 @@ impl Editor {
|
||||
})?
|
||||
};
|
||||
if let Some(rename_range) = rename_range {
|
||||
let rename_buffer_range = rename_range.to_offset(&snapshot);
|
||||
let cursor_offset_in_rename_range =
|
||||
cursor_buffer_offset.saturating_sub(rename_buffer_range.start);
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
let snapshot = cursor_buffer.read(cx).snapshot();
|
||||
let rename_buffer_range = rename_range.to_offset(&snapshot);
|
||||
let cursor_offset_in_rename_range =
|
||||
cursor_buffer_offset.saturating_sub(rename_buffer_range.start);
|
||||
|
||||
this.take_rename(false, cx);
|
||||
let buffer = this.buffer.read(cx).read(cx);
|
||||
let cursor_offset = selection.head().to_offset(&buffer);
|
||||
@@ -7836,7 +7889,6 @@ impl Editor {
|
||||
Some(rename)
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn pending_rename(&self) -> Option<&RenameState> {
|
||||
self.pending_rename.as_ref()
|
||||
}
|
||||
@@ -8505,6 +8557,17 @@ impl Editor {
|
||||
color_fetcher: fn(&ThemeColors) -> Hsla,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let snapshot = self.snapshot(cx);
|
||||
// this is to try and catch a panic sooner
|
||||
for range in &ranges {
|
||||
snapshot
|
||||
.buffer_snapshot
|
||||
.summary_for_anchor::<usize>(&range.start);
|
||||
snapshot
|
||||
.buffer_snapshot
|
||||
.summary_for_anchor::<usize>(&range.end);
|
||||
}
|
||||
|
||||
self.background_highlights
|
||||
.insert(TypeId::of::<T>(), (color_fetcher, ranges));
|
||||
cx.notify();
|
||||
|
||||
@@ -374,7 +374,7 @@ impl EditorElement {
|
||||
) {
|
||||
let mouse_position = cx.mouse_position();
|
||||
if !text_bounds.contains(&mouse_position)
|
||||
|| !cx.is_top_layer(&mouse_position, stacking_order)
|
||||
|| !cx.was_top_layer(&mouse_position, stacking_order)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -406,7 +406,7 @@ impl EditorElement {
|
||||
} else if !text_bounds.contains(&event.position) {
|
||||
return;
|
||||
}
|
||||
if !cx.is_top_layer(&event.position, stacking_order) {
|
||||
if !cx.was_top_layer(&event.position, stacking_order) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -478,11 +478,11 @@ impl EditorElement {
|
||||
editor.select(SelectPhase::End, cx);
|
||||
}
|
||||
|
||||
if interactive_bounds.did_visibly_contains(&event.position, cx)
|
||||
if interactive_bounds.visibly_contains(&event.position, cx)
|
||||
&& !pending_nonempty_selections
|
||||
&& event.modifiers.command
|
||||
&& text_bounds.contains(&event.position)
|
||||
&& cx.is_top_layer(&event.position, stacking_order)
|
||||
&& cx.was_top_layer(&event.position, stacking_order)
|
||||
{
|
||||
let point = position_map.point_for_position(text_bounds, event.position);
|
||||
editor.handle_click_hovered_link(point, event.modifiers, cx);
|
||||
@@ -550,7 +550,7 @@ impl EditorElement {
|
||||
let modifiers = event.modifiers;
|
||||
let text_hovered = text_bounds.contains(&event.position);
|
||||
let gutter_hovered = gutter_bounds.contains(&event.position);
|
||||
let was_top = cx.is_top_layer(&event.position, stacking_order);
|
||||
let was_top = cx.was_top_layer(&event.position, stacking_order);
|
||||
|
||||
editor.set_gutter_hovered(gutter_hovered, cx);
|
||||
|
||||
@@ -903,7 +903,7 @@ impl EditorElement {
|
||||
bounds: text_bounds,
|
||||
stacking_order: cx.stacking_order().clone(),
|
||||
};
|
||||
if interactive_text_bounds.did_visibly_contains(&cx.mouse_position(), cx) {
|
||||
if interactive_text_bounds.visibly_contains(&cx.mouse_position(), cx) {
|
||||
if self
|
||||
.editor
|
||||
.read(cx)
|
||||
@@ -1242,7 +1242,7 @@ impl EditorElement {
|
||||
popover_origin.x = popover_origin.x + x_out_of_bounds;
|
||||
}
|
||||
|
||||
if cx.is_top_layer(&popover_origin, cx.stacking_order()) {
|
||||
if cx.was_top_layer(&popover_origin, cx.stacking_order()) {
|
||||
cx.break_content_mask(|cx| {
|
||||
hover_popover.draw(popover_origin, available_space, cx)
|
||||
});
|
||||
@@ -1537,7 +1537,7 @@ impl EditorElement {
|
||||
stacking_order: cx.stacking_order().clone(),
|
||||
};
|
||||
let mut mouse_position = cx.mouse_position();
|
||||
if interactive_track_bounds.did_visibly_contains(&mouse_position, cx) {
|
||||
if interactive_track_bounds.visibly_contains(&mouse_position, cx) {
|
||||
cx.set_cursor_style(CursorStyle::Arrow);
|
||||
}
|
||||
|
||||
@@ -1567,7 +1567,7 @@ impl EditorElement {
|
||||
cx.stop_propagation();
|
||||
} else {
|
||||
editor.scroll_manager.set_is_dragging_scrollbar(false, cx);
|
||||
if interactive_track_bounds.did_visibly_contains(&event.position, cx) {
|
||||
if interactive_track_bounds.visibly_contains(&event.position, cx) {
|
||||
editor.scroll_manager.show_scrollbar(cx);
|
||||
}
|
||||
}
|
||||
@@ -2652,14 +2652,14 @@ impl EditorElement {
|
||||
|
||||
move |event: &ScrollWheelEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& interactive_bounds.did_visibly_contains(&event.position, cx)
|
||||
&& interactive_bounds.visibly_contains(&event.position, cx)
|
||||
{
|
||||
delta = delta.coalesce(event.delta);
|
||||
editor.update(cx, |editor, cx| {
|
||||
let position = event.position;
|
||||
let position_map: &PositionMap = &position_map;
|
||||
let bounds = &interactive_bounds;
|
||||
if !bounds.did_visibly_contains(&position, cx) {
|
||||
if !bounds.visibly_contains(&position, cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2719,7 +2719,7 @@ impl EditorElement {
|
||||
|
||||
move |event: &MouseDownEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& interactive_bounds.did_visibly_contains(&event.position, cx)
|
||||
&& interactive_bounds.visibly_contains(&event.position, cx)
|
||||
{
|
||||
match event.button {
|
||||
MouseButton::Left => editor.update(cx, |editor, cx| {
|
||||
@@ -2786,7 +2786,7 @@ impl EditorElement {
|
||||
)
|
||||
}
|
||||
|
||||
if interactive_bounds.did_visibly_contains(&event.position, cx) {
|
||||
if interactive_bounds.visibly_contains(&event.position, cx) {
|
||||
Self::mouse_moved(
|
||||
editor,
|
||||
event,
|
||||
@@ -3067,7 +3067,7 @@ impl Element for EditorElement {
|
||||
) {
|
||||
let editor = self.editor.clone();
|
||||
|
||||
cx.with_view(self.editor.entity_id(), |cx| {
|
||||
cx.paint_view(self.editor.entity_id(), |cx| {
|
||||
cx.with_text_style(
|
||||
Some(gpui::TextStyleRefinement {
|
||||
font_size: Some(self.style.text.font_size),
|
||||
|
||||
@@ -289,6 +289,7 @@ fn show_hover(
|
||||
})?;
|
||||
|
||||
let hover_result = hover_request.await.ok().flatten();
|
||||
let snapshot = this.update(&mut cx, |this, cx| this.snapshot(cx))?;
|
||||
let hover_popover = match hover_result {
|
||||
Some(hover_result) if !hover_result.is_empty() => {
|
||||
// Create symbol range of anchors for highlighting and filtering of future requests.
|
||||
|
||||
@@ -704,10 +704,12 @@ impl Item for Editor {
|
||||
|
||||
fn save(&mut self, project: Model<Project>, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
|
||||
self.report_editor_event("save", None, cx);
|
||||
let format = self.perform_format(project.clone(), FormatTrigger::Save, cx);
|
||||
let buffers = self.buffer().clone().read(cx).all_buffers();
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
format.await?;
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.perform_format(project.clone(), FormatTrigger::Save, cx)
|
||||
})?
|
||||
.await?;
|
||||
|
||||
if buffers.len() == 1 {
|
||||
project
|
||||
|
||||
@@ -5,6 +5,7 @@ use super::{Bias, DisplayPoint, DisplaySnapshot, SelectionGoal, ToDisplayPoint};
|
||||
use crate::{char_kind, scroll::ScrollAnchor, CharKind, EditorStyle, ToOffset, ToPoint};
|
||||
use gpui::{px, Pixels, WindowTextSystem};
|
||||
use language::Point;
|
||||
use multi_buffer::MultiBufferSnapshot;
|
||||
|
||||
use std::{ops::Range, sync::Arc};
|
||||
|
||||
@@ -254,7 +255,7 @@ pub fn previous_word_start(map: &DisplaySnapshot, point: DisplayPoint) -> Displa
|
||||
let raw_point = point.to_point(map);
|
||||
let scope = map.buffer_snapshot.language_scope_at(raw_point);
|
||||
|
||||
find_preceding_boundary(map, point, FindRange::MultiLine, |left, right| {
|
||||
find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| {
|
||||
(char_kind(&scope, left) != char_kind(&scope, right) && !right.is_whitespace())
|
||||
|| left == '\n'
|
||||
})
|
||||
@@ -267,7 +268,7 @@ pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> Dis
|
||||
let raw_point = point.to_point(map);
|
||||
let scope = map.buffer_snapshot.language_scope_at(raw_point);
|
||||
|
||||
find_preceding_boundary(map, point, FindRange::MultiLine, |left, right| {
|
||||
find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| {
|
||||
let is_word_start =
|
||||
char_kind(&scope, left) != char_kind(&scope, right) && !right.is_whitespace();
|
||||
let is_subword_start =
|
||||
@@ -366,16 +367,16 @@ pub fn end_of_paragraph(
|
||||
/// indicated by the given predicate returning true.
|
||||
/// The predicate is called with the character to the left and right of the candidate boundary location.
|
||||
/// If FindRange::SingleLine is specified and no boundary is found before the start of the current line, the start of the current line will be returned.
|
||||
pub fn find_preceding_boundary(
|
||||
map: &DisplaySnapshot,
|
||||
from: DisplayPoint,
|
||||
pub fn find_preceding_boundary_point(
|
||||
buffer_snapshot: &MultiBufferSnapshot,
|
||||
from: Point,
|
||||
find_range: FindRange,
|
||||
mut is_boundary: impl FnMut(char, char) -> bool,
|
||||
) -> DisplayPoint {
|
||||
) -> Point {
|
||||
let mut prev_ch = None;
|
||||
let mut offset = from.to_point(map).to_offset(&map.buffer_snapshot);
|
||||
let mut offset = from.to_offset(&buffer_snapshot);
|
||||
|
||||
for ch in map.buffer_snapshot.reversed_chars_at(offset) {
|
||||
for ch in buffer_snapshot.reversed_chars_at(offset) {
|
||||
if find_range == FindRange::SingleLine && ch == '\n' {
|
||||
break;
|
||||
}
|
||||
@@ -389,7 +390,26 @@ pub fn find_preceding_boundary(
|
||||
prev_ch = Some(ch);
|
||||
}
|
||||
|
||||
map.clip_point(offset.to_display_point(map), Bias::Left)
|
||||
offset.to_point(&buffer_snapshot)
|
||||
}
|
||||
|
||||
/// Scans for a boundary preceding the given start point `from` until a boundary is found,
|
||||
/// indicated by the given predicate returning true.
|
||||
/// The predicate is called with the character to the left and right of the candidate boundary location.
|
||||
/// If FindRange::SingleLine is specified and no boundary is found before the start of the current line, the start of the current line will be returned.
|
||||
pub fn find_preceding_boundary_display_point(
|
||||
map: &DisplaySnapshot,
|
||||
from: DisplayPoint,
|
||||
find_range: FindRange,
|
||||
is_boundary: impl FnMut(char, char) -> bool,
|
||||
) -> DisplayPoint {
|
||||
let result = find_preceding_boundary_point(
|
||||
&map.buffer_snapshot,
|
||||
from.to_point(map),
|
||||
find_range,
|
||||
is_boundary,
|
||||
);
|
||||
map.clip_point(result.to_display_point(map), Bias::Left)
|
||||
}
|
||||
|
||||
/// Scans for a boundary following the given start point until a boundary is found, indicated by the
|
||||
@@ -626,7 +646,7 @@ mod tests {
|
||||
) {
|
||||
let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
|
||||
assert_eq!(
|
||||
find_preceding_boundary(
|
||||
find_preceding_boundary_display_point(
|
||||
&snapshot,
|
||||
display_points[1],
|
||||
FindRange::MultiLine,
|
||||
@@ -700,7 +720,7 @@ mod tests {
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
find_preceding_boundary(
|
||||
find_preceding_boundary_display_point(
|
||||
&snapshot,
|
||||
buffer_snapshot.len().to_display_point(&snapshot),
|
||||
FindRange::MultiLine,
|
||||
|
||||
@@ -14,20 +14,26 @@ path = "src/extension_json_schemas.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-compression.workspace = true
|
||||
async-tar.workspace = true
|
||||
client.workspace = true
|
||||
collections.workspace = true
|
||||
fs.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
language.workspace = true
|
||||
log.workspace = true
|
||||
parking_lot.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
theme.workspace = true
|
||||
toml.workspace = true
|
||||
util.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
client = { workspace = true, features = ["test-support"] }
|
||||
fs = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
language = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use collections::HashMap;
|
||||
use fs::Fs;
|
||||
use anyhow::{anyhow, bail, Context as _, Result};
|
||||
use async_compression::futures::bufread::GzipDecoder;
|
||||
use async_tar::Archive;
|
||||
use client::ClientSettings;
|
||||
use collections::{BTreeMap, HashSet};
|
||||
use fs::{Fs, RemoveOptions};
|
||||
use futures::channel::mpsc::unbounded;
|
||||
use futures::StreamExt as _;
|
||||
use futures::{io::BufReader, AsyncReadExt as _};
|
||||
use gpui::{actions, AppContext, Context, Global, Model, ModelContext, Task};
|
||||
use language::{
|
||||
LanguageConfig, LanguageMatcher, LanguageQueries, LanguageRegistry, QUERY_FILENAME_PREFIXES,
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::Settings as _;
|
||||
use std::cmp::Ordering;
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
path::{Path, PathBuf},
|
||||
@@ -15,18 +22,51 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
use theme::{ThemeRegistry, ThemeSettings};
|
||||
use util::{paths::EXTENSIONS_DIR, ResultExt};
|
||||
use util::http::AsyncBody;
|
||||
use util::TryFutureExt;
|
||||
use util::{http::HttpClient, paths::EXTENSIONS_DIR, ResultExt};
|
||||
|
||||
#[cfg(test)]
|
||||
mod extension_store_test;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ExtensionsApiResponse {
|
||||
pub data: Vec<Extension>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Extension {
|
||||
pub id: Arc<str>,
|
||||
pub version: Arc<str>,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub authors: Vec<String>,
|
||||
pub repository: String,
|
||||
pub download_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ExtensionStatus {
|
||||
NotInstalled,
|
||||
Installing,
|
||||
Upgrading,
|
||||
Installed(Arc<str>),
|
||||
Removing,
|
||||
}
|
||||
|
||||
pub struct ExtensionStore {
|
||||
manifest: Arc<RwLock<Manifest>>,
|
||||
fs: Arc<dyn Fs>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
extensions_dir: PathBuf,
|
||||
extensions_being_installed: HashSet<Arc<str>>,
|
||||
extensions_being_uninstalled: HashSet<Arc<str>>,
|
||||
manifest_path: PathBuf,
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
theme_registry: Arc<ThemeRegistry>,
|
||||
extension_changes: ExtensionChanges,
|
||||
reload_task: Option<Task<Option<()>>>,
|
||||
needs_reload: bool,
|
||||
_watch_extensions_dir: [Task<()>; 2],
|
||||
}
|
||||
|
||||
@@ -34,11 +74,12 @@ struct GlobalExtensionStore(Model<ExtensionStore>);
|
||||
|
||||
impl Global for GlobalExtensionStore {}
|
||||
|
||||
#[derive(Deserialize, Serialize, Default)]
|
||||
#[derive(Debug, Deserialize, Serialize, Default)]
|
||||
pub struct Manifest {
|
||||
pub grammars: HashMap<Arc<str>, GrammarManifestEntry>,
|
||||
pub languages: HashMap<Arc<str>, LanguageManifestEntry>,
|
||||
pub themes: HashMap<String, ThemeManifestEntry>,
|
||||
pub extensions: BTreeMap<Arc<str>, Arc<str>>,
|
||||
pub grammars: BTreeMap<Arc<str>, GrammarManifestEntry>,
|
||||
pub languages: BTreeMap<Arc<str>, LanguageManifestEntry>,
|
||||
pub themes: BTreeMap<Arc<str>, ThemeManifestEntry>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, PartialOrd, Ord, Deserialize, Serialize)]
|
||||
@@ -61,10 +102,18 @@ pub struct ThemeManifestEntry {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ExtensionChanges {
|
||||
languages: HashSet<Arc<str>>,
|
||||
grammars: HashSet<Arc<str>>,
|
||||
themes: HashSet<Arc<str>>,
|
||||
}
|
||||
|
||||
actions!(zed, [ReloadExtensions]);
|
||||
|
||||
pub fn init(
|
||||
fs: Arc<fs::RealFs>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
theme_registry: Arc<ThemeRegistry>,
|
||||
cx: &mut AppContext,
|
||||
@@ -73,6 +122,7 @@ pub fn init(
|
||||
ExtensionStore::new(
|
||||
EXTENSIONS_DIR.clone(),
|
||||
fs.clone(),
|
||||
http_client.clone(),
|
||||
language_registry.clone(),
|
||||
theme_registry,
|
||||
cx,
|
||||
@@ -81,18 +131,21 @@ pub fn init(
|
||||
|
||||
cx.on_action(|_: &ReloadExtensions, cx| {
|
||||
let store = cx.global::<GlobalExtensionStore>().0.clone();
|
||||
store
|
||||
.update(cx, |store, cx| store.reload(cx))
|
||||
.detach_and_log_err(cx);
|
||||
store.update(cx, |store, cx| store.reload(cx))
|
||||
});
|
||||
|
||||
cx.set_global(GlobalExtensionStore(store));
|
||||
}
|
||||
|
||||
impl ExtensionStore {
|
||||
pub fn global(cx: &AppContext) -> Model<Self> {
|
||||
cx.global::<GlobalExtensionStore>().0.clone()
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
extensions_dir: PathBuf,
|
||||
fs: Arc<dyn Fs>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
theme_registry: Arc<ThemeRegistry>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
@@ -101,7 +154,13 @@ impl ExtensionStore {
|
||||
manifest: Default::default(),
|
||||
extensions_dir: extensions_dir.join("installed"),
|
||||
manifest_path: extensions_dir.join("manifest.json"),
|
||||
extensions_being_installed: Default::default(),
|
||||
extensions_being_uninstalled: Default::default(),
|
||||
reload_task: None,
|
||||
needs_reload: false,
|
||||
extension_changes: ExtensionChanges::default(),
|
||||
fs,
|
||||
http_client,
|
||||
language_registry,
|
||||
theme_registry,
|
||||
_watch_extensions_dir: [Task::ready(()), Task::ready(())],
|
||||
@@ -136,19 +195,218 @@ impl ExtensionStore {
|
||||
};
|
||||
|
||||
if should_reload {
|
||||
self.reload(cx).detach_and_log_err(cx);
|
||||
self.reload(cx)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extensions_dir(&self) -> PathBuf {
|
||||
self.extensions_dir.clone()
|
||||
}
|
||||
|
||||
pub fn extension_status(&self, extension_id: &str) -> ExtensionStatus {
|
||||
let is_uninstalling = self.extensions_being_uninstalled.contains(extension_id);
|
||||
if is_uninstalling {
|
||||
return ExtensionStatus::Removing;
|
||||
}
|
||||
|
||||
let installed_version = self.manifest.read().extensions.get(extension_id).cloned();
|
||||
let is_installing = self.extensions_being_installed.contains(extension_id);
|
||||
match (installed_version, is_installing) {
|
||||
(Some(_), true) => ExtensionStatus::Upgrading,
|
||||
(Some(version), false) => ExtensionStatus::Installed(version.clone()),
|
||||
(None, true) => ExtensionStatus::Installing,
|
||||
(None, false) => ExtensionStatus::NotInstalled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fetch_extensions(
|
||||
&self,
|
||||
search: Option<&str>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<Vec<Extension>>> {
|
||||
let url = format!(
|
||||
"{}/{}{query}",
|
||||
ClientSettings::get_global(cx).server_url,
|
||||
"api/extensions",
|
||||
query = search
|
||||
.map(|search| format!("?filter={search}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
let http_client = self.http_client.clone();
|
||||
cx.spawn(move |_, _| async move {
|
||||
let mut response = http_client.get(&url, AsyncBody::empty(), true).await?;
|
||||
|
||||
let mut body = Vec::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_end(&mut body)
|
||||
.await
|
||||
.context("error reading extensions")?;
|
||||
|
||||
if response.status().is_client_error() {
|
||||
let text = String::from_utf8_lossy(body.as_slice());
|
||||
bail!(
|
||||
"status error {}, response: {text:?}",
|
||||
response.status().as_u16()
|
||||
);
|
||||
}
|
||||
|
||||
let response: ExtensionsApiResponse = serde_json::from_slice(&body)?;
|
||||
|
||||
Ok(response.data)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn install_extension(
|
||||
&mut self,
|
||||
extension_id: Arc<str>,
|
||||
version: Arc<str>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) {
|
||||
log::info!("installing extension {extension_id} {version}");
|
||||
let url = format!(
|
||||
"{}/api/extensions/{extension_id}/{version}/download",
|
||||
ClientSettings::get_global(cx).server_url
|
||||
);
|
||||
|
||||
let extensions_dir = self.extensions_dir();
|
||||
let http_client = self.http_client.clone();
|
||||
|
||||
self.extensions_being_installed.insert(extension_id.clone());
|
||||
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
let mut response = http_client
|
||||
.get(&url, Default::default(), true)
|
||||
.await
|
||||
.map_err(|err| anyhow!("error downloading extension: {}", err))?;
|
||||
let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
|
||||
let archive = Archive::new(decompressed_bytes);
|
||||
archive
|
||||
.unpack(extensions_dir.join(extension_id.as_ref()))
|
||||
.await?;
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.extensions_being_installed
|
||||
.remove(extension_id.as_ref());
|
||||
this.reload(cx)
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
pub fn uninstall_extension(&mut self, extension_id: Arc<str>, cx: &mut ModelContext<Self>) {
|
||||
let extensions_dir = self.extensions_dir();
|
||||
let fs = self.fs.clone();
|
||||
|
||||
self.extensions_being_uninstalled
|
||||
.insert(extension_id.clone());
|
||||
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
fs.remove_dir(
|
||||
&extensions_dir.join(extension_id.as_ref()),
|
||||
RemoveOptions {
|
||||
recursive: true,
|
||||
ignore_if_not_exists: true,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.extensions_being_uninstalled
|
||||
.remove(extension_id.as_ref());
|
||||
this.reload(cx)
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx)
|
||||
}
|
||||
|
||||
/// Updates the set of installed extensions.
|
||||
///
|
||||
/// First, this unloads any themes, languages, or grammars that are
|
||||
/// no longer in the manifest, or whose files have changed on disk.
|
||||
/// Then it loads any themes, languages, or grammars that are newly
|
||||
/// added to the manifest, or whose files have changed on disk.
|
||||
fn manifest_updated(&mut self, manifest: Manifest, cx: &mut ModelContext<Self>) {
|
||||
fn diff<'a, T, I1, I2>(
|
||||
old_keys: I1,
|
||||
new_keys: I2,
|
||||
modified_keys: &HashSet<Arc<str>>,
|
||||
) -> (Vec<Arc<str>>, Vec<Arc<str>>)
|
||||
where
|
||||
T: PartialEq,
|
||||
I1: Iterator<Item = (&'a Arc<str>, T)>,
|
||||
I2: Iterator<Item = (&'a Arc<str>, T)>,
|
||||
{
|
||||
let mut removed_keys = Vec::default();
|
||||
let mut added_keys = Vec::default();
|
||||
let mut old_keys = old_keys.peekable();
|
||||
let mut new_keys = new_keys.peekable();
|
||||
loop {
|
||||
match (old_keys.peek(), new_keys.peek()) {
|
||||
(None, None) => return (removed_keys, added_keys),
|
||||
(None, Some(_)) => {
|
||||
added_keys.push(new_keys.next().unwrap().0.clone());
|
||||
}
|
||||
(Some(_), None) => {
|
||||
removed_keys.push(old_keys.next().unwrap().0.clone());
|
||||
}
|
||||
(Some((old_key, _)), Some((new_key, _))) => match old_key.cmp(&new_key) {
|
||||
Ordering::Equal => {
|
||||
let (old_key, old_value) = old_keys.next().unwrap();
|
||||
let (new_key, new_value) = new_keys.next().unwrap();
|
||||
if old_value != new_value || modified_keys.contains(old_key) {
|
||||
removed_keys.push(old_key.clone());
|
||||
added_keys.push(new_key.clone());
|
||||
}
|
||||
}
|
||||
Ordering::Less => {
|
||||
removed_keys.push(old_keys.next().unwrap().0.clone());
|
||||
}
|
||||
Ordering::Greater => {
|
||||
added_keys.push(new_keys.next().unwrap().0.clone());
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let old_manifest = self.manifest.read();
|
||||
let (languages_to_remove, languages_to_add) = diff(
|
||||
old_manifest.languages.iter(),
|
||||
manifest.languages.iter(),
|
||||
&self.extension_changes.languages,
|
||||
);
|
||||
let (grammars_to_remove, grammars_to_add) = diff(
|
||||
old_manifest.grammars.iter(),
|
||||
manifest.grammars.iter(),
|
||||
&self.extension_changes.grammars,
|
||||
);
|
||||
let (themes_to_remove, themes_to_add) = diff(
|
||||
old_manifest.themes.iter(),
|
||||
manifest.themes.iter(),
|
||||
&self.extension_changes.themes,
|
||||
);
|
||||
self.extension_changes.clear();
|
||||
drop(old_manifest);
|
||||
|
||||
let themes_to_remove = &themes_to_remove
|
||||
.into_iter()
|
||||
.map(|theme| theme.into())
|
||||
.collect::<Vec<_>>();
|
||||
self.theme_registry.remove_user_themes(&themes_to_remove);
|
||||
self.language_registry
|
||||
.register_wasm_grammars(manifest.grammars.iter().map(|(grammar_name, grammar)| {
|
||||
.remove_languages(&languages_to_remove, &grammars_to_remove);
|
||||
|
||||
self.language_registry
|
||||
.register_wasm_grammars(grammars_to_add.iter().map(|grammar_name| {
|
||||
let grammar = manifest.grammars.get(grammar_name).unwrap();
|
||||
let mut grammar_path = self.extensions_dir.clone();
|
||||
grammar_path.extend([grammar.extension.as_ref(), grammar.path.as_path()]);
|
||||
(grammar_name.clone(), grammar_path)
|
||||
}));
|
||||
|
||||
for (language_name, language) in &manifest.languages {
|
||||
for language_name in &languages_to_add {
|
||||
let language = manifest.languages.get(language_name.as_ref()).unwrap();
|
||||
let mut language_path = self.extensions_dir.clone();
|
||||
language_path.extend([language.extension.as_ref(), language.path.as_path()]);
|
||||
self.language_registry.register_language(
|
||||
@@ -164,13 +422,18 @@ impl ExtensionStore {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let (reload_theme_tx, mut reload_theme_rx) = unbounded();
|
||||
let fs = self.fs.clone();
|
||||
let root_dir = self.extensions_dir.clone();
|
||||
let theme_registry = self.theme_registry.clone();
|
||||
let themes = manifest.themes.clone();
|
||||
let themes = themes_to_add
|
||||
.iter()
|
||||
.filter_map(|name| manifest.themes.get(name).cloned())
|
||||
.collect::<Vec<_>>();
|
||||
cx.background_executor()
|
||||
.spawn(async move {
|
||||
for theme in themes.values() {
|
||||
for theme in &themes {
|
||||
let mut theme_path = root_dir.clone();
|
||||
theme_path.extend([theme.extension.as_ref(), theme.path.as_path()]);
|
||||
|
||||
@@ -179,26 +442,40 @@ impl ExtensionStore {
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
|
||||
reload_theme_tx.unbounded_send(()).ok();
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|_, cx| async move {
|
||||
while let Some(_) = reload_theme_rx.next().await {
|
||||
if cx
|
||||
.update(|cx| ThemeSettings::reload_current_theme(cx))
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
*self.manifest.write() = manifest;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn watch_extensions_dir(&self, cx: &mut ModelContext<Self>) -> [Task<()>; 2] {
|
||||
let manifest = self.manifest.clone();
|
||||
let fs = self.fs.clone();
|
||||
let language_registry = self.language_registry.clone();
|
||||
let theme_registry = self.theme_registry.clone();
|
||||
let extensions_dir = self.extensions_dir.clone();
|
||||
|
||||
let (reload_theme_tx, mut reload_theme_rx) = futures::channel::mpsc::unbounded();
|
||||
let (changes_tx, mut changes_rx) = unbounded();
|
||||
|
||||
let events_task = cx.background_executor().spawn(async move {
|
||||
let mut events = fs.watch(&extensions_dir, Duration::from_millis(250)).await;
|
||||
while let Some(events) = events.next().await {
|
||||
let mut changed_grammars = Vec::default();
|
||||
let mut changed_languages = Vec::default();
|
||||
let mut changed_themes = Vec::default();
|
||||
let mut changed_grammars = HashSet::default();
|
||||
let mut changed_languages = HashSet::default();
|
||||
let mut changed_themes = HashSet::default();
|
||||
|
||||
{
|
||||
let manifest = manifest.read();
|
||||
@@ -208,7 +485,7 @@ impl ExtensionStore {
|
||||
grammar_path
|
||||
.extend([grammar.extension.as_ref(), grammar.path.as_path()]);
|
||||
if event.path.starts_with(&grammar_path) || event.path == grammar_path {
|
||||
changed_grammars.push(grammar_name.clone());
|
||||
changed_grammars.insert(grammar_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,40 +495,37 @@ impl ExtensionStore {
|
||||
.extend([language.extension.as_ref(), language.path.as_path()]);
|
||||
if event.path.starts_with(&language_path) || event.path == language_path
|
||||
{
|
||||
changed_languages.push(language_name.clone());
|
||||
changed_languages.insert(language_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for (_theme_name, theme) in &manifest.themes {
|
||||
for (theme_name, theme) in &manifest.themes {
|
||||
let mut theme_path = extensions_dir.clone();
|
||||
theme_path.extend([theme.extension.as_ref(), theme.path.as_path()]);
|
||||
if event.path.starts_with(&theme_path) || event.path == theme_path {
|
||||
changed_themes.push(theme_path.clone());
|
||||
changed_themes.insert(theme_name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
language_registry.reload_languages(&changed_languages, &changed_grammars);
|
||||
|
||||
for theme_path in &changed_themes {
|
||||
theme_registry
|
||||
.load_user_theme(&theme_path, fs.clone())
|
||||
.await
|
||||
.context("failed to load user theme")
|
||||
.log_err();
|
||||
}
|
||||
|
||||
if !changed_themes.is_empty() {
|
||||
reload_theme_tx.unbounded_send(()).ok();
|
||||
}
|
||||
changes_tx
|
||||
.unbounded_send(ExtensionChanges {
|
||||
languages: changed_languages,
|
||||
grammars: changed_grammars,
|
||||
themes: changed_themes,
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
|
||||
let reload_theme_task = cx.spawn(|_, cx| async move {
|
||||
while let Some(_) = reload_theme_rx.next().await {
|
||||
if cx
|
||||
.update(|cx| ThemeSettings::reload_current_theme(cx))
|
||||
let reload_task = cx.spawn(|this, mut cx| async move {
|
||||
while let Some(changes) = changes_rx.next().await {
|
||||
if this
|
||||
.update(&mut cx, |this, cx| {
|
||||
this.extension_changes.merge(changes);
|
||||
this.reload(cx);
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
@@ -259,123 +533,179 @@ impl ExtensionStore {
|
||||
}
|
||||
});
|
||||
|
||||
[events_task, reload_theme_task]
|
||||
[events_task, reload_task]
|
||||
}
|
||||
|
||||
pub fn reload(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
|
||||
fn reload(&mut self, cx: &mut ModelContext<Self>) {
|
||||
if self.reload_task.is_some() {
|
||||
self.needs_reload = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let fs = self.fs.clone();
|
||||
let extensions_dir = self.extensions_dir.clone();
|
||||
let manifest_path = self.manifest_path.clone();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let manifest = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
let mut manifest = Manifest::default();
|
||||
self.needs_reload = false;
|
||||
self.reload_task = Some(cx.spawn(|this, mut cx| {
|
||||
async move {
|
||||
let manifest = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
let mut manifest = Manifest::default();
|
||||
|
||||
let mut extension_paths = fs
|
||||
.read_dir(&extensions_dir)
|
||||
.await
|
||||
.context("failed to read extensions directory")?;
|
||||
while let Some(extension_dir) = extension_paths.next().await {
|
||||
let extension_dir = extension_dir?;
|
||||
let Some(extension_name) =
|
||||
extension_dir.file_name().and_then(OsStr::to_str)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
fs.create_dir(&extensions_dir).await.log_err();
|
||||
|
||||
if let Ok(mut grammar_paths) =
|
||||
fs.read_dir(&extension_dir.join("grammars")).await
|
||||
{
|
||||
while let Some(grammar_path) = grammar_paths.next().await {
|
||||
let grammar_path = grammar_path?;
|
||||
let Ok(relative_path) = grammar_path.strip_prefix(&extension_dir)
|
||||
else {
|
||||
let extension_paths = fs.read_dir(&extensions_dir).await;
|
||||
if let Ok(mut extension_paths) = extension_paths {
|
||||
while let Some(extension_dir) = extension_paths.next().await {
|
||||
let Ok(extension_dir) = extension_dir else {
|
||||
continue;
|
||||
};
|
||||
let Some(grammar_name) =
|
||||
grammar_path.file_stem().and_then(OsStr::to_str)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
manifest.grammars.insert(
|
||||
grammar_name.into(),
|
||||
GrammarManifestEntry {
|
||||
extension: extension_name.into(),
|
||||
path: relative_path.into(),
|
||||
},
|
||||
);
|
||||
Self::add_extension_to_manifest(
|
||||
fs.clone(),
|
||||
extension_dir,
|
||||
&mut manifest,
|
||||
)
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(mut language_paths) =
|
||||
fs.read_dir(&extension_dir.join("languages")).await
|
||||
{
|
||||
while let Some(language_path) = language_paths.next().await {
|
||||
let language_path = language_path?;
|
||||
let Ok(relative_path) = language_path.strip_prefix(&extension_dir)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let config = fs.load(&language_path.join("config.toml")).await?;
|
||||
let config = ::toml::from_str::<LanguageConfig>(&config)?;
|
||||
|
||||
manifest.languages.insert(
|
||||
config.name.clone(),
|
||||
LanguageManifestEntry {
|
||||
extension: extension_name.into(),
|
||||
path: relative_path.into(),
|
||||
matcher: config.matcher,
|
||||
grammar: config.grammar,
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Ok(manifest_json) = serde_json::to_string_pretty(&manifest) {
|
||||
fs.save(
|
||||
&manifest_path,
|
||||
&manifest_json.as_str().into(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.context("failed to save extension manifest")
|
||||
.log_err();
|
||||
}
|
||||
|
||||
if let Ok(mut theme_paths) =
|
||||
fs.read_dir(&extension_dir.join("themes")).await
|
||||
{
|
||||
while let Some(theme_path) = theme_paths.next().await {
|
||||
let theme_path = theme_path?;
|
||||
let Ok(relative_path) = theme_path.strip_prefix(&extension_dir)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
manifest
|
||||
})
|
||||
.await;
|
||||
|
||||
let Some(theme_family) =
|
||||
ThemeRegistry::read_user_theme(&theme_path, fs.clone())
|
||||
.await
|
||||
.log_err()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for theme in theme_family.themes {
|
||||
let location = ThemeManifestEntry {
|
||||
extension: extension_name.into(),
|
||||
path: relative_path.into(),
|
||||
};
|
||||
|
||||
manifest.themes.insert(theme.name, location);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.manifest_updated(manifest, cx);
|
||||
this.reload_task.take();
|
||||
if this.needs_reload {
|
||||
this.reload(cx);
|
||||
}
|
||||
|
||||
fs.save(
|
||||
&manifest_path,
|
||||
&serde_json::to_string_pretty(&manifest)?.as_str().into(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.context("failed to save extension manifest")?;
|
||||
|
||||
anyhow::Ok(manifest)
|
||||
})
|
||||
.await?;
|
||||
this.update(&mut cx, |this, cx| this.manifest_updated(manifest, cx))
|
||||
})
|
||||
}
|
||||
.log_err()
|
||||
}));
|
||||
}
|
||||
|
||||
async fn add_extension_to_manifest(
|
||||
fs: Arc<dyn Fs>,
|
||||
extension_dir: PathBuf,
|
||||
manifest: &mut Manifest,
|
||||
) -> Result<()> {
|
||||
let extension_name = extension_dir
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.ok_or_else(|| anyhow!("invalid extension name"))?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExtensionJson {
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
let extension_json_path = extension_dir.join("extension.json");
|
||||
let extension_json = fs
|
||||
.load(&extension_json_path)
|
||||
.await
|
||||
.context("failed to load extension.json")?;
|
||||
let extension_json: ExtensionJson =
|
||||
serde_json::from_str(&extension_json).context("invalid extension.json")?;
|
||||
|
||||
manifest
|
||||
.extensions
|
||||
.insert(extension_name.into(), extension_json.version.into());
|
||||
|
||||
if let Ok(mut grammar_paths) = fs.read_dir(&extension_dir.join("grammars")).await {
|
||||
while let Some(grammar_path) = grammar_paths.next().await {
|
||||
let grammar_path = grammar_path?;
|
||||
let Ok(relative_path) = grammar_path.strip_prefix(&extension_dir) else {
|
||||
continue;
|
||||
};
|
||||
let Some(grammar_name) = grammar_path.file_stem().and_then(OsStr::to_str) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
manifest.grammars.insert(
|
||||
grammar_name.into(),
|
||||
GrammarManifestEntry {
|
||||
extension: extension_name.into(),
|
||||
path: relative_path.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(mut language_paths) = fs.read_dir(&extension_dir.join("languages")).await {
|
||||
while let Some(language_path) = language_paths.next().await {
|
||||
let language_path = language_path?;
|
||||
let Ok(relative_path) = language_path.strip_prefix(&extension_dir) else {
|
||||
continue;
|
||||
};
|
||||
let config = fs.load(&language_path.join("config.toml")).await?;
|
||||
let config = ::toml::from_str::<LanguageConfig>(&config)?;
|
||||
|
||||
manifest.languages.insert(
|
||||
config.name.clone(),
|
||||
LanguageManifestEntry {
|
||||
extension: extension_name.into(),
|
||||
path: relative_path.into(),
|
||||
matcher: config.matcher,
|
||||
grammar: config.grammar,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(mut theme_paths) = fs.read_dir(&extension_dir.join("themes")).await {
|
||||
while let Some(theme_path) = theme_paths.next().await {
|
||||
let theme_path = theme_path?;
|
||||
let Ok(relative_path) = theme_path.strip_prefix(&extension_dir) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(theme_family) = ThemeRegistry::read_user_theme(&theme_path, fs.clone())
|
||||
.await
|
||||
.log_err()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for theme in theme_family.themes {
|
||||
let location = ThemeManifestEntry {
|
||||
extension: extension_name.into(),
|
||||
path: relative_path.into(),
|
||||
};
|
||||
|
||||
manifest.themes.insert(theme.name.into(), location);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtensionChanges {
|
||||
fn clear(&mut self) {
|
||||
self.grammars.clear();
|
||||
self.languages.clear();
|
||||
self.themes.clear();
|
||||
}
|
||||
|
||||
fn merge(&mut self, other: Self) {
|
||||
self.grammars.extend(other.grammars);
|
||||
self.languages.extend(other.languages);
|
||||
self.themes.extend(other.themes);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,32 @@ use fs::FakeFs;
|
||||
use gpui::{Context, TestAppContext};
|
||||
use language::{LanguageMatcher, LanguageRegistry};
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use theme::ThemeRegistry;
|
||||
use util::http::FakeHttpClient;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let store = SettingsStore::test(cx);
|
||||
cx.set_global(store);
|
||||
theme::init(theme::LoadThemes::JustBase, cx);
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
let http_client = FakeHttpClient::with_200_response();
|
||||
|
||||
fs.insert_tree(
|
||||
"/the-extension-dir",
|
||||
json!({
|
||||
"installed": {
|
||||
"zed-monokai": {
|
||||
"extension.json": r#"{
|
||||
"id": "zed-monokai",
|
||||
"name": "Zed Monokai",
|
||||
"version": "2.0.0"
|
||||
}"#,
|
||||
"themes": {
|
||||
"monokai.json": r#"{
|
||||
"name": "Monokai",
|
||||
@@ -53,6 +67,11 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
}
|
||||
},
|
||||
"zed-ruby": {
|
||||
"extension.json": r#"{
|
||||
"id": "zed-ruby",
|
||||
"name": "Zed Ruby",
|
||||
"version": "1.0.0"
|
||||
}"#,
|
||||
"grammars": {
|
||||
"ruby.wasm": "",
|
||||
"embedded_template.wasm": "",
|
||||
@@ -82,6 +101,12 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
.await;
|
||||
|
||||
let mut expected_manifest = Manifest {
|
||||
extensions: [
|
||||
("zed-ruby".into(), "1.0.0".into()),
|
||||
("zed-monokai".into(), "2.0.0".into()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
grammars: [
|
||||
(
|
||||
"embedded_template".into(),
|
||||
@@ -169,6 +194,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
ExtensionStore::new(
|
||||
PathBuf::from("/the-extension-dir"),
|
||||
fs.clone(),
|
||||
http_client.clone(),
|
||||
language_registry.clone(),
|
||||
theme_registry.clone(),
|
||||
cx,
|
||||
@@ -201,6 +227,11 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
fs.insert_tree(
|
||||
"/the-extension-dir/installed/zed-gruvbox",
|
||||
json!({
|
||||
"extension.json": r#"{
|
||||
"id": "zed-gruvbox",
|
||||
"name": "Zed Gruvbox",
|
||||
"version": "1.0.0"
|
||||
}"#,
|
||||
"themes": {
|
||||
"gruvbox.json": r#"{
|
||||
"name": "Gruvbox",
|
||||
@@ -226,10 +257,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
},
|
||||
);
|
||||
|
||||
store
|
||||
.update(cx, |store, cx| store.reload(cx))
|
||||
.await
|
||||
.unwrap();
|
||||
store.update(cx, |store, cx| store.reload(cx));
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
store.read_with(cx, |store, _| {
|
||||
@@ -260,6 +288,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
ExtensionStore::new(
|
||||
PathBuf::from("/the-extension-dir"),
|
||||
fs.clone(),
|
||||
http_client.clone(),
|
||||
language_registry.clone(),
|
||||
theme_registry.clone(),
|
||||
cx,
|
||||
@@ -277,6 +306,10 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
language_registry.language_names(),
|
||||
["ERB", "Plain Text", "Ruby"]
|
||||
);
|
||||
assert_eq!(
|
||||
language_registry.grammar_names(),
|
||||
["embedded_template".into(), "ruby".into()]
|
||||
);
|
||||
assert_eq!(
|
||||
theme_registry.list_names(false),
|
||||
[
|
||||
@@ -294,4 +327,25 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
assert_eq!(fs.read_dir_call_count(), prev_fs_read_dir_call_count);
|
||||
assert_eq!(fs.metadata_call_count(), prev_fs_metadata_call_count + 2);
|
||||
});
|
||||
|
||||
store.update(cx, |store, cx| {
|
||||
store.uninstall_extension("zed-ruby".into(), cx)
|
||||
});
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
expected_manifest.extensions.remove("zed-ruby");
|
||||
expected_manifest.languages.remove("Ruby");
|
||||
expected_manifest.languages.remove("ERB");
|
||||
expected_manifest.grammars.remove("ruby");
|
||||
expected_manifest.grammars.remove("embedded_template");
|
||||
|
||||
store.read_with(cx, |store, _| {
|
||||
let manifest = store.manifest.read();
|
||||
assert_eq!(manifest.grammars, expected_manifest.grammars);
|
||||
assert_eq!(manifest.languages, expected_manifest.languages);
|
||||
assert_eq!(manifest.themes, expected_manifest.themes);
|
||||
|
||||
assert_eq!(language_registry.language_names(), ["Plain Text"]);
|
||||
assert_eq!(language_registry.grammar_names(), []);
|
||||
});
|
||||
}
|
||||
|
||||
38
crates/extensions_ui/Cargo.toml
Normal file
38
crates/extensions_ui/Cargo.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "extensions_ui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lib]
|
||||
path = "src/extensions_ui.rs"
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-compression.workspace = true
|
||||
async-tar.workspace = true
|
||||
client.workspace = true
|
||||
db.workspace = true
|
||||
editor.workspace = true
|
||||
extension.workspace = true
|
||||
fs.workspace = true
|
||||
futures.workspace = true
|
||||
fuzzy.workspace = true
|
||||
gpui.workspace = true
|
||||
log.workspace = true
|
||||
picker.workspace = true
|
||||
project.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
theme.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
workspace.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
editor = { workspace = true, features = ["test-support"] }
|
||||
1
crates/extensions_ui/LICENSE-GPL
Symbolic link
1
crates/extensions_ui/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
478
crates/extensions_ui/src/extensions_ui.rs
Normal file
478
crates/extensions_ui/src/extensions_ui.rs
Normal file
@@ -0,0 +1,478 @@
|
||||
use client::telemetry::Telemetry;
|
||||
use editor::{Editor, EditorElement, EditorStyle};
|
||||
use extension::{Extension, ExtensionStatus, ExtensionStore};
|
||||
use gpui::{
|
||||
actions, canvas, uniform_list, AnyElement, AppContext, AvailableSpace, EventEmitter,
|
||||
FocusableView, FontStyle, FontWeight, InteractiveElement, KeyContext, ParentElement, Render,
|
||||
Styled, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, WhiteSpace,
|
||||
WindowContext,
|
||||
};
|
||||
use settings::Settings;
|
||||
use std::time::Duration;
|
||||
use std::{ops::Range, sync::Arc};
|
||||
use theme::ThemeSettings;
|
||||
use ui::prelude::*;
|
||||
|
||||
use workspace::{
|
||||
item::{Item, ItemEvent},
|
||||
Workspace, WorkspaceId,
|
||||
};
|
||||
|
||||
actions!(zed, [Extensions]);
|
||||
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
cx.observe_new_views(move |workspace: &mut Workspace, _cx| {
|
||||
workspace.register_action(move |workspace, _: &Extensions, cx| {
|
||||
let extensions_page = ExtensionsPage::new(workspace, cx);
|
||||
workspace.add_item(Box::new(extensions_page), cx)
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub struct ExtensionsPage {
|
||||
list: UniformListScrollHandle,
|
||||
telemetry: Arc<Telemetry>,
|
||||
is_fetching_extensions: bool,
|
||||
extensions_entries: Vec<Extension>,
|
||||
query_editor: View<Editor>,
|
||||
query_contains_error: bool,
|
||||
_subscription: gpui::Subscription,
|
||||
extension_fetch_task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl ExtensionsPage {
|
||||
pub fn new(workspace: &Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
|
||||
cx.new_view(|cx: &mut ViewContext<Self>| {
|
||||
let store = ExtensionStore::global(cx);
|
||||
let subscription = cx.observe(&store, |_, _, cx| cx.notify());
|
||||
|
||||
let query_editor = cx.new_view(|cx| Editor::single_line(cx));
|
||||
cx.subscribe(&query_editor, Self::on_query_change).detach();
|
||||
|
||||
let mut this = Self {
|
||||
list: UniformListScrollHandle::new(),
|
||||
telemetry: workspace.client().telemetry().clone(),
|
||||
is_fetching_extensions: false,
|
||||
extensions_entries: Vec::new(),
|
||||
query_contains_error: false,
|
||||
extension_fetch_task: None,
|
||||
_subscription: subscription,
|
||||
query_editor,
|
||||
};
|
||||
this.fetch_extensions(None, cx);
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
fn install_extension(
|
||||
&self,
|
||||
extension_id: Arc<str>,
|
||||
version: Arc<str>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
ExtensionStore::global(cx).update(cx, |store, cx| {
|
||||
store.install_extension(extension_id, version, cx)
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn uninstall_extension(&self, extension_id: Arc<str>, cx: &mut ViewContext<Self>) {
|
||||
ExtensionStore::global(cx)
|
||||
.update(cx, |store, cx| store.uninstall_extension(extension_id, cx));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn fetch_extensions(&mut self, search: Option<&str>, cx: &mut ViewContext<Self>) {
|
||||
self.is_fetching_extensions = true;
|
||||
cx.notify();
|
||||
|
||||
let extensions =
|
||||
ExtensionStore::global(cx).update(cx, |store, cx| store.fetch_extensions(search, cx));
|
||||
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
let fetch_result = extensions.await;
|
||||
match fetch_result {
|
||||
Ok(extensions) => this.update(&mut cx, |this, cx| {
|
||||
this.extensions_entries = extensions;
|
||||
this.is_fetching_extensions = false;
|
||||
cx.notify();
|
||||
}),
|
||||
Err(err) => {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.is_fetching_extensions = false;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn render_extensions(&mut self, range: Range<usize>, cx: &mut ViewContext<Self>) -> Vec<Div> {
|
||||
self.extensions_entries[range]
|
||||
.iter()
|
||||
.map(|extension| self.render_entry(extension, cx))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_entry(&self, extension: &Extension, cx: &mut ViewContext<Self>) -> Div {
|
||||
let status = ExtensionStore::global(cx)
|
||||
.read(cx)
|
||||
.extension_status(&extension.id);
|
||||
|
||||
let upgrade_button = match status.clone() {
|
||||
ExtensionStatus::NotInstalled
|
||||
| ExtensionStatus::Installing
|
||||
| ExtensionStatus::Removing => None,
|
||||
ExtensionStatus::Installed(installed_version) => {
|
||||
if installed_version != extension.version {
|
||||
Some(
|
||||
Button::new(
|
||||
SharedString::from(format!("upgrade-{}", extension.id)),
|
||||
"Upgrade",
|
||||
)
|
||||
.on_click(cx.listener({
|
||||
let extension_id = extension.id.clone();
|
||||
let version = extension.version.clone();
|
||||
move |this, _, cx| {
|
||||
this.telemetry
|
||||
.report_app_event("extensions: install extension".to_string());
|
||||
this.install_extension(extension_id.clone(), version.clone(), cx);
|
||||
}
|
||||
}))
|
||||
.color(Color::Accent),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
ExtensionStatus::Upgrading => Some(
|
||||
Button::new(
|
||||
SharedString::from(format!("upgrade-{}", extension.id)),
|
||||
"Upgrade",
|
||||
)
|
||||
.color(Color::Accent)
|
||||
.disabled(true),
|
||||
),
|
||||
};
|
||||
|
||||
let install_or_uninstall_button = match status {
|
||||
ExtensionStatus::NotInstalled | ExtensionStatus::Installing => {
|
||||
Button::new(SharedString::from(extension.id.clone()), "Install")
|
||||
.on_click(cx.listener({
|
||||
let extension_id = extension.id.clone();
|
||||
let version = extension.version.clone();
|
||||
move |this, _, cx| {
|
||||
this.telemetry
|
||||
.report_app_event("extensions: install extension".to_string());
|
||||
this.install_extension(extension_id.clone(), version.clone(), cx);
|
||||
}
|
||||
}))
|
||||
.disabled(matches!(status, ExtensionStatus::Installing))
|
||||
}
|
||||
ExtensionStatus::Installed(_)
|
||||
| ExtensionStatus::Upgrading
|
||||
| ExtensionStatus::Removing => {
|
||||
Button::new(SharedString::from(extension.id.clone()), "Uninstall")
|
||||
.on_click(cx.listener({
|
||||
let extension_id = extension.id.clone();
|
||||
move |this, _, cx| {
|
||||
this.telemetry
|
||||
.report_app_event("extensions: uninstall extension".to_string());
|
||||
this.uninstall_extension(extension_id.clone(), cx);
|
||||
}
|
||||
}))
|
||||
.disabled(matches!(
|
||||
status,
|
||||
ExtensionStatus::Upgrading | ExtensionStatus::Removing
|
||||
))
|
||||
}
|
||||
}
|
||||
.color(Color::Accent);
|
||||
|
||||
let repository_url = extension.repository.clone();
|
||||
|
||||
div().w_full().child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.h(rems(7.))
|
||||
.p_3()
|
||||
.mt_4()
|
||||
.gap_2()
|
||||
.bg(cx.theme().colors().elevated_surface_background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.rounded_md()
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_end()
|
||||
.child(
|
||||
Headline::new(extension.name.clone())
|
||||
.size(HeadlineSize::Medium),
|
||||
)
|
||||
.child(
|
||||
Headline::new(format!("v{}", extension.version))
|
||||
.size(HeadlineSize::XSmall),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.children(upgrade_button)
|
||||
.child(install_or_uninstall_button),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
Label::new(format!(
|
||||
"{}: {}",
|
||||
if extension.authors.len() > 1 {
|
||||
"Authors"
|
||||
} else {
|
||||
"Author"
|
||||
},
|
||||
extension.authors.join(", ")
|
||||
))
|
||||
.size(LabelSize::Small),
|
||||
)
|
||||
.child(
|
||||
Label::new(format!("Downloads: {}", extension.download_count))
|
||||
.size(LabelSize::Small),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.children(extension.description.as_ref().map(|description| {
|
||||
Label::new(description.clone())
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Default)
|
||||
}))
|
||||
.child(
|
||||
IconButton::new(
|
||||
SharedString::from(format!("repository-{}", extension.id)),
|
||||
IconName::Github,
|
||||
)
|
||||
.icon_color(Color::Accent)
|
||||
.icon_size(IconSize::Small)
|
||||
.style(ButtonStyle::Filled)
|
||||
.on_click(cx.listener(move |_, _, cx| {
|
||||
cx.open_url(&repository_url);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
|
||||
let mut key_context = KeyContext::default();
|
||||
key_context.add("BufferSearchBar");
|
||||
|
||||
let editor_border = if self.query_contains_error {
|
||||
Color::Error.color(cx)
|
||||
} else {
|
||||
cx.theme().colors().border
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.key_context(key_context)
|
||||
// .capture_action(cx.listener(Self::tab))
|
||||
// .on_action(cx.listener(Self::dismiss))
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.gap_2()
|
||||
.border_1()
|
||||
.border_color(editor_border)
|
||||
.min_w(rems(384. / 16.))
|
||||
.rounded_lg()
|
||||
.child(Icon::new(IconName::MagnifyingGlass))
|
||||
.child(self.render_text_input(&self.query_editor, cx)),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_text_input(&self, editor: &View<Editor>, cx: &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,
|
||||
strikethrough: 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 on_query_change(
|
||||
&mut self,
|
||||
_: View<Editor>,
|
||||
event: &editor::EditorEvent,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let editor::EditorEvent::Edited = event {
|
||||
self.query_contains_error = false;
|
||||
self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
|
||||
let search = this
|
||||
.update(&mut cx, |this, cx| this.search_query(cx))
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
// Only debounce the fetching of extensions if we have a search
|
||||
// query.
|
||||
//
|
||||
// If the search was just cleared then we can just reload the list
|
||||
// of extensions without a debounce, which allows us to avoid seeing
|
||||
// an intermittent flash of a "no extensions" state.
|
||||
if let Some(_) = search {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(250))
|
||||
.await;
|
||||
};
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.fetch_extensions(search.as_deref(), cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
|
||||
let search = self.query_editor.read(cx).text(cx);
|
||||
if search.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(search)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ExtensionsPage {
|
||||
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.p_4()
|
||||
.gap_4()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.child(Headline::new("Extensions").size(HeadlineSize::XLarge)),
|
||||
)
|
||||
.child(h_flex().w_56().child(self.render_search(cx)))
|
||||
.child(v_flex().size_full().overflow_y_hidden().map(|this| {
|
||||
if self.extensions_entries.is_empty() {
|
||||
let message = if self.is_fetching_extensions {
|
||||
"Loading extensions..."
|
||||
} else if self.search_query(cx).is_some() {
|
||||
"No extensions that match your search."
|
||||
} else {
|
||||
"No extensions."
|
||||
};
|
||||
|
||||
return this.child(Label::new(message));
|
||||
}
|
||||
|
||||
this.child(
|
||||
canvas({
|
||||
let view = cx.view().clone();
|
||||
let scroll_handle = self.list.clone();
|
||||
let item_count = self.extensions_entries.len();
|
||||
move |bounds, cx| {
|
||||
uniform_list::<_, Div, _>(
|
||||
view,
|
||||
"entries",
|
||||
item_count,
|
||||
Self::render_extensions,
|
||||
)
|
||||
.size_full()
|
||||
.track_scroll(scroll_handle)
|
||||
.into_any_element()
|
||||
.draw(
|
||||
bounds.origin,
|
||||
bounds.size.map(AvailableSpace::Definite),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.size_full(),
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ItemEvent> for ExtensionsPage {}
|
||||
|
||||
impl FocusableView for ExtensionsPage {
|
||||
fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
|
||||
self.query_editor.read(cx).focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Item for ExtensionsPage {
|
||||
type Event = ItemEvent;
|
||||
|
||||
fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
|
||||
Label::new("Extensions")
|
||||
.color(if selected {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn telemetry_event_text(&self) -> Option<&'static str> {
|
||||
Some("extensions page")
|
||||
}
|
||||
|
||||
fn show_toolbar(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn clone_on_split(
|
||||
&self,
|
||||
_workspace_id: WorkspaceId,
|
||||
_: &mut ViewContext<Self>,
|
||||
) -> Option<View<Self>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
|
||||
f(*event)
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,7 @@ impl FeedbackModal {
|
||||
editor.set_show_gutter(false, cx);
|
||||
editor.set_show_copilot_suggestions(false);
|
||||
editor.set_vertical_scroll_margin(5, cx);
|
||||
editor.set_use_modal_editing(false);
|
||||
editor
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ use std::{
|
||||
pin::Pin,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use tempfile::NamedTempFile;
|
||||
use tempfile::{NamedTempFile, TempDir};
|
||||
use text::LineEnding;
|
||||
use util::ResultExt;
|
||||
|
||||
@@ -66,6 +66,7 @@ pub trait Fs: Send + Sync {
|
||||
|
||||
fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<Mutex<dyn GitRepository>>>;
|
||||
fn is_fake(&self) -> bool;
|
||||
async fn is_case_sensitive(&self) -> Result<bool>;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
fn as_fake(&self) -> &FakeFs;
|
||||
}
|
||||
@@ -339,6 +340,44 @@ impl Fs for RealFs {
|
||||
fn is_fake(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Checks whether the file system is case sensitive by attempting to create two files
|
||||
/// that have the same name except for the casing.
|
||||
///
|
||||
/// It creates both files in a temporary directory it removes at the end.
|
||||
async fn is_case_sensitive(&self) -> Result<bool> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let test_file_1 = temp_dir.path().join("case_sensitivity_test.tmp");
|
||||
let test_file_2 = temp_dir.path().join("CASE_SENSITIVITY_TEST.TMP");
|
||||
|
||||
let create_opts = CreateOptions {
|
||||
overwrite: false,
|
||||
ignore_if_exists: false,
|
||||
};
|
||||
|
||||
// Create file1
|
||||
self.create_file(&test_file_1, create_opts).await?;
|
||||
|
||||
// Now check whether it's possible to create file2
|
||||
let case_sensitive = match self.create_file(&test_file_2, create_opts).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => {
|
||||
if let Some(io_error) = e.downcast_ref::<io::Error>() {
|
||||
if io_error.kind() == io::ErrorKind::AlreadyExists {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
temp_dir.close()?;
|
||||
case_sensitive
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
fn as_fake(&self) -> &FakeFs {
|
||||
panic!("called `RealFs::as_fake`")
|
||||
@@ -1186,6 +1225,10 @@ impl Fs for FakeFs {
|
||||
true
|
||||
}
|
||||
|
||||
async fn is_case_sensitive(&self) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
fn as_fake(&self) -> &FakeFs {
|
||||
self
|
||||
|
||||
@@ -68,6 +68,7 @@ usvg = { version = "0.14", features = [] }
|
||||
util.workspace = true
|
||||
uuid = { version = "1.1.2", features = ["v4"] }
|
||||
waker-fn = "1.1.0"
|
||||
accesskit = { version = "0.12" }
|
||||
|
||||
[dev-dependencies]
|
||||
backtrace = "0.3"
|
||||
@@ -93,13 +94,21 @@ log.workspace = true
|
||||
media.workspace = true
|
||||
metal = "0.21.0"
|
||||
objc = "0.2"
|
||||
accesskit_macos = "0.11.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
flume = "0.11"
|
||||
xcb = { version = "1.3", features = ["as-raw-xcb-connection"] }
|
||||
open = "5.0.1"
|
||||
ashpd = "0.7.0"
|
||||
# todo!(linux) - Technically do not use `randr`, but it doesn't compile otherwise
|
||||
xcb = { version = "1.3", features = ["as-raw-xcb-connection", "present", "randr", "xkb"] }
|
||||
wayland-client= { version = "0.31.2" }
|
||||
wayland-protocols = { version = "0.31.2", features = ["client"] }
|
||||
wayland-backend = { version = "0.3.3", features = ["client_system"] }
|
||||
as-raw-xcb-connection = "1"
|
||||
#TODO: use these on all platforms
|
||||
blade-graphics = { git = "https://github.com/kvark/blade", rev = "c4f951a88b345724cb952e920ad30e39851f7760" }
|
||||
blade-macros = { git = "https://github.com/kvark/blade", rev = "c4f951a88b345724cb952e920ad30e39851f7760" }
|
||||
bytemuck = "1"
|
||||
cosmic-text = "0.10.0"
|
||||
cosmic-text = "0.10.0"
|
||||
xkbcommon = { version = "0.7", features = ["x11"] }
|
||||
|
||||
58
crates/gpui/src/access_kit.rs
Normal file
58
crates/gpui/src/access_kit.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use collections::{hash_map::Entry, HashMap};
|
||||
|
||||
use crate::{BorrowWindow, ElementContext, ElementId, GlobalElementId, WindowContext};
|
||||
|
||||
pub type AccessKitState = HashMap<accesskit::NodeId, accesskit::NodeBuilder>;
|
||||
|
||||
impl From<&GlobalElementId> for accesskit::NodeId {
|
||||
fn from(value: &GlobalElementId) -> Self {
|
||||
let mut hasher = std::hash::DefaultHasher::new();
|
||||
value.0.hash(&mut hasher);
|
||||
accesskit::NodeId(hasher.finish())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ElementContext<'a> {
|
||||
|
||||
// TODO: What's a good, useful signature for this? Need to expose this from the div as well.
|
||||
fn accesskit_action(&mut self, id: impl Into<ElementId>, action: accesskit::Action, f: impl FnOnce(accesskit::ActionRequest)) {
|
||||
self.with_element_id(Some(id), |cx| {
|
||||
// Get the access kit actions from somewhere
|
||||
// call f with the action request and cx
|
||||
// egui impl:
|
||||
// let accesskit_id = id.accesskit_id();
|
||||
// self.events.iter().filter_map(move |event| {
|
||||
// if let Event::AccessKitActionRequest(request) = event {
|
||||
// if request.target == accesskit_id && request.action == action {
|
||||
// return Some(request);
|
||||
// }
|
||||
// }
|
||||
// None
|
||||
// })
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Expose this through the div API
|
||||
fn with_accesskit_node(&mut self, id: impl Into<ElementId>, f: impl FnOnce(&mut accesskit::NodeBuilder)) {
|
||||
let id = id.into();
|
||||
let window = self.window_mut();
|
||||
let parent_id: accesskit::NodeId = (&window.element_id_stack).into();
|
||||
self.with_element_id(Some(id), |cx| {
|
||||
let window = cx.window_mut();
|
||||
let this_id: accesskit::NodeId = (&window.element_id_stack).into();
|
||||
|
||||
window.next_frame.accesskit.as_mut().map(|nodes| {
|
||||
if let Entry::Vacant(entry) = nodes.entry(this_id) {
|
||||
entry.insert(Default::default());
|
||||
let parent = nodes.get_mut(&parent_id).unwrap();
|
||||
parent.push_child(this_id);
|
||||
}
|
||||
|
||||
f(nodes.get_mut(&this_id).unwrap());
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,3 @@
|
||||
mod async_context;
|
||||
mod entity_map;
|
||||
mod model_context;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
mod test_context;
|
||||
|
||||
pub use async_context::*;
|
||||
use derive_more::{Deref, DerefMut};
|
||||
pub use entity_map::*;
|
||||
pub use model_context::*;
|
||||
use refineable::Refineable;
|
||||
use smol::future::FutureExt;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub use test_context::*;
|
||||
use time::UtcOffset;
|
||||
|
||||
use crate::WindowAppearance;
|
||||
use crate::{
|
||||
current_platform, image_cache::ImageCache, init_app_menus, Action, ActionRegistry, Any,
|
||||
AnyView, AnyWindowHandle, AppMetadata, AssetSource, BackgroundExecutor, ClipboardItem, Context,
|
||||
DispatchPhase, Entity, EventEmitter, ForegroundExecutor, Global, KeyBinding, Keymap, Keystroke,
|
||||
LayoutId, Menu, PathPromptOptions, Pixels, Platform, PlatformDisplay, Point, Render,
|
||||
SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextStyle, TextStyleRefinement,
|
||||
TextSystem, View, ViewContext, Window, WindowContext, WindowHandle, WindowId,
|
||||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
use collections::{FxHashMap, FxHashSet, VecDeque};
|
||||
use futures::{channel::oneshot, future::LocalBoxFuture, Future};
|
||||
|
||||
use slotmap::SlotMap;
|
||||
use std::{
|
||||
any::{type_name, TypeId},
|
||||
cell::{Ref, RefCell, RefMut},
|
||||
@@ -38,11 +8,42 @@ use std::{
|
||||
sync::{atomic::Ordering::SeqCst, Arc},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use derive_more::{Deref, DerefMut};
|
||||
use futures::{channel::oneshot, future::LocalBoxFuture, Future};
|
||||
use slotmap::SlotMap;
|
||||
use smol::future::FutureExt;
|
||||
use time::UtcOffset;
|
||||
|
||||
pub use async_context::*;
|
||||
use collections::{FxHashMap, FxHashSet, VecDeque};
|
||||
pub use entity_map::*;
|
||||
pub use model_context::*;
|
||||
use refineable::Refineable;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub use test_context::*;
|
||||
use util::{
|
||||
http::{self, HttpClient},
|
||||
ResultExt,
|
||||
};
|
||||
|
||||
use crate::WindowAppearance;
|
||||
use crate::{
|
||||
current_platform, image_cache::ImageCache, init_app_menus, Action, ActionRegistry, Any,
|
||||
AnyView, AnyWindowHandle, AppMetadata, AssetSource, BackgroundExecutor, ClipboardItem, Context,
|
||||
DispatchPhase, Entity, EventEmitter, ForegroundExecutor, Global, KeyBinding, Keymap, Keystroke,
|
||||
LayoutId, Menu, PathPromptOptions, Pixels, Platform, PlatformDisplay, Point, Render,
|
||||
SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextStyle, TextStyleRefinement,
|
||||
TextSystem, View, ViewContext, Window, WindowContext, WindowHandle, WindowId,
|
||||
};
|
||||
|
||||
mod async_context;
|
||||
mod entity_map;
|
||||
mod model_context;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
mod test_context;
|
||||
|
||||
/// The duration for which futures returned from [AppContext::on_app_context] or [ModelContext::on_app_quit] can run before the application fully quits.
|
||||
pub const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
@@ -111,6 +112,9 @@ impl App {
|
||||
/// Builds an app with the given asset source.
|
||||
#[allow(clippy::new_without_default)]
|
||||
pub fn new() -> Self {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
log::info!("GPUI was compiled in test mode");
|
||||
|
||||
Self(AppContext::new(
|
||||
current_platform(),
|
||||
Arc::new(()),
|
||||
@@ -237,6 +241,7 @@ pub struct AppContext {
|
||||
pub(crate) quit_observers: SubscriberSet<(), QuitHandler>,
|
||||
pub(crate) layout_id_buffer: Vec<LayoutId>, // We recycle this memory across layout requests.
|
||||
pub(crate) propagate_event: bool,
|
||||
pub(crate) screen_reader_enabled: bool,
|
||||
}
|
||||
|
||||
impl AppContext {
|
||||
@@ -295,6 +300,7 @@ impl AppContext {
|
||||
quit_observers: SubscriberSet::new(),
|
||||
layout_id_buffer: Default::default(),
|
||||
propagate_event: true,
|
||||
screen_reader_enabled: false,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -310,6 +316,7 @@ impl AppContext {
|
||||
app
|
||||
}
|
||||
|
||||
|
||||
/// Quit the application gracefully. Handlers registered with [`ModelContext::on_app_quit`]
|
||||
/// will be given 100ms to complete before exiting.
|
||||
pub fn shutdown(&mut self) {
|
||||
@@ -676,7 +683,6 @@ impl AppContext {
|
||||
self.update_window(window, |_, cx| cx.draw()).unwrap();
|
||||
}
|
||||
|
||||
#[allow(clippy::collapsible_else_if)]
|
||||
if self.pending_effects.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -38,9 +38,10 @@ use crate::{
|
||||
util::FluentBuilder, ArenaBox, AvailableSpace, Bounds, ElementContext, ElementId, LayoutId,
|
||||
Pixels, Point, Size, ViewContext, WindowContext, ELEMENT_ARENA,
|
||||
};
|
||||
use collections::FxHashSet;
|
||||
use derive_more::{Deref, DerefMut};
|
||||
pub(crate) use smallvec::SmallVec;
|
||||
use std::{any::{type_name_of_val, Any}, fmt::Debug, ops::DerefMut};
|
||||
use std::{any::Any, fmt::Debug, hash::{Hash, Hasher, SipHasher}, ops::DerefMut};
|
||||
|
||||
/// Implemented by types that participate in laying out and painting the contents of a window.
|
||||
/// Elements form a tree and are laid out according to web-based layout rules, as implemented by Taffy.
|
||||
@@ -59,19 +60,6 @@ pub trait Element: 'static + IntoElement {
|
||||
cx: &mut ElementContext,
|
||||
) -> (LayoutId, Self::State);
|
||||
|
||||
/// After layout is performed, we assign each element its bounds. Some elements may
|
||||
/// need to use these bounds to render and layout the appropriate children. This is
|
||||
/// also each element's opportunity to add opaque layers before painting [`crate::window::element_cx::add_opaque_layer`].
|
||||
fn post_layout(
|
||||
&mut self,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_state: &mut Self::State,
|
||||
_cx: &mut ElementContext,
|
||||
) {
|
||||
dbg!(type_name_of_val(self));
|
||||
// TODO: Delete this default implementation once we've implemented it everywhere.
|
||||
}
|
||||
|
||||
/// Once layout has been completed, this method will be called to paint the element to the screen.
|
||||
/// The state argument is the same state that was returned from [`Element::request_layout()`].
|
||||
fn paint(&mut self, bounds: Bounds<Pixels>, state: &mut Self::State, cx: &mut ElementContext);
|
||||
@@ -235,15 +223,14 @@ impl<C: RenderOnce> IntoElement for Component<C> {
|
||||
|
||||
/// A globally unique identifier for an element, used to track state across frames.
|
||||
#[derive(Deref, DerefMut, Default, Clone, Debug, Eq, PartialEq, Hash)]
|
||||
pub(crate) struct GlobalElementId(SmallVec<[ElementId; 32]>);
|
||||
pub(crate) struct GlobalElementId(pub(crate) SmallVec<[ElementId; 32]>);
|
||||
|
||||
|
||||
trait ElementObject {
|
||||
fn element_id(&self) -> Option<ElementId>;
|
||||
|
||||
fn request_layout(&mut self, cx: &mut ElementContext) -> LayoutId;
|
||||
|
||||
fn post_layout(&mut self, cx: &mut ElementContext);
|
||||
|
||||
fn paint(&mut self, cx: &mut ElementContext);
|
||||
|
||||
fn measure(
|
||||
@@ -316,44 +303,6 @@ impl<E: Element> DrawableElement<E> {
|
||||
layout_id
|
||||
}
|
||||
|
||||
fn post_layout(&mut self, cx: &mut ElementContext) {
|
||||
println!("DrawableElement post_layout");
|
||||
match &mut self.phase {
|
||||
ElementDrawPhase::LayoutRequested {
|
||||
layout_id,
|
||||
frame_state,
|
||||
..
|
||||
}
|
||||
| ElementDrawPhase::LayoutComputed {
|
||||
layout_id,
|
||||
frame_state,
|
||||
..
|
||||
} => {
|
||||
let bounds = cx.layout_bounds(*layout_id);
|
||||
let element = self.element.as_mut().unwrap();
|
||||
|
||||
if let Some(frame_state) = frame_state.as_mut() {
|
||||
println!("Some");
|
||||
element.post_layout(bounds, frame_state, cx);
|
||||
} else {
|
||||
println!("Else");
|
||||
let element_id = element
|
||||
.element_id()
|
||||
.expect("if we don't have frame state, we should have element state");
|
||||
|
||||
cx.with_element_state(element_id, |element_state, cx| {
|
||||
println!("with_element_state");
|
||||
let mut element_state = element_state.unwrap();
|
||||
element.post_layout(bounds, &mut element_state, cx);
|
||||
((), element_state)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_ => panic!("must call layout before post_layout"),
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(mut self, cx: &mut ElementContext) -> Option<E::State> {
|
||||
match self.phase {
|
||||
ElementDrawPhase::LayoutRequested {
|
||||
@@ -460,11 +409,6 @@ where
|
||||
DrawableElement::request_layout(self.as_mut().unwrap(), cx)
|
||||
}
|
||||
|
||||
fn post_layout(&mut self, cx: &mut ElementContext) {
|
||||
println!("ElementObject post_layout");
|
||||
DrawableElement::post_layout(self.as_mut().unwrap(), cx)
|
||||
}
|
||||
|
||||
fn paint(&mut self, cx: &mut ElementContext) {
|
||||
DrawableElement::paint(self.take().unwrap(), cx);
|
||||
}
|
||||
@@ -508,12 +452,6 @@ impl AnyElement {
|
||||
self.0.request_layout(cx)
|
||||
}
|
||||
|
||||
/// TODO
|
||||
pub fn post_layout(&mut self, cx: &mut ElementContext) {
|
||||
println!("AnyElement post_layout");
|
||||
self.0.post_layout(cx)
|
||||
}
|
||||
|
||||
/// Paints the element stored in this `AnyElement`.
|
||||
pub fn paint(&mut self, cx: &mut ElementContext) {
|
||||
self.0.paint(cx)
|
||||
@@ -555,16 +493,6 @@ impl Element for AnyElement {
|
||||
let layout_id = self.request_layout(cx);
|
||||
(layout_id, ())
|
||||
}
|
||||
|
||||
fn post_layout(
|
||||
&mut self,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_state: &mut Self::State,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
self.0.post_layout(cx);
|
||||
}
|
||||
|
||||
|
||||
fn paint(&mut self, _: Bounds<Pixels>, _: &mut Self::State, cx: &mut ElementContext) {
|
||||
self.paint(cx)
|
||||
|
||||
@@ -88,7 +88,7 @@ impl Interactivity {
|
||||
.push(Box::new(move |event, bounds, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& event.button == button
|
||||
&& bounds.did_visibly_contains(&event.position, cx)
|
||||
&& bounds.visibly_contains(&event.position, cx)
|
||||
{
|
||||
(listener)(event, cx)
|
||||
}
|
||||
@@ -105,9 +105,7 @@ impl Interactivity {
|
||||
) {
|
||||
self.mouse_down_listeners
|
||||
.push(Box::new(move |event, bounds, phase, cx| {
|
||||
if phase == DispatchPhase::Capture
|
||||
&& bounds.did_visibly_contains(&event.position, cx)
|
||||
{
|
||||
if phase == DispatchPhase::Capture && bounds.visibly_contains(&event.position, cx) {
|
||||
(listener)(event, cx)
|
||||
}
|
||||
}));
|
||||
@@ -123,9 +121,7 @@ impl Interactivity {
|
||||
) {
|
||||
self.mouse_down_listeners
|
||||
.push(Box::new(move |event, bounds, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& bounds.did_visibly_contains(&event.position, cx)
|
||||
{
|
||||
if phase == DispatchPhase::Bubble && bounds.visibly_contains(&event.position, cx) {
|
||||
(listener)(event, cx)
|
||||
}
|
||||
}));
|
||||
@@ -144,7 +140,7 @@ impl Interactivity {
|
||||
.push(Box::new(move |event, bounds, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& event.button == button
|
||||
&& bounds.did_visibly_contains(&event.position, cx)
|
||||
&& bounds.visibly_contains(&event.position, cx)
|
||||
{
|
||||
(listener)(event, cx)
|
||||
}
|
||||
@@ -161,9 +157,7 @@ impl Interactivity {
|
||||
) {
|
||||
self.mouse_up_listeners
|
||||
.push(Box::new(move |event, bounds, phase, cx| {
|
||||
if phase == DispatchPhase::Capture
|
||||
&& bounds.did_visibly_contains(&event.position, cx)
|
||||
{
|
||||
if phase == DispatchPhase::Capture && bounds.visibly_contains(&event.position, cx) {
|
||||
(listener)(event, cx)
|
||||
}
|
||||
}));
|
||||
@@ -179,9 +173,7 @@ impl Interactivity {
|
||||
) {
|
||||
self.mouse_up_listeners
|
||||
.push(Box::new(move |event, bounds, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& bounds.did_visibly_contains(&event.position, cx)
|
||||
{
|
||||
if phase == DispatchPhase::Bubble && bounds.visibly_contains(&event.position, cx) {
|
||||
(listener)(event, cx)
|
||||
}
|
||||
}));
|
||||
@@ -198,8 +190,7 @@ impl Interactivity {
|
||||
) {
|
||||
self.mouse_down_listeners
|
||||
.push(Box::new(move |event, bounds, phase, cx| {
|
||||
if phase == DispatchPhase::Capture
|
||||
&& !bounds.did_visibly_contains(&event.position, cx)
|
||||
if phase == DispatchPhase::Capture && !bounds.visibly_contains(&event.position, cx)
|
||||
{
|
||||
(listener)(event, cx)
|
||||
}
|
||||
@@ -220,7 +211,7 @@ impl Interactivity {
|
||||
.push(Box::new(move |event, bounds, phase, cx| {
|
||||
if phase == DispatchPhase::Capture
|
||||
&& event.button == button
|
||||
&& !bounds.did_visibly_contains(&event.position, cx)
|
||||
&& !bounds.visibly_contains(&event.position, cx)
|
||||
{
|
||||
(listener)(event, cx);
|
||||
}
|
||||
@@ -237,9 +228,7 @@ impl Interactivity {
|
||||
) {
|
||||
self.mouse_move_listeners
|
||||
.push(Box::new(move |event, bounds, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& bounds.did_visibly_contains(&event.position, cx)
|
||||
{
|
||||
if phase == DispatchPhase::Bubble && bounds.visibly_contains(&event.position, cx) {
|
||||
(listener)(event, cx);
|
||||
}
|
||||
}));
|
||||
@@ -288,9 +277,7 @@ impl Interactivity {
|
||||
) {
|
||||
self.scroll_wheel_listeners
|
||||
.push(Box::new(move |event, bounds, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& bounds.did_visibly_contains(&event.position, cx)
|
||||
{
|
||||
if phase == DispatchPhase::Bubble && bounds.visibly_contains(&event.position, cx) {
|
||||
(listener)(event, cx);
|
||||
}
|
||||
}));
|
||||
@@ -577,7 +564,7 @@ pub trait InteractiveElement: Sized {
|
||||
self
|
||||
}
|
||||
|
||||
// #[cfg(any(test, feature = "test-support"))]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
/// Set a key that can be used to look up this element's bounds
|
||||
/// in the [`VisualTestContext::debug_bounds`] map
|
||||
/// This is a noop in release builds
|
||||
@@ -586,14 +573,14 @@ pub trait InteractiveElement: Sized {
|
||||
self
|
||||
}
|
||||
|
||||
// #[cfg(not(any(test, feature = "test-support")))]
|
||||
// /// Set a key that can be used to look up this element's bounds
|
||||
// /// in the [`VisualTestContext::debug_bounds`] map
|
||||
// /// This is a noop in release builds
|
||||
// #[inline]
|
||||
// fn debug_selector(self, _: impl FnOnce() -> String) -> Self {
|
||||
// self
|
||||
// }
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
/// Set a key that can be used to look up this element's bounds
|
||||
/// in the [`VisualTestContext::debug_bounds`] map
|
||||
/// This is a noop in release builds
|
||||
#[inline]
|
||||
fn debug_selector(self, _: impl FnOnce() -> String) -> Self {
|
||||
self
|
||||
}
|
||||
|
||||
/// Bind the given callback to the mouse down event for any button, during the capture phase
|
||||
/// the fluent API equivalent to [`Interactivity::capture_any_mouse_down`]
|
||||
@@ -1085,7 +1072,6 @@ impl Element for Div {
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
(
|
||||
layout_id,
|
||||
DivState {
|
||||
@@ -1095,30 +1081,6 @@ impl Element for Div {
|
||||
)
|
||||
}
|
||||
|
||||
fn post_layout(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
element_state: &mut Self::State,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
dbg!(self.children.len());
|
||||
self.interactivity.post_layout(
|
||||
bounds,
|
||||
&mut element_state.interactive_state,
|
||||
cx,
|
||||
|_, scroll_offset, cx| {
|
||||
println!("in continuation");
|
||||
cx.with_element_offset(scroll_offset, |cx| {
|
||||
println!("in with_element_offset");
|
||||
for child in &mut self.children {
|
||||
println!("child");
|
||||
child.post_layout(cx);
|
||||
}
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
@@ -1245,7 +1207,7 @@ pub struct Interactivity {
|
||||
#[cfg(debug_assertions)]
|
||||
pub(crate) location: Option<core::panic::Location<'static>>,
|
||||
|
||||
// #[cfg(any(test, feature = "test-support"))]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub(crate) debug_selector: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1260,7 +1222,7 @@ pub struct InteractiveBounds {
|
||||
|
||||
impl InteractiveBounds {
|
||||
/// Checks whether this point was inside these bounds, and that these bounds where the topmost layer
|
||||
pub fn did_visibly_contains(&self, point: &Point<Pixels>, cx: &WindowContext) -> bool {
|
||||
pub fn visibly_contains(&self, point: &Point<Pixels>, cx: &WindowContext) -> bool {
|
||||
self.bounds.contains(point) && cx.was_top_layer(point, &self.stacking_order)
|
||||
}
|
||||
|
||||
@@ -1268,7 +1230,7 @@ impl InteractiveBounds {
|
||||
/// under an active drag
|
||||
pub fn drag_target_contains(&self, point: &Point<Pixels>, cx: &WindowContext) -> bool {
|
||||
self.bounds.contains(point)
|
||||
&& cx.is_top_layer_under_active_drag(point, &self.stacking_order)
|
||||
&& cx.was_top_layer_under_active_drag(point, &self.stacking_order)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1312,29 +1274,6 @@ impl Interactivity {
|
||||
(layout_id, element_state)
|
||||
}
|
||||
|
||||
/// TODO
|
||||
pub fn post_layout(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
element_state: &mut InteractiveElementState,
|
||||
cx: &mut ElementContext,
|
||||
continuation: impl FnOnce(Bounds<Pixels>, Point<Pixels>, &mut ElementContext),
|
||||
) {
|
||||
let z_index = self.base_style.z_index.unwrap_or(0);
|
||||
|
||||
cx.with_z_index(z_index, |cx| {
|
||||
println!("interactivity post_layout is adding an opaque layer");
|
||||
cx.add_opaque_layer(bounds.intersect(&cx.content_mask().bounds));
|
||||
|
||||
let scroll_offset = element_state
|
||||
.scroll_offset
|
||||
.as_ref()
|
||||
.map(|scroll_offset| *scroll_offset.borrow())
|
||||
.unwrap_or_default();
|
||||
continuation(bounds, scroll_offset, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Paint this element according to this interactivity state's configured styles
|
||||
/// and bind the element's mouse and keyboard events.
|
||||
///
|
||||
@@ -1349,8 +1288,11 @@ impl Interactivity {
|
||||
content_size: Size<Pixels>,
|
||||
element_state: &mut InteractiveElementState,
|
||||
cx: &mut ElementContext,
|
||||
continuation: impl FnOnce(&Style, Point<Pixels>, &mut ElementContext),
|
||||
f: impl FnOnce(&Style, Point<Pixels>, &mut ElementContext),
|
||||
) {
|
||||
let style = self.compute_style(Some(bounds), element_state, cx);
|
||||
let z_index = style.z_index.unwrap_or(0);
|
||||
|
||||
#[cfg(any(feature = "test-support", test))]
|
||||
if let Some(debug_selector) = &self.debug_selector {
|
||||
cx.window
|
||||
@@ -1359,8 +1301,8 @@ impl Interactivity {
|
||||
.insert(debug_selector.clone(), bounds);
|
||||
}
|
||||
|
||||
let paint_hover_group_handler = |this: &Self, cx: &mut ElementContext| {
|
||||
let hover_group_bounds = this
|
||||
let paint_hover_group_handler = |cx: &mut ElementContext| {
|
||||
let hover_group_bounds = self
|
||||
.group_hover_style
|
||||
.as_ref()
|
||||
.and_then(|group_hover| GroupBounds::get(&group_hover.group, cx));
|
||||
@@ -1377,15 +1319,12 @@ impl Interactivity {
|
||||
}
|
||||
};
|
||||
|
||||
let z_index = self.base_style.z_index.unwrap_or(0);
|
||||
if style.visibility == Visibility::Hidden {
|
||||
cx.with_z_index(z_index, |cx| paint_hover_group_handler(cx));
|
||||
return;
|
||||
}
|
||||
|
||||
cx.with_z_index(z_index, |cx| {
|
||||
let style = self.compute_style(Some(bounds), element_state, cx);
|
||||
|
||||
if style.visibility == Visibility::Hidden {
|
||||
paint_hover_group_handler(self, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
style.paint(bounds, cx, |cx: &mut ElementContext| {
|
||||
cx.with_text_style(style.text_style().cloned(), |cx| {
|
||||
cx.with_content_mask(style.overflow_mask(bounds, cx.rem_size()), |cx| {
|
||||
@@ -1500,19 +1439,19 @@ impl Interactivity {
|
||||
stacking_order: cx.stacking_order().clone(),
|
||||
};
|
||||
|
||||
// if self.block_mouse
|
||||
// || style.background.as_ref().is_some_and(|fill| {
|
||||
// fill.color().is_some_and(|color| !color.is_transparent())
|
||||
// })
|
||||
// {
|
||||
// cx.add_opaque_layer(interactive_bounds.bounds);
|
||||
// }
|
||||
if self.block_mouse
|
||||
|| style.background.as_ref().is_some_and(|fill| {
|
||||
fill.color().is_some_and(|color| !color.is_transparent())
|
||||
})
|
||||
{
|
||||
cx.add_opaque_layer(interactive_bounds.bounds);
|
||||
}
|
||||
|
||||
if !cx.has_active_drag() {
|
||||
if let Some(mouse_cursor) = style.mouse_cursor {
|
||||
let mouse_position = &cx.mouse_position();
|
||||
let hovered =
|
||||
interactive_bounds.did_visibly_contains(mouse_position, cx);
|
||||
interactive_bounds.visibly_contains(mouse_position, cx);
|
||||
if hovered {
|
||||
cx.set_cursor_style(mouse_cursor);
|
||||
}
|
||||
@@ -1528,8 +1467,7 @@ impl Interactivity {
|
||||
move |event: &MouseDownEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& !cx.default_prevented()
|
||||
&& interactive_bounds
|
||||
.did_visibly_contains(&event.position, cx)
|
||||
&& interactive_bounds.visibly_contains(&event.position, cx)
|
||||
{
|
||||
cx.focus(&focus_handle);
|
||||
// If there is a parent that is also focusable, prevent it
|
||||
@@ -1568,7 +1506,7 @@ impl Interactivity {
|
||||
})
|
||||
}
|
||||
|
||||
paint_hover_group_handler(self, cx);
|
||||
paint_hover_group_handler(cx);
|
||||
|
||||
if self.hover_style.is_some()
|
||||
|| self.base_style.mouse_cursor.is_some()
|
||||
@@ -1647,8 +1585,7 @@ impl Interactivity {
|
||||
move |event: &MouseDownEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& event.button == MouseButton::Left
|
||||
&& interactive_bounds
|
||||
.did_visibly_contains(&event.position, cx)
|
||||
&& interactive_bounds.visibly_contains(&event.position, cx)
|
||||
{
|
||||
*pending_mouse_down.borrow_mut() = Some(event.clone());
|
||||
cx.refresh();
|
||||
@@ -1709,7 +1646,7 @@ impl Interactivity {
|
||||
DispatchPhase::Bubble => {
|
||||
if let Some(mouse_down) = captured_mouse_down.take() {
|
||||
if interactive_bounds
|
||||
.did_visibly_contains(&event.position, cx)
|
||||
.visibly_contains(&event.position, cx)
|
||||
{
|
||||
let mouse_click = ClickEvent {
|
||||
down: mouse_down,
|
||||
@@ -1741,7 +1678,7 @@ impl Interactivity {
|
||||
return;
|
||||
}
|
||||
let is_hovered = interactive_bounds
|
||||
.did_visibly_contains(&event.position, cx)
|
||||
.visibly_contains(&event.position, cx)
|
||||
&& has_mouse_down.borrow().is_none()
|
||||
&& !cx.has_active_drag();
|
||||
let mut was_hovered = was_hovered.borrow_mut();
|
||||
@@ -1768,7 +1705,7 @@ impl Interactivity {
|
||||
|
||||
cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| {
|
||||
let is_hovered = interactive_bounds
|
||||
.did_visibly_contains(&event.position, cx)
|
||||
.visibly_contains(&event.position, cx)
|
||||
&& pending_mouse_down.borrow().is_none();
|
||||
if !is_hovered {
|
||||
active_tooltip.borrow_mut().take();
|
||||
@@ -1850,7 +1787,7 @@ impl Interactivity {
|
||||
let group = active_group_bounds
|
||||
.map_or(false, |bounds| bounds.contains(&down.position));
|
||||
let element =
|
||||
interactive_bounds.did_visibly_contains(&down.position, cx);
|
||||
interactive_bounds.visibly_contains(&down.position, cx);
|
||||
if group || element {
|
||||
*active_state.borrow_mut() =
|
||||
ElementClickedState { group, element };
|
||||
@@ -1903,7 +1840,7 @@ impl Interactivity {
|
||||
let interactive_bounds = interactive_bounds.clone();
|
||||
cx.on_mouse_event(move |event: &ScrollWheelEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& interactive_bounds.did_visibly_contains(&event.position, cx)
|
||||
&& interactive_bounds.visibly_contains(&event.position, cx)
|
||||
{
|
||||
let mut scroll_offset = scroll_offset.borrow_mut();
|
||||
let old_scroll_offset = *scroll_offset;
|
||||
@@ -1973,7 +1910,7 @@ impl Interactivity {
|
||||
cx.on_action(action_type, listener)
|
||||
}
|
||||
|
||||
continuation(&style, scroll_offset.unwrap_or_default(), cx)
|
||||
f(&style, scroll_offset.unwrap_or_default(), cx)
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1996,107 +1933,101 @@ impl Interactivity {
|
||||
let mut style = Style::default();
|
||||
style.refine(&self.base_style);
|
||||
|
||||
// cx.with_z_index(style.z_index.unwrap_or(0), |cx| {
|
||||
if let Some(focus_handle) = self.tracked_focus_handle.as_ref() {
|
||||
if let Some(in_focus_style) = self.in_focus_style.as_ref() {
|
||||
if focus_handle.within_focused(cx) {
|
||||
style.refine(in_focus_style);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(focus_style) = self.focus_style.as_ref() {
|
||||
if focus_handle.is_focused(cx) {
|
||||
style.refine(focus_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bounds) = bounds {
|
||||
let mouse_position = cx.mouse_position();
|
||||
if !cx.has_active_drag() {
|
||||
if let Some(group_hover) = self.group_hover_style.as_ref() {
|
||||
if let Some(group_bounds) = GroupBounds::get(&group_hover.group, cx.deref_mut())
|
||||
{
|
||||
if group_bounds.contains(&mouse_position)
|
||||
&& cx.is_top_layer(&mouse_position, cx.stacking_order())
|
||||
{
|
||||
style.refine(&group_hover.style);
|
||||
}
|
||||
cx.with_z_index(style.z_index.unwrap_or(0), |cx| {
|
||||
if let Some(focus_handle) = self.tracked_focus_handle.as_ref() {
|
||||
if let Some(in_focus_style) = self.in_focus_style.as_ref() {
|
||||
if focus_handle.within_focused(cx) {
|
||||
style.refine(in_focus_style);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hover_style) = self.hover_style.as_ref() {
|
||||
let outer_button = self.debug_selector.as_deref() == Some("outer_button");
|
||||
let inner_button = self.debug_selector.as_deref() == Some("inner_button");
|
||||
if outer_button {
|
||||
cx.is_top_layer_debug(&mouse_position, cx.stacking_order());
|
||||
}
|
||||
println!("\n\n\n");
|
||||
|
||||
if bounds
|
||||
.intersect(&cx.content_mask().bounds)
|
||||
.contains(&mouse_position)
|
||||
&& cx.is_top_layer(&mouse_position, cx.stacking_order())
|
||||
{
|
||||
style.refine(hover_style);
|
||||
if let Some(focus_style) = self.focus_style.as_ref() {
|
||||
if focus_handle.is_focused(cx) {
|
||||
style.refine(focus_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(drag) = cx.active_drag.take() {
|
||||
let mut can_drop = true;
|
||||
if let Some(can_drop_predicate) = &self.can_drop_predicate {
|
||||
can_drop = can_drop_predicate(drag.value.as_ref(), cx.deref_mut());
|
||||
}
|
||||
|
||||
if can_drop {
|
||||
for (state_type, group_drag_style) in &self.group_drag_over_styles {
|
||||
if let Some(bounds) = bounds {
|
||||
let mouse_position = cx.mouse_position();
|
||||
if !cx.has_active_drag() {
|
||||
if let Some(group_hover) = self.group_hover_style.as_ref() {
|
||||
if let Some(group_bounds) =
|
||||
GroupBounds::get(&group_drag_style.group, cx.deref_mut())
|
||||
GroupBounds::get(&group_hover.group, cx.deref_mut())
|
||||
{
|
||||
if *state_type == drag.value.as_ref().type_id()
|
||||
&& group_bounds.contains(&mouse_position)
|
||||
if group_bounds.contains(&mouse_position)
|
||||
&& cx.was_top_layer(&mouse_position, cx.stacking_order())
|
||||
{
|
||||
style.refine(&group_drag_style.style);
|
||||
style.refine(&group_hover.style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (state_type, build_drag_over_style) in &self.drag_over_styles {
|
||||
if *state_type == drag.value.as_ref().type_id()
|
||||
&& bounds
|
||||
.intersect(&cx.content_mask().bounds)
|
||||
.contains(&mouse_position)
|
||||
&& cx.is_top_layer_under_active_drag(
|
||||
&mouse_position,
|
||||
cx.stacking_order(),
|
||||
)
|
||||
if let Some(hover_style) = self.hover_style.as_ref() {
|
||||
if bounds
|
||||
.intersect(&cx.content_mask().bounds)
|
||||
.contains(&mouse_position)
|
||||
&& cx.was_top_layer(&mouse_position, cx.stacking_order())
|
||||
{
|
||||
style.refine(&build_drag_over_style(drag.value.as_ref(), cx));
|
||||
style.refine(hover_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cx.active_drag = Some(drag);
|
||||
}
|
||||
}
|
||||
if let Some(drag) = cx.active_drag.take() {
|
||||
let mut can_drop = true;
|
||||
if let Some(can_drop_predicate) = &self.can_drop_predicate {
|
||||
can_drop = can_drop_predicate(drag.value.as_ref(), cx.deref_mut());
|
||||
}
|
||||
|
||||
let clicked_state = element_state
|
||||
.clicked_state
|
||||
.get_or_insert_with(Default::default)
|
||||
.borrow();
|
||||
if clicked_state.group {
|
||||
if let Some(group) = self.group_active_style.as_ref() {
|
||||
style.refine(&group.style)
|
||||
}
|
||||
}
|
||||
if can_drop {
|
||||
for (state_type, group_drag_style) in &self.group_drag_over_styles {
|
||||
if let Some(group_bounds) =
|
||||
GroupBounds::get(&group_drag_style.group, cx.deref_mut())
|
||||
{
|
||||
if *state_type == drag.value.as_ref().type_id()
|
||||
&& group_bounds.contains(&mouse_position)
|
||||
{
|
||||
style.refine(&group_drag_style.style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(active_style) = self.active_style.as_ref() {
|
||||
if clicked_state.element {
|
||||
style.refine(active_style)
|
||||
for (state_type, build_drag_over_style) in &self.drag_over_styles {
|
||||
if *state_type == drag.value.as_ref().type_id()
|
||||
&& bounds
|
||||
.intersect(&cx.content_mask().bounds)
|
||||
.contains(&mouse_position)
|
||||
&& cx.was_top_layer_under_active_drag(
|
||||
&mouse_position,
|
||||
cx.stacking_order(),
|
||||
)
|
||||
{
|
||||
style.refine(&build_drag_over_style(drag.value.as_ref(), cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cx.active_drag = Some(drag);
|
||||
}
|
||||
}
|
||||
}
|
||||
// });
|
||||
|
||||
let clicked_state = element_state
|
||||
.clicked_state
|
||||
.get_or_insert_with(Default::default)
|
||||
.borrow();
|
||||
if clicked_state.group {
|
||||
if let Some(group) = self.group_active_style.as_ref() {
|
||||
style.refine(&group.style)
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(active_style) = self.active_style.as_ref() {
|
||||
if clicked_state.element {
|
||||
style.refine(active_style)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
style
|
||||
}
|
||||
@@ -2280,15 +2211,6 @@ where
|
||||
self.element.request_layout(state, cx)
|
||||
}
|
||||
|
||||
fn post_layout(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
state: &mut Self::State,
|
||||
cx: &mut ElementContext,
|
||||
) {
|
||||
self.element.post_layout(bounds, state, cx);
|
||||
}
|
||||
|
||||
fn paint(&mut self, bounds: Bounds<Pixels>, state: &mut Self::State, cx: &mut ElementContext) {
|
||||
self.element.paint(bounds, state, cx)
|
||||
}
|
||||
|
||||
@@ -520,7 +520,7 @@ impl Element for List {
|
||||
cx.on_mouse_event(move |event: &ScrollWheelEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& bounds.contains(&event.position)
|
||||
&& cx.is_top_layer(&event.position, cx.stacking_order())
|
||||
&& cx.was_top_layer(&event.position, cx.stacking_order())
|
||||
{
|
||||
list_state.0.borrow_mut().scroll(
|
||||
&scroll_top,
|
||||
|
||||
@@ -427,7 +427,7 @@ impl Element for InteractiveText {
|
||||
.clickable_ranges
|
||||
.iter()
|
||||
.any(|range| range.contains(&ix))
|
||||
&& cx.is_top_layer(&mouse_position, cx.stacking_order())
|
||||
&& cx.was_top_layer(&mouse_position, cx.stacking_order())
|
||||
{
|
||||
cx.set_cursor_style(crate::CursorStyle::PointingHand)
|
||||
}
|
||||
|
||||
@@ -61,11 +61,13 @@
|
||||
|
||||
#![deny(missing_docs)]
|
||||
#![allow(clippy::type_complexity)]
|
||||
#![allow(clippy::collapsible_else_if)]
|
||||
|
||||
#[macro_use]
|
||||
mod action;
|
||||
mod app;
|
||||
|
||||
mod access_kit;
|
||||
mod arena;
|
||||
mod assets;
|
||||
mod color;
|
||||
|
||||
@@ -6,13 +6,13 @@ mod client_dispatcher;
|
||||
mod dispatcher;
|
||||
mod platform;
|
||||
mod text_system;
|
||||
mod wayland;
|
||||
mod x11;
|
||||
|
||||
pub(crate) use blade_atlas::*;
|
||||
pub(crate) use dispatcher::*;
|
||||
pub(crate) use platform::*;
|
||||
pub(crate) use text_system::*;
|
||||
pub(crate) use x11::display::*;
|
||||
pub(crate) use x11::*;
|
||||
|
||||
use blade_belt::*;
|
||||
|
||||
@@ -200,7 +200,7 @@ impl BladeAtlasState {
|
||||
}
|
||||
|
||||
fn upload_texture(&mut self, id: AtlasTextureId, bounds: Bounds<DevicePixels>, bytes: &[u8]) {
|
||||
let data = self.upload_belt.alloc_data(bytes, &self.gpu);
|
||||
let data = unsafe { self.upload_belt.alloc_data(bytes, &self.gpu) };
|
||||
self.uploads.push(PendingUpload { id, bounds, data });
|
||||
}
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ impl BladeBelt {
|
||||
chunk.into()
|
||||
}
|
||||
|
||||
//todo!(linux): enforce T: bytemuck::Zeroable
|
||||
pub fn alloc_data<T>(&mut self, data: &[T], gpu: &gpu::Context) -> gpu::BufferPiece {
|
||||
// SAFETY: T should be zeroable and ordinary data, no references, pointers, cells or other complicated data type.
|
||||
pub unsafe fn alloc_data<T>(&mut self, data: &[T], gpu: &gpu::Context) -> gpu::BufferPiece {
|
||||
assert!(!data.is_empty());
|
||||
let type_alignment = mem::align_of::<T>() as u64;
|
||||
debug_assert_eq!(
|
||||
|
||||
@@ -340,7 +340,7 @@ impl BladeRenderer {
|
||||
pad: [0; 2],
|
||||
};
|
||||
|
||||
let vertex_buf = self.instance_belt.alloc_data(&vertices, &self.gpu);
|
||||
let vertex_buf = unsafe { self.instance_belt.alloc_data(&vertices, &self.gpu) };
|
||||
let mut pass = self.command_encoder.render(gpu::RenderTargetSet {
|
||||
colors: &[gpu::RenderTarget {
|
||||
view: tex_info.raw_view,
|
||||
@@ -389,7 +389,8 @@ impl BladeRenderer {
|
||||
for batch in scene.batches() {
|
||||
match batch {
|
||||
PrimitiveBatch::Quads(quads) => {
|
||||
let instance_buf = self.instance_belt.alloc_data(quads, &self.gpu);
|
||||
let instance_buf =
|
||||
unsafe { self.instance_belt.alloc_data(quads, &self.gpu) };
|
||||
let mut encoder = pass.with(&self.pipelines.quads);
|
||||
encoder.bind(
|
||||
0,
|
||||
@@ -401,7 +402,8 @@ impl BladeRenderer {
|
||||
encoder.draw(0, 4, 0, quads.len() as u32);
|
||||
}
|
||||
PrimitiveBatch::Shadows(shadows) => {
|
||||
let instance_buf = self.instance_belt.alloc_data(shadows, &self.gpu);
|
||||
let instance_buf =
|
||||
unsafe { self.instance_belt.alloc_data(shadows, &self.gpu) };
|
||||
let mut encoder = pass.with(&self.pipelines.shadows);
|
||||
encoder.bind(
|
||||
0,
|
||||
@@ -428,7 +430,8 @@ impl BladeRenderer {
|
||||
tile: (*tile).clone(),
|
||||
}];
|
||||
|
||||
let instance_buf = self.instance_belt.alloc_data(&sprites, &self.gpu);
|
||||
let instance_buf =
|
||||
unsafe { self.instance_belt.alloc_data(&sprites, &self.gpu) };
|
||||
encoder.bind(
|
||||
0,
|
||||
&ShaderPathsData {
|
||||
@@ -442,7 +445,8 @@ impl BladeRenderer {
|
||||
}
|
||||
}
|
||||
PrimitiveBatch::Underlines(underlines) => {
|
||||
let instance_buf = self.instance_belt.alloc_data(underlines, &self.gpu);
|
||||
let instance_buf =
|
||||
unsafe { self.instance_belt.alloc_data(underlines, &self.gpu) };
|
||||
let mut encoder = pass.with(&self.pipelines.underlines);
|
||||
encoder.bind(
|
||||
0,
|
||||
@@ -458,7 +462,8 @@ impl BladeRenderer {
|
||||
sprites,
|
||||
} => {
|
||||
let tex_info = self.atlas.get_texture_info(texture_id);
|
||||
let instance_buf = self.instance_belt.alloc_data(&sprites, &self.gpu);
|
||||
let instance_buf =
|
||||
unsafe { self.instance_belt.alloc_data(sprites, &self.gpu) };
|
||||
let mut encoder = pass.with(&self.pipelines.mono_sprites);
|
||||
encoder.bind(
|
||||
0,
|
||||
@@ -476,7 +481,8 @@ impl BladeRenderer {
|
||||
sprites,
|
||||
} => {
|
||||
let tex_info = self.atlas.get_texture_info(texture_id);
|
||||
let instance_buf = self.instance_belt.alloc_data(&sprites, &self.gpu);
|
||||
let instance_buf =
|
||||
unsafe { self.instance_belt.alloc_data(sprites, &self.gpu) };
|
||||
let mut encoder = pass.with(&self.pipelines.poly_sprites);
|
||||
encoder.bind(
|
||||
0,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#![allow(unused)]
|
||||
|
||||
use std::env;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
@@ -7,22 +8,23 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ashpd::desktop::file_chooser::{OpenFileRequest, SaveFileRequest};
|
||||
use async_task::Runnable;
|
||||
use flume::{Receiver, Sender};
|
||||
use futures::channel::oneshot;
|
||||
use parking_lot::Mutex;
|
||||
use time::UtcOffset;
|
||||
|
||||
use collections::{HashMap, HashSet};
|
||||
use wayland_client::Connection;
|
||||
|
||||
use crate::platform::linux::client::Client;
|
||||
use crate::platform::linux::client_dispatcher::ClientDispatcher;
|
||||
use crate::platform::linux::wayland::{WaylandClient, WaylandClientDispatcher};
|
||||
use crate::platform::{X11Client, X11ClientDispatcher, XcbAtoms};
|
||||
use crate::{
|
||||
Action, AnyWindowHandle, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DisplayId,
|
||||
Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId,
|
||||
ForegroundExecutor, Keymap, LinuxDispatcher, LinuxTextSystem, Menu, PathPromptOptions,
|
||||
Platform, PlatformDisplay, PlatformInput, PlatformTextSystem, PlatformWindow, Point, Result,
|
||||
SemanticVersion, Size, Task, WindowAppearance, WindowOptions, X11Display, X11Window,
|
||||
X11WindowState,
|
||||
Platform, PlatformDisplay, PlatformInput, PlatformTextSystem, PlatformWindow, Result,
|
||||
SemanticVersion, Task, WindowOptions,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -48,8 +50,8 @@ pub(crate) struct LinuxPlatformInner {
|
||||
}
|
||||
|
||||
pub(crate) struct LinuxPlatform {
|
||||
client: Arc<dyn Client>,
|
||||
inner: Arc<LinuxPlatformInner>,
|
||||
client: Rc<dyn Client>,
|
||||
inner: Rc<LinuxPlatformInner>,
|
||||
}
|
||||
|
||||
pub(crate) struct LinuxPlatformState {
|
||||
@@ -64,34 +66,93 @@ impl Default for LinuxPlatform {
|
||||
|
||||
impl LinuxPlatform {
|
||||
pub(crate) fn new() -> Self {
|
||||
let (xcb_connection, x_root_index) = xcb::Connection::connect(None).unwrap();
|
||||
let atoms = XcbAtoms::intern_all(&xcb_connection).unwrap();
|
||||
let wayland_display = env::var_os("WAYLAND_DISPLAY");
|
||||
let use_wayland = wayland_display.is_some() && !wayland_display.unwrap().is_empty();
|
||||
|
||||
let xcb_connection = Arc::new(xcb_connection);
|
||||
let (main_sender, main_receiver) = flume::unbounded::<Runnable>();
|
||||
let client_dispatcher: Arc<dyn ClientDispatcher + Send + Sync> =
|
||||
Arc::new(X11ClientDispatcher::new(&xcb_connection, x_root_index));
|
||||
let dispatcher = LinuxDispatcher::new(main_sender, &client_dispatcher);
|
||||
let dispatcher = Arc::new(dispatcher);
|
||||
let text_system = Arc::new(LinuxTextSystem::new());
|
||||
let callbacks = Mutex::new(Callbacks::default());
|
||||
let state = Mutex::new(LinuxPlatformState {
|
||||
quit_requested: false,
|
||||
});
|
||||
|
||||
let inner = LinuxPlatformInner {
|
||||
if use_wayland {
|
||||
Self::new_wayland(main_sender, main_receiver, text_system, callbacks, state)
|
||||
} else {
|
||||
Self::new_x11(main_sender, main_receiver, text_system, callbacks, state)
|
||||
}
|
||||
}
|
||||
|
||||
fn new_wayland(
|
||||
main_sender: Sender<Runnable>,
|
||||
main_receiver: Receiver<Runnable>,
|
||||
text_system: Arc<LinuxTextSystem>,
|
||||
callbacks: Mutex<Callbacks>,
|
||||
state: Mutex<LinuxPlatformState>,
|
||||
) -> Self {
|
||||
let conn = Arc::new(Connection::connect_to_env().unwrap());
|
||||
let client_dispatcher: Arc<dyn ClientDispatcher + Send + Sync> =
|
||||
Arc::new(WaylandClientDispatcher::new(&conn));
|
||||
let dispatcher = Arc::new(LinuxDispatcher::new(main_sender, &client_dispatcher));
|
||||
let inner = Rc::new(LinuxPlatformInner {
|
||||
background_executor: BackgroundExecutor::new(dispatcher.clone()),
|
||||
foreground_executor: ForegroundExecutor::new(dispatcher.clone()),
|
||||
main_receiver,
|
||||
text_system: Arc::new(LinuxTextSystem::new()),
|
||||
callbacks: Mutex::new(Callbacks::default()),
|
||||
state: Mutex::new(LinuxPlatformState {
|
||||
quit_requested: false,
|
||||
}),
|
||||
};
|
||||
let inner = Arc::new(inner);
|
||||
|
||||
let x11client = X11Client::new(Arc::clone(&inner), xcb_connection, x_root_index, atoms);
|
||||
let x11client = Arc::new(x11client);
|
||||
|
||||
text_system,
|
||||
callbacks,
|
||||
state,
|
||||
});
|
||||
let client = Rc::new(WaylandClient::new(Rc::clone(&inner), Arc::clone(&conn)));
|
||||
Self {
|
||||
client: x11client,
|
||||
inner: Arc::clone(&inner),
|
||||
client,
|
||||
inner: Rc::clone(&inner),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_x11(
|
||||
main_sender: Sender<Runnable>,
|
||||
main_receiver: Receiver<Runnable>,
|
||||
text_system: Arc<LinuxTextSystem>,
|
||||
callbacks: Mutex<Callbacks>,
|
||||
state: Mutex<LinuxPlatformState>,
|
||||
) -> Self {
|
||||
let (xcb_connection, x_root_index) = xcb::Connection::connect_with_extensions(
|
||||
None,
|
||||
&[xcb::Extension::Present, xcb::Extension::Xkb],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let xkb_ver = xcb_connection
|
||||
.wait_for_reply(xcb_connection.send_request(&xcb::xkb::UseExtension {
|
||||
wanted_major: xcb::xkb::MAJOR_VERSION as u16,
|
||||
wanted_minor: xcb::xkb::MINOR_VERSION as u16,
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(xkb_ver.supported());
|
||||
|
||||
let atoms = XcbAtoms::intern_all(&xcb_connection).unwrap();
|
||||
let xcb_connection = Arc::new(xcb_connection);
|
||||
let client_dispatcher: Arc<dyn ClientDispatcher + Send + Sync> =
|
||||
Arc::new(X11ClientDispatcher::new(&xcb_connection, x_root_index));
|
||||
let dispatcher = Arc::new(LinuxDispatcher::new(main_sender, &client_dispatcher));
|
||||
let inner = Rc::new(LinuxPlatformInner {
|
||||
background_executor: BackgroundExecutor::new(dispatcher.clone()),
|
||||
foreground_executor: ForegroundExecutor::new(dispatcher.clone()),
|
||||
main_receiver,
|
||||
text_system,
|
||||
callbacks,
|
||||
state,
|
||||
});
|
||||
let client = Rc::new(X11Client::new(
|
||||
Rc::clone(&inner),
|
||||
xcb_connection,
|
||||
x_root_index,
|
||||
atoms,
|
||||
));
|
||||
Self {
|
||||
client,
|
||||
inner: Rc::clone(&inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,7 +215,7 @@ impl Platform for LinuxPlatform {
|
||||
}
|
||||
|
||||
fn open_url(&self, url: &str) {
|
||||
unimplemented!()
|
||||
open::that(url);
|
||||
}
|
||||
|
||||
fn on_open_urls(&self, callback: Box<dyn FnMut(Vec<String>)>) {
|
||||
@@ -165,15 +226,75 @@ impl Platform for LinuxPlatform {
|
||||
&self,
|
||||
options: PathPromptOptions,
|
||||
) -> oneshot::Receiver<Option<Vec<PathBuf>>> {
|
||||
unimplemented!()
|
||||
let (done_tx, done_rx) = oneshot::channel();
|
||||
self.foreground_executor()
|
||||
.spawn(async move {
|
||||
let title = if options.multiple {
|
||||
if !options.files {
|
||||
"Open folders"
|
||||
} else {
|
||||
"Open files"
|
||||
}
|
||||
} else {
|
||||
if !options.files {
|
||||
"Open folder"
|
||||
} else {
|
||||
"Open file"
|
||||
}
|
||||
};
|
||||
|
||||
let result = OpenFileRequest::default()
|
||||
.modal(true)
|
||||
.title(title)
|
||||
.accept_label("Select")
|
||||
.multiple(options.multiple)
|
||||
.directory(options.directories)
|
||||
.send()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|request| request.response().ok())
|
||||
.and_then(|response| {
|
||||
response
|
||||
.uris()
|
||||
.iter()
|
||||
.map(|uri| uri.to_file_path().ok())
|
||||
.collect()
|
||||
});
|
||||
|
||||
done_tx.send(result);
|
||||
})
|
||||
.detach();
|
||||
done_rx
|
||||
}
|
||||
|
||||
fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver<Option<PathBuf>> {
|
||||
unimplemented!()
|
||||
let (done_tx, done_rx) = oneshot::channel();
|
||||
let directory = directory.to_owned();
|
||||
self.foreground_executor()
|
||||
.spawn(async move {
|
||||
let result = SaveFileRequest::default()
|
||||
.modal(true)
|
||||
.title("Select new path")
|
||||
.accept_label("Accept")
|
||||
.send()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|request| request.response().ok())
|
||||
.and_then(|response| {
|
||||
response
|
||||
.uris()
|
||||
.first()
|
||||
.and_then(|uri| uri.to_file_path().ok())
|
||||
});
|
||||
|
||||
done_tx.send(result);
|
||||
})
|
||||
.detach();
|
||||
done_rx
|
||||
}
|
||||
|
||||
fn reveal_path(&self, path: &Path) {
|
||||
unimplemented!()
|
||||
open::that(path);
|
||||
}
|
||||
|
||||
fn on_become_active(&self, callback: Box<dyn FnMut()>) {
|
||||
|
||||
10
crates/gpui/src/platform/linux/wayland.rs
Normal file
10
crates/gpui/src/platform/linux/wayland.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//todo!(linux): remove this once the relevant functionality has been implemented
|
||||
#![allow(unused_variables)]
|
||||
|
||||
pub(crate) use client::*;
|
||||
pub(crate) use client_dispatcher::*;
|
||||
|
||||
mod client;
|
||||
mod client_dispatcher;
|
||||
mod display;
|
||||
mod window;
|
||||
253
crates/gpui/src/platform/linux/wayland/client.rs
Normal file
253
crates/gpui/src/platform/linux/wayland/client.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use wayland_client::protocol::wl_callback::WlCallback;
|
||||
use wayland_client::{
|
||||
delegate_noop,
|
||||
protocol::{
|
||||
wl_buffer, wl_callback, wl_compositor, wl_keyboard, wl_registry, wl_seat, wl_shm,
|
||||
wl_shm_pool,
|
||||
wl_surface::{self, WlSurface},
|
||||
},
|
||||
Connection, Dispatch, EventQueue, Proxy, QueueHandle,
|
||||
};
|
||||
use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
|
||||
|
||||
use crate::platform::linux::client::Client;
|
||||
use crate::platform::linux::wayland::window::WaylandWindow;
|
||||
use crate::platform::{LinuxPlatformInner, PlatformWindow};
|
||||
use crate::{
|
||||
platform::linux::wayland::window::WaylandWindowState, AnyWindowHandle, DisplayId,
|
||||
PlatformDisplay, WindowOptions,
|
||||
};
|
||||
|
||||
pub(crate) struct WaylandClientState {
|
||||
compositor: Option<wl_compositor::WlCompositor>,
|
||||
buffer: Option<wl_buffer::WlBuffer>,
|
||||
wm_base: Option<xdg_wm_base::XdgWmBase>,
|
||||
windows: Vec<(xdg_surface::XdgSurface, Rc<WaylandWindowState>)>,
|
||||
platform_inner: Rc<LinuxPlatformInner>,
|
||||
}
|
||||
|
||||
pub(crate) struct WaylandClient {
|
||||
platform_inner: Rc<LinuxPlatformInner>,
|
||||
conn: Arc<Connection>,
|
||||
state: Mutex<WaylandClientState>,
|
||||
event_queue: Mutex<EventQueue<WaylandClientState>>,
|
||||
qh: Arc<QueueHandle<WaylandClientState>>,
|
||||
}
|
||||
|
||||
impl WaylandClient {
|
||||
pub(crate) fn new(linux_platform_inner: Rc<LinuxPlatformInner>, conn: Arc<Connection>) -> Self {
|
||||
let state = WaylandClientState {
|
||||
compositor: None,
|
||||
buffer: None,
|
||||
wm_base: None,
|
||||
windows: Vec::new(),
|
||||
platform_inner: Rc::clone(&linux_platform_inner),
|
||||
};
|
||||
let event_queue: EventQueue<WaylandClientState> = conn.new_event_queue();
|
||||
let qh = event_queue.handle();
|
||||
Self {
|
||||
platform_inner: linux_platform_inner,
|
||||
conn,
|
||||
state: Mutex::new(state),
|
||||
event_queue: Mutex::new(event_queue),
|
||||
qh: Arc::new(qh),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Client for WaylandClient {
|
||||
fn run(&self, on_finish_launching: Box<dyn FnOnce()>) {
|
||||
let display = self.conn.display();
|
||||
let mut eq = self.event_queue.lock();
|
||||
let _registry = display.get_registry(&self.qh, ());
|
||||
|
||||
eq.roundtrip(&mut self.state.lock()).unwrap();
|
||||
|
||||
on_finish_launching();
|
||||
while !self.platform_inner.state.lock().quit_requested {
|
||||
eq.flush().unwrap();
|
||||
eq.dispatch_pending(&mut self.state.lock()).unwrap();
|
||||
if let Some(guard) = self.conn.prepare_read() {
|
||||
guard.read().unwrap();
|
||||
eq.dispatch_pending(&mut self.state.lock()).unwrap();
|
||||
}
|
||||
if let Ok(runnable) = self.platform_inner.main_receiver.try_recv() {
|
||||
runnable.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn displays(&self) -> Vec<Rc<dyn PlatformDisplay>> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn display(&self, id: DisplayId) -> Option<Rc<dyn PlatformDisplay>> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn open_window(
|
||||
&self,
|
||||
handle: AnyWindowHandle,
|
||||
options: WindowOptions,
|
||||
) -> Box<dyn PlatformWindow> {
|
||||
let mut state = self.state.lock();
|
||||
|
||||
let wm_base = state.wm_base.as_ref().unwrap();
|
||||
let compositor = state.compositor.as_ref().unwrap();
|
||||
let wl_surface = compositor.create_surface(&self.qh, ());
|
||||
let xdg_surface = wm_base.get_xdg_surface(&wl_surface, &self.qh, ());
|
||||
let toplevel = xdg_surface.get_toplevel(&self.qh, ());
|
||||
let wl_surface = Arc::new(wl_surface);
|
||||
|
||||
wl_surface.frame(&self.qh, wl_surface.clone());
|
||||
wl_surface.commit();
|
||||
|
||||
let window_state = Rc::new(WaylandWindowState::new(
|
||||
&self.conn,
|
||||
wl_surface.clone(),
|
||||
Arc::new(toplevel),
|
||||
options,
|
||||
));
|
||||
|
||||
state.windows.push((xdg_surface, Rc::clone(&window_state)));
|
||||
Box::new(WaylandWindow(window_state))
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, ()> for WaylandClientState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &wl_registry::WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name, interface, ..
|
||||
} = event
|
||||
{
|
||||
match &interface[..] {
|
||||
"wl_compositor" => {
|
||||
let compositor =
|
||||
registry.bind::<wl_compositor::WlCompositor, _, _>(name, 1, qh, ());
|
||||
state.compositor = Some(compositor);
|
||||
}
|
||||
"xdg_wm_base" => {
|
||||
let wm_base = registry.bind::<xdg_wm_base::XdgWmBase, _, _>(name, 1, qh, ());
|
||||
state.wm_base = Some(wm_base);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delegate_noop!(WaylandClientState: ignore wl_compositor::WlCompositor);
|
||||
delegate_noop!(WaylandClientState: ignore wl_surface::WlSurface);
|
||||
delegate_noop!(WaylandClientState: ignore wl_shm::WlShm);
|
||||
delegate_noop!(WaylandClientState: ignore wl_shm_pool::WlShmPool);
|
||||
delegate_noop!(WaylandClientState: ignore wl_buffer::WlBuffer);
|
||||
delegate_noop!(WaylandClientState: ignore wl_seat::WlSeat);
|
||||
delegate_noop!(WaylandClientState: ignore wl_keyboard::WlKeyboard);
|
||||
|
||||
impl Dispatch<WlCallback, Arc<WlSurface>> for WaylandClientState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlCallback,
|
||||
event: wl_callback::Event,
|
||||
surf: &Arc<WlSurface>,
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_callback::Event::Done { .. } = event {
|
||||
for window in &state.windows {
|
||||
if window.1.surface.id() == surf.id() {
|
||||
window.1.surface.frame(qh, surf.clone());
|
||||
window.1.update();
|
||||
window.1.surface.commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<xdg_surface::XdgSurface, ()> for WaylandClientState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
xdg_surface: &xdg_surface::XdgSurface,
|
||||
event: xdg_surface::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let xdg_surface::Event::Configure { serial, .. } = event {
|
||||
xdg_surface.ack_configure(serial);
|
||||
for window in &state.windows {
|
||||
if &window.0 == xdg_surface {
|
||||
window.1.update();
|
||||
window.1.surface.commit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<xdg_toplevel::XdgToplevel, ()> for WaylandClientState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
xdg_toplevel: &xdg_toplevel::XdgToplevel,
|
||||
event: <xdg_toplevel::XdgToplevel as Proxy>::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let xdg_toplevel::Event::Configure {
|
||||
width,
|
||||
height,
|
||||
states: _states,
|
||||
} = event
|
||||
{
|
||||
if width == 0 || height == 0 {
|
||||
return;
|
||||
}
|
||||
for window in &state.windows {
|
||||
if window.1.toplevel.id() == xdg_toplevel.id() {
|
||||
window.1.resize(width, height);
|
||||
window.1.surface.commit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if let xdg_toplevel::Event::Close = event {
|
||||
state.windows.retain(|(_, window)| {
|
||||
if window.toplevel.id() == xdg_toplevel.id() {
|
||||
window.toplevel.destroy();
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
state.platform_inner.state.lock().quit_requested |= state.windows.is_empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<xdg_wm_base::XdgWmBase, ()> for WaylandClientState {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
wm_base: &xdg_wm_base::XdgWmBase,
|
||||
event: <xdg_wm_base::XdgWmBase as Proxy>::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let xdg_wm_base::Event::Ping { serial } = event {
|
||||
wm_base.pong(serial);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
crates/gpui/src/platform/linux/wayland/client_dispatcher.rs
Normal file
30
crates/gpui/src/platform/linux/wayland/client_dispatcher.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use wayland_client::{Connection, EventQueue};
|
||||
|
||||
use crate::platform::linux::client_dispatcher::ClientDispatcher;
|
||||
|
||||
pub(crate) struct WaylandClientDispatcher {
|
||||
conn: Arc<Connection>,
|
||||
event_queue: Arc<EventQueue<Connection>>,
|
||||
}
|
||||
|
||||
impl WaylandClientDispatcher {
|
||||
pub(crate) fn new(conn: &Arc<Connection>) -> Self {
|
||||
let event_queue = conn.new_event_queue();
|
||||
Self {
|
||||
conn: Arc::clone(conn),
|
||||
event_queue: Arc::new(event_queue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WaylandClientDispatcher {
|
||||
fn drop(&mut self) {
|
||||
//todo!(linux)
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientDispatcher for WaylandClientDispatcher {
|
||||
fn dispatch_on_main_thread(&self) {}
|
||||
}
|
||||
31
crates/gpui/src/platform/linux/wayland/display.rs
Normal file
31
crates/gpui/src/platform/linux/wayland/display.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{Bounds, DisplayId, GlobalPixels, PlatformDisplay, Size};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WaylandDisplay {}
|
||||
|
||||
impl PlatformDisplay for WaylandDisplay {
|
||||
// todo!(linux)
|
||||
fn id(&self) -> DisplayId {
|
||||
DisplayId(123) // return some fake data so it doesn't panic
|
||||
}
|
||||
|
||||
// todo!(linux)
|
||||
fn uuid(&self) -> anyhow::Result<Uuid> {
|
||||
Ok(Uuid::from_bytes([0; 16])) // return some fake data so it doesn't panic
|
||||
}
|
||||
|
||||
// todo!(linux)
|
||||
fn bounds(&self) -> Bounds<GlobalPixels> {
|
||||
Bounds {
|
||||
origin: Default::default(),
|
||||
size: Size {
|
||||
width: GlobalPixels(1000f32),
|
||||
height: GlobalPixels(500f32),
|
||||
},
|
||||
} // return some fake data so it doesn't panic
|
||||
}
|
||||
}
|
||||
350
crates/gpui/src/platform/linux/wayland/window.rs
Normal file
350
crates/gpui/src/platform/linux/wayland/window.rs
Normal file
@@ -0,0 +1,350 @@
|
||||
use std::any::Any;
|
||||
use std::ffi::c_void;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use blade_graphics as gpu;
|
||||
use blade_rwh::{HasRawDisplayHandle, HasRawWindowHandle, RawDisplayHandle, RawWindowHandle};
|
||||
use futures::channel::oneshot::Receiver;
|
||||
use parking_lot::Mutex;
|
||||
use raw_window_handle::{
|
||||
DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle,
|
||||
};
|
||||
use wayland_client::{protocol::wl_surface, Proxy};
|
||||
use wayland_protocols::xdg::shell::client::xdg_toplevel;
|
||||
|
||||
use crate::platform::linux::blade_renderer::BladeRenderer;
|
||||
use crate::platform::linux::wayland::display::WaylandDisplay;
|
||||
use crate::platform::{PlatformAtlas, PlatformInputHandler, PlatformWindow};
|
||||
use crate::scene::Scene;
|
||||
use crate::{
|
||||
px, Bounds, Modifiers, Pixels, PlatformDisplay, PlatformInput, Point, PromptLevel, Size,
|
||||
WindowAppearance, WindowBounds, WindowOptions,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Callbacks {
|
||||
request_frame: Option<Box<dyn FnMut()>>,
|
||||
input: Option<Box<dyn FnMut(crate::PlatformInput) -> bool>>,
|
||||
active_status_change: Option<Box<dyn FnMut(bool)>>,
|
||||
resize: Option<Box<dyn FnMut(Size<Pixels>, f32)>>,
|
||||
fullscreen: Option<Box<dyn FnMut(bool)>>,
|
||||
moved: Option<Box<dyn FnMut()>>,
|
||||
should_close: Option<Box<dyn FnMut() -> bool>>,
|
||||
close: Option<Box<dyn FnOnce()>>,
|
||||
appearance_changed: Option<Box<dyn FnMut()>>,
|
||||
}
|
||||
|
||||
struct WaylandWindowInner {
|
||||
renderer: BladeRenderer,
|
||||
bounds: Bounds<i32>,
|
||||
}
|
||||
|
||||
struct RawWindow {
|
||||
window: *mut c_void,
|
||||
display: *mut c_void,
|
||||
}
|
||||
|
||||
unsafe impl HasRawWindowHandle for RawWindow {
|
||||
fn raw_window_handle(&self) -> RawWindowHandle {
|
||||
let mut wh = blade_rwh::WaylandWindowHandle::empty();
|
||||
wh.surface = self.window;
|
||||
wh.into()
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl HasRawDisplayHandle for RawWindow {
|
||||
fn raw_display_handle(&self) -> RawDisplayHandle {
|
||||
let mut dh = blade_rwh::WaylandDisplayHandle::empty();
|
||||
dh.display = self.display;
|
||||
dh.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl WaylandWindowInner {
|
||||
fn new(
|
||||
conn: &Arc<wayland_client::Connection>,
|
||||
wl_surf: &Arc<wl_surface::WlSurface>,
|
||||
bounds: Bounds<i32>,
|
||||
) -> Self {
|
||||
let raw = RawWindow {
|
||||
window: wl_surf.id().as_ptr() as *mut _,
|
||||
display: conn.backend().display_ptr() as *mut _,
|
||||
};
|
||||
let gpu = Arc::new(
|
||||
unsafe {
|
||||
gpu::Context::init_windowed(
|
||||
&raw,
|
||||
gpu::ContextDesc {
|
||||
validation: false,
|
||||
capture: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
.unwrap(),
|
||||
);
|
||||
let extent = gpu::Extent {
|
||||
width: bounds.size.width as u32,
|
||||
height: bounds.size.height as u32,
|
||||
depth: 1,
|
||||
};
|
||||
Self {
|
||||
renderer: BladeRenderer::new(gpu, extent),
|
||||
bounds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct WaylandWindowState {
|
||||
conn: Arc<wayland_client::Connection>,
|
||||
inner: Mutex<WaylandWindowInner>,
|
||||
pub(crate) callbacks: Mutex<Callbacks>,
|
||||
pub(crate) surface: Arc<wl_surface::WlSurface>,
|
||||
pub(crate) toplevel: Arc<xdg_toplevel::XdgToplevel>,
|
||||
}
|
||||
|
||||
impl WaylandWindowState {
|
||||
pub(crate) fn new(
|
||||
conn: &Arc<wayland_client::Connection>,
|
||||
wl_surf: Arc<wl_surface::WlSurface>,
|
||||
toplevel: Arc<xdg_toplevel::XdgToplevel>,
|
||||
options: WindowOptions,
|
||||
) -> Self {
|
||||
if options.bounds == WindowBounds::Maximized {
|
||||
toplevel.set_maximized();
|
||||
} else if options.bounds == WindowBounds::Fullscreen {
|
||||
toplevel.set_fullscreen(None);
|
||||
}
|
||||
|
||||
let bounds: Bounds<i32> = match options.bounds {
|
||||
WindowBounds::Fullscreen | WindowBounds::Maximized => Bounds {
|
||||
origin: Point::default(),
|
||||
size: Size {
|
||||
width: 500,
|
||||
height: 500,
|
||||
}, //todo!(implement)
|
||||
},
|
||||
WindowBounds::Fixed(bounds) => bounds.map(|p| p.0 as i32),
|
||||
};
|
||||
|
||||
Self {
|
||||
conn: Arc::clone(conn),
|
||||
surface: Arc::clone(&wl_surf),
|
||||
inner: Mutex::new(WaylandWindowInner::new(&Arc::clone(conn), &wl_surf, bounds)),
|
||||
callbacks: Mutex::new(Callbacks::default()),
|
||||
toplevel,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&self) {
|
||||
let mut cb = self.callbacks.lock();
|
||||
if let Some(mut fun) = cb.request_frame.take() {
|
||||
drop(cb);
|
||||
fun();
|
||||
self.callbacks.lock().request_frame = Some(fun);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resize(&self, width: i32, height: i32) {
|
||||
{
|
||||
let mut inner = self.inner.lock();
|
||||
inner.bounds.size.width = width;
|
||||
inner.bounds.size.height = height;
|
||||
inner.renderer.resize(gpu::Extent {
|
||||
width: width as u32,
|
||||
height: height as u32,
|
||||
depth: 1,
|
||||
});
|
||||
}
|
||||
let mut callbacks = self.callbacks.lock();
|
||||
if let Some(ref mut fun) = callbacks.resize {
|
||||
fun(
|
||||
Size {
|
||||
width: px(width as f32),
|
||||
height: px(height as f32),
|
||||
},
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
if let Some(ref mut fun) = callbacks.moved {
|
||||
fun()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let mut callbacks = self.callbacks.lock();
|
||||
if let Some(fun) = callbacks.close.take() {
|
||||
fun()
|
||||
}
|
||||
self.toplevel.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct WaylandWindow(pub(crate) Rc<WaylandWindowState>);
|
||||
|
||||
impl HasWindowHandle for WaylandWindow {
|
||||
fn window_handle(&self) -> Result<WindowHandle<'_>, HandleError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
impl HasDisplayHandle for WaylandWindow {
|
||||
fn display_handle(&self) -> Result<DisplayHandle<'_>, HandleError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformWindow for WaylandWindow {
|
||||
//todo!(linux)
|
||||
fn bounds(&self) -> WindowBounds {
|
||||
WindowBounds::Maximized
|
||||
}
|
||||
|
||||
// todo!(linux)
|
||||
fn content_size(&self) -> Size<Pixels> {
|
||||
let inner = self.0.inner.lock();
|
||||
Size {
|
||||
width: Pixels(inner.bounds.size.width as f32),
|
||||
height: Pixels(inner.bounds.size.height as f32),
|
||||
}
|
||||
}
|
||||
|
||||
// todo!(linux)
|
||||
fn scale_factor(&self) -> f32 {
|
||||
1f32
|
||||
}
|
||||
|
||||
//todo!(linux)
|
||||
fn titlebar_height(&self) -> Pixels {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
// todo!(linux)
|
||||
fn appearance(&self) -> WindowAppearance {
|
||||
WindowAppearance::Light
|
||||
}
|
||||
|
||||
// todo!(linux)
|
||||
fn display(&self) -> Rc<dyn PlatformDisplay> {
|
||||
Rc::new(WaylandDisplay {})
|
||||
}
|
||||
|
||||
// todo!(linux)
|
||||
fn mouse_position(&self) -> Point<Pixels> {
|
||||
Point::default()
|
||||
}
|
||||
|
||||
//todo!(linux)
|
||||
fn modifiers(&self) -> Modifiers {
|
||||
crate::Modifiers::default()
|
||||
}
|
||||
|
||||
//todo!(linux)
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn set_input_handler(&mut self, input_handler: PlatformInputHandler) {
|
||||
//todo!(linux)
|
||||
}
|
||||
|
||||
//todo!(linux)
|
||||
fn take_input_handler(&mut self) -> Option<PlatformInputHandler> {
|
||||
None
|
||||
}
|
||||
|
||||
//todo!(linux)
|
||||
fn prompt(
|
||||
&self,
|
||||
level: PromptLevel,
|
||||
msg: &str,
|
||||
detail: Option<&str>,
|
||||
answers: &[&str],
|
||||
) -> Receiver<usize> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn activate(&self) {
|
||||
//todo!(linux)
|
||||
}
|
||||
|
||||
fn set_title(&mut self, title: &str) {
|
||||
self.0.toplevel.set_title(title.to_string());
|
||||
}
|
||||
|
||||
fn set_edited(&mut self, edited: bool) {
|
||||
//todo!(linux)
|
||||
}
|
||||
|
||||
fn show_character_palette(&self) {
|
||||
//todo!(linux)
|
||||
}
|
||||
|
||||
fn minimize(&self) {
|
||||
//todo!(linux)
|
||||
}
|
||||
|
||||
fn zoom(&self) {
|
||||
//todo!(linux)
|
||||
}
|
||||
|
||||
fn toggle_full_screen(&self) {
|
||||
//todo!(linux)
|
||||
}
|
||||
|
||||
fn on_request_frame(&self, callback: Box<dyn FnMut()>) {
|
||||
self.0.callbacks.lock().request_frame = Some(callback);
|
||||
}
|
||||
|
||||
fn on_input(&self, callback: Box<dyn FnMut(PlatformInput) -> bool>) {
|
||||
//todo!(linux)
|
||||
}
|
||||
|
||||
fn on_active_status_change(&self, callback: Box<dyn FnMut(bool)>) {
|
||||
//todo!(linux)
|
||||
}
|
||||
|
||||
fn on_resize(&self, callback: Box<dyn FnMut(Size<Pixels>, f32)>) {
|
||||
self.0.callbacks.lock().resize = Some(callback);
|
||||
}
|
||||
|
||||
fn on_fullscreen(&self, callback: Box<dyn FnMut(bool)>) {
|
||||
//todo!(linux)
|
||||
}
|
||||
|
||||
fn on_moved(&self, callback: Box<dyn FnMut()>) {
|
||||
self.0.callbacks.lock().moved = Some(callback);
|
||||
}
|
||||
|
||||
fn on_should_close(&self, callback: Box<dyn FnMut() -> bool>) {
|
||||
self.0.callbacks.lock().should_close = Some(callback);
|
||||
}
|
||||
|
||||
fn on_close(&self, callback: Box<dyn FnOnce()>) {
|
||||
self.0.callbacks.lock().close = Some(callback);
|
||||
}
|
||||
|
||||
fn on_appearance_changed(&self, callback: Box<dyn FnMut()>) {
|
||||
//todo!(linux)
|
||||
}
|
||||
|
||||
// todo!(linux)
|
||||
fn is_topmost_for_position(&self, position: Point<Pixels>) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn draw(&self, scene: &Scene) {
|
||||
let mut inner = self.0.inner.lock();
|
||||
inner.renderer.draw(scene);
|
||||
}
|
||||
|
||||
fn sprite_atlas(&self) -> Arc<dyn PlatformAtlas> {
|
||||
let inner = self.0.inner.lock();
|
||||
inner.renderer.atlas().clone()
|
||||
}
|
||||
|
||||
fn set_graphics_profiler_enabled(&self, enabled: bool) {
|
||||
//todo!(linux)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
mod client;
|
||||
mod client_dispatcher;
|
||||
pub mod display;
|
||||
mod display;
|
||||
mod event;
|
||||
mod window;
|
||||
|
||||
pub(crate) use client::*;
|
||||
pub(crate) use client_dispatcher::*;
|
||||
pub(crate) use display::*;
|
||||
pub(crate) use event::*;
|
||||
pub(crate) use window::*;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user