Compare commits
66
Commits
dcf28e2b60
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d082f4fad9 | ||
|
|
8c59eabba3 | ||
|
|
bc6bdb3c35 | ||
|
|
dbfee32d55 | ||
|
|
4f52fc52df | ||
|
|
fbf06f2c81 | ||
|
|
b0d1521c49 | ||
|
|
9dbac5308d | ||
|
|
c8cabb7cd2 | ||
|
|
6d9284b37a | ||
|
|
b518c729f6 | ||
|
|
4b57a1d2a6 | ||
|
|
5ea6cdb34f | ||
|
|
1f04a824d7 | ||
|
|
c78e0a5163 | ||
|
|
5d4c8634ef | ||
|
|
4efeec08c4 | ||
|
|
2e451aae12 | ||
|
|
cdfcfdd782 | ||
|
|
9817dd29a6 | ||
|
|
f066cb8223 | ||
|
|
6d60726f27 | ||
|
|
80186a79e5 | ||
|
|
cffcb4711d | ||
|
|
44484d9992 | ||
|
|
15ac8d6775 | ||
|
|
6f0cefed33 | ||
|
|
c239e351b8 | ||
|
|
9ff18aae35 | ||
|
|
6fef2ae1c6 | ||
|
|
c2a723faa8 | ||
|
|
6b872527ad | ||
|
|
d9b16aea9a | ||
|
|
8345def015 | ||
|
|
b0ba2549d7 | ||
|
|
c8034642c5 | ||
|
|
8205c69e19 | ||
|
|
d36364d60d | ||
|
|
99363475e0 | ||
|
|
a52e1877fe | ||
|
|
b41de00c95 | ||
|
|
94cbb4aa0e | ||
|
|
40e7ca368b | ||
|
|
b91697defc | ||
|
|
1d57a2deab | ||
|
|
aa26c9ccba | ||
|
|
ff61c28a76 | ||
|
|
069eae8145 | ||
|
|
ccbcc644db | ||
|
|
15c5ce7677 | ||
|
|
40d726c986 | ||
|
|
fe4eb7df74 | ||
|
|
b5d6d91851 | ||
|
|
d475d03d0c | ||
|
|
0f00fed122 | ||
|
|
ef73b3c629 | ||
|
|
bbf31baee5 | ||
|
|
80227b3ed3 | ||
|
|
d00c5a1982 | ||
|
|
c054017d7e | ||
|
|
d065e70cd1 | ||
|
|
7a6b6feacc | ||
|
|
55c5ebbf17 | ||
|
|
3fecda175b | ||
|
|
2423cdca19 | ||
|
|
4b021bef01 |
@@ -21,7 +21,7 @@ jobs:
|
|||||||
os: windows-11-arm
|
os: windows-11-arm
|
||||||
target: aarch64-pc-windows-msvc
|
target: aarch64-pc-windows-msvc
|
||||||
- platform: macos-x64
|
- platform: macos-x64
|
||||||
os: macos-13
|
os: macos-15-intel
|
||||||
target: x86_64-apple-darwin
|
target: x86_64-apple-darwin
|
||||||
- platform: macos-arm64
|
- platform: macos-arm64
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
# Windows and macOS builds using cargo-packager
|
# Windows and macOS builds using cargo-packager
|
||||||
- name: Build with cargo-packager (Windows/macOS)
|
- name: Build with cargo-packager (Windows/macOS)
|
||||||
if: runner.os != 'Linux'
|
if: runner.os != 'Linux'
|
||||||
working-directory: crates/coop
|
working-directory: desktop
|
||||||
run: |
|
run: |
|
||||||
cargo install cargo-packager --locked
|
cargo install cargo-packager --locked
|
||||||
cargo packager --release
|
cargo packager --release
|
||||||
@@ -130,7 +130,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Make get-crate-version executable
|
- name: Make get-crate-version executable
|
||||||
run: chmod +x script/get-crate-version
|
run: chmod +x script/get-crate-version
|
||||||
@@ -154,17 +154,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: akkuman/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.version.outputs.tag }}
|
server_url: "https://git.reya.su/"
|
||||||
name: ${{ steps.version.outputs.tag }}
|
repository: "reya/coop"
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
draft: true
|
draft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
generate_release_notes: true
|
|
||||||
files: |
|
files: |
|
||||||
artifacts/**/*
|
artifacts/**/*
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Output release info
|
- name: Output release info
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+5
-2
@@ -19,5 +19,8 @@ dist/
|
|||||||
|
|
||||||
# Useless stuffs
|
# Useless stuffs
|
||||||
.DS_Store
|
.DS_Store
|
||||||
# Added by goreleaser init:
|
|
||||||
.intentionally-empty-file.o
|
.cargo/
|
||||||
|
vendor/
|
||||||
|
wasm/
|
||||||
|
node_modules/
|
||||||
|
|||||||
Generated
+2304
-1819
File diff suppressed because it is too large
Load Diff
+18
-11
@@ -1,18 +1,17 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["crates/*"]
|
members = ["crates/*", "desktop", "web"]
|
||||||
default-members = ["crates/coop"]
|
default-members = ["desktop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.0.0-beta"
|
version = "1.0.0-beta5"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|
||||||
# GPUI
|
# GPUI
|
||||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["screen-capture", "x11", "wayland", "runtime_shaders"] }
|
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
||||||
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
||||||
gpui_windows = { git = "https://github.com/zed-industries/zed" }
|
gpui_windows = { git = "https://github.com/zed-industries/zed" }
|
||||||
gpui_macos = { git = "https://github.com/zed-industries/zed" }
|
gpui_macos = { git = "https://github.com/zed-industries/zed" }
|
||||||
@@ -21,27 +20,35 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
|||||||
|
|
||||||
# Nostr
|
# Nostr
|
||||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
|
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||||
|
|
||||||
# Others
|
# Others
|
||||||
anyhow = "1.0.44"
|
anyhow = "1.0.44"
|
||||||
chrono = "0.4.38"
|
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
itertools = "0.13.0"
|
itertools = "0.13.0"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
oneshot = "0.1.10"
|
oneshot = "0.1.10"
|
||||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||||
rust-embed = "8.5.0"
|
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
schemars = "1"
|
schemars = "1"
|
||||||
smallvec = "1.14.0"
|
smallvec = "1.14.0"
|
||||||
smol = "2"
|
smol = "2"
|
||||||
tracing = "0.1.40"
|
|
||||||
webbrowser = "1.0.4"
|
webbrowser = "1.0.4"
|
||||||
|
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||||
|
errno = { version = "0.3.14", default-features = false }
|
||||||
|
instant = "0.1"
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
# Use stacker's psm version which may have better WASM support
|
||||||
|
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["crates/*", "desktop", "web"]
|
||||||
|
default-members = ["desktop"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "1.0.0-beta5"
|
||||||
|
edition = "2024"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# GPUI
|
||||||
|
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||||
|
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
||||||
|
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
||||||
|
gpui_windows = { git = "https://github.com/zed-industries/zed" }
|
||||||
|
gpui_macos = { git = "https://github.com/zed-industries/zed" }
|
||||||
|
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||||
|
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||||
|
|
||||||
|
# Nostr
|
||||||
|
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||||
|
|
||||||
|
# Others
|
||||||
|
anyhow = "1.0.44"
|
||||||
|
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||||
|
futures = "0.3"
|
||||||
|
itertools = "0.13.0"
|
||||||
|
log = "0.4"
|
||||||
|
oneshot = "0.1.10"
|
||||||
|
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||||
|
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
schemars = "1"
|
||||||
|
smallvec = "1.14.0"
|
||||||
|
smol = "2"
|
||||||
|
webbrowser = "1.0.4"
|
||||||
|
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||||
|
errno = { version = "0.3.14", default-features = false }
|
||||||
|
instant = "0.1"
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
# Use stacker's psm version which may have better WASM support
|
||||||
|
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
opt-level = "z"
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
[profile.profiling]
|
||||||
|
inherits = "release"
|
||||||
|
debug = true
|
||||||
@@ -1,125 +1,5 @@
|
|||||||

|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://github.com/lumehq/coop/actions/workflows/rust.yml">
|
|
||||||
<img alt="Actions" src="https://github.com/lumehq/coop/actions/workflows/rust.yml/badge.svg">
|
|
||||||
</a>
|
|
||||||
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/lumehq/coop">
|
|
||||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/lumehq/coop">
|
|
||||||
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/lumehq/coop">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
|
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
|
||||||
|
|
||||||
### Screenshots
|
|
||||||
|
|
||||||
<p float="left">
|
|
||||||
<img src="/docs/mac_01.png" width="250" />
|
|
||||||
<img src="/docs/mac_02.png" width="250" />
|
|
||||||
<img src="/docs/mac_03.png" width="250" />
|
|
||||||
<img src="/docs/mac_04.png" width="250" />
|
|
||||||
<img src="/docs/mac_05.png" width="250" />
|
|
||||||
<img src="/docs/mac_06.png" width="250" />
|
|
||||||
<img src="/docs/mac_07.png" width="250" />
|
|
||||||
<img src="/docs/mac_08.png" width="250" />
|
|
||||||
<img src="/docs/mac_09.png" width="250" />
|
|
||||||
<img src="/docs/linux_01.png" width="250" />
|
|
||||||
<img src="/docs/linux_02.png" width="250" />
|
|
||||||
<img src="/docs/linux_03.png" width="250" />
|
|
||||||
<img src="/docs/linux_04.png" width="250" />
|
|
||||||
<img src="/docs/linux_05.png" width="250" />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
To install Coop, follow these steps:
|
|
||||||
|
|
||||||
1. **Download the Latest Release**:
|
|
||||||
|
|
||||||
- Visit the [Coop Releases page on GitHub](https://github.com/lumehq/coop/releases).
|
|
||||||
- Download the package that matches your operating system (Windows, macOS, or Linux).
|
|
||||||
|
|
||||||
2. **Install**:
|
|
||||||
|
|
||||||
- **Windows**: Run the downloaded `.exe` installer and follow the on-screen instructions.
|
|
||||||
- **macOS**: Open the downloaded `.dmg` file and drag Coop to your Applications folder.
|
|
||||||
- **Linux**: Run the downloaded `.flatpak` or `.snap` installer and follow the on-screen instructions.
|
|
||||||
|
|
||||||
3. **Run Coop**:
|
|
||||||
- Launch Coop from your Applications folder (macOS) or by double-clicking the executable (Windows/Linux).
|
|
||||||
|
|
||||||
For more detailed instructions, refer to the [Release Notes](#) on GitHub.
|
|
||||||
|
|
||||||
### Developing Coop
|
|
||||||
|
|
||||||
Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nostr SDK](https://github.com/rust-nostr/nostr)
|
|
||||||
|
|
||||||
#### Prerequisites
|
|
||||||
|
|
||||||
- **Rust Toolchain**: Ensure you have Rust installed. If not, you can install it using [rustup](https://rustup.rs/).
|
|
||||||
- **Cargo**: Rust's package manager, which comes bundled with the Rust installation.
|
|
||||||
- **Git**: To clone the repository and manage version control.
|
|
||||||
|
|
||||||
#### Setting Up the Development Environment
|
|
||||||
|
|
||||||
1. Clone the repository:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/lumehq/coop.git
|
|
||||||
cd coop
|
|
||||||
```
|
|
||||||
|
|
||||||
2.1 Install Linux dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./script/linux
|
|
||||||
```
|
|
||||||
|
|
||||||
2.2 Install FreeBSD dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./script/freebsd
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Install Rust dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Run the app:
|
|
||||||
```bash
|
|
||||||
cargo run
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Building for Production
|
|
||||||
|
|
||||||
To build Coop for production, use the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
This will generate an optimized binary in the `target/release` directory.
|
|
||||||
|
|
||||||
#### Contributing Code
|
|
||||||
|
|
||||||
If you'd like to contribute to Coop, please follow these steps:
|
|
||||||
|
|
||||||
1. Fork the repository.
|
|
||||||
2. Create a new branch for your feature or bugfix.
|
|
||||||
3. Make your changes and ensure all tests pass.
|
|
||||||
4. Submit a pull request with a detailed description of your changes.
|
|
||||||
|
|
||||||
For more information, see the [Contributing](#contributing) section.
|
|
||||||
|
|
||||||
#### Additional Resources
|
|
||||||
|
|
||||||
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
|
|
||||||
- [GPUI](https://www.gpui.rs/)
|
|
||||||
- [GPUI Components](https://github.com/longbridge/gpui-component/)
|
|
||||||
- [Coop Issue Tracker](https://github.com/lumehq/coop/issues/)
|
|
||||||
|
|
||||||
### License
|
### License
|
||||||
|
|
||||||
Copyright (C) 2025 Ren Amamiya & other Coop contributors
|
Copyright (C) 2025 Ren Amamiya & other Coop contributors
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M14.25 10.75C14.25 9.64543 15.1454 8.75 16.25 8.75H20.25C21.3546 8.75 22.25 9.64543 22.25 10.75V19.25C22.25 20.3546 21.3546 21.25 20.25 21.25H16.25C15.1454 21.25 14.25 20.3546 14.25 19.25V10.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.25 18.25H19.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.25 8.75V5.75C20.25 4.64543 19.3546 3.75 18.25 3.75H5.75C4.64543 3.75 3.75 4.64543 3.75 5.75V14.75C3.75 15.8546 2.85457 16.75 1.75 16.75V18.25C1.75 19.3546 2.64543 20.25 3.75 20.25H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.75 16.75H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 898 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="8.75" r="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="4" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="20" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.25 16.625V16.5C7.25 13.8766 9.37665 11.75 12 11.75C14.6234 11.75 16.75 13.8766 16.75 16.5V16.625C16.75 17.5225 16.0225 18.25 15.125 18.25H8.875C7.97754 18.25 7.25 17.5225 7.25 16.625Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.25 17.2602H2.75C1.64543 17.2602 0.706551 16.3538 0.919944 15.2701C1.25877 13.5493 2.15049 12.3257 4 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M19.75 17.2601H21.25C22.3546 17.2601 23.2935 16.3538 23.08 15.27C22.7412 13.5493 21.8495 12.3257 20 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M11.75 6.75H19.25C20.3546 6.75 21.25 7.64543 21.25 8.75V15.25C21.25 16.3546 20.3546 17.25 19.25 17.25H11.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.75 6.75H4.75C3.64543 6.75 2.75 7.64543 2.75 8.75V15.25C2.75 16.3546 3.64543 17.25 4.75 17.25H5.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.75 3.75V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 604 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M7.25 4.75H4.75C3.64543 4.75 2.75 5.64543 2.75 6.75V9.25M16.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V9.25M21.25 14.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H16.75M7.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V14.75M7.75 9.75V14.25M16.25 9.75V14.25M12 9.75V12.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 468 B |
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
"id": "aurora",
|
||||||
|
"name": "Aurora",
|
||||||
|
"author": "Coop",
|
||||||
|
"url": "https://github.com/lumehq/coop",
|
||||||
|
"light": {
|
||||||
|
"background": "#fdfcfeff",
|
||||||
|
"surface_background": "#f8f8ffff",
|
||||||
|
"elevated_surface_background": "#f0f1feff",
|
||||||
|
"panel_background": "#fdfcfeff",
|
||||||
|
"overlay": "#211f4300",
|
||||||
|
"title_bar": "#f0f1feff",
|
||||||
|
"title_bar_inactive": "#fdfcfeff",
|
||||||
|
"window_border": "#dadcffff",
|
||||||
|
"border": "#dadcffff",
|
||||||
|
"border_variant": "#cbcdffff",
|
||||||
|
"border_focused": "#5b5bd6ff",
|
||||||
|
"border_selected": "#5b5bd6ff",
|
||||||
|
"border_transparent": "#00000000",
|
||||||
|
"border_disabled": "#e6e7ffff",
|
||||||
|
"ring": "#5151cdff",
|
||||||
|
"text": "#1f2d5cff",
|
||||||
|
"text_muted": "#5753c6ff",
|
||||||
|
"text_placeholder": "#9b9ef0ff",
|
||||||
|
"text_accent": "#5b5bd6ff",
|
||||||
|
"text_danger": "#e54d2eff",
|
||||||
|
"text_warning": "#f76b15ff",
|
||||||
|
"icon": "#5753c6ff",
|
||||||
|
"icon_muted": "#9b9ef0ff",
|
||||||
|
"icon_accent": "#5151cdff",
|
||||||
|
"element_foreground": "#ffffffff",
|
||||||
|
"element_background": "#5b5bd6ff",
|
||||||
|
"element_hover": "#5151cdff",
|
||||||
|
"element_active": "#6e56cfff",
|
||||||
|
"element_selected": "#654dc4ff",
|
||||||
|
"element_disabled": "#5b5bd64d",
|
||||||
|
"secondary_foreground": "#1f2d5cff",
|
||||||
|
"secondary_background": "#f0f1feff",
|
||||||
|
"secondary_hover": "#e6e7ffff",
|
||||||
|
"secondary_active": "#dadcffff",
|
||||||
|
"secondary_selected": "#dadcffff",
|
||||||
|
"secondary_disabled": "#5b5bd64d",
|
||||||
|
"danger_foreground": "#ffffffff",
|
||||||
|
"danger_background": "#feebe7ff",
|
||||||
|
"danger_hover": "#ffcdc2ff",
|
||||||
|
"danger_active": "#fdbdafff",
|
||||||
|
"danger_selected": "#fdbdafff",
|
||||||
|
"danger_disabled": "#e54d2e4d",
|
||||||
|
"warning_foreground": "#ffffffff",
|
||||||
|
"warning_background": "#fff7edff",
|
||||||
|
"warning_hover": "#ffd19aff",
|
||||||
|
"warning_active": "#ffc182ff",
|
||||||
|
"warning_selected": "#ffc182ff",
|
||||||
|
"warning_disabled": "#f76b154d",
|
||||||
|
"ghost_element_background": "#00000000",
|
||||||
|
"ghost_element_background_alt": "#f0f1feff",
|
||||||
|
"ghost_element_hover": "#211f430d",
|
||||||
|
"ghost_element_active": "#211f431a",
|
||||||
|
"ghost_element_selected": "#211f431a",
|
||||||
|
"ghost_element_disabled": "#211f4305",
|
||||||
|
"tab_background": "#f0f1feff",
|
||||||
|
"tab_foreground": "#5753c6ff",
|
||||||
|
"tab_hover_background": "#211f430d",
|
||||||
|
"tab_active_background": "#fdfcfeff",
|
||||||
|
"tab_active_foreground": "#1f2d5cff",
|
||||||
|
"scrollbar_thumb_background": "#211f431a",
|
||||||
|
"scrollbar_thumb_hover_background": "#211f4326",
|
||||||
|
"scrollbar_thumb_border": "#00000000",
|
||||||
|
"scrollbar_track_background": "#00000000",
|
||||||
|
"scrollbar_track_border": "#00000000",
|
||||||
|
"drop_target_background": "#5b5bd61a",
|
||||||
|
"cursor": "#5b5bd6ff",
|
||||||
|
"selection": "#5b5bd640"
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"background": "#14121fff",
|
||||||
|
"surface_background": "#1b1525ff",
|
||||||
|
"elevated_surface_background": "#291f43ff",
|
||||||
|
"panel_background": "#14121fff",
|
||||||
|
"overlay": "#baa7ff1a",
|
||||||
|
"title_bar": "#291f43ff",
|
||||||
|
"title_bar_inactive": "#14121fff",
|
||||||
|
"window_border": "#473876ff",
|
||||||
|
"border": "#473876ff",
|
||||||
|
"border_variant": "#3c2e69ff",
|
||||||
|
"border_focused": "#7d66d9ff",
|
||||||
|
"border_selected": "#7d66d9ff",
|
||||||
|
"border_transparent": "#00000000",
|
||||||
|
"border_disabled": "#33255bff",
|
||||||
|
"ring": "#6e56cfff",
|
||||||
|
"text": "#e2ddfeff",
|
||||||
|
"text_muted": "#baa7ffff",
|
||||||
|
"text_placeholder": "#6958adff",
|
||||||
|
"text_accent": "#baa7ffff",
|
||||||
|
"text_danger": "#ff977dff",
|
||||||
|
"text_warning": "#ffa057ff",
|
||||||
|
"icon": "#baa7ffff",
|
||||||
|
"icon_muted": "#6958adff",
|
||||||
|
"icon_accent": "#6e56cfff",
|
||||||
|
"element_foreground": "#14121fff",
|
||||||
|
"element_background": "#7d66d9ff",
|
||||||
|
"element_hover": "#baa7ffff",
|
||||||
|
"element_active": "#6e56cfff",
|
||||||
|
"element_selected": "#654dc4ff",
|
||||||
|
"element_disabled": "#7d66d94d",
|
||||||
|
"secondary_foreground": "#e2ddfeff",
|
||||||
|
"secondary_background": "#291f43ff",
|
||||||
|
"secondary_hover": "#33255bff",
|
||||||
|
"secondary_active": "#3c2e69ff",
|
||||||
|
"secondary_selected": "#3c2e69ff",
|
||||||
|
"secondary_disabled": "#7d66d94d",
|
||||||
|
"danger_foreground": "#181111ff",
|
||||||
|
"danger_background": "#391714ff",
|
||||||
|
"danger_hover": "#5e1c16ff",
|
||||||
|
"danger_active": "#6e2920ff",
|
||||||
|
"danger_selected": "#6e2920ff",
|
||||||
|
"danger_disabled": "#ff977d4d",
|
||||||
|
"warning_foreground": "#17120eff",
|
||||||
|
"warning_background": "#331e0bff",
|
||||||
|
"warning_hover": "#562800ff",
|
||||||
|
"warning_active": "#66350cff",
|
||||||
|
"warning_selected": "#66350cff",
|
||||||
|
"warning_disabled": "#ffa0574d",
|
||||||
|
"ghost_element_background": "#00000000",
|
||||||
|
"ghost_element_background_alt": "#291f43ff",
|
||||||
|
"ghost_element_hover": "#baa7ff0d",
|
||||||
|
"ghost_element_active": "#baa7ff1a",
|
||||||
|
"ghost_element_selected": "#baa7ff1a",
|
||||||
|
"ghost_element_disabled": "#baa7ff05",
|
||||||
|
"tab_background": "#291f43ff",
|
||||||
|
"tab_foreground": "#baa7ffff",
|
||||||
|
"tab_hover_background": "#baa7ff0d",
|
||||||
|
"tab_active_background": "#14121fff",
|
||||||
|
"tab_active_foreground": "#e2ddfeff",
|
||||||
|
"scrollbar_thumb_background": "#baa7ff1a",
|
||||||
|
"scrollbar_thumb_hover_background": "#baa7ff26",
|
||||||
|
"scrollbar_thumb_border": "#00000000",
|
||||||
|
"scrollbar_track_background": "#00000000",
|
||||||
|
"scrollbar_track_border": "#00000000",
|
||||||
|
"drop_target_background": "#baa7ff1a",
|
||||||
|
"cursor": "#baa7ffff",
|
||||||
|
"selection": "#baa7ff40"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,74 +1,76 @@
|
|||||||
{
|
{
|
||||||
"id": "catppuccin-frappe",
|
"id": "catppuccin-frappe",
|
||||||
"name": "Catppuccin Frappé",
|
"name": "Catppuccin Frappé",
|
||||||
"author": "Catppuccin",
|
"author": "Catppuccin Org (ported by Coop)",
|
||||||
"url": "https://github.com/catppuccin/catppuccin",
|
"url": "https://catppuccin.com",
|
||||||
"light": {
|
"light": {
|
||||||
"background": "#303446",
|
"background": "#303446",
|
||||||
"surface_background": "#292c3c",
|
"surface_background": "#292c3c",
|
||||||
"elevated_surface_background": "#232634",
|
"elevated_surface_background": "#232634",
|
||||||
"panel_background": "#303446",
|
"panel_background": "#303446",
|
||||||
"overlay": "#c6d0f51a",
|
"overlay": "#c6d0f51a",
|
||||||
"title_bar": "#292c3c",
|
"title_bar": "#232634",
|
||||||
"title_bar_inactive": "#232634",
|
"title_bar_inactive": "#303446",
|
||||||
"window_border": "#737994",
|
"window_border": "#51576d",
|
||||||
"border": "#626880",
|
"border": "#51576d",
|
||||||
"border_variant": "#51576d",
|
"border_variant": "#414559",
|
||||||
"border_focused": "#8caaee",
|
"border_focused": "#8caaee",
|
||||||
"border_selected": "#8caaee",
|
"border_selected": "#8caaee",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#c6d0f500",
|
||||||
"border_disabled": "#414559",
|
"border_disabled": "#292c3c",
|
||||||
"ring": "#8caaee",
|
"ring": "#babbf1",
|
||||||
"text": "#c6d0f5",
|
"text": "#c6d0f5",
|
||||||
"text_muted": "#b5bfe2",
|
"text_muted": "#a5adce",
|
||||||
"text_placeholder": "#a5adce",
|
"text_placeholder": "#838ba7",
|
||||||
"text_accent": "#8caaee",
|
"text_accent": "#8caaee",
|
||||||
"icon": "#b5bfe2",
|
"text_danger": "#e78284",
|
||||||
"icon_muted": "#a5adce",
|
"text_warning": "#ef9f76",
|
||||||
"icon_accent": "#8caaee",
|
"icon": "#a5adce",
|
||||||
"element_foreground": "#232634",
|
"icon_muted": "#838ba7",
|
||||||
|
"icon_accent": "#babbf1",
|
||||||
|
"element_foreground": "#303446",
|
||||||
"element_background": "#8caaee",
|
"element_background": "#8caaee",
|
||||||
"element_hover": "#babbf1",
|
"element_hover": "#babbf1",
|
||||||
"element_active": "#7e99d6",
|
"element_active": "#99d1db",
|
||||||
"element_selected": "#7088bf",
|
"element_selected": "#85c1dc",
|
||||||
"element_disabled": "#8caaee4d",
|
"element_disabled": "#8caaee4d",
|
||||||
"secondary_foreground": "#7088bf",
|
"secondary_foreground": "#c6d0f5",
|
||||||
"secondary_background": "#292c3c",
|
"secondary_background": "#414559",
|
||||||
"secondary_hover": "#8caaee33",
|
"secondary_hover": "#51576d",
|
||||||
"secondary_active": "#232634",
|
"secondary_active": "#626880",
|
||||||
"secondary_selected": "#232634",
|
"secondary_selected": "#626880",
|
||||||
"secondary_disabled": "#8caaee4d",
|
"secondary_disabled": "#8caaee4d",
|
||||||
"danger_foreground": "#232634",
|
"danger_foreground": "#303446",
|
||||||
"danger_background": "#e78284",
|
"danger_background": "#e78284",
|
||||||
"danger_hover": "#ea999c",
|
"danger_hover": "#ea999c",
|
||||||
"danger_active": "#d07576",
|
"danger_active": "#ef9f76",
|
||||||
"danger_selected": "#b96869",
|
"danger_selected": "#e5c890",
|
||||||
"danger_disabled": "#e782844d",
|
"danger_disabled": "#e782844d",
|
||||||
"warning_foreground": "#232634",
|
"warning_foreground": "#303446",
|
||||||
"warning_background": "#e5c890",
|
"warning_background": "#ef9f76",
|
||||||
"warning_hover": "#ef9f76",
|
"warning_hover": "#e5c890",
|
||||||
"warning_active": "#ceb482",
|
"warning_active": "#a6d189",
|
||||||
"warning_selected": "#b7a074",
|
"warning_selected": "#81c8be",
|
||||||
"warning_disabled": "#e5c8904d",
|
"warning_disabled": "#ef9f764d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#c6d0f500",
|
||||||
"ghost_element_background_alt": "#414559",
|
"ghost_element_background_alt": "#292c3c",
|
||||||
"ghost_element_hover": "#c6d0f533",
|
"ghost_element_hover": "#c6d0f50d",
|
||||||
"ghost_element_active": "#51576d",
|
"ghost_element_active": "#c6d0f51a",
|
||||||
"ghost_element_selected": "#51576d",
|
"ghost_element_selected": "#c6d0f51a",
|
||||||
"ghost_element_disabled": "#c6d0f50d",
|
"ghost_element_disabled": "#c6d0f505",
|
||||||
"tab_inactive_background": "#292c3c",
|
"tab_background": "#232634",
|
||||||
"tab_inactive_foreground": "#b5bfe2",
|
"tab_foreground": "#a5adce",
|
||||||
|
"tab_hover_background": "#c6d0f50d",
|
||||||
"tab_active_background": "#303446",
|
"tab_active_background": "#303446",
|
||||||
"tab_active_foreground": "#c6d0f5",
|
"tab_active_foreground": "#c6d0f5",
|
||||||
"tab_hover_foreground": "#babbf1",
|
"scrollbar_thumb_background": "#c6d0f51a",
|
||||||
"scrollbar_thumb_background": "#c6d0f533",
|
"scrollbar_thumb_hover_background": "#c6d0f526",
|
||||||
"scrollbar_thumb_hover_background": "#c6d0f580",
|
"scrollbar_thumb_border": "#c6d0f500",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_track_background": "#c6d0f500",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_border": "#c6d0f500",
|
||||||
"scrollbar_track_border": "#51576d",
|
|
||||||
"drop_target_background": "#8caaee1a",
|
"drop_target_background": "#8caaee1a",
|
||||||
"cursor": "#f2d5cf",
|
"cursor": "#8caaee",
|
||||||
"selection": "#949cbb40"
|
"selection": "#8caaee40"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#303446",
|
"background": "#303446",
|
||||||
@@ -76,65 +78,67 @@
|
|||||||
"elevated_surface_background": "#232634",
|
"elevated_surface_background": "#232634",
|
||||||
"panel_background": "#303446",
|
"panel_background": "#303446",
|
||||||
"overlay": "#c6d0f51a",
|
"overlay": "#c6d0f51a",
|
||||||
"title_bar": "#292c3c",
|
"title_bar": "#232634",
|
||||||
"title_bar_inactive": "#232634",
|
"title_bar_inactive": "#303446",
|
||||||
"window_border": "#737994",
|
"window_border": "#51576d",
|
||||||
"border": "#626880",
|
"border": "#51576d",
|
||||||
"border_variant": "#51576d",
|
"border_variant": "#414559",
|
||||||
"border_focused": "#8caaee",
|
"border_focused": "#8caaee",
|
||||||
"border_selected": "#8caaee",
|
"border_selected": "#8caaee",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#c6d0f500",
|
||||||
"border_disabled": "#414559",
|
"border_disabled": "#292c3c",
|
||||||
"ring": "#8caaee",
|
"ring": "#babbf1",
|
||||||
"text": "#c6d0f5",
|
"text": "#c6d0f5",
|
||||||
"text_muted": "#b5bfe2",
|
"text_muted": "#a5adce",
|
||||||
"text_placeholder": "#a5adce",
|
"text_placeholder": "#838ba7",
|
||||||
"text_accent": "#8caaee",
|
"text_accent": "#8caaee",
|
||||||
"icon": "#b5bfe2",
|
"text_danger": "#e78284",
|
||||||
"icon_muted": "#a5adce",
|
"text_warning": "#ef9f76",
|
||||||
"icon_accent": "#8caaee",
|
"icon": "#a5adce",
|
||||||
"element_foreground": "#232634",
|
"icon_muted": "#838ba7",
|
||||||
|
"icon_accent": "#babbf1",
|
||||||
|
"element_foreground": "#303446",
|
||||||
"element_background": "#8caaee",
|
"element_background": "#8caaee",
|
||||||
"element_hover": "#babbf1",
|
"element_hover": "#babbf1",
|
||||||
"element_active": "#7e99d6",
|
"element_active": "#99d1db",
|
||||||
"element_selected": "#7088bf",
|
"element_selected": "#85c1dc",
|
||||||
"element_disabled": "#8caaee4d",
|
"element_disabled": "#8caaee4d",
|
||||||
"secondary_foreground": "#7088bf",
|
"secondary_foreground": "#c6d0f5",
|
||||||
"secondary_background": "#292c3c",
|
"secondary_background": "#414559",
|
||||||
"secondary_hover": "#8caaee33",
|
"secondary_hover": "#51576d",
|
||||||
"secondary_active": "#232634",
|
"secondary_active": "#626880",
|
||||||
"secondary_selected": "#232634",
|
"secondary_selected": "#626880",
|
||||||
"secondary_disabled": "#8caaee4d",
|
"secondary_disabled": "#8caaee4d",
|
||||||
"danger_foreground": "#232634",
|
"danger_foreground": "#303446",
|
||||||
"danger_background": "#e78284",
|
"danger_background": "#e78284",
|
||||||
"danger_hover": "#ea999c",
|
"danger_hover": "#ea999c",
|
||||||
"danger_active": "#d07576",
|
"danger_active": "#ef9f76",
|
||||||
"danger_selected": "#b96869",
|
"danger_selected": "#e5c890",
|
||||||
"danger_disabled": "#e782844d",
|
"danger_disabled": "#e782844d",
|
||||||
"warning_foreground": "#232634",
|
"warning_foreground": "#303446",
|
||||||
"warning_background": "#e5c890",
|
"warning_background": "#ef9f76",
|
||||||
"warning_hover": "#ef9f76",
|
"warning_hover": "#e5c890",
|
||||||
"warning_active": "#ceb482",
|
"warning_active": "#a6d189",
|
||||||
"warning_selected": "#b7a074",
|
"warning_selected": "#81c8be",
|
||||||
"warning_disabled": "#e5c8904d",
|
"warning_disabled": "#ef9f764d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#c6d0f500",
|
||||||
"ghost_element_background_alt": "#414559",
|
"ghost_element_background_alt": "#292c3c",
|
||||||
"ghost_element_hover": "#c6d0f533",
|
"ghost_element_hover": "#c6d0f50d",
|
||||||
"ghost_element_active": "#51576d",
|
"ghost_element_active": "#c6d0f51a",
|
||||||
"ghost_element_selected": "#51576d",
|
"ghost_element_selected": "#c6d0f51a",
|
||||||
"ghost_element_disabled": "#c6d0f50d",
|
"ghost_element_disabled": "#c6d0f505",
|
||||||
"tab_inactive_background": "#292c3c",
|
"tab_background": "#232634",
|
||||||
"tab_inactive_foreground": "#b5bfe2",
|
"tab_foreground": "#a5adce",
|
||||||
|
"tab_hover_background": "#c6d0f50d",
|
||||||
"tab_active_background": "#303446",
|
"tab_active_background": "#303446",
|
||||||
"tab_active_foreground": "#c6d0f5",
|
"tab_active_foreground": "#c6d0f5",
|
||||||
"tab_hover_foreground": "#babbf1",
|
"scrollbar_thumb_background": "#c6d0f51a",
|
||||||
"scrollbar_thumb_background": "#c6d0f533",
|
"scrollbar_thumb_hover_background": "#c6d0f526",
|
||||||
"scrollbar_thumb_hover_background": "#c6d0f580",
|
"scrollbar_thumb_border": "#c6d0f500",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_track_background": "#c6d0f500",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_border": "#c6d0f500",
|
||||||
"scrollbar_track_border": "#51576d",
|
|
||||||
"drop_target_background": "#8caaee1a",
|
"drop_target_background": "#8caaee1a",
|
||||||
"cursor": "#f2d5cf",
|
"cursor": "#8caaee",
|
||||||
"selection": "#949cbb40"
|
"selection": "#8caaee40"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,76 @@
|
|||||||
{
|
{
|
||||||
"id": "catppuccin-latte",
|
"id": "catppuccin-latte",
|
||||||
"name": "Catppuccin Latte",
|
"name": "Catppuccin Latte",
|
||||||
"author": "Catppuccin",
|
"author": "Catppuccin Org (ported by Coop)",
|
||||||
"url": "https://github.com/catppuccin/catppuccin",
|
"url": "https://catppuccin.com",
|
||||||
"light": {
|
"light": {
|
||||||
"background": "#eff1f5",
|
"background": "#eff1f5",
|
||||||
"surface_background": "#e6e9ef",
|
"surface_background": "#e6e9ef",
|
||||||
"elevated_surface_background": "#dce0e8",
|
"elevated_surface_background": "#dce0e8",
|
||||||
"panel_background": "#eff1f5",
|
"panel_background": "#eff1f5",
|
||||||
"overlay": "#4c4f691a",
|
"overlay": "#4c4f691a",
|
||||||
"title_bar": "#e6e9ef",
|
"title_bar": "#dce0e8",
|
||||||
"title_bar_inactive": "#dce0e8",
|
"title_bar_inactive": "#eff1f5",
|
||||||
"window_border": "#9ca0b0",
|
"window_border": "#bcc0cc",
|
||||||
"border": "#acb0be",
|
"border": "#bcc0cc",
|
||||||
"border_variant": "#bcc0cc",
|
"border_variant": "#ccd0da",
|
||||||
"border_focused": "#1e66f5",
|
"border_focused": "#1e66f5",
|
||||||
"border_selected": "#1e66f5",
|
"border_selected": "#1e66f5",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#4c4f6900",
|
||||||
"border_disabled": "#ccd0da",
|
"border_disabled": "#e6e9ef",
|
||||||
"ring": "#1e66f5",
|
"ring": "#7287fd",
|
||||||
"text": "#4c4f69",
|
"text": "#4c4f69",
|
||||||
"text_muted": "#5c5f77",
|
"text_muted": "#6c6f85",
|
||||||
"text_placeholder": "#6c6f85",
|
"text_placeholder": "#8c8fa1",
|
||||||
"text_accent": "#1e66f5",
|
"text_accent": "#1e66f5",
|
||||||
"icon": "#5c5f77",
|
"text_danger": "#d20f39",
|
||||||
"icon_muted": "#6c6f85",
|
"text_warning": "#fe640b",
|
||||||
"icon_accent": "#1e66f5",
|
"icon": "#6c6f85",
|
||||||
|
"icon_muted": "#8c8fa1",
|
||||||
|
"icon_accent": "#7287fd",
|
||||||
"element_foreground": "#eff1f5",
|
"element_foreground": "#eff1f5",
|
||||||
"element_background": "#1e66f5",
|
"element_background": "#1e66f5",
|
||||||
"element_hover": "#8839ef",
|
"element_hover": "#7287fd",
|
||||||
"element_active": "#1c5ce0",
|
"element_active": "#04a5e5",
|
||||||
"element_selected": "#1a52cc",
|
"element_selected": "#209fb5",
|
||||||
"element_disabled": "#1e66f54d",
|
"element_disabled": "#1e66f54d",
|
||||||
"secondary_foreground": "#1a52cc",
|
"secondary_foreground": "#4c4f69",
|
||||||
"secondary_background": "#e6e9ef",
|
"secondary_background": "#ccd0da",
|
||||||
"secondary_hover": "#8839ef33",
|
"secondary_hover": "#bcc0cc",
|
||||||
"secondary_active": "#dce0e8",
|
"secondary_active": "#acb0be",
|
||||||
"secondary_selected": "#dce0e8",
|
"secondary_selected": "#acb0be",
|
||||||
"secondary_disabled": "#1e66f54d",
|
"secondary_disabled": "#1e66f54d",
|
||||||
"danger_foreground": "#eff1f5",
|
"danger_foreground": "#eff1f5",
|
||||||
"danger_background": "#d20f39",
|
"danger_background": "#d20f39",
|
||||||
"danger_hover": "#e64553",
|
"danger_hover": "#e64553",
|
||||||
"danger_active": "#bd0d33",
|
"danger_active": "#fe640b",
|
||||||
"danger_selected": "#a80b2d",
|
"danger_selected": "#df8e1d",
|
||||||
"danger_disabled": "#d20f394d",
|
"danger_disabled": "#d20f394d",
|
||||||
"warning_foreground": "#4c4f69",
|
"warning_foreground": "#eff1f5",
|
||||||
"warning_background": "#df8e1d",
|
"warning_background": "#fe640b",
|
||||||
"warning_hover": "#fe640b",
|
"warning_hover": "#df8e1d",
|
||||||
"warning_active": "#c9801a",
|
"warning_active": "#40a02b",
|
||||||
"warning_selected": "#b47217",
|
"warning_selected": "#179299",
|
||||||
"warning_disabled": "#df8e1d4d",
|
"warning_disabled": "#fe640b4d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#4c4f6900",
|
||||||
"ghost_element_background_alt": "#ccd0da",
|
"ghost_element_background_alt": "#e6e9ef",
|
||||||
"ghost_element_hover": "#4c4f6933",
|
"ghost_element_hover": "#4c4f690d",
|
||||||
"ghost_element_active": "#bcc0cc",
|
"ghost_element_active": "#4c4f691a",
|
||||||
"ghost_element_selected": "#bcc0cc",
|
"ghost_element_selected": "#4c4f691a",
|
||||||
"ghost_element_disabled": "#4c4f690d",
|
"ghost_element_disabled": "#4c4f6905",
|
||||||
"tab_inactive_background": "#e6e9ef",
|
"tab_background": "#e6e9ef",
|
||||||
"tab_inactive_foreground": "#5c5f77",
|
"tab_foreground": "#6c6f85",
|
||||||
|
"tab_hover_background": "#4c4f690d",
|
||||||
"tab_active_background": "#eff1f5",
|
"tab_active_background": "#eff1f5",
|
||||||
"tab_active_foreground": "#4c4f69",
|
"tab_active_foreground": "#4c4f69",
|
||||||
"tab_hover_foreground": "#8839ef",
|
"scrollbar_thumb_background": "#4c4f691a",
|
||||||
"scrollbar_thumb_background": "#4c4f6933",
|
"scrollbar_thumb_hover_background": "#4c4f6926",
|
||||||
"scrollbar_thumb_hover_background": "#4c4f6980",
|
"scrollbar_thumb_border": "#4c4f6900",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_track_background": "#4c4f6900",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_border": "#4c4f6900",
|
||||||
"scrollbar_track_border": "#bcc0cc",
|
|
||||||
"drop_target_background": "#1e66f51a",
|
"drop_target_background": "#1e66f51a",
|
||||||
"cursor": "#dc8a78",
|
"cursor": "#1e66f5",
|
||||||
"selection": "#7c7f9340"
|
"selection": "#1e66f540"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#eff1f5",
|
"background": "#eff1f5",
|
||||||
@@ -76,65 +78,67 @@
|
|||||||
"elevated_surface_background": "#dce0e8",
|
"elevated_surface_background": "#dce0e8",
|
||||||
"panel_background": "#eff1f5",
|
"panel_background": "#eff1f5",
|
||||||
"overlay": "#4c4f691a",
|
"overlay": "#4c4f691a",
|
||||||
"title_bar": "#e6e9ef",
|
"title_bar": "#dce0e8",
|
||||||
"title_bar_inactive": "#dce0e8",
|
"title_bar_inactive": "#eff1f5",
|
||||||
"window_border": "#9ca0b0",
|
"window_border": "#bcc0cc",
|
||||||
"border": "#acb0be",
|
"border": "#bcc0cc",
|
||||||
"border_variant": "#bcc0cc",
|
"border_variant": "#ccd0da",
|
||||||
"border_focused": "#1e66f5",
|
"border_focused": "#1e66f5",
|
||||||
"border_selected": "#1e66f5",
|
"border_selected": "#1e66f5",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#4c4f6900",
|
||||||
"border_disabled": "#ccd0da",
|
"border_disabled": "#e6e9ef",
|
||||||
"ring": "#1e66f5",
|
"ring": "#7287fd",
|
||||||
"text": "#4c4f69",
|
"text": "#4c4f69",
|
||||||
"text_muted": "#5c5f77",
|
"text_muted": "#6c6f85",
|
||||||
"text_placeholder": "#6c6f85",
|
"text_placeholder": "#8c8fa1",
|
||||||
"text_accent": "#1e66f5",
|
"text_accent": "#1e66f5",
|
||||||
"icon": "#5c5f77",
|
"text_danger": "#d20f39",
|
||||||
"icon_muted": "#6c6f85",
|
"text_warning": "#fe640b",
|
||||||
"icon_accent": "#1e66f5",
|
"icon": "#6c6f85",
|
||||||
|
"icon_muted": "#8c8fa1",
|
||||||
|
"icon_accent": "#7287fd",
|
||||||
"element_foreground": "#eff1f5",
|
"element_foreground": "#eff1f5",
|
||||||
"element_background": "#1e66f5",
|
"element_background": "#1e66f5",
|
||||||
"element_hover": "#8839ef",
|
"element_hover": "#7287fd",
|
||||||
"element_active": "#1c5ce0",
|
"element_active": "#04a5e5",
|
||||||
"element_selected": "#1a52cc",
|
"element_selected": "#209fb5",
|
||||||
"element_disabled": "#1e66f54d",
|
"element_disabled": "#1e66f54d",
|
||||||
"secondary_foreground": "#1a52cc",
|
"secondary_foreground": "#4c4f69",
|
||||||
"secondary_background": "#e6e9ef",
|
"secondary_background": "#ccd0da",
|
||||||
"secondary_hover": "#8839ef33",
|
"secondary_hover": "#bcc0cc",
|
||||||
"secondary_active": "#dce0e8",
|
"secondary_active": "#acb0be",
|
||||||
"secondary_selected": "#dce0e8",
|
"secondary_selected": "#acb0be",
|
||||||
"secondary_disabled": "#1e66f54d",
|
"secondary_disabled": "#1e66f54d",
|
||||||
"danger_foreground": "#eff1f5",
|
"danger_foreground": "#eff1f5",
|
||||||
"danger_background": "#d20f39",
|
"danger_background": "#d20f39",
|
||||||
"danger_hover": "#e64553",
|
"danger_hover": "#e64553",
|
||||||
"danger_active": "#bd0d33",
|
"danger_active": "#fe640b",
|
||||||
"danger_selected": "#a80b2d",
|
"danger_selected": "#df8e1d",
|
||||||
"danger_disabled": "#d20f394d",
|
"danger_disabled": "#d20f394d",
|
||||||
"warning_foreground": "#4c4f69",
|
"warning_foreground": "#eff1f5",
|
||||||
"warning_background": "#df8e1d",
|
"warning_background": "#fe640b",
|
||||||
"warning_hover": "#fe640b",
|
"warning_hover": "#df8e1d",
|
||||||
"warning_active": "#c9801a",
|
"warning_active": "#40a02b",
|
||||||
"warning_selected": "#b47217",
|
"warning_selected": "#179299",
|
||||||
"warning_disabled": "#df8e1d4d",
|
"warning_disabled": "#fe640b4d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#4c4f6900",
|
||||||
"ghost_element_background_alt": "#ccd0da",
|
"ghost_element_background_alt": "#e6e9ef",
|
||||||
"ghost_element_hover": "#4c4f6933",
|
"ghost_element_hover": "#4c4f690d",
|
||||||
"ghost_element_active": "#bcc0cc",
|
"ghost_element_active": "#4c4f691a",
|
||||||
"ghost_element_selected": "#bcc0cc",
|
"ghost_element_selected": "#4c4f691a",
|
||||||
"ghost_element_disabled": "#4c4f690d",
|
"ghost_element_disabled": "#4c4f6905",
|
||||||
"tab_inactive_background": "#e6e9ef",
|
"tab_background": "#e6e9ef",
|
||||||
"tab_inactive_foreground": "#5c5f77",
|
"tab_foreground": "#6c6f85",
|
||||||
|
"tab_hover_background": "#4c4f690d",
|
||||||
"tab_active_background": "#eff1f5",
|
"tab_active_background": "#eff1f5",
|
||||||
"tab_active_foreground": "#4c4f69",
|
"tab_active_foreground": "#4c4f69",
|
||||||
"tab_hover_foreground": "#8839ef",
|
"scrollbar_thumb_background": "#4c4f691a",
|
||||||
"scrollbar_thumb_background": "#4c4f6933",
|
"scrollbar_thumb_hover_background": "#4c4f6926",
|
||||||
"scrollbar_thumb_hover_background": "#4c4f6980",
|
"scrollbar_thumb_border": "#4c4f6900",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_track_background": "#4c4f6900",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_border": "#4c4f6900",
|
||||||
"scrollbar_track_border": "#bcc0cc",
|
|
||||||
"drop_target_background": "#1e66f51a",
|
"drop_target_background": "#1e66f51a",
|
||||||
"cursor": "#dc8a78",
|
"cursor": "#1e66f5",
|
||||||
"selection": "#7c7f9340"
|
"selection": "#1e66f540"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,76 @@
|
|||||||
{
|
{
|
||||||
"id": "catppuccin-macchiato",
|
"id": "catppuccin-macchiato",
|
||||||
"name": "Catppuccin Macchiato",
|
"name": "Catppuccin Macchiato",
|
||||||
"author": "Catppuccin",
|
"author": "Catppuccin Org (ported by Coop)",
|
||||||
"url": "https://github.com/catppuccin/catppuccin",
|
"url": "https://catppuccin.com",
|
||||||
"light": {
|
"light": {
|
||||||
"background": "#24273a",
|
"background": "#24273a",
|
||||||
"surface_background": "#1e2030",
|
"surface_background": "#1e2030",
|
||||||
"elevated_surface_background": "#181926",
|
"elevated_surface_background": "#181926",
|
||||||
"panel_background": "#24273a",
|
"panel_background": "#24273a",
|
||||||
"overlay": "#cad3f51a",
|
"overlay": "#cad3f51a",
|
||||||
"title_bar": "#1e2030",
|
"title_bar": "#181926",
|
||||||
"title_bar_inactive": "#181926",
|
"title_bar_inactive": "#24273a",
|
||||||
"window_border": "#6e738d",
|
"window_border": "#494d64",
|
||||||
"border": "#5b6078",
|
"border": "#494d64",
|
||||||
"border_variant": "#494d64",
|
"border_variant": "#363a4f",
|
||||||
"border_focused": "#8aadf4",
|
"border_focused": "#8aadf4",
|
||||||
"border_selected": "#8aadf4",
|
"border_selected": "#8aadf4",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#cad3f500",
|
||||||
"border_disabled": "#363a4f",
|
"border_disabled": "#1e2030",
|
||||||
"ring": "#8aadf4",
|
"ring": "#b7bdf8",
|
||||||
"text": "#cad3f5",
|
"text": "#cad3f5",
|
||||||
"text_muted": "#b8c0e0",
|
"text_muted": "#a5adcb",
|
||||||
"text_placeholder": "#a5adcb",
|
"text_placeholder": "#8087a2",
|
||||||
"text_accent": "#8aadf4",
|
"text_accent": "#8aadf4",
|
||||||
"icon": "#b8c0e0",
|
"text_danger": "#ed8796",
|
||||||
"icon_muted": "#a5adcb",
|
"text_warning": "#f5a97f",
|
||||||
"icon_accent": "#8aadf4",
|
"icon": "#a5adcb",
|
||||||
"element_foreground": "#181926",
|
"icon_muted": "#8087a2",
|
||||||
|
"icon_accent": "#b7bdf8",
|
||||||
|
"element_foreground": "#24273a",
|
||||||
"element_background": "#8aadf4",
|
"element_background": "#8aadf4",
|
||||||
"element_hover": "#b7bdf8",
|
"element_hover": "#b7bdf8",
|
||||||
"element_active": "#7c9cdc",
|
"element_active": "#91d7e3",
|
||||||
"element_selected": "#6e8bc5",
|
"element_selected": "#7dc4e4",
|
||||||
"element_disabled": "#8aadf44d",
|
"element_disabled": "#8aadf44d",
|
||||||
"secondary_foreground": "#6e8bc5",
|
"secondary_foreground": "#cad3f5",
|
||||||
"secondary_background": "#1e2030",
|
"secondary_background": "#363a4f",
|
||||||
"secondary_hover": "#8aadf433",
|
"secondary_hover": "#494d64",
|
||||||
"secondary_active": "#181926",
|
"secondary_active": "#5b6078",
|
||||||
"secondary_selected": "#181926",
|
"secondary_selected": "#5b6078",
|
||||||
"secondary_disabled": "#8aadf44d",
|
"secondary_disabled": "#8aadf44d",
|
||||||
"danger_foreground": "#181926",
|
"danger_foreground": "#24273a",
|
||||||
"danger_background": "#ed8796",
|
"danger_background": "#ed8796",
|
||||||
"danger_hover": "#ee99a0",
|
"danger_hover": "#ee99a0",
|
||||||
"danger_active": "#d57a87",
|
"danger_active": "#f5a97f",
|
||||||
"danger_selected": "#be6d78",
|
"danger_selected": "#eed49f",
|
||||||
"danger_disabled": "#ed87964d",
|
"danger_disabled": "#ed87964d",
|
||||||
"warning_foreground": "#181926",
|
"warning_foreground": "#24273a",
|
||||||
"warning_background": "#eed49f",
|
"warning_background": "#f5a97f",
|
||||||
"warning_hover": "#f5a97f",
|
"warning_hover": "#eed49f",
|
||||||
"warning_active": "#d6bf8f",
|
"warning_active": "#a6da95",
|
||||||
"warning_selected": "#beaa7f",
|
"warning_selected": "#8bd5ca",
|
||||||
"warning_disabled": "#eed49f4d",
|
"warning_disabled": "#f5a97f4d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#cad3f500",
|
||||||
"ghost_element_background_alt": "#363a4f",
|
"ghost_element_background_alt": "#1e2030",
|
||||||
"ghost_element_hover": "#cad3f533",
|
"ghost_element_hover": "#cad3f50d",
|
||||||
"ghost_element_active": "#494d64",
|
"ghost_element_active": "#cad3f51a",
|
||||||
"ghost_element_selected": "#494d64",
|
"ghost_element_selected": "#cad3f51a",
|
||||||
"ghost_element_disabled": "#cad3f50d",
|
"ghost_element_disabled": "#cad3f505",
|
||||||
"tab_inactive_background": "#1e2030",
|
"tab_background": "#181926",
|
||||||
"tab_inactive_foreground": "#b8c0e0",
|
"tab_foreground": "#a5adcb",
|
||||||
|
"tab_hover_background": "#cad3f50d",
|
||||||
"tab_active_background": "#24273a",
|
"tab_active_background": "#24273a",
|
||||||
"tab_active_foreground": "#cad3f5",
|
"tab_active_foreground": "#cad3f5",
|
||||||
"tab_hover_foreground": "#b7bdf8",
|
"scrollbar_thumb_background": "#cad3f51a",
|
||||||
"scrollbar_thumb_background": "#cad3f533",
|
"scrollbar_thumb_hover_background": "#cad3f526",
|
||||||
"scrollbar_thumb_hover_background": "#cad3f580",
|
"scrollbar_thumb_border": "#cad3f500",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_track_background": "#cad3f500",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_border": "#cad3f500",
|
||||||
"scrollbar_track_border": "#494d64",
|
|
||||||
"drop_target_background": "#8aadf41a",
|
"drop_target_background": "#8aadf41a",
|
||||||
"cursor": "#f4dbd6",
|
"cursor": "#8aadf4",
|
||||||
"selection": "#939ab740"
|
"selection": "#8aadf440"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#24273a",
|
"background": "#24273a",
|
||||||
@@ -76,65 +78,67 @@
|
|||||||
"elevated_surface_background": "#181926",
|
"elevated_surface_background": "#181926",
|
||||||
"panel_background": "#24273a",
|
"panel_background": "#24273a",
|
||||||
"overlay": "#cad3f51a",
|
"overlay": "#cad3f51a",
|
||||||
"title_bar": "#1e2030",
|
"title_bar": "#181926",
|
||||||
"title_bar_inactive": "#181926",
|
"title_bar_inactive": "#24273a",
|
||||||
"window_border": "#6e738d",
|
"window_border": "#494d64",
|
||||||
"border": "#5b6078",
|
"border": "#494d64",
|
||||||
"border_variant": "#494d64",
|
"border_variant": "#363a4f",
|
||||||
"border_focused": "#8aadf4",
|
"border_focused": "#8aadf4",
|
||||||
"border_selected": "#8aadf4",
|
"border_selected": "#8aadf4",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#cad3f500",
|
||||||
"border_disabled": "#363a4f",
|
"border_disabled": "#1e2030",
|
||||||
"ring": "#8aadf4",
|
"ring": "#b7bdf8",
|
||||||
"text": "#cad3f5",
|
"text": "#cad3f5",
|
||||||
"text_muted": "#b8c0e0",
|
"text_muted": "#a5adcb",
|
||||||
"text_placeholder": "#a5adcb",
|
"text_placeholder": "#8087a2",
|
||||||
"text_accent": "#8aadf4",
|
"text_accent": "#8aadf4",
|
||||||
"icon": "#b8c0e0",
|
"text_danger": "#ed8796",
|
||||||
"icon_muted": "#a5adcb",
|
"text_warning": "#f5a97f",
|
||||||
"icon_accent": "#8aadf4",
|
"icon": "#a5adcb",
|
||||||
"element_foreground": "#181926",
|
"icon_muted": "#8087a2",
|
||||||
|
"icon_accent": "#b7bdf8",
|
||||||
|
"element_foreground": "#24273a",
|
||||||
"element_background": "#8aadf4",
|
"element_background": "#8aadf4",
|
||||||
"element_hover": "#b7bdf8",
|
"element_hover": "#b7bdf8",
|
||||||
"element_active": "#7c9cdc",
|
"element_active": "#91d7e3",
|
||||||
"element_selected": "#6e8bc5",
|
"element_selected": "#7dc4e4",
|
||||||
"element_disabled": "#8aadf44d",
|
"element_disabled": "#8aadf44d",
|
||||||
"secondary_foreground": "#6e8bc5",
|
"secondary_foreground": "#cad3f5",
|
||||||
"secondary_background": "#1e2030",
|
"secondary_background": "#363a4f",
|
||||||
"secondary_hover": "#8aadf433",
|
"secondary_hover": "#494d64",
|
||||||
"secondary_active": "#181926",
|
"secondary_active": "#5b6078",
|
||||||
"secondary_selected": "#181926",
|
"secondary_selected": "#5b6078",
|
||||||
"secondary_disabled": "#8aadf44d",
|
"secondary_disabled": "#8aadf44d",
|
||||||
"danger_foreground": "#181926",
|
"danger_foreground": "#24273a",
|
||||||
"danger_background": "#ed8796",
|
"danger_background": "#ed8796",
|
||||||
"danger_hover": "#ee99a0",
|
"danger_hover": "#ee99a0",
|
||||||
"danger_active": "#d57a87",
|
"danger_active": "#f5a97f",
|
||||||
"danger_selected": "#be6d78",
|
"danger_selected": "#eed49f",
|
||||||
"danger_disabled": "#ed87964d",
|
"danger_disabled": "#ed87964d",
|
||||||
"warning_foreground": "#181926",
|
"warning_foreground": "#24273a",
|
||||||
"warning_background": "#eed49f",
|
"warning_background": "#f5a97f",
|
||||||
"warning_hover": "#f5a97f",
|
"warning_hover": "#eed49f",
|
||||||
"warning_active": "#d6bf8f",
|
"warning_active": "#a6da95",
|
||||||
"warning_selected": "#beaa7f",
|
"warning_selected": "#8bd5ca",
|
||||||
"warning_disabled": "#eed49f4d",
|
"warning_disabled": "#f5a97f4d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#cad3f500",
|
||||||
"ghost_element_background_alt": "#363a4f",
|
"ghost_element_background_alt": "#1e2030",
|
||||||
"ghost_element_hover": "#cad3f533",
|
"ghost_element_hover": "#cad3f50d",
|
||||||
"ghost_element_active": "#494d64",
|
"ghost_element_active": "#cad3f51a",
|
||||||
"ghost_element_selected": "#494d64",
|
"ghost_element_selected": "#cad3f51a",
|
||||||
"ghost_element_disabled": "#cad3f50d",
|
"ghost_element_disabled": "#cad3f505",
|
||||||
"tab_inactive_background": "#1e2030",
|
"tab_background": "#181926",
|
||||||
"tab_inactive_foreground": "#b8c0e0",
|
"tab_foreground": "#a5adcb",
|
||||||
|
"tab_hover_background": "#cad3f50d",
|
||||||
"tab_active_background": "#24273a",
|
"tab_active_background": "#24273a",
|
||||||
"tab_active_foreground": "#cad3f5",
|
"tab_active_foreground": "#cad3f5",
|
||||||
"tab_hover_foreground": "#b7bdf8",
|
"scrollbar_thumb_background": "#cad3f51a",
|
||||||
"scrollbar_thumb_background": "#cad3f533",
|
"scrollbar_thumb_hover_background": "#cad3f526",
|
||||||
"scrollbar_thumb_hover_background": "#cad3f580",
|
"scrollbar_thumb_border": "#cad3f500",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_track_background": "#cad3f500",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_border": "#cad3f500",
|
||||||
"scrollbar_track_border": "#494d64",
|
|
||||||
"drop_target_background": "#8aadf41a",
|
"drop_target_background": "#8aadf41a",
|
||||||
"cursor": "#f4dbd6",
|
"cursor": "#8aadf4",
|
||||||
"selection": "#939ab740"
|
"selection": "#8aadf440"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,76 @@
|
|||||||
{
|
{
|
||||||
"id": "catppuccin-mocha",
|
"id": "catppuccin-mocha",
|
||||||
"name": "Catppuccin Mocha",
|
"name": "Catppuccin Mocha",
|
||||||
"author": "Catppuccin",
|
"author": "Catppuccin Org (ported by Coop)",
|
||||||
"url": "https://github.com/catppuccin/catppuccin",
|
"url": "https://catppuccin.com",
|
||||||
"light": {
|
"light": {
|
||||||
"background": "#1e1e2e",
|
"background": "#1e1e2e",
|
||||||
"surface_background": "#181825",
|
"surface_background": "#181825",
|
||||||
"elevated_surface_background": "#11111b",
|
"elevated_surface_background": "#11111b",
|
||||||
"panel_background": "#1e1e2e",
|
"panel_background": "#1e1e2e",
|
||||||
"overlay": "#cdd6f41a",
|
"overlay": "#cdd6f41a",
|
||||||
"title_bar": "#181825",
|
"title_bar": "#11111b",
|
||||||
"title_bar_inactive": "#11111b",
|
"title_bar_inactive": "#1e1e2e",
|
||||||
"window_border": "#6c7086",
|
"window_border": "#45475a",
|
||||||
"border": "#585b70",
|
"border": "#45475a",
|
||||||
"border_variant": "#45475a",
|
"border_variant": "#313244",
|
||||||
"border_focused": "#89b4fa",
|
"border_focused": "#89b4fa",
|
||||||
"border_selected": "#89b4fa",
|
"border_selected": "#89b4fa",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#cdd6f400",
|
||||||
"border_disabled": "#313244",
|
"border_disabled": "#181825",
|
||||||
"ring": "#89b4fa",
|
"ring": "#b4befe",
|
||||||
"text": "#cdd6f4",
|
"text": "#cdd6f4",
|
||||||
"text_muted": "#bac2de",
|
"text_muted": "#a6adc8",
|
||||||
"text_placeholder": "#a6adc8",
|
"text_placeholder": "#7f849c",
|
||||||
"text_accent": "#89b4fa",
|
"text_accent": "#89b4fa",
|
||||||
"icon": "#bac2de",
|
"text_danger": "#f38ba8",
|
||||||
"icon_muted": "#a6adc8",
|
"text_warning": "#fab387",
|
||||||
"icon_accent": "#89b4fa",
|
"icon": "#a6adc8",
|
||||||
"element_foreground": "#11111b",
|
"icon_muted": "#7f849c",
|
||||||
|
"icon_accent": "#b4befe",
|
||||||
|
"element_foreground": "#1e1e2e",
|
||||||
"element_background": "#89b4fa",
|
"element_background": "#89b4fa",
|
||||||
"element_hover": "#b4befe",
|
"element_hover": "#b4befe",
|
||||||
"element_active": "#7ba2e1",
|
"element_active": "#89dceb",
|
||||||
"element_selected": "#6d90c9",
|
"element_selected": "#74c7ec",
|
||||||
"element_disabled": "#89b4fa4d",
|
"element_disabled": "#89b4fa4d",
|
||||||
"secondary_foreground": "#6d90c9",
|
"secondary_foreground": "#cdd6f4",
|
||||||
"secondary_background": "#181825",
|
"secondary_background": "#313244",
|
||||||
"secondary_hover": "#89b4fa33",
|
"secondary_hover": "#45475a",
|
||||||
"secondary_active": "#11111b",
|
"secondary_active": "#585b70",
|
||||||
"secondary_selected": "#11111b",
|
"secondary_selected": "#585b70",
|
||||||
"secondary_disabled": "#89b4fa4d",
|
"secondary_disabled": "#89b4fa4d",
|
||||||
"danger_foreground": "#11111b",
|
"danger_foreground": "#1e1e2e",
|
||||||
"danger_background": "#f38ba8",
|
"danger_background": "#f38ba8",
|
||||||
"danger_hover": "#eba0ac",
|
"danger_hover": "#eba0ac",
|
||||||
"danger_active": "#db7d98",
|
"danger_active": "#fab387",
|
||||||
"danger_selected": "#c46f88",
|
"danger_selected": "#f9e2af",
|
||||||
"danger_disabled": "#f38ba84d",
|
"danger_disabled": "#f38ba84d",
|
||||||
"warning_foreground": "#11111b",
|
"warning_foreground": "#1e1e2e",
|
||||||
"warning_background": "#f9e2af",
|
"warning_background": "#fab387",
|
||||||
"warning_hover": "#fab387",
|
"warning_hover": "#f9e2af",
|
||||||
"warning_active": "#e0cb9e",
|
"warning_active": "#a6e3a1",
|
||||||
"warning_selected": "#c8b48d",
|
"warning_selected": "#94e2d5",
|
||||||
"warning_disabled": "#f9e2af4d",
|
"warning_disabled": "#fab3874d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#cdd6f400",
|
||||||
"ghost_element_background_alt": "#313244",
|
"ghost_element_background_alt": "#181825",
|
||||||
"ghost_element_hover": "#cdd6f433",
|
"ghost_element_hover": "#cdd6f40d",
|
||||||
"ghost_element_active": "#45475a",
|
"ghost_element_active": "#cdd6f41a",
|
||||||
"ghost_element_selected": "#45475a",
|
"ghost_element_selected": "#cdd6f41a",
|
||||||
"ghost_element_disabled": "#cdd6f40d",
|
"ghost_element_disabled": "#cdd6f405",
|
||||||
"tab_inactive_background": "#181825",
|
"tab_background": "#11111b",
|
||||||
"tab_inactive_foreground": "#bac2de",
|
"tab_foreground": "#a6adc8",
|
||||||
|
"tab_hover_background": "#cdd6f40d",
|
||||||
"tab_active_background": "#1e1e2e",
|
"tab_active_background": "#1e1e2e",
|
||||||
"tab_active_foreground": "#cdd6f4",
|
"tab_active_foreground": "#cdd6f4",
|
||||||
"tab_hover_foreground": "#b4befe",
|
"scrollbar_thumb_background": "#cdd6f41a",
|
||||||
"scrollbar_thumb_background": "#cdd6f433",
|
"scrollbar_thumb_hover_background": "#cdd6f426",
|
||||||
"scrollbar_thumb_hover_background": "#cdd6f580",
|
"scrollbar_thumb_border": "#cdd6f400",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_track_background": "#cdd6f400",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_border": "#cdd6f400",
|
||||||
"scrollbar_track_border": "#45475a",
|
|
||||||
"drop_target_background": "#89b4fa1a",
|
"drop_target_background": "#89b4fa1a",
|
||||||
"cursor": "#f5e0dc",
|
"cursor": "#89b4fa",
|
||||||
"selection": "#9399b240"
|
"selection": "#89b4fa40"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#1e1e2e",
|
"background": "#1e1e2e",
|
||||||
@@ -76,65 +78,67 @@
|
|||||||
"elevated_surface_background": "#11111b",
|
"elevated_surface_background": "#11111b",
|
||||||
"panel_background": "#1e1e2e",
|
"panel_background": "#1e1e2e",
|
||||||
"overlay": "#cdd6f41a",
|
"overlay": "#cdd6f41a",
|
||||||
"title_bar": "#181825",
|
"title_bar": "#11111b",
|
||||||
"title_bar_inactive": "#11111b",
|
"title_bar_inactive": "#1e1e2e",
|
||||||
"window_border": "#6c7086",
|
"window_border": "#45475a",
|
||||||
"border": "#585b70",
|
"border": "#45475a",
|
||||||
"border_variant": "#45475a",
|
"border_variant": "#313244",
|
||||||
"border_focused": "#89b4fa",
|
"border_focused": "#89b4fa",
|
||||||
"border_selected": "#89b4fa",
|
"border_selected": "#89b4fa",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#cdd6f400",
|
||||||
"border_disabled": "#313244",
|
"border_disabled": "#181825",
|
||||||
"ring": "#89b4fa",
|
"ring": "#b4befe",
|
||||||
"text": "#cdd6f4",
|
"text": "#cdd6f4",
|
||||||
"text_muted": "#bac2de",
|
"text_muted": "#a6adc8",
|
||||||
"text_placeholder": "#a6adc8",
|
"text_placeholder": "#7f849c",
|
||||||
"text_accent": "#89b4fa",
|
"text_accent": "#89b4fa",
|
||||||
"icon": "#bac2de",
|
"text_danger": "#f38ba8",
|
||||||
"icon_muted": "#a6adc8",
|
"text_warning": "#fab387",
|
||||||
"icon_accent": "#89b4fa",
|
"icon": "#a6adc8",
|
||||||
"element_foreground": "#11111b",
|
"icon_muted": "#7f849c",
|
||||||
|
"icon_accent": "#b4befe",
|
||||||
|
"element_foreground": "#1e1e2e",
|
||||||
"element_background": "#89b4fa",
|
"element_background": "#89b4fa",
|
||||||
"element_hover": "#b4befe",
|
"element_hover": "#b4befe",
|
||||||
"element_active": "#7ba2e1",
|
"element_active": "#89dceb",
|
||||||
"element_selected": "#6d90c9",
|
"element_selected": "#74c7ec",
|
||||||
"element_disabled": "#89b4fa4d",
|
"element_disabled": "#89b4fa4d",
|
||||||
"secondary_foreground": "#6d90c9",
|
"secondary_foreground": "#cdd6f4",
|
||||||
"secondary_background": "#181825",
|
"secondary_background": "#313244",
|
||||||
"secondary_hover": "#89b4fa33",
|
"secondary_hover": "#45475a",
|
||||||
"secondary_active": "#11111b",
|
"secondary_active": "#585b70",
|
||||||
"secondary_selected": "#11111b",
|
"secondary_selected": "#585b70",
|
||||||
"secondary_disabled": "#89b4fa4d",
|
"secondary_disabled": "#89b4fa4d",
|
||||||
"danger_foreground": "#11111b",
|
"danger_foreground": "#1e1e2e",
|
||||||
"danger_background": "#f38ba8",
|
"danger_background": "#f38ba8",
|
||||||
"danger_hover": "#eba0ac",
|
"danger_hover": "#eba0ac",
|
||||||
"danger_active": "#db7d98",
|
"danger_active": "#fab387",
|
||||||
"danger_selected": "#c46f88",
|
"danger_selected": "#f9e2af",
|
||||||
"danger_disabled": "#f38ba84d",
|
"danger_disabled": "#f38ba84d",
|
||||||
"warning_foreground": "#11111b",
|
"warning_foreground": "#1e1e2e",
|
||||||
"warning_background": "#f9e2af",
|
"warning_background": "#fab387",
|
||||||
"warning_hover": "#fab387",
|
"warning_hover": "#f9e2af",
|
||||||
"warning_active": "#e0cb9e",
|
"warning_active": "#a6e3a1",
|
||||||
"warning_selected": "#c8b48d",
|
"warning_selected": "#94e2d5",
|
||||||
"warning_disabled": "#f9e2af4d",
|
"warning_disabled": "#fab3874d",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#cdd6f400",
|
||||||
"ghost_element_background_alt": "#313244",
|
"ghost_element_background_alt": "#181825",
|
||||||
"ghost_element_hover": "#cdd6f433",
|
"ghost_element_hover": "#cdd6f40d",
|
||||||
"ghost_element_active": "#45475a",
|
"ghost_element_active": "#cdd6f41a",
|
||||||
"ghost_element_selected": "#45475a",
|
"ghost_element_selected": "#cdd6f41a",
|
||||||
"ghost_element_disabled": "#cdd6f40d",
|
"ghost_element_disabled": "#cdd6f405",
|
||||||
"tab_inactive_background": "#181825",
|
"tab_background": "#11111b",
|
||||||
"tab_inactive_foreground": "#bac2de",
|
"tab_foreground": "#a6adc8",
|
||||||
|
"tab_hover_background": "#cdd6f40d",
|
||||||
"tab_active_background": "#1e1e2e",
|
"tab_active_background": "#1e1e2e",
|
||||||
"tab_active_foreground": "#cdd6f4",
|
"tab_active_foreground": "#cdd6f4",
|
||||||
"tab_hover_foreground": "#b4befe",
|
"scrollbar_thumb_background": "#cdd6f41a",
|
||||||
"scrollbar_thumb_background": "#cdd6f433",
|
"scrollbar_thumb_hover_background": "#cdd6f426",
|
||||||
"scrollbar_thumb_hover_background": "#cdd6f580",
|
"scrollbar_thumb_border": "#cdd6f400",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_track_background": "#cdd6f400",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_border": "#cdd6f400",
|
||||||
"scrollbar_track_border": "#45475a",
|
|
||||||
"drop_target_background": "#89b4fa1a",
|
"drop_target_background": "#89b4fa1a",
|
||||||
"cursor": "#f5e0dc",
|
"cursor": "#89b4fa",
|
||||||
"selection": "#9399b240"
|
"selection": "#89b4fa40"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-103
@@ -1,140 +1,144 @@
|
|||||||
{
|
{
|
||||||
"id": "flexoki",
|
"id": "flexoki",
|
||||||
"name": "Flexoki",
|
"name": "Flexoki",
|
||||||
"author": "Stephan Ango",
|
"author": "Steph Ango (ported by Coop)",
|
||||||
"url": "https://stephango.com/flexoki",
|
"url": "https://stephango.com/flexoki",
|
||||||
"light": {
|
"light": {
|
||||||
"background": "#FFFCF0",
|
"background": "#FFFCF0",
|
||||||
"surface_background": "#F2F0E5",
|
"surface_background": "#F2F0E5",
|
||||||
"elevated_surface_background": "#E6E4D9",
|
"elevated_surface_background": "#E6E4D9",
|
||||||
"panel_background": "#FFFCF0",
|
"panel_background": "#FFFCF0",
|
||||||
"overlay": "#100F0F1a",
|
"overlay": "#100F0F1A",
|
||||||
"title_bar": "#F2F0E5",
|
"title_bar": "#E6E4D9",
|
||||||
"title_bar_inactive": "#E6E4D9",
|
"title_bar_inactive": "#FFFCF0",
|
||||||
"window_border": "#B7B5AC",
|
"window_border": "#CECDC3",
|
||||||
"border": "#CECDC3",
|
"border": "#CECDC3",
|
||||||
"border_variant": "#DAD8CE",
|
"border_variant": "#DAD8CE",
|
||||||
"border_focused": "#205EA6",
|
"border_focused": "#24837B",
|
||||||
"border_selected": "#205EA6",
|
"border_selected": "#24837B",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#100F0F00",
|
||||||
"border_disabled": "#E6E4D9",
|
"border_disabled": "#E6E4D9",
|
||||||
"ring": "#205EA6",
|
"ring": "#3AA99F",
|
||||||
"text": "#100F0F",
|
"text": "#100F0F",
|
||||||
"text_muted": "#6F6E69",
|
"text_muted": "#6F6E69",
|
||||||
"text_placeholder": "#9F9D96",
|
"text_placeholder": "#B7B5AC",
|
||||||
"text_accent": "#205EA6",
|
"text_accent": "#24837B",
|
||||||
|
"text_danger": "#AF3029",
|
||||||
|
"text_warning": "#BC5215",
|
||||||
"icon": "#6F6E69",
|
"icon": "#6F6E69",
|
||||||
"icon_muted": "#9F9D96",
|
"icon_muted": "#B7B5AC",
|
||||||
"icon_accent": "#205EA6",
|
"icon_accent": "#3AA99F",
|
||||||
"element_foreground": "#FFFCF0",
|
"element_foreground": "#FFFCF0",
|
||||||
"element_background": "#205EA6",
|
"element_background": "#24837B",
|
||||||
"element_hover": "#1A4F8C",
|
"element_hover": "#3AA99F",
|
||||||
"element_active": "#163B66",
|
"element_active": "#1C1B1A",
|
||||||
"element_selected": "#133051",
|
"element_selected": "#100F0F",
|
||||||
"element_disabled": "#205EA64d",
|
"element_disabled": "#24837B4D",
|
||||||
"secondary_foreground": "#163B66",
|
"secondary_foreground": "#100F0F",
|
||||||
"secondary_background": "#F2F0E5",
|
"secondary_background": "#E6E4D9",
|
||||||
"secondary_hover": "#205EA61a",
|
"secondary_hover": "#DAD8CE",
|
||||||
"secondary_active": "#E6E4D9",
|
"secondary_active": "#CECDC3",
|
||||||
"secondary_selected": "#E6E4D9",
|
"secondary_selected": "#CECDC3",
|
||||||
"secondary_disabled": "#205EA64d",
|
"secondary_disabled": "#24837B4D",
|
||||||
"danger_foreground": "#FFFCF0",
|
"danger_foreground": "#FFFCF0",
|
||||||
"danger_background": "#D14D41",
|
"danger_background": "#AF3029",
|
||||||
"danger_hover": "#C03E35",
|
"danger_hover": "#D14D41",
|
||||||
"danger_active": "#AF3029",
|
"danger_active": "#1C1B1A",
|
||||||
"danger_selected": "#942822",
|
"danger_selected": "#100F0F",
|
||||||
"danger_disabled": "#D14D414d",
|
"danger_disabled": "#AF30294D",
|
||||||
"warning_foreground": "#100F0F",
|
"warning_foreground": "#FFFCF0",
|
||||||
"warning_background": "#D0A215",
|
"warning_background": "#BC5215",
|
||||||
"warning_hover": "#BE9207",
|
"warning_hover": "#DA702C",
|
||||||
"warning_active": "#AD8301",
|
"warning_active": "#1C1B1A",
|
||||||
"warning_selected": "#8E6B01",
|
"warning_selected": "#100F0F",
|
||||||
"warning_disabled": "#D0A2154d",
|
"warning_disabled": "#BC52154D",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#100F0F00",
|
||||||
"ghost_element_background_alt": "#E6E4D9",
|
"ghost_element_background_alt": "#F2F0E5",
|
||||||
"ghost_element_hover": "#100F0F1a",
|
"ghost_element_hover": "#100F0F0D",
|
||||||
"ghost_element_active": "#DAD8CE",
|
"ghost_element_active": "#100F0F1A",
|
||||||
"ghost_element_selected": "#DAD8CE",
|
"ghost_element_selected": "#100F0F1A",
|
||||||
"ghost_element_disabled": "#100F0F0d",
|
"ghost_element_disabled": "#100F0F05",
|
||||||
"tab_inactive_background": "#F2F0E5",
|
"tab_background": "#E6E4D9",
|
||||||
"tab_inactive_foreground": "#6F6E69",
|
"tab_foreground": "#6F6E69",
|
||||||
|
"tab_hover_background": "#100F0F0D",
|
||||||
"tab_active_background": "#FFFCF0",
|
"tab_active_background": "#FFFCF0",
|
||||||
"tab_active_foreground": "#100F0F",
|
"tab_active_foreground": "#100F0F",
|
||||||
"tab_hover_foreground": "#205EA6",
|
"scrollbar_thumb_background": "#100F0F1A",
|
||||||
"scrollbar_thumb_background": "#100F0F33",
|
"scrollbar_thumb_hover_background": "#100F0F26",
|
||||||
"scrollbar_thumb_hover_background": "#100F0F4d",
|
"scrollbar_thumb_border": "#100F0F00",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_track_background": "#100F0F00",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_border": "#100F0F00",
|
||||||
"scrollbar_track_border": "#DAD8CE",
|
"drop_target_background": "#24837B1A",
|
||||||
"drop_target_background": "#205EA61a",
|
"cursor": "#24837B",
|
||||||
"cursor": "#205EA6",
|
"selection": "#24837B40"
|
||||||
"selection": "#205EA640"
|
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#100F0F",
|
"background": "#100F0F",
|
||||||
"surface_background": "#1C1B1A",
|
"surface_background": "#1C1B1A",
|
||||||
"elevated_surface_background": "#282726",
|
"elevated_surface_background": "#282726",
|
||||||
"panel_background": "#100F0F",
|
"panel_background": "#100F0F",
|
||||||
"overlay": "#FFFCF01a",
|
"overlay": "#FFFCF01A",
|
||||||
"title_bar": "#1C1B1A",
|
"title_bar": "#282726",
|
||||||
"title_bar_inactive": "#282726",
|
"title_bar_inactive": "#100F0F",
|
||||||
"window_border": "#575653",
|
"window_border": "#403E3C",
|
||||||
"border": "#403E3C",
|
"border": "#403E3C",
|
||||||
"border_variant": "#343331",
|
"border_variant": "#343331",
|
||||||
"border_focused": "#4385BE",
|
"border_focused": "#3AA99F",
|
||||||
"border_selected": "#4385BE",
|
"border_selected": "#3AA99F",
|
||||||
"border_transparent": "#00000000",
|
"border_transparent": "#100F0F00",
|
||||||
"border_disabled": "#282726",
|
"border_disabled": "#282726",
|
||||||
"ring": "#4385BE",
|
"ring": "#24837B",
|
||||||
"text": "#FFFCF0",
|
"text": "#CECDC3",
|
||||||
"text_muted": "#878580",
|
"text_muted": "#878580",
|
||||||
"text_placeholder": "#6F6E69",
|
"text_placeholder": "#575653",
|
||||||
"text_accent": "#4385BE",
|
"text_accent": "#3AA99F",
|
||||||
|
"text_danger": "#D14D41",
|
||||||
|
"text_warning": "#DA702C",
|
||||||
"icon": "#878580",
|
"icon": "#878580",
|
||||||
"icon_muted": "#6F6E69",
|
"icon_muted": "#575653",
|
||||||
"icon_accent": "#4385BE",
|
"icon_accent": "#24837B",
|
||||||
"element_foreground": "#100F0F",
|
"element_foreground": "#100F0F",
|
||||||
"element_background": "#4385BE",
|
"element_background": "#3AA99F",
|
||||||
"element_hover": "#3171B2",
|
"element_hover": "#24837B",
|
||||||
"element_active": "#205EA6",
|
"element_active": "#CECDC3",
|
||||||
"element_selected": "#1A4F8C",
|
"element_selected": "#F2F0E5",
|
||||||
"element_disabled": "#4385BE4d",
|
"element_disabled": "#3AA99F4D",
|
||||||
"secondary_foreground": "#205EA6",
|
"secondary_foreground": "#CECDC3",
|
||||||
"secondary_background": "#1C1B1A",
|
"secondary_background": "#1C1B1A",
|
||||||
"secondary_hover": "#4385BE1a",
|
"secondary_hover": "#282726",
|
||||||
"secondary_active": "#282726",
|
"secondary_active": "#343331",
|
||||||
"secondary_selected": "#282726",
|
"secondary_selected": "#343331",
|
||||||
"secondary_disabled": "#4385BE4d",
|
"secondary_disabled": "#3AA99F4D",
|
||||||
"danger_foreground": "#100F0F",
|
"danger_foreground": "#100F0F",
|
||||||
"danger_background": "#E8705F",
|
"danger_background": "#D14D41",
|
||||||
"danger_hover": "#D14D41",
|
"danger_hover": "#AF3029",
|
||||||
"danger_active": "#C03E35",
|
"danger_active": "#CECDC3",
|
||||||
"danger_selected": "#AF3029",
|
"danger_selected": "#F2F0E5",
|
||||||
"danger_disabled": "#E8705F4d",
|
"danger_disabled": "#D14D414D",
|
||||||
"warning_foreground": "#100F0F",
|
"warning_foreground": "#100F0F",
|
||||||
"warning_background": "#DFB431",
|
"warning_background": "#DA702C",
|
||||||
"warning_hover": "#D0A215",
|
"warning_hover": "#BC5215",
|
||||||
"warning_active": "#BE9207",
|
"warning_active": "#CECDC3",
|
||||||
"warning_selected": "#AD8301",
|
"warning_selected": "#F2F0E5",
|
||||||
"warning_disabled": "#DFB4314d",
|
"warning_disabled": "#DA702C4D",
|
||||||
"ghost_element_background": "#00000000",
|
"ghost_element_background": "#100F0F00",
|
||||||
"ghost_element_background_alt": "#282726",
|
"ghost_element_background_alt": "#1C1B1A",
|
||||||
"ghost_element_hover": "#FFFCF01a",
|
"ghost_element_hover": "#FFFCF00D",
|
||||||
"ghost_element_active": "#343331",
|
"ghost_element_active": "#FFFCF01A",
|
||||||
"ghost_element_selected": "#343331",
|
"ghost_element_selected": "#FFFCF01A",
|
||||||
"ghost_element_disabled": "#FFFCF00d",
|
"ghost_element_disabled": "#FFFCF005",
|
||||||
"tab_inactive_background": "#1C1B1A",
|
"tab_background": "#282726",
|
||||||
"tab_inactive_foreground": "#878580",
|
"tab_foreground": "#878580",
|
||||||
|
"tab_hover_background": "#FFFCF00D",
|
||||||
"tab_active_background": "#100F0F",
|
"tab_active_background": "#100F0F",
|
||||||
"tab_active_foreground": "#FFFCF0",
|
"tab_active_foreground": "#CECDC3",
|
||||||
"tab_hover_foreground": "#4385BE",
|
"scrollbar_thumb_background": "#FFFCF01A",
|
||||||
"scrollbar_thumb_background": "#FFFCF033",
|
"scrollbar_thumb_hover_background": "#FFFCF026",
|
||||||
"scrollbar_thumb_hover_background": "#FFFCF04d",
|
"scrollbar_thumb_border": "#100F0F00",
|
||||||
"scrollbar_thumb_border": "#00000000",
|
"scrollbar_track_background": "#100F0F00",
|
||||||
"scrollbar_track_background": "#00000000",
|
"scrollbar_track_border": "#100F0F00",
|
||||||
"scrollbar_track_border": "#343331",
|
"drop_target_background": "#3AA99F1A",
|
||||||
"drop_target_background": "#4385BE1a",
|
"cursor": "#3AA99F",
|
||||||
"cursor": "#4385BE",
|
"selection": "#3AA99F40"
|
||||||
"selection": "#4385BE40"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
"id": "forest",
|
||||||
|
"name": "Forest",
|
||||||
|
"author": "Coop",
|
||||||
|
"url": "https://github.com/lumehq/coop",
|
||||||
|
"light": {
|
||||||
|
"background": "#fbfefcff",
|
||||||
|
"surface_background": "#f4fbf6ff",
|
||||||
|
"elevated_surface_background": "#e9f6e9ff",
|
||||||
|
"panel_background": "#fbfefcff",
|
||||||
|
"overlay": "#193b2d1a",
|
||||||
|
"title_bar": "#e9f6e9ff",
|
||||||
|
"title_bar_inactive": "#fbfefcff",
|
||||||
|
"window_border": "#c4e8d1ff",
|
||||||
|
"border": "#c4e8d1ff",
|
||||||
|
"border_variant": "#b2ddb5ff",
|
||||||
|
"border_focused": "#30a46cff",
|
||||||
|
"border_selected": "#30a46cff",
|
||||||
|
"border_transparent": "#00000000",
|
||||||
|
"border_disabled": "#e0f3e6ff",
|
||||||
|
"ring": "#2b9a66ff",
|
||||||
|
"text": "#193b2dff",
|
||||||
|
"text_muted": "#2f7c57ff",
|
||||||
|
"text_placeholder": "#8eceaaff",
|
||||||
|
"text_accent": "#30a46cff",
|
||||||
|
"text_danger": "#e54d2eff",
|
||||||
|
"text_warning": "#f76b15ff",
|
||||||
|
"icon": "#2f7c57ff",
|
||||||
|
"icon_muted": "#8eceaaff",
|
||||||
|
"icon_accent": "#2b9a66ff",
|
||||||
|
"element_foreground": "#ffffffff",
|
||||||
|
"element_background": "#30a46cff",
|
||||||
|
"element_hover": "#2b9a66ff",
|
||||||
|
"element_active": "#2a7e3bff",
|
||||||
|
"element_selected": "#218358ff",
|
||||||
|
"element_disabled": "#30a46c4d",
|
||||||
|
"secondary_foreground": "#193b2dff",
|
||||||
|
"secondary_background": "#e9f6e9ff",
|
||||||
|
"secondary_hover": "#daf1dbff",
|
||||||
|
"secondary_active": "#c4e8d1ff",
|
||||||
|
"secondary_selected": "#c4e8d1ff",
|
||||||
|
"secondary_disabled": "#30a46c4d",
|
||||||
|
"danger_foreground": "#ffffffff",
|
||||||
|
"danger_background": "#feebe7ff",
|
||||||
|
"danger_hover": "#ffcdc2ff",
|
||||||
|
"danger_active": "#fdbdafff",
|
||||||
|
"danger_selected": "#fdbdafff",
|
||||||
|
"danger_disabled": "#e54d2e4d",
|
||||||
|
"warning_foreground": "#ffffffff",
|
||||||
|
"warning_background": "#fff7edff",
|
||||||
|
"warning_hover": "#ffd19aff",
|
||||||
|
"warning_active": "#ffc182ff",
|
||||||
|
"warning_selected": "#ffc182ff",
|
||||||
|
"warning_disabled": "#f76b154d",
|
||||||
|
"ghost_element_background": "#00000000",
|
||||||
|
"ghost_element_background_alt": "#e9f6e9ff",
|
||||||
|
"ghost_element_hover": "#193b2d0d",
|
||||||
|
"ghost_element_active": "#193b2d1a",
|
||||||
|
"ghost_element_selected": "#193b2d1a",
|
||||||
|
"ghost_element_disabled": "#193b2d05",
|
||||||
|
"tab_background": "#e9f6e9ff",
|
||||||
|
"tab_foreground": "#2f7c57ff",
|
||||||
|
"tab_hover_background": "#193b2d0d",
|
||||||
|
"tab_active_background": "#fbfefcff",
|
||||||
|
"tab_active_foreground": "#193b2dff",
|
||||||
|
"scrollbar_thumb_background": "#193b2d1a",
|
||||||
|
"scrollbar_thumb_hover_background": "#193b2d26",
|
||||||
|
"scrollbar_thumb_border": "#00000000",
|
||||||
|
"scrollbar_track_background": "#00000000",
|
||||||
|
"scrollbar_track_border": "#00000000",
|
||||||
|
"drop_target_background": "#30a46c1a",
|
||||||
|
"cursor": "#30a46cff",
|
||||||
|
"selection": "#30a46c40"
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"background": "#0e1512ff",
|
||||||
|
"surface_background": "#121b17ff",
|
||||||
|
"elevated_surface_background": "#132d21ff",
|
||||||
|
"panel_background": "#0e1512ff",
|
||||||
|
"overlay": "#b1f1cb1a",
|
||||||
|
"title_bar": "#132d21ff",
|
||||||
|
"title_bar_inactive": "#0e1512ff",
|
||||||
|
"window_border": "#20573eff",
|
||||||
|
"border": "#20573eff",
|
||||||
|
"border_variant": "#174933ff",
|
||||||
|
"border_focused": "#33b074ff",
|
||||||
|
"border_selected": "#33b074ff",
|
||||||
|
"border_transparent": "#00000000",
|
||||||
|
"border_disabled": "#113b29ff",
|
||||||
|
"ring": "#33b074ff",
|
||||||
|
"text": "#b1f1cbff",
|
||||||
|
"text_muted": "#71d083ff",
|
||||||
|
"text_placeholder": "#2f7c57ff",
|
||||||
|
"text_accent": "#3dd68cff",
|
||||||
|
"text_danger": "#ff977dff",
|
||||||
|
"text_warning": "#ffa057ff",
|
||||||
|
"icon": "#71d083ff",
|
||||||
|
"icon_muted": "#2f7c57ff",
|
||||||
|
"icon_accent": "#33b074ff",
|
||||||
|
"element_foreground": "#0e1512ff",
|
||||||
|
"element_background": "#3dd68cff",
|
||||||
|
"element_hover": "#71d083ff",
|
||||||
|
"element_active": "#33b074ff",
|
||||||
|
"element_selected": "#30a46cff",
|
||||||
|
"element_disabled": "#3dd68c4d",
|
||||||
|
"secondary_foreground": "#b1f1cbff",
|
||||||
|
"secondary_background": "#132d21ff",
|
||||||
|
"secondary_hover": "#113b29ff",
|
||||||
|
"secondary_active": "#174933ff",
|
||||||
|
"secondary_selected": "#174933ff",
|
||||||
|
"secondary_disabled": "#3dd68c4d",
|
||||||
|
"danger_foreground": "#181111ff",
|
||||||
|
"danger_background": "#391714ff",
|
||||||
|
"danger_hover": "#5e1c16ff",
|
||||||
|
"danger_active": "#6e2920ff",
|
||||||
|
"danger_selected": "#6e2920ff",
|
||||||
|
"danger_disabled": "#ff977d4d",
|
||||||
|
"warning_foreground": "#17120eff",
|
||||||
|
"warning_background": "#331e0bff",
|
||||||
|
"warning_hover": "#562800ff",
|
||||||
|
"warning_active": "#66350cff",
|
||||||
|
"warning_selected": "#66350cff",
|
||||||
|
"warning_disabled": "#ffa0574d",
|
||||||
|
"ghost_element_background": "#00000000",
|
||||||
|
"ghost_element_background_alt": "#132d21ff",
|
||||||
|
"ghost_element_hover": "#b1f1cb0d",
|
||||||
|
"ghost_element_active": "#b1f1cb1a",
|
||||||
|
"ghost_element_selected": "#b1f1cb1a",
|
||||||
|
"ghost_element_disabled": "#b1f1cb05",
|
||||||
|
"tab_background": "#132d21ff",
|
||||||
|
"tab_foreground": "#71d083ff",
|
||||||
|
"tab_hover_background": "#b1f1cb0d",
|
||||||
|
"tab_active_background": "#0e1512ff",
|
||||||
|
"tab_active_foreground": "#b1f1cbff",
|
||||||
|
"scrollbar_thumb_background": "#b1f1cb1a",
|
||||||
|
"scrollbar_thumb_hover_background": "#b1f1cb26",
|
||||||
|
"scrollbar_thumb_border": "#00000000",
|
||||||
|
"scrollbar_track_background": "#00000000",
|
||||||
|
"scrollbar_track_border": "#00000000",
|
||||||
|
"drop_target_background": "#3dd68c1a",
|
||||||
|
"cursor": "#3dd68cff",
|
||||||
|
"selection": "#3dd68c40"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
"id": "ocean",
|
||||||
|
"name": "Ocean",
|
||||||
|
"author": "Coop",
|
||||||
|
"url": "https://github.com/lumehq/coop",
|
||||||
|
"light": {
|
||||||
|
"background": "#fafefeff",
|
||||||
|
"surface_background": "#f2fbfaff",
|
||||||
|
"elevated_surface_background": "#e6f7f7ff",
|
||||||
|
"panel_background": "#fafefeff",
|
||||||
|
"overlay": "#00333f1a",
|
||||||
|
"title_bar": "#e6f7f7ff",
|
||||||
|
"title_bar_inactive": "#fafefeff",
|
||||||
|
"window_border": "#cce5e9ff",
|
||||||
|
"border": "#cce5e9ff",
|
||||||
|
"border_variant": "#b8dde3ff",
|
||||||
|
"border_focused": "#00a2c7ff",
|
||||||
|
"border_selected": "#00a2c7ff",
|
||||||
|
"border_transparent": "#00000000",
|
||||||
|
"border_disabled": "#e0f0f2ff",
|
||||||
|
"ring": "#0797b9ff",
|
||||||
|
"text": "#0d3c48ff",
|
||||||
|
"text_muted": "#107d98ff",
|
||||||
|
"text_placeholder": "#60b3d7ff",
|
||||||
|
"text_accent": "#00a2c7ff",
|
||||||
|
"text_danger": "#e54d2eff",
|
||||||
|
"text_warning": "#f76b15ff",
|
||||||
|
"icon": "#107d98ff",
|
||||||
|
"icon_muted": "#60b3d7ff",
|
||||||
|
"icon_accent": "#0797b9ff",
|
||||||
|
"element_foreground": "#ffffffff",
|
||||||
|
"element_background": "#00a2c7ff",
|
||||||
|
"element_hover": "#0797b9ff",
|
||||||
|
"element_active": "#12667eff",
|
||||||
|
"element_selected": "#0d4a5cff",
|
||||||
|
"element_disabled": "#00a2c74d",
|
||||||
|
"secondary_foreground": "#0d4a5cff",
|
||||||
|
"secondary_background": "#ddf9f2ff",
|
||||||
|
"secondary_hover": "#c8f4e9ff",
|
||||||
|
"secondary_active": "#b3ecdeff",
|
||||||
|
"secondary_selected": "#b3ecdeff",
|
||||||
|
"secondary_disabled": "#00a2c74d",
|
||||||
|
"danger_foreground": "#ffffffff",
|
||||||
|
"danger_background": "#feebe7ff",
|
||||||
|
"danger_hover": "#ffcdc2ff",
|
||||||
|
"danger_active": "#fdbdafff",
|
||||||
|
"danger_selected": "#fdbdafff",
|
||||||
|
"danger_disabled": "#e54d2e4d",
|
||||||
|
"warning_foreground": "#ffffffff",
|
||||||
|
"warning_background": "#fff7edff",
|
||||||
|
"warning_hover": "#ffd19aff",
|
||||||
|
"warning_active": "#ffc182ff",
|
||||||
|
"warning_selected": "#ffc182ff",
|
||||||
|
"warning_disabled": "#f76b154d",
|
||||||
|
"ghost_element_background": "#00000000",
|
||||||
|
"ghost_element_background_alt": "#e6f7f7ff",
|
||||||
|
"ghost_element_hover": "#00333f0d",
|
||||||
|
"ghost_element_active": "#00333f1a",
|
||||||
|
"ghost_element_selected": "#00333f1a",
|
||||||
|
"ghost_element_disabled": "#00333f05",
|
||||||
|
"tab_background": "#e6f7f7ff",
|
||||||
|
"tab_foreground": "#107d98ff",
|
||||||
|
"tab_hover_background": "#00333f0d",
|
||||||
|
"tab_active_background": "#fafefeff",
|
||||||
|
"tab_active_foreground": "#0d3c48ff",
|
||||||
|
"scrollbar_thumb_background": "#00333f1a",
|
||||||
|
"scrollbar_thumb_hover_background": "#00333f26",
|
||||||
|
"scrollbar_thumb_border": "#00000000",
|
||||||
|
"scrollbar_track_background": "#00000000",
|
||||||
|
"scrollbar_track_border": "#00000000",
|
||||||
|
"drop_target_background": "#00a2c71a",
|
||||||
|
"cursor": "#00a2c7ff",
|
||||||
|
"selection": "#00a2c740"
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"background": "#0b161aff",
|
||||||
|
"surface_background": "#101b20ff",
|
||||||
|
"elevated_surface_background": "#082c36ff",
|
||||||
|
"panel_background": "#0b161aff",
|
||||||
|
"overlay": "#c2f3ff1a",
|
||||||
|
"title_bar": "#082c36ff",
|
||||||
|
"title_bar_inactive": "#0b161aff",
|
||||||
|
"window_border": "#1b537bff",
|
||||||
|
"border": "#1b537bff",
|
||||||
|
"border_variant": "#154467ff",
|
||||||
|
"border_focused": "#00a2c7ff",
|
||||||
|
"border_selected": "#00a2c7ff",
|
||||||
|
"border_transparent": "#00000000",
|
||||||
|
"border_disabled": "#112840ff",
|
||||||
|
"ring": "#23afd0ff",
|
||||||
|
"text": "#b6ecf7ff",
|
||||||
|
"text_muted": "#4ccce6ff",
|
||||||
|
"text_placeholder": "#197caeff",
|
||||||
|
"text_accent": "#7ce2feff",
|
||||||
|
"text_danger": "#ff977dff",
|
||||||
|
"text_warning": "#ffa057ff",
|
||||||
|
"icon": "#4ccce6ff",
|
||||||
|
"icon_muted": "#197caeff",
|
||||||
|
"icon_accent": "#23afd0ff",
|
||||||
|
"element_foreground": "#0b161aff",
|
||||||
|
"element_background": "#7ce2feff",
|
||||||
|
"element_hover": "#a8eeffff",
|
||||||
|
"element_active": "#23afd0ff",
|
||||||
|
"element_selected": "#00a2c7ff",
|
||||||
|
"element_disabled": "#7ce2fe4d",
|
||||||
|
"secondary_foreground": "#adf0ddff",
|
||||||
|
"secondary_background": "#0d2d2aff",
|
||||||
|
"secondary_hover": "#023b37ff",
|
||||||
|
"secondary_active": "#084843ff",
|
||||||
|
"secondary_selected": "#084843ff",
|
||||||
|
"secondary_disabled": "#7ce2fe4d",
|
||||||
|
"danger_foreground": "#181111ff",
|
||||||
|
"danger_background": "#391714ff",
|
||||||
|
"danger_hover": "#5e1c16ff",
|
||||||
|
"danger_active": "#6e2920ff",
|
||||||
|
"danger_selected": "#6e2920ff",
|
||||||
|
"danger_disabled": "#ff977d4d",
|
||||||
|
"warning_foreground": "#17120eff",
|
||||||
|
"warning_background": "#331e0bff",
|
||||||
|
"warning_hover": "#562800ff",
|
||||||
|
"warning_active": "#66350cff",
|
||||||
|
"warning_selected": "#66350cff",
|
||||||
|
"warning_disabled": "#ffa0574d",
|
||||||
|
"ghost_element_background": "#00000000",
|
||||||
|
"ghost_element_background_alt": "#082c36ff",
|
||||||
|
"ghost_element_hover": "#c2f3ff0d",
|
||||||
|
"ghost_element_active": "#c2f3ff1a",
|
||||||
|
"ghost_element_selected": "#c2f3ff1a",
|
||||||
|
"ghost_element_disabled": "#c2f3ff05",
|
||||||
|
"tab_background": "#082c36ff",
|
||||||
|
"tab_foreground": "#4ccce6ff",
|
||||||
|
"tab_hover_background": "#c2f3ff0d",
|
||||||
|
"tab_active_background": "#0b161aff",
|
||||||
|
"tab_active_foreground": "#b6ecf7ff",
|
||||||
|
"scrollbar_thumb_background": "#c2f3ff1a",
|
||||||
|
"scrollbar_thumb_hover_background": "#c2f3ff26",
|
||||||
|
"scrollbar_thumb_border": "#00000000",
|
||||||
|
"scrollbar_track_background": "#00000000",
|
||||||
|
"scrollbar_track_border": "#00000000",
|
||||||
|
"drop_target_background": "#7ce2fe1a",
|
||||||
|
"cursor": "#7ce2feff",
|
||||||
|
"selection": "#7ce2fe40"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "rose-pine-dawn",
|
|
||||||
"name": "Rosé Pine Dawn",
|
|
||||||
"author": "Rosé Pine",
|
|
||||||
"url": "https://rosepinetheme.com/",
|
|
||||||
"light": {
|
|
||||||
"background": "#faf4ed",
|
|
||||||
"surface_background": "#fffaf3",
|
|
||||||
"elevated_surface_background": "#f2e9e1",
|
|
||||||
"panel_background": "#faf4ed",
|
|
||||||
"overlay": "#5752791a",
|
|
||||||
"title_bar": "#fffaf3",
|
|
||||||
"title_bar_inactive": "#f2e9e1",
|
|
||||||
"window_border": "#cecacd",
|
|
||||||
"border": "#dfdad9",
|
|
||||||
"border_variant": "#f4ede8",
|
|
||||||
"border_focused": "#907aa9",
|
|
||||||
"border_selected": "#907aa9",
|
|
||||||
"border_transparent": "#00000000",
|
|
||||||
"border_disabled": "#f2e9e1",
|
|
||||||
"ring": "#907aa9",
|
|
||||||
"text": "#575279",
|
|
||||||
"text_muted": "#797593",
|
|
||||||
"text_placeholder": "#9893a5",
|
|
||||||
"text_accent": "#907aa9",
|
|
||||||
"icon": "#797593",
|
|
||||||
"icon_muted": "#9893a5",
|
|
||||||
"icon_accent": "#907aa9",
|
|
||||||
"element_foreground": "#faf4ed",
|
|
||||||
"element_background": "#907aa9",
|
|
||||||
"element_hover": "#907aa9e6",
|
|
||||||
"element_active": "#826b95",
|
|
||||||
"element_selected": "#745c81",
|
|
||||||
"element_disabled": "#907aa94d",
|
|
||||||
"secondary_foreground": "#745c81",
|
|
||||||
"secondary_background": "#fffaf3",
|
|
||||||
"secondary_hover": "#907aa91a",
|
|
||||||
"secondary_active": "#f2e9e1",
|
|
||||||
"secondary_selected": "#f2e9e1",
|
|
||||||
"secondary_disabled": "#907aa94d",
|
|
||||||
"danger_foreground": "#faf4ed",
|
|
||||||
"danger_background": "#b4637a",
|
|
||||||
"danger_hover": "#a7586e",
|
|
||||||
"danger_active": "#9a4d62",
|
|
||||||
"danger_selected": "#8d4256",
|
|
||||||
"danger_disabled": "#b4637a4d",
|
|
||||||
"warning_foreground": "#575279",
|
|
||||||
"warning_background": "#ea9d34",
|
|
||||||
"warning_hover": "#d98e2f",
|
|
||||||
"warning_active": "#c87f2a",
|
|
||||||
"warning_selected": "#b77025",
|
|
||||||
"warning_disabled": "#ea9d344d",
|
|
||||||
"ghost_element_background": "#00000000",
|
|
||||||
"ghost_element_background_alt": "#f2e9e1",
|
|
||||||
"ghost_element_hover": "#5752791a",
|
|
||||||
"ghost_element_active": "#dfdad9",
|
|
||||||
"ghost_element_selected": "#dfdad9",
|
|
||||||
"ghost_element_disabled": "#5752790d",
|
|
||||||
"tab_inactive_background": "#fffaf3",
|
|
||||||
"tab_inactive_foreground": "#797593",
|
|
||||||
"tab_active_background": "#faf4ed",
|
|
||||||
"tab_active_foreground": "#575279",
|
|
||||||
"tab_hover_foreground": "#907aa9",
|
|
||||||
"scrollbar_thumb_background": "#57527933",
|
|
||||||
"scrollbar_thumb_hover_background": "#5752794d",
|
|
||||||
"scrollbar_thumb_border": "#00000000",
|
|
||||||
"scrollbar_track_background": "#00000000",
|
|
||||||
"scrollbar_track_border": "#dfdad9",
|
|
||||||
"drop_target_background": "#907aa91a",
|
|
||||||
"cursor": "#907aa9",
|
|
||||||
"selection": "#907aa940"
|
|
||||||
},
|
|
||||||
"dark": {
|
|
||||||
"background": "#faf4ed",
|
|
||||||
"surface_background": "#fffaf3",
|
|
||||||
"elevated_surface_background": "#f2e9e1",
|
|
||||||
"panel_background": "#faf4ed",
|
|
||||||
"overlay": "#5752791a",
|
|
||||||
"title_bar": "#fffaf3",
|
|
||||||
"title_bar_inactive": "#f2e9e1",
|
|
||||||
"window_border": "#cecacd",
|
|
||||||
"border": "#dfdad9",
|
|
||||||
"border_variant": "#f4ede8",
|
|
||||||
"border_focused": "#907aa9",
|
|
||||||
"border_selected": "#907aa9",
|
|
||||||
"border_transparent": "#00000000",
|
|
||||||
"border_disabled": "#f2e9e1",
|
|
||||||
"ring": "#907aa9",
|
|
||||||
"text": "#575279",
|
|
||||||
"text_muted": "#797593",
|
|
||||||
"text_placeholder": "#9893a5",
|
|
||||||
"text_accent": "#907aa9",
|
|
||||||
"icon": "#797593",
|
|
||||||
"icon_muted": "#9893a5",
|
|
||||||
"icon_accent": "#907aa9",
|
|
||||||
"element_foreground": "#faf4ed",
|
|
||||||
"element_background": "#907aa9",
|
|
||||||
"element_hover": "#907aa9e6",
|
|
||||||
"element_active": "#826b95",
|
|
||||||
"element_selected": "#745c81",
|
|
||||||
"element_disabled": "#907aa94d",
|
|
||||||
"secondary_foreground": "#745c81",
|
|
||||||
"secondary_background": "#fffaf3",
|
|
||||||
"secondary_hover": "#907aa91a",
|
|
||||||
"secondary_active": "#f2e9e1",
|
|
||||||
"secondary_selected": "#f2e9e1",
|
|
||||||
"secondary_disabled": "#907aa94d",
|
|
||||||
"danger_foreground": "#faf4ed",
|
|
||||||
"danger_background": "#b4637a",
|
|
||||||
"danger_hover": "#a7586e",
|
|
||||||
"danger_active": "#9a4d62",
|
|
||||||
"danger_selected": "#8d4256",
|
|
||||||
"danger_disabled": "#b4637a4d",
|
|
||||||
"warning_foreground": "#575279",
|
|
||||||
"warning_background": "#ea9d34",
|
|
||||||
"warning_hover": "#d98e2f",
|
|
||||||
"warning_active": "#c87f2a",
|
|
||||||
"warning_selected": "#b77025",
|
|
||||||
"warning_disabled": "#ea9d344d",
|
|
||||||
"ghost_element_background": "#00000000",
|
|
||||||
"ghost_element_background_alt": "#f2e9e1",
|
|
||||||
"ghost_element_hover": "#5752791a",
|
|
||||||
"ghost_element_active": "#dfdad9",
|
|
||||||
"ghost_element_selected": "#dfdad9",
|
|
||||||
"ghost_element_disabled": "#5752790d",
|
|
||||||
"tab_inactive_background": "#fffaf3",
|
|
||||||
"tab_inactive_foreground": "#797593",
|
|
||||||
"tab_active_background": "#faf4ed",
|
|
||||||
"tab_active_foreground": "#575279",
|
|
||||||
"tab_hover_foreground": "#907aa9",
|
|
||||||
"scrollbar_thumb_background": "#57527933",
|
|
||||||
"scrollbar_thumb_hover_background": "#5752794d",
|
|
||||||
"scrollbar_thumb_border": "#00000000",
|
|
||||||
"scrollbar_track_background": "#00000000",
|
|
||||||
"scrollbar_track_border": "#dfdad9",
|
|
||||||
"drop_target_background": "#907aa91a",
|
|
||||||
"cursor": "#907aa9",
|
|
||||||
"selection": "#907aa940"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "rose-pine-moon",
|
|
||||||
"name": "Rosé Pine Moon",
|
|
||||||
"author": "Rosé Pine",
|
|
||||||
"url": "https://rosepinetheme.com/",
|
|
||||||
"light": {
|
|
||||||
"background": "#232136",
|
|
||||||
"surface_background": "#2a273f",
|
|
||||||
"elevated_surface_background": "#393552",
|
|
||||||
"panel_background": "#232136",
|
|
||||||
"overlay": "#e0def41a",
|
|
||||||
"title_bar": "#2a273f",
|
|
||||||
"title_bar_inactive": "#393552",
|
|
||||||
"window_border": "#56526e",
|
|
||||||
"border": "#44415a",
|
|
||||||
"border_variant": "#393552",
|
|
||||||
"border_focused": "#c4a7e7",
|
|
||||||
"border_selected": "#c4a7e7",
|
|
||||||
"border_transparent": "#00000000",
|
|
||||||
"border_disabled": "#393552",
|
|
||||||
"ring": "#c4a7e7",
|
|
||||||
"text": "#e0def4",
|
|
||||||
"text_muted": "#908caa",
|
|
||||||
"text_placeholder": "#6e6a86",
|
|
||||||
"text_accent": "#c4a7e7",
|
|
||||||
"icon": "#908caa",
|
|
||||||
"icon_muted": "#6e6a86",
|
|
||||||
"icon_accent": "#c4a7e7",
|
|
||||||
"element_foreground": "#232136",
|
|
||||||
"element_background": "#c4a7e7",
|
|
||||||
"element_hover": "#c4a7e7e6",
|
|
||||||
"element_active": "#b296d6",
|
|
||||||
"element_selected": "#a085c5",
|
|
||||||
"element_disabled": "#c4a7e74d",
|
|
||||||
"secondary_foreground": "#a085c5",
|
|
||||||
"secondary_background": "#393552",
|
|
||||||
"secondary_hover": "#c4a7e71a",
|
|
||||||
"secondary_active": "#44415a",
|
|
||||||
"secondary_selected": "#44415a",
|
|
||||||
"secondary_disabled": "#c4a7e74d",
|
|
||||||
"danger_foreground": "#232136",
|
|
||||||
"danger_background": "#eb6f92",
|
|
||||||
"danger_hover": "#e55a82",
|
|
||||||
"danger_active": "#df4572",
|
|
||||||
"danger_selected": "#d93062",
|
|
||||||
"danger_disabled": "#eb6f924d",
|
|
||||||
"warning_foreground": "#232136",
|
|
||||||
"warning_background": "#f6c177",
|
|
||||||
"warning_hover": "#f4b35e",
|
|
||||||
"warning_active": "#f2a545",
|
|
||||||
"warning_selected": "#f0972c",
|
|
||||||
"warning_disabled": "#f6c1774d",
|
|
||||||
"ghost_element_background": "#00000000",
|
|
||||||
"ghost_element_background_alt": "#393552",
|
|
||||||
"ghost_element_hover": "#e0def41a",
|
|
||||||
"ghost_element_active": "#44415a",
|
|
||||||
"ghost_element_selected": "#44415a",
|
|
||||||
"ghost_element_disabled": "#e0def40d",
|
|
||||||
"tab_inactive_background": "#2a273f",
|
|
||||||
"tab_inactive_foreground": "#908caa",
|
|
||||||
"tab_active_background": "#232136",
|
|
||||||
"tab_active_foreground": "#e0def4",
|
|
||||||
"tab_hover_foreground": "#c4a7e7",
|
|
||||||
"scrollbar_thumb_background": "#e0def433",
|
|
||||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
|
||||||
"scrollbar_thumb_border": "#00000000",
|
|
||||||
"scrollbar_track_background": "#00000000",
|
|
||||||
"scrollbar_track_border": "#44415a",
|
|
||||||
"drop_target_background": "#c4a7e71a",
|
|
||||||
"cursor": "#c4a7e7",
|
|
||||||
"selection": "#c4a7e740"
|
|
||||||
},
|
|
||||||
"dark": {
|
|
||||||
"background": "#232136",
|
|
||||||
"surface_background": "#2a273f",
|
|
||||||
"elevated_surface_background": "#393552",
|
|
||||||
"panel_background": "#232136",
|
|
||||||
"overlay": "#e0def41a",
|
|
||||||
"title_bar": "#2a273f",
|
|
||||||
"title_bar_inactive": "#393552",
|
|
||||||
"window_border": "#56526e",
|
|
||||||
"border": "#44415a",
|
|
||||||
"border_variant": "#393552",
|
|
||||||
"border_focused": "#c4a7e7",
|
|
||||||
"border_selected": "#c4a7e7",
|
|
||||||
"border_transparent": "#00000000",
|
|
||||||
"border_disabled": "#393552",
|
|
||||||
"ring": "#c4a7e7",
|
|
||||||
"text": "#e0def4",
|
|
||||||
"text_muted": "#908caa",
|
|
||||||
"text_placeholder": "#6e6a86",
|
|
||||||
"text_accent": "#c4a7e7",
|
|
||||||
"icon": "#908caa",
|
|
||||||
"icon_muted": "#6e6a86",
|
|
||||||
"icon_accent": "#c4a7e7",
|
|
||||||
"element_foreground": "#232136",
|
|
||||||
"element_background": "#c4a7e7",
|
|
||||||
"element_hover": "#c4a7e7e6",
|
|
||||||
"element_active": "#b296d6",
|
|
||||||
"element_selected": "#a085c5",
|
|
||||||
"element_disabled": "#c4a7e74d",
|
|
||||||
"secondary_foreground": "#a085c5",
|
|
||||||
"secondary_background": "#393552",
|
|
||||||
"secondary_hover": "#c4a7e71a",
|
|
||||||
"secondary_active": "#44415a",
|
|
||||||
"secondary_selected": "#44415a",
|
|
||||||
"secondary_disabled": "#c4a7e74d",
|
|
||||||
"danger_foreground": "#232136",
|
|
||||||
"danger_background": "#eb6f92",
|
|
||||||
"danger_hover": "#e55a82",
|
|
||||||
"danger_active": "#df4572",
|
|
||||||
"danger_selected": "#d93062",
|
|
||||||
"danger_disabled": "#eb6f924d",
|
|
||||||
"warning_foreground": "#232136",
|
|
||||||
"warning_background": "#f6c177",
|
|
||||||
"warning_hover": "#f4b35e",
|
|
||||||
"warning_active": "#f2a545",
|
|
||||||
"warning_selected": "#f0972c",
|
|
||||||
"warning_disabled": "#f6c1774d",
|
|
||||||
"ghost_element_background": "#00000000",
|
|
||||||
"ghost_element_background_alt": "#393552",
|
|
||||||
"ghost_element_hover": "#e0def41a",
|
|
||||||
"ghost_element_active": "#44415a",
|
|
||||||
"ghost_element_selected": "#44415a",
|
|
||||||
"ghost_element_disabled": "#e0def40d",
|
|
||||||
"tab_inactive_background": "#2a273f",
|
|
||||||
"tab_inactive_foreground": "#908caa",
|
|
||||||
"tab_active_background": "#232136",
|
|
||||||
"tab_active_foreground": "#e0def4",
|
|
||||||
"tab_hover_foreground": "#c4a7e7",
|
|
||||||
"scrollbar_thumb_background": "#e0def433",
|
|
||||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
|
||||||
"scrollbar_thumb_border": "#00000000",
|
|
||||||
"scrollbar_track_background": "#00000000",
|
|
||||||
"scrollbar_track_border": "#44415a",
|
|
||||||
"drop_target_background": "#c4a7e71a",
|
|
||||||
"cursor": "#c4a7e7",
|
|
||||||
"selection": "#c4a7e740"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "rose-pine",
|
|
||||||
"name": "Rosé Pine",
|
|
||||||
"author": "Rosé Pine",
|
|
||||||
"url": "https://rosepinetheme.com/",
|
|
||||||
"light": {
|
|
||||||
"background": "#191724",
|
|
||||||
"surface_background": "#1f1d2e",
|
|
||||||
"elevated_surface_background": "#26233a",
|
|
||||||
"panel_background": "#191724",
|
|
||||||
"overlay": "#e0def41a",
|
|
||||||
"title_bar": "#1f1d2e",
|
|
||||||
"title_bar_inactive": "#26233a",
|
|
||||||
"window_border": "#524f67",
|
|
||||||
"border": "#403d52",
|
|
||||||
"border_variant": "#26233a",
|
|
||||||
"border_focused": "#c4a7e7",
|
|
||||||
"border_selected": "#c4a7e7",
|
|
||||||
"border_transparent": "#00000000",
|
|
||||||
"border_disabled": "#26233a",
|
|
||||||
"ring": "#c4a7e7",
|
|
||||||
"text": "#e0def4",
|
|
||||||
"text_muted": "#908caa",
|
|
||||||
"text_placeholder": "#6e6a86",
|
|
||||||
"text_accent": "#c4a7e7",
|
|
||||||
"icon": "#908caa",
|
|
||||||
"icon_muted": "#6e6a86",
|
|
||||||
"icon_accent": "#c4a7e7",
|
|
||||||
"element_foreground": "#191724",
|
|
||||||
"element_background": "#c4a7e7",
|
|
||||||
"element_hover": "#c4a7e7e6",
|
|
||||||
"element_active": "#b296d6",
|
|
||||||
"element_selected": "#a085c5",
|
|
||||||
"element_disabled": "#c4a7e74d",
|
|
||||||
"secondary_foreground": "#a085c5",
|
|
||||||
"secondary_background": "#26233a",
|
|
||||||
"secondary_hover": "#c4a7e71a",
|
|
||||||
"secondary_active": "#403d52",
|
|
||||||
"secondary_selected": "#403d52",
|
|
||||||
"secondary_disabled": "#c4a7e74d",
|
|
||||||
"danger_foreground": "#191724",
|
|
||||||
"danger_background": "#eb6f92",
|
|
||||||
"danger_hover": "#e55a82",
|
|
||||||
"danger_active": "#df4572",
|
|
||||||
"danger_selected": "#d93062",
|
|
||||||
"danger_disabled": "#eb6f924d",
|
|
||||||
"warning_foreground": "#191724",
|
|
||||||
"warning_background": "#f6c177",
|
|
||||||
"warning_hover": "#f4b35e",
|
|
||||||
"warning_active": "#f2a545",
|
|
||||||
"warning_selected": "#f0972c",
|
|
||||||
"warning_disabled": "#f6c1774d",
|
|
||||||
"ghost_element_background": "#00000000",
|
|
||||||
"ghost_element_background_alt": "#26233a",
|
|
||||||
"ghost_element_hover": "#e0def41a",
|
|
||||||
"ghost_element_active": "#403d52",
|
|
||||||
"ghost_element_selected": "#403d52",
|
|
||||||
"ghost_element_disabled": "#e0def40d",
|
|
||||||
"tab_inactive_background": "#1f1d2e",
|
|
||||||
"tab_inactive_foreground": "#908caa",
|
|
||||||
"tab_active_background": "#191724",
|
|
||||||
"tab_active_foreground": "#e0def4",
|
|
||||||
"tab_hover_foreground": "#c4a7e7",
|
|
||||||
"scrollbar_thumb_background": "#e0def433",
|
|
||||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
|
||||||
"scrollbar_thumb_border": "#00000000",
|
|
||||||
"scrollbar_track_background": "#00000000",
|
|
||||||
"scrollbar_track_border": "#403d52",
|
|
||||||
"drop_target_background": "#c4a7e71a",
|
|
||||||
"cursor": "#c4a7e7",
|
|
||||||
"selection": "#c4a7e740"
|
|
||||||
},
|
|
||||||
"dark": {
|
|
||||||
"background": "#191724",
|
|
||||||
"surface_background": "#1f1d2e",
|
|
||||||
"elevated_surface_background": "#26233a",
|
|
||||||
"panel_background": "#191724",
|
|
||||||
"overlay": "#e0def41a",
|
|
||||||
"title_bar": "#1f1d2e",
|
|
||||||
"title_bar_inactive": "#26233a",
|
|
||||||
"window_border": "#524f67",
|
|
||||||
"border": "#403d52",
|
|
||||||
"border_variant": "#26233a",
|
|
||||||
"border_focused": "#c4a7e7",
|
|
||||||
"border_selected": "#c4a7e7",
|
|
||||||
"border_transparent": "#00000000",
|
|
||||||
"border_disabled": "#26233a",
|
|
||||||
"ring": "#c4a7e7",
|
|
||||||
"text": "#e0def4",
|
|
||||||
"text_muted": "#908caa",
|
|
||||||
"text_placeholder": "#6e6a86",
|
|
||||||
"text_accent": "#c4a7e7",
|
|
||||||
"icon": "#908caa",
|
|
||||||
"icon_muted": "#6e6a86",
|
|
||||||
"icon_accent": "#c4a7e7",
|
|
||||||
"element_foreground": "#191724",
|
|
||||||
"element_background": "#c4a7e7",
|
|
||||||
"element_hover": "#c4a7e7e6",
|
|
||||||
"element_active": "#b296d6",
|
|
||||||
"element_selected": "#a085c5",
|
|
||||||
"element_disabled": "#c4a7e74d",
|
|
||||||
"secondary_foreground": "#a085c5",
|
|
||||||
"secondary_background": "#26233a",
|
|
||||||
"secondary_hover": "#c4a7e71a",
|
|
||||||
"secondary_active": "#403d52",
|
|
||||||
"secondary_selected": "#403d52",
|
|
||||||
"secondary_disabled": "#c4a7e74d",
|
|
||||||
"danger_foreground": "#191724",
|
|
||||||
"danger_background": "#eb6f92",
|
|
||||||
"danger_hover": "#e55a82",
|
|
||||||
"danger_active": "#df4572",
|
|
||||||
"danger_selected": "#d93062",
|
|
||||||
"danger_disabled": "#eb6f924d",
|
|
||||||
"warning_foreground": "#191724",
|
|
||||||
"warning_background": "#f6c177",
|
|
||||||
"warning_hover": "#f4b35e",
|
|
||||||
"warning_active": "#f2a545",
|
|
||||||
"warning_selected": "#f0972c",
|
|
||||||
"warning_disabled": "#f6c1774d",
|
|
||||||
"ghost_element_background": "#00000000",
|
|
||||||
"ghost_element_background_alt": "#26233a",
|
|
||||||
"ghost_element_hover": "#e0def41a",
|
|
||||||
"ghost_element_active": "#403d52",
|
|
||||||
"ghost_element_selected": "#403d52",
|
|
||||||
"ghost_element_disabled": "#e0def40d",
|
|
||||||
"tab_inactive_background": "#1f1d2e",
|
|
||||||
"tab_inactive_foreground": "#908caa",
|
|
||||||
"tab_active_background": "#191724",
|
|
||||||
"tab_active_foreground": "#e0def4",
|
|
||||||
"tab_hover_foreground": "#c4a7e7",
|
|
||||||
"scrollbar_thumb_background": "#e0def433",
|
|
||||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
|
||||||
"scrollbar_thumb_border": "#00000000",
|
|
||||||
"scrollbar_track_background": "#00000000",
|
|
||||||
"scrollbar_track_border": "#403d52",
|
|
||||||
"drop_target_background": "#c4a7e71a",
|
|
||||||
"cursor": "#c4a7e7",
|
|
||||||
"selection": "#c4a7e740"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,17 +5,8 @@ edition.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui_tokio.workspace = true
|
instant.workspace = true
|
||||||
anyhow.workspace = true
|
|
||||||
smol.workspace = true
|
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
smallvec.workspace = true
|
|
||||||
serde = { workspace = true, features = ["derive"] }
|
|
||||||
serde_json.workspace = true
|
|
||||||
|
|
||||||
semver = "1.0.27"
|
gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] }
|
||||||
tempfile = "3.23.0"
|
|
||||||
futures.workspace = true
|
|
||||||
|
|||||||
+116
-497
@@ -1,39 +1,29 @@
|
|||||||
use std::ffi::OsString;
|
#![cfg(not(target_arch = "wasm32"))]
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Window};
|
||||||
use gpui::http_client::{AsyncBody, HttpClient};
|
use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version};
|
||||||
use gpui::{
|
use instant::{Duration, Instant};
|
||||||
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
|
||||||
Window,
|
|
||||||
};
|
|
||||||
use semver::Version;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
use smol::fs::File;
|
|
||||||
use smol::io::AsyncReadExt;
|
|
||||||
use smol::process::Command;
|
|
||||||
|
|
||||||
const GITHUB_API_URL: &str = "https://api.github.com";
|
|
||||||
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||||
|
|
||||||
fn get_github_repo_owner() -> String {
|
fn get_github_repo_owner() -> String {
|
||||||
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "your-username".to_string())
|
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_github_repo_name() -> String {
|
fn get_github_repo_name() -> String {
|
||||||
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "your-repo".to_string())
|
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_flatpak_installation() -> bool {
|
fn is_flatpak_installation() -> bool {
|
||||||
// Check if app is installed via Flatpak
|
|
||||||
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initialize the auto-update system.
|
||||||
|
///
|
||||||
|
/// Skips initialization when running as a Flatpak (updates are handled by the
|
||||||
|
/// Flatpak distribution channel). Otherwise creates the global [`AutoUpdater`]
|
||||||
|
/// entity and schedules a check for updates after a 2-minute delay.
|
||||||
pub fn init(window: &mut Window, cx: &mut App) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
// Skip auto-update initialization if installed via Flatpak
|
|
||||||
if is_flatpak_installation() {
|
if is_flatpak_installation() {
|
||||||
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
|
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
|
||||||
return;
|
return;
|
||||||
@@ -46,516 +36,145 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
|||||||
|
|
||||||
impl Global for GlobalAutoUpdater {}
|
impl Global for GlobalAutoUpdater {}
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
/// Observable auto-update status — re-exported from [`gpui_updater::UpdateStatus`].
|
||||||
struct InstallerDir(tempfile::TempDir);
|
pub use gpui_updater::UpdateStatus as AutoUpdateStatus;
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
/// The global auto-updater entity.
|
||||||
impl InstallerDir {
|
///
|
||||||
async fn new() -> Result<Self, Error> {
|
/// Wraps [`gpui_updater::Updater`] with Coop-specific configuration
|
||||||
Ok(Self(
|
/// (GitHub repo, Flatpak detection, delayed auto-check).
|
||||||
tempfile::Builder::new()
|
///
|
||||||
.prefix("coop-auto-update")
|
/// Retrieve the global instance via [`AutoUpdater::global`].
|
||||||
.tempdir()?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path(&self) -> &Path {
|
|
||||||
self.0.path()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
struct InstallerDir(PathBuf);
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
impl InstallerDir {
|
|
||||||
async fn new() -> Result<Self, Error> {
|
|
||||||
let installer_dir = std::env::current_exe()?
|
|
||||||
.parent()
|
|
||||||
.context("No parent dir for Coop.exe")?
|
|
||||||
.join("updates");
|
|
||||||
|
|
||||||
if smol::fs::metadata(&installer_dir).await.is_ok() {
|
|
||||||
smol::fs::remove_dir_all(&installer_dir).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
smol::fs::create_dir(&installer_dir).await?;
|
|
||||||
|
|
||||||
Ok(Self(installer_dir))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path(&self) -> &Path {
|
|
||||||
self.0.as_path()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MacOsUnmounter<'a> {
|
|
||||||
mount_path: PathBuf,
|
|
||||||
background_executor: &'a BackgroundExecutor,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for MacOsUnmounter<'_> {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let mount_path = std::mem::take(&mut self.mount_path);
|
|
||||||
|
|
||||||
self.background_executor
|
|
||||||
.spawn(async move {
|
|
||||||
let unmount_output = Command::new("hdiutil")
|
|
||||||
.args(["detach", "-force"])
|
|
||||||
.arg(&mount_path)
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match unmount_output {
|
|
||||||
Ok(output) if output.status.success() => {
|
|
||||||
log::info!("Successfully unmounted the disk image");
|
|
||||||
}
|
|
||||||
Ok(output) => {
|
|
||||||
log::error!(
|
|
||||||
"Failed to unmount disk image: {:?}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
log::error!("Error while trying to unmount disk image: {:?}", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub enum AutoUpdateStatus {
|
|
||||||
Idle,
|
|
||||||
Checking,
|
|
||||||
Checked { download_url: String },
|
|
||||||
Installing,
|
|
||||||
Updated,
|
|
||||||
Errored { msg: Box<String> },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
|
|
||||||
fn as_ref(&self) -> &AutoUpdateStatus {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AutoUpdateStatus {
|
|
||||||
pub fn is_updating(&self) -> bool {
|
|
||||||
matches!(self, Self::Checked { .. } | Self::Installing)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_updated(&self) -> bool {
|
|
||||||
matches!(self, Self::Updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn checked(download_url: String) -> Self {
|
|
||||||
Self::Checked { download_url }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error(e: String) -> Self {
|
|
||||||
Self::Errored { msg: Box::new(e) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct GitHubRelease {
|
|
||||||
pub tag_name: String,
|
|
||||||
pub assets: Vec<GitHubAsset>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct GitHubAsset {
|
|
||||||
pub name: String,
|
|
||||||
pub browser_download_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct AutoUpdater {
|
pub struct AutoUpdater {
|
||||||
/// Current status of the auto updater
|
/// The underlying gpui-updater entity that does the heavy lifting.
|
||||||
pub status: AutoUpdateStatus,
|
pub updater: Entity<Updater>,
|
||||||
|
/// Currently running app version.
|
||||||
/// Current version of the application
|
|
||||||
pub version: Version,
|
pub version: Version,
|
||||||
|
/// Keeps the observer subscription alive.
|
||||||
/// Event subscriptions
|
_subscription: Subscription,
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
/// When the last error was recorded, so we can reset to idle after 5s.
|
||||||
|
error_time: Option<Instant>,
|
||||||
/// Background tasks
|
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AutoUpdater {
|
impl AutoUpdater {
|
||||||
/// Retrieve the global auto updater instance
|
/// Retrieve the global auto updater instance.
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
cx.global::<GlobalAutoUpdater>().0.clone()
|
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the global auto updater instance
|
|
||||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
cx.set_global(GlobalAutoUpdater(state));
|
cx.set_global(GlobalAutoUpdater(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||||
let mut subscriptions = smallvec![];
|
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
// Observe the status
|
|
||||||
cx.observe_self(|this, cx| {
|
|
||||||
if let AutoUpdateStatus::Checked { download_url } = this.status.clone() {
|
|
||||||
this.download_and_install(&download_url, cx);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Run at the end of current cycle
|
|
||||||
cx.defer_in(window, |this, _window, cx| {
|
|
||||||
this.check(cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
|
||||||
status: AutoUpdateStatus::Idle,
|
|
||||||
version,
|
|
||||||
tasks: vec![],
|
|
||||||
_subscriptions: subscriptions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
|
|
||||||
self.status = status;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let version = self.version.clone();
|
|
||||||
let duration = Duration::from_secs(120);
|
|
||||||
let task = self.check_for_updates(version, cx);
|
|
||||||
|
|
||||||
// Check for updates after 2 minutes
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
||||||
cx.background_executor().timer(duration).await;
|
|
||||||
|
|
||||||
// Update the status to checking
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_status(AutoUpdateStatus::Checking, cx);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
match task.await {
|
|
||||||
Ok(download_url) => {
|
|
||||||
// Update the status to checked with download URL
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_status(AutoUpdateStatus::checked(download_url), cx);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("Failed to check for updates: {e}");
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_status(AutoUpdateStatus::Idle, cx);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_for_updates(&self, version: Version, cx: &App) -> Task<Result<String, Error>> {
|
|
||||||
let http_client = cx.http_client();
|
|
||||||
let repo_owner = get_github_repo_owner();
|
let repo_owner = get_github_repo_owner();
|
||||||
let repo_name = get_github_repo_name();
|
let repo_name = get_github_repo_name();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
let source =
|
||||||
let url = format!(
|
GitHubSource::new(&repo_owner, &repo_name).asset_contains(match std::env::consts::OS {
|
||||||
"{}/repos/{}/{}/releases/latest",
|
"macos" => "macos",
|
||||||
GITHUB_API_URL, repo_owner, repo_name
|
"linux" => "linux",
|
||||||
);
|
_ => "",
|
||||||
|
});
|
||||||
|
|
||||||
let async_body = AsyncBody::default();
|
let updater: Entity<Updater> =
|
||||||
let mut body = Vec::new();
|
cx.new(|cx| Updater::new(source, EngineConfig::new(version.clone()), cx));
|
||||||
let mut response = http_client.get(&url, async_body, false).await?;
|
|
||||||
|
|
||||||
// Read the response body into a vector
|
// When an update becomes available, automatically download and install it.
|
||||||
response.body_mut().read_to_end(&mut body).await?;
|
let subscription = cx.observe(&updater, |this: &mut AutoUpdater, _updater, cx| {
|
||||||
|
let status = this.updater.read(cx).status().clone();
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if matches!(status, UpdateStatus::Available(_)) {
|
||||||
return Err(anyhow!("GitHub API returned error: {}", response.status()));
|
this.updater.update(cx, |updater, cx| {
|
||||||
|
updater.download_and_install(cx);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the response body as JSON
|
if matches!(status, UpdateStatus::Errored(_)) {
|
||||||
let release: GitHubRelease = serde_json::from_slice(&body)?;
|
this.error_time = Some(Instant::now());
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
// Parse version from tag (remove 'v' prefix if present)
|
cx.background_executor().timer(Duration::from_secs(5)).await;
|
||||||
let tag_version = release.tag_name.trim_start_matches('v');
|
this.update(cx, |_this, cx| cx.notify()).ok();
|
||||||
let new_version = Version::parse(tag_version).context(format!(
|
})
|
||||||
"Failed to parse version from tag: {}",
|
.detach();
|
||||||
release.tag_name
|
|
||||||
))?;
|
|
||||||
|
|
||||||
if new_version > version {
|
|
||||||
// Find the appropriate asset for the current platform
|
|
||||||
let current_os = std::env::consts::OS;
|
|
||||||
let asset_name = match current_os {
|
|
||||||
"macos" => "Coop.dmg",
|
|
||||||
"linux" => "coop.tar.gz",
|
|
||||||
"windows" => "Coop.exe",
|
|
||||||
_ => return Err(anyhow!("Unsupported OS: {}", current_os)),
|
|
||||||
};
|
|
||||||
|
|
||||||
let download_url = release
|
|
||||||
.assets
|
|
||||||
.iter()
|
|
||||||
.find(|asset| asset.name == asset_name)
|
|
||||||
.map(|asset| asset.browser_download_url.clone())
|
|
||||||
.context(format!(
|
|
||||||
"No {} asset found in release {}",
|
|
||||||
asset_name, release.tag_name
|
|
||||||
))?;
|
|
||||||
|
|
||||||
Ok(download_url)
|
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!(
|
this.error_time = None;
|
||||||
"No update available. Current: {}, Latest: {}",
|
|
||||||
version,
|
|
||||||
new_version
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn download_and_install(&mut self, download_url: &str, cx: &mut Context<Self>) {
|
cx.notify();
|
||||||
let http_client = cx.http_client();
|
|
||||||
let download_url = download_url.to_string();
|
|
||||||
|
|
||||||
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
|
|
||||||
let installer_dir = InstallerDir::new().await?;
|
|
||||||
let target_path = Self::target_path(&installer_dir).await?;
|
|
||||||
|
|
||||||
// Download the release
|
|
||||||
download(&download_url, &target_path, http_client).await?;
|
|
||||||
|
|
||||||
Ok((installer_dir, target_path))
|
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(
|
// Schedule an auto-check after a 2-minute delay (deferred to run at the
|
||||||
// Install the new release
|
// end of the current frame so the window is fully set up).
|
||||||
|
cx.defer_in(window, |_this, _window, cx| {
|
||||||
|
let duration = Duration::from_secs(120);
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(duration).await;
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_status(AutoUpdateStatus::Installing, cx);
|
this.updater.update(cx, |updater, cx| {
|
||||||
})?;
|
updater.check(cx);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
});
|
||||||
|
|
||||||
match task.await {
|
Self {
|
||||||
Ok((installer_dir, target_path)) => {
|
updater,
|
||||||
if Self::install(installer_dir, target_path, cx).await.is_ok() {
|
version,
|
||||||
// Update the status to updated
|
_subscription: subscription,
|
||||||
this.update(cx, |this, cx| {
|
error_time: None,
|
||||||
this.set_status(AutoUpdateStatus::Updated, cx);
|
}
|
||||||
})?;
|
}
|
||||||
}
|
|
||||||
}
|
pub fn idle(&self, cx: &App) -> bool {
|
||||||
Err(e) => {
|
let status = self.updater.read(cx).status();
|
||||||
// Update the status to error including the error message
|
if status == &UpdateStatus::Idle {
|
||||||
this.update(cx, |this, cx| {
|
return true;
|
||||||
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
|
}
|
||||||
})?;
|
if matches!(status, UpdateStatus::Errored(_))
|
||||||
}
|
&& self
|
||||||
|
.error_time
|
||||||
|
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self, cx: &App) -> SharedString {
|
||||||
|
let status = self.updater.read(cx).status();
|
||||||
|
|
||||||
|
match status {
|
||||||
|
UpdateStatus::Idle => "Up to date".into(),
|
||||||
|
UpdateStatus::Checking => "Checking for updates…".into(),
|
||||||
|
UpdateStatus::UpToDate => "Up to date".into(),
|
||||||
|
UpdateStatus::Available(version) => format!("Version {version} available").into(),
|
||||||
|
UpdateStatus::Downloading { downloaded, total } => {
|
||||||
|
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
|
||||||
|
let downloaded_mb = *downloaded as f64 / 1_048_576.0;
|
||||||
|
match total_mb {
|
||||||
|
Some(t) => format!("Downloading {downloaded_mb:.1} / {t:.1} MB").into(),
|
||||||
|
None => format!("Downloading {downloaded_mb:.1} MB").into(),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
UpdateStatus::Installing => "Installing update…".into(),
|
||||||
}),
|
UpdateStatus::Staged(version) => {
|
||||||
);
|
format!("Version {version} ready — restart to apply").into()
|
||||||
}
|
}
|
||||||
|
UpdateStatus::Errored(msg) => {
|
||||||
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
|
if self
|
||||||
let filename = match std::env::consts::OS {
|
.error_time
|
||||||
"macos" => anyhow::Ok("Coop.dmg"),
|
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
||||||
"linux" => Ok("coop.tar.gz"),
|
{
|
||||||
"windows" => Ok("Coop.exe"),
|
"Up to date".into()
|
||||||
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
|
} else {
|
||||||
}?;
|
format!("Update failed: {msg}").into()
|
||||||
|
}
|
||||||
Ok(installer_dir.path().join(filename))
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async fn install(
|
|
||||||
installer_dir: InstallerDir,
|
|
||||||
target_path: PathBuf,
|
|
||||||
cx: &AsyncApp,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
match std::env::consts::OS {
|
|
||||||
"macos" => install_release_macos(&installer_dir, target_path, cx).await,
|
|
||||||
"linux" => install_release_linux(&installer_dir, target_path, cx).await,
|
|
||||||
"windows" => install_release_windows(target_path).await,
|
|
||||||
unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download(
|
|
||||||
url: &str,
|
|
||||||
target_path: &std::path::Path,
|
|
||||||
client: Arc<dyn HttpClient>,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let body = AsyncBody::default();
|
|
||||||
let mut target_file = File::create(&target_path).await?;
|
|
||||||
let mut response = client.get(url, body, true).await?;
|
|
||||||
|
|
||||||
// Copy the response body to the target file
|
|
||||||
smol::io::copy(response.body_mut(), &mut target_file).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn install_release_macos(
|
|
||||||
temp_dir: &InstallerDir,
|
|
||||||
downloaded_dmg: PathBuf,
|
|
||||||
cx: &AsyncApp,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let running_app_path = cx.update(|cx| cx.app_path())?;
|
|
||||||
let running_app_filename = running_app_path
|
|
||||||
.file_name()
|
|
||||||
.with_context(|| format!("invalid running app path {running_app_path:?}"))?;
|
|
||||||
|
|
||||||
let mount_path = temp_dir.path().join("Coop");
|
|
||||||
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
|
|
||||||
|
|
||||||
mounted_app_path.push("/");
|
|
||||||
|
|
||||||
let output = Command::new("hdiutil")
|
|
||||||
.args(["attach", "-nobrowse"])
|
|
||||||
.arg(&downloaded_dmg)
|
|
||||||
.arg("-mountroot")
|
|
||||||
.arg(temp_dir.path())
|
|
||||||
.output()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
anyhow::ensure!(
|
|
||||||
output.status.success(),
|
|
||||||
"failed to mount: {:?}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
|
|
||||||
let _unmounter = MacOsUnmounter {
|
|
||||||
mount_path: mount_path.clone(),
|
|
||||||
background_executor: cx.background_executor(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let output = Command::new("rsync")
|
|
||||||
.args(["-av", "--delete"])
|
|
||||||
.arg(&mounted_app_path)
|
|
||||||
.arg(&running_app_path)
|
|
||||||
.output()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
anyhow::ensure!(
|
|
||||||
output.status.success(),
|
|
||||||
"failed to copy app: {:?}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn install_release_linux(
|
|
||||||
temp_dir: &InstallerDir,
|
|
||||||
downloaded_tar_gz: PathBuf,
|
|
||||||
cx: &AsyncApp,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let running_app_path = cx.update(|cx| cx.app_path())?;
|
|
||||||
|
|
||||||
// Extract the tar.gz file
|
|
||||||
let extracted = temp_dir.path().join("coop");
|
|
||||||
smol::fs::create_dir_all(&extracted)
|
|
||||||
.await
|
|
||||||
.context("failed to create directory to extract update")?;
|
|
||||||
|
|
||||||
let output = Command::new("tar")
|
|
||||||
.arg("-xzf")
|
|
||||||
.arg(&downloaded_tar_gz)
|
|
||||||
.arg("-C")
|
|
||||||
.arg(&extracted)
|
|
||||||
.output()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
anyhow::ensure!(
|
|
||||||
output.status.success(),
|
|
||||||
"failed to extract {:?} to {:?}: {:?}",
|
|
||||||
downloaded_tar_gz,
|
|
||||||
extracted,
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find the extracted app directory
|
|
||||||
let mut entries = smol::fs::read_dir(&extracted).await?;
|
|
||||||
let mut app_dir = None;
|
|
||||||
|
|
||||||
use smol::stream::StreamExt;
|
|
||||||
|
|
||||||
while let Some(entry) = entries.next().await {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
app_dir = Some(path);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let from = app_dir.context("No app directory found in archive")?;
|
|
||||||
|
|
||||||
// Copy to the current installation directory
|
|
||||||
let output = Command::new("rsync")
|
|
||||||
.args(["-av", "--delete"])
|
|
||||||
.arg(&from)
|
|
||||||
.arg(
|
|
||||||
running_app_path
|
|
||||||
.parent()
|
|
||||||
.context("No parent directory for app")?,
|
|
||||||
)
|
|
||||||
.output()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
anyhow::ensure!(
|
|
||||||
output.status.success(),
|
|
||||||
"failed to copy app from {:?} to {:?}: {:?}",
|
|
||||||
from,
|
|
||||||
running_app_path.parent(),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
|
|
||||||
//const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
||||||
|
|
||||||
let system_root = std::env::var("SYSTEMROOT");
|
|
||||||
let powershell_path = system_root.as_ref().map_or_else(
|
|
||||||
|_| "powershell.exe".to_string(),
|
|
||||||
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut installer_path = std::ffi::OsString::new();
|
|
||||||
installer_path.push("\"");
|
|
||||||
installer_path.push(&downloaded_installer);
|
|
||||||
installer_path.push("\"");
|
|
||||||
|
|
||||||
let output = Command::new(powershell_path)
|
|
||||||
//.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.args(["-NoProfile", "-WindowStyle", "Hidden"])
|
|
||||||
.args(["Start-Process"])
|
|
||||||
.arg(installer_path)
|
|
||||||
.arg("-ArgumentList")
|
|
||||||
.args(["/P", "/R"])
|
|
||||||
.output()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
anyhow::ensure!(
|
|
||||||
output.status.success(),
|
|
||||||
"failed to start installer: {:?}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "browser-signer-proxy"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
description = "Nostr browser signer (NIP-07) proxy using smol async runtime"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/nostrdevkit/nostr"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
atomic-destructor = "0.2"
|
||||||
|
event-listener = "5"
|
||||||
|
nostr.workspace = true
|
||||||
|
opaquerr = "0.1"
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
tracing = { version = "0.1", features = ["std"] }
|
||||||
|
uuid = { version = "1.23", features = ["serde", "v4"] }
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# browser-signer-proxy
|
||||||
|
|
||||||
|
Proxy to use Nostr Browser signer ([NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)) in native applications.
|
||||||
|
|
||||||
|
This is a re-implementation of [`nostr-browser-signer-proxy`](https://github.com/nostrdevkit/nostr/tree/master/signer/nostr-browser-signer-proxy)
|
||||||
|
using the [`smol`](https://github.com/smol-rs/smol) async runtime instead of tokio.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
This crate provides a local HTTP proxy that communicates with a NIP-07 browser extension
|
||||||
|
(e.g., Alby, nos2x) running in a browser tab. Native applications can use this proxy to
|
||||||
|
request public keys, sign events, and perform NIP-04/NIP-44 encryption/decryption through
|
||||||
|
the browser extension.
|
||||||
|
|
||||||
|
The HTTP server is implemented with a minimal, dependency-free approach using `smol::net::TcpListener`
|
||||||
|
and manual HTTP/1.1 parsing — avoiding heavy HTTP framework dependencies entirely.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use browser_signer_proxy::prelude::*;
|
||||||
|
|
||||||
|
async fn example() -> Result<(), Error> {
|
||||||
|
// Create the proxy with default options (localhost:7400)
|
||||||
|
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
|
||||||
|
|
||||||
|
// Open the proxy URL in a browser
|
||||||
|
webbrowser::open(&proxy.url())?;
|
||||||
|
|
||||||
|
// Start the proxy server
|
||||||
|
proxy.start().await?;
|
||||||
|
|
||||||
|
// Use it as an async Nostr signer
|
||||||
|
let public_key = proxy.get_public_key_async().await?;
|
||||||
|
println!("Connected with public key: {public_key}");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Differences from the tokio-based version
|
||||||
|
|
||||||
|
| Feature | tokio (original) | smol (this crate) |
|
||||||
|
|---|---|---|
|
||||||
|
| Async runtime | `tokio` | `smol` |
|
||||||
|
| HTTP server | `hyper` | `smol::net::TcpListener` + manual HTTP/1.1 |
|
||||||
|
| Mutex | `tokio::sync::Mutex` | `smol::lock::Mutex` |
|
||||||
|
| Shutdown signal | `tokio::sync::Notify` | `event_listener::Event` |
|
||||||
|
| Request-response channel | `tokio::sync::oneshot` | `smol::channel::bounded(1)` |
|
||||||
|
| Timeout | `tokio::time::timeout` | `smol::future::or` + `smol::Timer` |
|
||||||
|
| Task spawning | `tokio::spawn` | `smol::spawn` |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is distributed under the MIT software license.
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Coop — Web Signer Proxy</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--brand: #F8FF37;
|
||||||
|
--ink: #111111;
|
||||||
|
--ink-soft: #333333;
|
||||||
|
--muted: #666666;
|
||||||
|
--paper: #FFFFFF;
|
||||||
|
--edge: rgba(17, 17, 17, 0.14);
|
||||||
|
--radius-sm: 1rem;
|
||||||
|
--radius-md: 1.5rem;
|
||||||
|
--radius-lg: 2.5rem;
|
||||||
|
--green: #2E8B57;
|
||||||
|
--red: #D32F2F;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--brand);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--paper);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 2.5rem;
|
||||||
|
max-width: 440px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 8px 0 rgba(17, 17, 17, 0.12), 0 2px 20px rgba(17, 17, 17, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo__mark {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
background: var(--ink);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: "Nunito", system-ui, sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--brand);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo__text {
|
||||||
|
font-family: "Nunito", system-ui, sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-family: "Nunito", system-ui, sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin: 0 0 1.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: background 300ms ease, color 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status--checking {
|
||||||
|
background: rgba(17, 17, 17, 0.05);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status--connected {
|
||||||
|
background: rgba(46, 139, 87, 0.1);
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status--error {
|
||||||
|
background: rgba(211, 47, 47, 0.08);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status__dot {
|
||||||
|
width: 0.7rem;
|
||||||
|
height: 0.7rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status--checking .status__dot {
|
||||||
|
background: var(--muted);
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status--connected .status__dot {
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status--error .status__dot {
|
||||||
|
background: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.4; transform: scale(0.85); }
|
||||||
|
50% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--edge);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint strong {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1 class="heading">Web Signer</h1>
|
||||||
|
|
||||||
|
<p class="subtitle">
|
||||||
|
This page connects the app to your Nostr Web Signer extension so you can sign in and use Coop securely.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="nip07-status" class="status status--checking">
|
||||||
|
<div class="status__dot"></div>
|
||||||
|
<span id="nip07-status-text">Checking extension…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint">
|
||||||
|
<strong>Keep this tab open</strong> while using the app — it automatically handles sign-in requests in the background.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="proxy.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
let isPolling = false;
|
||||||
|
|
||||||
|
async function pollForRequests() {
|
||||||
|
if (isPolling) return;
|
||||||
|
isPolling = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/pending');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
console.log('Polled for requests, got:', data);
|
||||||
|
|
||||||
|
// Process any new requests
|
||||||
|
if (data.requests && data.requests.length > 0) {
|
||||||
|
console.log(`Processing ${data.requests.length} requests`);
|
||||||
|
for (const request of data.requests) {
|
||||||
|
await handleNip07Request(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Polling error:', error);
|
||||||
|
updateStatus('Error: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
isPolling = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNip07Request(request) {
|
||||||
|
console.log('Handling request:', request);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (!window.nostr) {
|
||||||
|
throw new Error('NIP-07 extension not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (request.method) {
|
||||||
|
case 'get_public_key':
|
||||||
|
console.log('Calling nostr.getPublicKey()');
|
||||||
|
result = await window.nostr.getPublicKey();
|
||||||
|
console.log('Got public key:', result);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'sign_event':
|
||||||
|
console.log('Calling nostr.signEvent() with:', request.params);
|
||||||
|
result = await window.nostr.signEvent(request.params);
|
||||||
|
console.log('Got signed event:', result);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'nip04_encrypt':
|
||||||
|
console.log('Calling nostr.nip04.encrypt()');
|
||||||
|
result = await window.nostr.nip04.encrypt(
|
||||||
|
request.params.public_key,
|
||||||
|
request.params.content
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'nip04_decrypt':
|
||||||
|
console.log('Calling nostr.nip04.decrypt()');
|
||||||
|
result = await window.nostr.nip04.decrypt(
|
||||||
|
request.params.public_key,
|
||||||
|
request.params.content
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'nip44_encrypt':
|
||||||
|
console.log('Calling nostr.nip44.encrypt()');
|
||||||
|
result = await window.nostr.nip44.encrypt(
|
||||||
|
request.params.public_key,
|
||||||
|
request.params.content
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'nip44_decrypt':
|
||||||
|
console.log('Calling nostr.nip44.decrypt()');
|
||||||
|
result = await window.nostr.nip44.decrypt(
|
||||||
|
request.params.public_key,
|
||||||
|
request.params.content
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown method: ${request.method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send response back to server
|
||||||
|
const responsePayload = {
|
||||||
|
id: request.id,
|
||||||
|
result: result,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Sending response:', responsePayload);
|
||||||
|
|
||||||
|
await fetch('/api/response', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(responsePayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Response sent successfully');
|
||||||
|
updateStatus('Request processed successfully', 'connected');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling request:', error);
|
||||||
|
|
||||||
|
// Send error response back to server
|
||||||
|
const errorPayload = {
|
||||||
|
id: request.id,
|
||||||
|
result: null,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Sending error response:', errorPayload);
|
||||||
|
|
||||||
|
await fetch('/api/response', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(errorPayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
updateStatus('Error: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(message, state) {
|
||||||
|
const container = document.getElementById('nip07-status');
|
||||||
|
const textEl = document.getElementById('nip07-status-text');
|
||||||
|
if (container && textEl) {
|
||||||
|
container.className = 'status status--' + state;
|
||||||
|
textEl.textContent = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling when page loads
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
console.log('NIP-07 Proxy loaded');
|
||||||
|
|
||||||
|
// Check if NIP-07 extension is available
|
||||||
|
if (window.nostr) {
|
||||||
|
console.log('NIP-07 extension detected');
|
||||||
|
updateStatus('Connected — ready', 'connected');
|
||||||
|
} else {
|
||||||
|
console.log('NIP-07 extension not found');
|
||||||
|
updateStatus('No NIP-07 extension found', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling every 500 ms
|
||||||
|
setInterval(pollForRequests, 500);
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Copyright (c) 2022-2023 Yuki Kishimoto
|
||||||
|
// Copyright (c) 2023-2025 Rust Nostr Developers
|
||||||
|
// Distributed under the MIT software license
|
||||||
|
|
||||||
|
//! Error types for the browser signer proxy.
|
||||||
|
|
||||||
|
opaquerr::define_kind! {
|
||||||
|
/// Nostr browser signer proxy error kind.
|
||||||
|
pub ErrorKind {
|
||||||
|
/// Nostr protocol error.
|
||||||
|
Protocol => "nostr protocol error",
|
||||||
|
/// I/O error.
|
||||||
|
IO => "I/O error",
|
||||||
|
/// JSON error.
|
||||||
|
Json => "JSON error",
|
||||||
|
/// The operation timed out.
|
||||||
|
Timeout => "timeout",
|
||||||
|
/// The operation cannot be completed in the current state.
|
||||||
|
State => "invalid state",
|
||||||
|
/// Anything not covered by the stable categories above.
|
||||||
|
Other => "other error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opaquerr::define_error! {
|
||||||
|
/// Nostr browser signer proxy error.
|
||||||
|
pub Error(ErrorKind)
|
||||||
|
|
||||||
|
from {
|
||||||
|
nostr::error::Error => ErrorKind::Protocol,
|
||||||
|
std::io::Error => ErrorKind::IO,
|
||||||
|
serde_json::Error => ErrorKind::Json,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub(crate) fn generic<S>(message: S) -> Self
|
||||||
|
where
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
Self::new(ErrorKind::Other, message.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn timeout() -> Self {
|
||||||
|
Self::simple(ErrorKind::Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn shutdown() -> Self {
|
||||||
|
Self::with_static_message(ErrorKind::State, "server is shutdown")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,764 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use atomic_destructor::{AtomicDestroyer, AtomicDestructor};
|
||||||
|
use event_listener::Event as ShutdownEvent;
|
||||||
|
use nostr::prelude::*;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::{Deserialize, Serialize, Serializer};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use smol::channel;
|
||||||
|
use smol::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use smol::lock::Mutex;
|
||||||
|
use smol::net::{TcpListener, TcpStream};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
mod error;
|
||||||
|
pub mod prelude;
|
||||||
|
|
||||||
|
pub use self::error::Error;
|
||||||
|
|
||||||
|
const DEFAULT_HTML: &str = include_str!("../index.html");
|
||||||
|
const JS: &str = include_str!("../proxy.js");
|
||||||
|
|
||||||
|
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Message {
|
||||||
|
id: Uuid,
|
||||||
|
error: Option<String>,
|
||||||
|
result: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Message {
|
||||||
|
fn into_result(self) -> Result<Value, String> {
|
||||||
|
if let Some(error) = self.error {
|
||||||
|
Err(error)
|
||||||
|
} else {
|
||||||
|
Ok(self.result.unwrap_or(Value::Null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum RequestMethod {
|
||||||
|
GetPublicKey,
|
||||||
|
SignEvent,
|
||||||
|
Nip04Encrypt,
|
||||||
|
Nip04Decrypt,
|
||||||
|
Nip44Encrypt,
|
||||||
|
Nip44Decrypt,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestMethod {
|
||||||
|
fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::GetPublicKey => "get_public_key",
|
||||||
|
Self::SignEvent => "sign_event",
|
||||||
|
Self::Nip04Encrypt => "nip04_encrypt",
|
||||||
|
Self::Nip04Decrypt => "nip04_decrypt",
|
||||||
|
Self::Nip44Encrypt => "nip44_encrypt",
|
||||||
|
Self::Nip44Decrypt => "nip44_decrypt",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for RequestMethod {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
struct RequestData {
|
||||||
|
id: Uuid,
|
||||||
|
method: RequestMethod,
|
||||||
|
params: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestData {
|
||||||
|
#[inline]
|
||||||
|
fn new(method: RequestMethod, params: Value) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Requests<'a> {
|
||||||
|
requests: &'a [RequestData],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Requests<'a> {
|
||||||
|
#[inline]
|
||||||
|
fn new(requests: &'a [RequestData]) -> Self {
|
||||||
|
Self { requests }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
self.requests.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Params for NIP-04 and NIP-44 encryption/decryption
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CryptoParams<'a> {
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
content: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> CryptoParams<'a> {
|
||||||
|
#[inline]
|
||||||
|
fn new(public_key: &'a PublicKey, content: &'a str) -> Self {
|
||||||
|
Self {
|
||||||
|
public_key,
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ProxyState {
|
||||||
|
/// Requests waiting to be picked up by browser
|
||||||
|
pub outgoing_requests: Mutex<Vec<RequestData>>,
|
||||||
|
/// Map of request ID to response sender
|
||||||
|
pub pending_responses: Mutex<PendingResponseMap>,
|
||||||
|
/// Last time the client asked for the pending requests
|
||||||
|
pub last_pending_request: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration options for [`BrowserSignerProxy`].
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BrowserSignerProxyOptions {
|
||||||
|
/// Request timeout for the signer extension. Default is 30 seconds.
|
||||||
|
pub timeout: Duration,
|
||||||
|
/// Proxy server IP address and port. Default is `127.0.0.1:7400`.
|
||||||
|
pub addr: SocketAddr,
|
||||||
|
/// Custom HTML page.
|
||||||
|
// NOTE: not `Option` to move it between threads without reference counter
|
||||||
|
pub custom_html: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct InnerBrowserSignerProxy {
|
||||||
|
/// Configuration options for the proxy
|
||||||
|
options: BrowserSignerProxyOptions,
|
||||||
|
/// Internal state of the proxy including request queues
|
||||||
|
state: Arc<ProxyState>,
|
||||||
|
/// Notification trigger for graceful shutdown
|
||||||
|
shutdown: Arc<ShutdownEvent>,
|
||||||
|
/// Flag to indicate if the server is shutdown
|
||||||
|
is_shutdown: Arc<AtomicBool>,
|
||||||
|
/// Flag indicating if the server is started
|
||||||
|
is_started: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AtomicDestroyer for InnerBrowserSignerProxy {
|
||||||
|
fn on_destroy(&self) {
|
||||||
|
self.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InnerBrowserSignerProxy {
|
||||||
|
#[inline]
|
||||||
|
fn is_shutdown(&self) -> bool {
|
||||||
|
self.is_shutdown.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shutdown(&self) {
|
||||||
|
// Mark the server as shutdown
|
||||||
|
self.is_shutdown.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
// Notify all waiters that the proxy is shutting down
|
||||||
|
self.shutdown.notify(usize::MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nostr Browser Signer Proxy
|
||||||
|
///
|
||||||
|
/// Proxy to use Nostr Browser signer (NIP-07) in native applications.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BrowserSignerProxy {
|
||||||
|
inner: AtomicDestructor<InnerBrowserSignerProxy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BrowserSignerProxyOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
timeout: Duration::from_secs(30),
|
||||||
|
// 7 for NIP-07 and 400 because the NIP title is 40 bytes :)
|
||||||
|
addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 7400)),
|
||||||
|
custom_html: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserSignerProxyOptions {
|
||||||
|
/// Sets the timeout duration.
|
||||||
|
pub const fn timeout(mut self, timeout: Duration) -> Self {
|
||||||
|
self.timeout = timeout;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the IP address.
|
||||||
|
pub const fn ip_addr(mut self, new_ip: IpAddr) -> Self {
|
||||||
|
self.addr = SocketAddr::new(new_ip, self.addr.port());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the port number.
|
||||||
|
pub const fn port(mut self, new_port: u16) -> Self {
|
||||||
|
self.addr = SocketAddr::new(self.addr.ip(), new_port);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a custom html page.
|
||||||
|
///
|
||||||
|
/// The page must include `/proxy.js` script (`<script src="/proxy.js"></script>`)
|
||||||
|
/// which will handle communication with the server and update the element
|
||||||
|
/// with id `nip07-proxy-status` with the status.
|
||||||
|
pub const fn custom_html_page(mut self, custom_html: &'static str) -> Self {
|
||||||
|
self.custom_html = custom_html;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserSignerProxy {
|
||||||
|
/// Construct a new browser signer proxy
|
||||||
|
pub fn new(options: BrowserSignerProxyOptions) -> Self {
|
||||||
|
let state = ProxyState {
|
||||||
|
outgoing_requests: Mutex::new(Vec::new()),
|
||||||
|
pending_responses: Mutex::new(HashMap::new()),
|
||||||
|
last_pending_request: Arc::new(AtomicU64::new(0)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
inner: AtomicDestructor::new(InnerBrowserSignerProxy {
|
||||||
|
options,
|
||||||
|
state: Arc::new(state),
|
||||||
|
shutdown: Arc::new(ShutdownEvent::new()),
|
||||||
|
is_shutdown: Arc::new(AtomicBool::new(false)),
|
||||||
|
is_started: Arc::new(AtomicBool::new(false)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indicates whether the server is currently running.
|
||||||
|
#[inline]
|
||||||
|
pub fn is_started(&self) -> bool {
|
||||||
|
self.inner.is_started.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if there is an open browser tab ready to respond to requests by
|
||||||
|
/// verifying the time since the last pending request.
|
||||||
|
#[inline]
|
||||||
|
pub fn is_session_active(&self) -> bool {
|
||||||
|
current_time() - self.inner.state.last_pending_request.load(Ordering::SeqCst) < 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the signer proxy webpage URL
|
||||||
|
#[inline]
|
||||||
|
pub fn url(&self) -> String {
|
||||||
|
format!("http://{}", self.inner.options.addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the proxy server.
|
||||||
|
///
|
||||||
|
/// If this is not called explicitly, the server will be automatically
|
||||||
|
/// started on the first interaction with the signer.
|
||||||
|
pub async fn start(&self) -> Result<(), Error> {
|
||||||
|
// Ensure is not shutdown
|
||||||
|
if self.inner.is_shutdown() {
|
||||||
|
return Err(Error::shutdown());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the proxy as started and check if was already started
|
||||||
|
let is_started: bool = self.inner.is_started.swap(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
// Immediately return if already started
|
||||||
|
if is_started {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener: TcpListener = match TcpListener::bind(self.inner.options.addr).await {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(e) => {
|
||||||
|
// Undo the started flag if binding fails
|
||||||
|
self.inner.is_started.store(false, Ordering::SeqCst);
|
||||||
|
return Err(Error::from(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let addr: SocketAddr = self.inner.options.addr;
|
||||||
|
let state: Arc<ProxyState> = self.inner.state.clone();
|
||||||
|
let custom_html: &'static str = self.inner.options.custom_html;
|
||||||
|
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
|
||||||
|
|
||||||
|
smol::spawn(async move {
|
||||||
|
tracing::info!("Starting proxy server on {addr}");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Race between accepting a new connection and shutdown signal
|
||||||
|
let shutdown_listener = shutdown.listen();
|
||||||
|
|
||||||
|
enum AcceptEvent {
|
||||||
|
Connection(Result<(TcpStream, SocketAddr), std::io::Error>),
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
let event = smol::future::or(
|
||||||
|
async { AcceptEvent::Connection(listener.accept().await) },
|
||||||
|
async {
|
||||||
|
shutdown_listener.await;
|
||||||
|
AcceptEvent::Shutdown
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match event {
|
||||||
|
AcceptEvent::Connection(Ok((stream, _))) => {
|
||||||
|
let state: Arc<ProxyState> = state.clone();
|
||||||
|
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
|
||||||
|
|
||||||
|
smol::spawn(async move {
|
||||||
|
let shutdown_listener = shutdown.listen();
|
||||||
|
|
||||||
|
smol::future::or(
|
||||||
|
async {
|
||||||
|
handle_connection(stream, state, custom_html).await;
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
shutdown_listener.await;
|
||||||
|
tracing::debug!(
|
||||||
|
"Closing connection, proxy server is shutting down."
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
AcceptEvent::Connection(Err(e)) => {
|
||||||
|
tracing::error!("Failed to accept connection: {e}");
|
||||||
|
}
|
||||||
|
AcceptEvent::Shutdown => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Proxy server shut down.");
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn store_pending_response(&self, id: Uuid, tx: channel::Sender<Result<Value, String>>) {
|
||||||
|
let mut pending_responses = self.inner.state.pending_responses.lock().await;
|
||||||
|
pending_responses.insert(id, tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn store_outgoing_request(&self, request: RequestData) {
|
||||||
|
let mut outgoing_requests = self.inner.state.outgoing_requests.lock().await;
|
||||||
|
outgoing_requests.push(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
// Start the proxy if not already started
|
||||||
|
self.start().await?;
|
||||||
|
|
||||||
|
// Construct the request
|
||||||
|
let request: RequestData = RequestData::new(method, params);
|
||||||
|
|
||||||
|
// Create a bounded channel of size 1 as a oneshot replacement
|
||||||
|
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
|
||||||
|
|
||||||
|
// Store the response sender
|
||||||
|
self.store_pending_response(request.id, tx).await;
|
||||||
|
|
||||||
|
// Add to outgoing requests queue
|
||||||
|
self.store_outgoing_request(request).await;
|
||||||
|
|
||||||
|
// Wait for response with timeout
|
||||||
|
let response = race_timeout(self.inner.options.timeout, rx.recv()).await;
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
|
||||||
|
Ok(Err(error)) => Err(Error::generic(error)),
|
||||||
|
Err(TimeoutError) => Err(Error::timeout()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn _get_public_key(&self) -> Result<PublicKey, Error> {
|
||||||
|
self.request(RequestMethod::GetPublicKey, json!({})).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
|
||||||
|
let event: Event = self
|
||||||
|
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
|
||||||
|
.await?;
|
||||||
|
event.verify()?;
|
||||||
|
Ok(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn _nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||||
|
let params = CryptoParams::new(public_key, content);
|
||||||
|
self.request(RequestMethod::Nip04Encrypt, serde_json::to_value(params)?)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn _nip04_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||||
|
let params = CryptoParams::new(public_key, content);
|
||||||
|
self.request(RequestMethod::Nip04Decrypt, serde_json::to_value(params)?)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn _nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||||
|
let params = CryptoParams::new(public_key, content);
|
||||||
|
self.request(RequestMethod::Nip44Encrypt, serde_json::to_value(params)?)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
async fn _nip44_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||||
|
let params = CryptoParams::new(public_key, content);
|
||||||
|
self.request(RequestMethod::Nip44Decrypt, serde_json::to_value(params)?)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncGetPublicKey for BrowserSignerProxy {
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn get_public_key_async(
|
||||||
|
&self,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
|
||||||
|
Box::pin(async move { self._get_public_key().await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncSignEvent for BrowserSignerProxy {
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn sign_event_async(
|
||||||
|
&self,
|
||||||
|
unsigned: UnsignedEvent,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
|
||||||
|
Box::pin(async move { self._sign_event(unsigned).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncNip04 for BrowserSignerProxy {
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn nip04_encrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
content: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||||
|
Box::pin(async move { self._nip04_encrypt(public_key, content).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nip04_decrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
encrypted_content: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||||
|
Box::pin(async move { self._nip04_decrypt(public_key, encrypted_content).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncNip44 for BrowserSignerProxy {
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn nip44_encrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
content: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||||
|
Box::pin(async move { self._nip44_encrypt(public_key, content).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nip44_decrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
payload: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||||
|
Box::pin(async move { self._nip44_decrypt(public_key, payload).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Minimal HTTP server ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Handle a single HTTP connection.
|
||||||
|
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
|
||||||
|
let mut reader = BufReader::new(stream);
|
||||||
|
|
||||||
|
// Read the request line
|
||||||
|
let mut request_line = String::new();
|
||||||
|
if reader.read_line(&mut request_line).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let request_line = request_line.trim_end().to_string();
|
||||||
|
|
||||||
|
// Parse method, path, and HTTP version from request line
|
||||||
|
let parts: Vec<&str> = request_line.split_whitespace().collect();
|
||||||
|
if parts.len() < 2 {
|
||||||
|
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let method = parts[0].to_uppercase();
|
||||||
|
let path = parts[1].to_string();
|
||||||
|
|
||||||
|
// Read headers until empty line
|
||||||
|
let mut headers = Vec::new();
|
||||||
|
let mut content_length: usize = 0;
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
if reader.read_line(&mut line).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let line = line.trim_end().to_string();
|
||||||
|
if line.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(value) = line.strip_prefix("content-length:") {
|
||||||
|
content_length = value.trim().parse().unwrap_or(0);
|
||||||
|
} else if let Some(value) = line.strip_prefix("Content-Length:") {
|
||||||
|
content_length = value.trim().parse().unwrap_or(0);
|
||||||
|
}
|
||||||
|
headers.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
match (method.as_str(), path.as_str()) {
|
||||||
|
// Serve the HTML proxy page
|
||||||
|
("GET", "/") => {
|
||||||
|
let html = if custom_html.is_empty() {
|
||||||
|
DEFAULT_HTML
|
||||||
|
} else {
|
||||||
|
custom_html
|
||||||
|
};
|
||||||
|
send_response(&mut reader, 200, "OK", "text/html", html).await;
|
||||||
|
}
|
||||||
|
// Serve the JS proxy script
|
||||||
|
("GET", "/proxy.js") => {
|
||||||
|
send_response(&mut reader, 200, "OK", "application/javascript", JS).await;
|
||||||
|
}
|
||||||
|
// Browser polls this endpoint to get pending requests
|
||||||
|
("GET", "/api/pending") => {
|
||||||
|
state
|
||||||
|
.last_pending_request
|
||||||
|
.store(current_time(), Ordering::SeqCst);
|
||||||
|
|
||||||
|
let mut outgoing = state.outgoing_requests.lock().await;
|
||||||
|
|
||||||
|
let requests = Requests::new(&outgoing);
|
||||||
|
let json = match serde_json::to_string(&requests) {
|
||||||
|
Ok(j) => j,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to serialize pending requests: {e}");
|
||||||
|
send_response(&mut reader, 500, "Internal Server Error", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::debug!("Sending {} pending requests to browser", requests.len());
|
||||||
|
|
||||||
|
// Clear the outgoing requests after sending them
|
||||||
|
outgoing.clear();
|
||||||
|
|
||||||
|
send_response_cors_json(&mut reader, 200, "OK", &json).await;
|
||||||
|
}
|
||||||
|
// Receive response from browser extension
|
||||||
|
("POST", "/api/response") => {
|
||||||
|
let mut body_bytes = vec![0u8; content_length];
|
||||||
|
if content_length > 0 && reader.read_exact(&mut body_bytes).await.is_err() {
|
||||||
|
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message: Message = match serde_json::from_slice(&body_bytes) {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to parse response body: {e}");
|
||||||
|
send_response(&mut reader, 400, "Invalid JSON", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::debug!("Received response from browser: {message:?}");
|
||||||
|
|
||||||
|
let id: Uuid = message.id;
|
||||||
|
let mut pending = state.pending_responses.lock().await;
|
||||||
|
|
||||||
|
match pending.remove(&id) {
|
||||||
|
Some(sender) => {
|
||||||
|
// Use try_send since we already hold the lock
|
||||||
|
let _ = sender.try_send(message.into_result());
|
||||||
|
tracing::info!("Forwarded response for request {id}");
|
||||||
|
}
|
||||||
|
None => tracing::warn!("No pending request found for {id}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
|
||||||
|
}
|
||||||
|
// CORS preflight
|
||||||
|
("OPTIONS", _) => {
|
||||||
|
let response = "HTTP/1.1 200 OK\r\n\
|
||||||
|
Access-Control-Allow-Origin: *\r\n\
|
||||||
|
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
|
||||||
|
Access-Control-Allow-Headers: Content-Type\r\n\
|
||||||
|
Content-Length: 0\r\n\
|
||||||
|
Connection: close\r\n\
|
||||||
|
\r\n";
|
||||||
|
let _ = reader.get_mut().write_all(response.as_bytes()).await;
|
||||||
|
let _ = reader.get_mut().flush().await;
|
||||||
|
}
|
||||||
|
// 404 - not found
|
||||||
|
_ => {
|
||||||
|
send_response(&mut reader, 404, "Not Found", "", "").await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write an HTTP response to the stream.
|
||||||
|
async fn send_response(
|
||||||
|
stream: &mut (impl AsyncWriteExt + Unpin),
|
||||||
|
status: u16,
|
||||||
|
status_text: &str,
|
||||||
|
content_type: &str,
|
||||||
|
body: &str,
|
||||||
|
) {
|
||||||
|
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
||||||
|
|
||||||
|
if !content_type.is_empty() {
|
||||||
|
response.push_str(&format!("Content-Type: {content_type}\r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||||
|
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
||||||
|
response.push_str("Connection: close\r\n");
|
||||||
|
response.push_str("\r\n");
|
||||||
|
response.push_str(body);
|
||||||
|
|
||||||
|
let _ = stream.write_all(response.as_bytes()).await;
|
||||||
|
let _ = stream.flush().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a response with CORS headers and JSON content type.
|
||||||
|
async fn send_response_cors_json(
|
||||||
|
stream: &mut (impl AsyncWriteExt + Unpin),
|
||||||
|
status: u16,
|
||||||
|
status_text: &str,
|
||||||
|
body: &str,
|
||||||
|
) {
|
||||||
|
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
||||||
|
response.push_str("Content-Type: application/json\r\n");
|
||||||
|
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||||
|
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
||||||
|
response.push_str("Connection: close\r\n");
|
||||||
|
response.push_str("\r\n");
|
||||||
|
response.push_str(body);
|
||||||
|
|
||||||
|
let _ = stream.write_all(response.as_bytes()).await;
|
||||||
|
let _ = stream.flush().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a response with CORS headers.
|
||||||
|
async fn send_response_cors(
|
||||||
|
stream: &mut (impl AsyncWriteExt + Unpin),
|
||||||
|
status: u16,
|
||||||
|
status_text: &str,
|
||||||
|
content_type: &str,
|
||||||
|
body: &str,
|
||||||
|
) {
|
||||||
|
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
||||||
|
|
||||||
|
if !content_type.is_empty() {
|
||||||
|
response.push_str(&format!("Content-Type: {content_type}\r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||||
|
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
||||||
|
response.push_str("Connection: close\r\n");
|
||||||
|
response.push_str("\r\n");
|
||||||
|
response.push_str(body);
|
||||||
|
|
||||||
|
let _ = stream.write_all(response.as_bytes()).await;
|
||||||
|
let _ = stream.flush().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Timeout helper ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// An error indicating that an operation timed out.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TimeoutError;
|
||||||
|
|
||||||
|
/// Races a channel receive against a duration.
|
||||||
|
///
|
||||||
|
/// Returns the channel value on success, or [`TimeoutError`] if the duration
|
||||||
|
/// elapses first or the channel is closed.
|
||||||
|
async fn race_timeout<T>(
|
||||||
|
duration: Duration,
|
||||||
|
recv: impl Future<Output = Result<T, channel::RecvError>>,
|
||||||
|
) -> Result<T, TimeoutError> {
|
||||||
|
enum Event<T> {
|
||||||
|
Value(T),
|
||||||
|
ChannelClosed,
|
||||||
|
Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
let event = smol::future::or(
|
||||||
|
async {
|
||||||
|
match recv.await {
|
||||||
|
Ok(value) => Event::Value(value),
|
||||||
|
Err(_) => Event::ChannelClosed,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
smol::Timer::after(duration).await;
|
||||||
|
Event::Timeout
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match event {
|
||||||
|
Event::Value(value) => Ok(value),
|
||||||
|
Event::ChannelClosed | Event::Timeout => Err(TimeoutError),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utility ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
|
||||||
|
/// time is before the epoch, returns 0.
|
||||||
|
#[inline]
|
||||||
|
fn current_time() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Copyright (c) 2022-2023 Yuki Kishimoto
|
||||||
|
// Copyright (c) 2023-2025 Rust Nostr Developers
|
||||||
|
// Distributed under the MIT software license
|
||||||
|
|
||||||
|
//! Prelude
|
||||||
|
|
||||||
|
#![allow(unknown_lints)]
|
||||||
|
#![allow(ambiguous_glob_reexports)]
|
||||||
|
#![doc(hidden)]
|
||||||
|
|
||||||
|
pub use nostr::prelude::*;
|
||||||
|
|
||||||
|
pub use crate::error::{Error, ErrorKind};
|
||||||
|
pub use crate::*;
|
||||||
@@ -13,15 +13,16 @@ settings = { path = "../settings" }
|
|||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
instant.workspace = true
|
||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
smol.workspace = true
|
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
futures.workspace = true
|
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
|
|
||||||
|
futures.workspace = true
|
||||||
fuzzy-matcher = "0.3.7"
|
fuzzy-matcher = "0.3.7"
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
smol.workspace = true
|
||||||
|
|||||||
+402
-411
File diff suppressed because it is too large
Load Diff
+127
-157
@@ -1,8 +1,110 @@
|
|||||||
use std::hash::Hash;
|
use std::hash::Hash;
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
use common::EventUtils;
|
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
||||||
|
use gpui::{SharedString, SharedUri};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
/// Rendered message.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Message {
|
||||||
|
pub id: EventId,
|
||||||
|
/// Author's public key
|
||||||
|
pub author: PublicKey,
|
||||||
|
/// The content/text of the message
|
||||||
|
pub content: String,
|
||||||
|
/// List of media URLs in the message
|
||||||
|
pub media: Vec<SharedUri>,
|
||||||
|
/// Message created time as unix timestamp
|
||||||
|
pub created_at: Timestamp,
|
||||||
|
/// List of mentioned public keys in the message
|
||||||
|
pub mentions: Vec<Mention>,
|
||||||
|
/// List of event of the message this message is a reply to
|
||||||
|
pub replies_to: Vec<EventId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Event> for Message {
|
||||||
|
fn from(val: &Event) -> Self {
|
||||||
|
let mentions = extract_mentions(&val.content);
|
||||||
|
let replies_to = extract_reply_ids(&val.tags);
|
||||||
|
let (media, string) = extract_and_remove_media_urls(&val.content);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id: val.id,
|
||||||
|
author: val.pubkey,
|
||||||
|
content: string,
|
||||||
|
media,
|
||||||
|
created_at: val.created_at,
|
||||||
|
mentions,
|
||||||
|
replies_to,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&UnsignedEvent> for Message {
|
||||||
|
fn from(val: &UnsignedEvent) -> Self {
|
||||||
|
let mentions = extract_mentions(&val.content);
|
||||||
|
let replies_to = extract_reply_ids(&val.tags);
|
||||||
|
let (media, string) = extract_and_remove_media_urls(&val.content);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
// Event ID must be known
|
||||||
|
id: val.id.unwrap(),
|
||||||
|
author: val.pubkey,
|
||||||
|
content: string,
|
||||||
|
media,
|
||||||
|
created_at: val.created_at,
|
||||||
|
mentions,
|
||||||
|
replies_to,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&NewMessage> for Message {
|
||||||
|
fn from(val: &NewMessage) -> Self {
|
||||||
|
let mentions = extract_mentions(&val.rumor.content);
|
||||||
|
let replies_to = extract_reply_ids(&val.rumor.tags);
|
||||||
|
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
// Event ID must be known
|
||||||
|
id: val.rumor.id.unwrap(),
|
||||||
|
author: val.rumor.pubkey,
|
||||||
|
content: string,
|
||||||
|
media,
|
||||||
|
created_at: val.rumor.created_at,
|
||||||
|
mentions,
|
||||||
|
replies_to,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Eq for Message {}
|
||||||
|
|
||||||
|
impl PartialEq for Message {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.id == other.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for Message {
|
||||||
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
|
self.created_at.cmp(&other.created_at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for Message {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hash for Message {
|
||||||
|
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||||
|
self.id.hash(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// New message.
|
/// New message.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct NewMessage {
|
pub struct NewMessage {
|
||||||
@@ -23,194 +125,62 @@ impl NewMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Message.
|
/// Trash message.
|
||||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum Message {
|
pub struct FailedMessage {
|
||||||
User(RenderedMessage),
|
pub raw_event: SharedString,
|
||||||
Warning(String, Timestamp),
|
pub reason: SharedString,
|
||||||
System(Timestamp),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Message {
|
impl FailedMessage {
|
||||||
pub fn user<I>(user: I) -> Self
|
pub fn new<T>(event: &Event, reason: T) -> Self
|
||||||
where
|
where
|
||||||
I: Into<RenderedMessage>,
|
T: Into<SharedString>,
|
||||||
{
|
{
|
||||||
Self::User(user.into())
|
Self {
|
||||||
}
|
raw_event: SharedString::from(event.as_json()),
|
||||||
|
reason: reason.into(),
|
||||||
pub fn warning<I>(content: I) -> Self
|
|
||||||
where
|
|
||||||
I: Into<String>,
|
|
||||||
{
|
|
||||||
Self::Warning(content.into(), Timestamp::now())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn system() -> Self {
|
|
||||||
Self::System(Timestamp::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn timestamp(&self) -> &Timestamp {
|
|
||||||
match self {
|
|
||||||
Message::User(msg) => &msg.created_at,
|
|
||||||
Message::Warning(_, ts) => ts,
|
|
||||||
Message::System(ts) => ts,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&NewMessage> for Message {
|
|
||||||
fn from(val: &NewMessage) -> Self {
|
|
||||||
Self::User(val.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&UnsignedEvent> for Message {
|
|
||||||
fn from(val: &UnsignedEvent) -> Self {
|
|
||||||
Self::User(val.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Ord for Message {
|
|
||||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
|
||||||
match (self, other) {
|
|
||||||
// System always comes first
|
|
||||||
(Message::System(_), Message::System(_)) => self.timestamp().cmp(other.timestamp()),
|
|
||||||
(Message::System(_), _) => std::cmp::Ordering::Less,
|
|
||||||
(_, Message::System(_)) => std::cmp::Ordering::Greater,
|
|
||||||
|
|
||||||
// For non-system messages, compare by timestamp
|
|
||||||
_ => self.timestamp().cmp(other.timestamp()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PartialOrd for Message {
|
|
||||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
|
||||||
Some(self.cmp(other))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rendered message.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RenderedMessage {
|
pub struct Mention {
|
||||||
pub id: EventId,
|
pub public_key: PublicKey,
|
||||||
/// Author's public key
|
pub range: Range<usize>,
|
||||||
pub author: PublicKey,
|
|
||||||
/// The content/text of the message
|
|
||||||
pub content: String,
|
|
||||||
/// Message created time as unix timestamp
|
|
||||||
pub created_at: Timestamp,
|
|
||||||
/// List of mentioned public keys in the message
|
|
||||||
pub mentions: Vec<PublicKey>,
|
|
||||||
/// List of event of the message this message is a reply to
|
|
||||||
pub replies_to: Vec<EventId>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&Event> for RenderedMessage {
|
impl Mention {
|
||||||
fn from(val: &Event) -> Self {
|
pub fn new(public_key: PublicKey, range: Range<usize>) -> Self {
|
||||||
let mentions = extract_mentions(&val.content);
|
Self { public_key, range }
|
||||||
let replies_to = extract_reply_ids(&val.tags);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
id: val.id,
|
|
||||||
author: val.pubkey,
|
|
||||||
content: val.content.clone(),
|
|
||||||
created_at: val.created_at,
|
|
||||||
mentions,
|
|
||||||
replies_to,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&UnsignedEvent> for RenderedMessage {
|
|
||||||
fn from(val: &UnsignedEvent) -> Self {
|
|
||||||
let mentions = extract_mentions(&val.content);
|
|
||||||
let replies_to = extract_reply_ids(&val.tags);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
// Event ID must be known
|
|
||||||
id: val.id.unwrap(),
|
|
||||||
author: val.pubkey,
|
|
||||||
content: val.content.clone(),
|
|
||||||
created_at: val.created_at,
|
|
||||||
mentions,
|
|
||||||
replies_to,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&NewMessage> for RenderedMessage {
|
|
||||||
fn from(val: &NewMessage) -> Self {
|
|
||||||
let mentions = extract_mentions(&val.rumor.content);
|
|
||||||
let replies_to = extract_reply_ids(&val.rumor.tags);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
// Event ID must be known
|
|
||||||
id: val.rumor.id.unwrap(),
|
|
||||||
author: val.rumor.pubkey,
|
|
||||||
content: val.rumor.content.clone(),
|
|
||||||
created_at: val.rumor.created_at,
|
|
||||||
mentions,
|
|
||||||
replies_to,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Eq for RenderedMessage {}
|
|
||||||
|
|
||||||
impl PartialEq for RenderedMessage {
|
|
||||||
fn eq(&self, other: &Self) -> bool {
|
|
||||||
self.id == other.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Ord for RenderedMessage {
|
|
||||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
|
||||||
self.created_at.cmp(&other.created_at)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PartialOrd for RenderedMessage {
|
|
||||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
|
||||||
Some(self.cmp(other))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Hash for RenderedMessage {
|
|
||||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
|
||||||
self.id.hash(state);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts all mentions (public keys) from a content string.
|
/// Extracts all mentions (public keys) from a content string.
|
||||||
fn extract_mentions(content: &str) -> Vec<PublicKey> {
|
fn extract_mentions(content: &str) -> Vec<Mention> {
|
||||||
let parser = NostrParser::new();
|
let parser = NostrParser::new();
|
||||||
let tokens = parser.parse(content);
|
let tokens = parser.parse(content);
|
||||||
|
|
||||||
tokens
|
tokens
|
||||||
.filter_map(|token| match token {
|
.filter_map(|token| match token.value {
|
||||||
Token::Nostr(nip21) => match nip21 {
|
Nip21::Pubkey(public_key) => Some(Mention::new(public_key, token.range)),
|
||||||
Nip21::Pubkey(pubkey) => Some(pubkey),
|
Nip21::Profile(profile) => Some(Mention::new(profile.public_key, token.range)),
|
||||||
Nip21::Profile(profile) => Some(profile.public_key),
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts all reply (ids) from the event tags.
|
/// Extracts all reply (ids) from the event tags.
|
||||||
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
||||||
let mut replies_to = vec![];
|
let mut replies_to = vec![];
|
||||||
|
|
||||||
for tag in inner.filter(TagKind::e()) {
|
for tag in inner.iter().filter(|tag| tag.kind() == "e") {
|
||||||
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
|
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
|
||||||
replies_to.push(id);
|
replies_to.push(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for tag in inner.filter(TagKind::q()) {
|
for tag in inner.iter().filter(|tag| tag.kind() == "q") {
|
||||||
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
|
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
|
||||||
replies_to.push(id);
|
replies_to.push(id);
|
||||||
}
|
}
|
||||||
|
|||||||
+206
-152
@@ -1,16 +1,16 @@
|
|||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::{Error, anyhow};
|
||||||
use common::EventUtils;
|
use common::EventExt;
|
||||||
|
use device::DeviceRegistry;
|
||||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||||
|
use instant::Duration;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry};
|
use person::{Person, PersonRegistry};
|
||||||
use settings::{RoomConfig, SignerKind};
|
use settings::{RoomConfig, SignerKind};
|
||||||
use state::{NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
||||||
|
|
||||||
use crate::NewMessage;
|
use crate::NewMessage;
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ pub struct SendReport {
|
|||||||
pub receiver: PublicKey,
|
pub receiver: PublicKey,
|
||||||
pub gift_wrap_id: Option<EventId>,
|
pub gift_wrap_id: Option<EventId>,
|
||||||
pub error: Option<SharedString>,
|
pub error: Option<SharedString>,
|
||||||
pub output: Option<Output<EventId>>,
|
pub output: Option<Output<EventId, EventSendStatus>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SendReport {
|
impl SendReport {
|
||||||
@@ -42,7 +42,7 @@ impl SendReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set the output.
|
/// Set the output.
|
||||||
pub fn output(mut self, output: Output<EventId>) -> Self {
|
pub fn output(mut self, output: Output<EventId, EventSendStatus>) -> Self {
|
||||||
self.output = Some(output);
|
self.output = Some(output);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -58,16 +58,44 @@ impl SendReport {
|
|||||||
|
|
||||||
/// Returns true if the send is pending.
|
/// Returns true if the send is pending.
|
||||||
pub fn pending(&self) -> bool {
|
pub fn pending(&self) -> bool {
|
||||||
self.output.is_none() && self.error.is_none()
|
self.error.is_none()
|
||||||
|
&& self
|
||||||
|
.output
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|o| o.success.is_empty() && o.failed.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the send was successful.
|
/// Returns true if the send was successful.
|
||||||
pub fn success(&self) -> bool {
|
pub fn success(&self) -> bool {
|
||||||
if let Some(output) = self.output.as_ref() {
|
self.error.is_none() && self.output.as_ref().is_some_and(|o| !o.success.is_empty())
|
||||||
!output.failed.is_empty()
|
}
|
||||||
} else {
|
|
||||||
false
|
/// Returns true if the send failed.
|
||||||
}
|
pub fn failed(&self) -> bool {
|
||||||
|
self.error.is_some() && self.output.as_ref().is_some_and(|o| !o.failed.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SendStatus {
|
||||||
|
Ok {
|
||||||
|
id: EventId,
|
||||||
|
relay: RelayUrl,
|
||||||
|
},
|
||||||
|
Failed {
|
||||||
|
id: EventId,
|
||||||
|
relay: RelayUrl,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SendStatus {
|
||||||
|
pub fn ok(id: EventId, relay: RelayUrl) -> Self {
|
||||||
|
Self::Ok { id, relay }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn failed(id: EventId, relay: RelayUrl, message: String) -> Self {
|
||||||
|
Self::Failed { id, relay, message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +172,8 @@ impl From<&UnsignedEvent> for Room {
|
|||||||
let members = val.extract_public_keys();
|
let members = val.extract_public_keys();
|
||||||
let subject = val
|
let subject = val
|
||||||
.tags
|
.tags
|
||||||
.find(TagKind::Subject)
|
.iter()
|
||||||
|
.find(|tag| tag.kind() == "subject")
|
||||||
.and_then(|tag| tag.content().map(|s| s.to_owned().into()));
|
.and_then(|tag| tag.content().map(|s| s.to_owned().into()));
|
||||||
|
|
||||||
Room {
|
Room {
|
||||||
@@ -178,7 +207,7 @@ impl Room {
|
|||||||
// WARNING: never sign this event
|
// WARNING: never sign this event
|
||||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
|
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
.build(author);
|
.finalize_unsigned(author);
|
||||||
|
|
||||||
// Ensure that the ID is set
|
// Ensure that the ID is set
|
||||||
event.ensure_id();
|
event.ensure_id();
|
||||||
@@ -205,10 +234,8 @@ impl Room {
|
|||||||
|
|
||||||
/// Sets this room is ongoing conversation
|
/// Sets this room is ongoing conversation
|
||||||
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
|
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.kind != RoomKind::Ongoing {
|
self.kind = RoomKind::Ongoing;
|
||||||
self.kind = RoomKind::Ongoing;
|
cx.notify();
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the creation timestamp of the room
|
/// Updates the creation timestamp of the room
|
||||||
@@ -244,8 +271,8 @@ impl Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the members of the room
|
/// Returns the members of the room
|
||||||
pub fn members(&self) -> Vec<PublicKey> {
|
pub fn members(&self) -> &[PublicKey] {
|
||||||
self.members.clone()
|
&self.members
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if the room has more than two members (group)
|
/// Checks if the room has more than two members (group)
|
||||||
@@ -329,46 +356,39 @@ impl Room {
|
|||||||
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
let members = self.members().to_vec();
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
let sender = signer.public_key().unwrap();
|
|
||||||
|
|
||||||
// Get room's id
|
|
||||||
let id = self.id;
|
|
||||||
|
|
||||||
// Get all members, excluding the sender
|
|
||||||
let members: Vec<PublicKey> = self
|
|
||||||
.members
|
|
||||||
.iter()
|
|
||||||
.filter(|public_key| public_key != &&sender)
|
|
||||||
.copied()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let id = SubscriptionId::new(format!("room-{id}"));
|
|
||||||
let opts = SubscribeAutoCloseOptions::default()
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||||
.timeout(Some(Duration::from_secs(TIMEOUT)));
|
.timeout(Some(Duration::from_secs(TIMEOUT)));
|
||||||
|
|
||||||
// Construct filters for each member
|
let tasks: Vec<_> = members
|
||||||
let filters: Vec<Filter> = members
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|public_key| {
|
.map(|public_key| {
|
||||||
Filter::new()
|
let client = client.clone();
|
||||||
.author(public_key)
|
async move {
|
||||||
.kind(Kind::RelayList)
|
let inbox = Filter::new()
|
||||||
.limit(1)
|
.author(public_key)
|
||||||
|
.kind(Kind::InboxRelays)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let announcement = Filter::new()
|
||||||
|
.author(public_key)
|
||||||
|
.kind(Kind::Custom(10044))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
client
|
||||||
|
.subscribe(vec![inbox, announcement])
|
||||||
|
.close_on(opts)
|
||||||
|
.await
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Construct target for subscription
|
for result in futures::future::join_all(tasks).await {
|
||||||
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
result?;
|
||||||
.into_iter()
|
}
|
||||||
.map(|relay| (relay, filters.clone()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Subscribe to the target
|
|
||||||
client.subscribe(target).close_on(opts).with_id(id).await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -378,12 +398,12 @@ impl Room {
|
|||||||
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
|
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let conversation_id = self.id.to_string();
|
let room_id = self.id.to_string();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id);
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::R), room_id);
|
||||||
|
|
||||||
let messages = client
|
let messages = client
|
||||||
.database()
|
.database()
|
||||||
@@ -399,12 +419,23 @@ impl Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Construct a rumor event for direct message
|
// Construct a rumor event for direct message
|
||||||
pub fn rumor<S, I>(&self, content: S, replies: I, cx: &App) -> Option<UnsignedEvent>
|
pub fn rumor<S, I>(
|
||||||
|
&self,
|
||||||
|
content: S,
|
||||||
|
replies: I,
|
||||||
|
reaction: bool,
|
||||||
|
cx: &App,
|
||||||
|
) -> Option<UnsignedEvent>
|
||||||
where
|
where
|
||||||
S: Into<String>,
|
S: Into<String>,
|
||||||
I: IntoIterator<Item = EventId>,
|
I: IntoIterator<Item = EventId>,
|
||||||
{
|
{
|
||||||
let kind = Kind::PrivateDirectMessage;
|
let kind = if reaction {
|
||||||
|
Kind::Reaction
|
||||||
|
} else {
|
||||||
|
Kind::PrivateDirectMessage
|
||||||
|
};
|
||||||
|
|
||||||
let content: String = content.into();
|
let content: String = content.into();
|
||||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
let replies: Vec<EventId> = replies.into_iter().collect();
|
||||||
|
|
||||||
@@ -412,24 +443,14 @@ impl Room {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
// Get current user's public key
|
// Get current user's public key
|
||||||
let sender = nostr.read(cx).signer().public_key()?;
|
let sender = nostr.read(cx).current_user()?;
|
||||||
|
|
||||||
// Get all members, excluding the sender
|
|
||||||
let members: Vec<Person> = self
|
|
||||||
.members
|
|
||||||
.iter()
|
|
||||||
.filter(|public_key| public_key != &&sender)
|
|
||||||
.map(|member| persons.read(cx).get(member, cx))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Construct event's tags
|
// Construct event's tags
|
||||||
let mut tags = vec![];
|
let mut tags = vec![];
|
||||||
|
|
||||||
// Add subject tag if present
|
// Add subject tag if present
|
||||||
if let Some(value) = self.subject.as_ref() {
|
if let Some(value) = self.subject.as_ref() {
|
||||||
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
|
tags.push(Tag::custom("subject", vec![value.to_string()]));
|
||||||
value.to_string(),
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all reply tags
|
// Add all reply tags
|
||||||
@@ -437,21 +458,23 @@ impl Room {
|
|||||||
tags.push(Tag::event(id))
|
tags.push(Tag::event(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all receiver tags
|
// Add all receiver tags (no intermediate allocation)
|
||||||
for member in members.into_iter() {
|
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
|
||||||
tags.push(Tag::from_standardized_without_cell(
|
let member = persons.read(cx).get(public_key, cx);
|
||||||
TagStandard::PublicKey {
|
tags.push(
|
||||||
|
Nip01Tag::PublicKey {
|
||||||
public_key: member.public_key(),
|
public_key: member.public_key(),
|
||||||
relay_url: member.messaging_relay_hint(),
|
relay_hint: member.messaging_relay_hint(),
|
||||||
alias: None,
|
}
|
||||||
uppercase: false,
|
.to_tag(),
|
||||||
},
|
);
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct a direct message rumor event
|
// Construct a direct message rumor event
|
||||||
// WARNING: never sign and send this event to relays
|
// WARNING: never sign and send this event to relays
|
||||||
let mut event = EventBuilder::new(kind, content).tags(tags).build(sender);
|
let mut event = EventBuilder::new(kind, content)
|
||||||
|
.tags(tags)
|
||||||
|
.finalize_unsigned(sender);
|
||||||
|
|
||||||
// Ensure that the ID is set
|
// Ensure that the ID is set
|
||||||
event.ensure_id();
|
event.ensure_id();
|
||||||
@@ -459,17 +482,45 @@ impl Room {
|
|||||||
Some(event)
|
Some(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Select the appropriate signer based on signer kind and available keys.
|
||||||
|
fn select_signer(
|
||||||
|
signer_kind: &SignerKind,
|
||||||
|
has_announcement: bool,
|
||||||
|
encryption_signer: &Option<UniversalSigner>,
|
||||||
|
user_signer: &UniversalSigner,
|
||||||
|
) -> UniversalSigner {
|
||||||
|
match signer_kind {
|
||||||
|
SignerKind::Auto => {
|
||||||
|
if has_announcement {
|
||||||
|
encryption_signer
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| user_signer.clone())
|
||||||
|
} else {
|
||||||
|
user_signer.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SignerKind::Encryption => encryption_signer
|
||||||
|
.clone()
|
||||||
|
.expect("encryption signer must be set"),
|
||||||
|
SignerKind::User => user_signer.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Send rumor event to all members's messaging relays
|
/// Send rumor event to all members's messaging relays
|
||||||
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
|
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
|
||||||
let config = self.config.clone();
|
let config = self.config.clone();
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
|
let device = DeviceRegistry::global(cx);
|
||||||
|
let encryption_signer = device.read(cx).signer(cx);
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let user_signer = nostr.read(cx).signer();
|
||||||
|
let current_user = nostr.read(cx).current_user()?;
|
||||||
|
|
||||||
// Get current user's public key
|
// Get sender's profile
|
||||||
let public_key = nostr.read(cx).signer().public_key()?;
|
let persons = PersonRegistry::global(cx);
|
||||||
let sender = persons.read(cx).get(&public_key, cx);
|
let sender = persons.read(cx).get(¤t_user, cx);
|
||||||
|
|
||||||
// Get all members (excluding sender)
|
// Get all members (excluding sender)
|
||||||
let members: Vec<Person> = self
|
let members: Vec<Person> = self
|
||||||
@@ -483,65 +534,45 @@ impl Room {
|
|||||||
let signer_kind = config.signer_kind();
|
let signer_kind = config.signer_kind();
|
||||||
let backup = config.backup();
|
let backup = config.backup();
|
||||||
|
|
||||||
let user_signer = signer.get().await;
|
|
||||||
let encryption_signer = signer.get_encryption_signer().await;
|
|
||||||
|
|
||||||
let mut sents = 0;
|
let mut sents = 0;
|
||||||
let mut reports = Vec::new();
|
let mut reports = Vec::new();
|
||||||
|
|
||||||
// Process each member
|
// Process each member
|
||||||
for member in members {
|
for member in members {
|
||||||
let relays = member.messaging_relays();
|
|
||||||
let announcement = member.announcement();
|
let announcement = member.announcement();
|
||||||
let public_key = member.public_key();
|
let public_key = member.public_key();
|
||||||
|
|
||||||
if relays.is_empty() {
|
|
||||||
reports.push(SendReport::new(public_key).error("No messaging relays"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle encryption signer requirements
|
// Handle encryption signer requirements
|
||||||
if signer_kind.encryption() {
|
if signer_kind.encryption() {
|
||||||
|
// Receiver didn't set up a decoupled encryption key
|
||||||
if announcement.is_none() {
|
if announcement.is_none() {
|
||||||
reports.push(SendReport::new(public_key).error(NO_DEKEY));
|
reports.push(SendReport::new(public_key).error(NO_DEKEY));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sender didn't set up a decoupled encryption key
|
||||||
if encryption_signer.is_none() {
|
if encryption_signer.is_none() {
|
||||||
reports.push(SendReport::new(sender.public_key()).error(USER_NO_DEKEY));
|
reports.push(SendReport::new(sender.public_key()).error(USER_NO_DEKEY));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine receiver and signer
|
// Determine the signer to use
|
||||||
let (receiver, signer) = match signer_kind {
|
let signer = Self::select_signer(
|
||||||
SignerKind::Auto => {
|
signer_kind,
|
||||||
if let Some(announcement) = announcement {
|
announcement.is_some(),
|
||||||
if let Some(enc_signer) = encryption_signer.as_ref() {
|
&encryption_signer,
|
||||||
(announcement.public_key(), enc_signer.clone())
|
&user_signer,
|
||||||
} else {
|
);
|
||||||
(member.public_key(), user_signer.clone())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(member.public_key(), user_signer.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SignerKind::Encryption => {
|
|
||||||
// Safe to unwrap due to earlier checks
|
|
||||||
(
|
|
||||||
announcement.unwrap().public_key(),
|
|
||||||
encryption_signer.as_ref().unwrap().clone(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
SignerKind::User => (member.public_key(), user_signer.clone()),
|
|
||||||
};
|
|
||||||
|
|
||||||
match send_gift_wrap(&client, &signer, &receiver, &rumor, relays, public_key).await
|
// Send the gift wrap event and collect the report
|
||||||
{
|
match send_gift_wrap(&client, &signer, &member, &rumor, signer_kind).await {
|
||||||
Ok((report, _)) => {
|
Ok(report) => {
|
||||||
reports.push(report);
|
reports.push(report);
|
||||||
sents += 1;
|
sents += 1;
|
||||||
}
|
}
|
||||||
Err(report) => {
|
Err(error) => {
|
||||||
|
let report = SendReport::new(public_key).error(error.to_string());
|
||||||
reports.push(report);
|
reports.push(report);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -549,14 +580,22 @@ impl Room {
|
|||||||
|
|
||||||
// Send backup to current user if needed
|
// Send backup to current user if needed
|
||||||
if backup && sents >= 1 {
|
if backup && sents >= 1 {
|
||||||
let relays = sender.messaging_relays();
|
|
||||||
let public_key = sender.public_key();
|
let public_key = sender.public_key();
|
||||||
let signer = encryption_signer.as_ref().unwrap_or(&user_signer);
|
|
||||||
|
|
||||||
match send_gift_wrap(&client, signer, &public_key, &rumor, relays, public_key).await
|
// Determine the signer to use
|
||||||
{
|
let signer = Self::select_signer(
|
||||||
Ok((report, _)) => reports.push(report),
|
signer_kind,
|
||||||
Err(report) => reports.push(report),
|
sender.announcement().is_some(),
|
||||||
|
&encryption_signer,
|
||||||
|
&user_signer,
|
||||||
|
);
|
||||||
|
|
||||||
|
match send_gift_wrap(&client, &signer, &sender, &rumor, signer_kind).await {
|
||||||
|
Ok(report) => reports.push(report),
|
||||||
|
Err(error) => {
|
||||||
|
let report = SendReport::new(public_key).error(error.to_string());
|
||||||
|
reports.push(report);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,39 +605,54 @@ impl Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to send a gift-wrapped event
|
// Helper function to send a gift-wrapped event
|
||||||
async fn send_gift_wrap<T>(
|
async fn send_gift_wrap(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
signer: &T,
|
signer: &UniversalSigner,
|
||||||
receiver: &PublicKey,
|
receiver: &Person,
|
||||||
rumor: &UnsignedEvent,
|
rumor: &UnsignedEvent,
|
||||||
relays: &[RelayUrl],
|
config: &SignerKind,
|
||||||
public_key: PublicKey,
|
) -> Result<SendReport, Error> {
|
||||||
) -> Result<(SendReport, bool), SendReport>
|
let k_tag = Tag::custom("k", vec!["14"]);
|
||||||
where
|
let mut extra_tags = vec![k_tag];
|
||||||
T: NostrSigner + 'static,
|
|
||||||
{
|
|
||||||
// Ensure relay connections
|
|
||||||
for url in relays {
|
|
||||||
client.add_relay(url).and_connect().await.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
match EventBuilder::gift_wrap(signer, receiver, rumor.clone(), []).await {
|
// Determine the receiver public key based on the config
|
||||||
Ok(event) => {
|
let receiver = match config {
|
||||||
match client
|
SignerKind::Auto => {
|
||||||
.send_event(&event)
|
if let Some(announcement) = receiver.announcement().as_ref() {
|
||||||
.to(relays)
|
extra_tags.push(Tag::public_key(receiver.public_key()));
|
||||||
.ack_policy(AckPolicy::none())
|
announcement.public_key()
|
||||||
.await
|
} else {
|
||||||
{
|
receiver.public_key()
|
||||||
Ok(output) => Ok((
|
|
||||||
SendReport::new(public_key)
|
|
||||||
.gift_wrap_id(event.id)
|
|
||||||
.output(output),
|
|
||||||
true,
|
|
||||||
)),
|
|
||||||
Err(e) => Err(SendReport::new(public_key).error(e.to_string())),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => Err(SendReport::new(public_key).error(e.to_string())),
|
SignerKind::Encryption => {
|
||||||
}
|
if let Some(announcement) = receiver.announcement().as_ref() {
|
||||||
|
extra_tags.push(Tag::public_key(receiver.public_key()));
|
||||||
|
announcement.public_key()
|
||||||
|
} else {
|
||||||
|
return Err(anyhow!("User has no encryption announcement"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SignerKind::User => receiver.public_key(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Construct the gift wrap event
|
||||||
|
let event = nip59::GiftWrapBuilder::new(receiver, rumor.clone())
|
||||||
|
.extra_tags(extra_tags)
|
||||||
|
.finalize_async(signer)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Send the gift wrap event and collect the report
|
||||||
|
let report = client
|
||||||
|
.send_event(&event)
|
||||||
|
.to_nip17()
|
||||||
|
.ack_policy(AckPolicy::none())
|
||||||
|
.await
|
||||||
|
.map(|output| {
|
||||||
|
SendReport::new(receiver)
|
||||||
|
.gift_wrap_id(event.id)
|
||||||
|
.output(output)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,17 +14,16 @@ chat = { path = "../chat" }
|
|||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui_tokio.workspace = true
|
|
||||||
|
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
smol.workspace = true
|
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
|
||||||
|
|
||||||
once_cell = "1.19.0"
|
linkify = "0.10.0"
|
||||||
|
pulldown-cmark = "0.13.1"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -7,21 +7,11 @@ use settings::SignerKind;
|
|||||||
#[action(namespace = chat, no_json)]
|
#[action(namespace = chat, no_json)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
Insert(&'static str),
|
Insert(&'static str),
|
||||||
ChangeSubject(&'static str),
|
ChangeSubject(String),
|
||||||
ChangeSigner(SignerKind),
|
ChangeSigner(SignerKind),
|
||||||
ToggleBackup,
|
ToggleBackup,
|
||||||
|
Copy(PublicKey),
|
||||||
|
Relays(PublicKey),
|
||||||
|
Njump(PublicKey),
|
||||||
|
Trace(EventId),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
|
||||||
#[action(namespace = chat, no_json)]
|
|
||||||
pub struct SeenOn(pub EventId);
|
|
||||||
|
|
||||||
/// Define a open public key action
|
|
||||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
|
|
||||||
#[action(namespace = pubkey, no_json)]
|
|
||||||
pub struct OpenPublicKey(pub PublicKey);
|
|
||||||
|
|
||||||
/// Define a copy inline public key action
|
|
||||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
|
|
||||||
#[action(namespace = pubkey, no_json)]
|
|
||||||
pub struct CopyPublicKey(pub PublicKey);
|
|
||||||
|
|||||||
+743
-373
File diff suppressed because it is too large
Load Diff
@@ -1,60 +0,0 @@
|
|||||||
use gpui::{
|
|
||||||
div, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
|
|
||||||
Styled, Window,
|
|
||||||
};
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::input::{InputState, TextInput};
|
|
||||||
use ui::{v_flex, Sizable};
|
|
||||||
|
|
||||||
pub fn init(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Subject> {
|
|
||||||
cx.new(|cx| Subject::new(subject, window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Subject {
|
|
||||||
input: Entity<InputState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Subject {
|
|
||||||
pub fn new(subject: Option<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("Plan for holiday"));
|
|
||||||
|
|
||||||
if let Some(value) = subject {
|
|
||||||
input.update(cx, |this, cx| {
|
|
||||||
this.set_value(value, window, cx);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
Self { input }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_subject(&self, cx: &App) -> SharedString {
|
|
||||||
self.input.read(cx).value()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for Subject {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("Subject:")),
|
|
||||||
)
|
|
||||||
.child(TextInput::new(&self.input).small()),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.italic()
|
|
||||||
.text_color(cx.theme().text_placeholder)
|
|
||||||
.child(SharedString::from(
|
|
||||||
"Subject will be updated when you send a new message.",
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+281
-205
@@ -1,29 +1,29 @@
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chat::Mention;
|
||||||
|
use common::RangeExt;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString,
|
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||||
StyledText, UnderlineStyle, Window,
|
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use regex::Regex;
|
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::actions::OpenPublicKey;
|
#[allow(clippy::enum_variant_names)]
|
||||||
|
#[allow(dead_code)]
|
||||||
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
|
|
||||||
Regex::new(r"(?i)(?:^|\s)(?:https?://)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:/[^\s]*)?(?:\s|$)").unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
static NOSTR_URI_REGEX: Lazy<Regex> =
|
|
||||||
Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap());
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Highlight {
|
pub enum Highlight {
|
||||||
Link,
|
Code,
|
||||||
Nostr,
|
InlineCode(bool),
|
||||||
|
Highlight(HighlightStyle),
|
||||||
|
Mention,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<HighlightStyle> for Highlight {
|
||||||
|
fn from(style: HighlightStyle) -> Self {
|
||||||
|
Self::Highlight(style)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -35,7 +35,12 @@ pub struct RenderedText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RenderedText {
|
impl RenderedText {
|
||||||
pub fn new(content: &str, cx: &App) -> Self {
|
pub fn new(
|
||||||
|
content: &str,
|
||||||
|
mentions: &[Mention],
|
||||||
|
persons: &Entity<PersonRegistry>,
|
||||||
|
cx: &App,
|
||||||
|
) -> Self {
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
let mut highlights = Vec::new();
|
let mut highlights = Vec::new();
|
||||||
let mut link_ranges = Vec::new();
|
let mut link_ranges = Vec::new();
|
||||||
@@ -43,10 +48,12 @@ impl RenderedText {
|
|||||||
|
|
||||||
render_plain_text_mut(
|
render_plain_text_mut(
|
||||||
content,
|
content,
|
||||||
|
mentions,
|
||||||
&mut text,
|
&mut text,
|
||||||
&mut highlights,
|
&mut highlights,
|
||||||
&mut link_ranges,
|
&mut link_ranges,
|
||||||
&mut link_urls,
|
&mut link_urls,
|
||||||
|
persons,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -61,7 +68,8 @@ impl RenderedText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||||
let link_color = cx.theme().text_accent;
|
let code_background = cx.theme().elevated_surface_background;
|
||||||
|
let color = cx.theme().text_accent;
|
||||||
|
|
||||||
InteractiveText::new(
|
InteractiveText::new(
|
||||||
id,
|
id,
|
||||||
@@ -71,15 +79,36 @@ impl RenderedText {
|
|||||||
(
|
(
|
||||||
range.clone(),
|
range.clone(),
|
||||||
match highlight {
|
match highlight {
|
||||||
Highlight::Link => HighlightStyle {
|
Highlight::Code => HighlightStyle {
|
||||||
color: Some(link_color),
|
background_color: Some(code_background),
|
||||||
underline: Some(UnderlineStyle::default()),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
Highlight::Nostr => HighlightStyle {
|
Highlight::InlineCode(link) => {
|
||||||
color: Some(link_color),
|
if *link {
|
||||||
|
HighlightStyle {
|
||||||
|
background_color: Some(code_background),
|
||||||
|
underline: Some(UnderlineStyle {
|
||||||
|
thickness: 1.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HighlightStyle {
|
||||||
|
background_color: Some(code_background),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Highlight::Mention => HighlightStyle {
|
||||||
|
color: Some(color),
|
||||||
|
underline: Some(UnderlineStyle {
|
||||||
|
thickness: 1.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
|
Highlight::Highlight(highlight) => *highlight,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
@@ -87,22 +116,10 @@ impl RenderedText {
|
|||||||
)
|
)
|
||||||
.on_click(self.link_ranges.clone(), {
|
.on_click(self.link_ranges.clone(), {
|
||||||
let link_urls = self.link_urls.clone();
|
let link_urls = self.link_urls.clone();
|
||||||
move |ix, window, cx| {
|
move |ix, _, cx| {
|
||||||
let token = link_urls[ix].as_str();
|
let url = &link_urls[ix];
|
||||||
|
if url.starts_with("http") {
|
||||||
if let Some(clean_url) = token.strip_prefix("nostr:") {
|
cx.open_url(url);
|
||||||
if let Ok(public_key) = PublicKey::parse(clean_url) {
|
|
||||||
window.dispatch_action(Box::new(OpenPublicKey(public_key)), cx);
|
|
||||||
}
|
|
||||||
} else if is_url(token) {
|
|
||||||
let url = if token.starts_with("http") {
|
|
||||||
token.to_string()
|
|
||||||
} else {
|
|
||||||
format!("https://{token}")
|
|
||||||
};
|
|
||||||
cx.open_url(&url);
|
|
||||||
} else {
|
|
||||||
log::warn!("Unrecognized token {token}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -110,214 +127,273 @@ impl RenderedText {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_plain_text_mut(
|
fn render_plain_text_mut(
|
||||||
content: &str,
|
block: &str,
|
||||||
|
mut mentions: &[Mention],
|
||||||
text: &mut String,
|
text: &mut String,
|
||||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||||
link_ranges: &mut Vec<Range<usize>>,
|
link_ranges: &mut Vec<Range<usize>>,
|
||||||
link_urls: &mut Vec<String>,
|
link_urls: &mut Vec<String>,
|
||||||
|
persons: &Entity<PersonRegistry>,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) {
|
) {
|
||||||
// Copy the content directly
|
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||||
text.push_str(content);
|
|
||||||
|
|
||||||
// Collect all URLs
|
let mut bold_depth = 0;
|
||||||
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
|
let mut italic_depth = 0;
|
||||||
|
let mut strikethrough_depth = 0;
|
||||||
|
let mut link_url = None;
|
||||||
|
let mut list_stack = Vec::new();
|
||||||
|
|
||||||
for link in URL_REGEX.find_iter(content) {
|
let mut options = Options::all();
|
||||||
let range = link.start()..link.end();
|
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
|
||||||
let url = link.as_str().to_string();
|
|
||||||
|
|
||||||
url_matches.push((range, url));
|
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
|
||||||
}
|
let prev_len = text.len();
|
||||||
|
|
||||||
// Collect all nostr entities with nostr: prefix
|
match event {
|
||||||
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
|
Event::Text(t) => {
|
||||||
|
// Process text with mention replacements
|
||||||
|
let t_str = t.as_ref();
|
||||||
|
let mut last_processed = 0;
|
||||||
|
|
||||||
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
|
while let Some(mention) = mentions.first() {
|
||||||
let range = nostr_match.start()..nostr_match.end();
|
if !source_range.contains_inclusive(&mention.range) {
|
||||||
let nostr_uri = nostr_match.as_str().to_string();
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this nostr URI overlaps with any already processed URL
|
// Calculate positions within the current text
|
||||||
if !url_matches
|
let mention_start_in_text = mention.range.start - source_range.start;
|
||||||
.iter()
|
let mention_end_in_text = mention.range.end - source_range.start;
|
||||||
.any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end)
|
|
||||||
{
|
|
||||||
nostr_matches.push((range, nostr_uri));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine all matches for processing from end to start
|
// Add text before this mention
|
||||||
let mut all_matches = Vec::new();
|
if mention_start_in_text > last_processed {
|
||||||
all_matches.extend(url_matches);
|
let before_mention = &t_str[last_processed..mention_start_in_text];
|
||||||
all_matches.extend(nostr_matches);
|
process_text_segment(
|
||||||
|
before_mention,
|
||||||
|
prev_len + last_processed,
|
||||||
|
bold_depth,
|
||||||
|
italic_depth,
|
||||||
|
strikethrough_depth,
|
||||||
|
link_url.clone(),
|
||||||
|
text,
|
||||||
|
highlights,
|
||||||
|
link_ranges,
|
||||||
|
link_urls,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Sort by position (end to start) to avoid changing positions when replacing text
|
// Process the mention replacement
|
||||||
all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start));
|
let profile = persons.read(cx).get(&mention.public_key, cx);
|
||||||
|
let replacement_text = format!("@{}", profile.name());
|
||||||
|
|
||||||
// Process all matches
|
let replacement_start = text.len();
|
||||||
for (range, entity) in all_matches {
|
text.push_str(&replacement_text);
|
||||||
// Handle URL token
|
let replacement_end = text.len();
|
||||||
if is_url(&entity) {
|
|
||||||
highlights.push((range.clone(), Highlight::Link));
|
|
||||||
link_ranges.push(range);
|
|
||||||
link_urls.push(entity);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Ok(nip21) = Nip21::parse(&entity) {
|
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
||||||
match nip21 {
|
|
||||||
Nip21::Pubkey(public_key) => {
|
last_processed = mention_end_in_text;
|
||||||
render_pubkey(
|
mentions = &mentions[1..];
|
||||||
public_key,
|
|
||||||
text,
|
|
||||||
&range,
|
|
||||||
highlights,
|
|
||||||
link_ranges,
|
|
||||||
link_urls,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Nip21::Profile(nip19_profile) => {
|
|
||||||
render_pubkey(
|
// Add any remaining text after the last mention
|
||||||
nip19_profile.public_key,
|
if last_processed < t_str.len() {
|
||||||
|
let remaining_text = &t_str[last_processed..];
|
||||||
|
process_text_segment(
|
||||||
|
remaining_text,
|
||||||
|
prev_len + last_processed,
|
||||||
|
bold_depth,
|
||||||
|
italic_depth,
|
||||||
|
strikethrough_depth,
|
||||||
|
link_url.clone(),
|
||||||
text,
|
text,
|
||||||
&range,
|
|
||||||
highlights,
|
|
||||||
link_ranges,
|
|
||||||
link_urls,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Nip21::EventId(event_id) => {
|
|
||||||
render_bech32(
|
|
||||||
event_id.to_bech32().unwrap(),
|
|
||||||
text,
|
|
||||||
&range,
|
|
||||||
highlights,
|
|
||||||
link_ranges,
|
|
||||||
link_urls,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Nip21::Event(nip19_event) => {
|
|
||||||
render_bech32(
|
|
||||||
nip19_event.to_bech32().unwrap(),
|
|
||||||
text,
|
|
||||||
&range,
|
|
||||||
highlights,
|
|
||||||
link_ranges,
|
|
||||||
link_urls,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Nip21::Coordinate(nip19_coordinate) => {
|
|
||||||
render_bech32(
|
|
||||||
nip19_coordinate.to_bech32().unwrap(),
|
|
||||||
text,
|
|
||||||
&range,
|
|
||||||
highlights,
|
highlights,
|
||||||
link_ranges,
|
link_ranges,
|
||||||
link_urls,
|
link_urls,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Event::Code(t) => {
|
||||||
|
text.push_str(t.as_ref());
|
||||||
|
let is_link = link_url.is_some();
|
||||||
|
|
||||||
|
if let Some(link_url) = link_url.clone() {
|
||||||
|
link_ranges.push(prev_len..text.len());
|
||||||
|
link_urls.push(link_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
|
||||||
|
}
|
||||||
|
Event::Start(tag) => match tag {
|
||||||
|
Tag::Paragraph => new_paragraph(text, &mut list_stack),
|
||||||
|
Tag::Heading { .. } => {
|
||||||
|
new_paragraph(text, &mut list_stack);
|
||||||
|
bold_depth += 1;
|
||||||
|
}
|
||||||
|
Tag::CodeBlock(_kind) => {
|
||||||
|
new_paragraph(text, &mut list_stack);
|
||||||
|
}
|
||||||
|
Tag::Emphasis => italic_depth += 1,
|
||||||
|
Tag::Strong => bold_depth += 1,
|
||||||
|
Tag::Strikethrough => strikethrough_depth += 1,
|
||||||
|
Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
|
||||||
|
Tag::List(number) => {
|
||||||
|
list_stack.push((number, false));
|
||||||
|
}
|
||||||
|
Tag::Item => {
|
||||||
|
let len = list_stack.len();
|
||||||
|
if let Some((list_number, has_content)) = list_stack.last_mut() {
|
||||||
|
*has_content = false;
|
||||||
|
if !text.is_empty() && !text.ends_with('\n') {
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
for _ in 0..len - 1 {
|
||||||
|
text.push_str(" ");
|
||||||
|
}
|
||||||
|
if let Some(number) = list_number {
|
||||||
|
text.push_str(&format!("{}. ", number));
|
||||||
|
*number += 1;
|
||||||
|
*has_content = false;
|
||||||
|
} else {
|
||||||
|
text.push_str("- ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Event::End(tag) => match tag {
|
||||||
|
TagEnd::Heading(_) => bold_depth -= 1,
|
||||||
|
TagEnd::Emphasis => italic_depth -= 1,
|
||||||
|
TagEnd::Strong => bold_depth -= 1,
|
||||||
|
TagEnd::Strikethrough => strikethrough_depth -= 1,
|
||||||
|
TagEnd::Link => link_url = None,
|
||||||
|
TagEnd::List(_) => drop(list_stack.pop()),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Event::HardBreak => text.push('\n'),
|
||||||
|
Event::SoftBreak => text.push('\n'),
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a string is a URL
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn is_url(s: &str) -> bool {
|
fn process_text_segment(
|
||||||
URL_REGEX.is_match(s)
|
segment: &str,
|
||||||
}
|
segment_start: usize,
|
||||||
|
bold_depth: i32,
|
||||||
|
italic_depth: i32,
|
||||||
|
strikethrough_depth: i32,
|
||||||
|
link_url: Option<String>,
|
||||||
|
text: &mut String,
|
||||||
|
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||||
|
link_ranges: &mut Vec<Range<usize>>,
|
||||||
|
link_urls: &mut Vec<String>,
|
||||||
|
) {
|
||||||
|
// Build the style for this segment
|
||||||
|
let mut style = HighlightStyle::default();
|
||||||
|
if bold_depth > 0 {
|
||||||
|
style.font_weight = Some(FontWeight::BOLD);
|
||||||
|
}
|
||||||
|
if italic_depth > 0 {
|
||||||
|
style.font_style = Some(FontStyle::Italic);
|
||||||
|
}
|
||||||
|
if strikethrough_depth > 0 {
|
||||||
|
style.strikethrough = Some(StrikethroughStyle {
|
||||||
|
thickness: 1.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Format a bech32 entity with ellipsis and last 4 characters
|
// Add the text
|
||||||
fn format_shortened_entity(entity: &str) -> String {
|
text.push_str(segment);
|
||||||
let prefix_end = entity.find('1').unwrap_or(0);
|
let text_end = text.len();
|
||||||
|
|
||||||
if prefix_end > 0 && entity.len() > prefix_end + 5 {
|
if let Some(link_url) = link_url {
|
||||||
let prefix = &entity[0..=prefix_end]; // Include the '1'
|
// Handle as a markdown link
|
||||||
let suffix = &entity[entity.len() - 4..]; // Last 4 chars
|
link_ranges.push(segment_start..text_end);
|
||||||
|
link_urls.push(link_url);
|
||||||
|
style.underline = Some(UnderlineStyle {
|
||||||
|
thickness: 1.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
format!("{prefix}...{suffix}")
|
// Add highlight for the entire linked segment
|
||||||
|
if style != HighlightStyle::default() {
|
||||||
|
highlights.push((segment_start..text_end, Highlight::Highlight(style)));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
entity.to_string()
|
// Handle link detection within the segment
|
||||||
}
|
let mut finder = linkify::LinkFinder::new();
|
||||||
}
|
finder.kinds(&[linkify::LinkKind::Url]);
|
||||||
|
let mut last_link_pos = 0;
|
||||||
|
|
||||||
fn render_pubkey(
|
for link in finder.links(segment) {
|
||||||
public_key: PublicKey,
|
let start = link.start();
|
||||||
text: &mut String,
|
let end = link.end();
|
||||||
range: &Range<usize>,
|
|
||||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
|
||||||
link_ranges: &mut Vec<Range<usize>>,
|
|
||||||
link_urls: &mut Vec<String>,
|
|
||||||
cx: &App,
|
|
||||||
) {
|
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
let profile = persons.read(cx).get(&public_key, cx);
|
|
||||||
let display_name = format!("@{}", profile.name());
|
|
||||||
|
|
||||||
text.replace_range(range.clone(), &display_name);
|
// Add non-link text before this link
|
||||||
|
if start > last_link_pos {
|
||||||
|
let non_link_start = segment_start + last_link_pos;
|
||||||
|
let non_link_end = segment_start + start;
|
||||||
|
|
||||||
let new_length = display_name.len();
|
if style != HighlightStyle::default() {
|
||||||
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
highlights.push((non_link_start..non_link_end, Highlight::Highlight(style)));
|
||||||
let new_range = range.start..(range.start + new_length);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
highlights.push((new_range.clone(), Highlight::Nostr));
|
// Add the link
|
||||||
link_ranges.push(new_range);
|
let range = (segment_start + start)..(segment_start + end);
|
||||||
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
|
link_ranges.push(range.clone());
|
||||||
|
link_urls.push(link.as_str().to_string());
|
||||||
|
|
||||||
if length_diff != 0 {
|
// Apply link styling (underline + existing style)
|
||||||
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
let mut link_style = style;
|
||||||
}
|
link_style.underline = Some(UnderlineStyle {
|
||||||
}
|
thickness: 1.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
fn render_bech32(
|
highlights.push((range, Highlight::Highlight(link_style)));
|
||||||
bech32: String,
|
|
||||||
text: &mut String,
|
|
||||||
range: &Range<usize>,
|
|
||||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
|
||||||
link_ranges: &mut Vec<Range<usize>>,
|
|
||||||
link_urls: &mut Vec<String>,
|
|
||||||
) {
|
|
||||||
let njump_url = format!("https://njump.me/{bech32}");
|
|
||||||
let shortened_entity = format_shortened_entity(&bech32);
|
|
||||||
let display_text = format!("https://njump.me/{shortened_entity}");
|
|
||||||
|
|
||||||
text.replace_range(range.clone(), &display_text);
|
last_link_pos = end;
|
||||||
|
|
||||||
let new_length = display_text.len();
|
|
||||||
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
|
||||||
let new_range = range.start..(range.start + new_length);
|
|
||||||
|
|
||||||
highlights.push((new_range.clone(), Highlight::Link));
|
|
||||||
link_ranges.push(new_range);
|
|
||||||
link_urls.push(njump_url);
|
|
||||||
|
|
||||||
if length_diff != 0 {
|
|
||||||
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to adjust ranges when text length changes
|
|
||||||
fn adjust_ranges(
|
|
||||||
highlights: &mut [(Range<usize>, Highlight)],
|
|
||||||
link_ranges: &mut [Range<usize>],
|
|
||||||
position: usize,
|
|
||||||
length_diff: isize,
|
|
||||||
) {
|
|
||||||
// Adjust highlight ranges
|
|
||||||
for (range, _) in highlights.iter_mut() {
|
|
||||||
if range.start > position {
|
|
||||||
range.start = (range.start as isize + length_diff) as usize;
|
|
||||||
range.end = (range.end as isize + length_diff) as usize;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust link ranges
|
// Add any remaining text after the last link
|
||||||
for range in link_ranges.iter_mut() {
|
if last_link_pos < segment.len() {
|
||||||
if range.start > position {
|
let remaining_start = segment_start + last_link_pos;
|
||||||
range.start = (range.start as isize + length_diff) as usize;
|
let remaining_end = segment_start + segment.len();
|
||||||
range.end = (range.end as isize + length_diff) as usize;
|
|
||||||
|
if style != HighlightStyle::default() {
|
||||||
|
highlights.push((remaining_start..remaining_end, Highlight::Highlight(style)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||||
|
let mut is_subsequent_paragraph_of_list = false;
|
||||||
|
if let Some((_, has_content)) = list_stack.last_mut() {
|
||||||
|
if *has_content {
|
||||||
|
is_subsequent_paragraph_of_list = true;
|
||||||
|
} else {
|
||||||
|
*has_content = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !text.is_empty() {
|
||||||
|
if !text.ends_with('\n') {
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
for _ in 0..list_stack.len().saturating_sub(1) {
|
||||||
|
text.push_str(" ");
|
||||||
|
}
|
||||||
|
if is_subsequent_paragraph_of_list {
|
||||||
|
text.push_str(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,15 +7,14 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
|
instant.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
|
||||||
anyhow.workspace = true
|
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
smallvec.workspace = true
|
|
||||||
smol.workspace = true
|
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
qrcode = "0.14.1"
|
qrcode = "0.14.1"
|
||||||
|
regex = "1.10"
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::mem::take;
|
||||||
|
|
||||||
|
use futures::FutureExt;
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
|
||||||
|
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn coop_cache(id: impl Into<ElementId>, max_items: usize) -> CoopImageCacheProvider {
|
||||||
|
CoopImageCacheProvider {
|
||||||
|
id: id.into(),
|
||||||
|
max_items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CoopImageCacheProvider {
|
||||||
|
id: ElementId,
|
||||||
|
max_items: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageCacheProvider for CoopImageCacheProvider {
|
||||||
|
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
|
||||||
|
window
|
||||||
|
.with_global_id(self.id.clone(), |id, window| {
|
||||||
|
window.with_element_state(id, |cache, _| {
|
||||||
|
let cache = cache.unwrap_or_else(|| CoopImageCache::new(self.max_items, cx));
|
||||||
|
(cache.clone(), cache)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CoopImageCache {
|
||||||
|
max_items: usize,
|
||||||
|
usage_list: VecDeque<u64>,
|
||||||
|
cache: HashMap<u64, (ImageCacheItem, Resource)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoopImageCache {
|
||||||
|
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
|
||||||
|
cx.new(|cx| {
|
||||||
|
log::info!("Creating CoopImageCache");
|
||||||
|
cx.on_release(|this: &mut Self, cx| {
|
||||||
|
for (ix, (mut image, resource)) in take(&mut this.cache) {
|
||||||
|
if let Some(Ok(image)) = image.get() {
|
||||||
|
log::info!("Dropping image {ix}");
|
||||||
|
cx.drop_image(image, None);
|
||||||
|
}
|
||||||
|
ImageSource::Resource(resource).remove_asset(cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
CoopImageCache {
|
||||||
|
max_items,
|
||||||
|
usage_list: VecDeque::with_capacity(max_items),
|
||||||
|
cache: HashMap::with_capacity(max_items),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageCache for CoopImageCache {
|
||||||
|
fn load(
|
||||||
|
&mut self,
|
||||||
|
resource: &Resource,
|
||||||
|
window: &mut gpui::Window,
|
||||||
|
cx: &mut gpui::App,
|
||||||
|
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
|
||||||
|
let hash = hash(resource);
|
||||||
|
|
||||||
|
if let Some(item) = self.cache.get_mut(&hash) {
|
||||||
|
let current_idx = self
|
||||||
|
.usage_list
|
||||||
|
.iter()
|
||||||
|
.position(|item| *item == hash)
|
||||||
|
.expect("cache has an item usage_list doesn't");
|
||||||
|
|
||||||
|
self.usage_list.remove(current_idx);
|
||||||
|
self.usage_list.push_front(hash);
|
||||||
|
|
||||||
|
return item.0.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
|
||||||
|
let task = cx.background_executor().spawn(load_future).shared();
|
||||||
|
|
||||||
|
if self.usage_list.len() >= self.max_items {
|
||||||
|
log::info!("Image cache is full, evicting oldest item");
|
||||||
|
|
||||||
|
if let Some(oldest) = self.usage_list.pop_back() {
|
||||||
|
let mut image = self
|
||||||
|
.cache
|
||||||
|
.remove(&oldest)
|
||||||
|
.expect("usage_list has an item cache doesn't");
|
||||||
|
|
||||||
|
if let Some(Ok(image)) = image.0.get() {
|
||||||
|
log::info!("requesting image to be dropped");
|
||||||
|
cx.drop_image(image, Some(window));
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageSource::Resource(image.1).remove_asset(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cache.insert(
|
||||||
|
hash,
|
||||||
|
(
|
||||||
|
gpui::ImageCacheItem::Loading(task.clone()),
|
||||||
|
resource.clone(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
self.usage_list.push_front(hash);
|
||||||
|
|
||||||
|
let entity = window.current_view();
|
||||||
|
|
||||||
|
window
|
||||||
|
.spawn(cx, async move |cx| {
|
||||||
|
let result = task.await;
|
||||||
|
|
||||||
|
if let Err(err) = result {
|
||||||
|
log::error!("error loading image into cache: {:?}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.on_next_frame(move |_, cx| {
|
||||||
|
cx.notify(entity);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::marker::PhantomData;
|
use std::marker::PhantomData;
|
||||||
use std::time::Duration;
|
use instant::Duration;
|
||||||
|
|
||||||
use futures::channel::oneshot;
|
use futures::channel::oneshot;
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
|
||||||
use chrono::{Local, TimeZone};
|
use chrono::{Local, TimeZone};
|
||||||
use gpui::{Image, ImageFormat, SharedString};
|
use gpui::{Image, ImageFormat, SharedString};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use qrcode::render::svg;
|
|
||||||
use qrcode::QrCode;
|
use qrcode::QrCode;
|
||||||
|
use qrcode::render::svg;
|
||||||
|
|
||||||
const NOW: &str = "now";
|
const NOW: &str = "now";
|
||||||
const SECONDS_IN_MINUTE: i64 = 60;
|
const SECONDS_IN_MINUTE: i64 = 60;
|
||||||
@@ -13,12 +12,12 @@ const MINUTES_IN_HOUR: i64 = 60;
|
|||||||
const HOURS_IN_DAY: i64 = 24;
|
const HOURS_IN_DAY: i64 = 24;
|
||||||
const DAYS_IN_MONTH: i64 = 30;
|
const DAYS_IN_MONTH: i64 = 30;
|
||||||
|
|
||||||
pub trait RenderedTimestamp {
|
pub trait TimestampExt {
|
||||||
fn to_human_time(&self) -> SharedString;
|
fn to_human_time(&self) -> SharedString;
|
||||||
fn to_ago(&self) -> SharedString;
|
fn to_ago(&self) -> SharedString;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderedTimestamp for Timestamp {
|
impl TimestampExt for Timestamp {
|
||||||
fn to_human_time(&self) -> SharedString {
|
fn to_human_time(&self) -> SharedString {
|
||||||
let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
|
let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
|
||||||
chrono::LocalResult::Single(time) => time,
|
chrono::LocalResult::Single(time) => time,
|
||||||
@@ -61,23 +60,11 @@ impl RenderedTimestamp for Timestamp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait TextUtils {
|
pub trait StringExt {
|
||||||
fn to_public_key(&self) -> Result<PublicKey, Error>;
|
|
||||||
fn to_qr(&self) -> Option<Arc<Image>>;
|
fn to_qr(&self) -> Option<Arc<Image>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: AsRef<str>> TextUtils for T {
|
impl<T: AsRef<str>> StringExt for T {
|
||||||
fn to_public_key(&self) -> Result<PublicKey, Error> {
|
|
||||||
let s = self.as_ref();
|
|
||||||
if s.starts_with("nprofile1") {
|
|
||||||
Ok(Nip19Profile::from_bech32(s)?.public_key)
|
|
||||||
} else if s.starts_with("npub1") {
|
|
||||||
Ok(PublicKey::parse(s)?)
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("Invalid public key"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_qr(&self) -> Option<Arc<Image>> {
|
fn to_qr(&self) -> Option<Arc<Image>> {
|
||||||
let s = self.as_ref();
|
let s = self.as_ref();
|
||||||
let code = QrCode::new(s).unwrap();
|
let code = QrCode::new(s).unwrap();
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ use std::hash::{DefaultHasher, Hash, Hasher};
|
|||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
pub trait EventUtils {
|
pub trait EventExt {
|
||||||
fn uniq_id(&self) -> u64;
|
fn uniq_id(&self) -> u64;
|
||||||
fn extract_public_keys(&self) -> Vec<PublicKey>;
|
fn extract_public_keys(&self) -> Vec<PublicKey>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventUtils for Event {
|
impl EventExt for Event {
|
||||||
fn uniq_id(&self) -> u64 {
|
fn uniq_id(&self) -> u64 {
|
||||||
let mut hasher = DefaultHasher::new();
|
let mut hasher = DefaultHasher::new();
|
||||||
let mut pubkeys: Vec<PublicKey> = self.extract_public_keys();
|
let mut pubkeys: Vec<PublicKey> = self.extract_public_keys();
|
||||||
@@ -18,14 +18,14 @@ impl EventUtils for Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
||||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
|
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
|
||||||
public_keys.push(self.pubkey);
|
public_keys.push(self.pubkey);
|
||||||
|
|
||||||
public_keys.into_iter().unique().collect()
|
public_keys.into_iter().unique().collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventUtils for UnsignedEvent {
|
impl EventExt for UnsignedEvent {
|
||||||
fn uniq_id(&self) -> u64 {
|
fn uniq_id(&self) -> u64 {
|
||||||
let mut hasher = DefaultHasher::new();
|
let mut hasher = DefaultHasher::new();
|
||||||
let mut pubkeys: Vec<PublicKey> = vec![];
|
let mut pubkeys: Vec<PublicKey> = vec![];
|
||||||
@@ -46,7 +46,7 @@ impl EventUtils for UnsignedEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
fn extract_public_keys(&self) -> Vec<PublicKey> {
|
||||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
|
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
|
||||||
public_keys.push(self.pubkey);
|
public_keys.push(self.pubkey);
|
||||||
public_keys.into_iter().unique().sorted().collect()
|
public_keys.into_iter().unique().sorted().collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
|
pub use caching::*;
|
||||||
pub use debounced_delay::*;
|
pub use debounced_delay::*;
|
||||||
pub use display::*;
|
pub use display::*;
|
||||||
pub use event::*;
|
pub use event::*;
|
||||||
|
pub use media_extractor::*;
|
||||||
|
pub use parser::*;
|
||||||
pub use paths::*;
|
pub use paths::*;
|
||||||
|
pub use range::*;
|
||||||
|
|
||||||
|
mod caching;
|
||||||
mod debounced_delay;
|
mod debounced_delay;
|
||||||
mod display;
|
mod display;
|
||||||
mod event;
|
mod event;
|
||||||
|
mod media_extractor;
|
||||||
|
mod parser;
|
||||||
mod paths;
|
mod paths;
|
||||||
|
mod range;
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
use gpui::SharedUri;
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
/// Extracts media URLs from a string and returns both the extracted URLs
|
||||||
|
/// and the string with media URLs removed
|
||||||
|
pub struct MediaExtractor {
|
||||||
|
image_regex: Regex,
|
||||||
|
video_regex: Regex,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaExtractor {
|
||||||
|
/// Creates a new MediaExtractor with compiled regex patterns
|
||||||
|
pub fn new() -> Self {
|
||||||
|
MediaExtractor {
|
||||||
|
// Match common image extensions
|
||||||
|
image_regex: Regex::new(
|
||||||
|
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
|
||||||
|
).unwrap(),
|
||||||
|
// Match common video extensions
|
||||||
|
video_regex: Regex::new(
|
||||||
|
r#"(?i)\bhttps?://[^\s<>"']+\.(?:mp4|mov|avi|mkv|webm|flv|wmv|m4v|3gp)(?:\?[^\s<>"']*)?\b"#,
|
||||||
|
).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts all media URLs from a string
|
||||||
|
pub fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
|
||||||
|
// Extract image URLs
|
||||||
|
for capture in self.image_regex.find_iter(text) {
|
||||||
|
urls.push(capture.as_str().to_string().into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract video URLs
|
||||||
|
// for capture in self.video_regex.find_iter(text) {
|
||||||
|
// urls.push(capture.as_str().to_string().into());
|
||||||
|
// }
|
||||||
|
|
||||||
|
urls
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes all media URLs from a string and returns the cleaned text
|
||||||
|
pub fn remove_media_urls(&self, text: &str) -> String {
|
||||||
|
let mut result = text.to_string();
|
||||||
|
|
||||||
|
// Remove image URLs
|
||||||
|
result = self.image_regex.replace_all(&result, "").to_string();
|
||||||
|
|
||||||
|
// Remove video URLs
|
||||||
|
// result = self.video_regex.replace_all(&result, "").to_string();
|
||||||
|
|
||||||
|
// Clean up extra whitespace that might result from removal
|
||||||
|
self.cleanup_text(&result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts media URLs and removes them from the string, returning both
|
||||||
|
pub fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
|
||||||
|
let urls = self.extract_media_urls(text);
|
||||||
|
let cleaned_text = self.remove_media_urls(text);
|
||||||
|
(urls, cleaned_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to clean up text after URL removal
|
||||||
|
fn cleanup_text(&self, text: &str) -> String {
|
||||||
|
let text = text.trim();
|
||||||
|
|
||||||
|
// Remove multiple consecutive spaces
|
||||||
|
let re = Regex::new(r"\s+").unwrap();
|
||||||
|
re.replace_all(text, " ").trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates if a URL is a valid media URL
|
||||||
|
pub fn is_media_url(&self, url: &str) -> bool {
|
||||||
|
self.image_regex.is_match(url) || self.video_regex.is_match(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Categorizes extracted URLs into images and videos
|
||||||
|
pub fn categorize_urls(&self, urls: &[SharedUri]) -> (Vec<SharedUri>, Vec<SharedUri>) {
|
||||||
|
let mut images = Vec::new();
|
||||||
|
let mut videos = Vec::new();
|
||||||
|
|
||||||
|
for url in urls {
|
||||||
|
if self.image_regex.is_match(url) {
|
||||||
|
images.push(url.clone());
|
||||||
|
} else if self.video_regex.is_match(url) {
|
||||||
|
videos.push(url.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(images, videos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MediaExtractor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience function for one-time extraction and removal
|
||||||
|
pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
|
||||||
|
let extractor = MediaExtractor::new();
|
||||||
|
extractor.extract_and_remove(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience function for just extracting media URLs
|
||||||
|
pub fn extract_media_urls(text: &str) -> Vec<SharedUri> {
|
||||||
|
let extractor = MediaExtractor::new();
|
||||||
|
extractor.extract_media_urls(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience function for just removing media URLs
|
||||||
|
pub fn remove_media_urls(text: &str) -> String {
|
||||||
|
let extractor = MediaExtractor::new();
|
||||||
|
extractor.remove_media_urls(text)
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
const BECH32_SEPARATOR: u8 = b'1';
|
||||||
|
const SCHEME_WITH_COLON: &str = "nostr:";
|
||||||
|
|
||||||
|
/// Nostr parsed token with its range in the original text
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Token {
|
||||||
|
/// The parsed NIP-21 URI
|
||||||
|
///
|
||||||
|
/// <https://github.com/nostr-protocol/nips/blob/master/21.md>
|
||||||
|
pub value: Nip21,
|
||||||
|
/// The range of this token in the original text
|
||||||
|
pub range: Range<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Match {
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nostr parser
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct NostrParser;
|
||||||
|
|
||||||
|
impl Default for NostrParser {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NostrParser {
|
||||||
|
/// Create new parser
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse text
|
||||||
|
pub fn parse<'a>(&self, text: &'a str) -> NostrParserIter<'a> {
|
||||||
|
NostrParserIter::new(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FindMatches<'a> {
|
||||||
|
bytes: &'a [u8],
|
||||||
|
pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> FindMatches<'a> {
|
||||||
|
fn new(text: &'a str) -> Self {
|
||||||
|
Self {
|
||||||
|
bytes: text.as_bytes(),
|
||||||
|
pos: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_parse_nostr_uri(&mut self) -> Option<Match> {
|
||||||
|
let start = self.pos;
|
||||||
|
let bytes = self.bytes;
|
||||||
|
let len = bytes.len();
|
||||||
|
|
||||||
|
// Check if we have "nostr:" prefix
|
||||||
|
if len - start < SCHEME_WITH_COLON.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for "nostr:" prefix (case-insensitive)
|
||||||
|
let scheme_prefix = &bytes[start..start + SCHEME_WITH_COLON.len()];
|
||||||
|
if !scheme_prefix.eq_ignore_ascii_case(SCHEME_WITH_COLON.as_bytes()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the scheme
|
||||||
|
let pos = start + SCHEME_WITH_COLON.len();
|
||||||
|
|
||||||
|
// Parse bech32 entity
|
||||||
|
let mut has_separator = false;
|
||||||
|
let mut end = pos;
|
||||||
|
|
||||||
|
while end < len {
|
||||||
|
let byte = bytes[end];
|
||||||
|
|
||||||
|
// Check for bech32 separator
|
||||||
|
if byte == BECH32_SEPARATOR && !has_separator {
|
||||||
|
has_separator = true;
|
||||||
|
end += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if character is valid for bech32
|
||||||
|
if !byte.is_ascii_alphanumeric() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have at least one character after separator
|
||||||
|
if !has_separator || end <= pos + 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update position
|
||||||
|
self.pos = end;
|
||||||
|
|
||||||
|
Some(Match { start, end })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iterator for FindMatches<'_> {
|
||||||
|
type Item = Match;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
while self.pos < self.bytes.len() {
|
||||||
|
// Try to parse nostr URI
|
||||||
|
if let Some(mat) = self.try_parse_nostr_uri() {
|
||||||
|
return Some(mat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip one character if no match found
|
||||||
|
self.pos += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HandleMatch {
|
||||||
|
Token(Token),
|
||||||
|
Recursion,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NostrParserIter<'a> {
|
||||||
|
/// The original text
|
||||||
|
text: &'a str,
|
||||||
|
/// Matches found
|
||||||
|
matches: FindMatches<'a>,
|
||||||
|
/// A pending match
|
||||||
|
pending_match: Option<Match>,
|
||||||
|
/// Last match end index
|
||||||
|
last_match_end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> NostrParserIter<'a> {
|
||||||
|
fn new(text: &'a str) -> Self {
|
||||||
|
Self {
|
||||||
|
text,
|
||||||
|
matches: FindMatches::new(text),
|
||||||
|
pending_match: None,
|
||||||
|
last_match_end: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_match(&mut self, mat: Match) -> HandleMatch {
|
||||||
|
// Update last match end
|
||||||
|
self.last_match_end = mat.end;
|
||||||
|
|
||||||
|
// Extract the matched string
|
||||||
|
let data: &str = &self.text[mat.start..mat.end];
|
||||||
|
|
||||||
|
// Parse NIP-21 URI
|
||||||
|
match Nip21::parse(data) {
|
||||||
|
Ok(uri) => HandleMatch::Token(Token {
|
||||||
|
value: uri,
|
||||||
|
range: mat.start..mat.end,
|
||||||
|
}),
|
||||||
|
// If the nostr URI parsing is invalid, skip it
|
||||||
|
Err(_) => HandleMatch::Recursion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Iterator for NostrParserIter<'a> {
|
||||||
|
type Item = Token;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
// Handle a pending match
|
||||||
|
if let Some(pending_match) = self.pending_match.take() {
|
||||||
|
return match self.handle_match(pending_match) {
|
||||||
|
HandleMatch::Token(token) => Some(token),
|
||||||
|
HandleMatch::Recursion => self.next(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.matches.next() {
|
||||||
|
Some(mat) => {
|
||||||
|
// Skip any text before this match
|
||||||
|
if mat.start > self.last_match_end {
|
||||||
|
// Update pending match
|
||||||
|
// This will be handled at next iteration, in `handle_match` method.
|
||||||
|
self.pending_match = Some(mat);
|
||||||
|
|
||||||
|
// Skip the text before the match
|
||||||
|
self.last_match_end = mat.start;
|
||||||
|
return self.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle match
|
||||||
|
match self.handle_match(mat) {
|
||||||
|
HandleMatch::Token(token) => Some(token),
|
||||||
|
HandleMatch::Recursion => self.next(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,13 @@ pub fn home_dir() -> &'static PathBuf {
|
|||||||
HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
|
HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the path to the user's download directory.
|
||||||
|
pub fn download_dir() -> &'static PathBuf {
|
||||||
|
static DOWNLOAD_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
DOWNLOAD_DIR
|
||||||
|
.get_or_init(|| dirs::download_dir().expect("failed to determine download directory"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the path to the configuration directory used by Coop.
|
/// Returns the path to the configuration directory used by Coop.
|
||||||
pub fn config_dir() -> &'static PathBuf {
|
pub fn config_dir() -> &'static PathBuf {
|
||||||
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
@@ -56,9 +63,3 @@ pub fn support_dir() -> &'static PathBuf {
|
|||||||
config_dir().clone()
|
config_dir().clone()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the path to the `nostr` file.
|
|
||||||
pub fn nostr_file() -> &'static PathBuf {
|
|
||||||
static NOSTR_FILE: OnceLock<PathBuf> = OnceLock::new();
|
|
||||||
NOSTR_FILE.get_or_init(|| support_dir().join("nostr-db"))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
use std::cmp::{self};
|
||||||
|
use std::ops::{Range, RangeInclusive};
|
||||||
|
|
||||||
|
pub trait RangeExt<T> {
|
||||||
|
fn sorted(&self) -> Self;
|
||||||
|
fn to_inclusive(&self) -> RangeInclusive<T>;
|
||||||
|
fn overlaps(&self, other: &Range<T>) -> bool;
|
||||||
|
fn contains_inclusive(&self, other: &Range<T>) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Ord + Clone> RangeExt<T> for Range<T> {
|
||||||
|
fn sorted(&self) -> Self {
|
||||||
|
cmp::min(&self.start, &self.end).clone()..cmp::max(&self.start, &self.end).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_inclusive(&self) -> RangeInclusive<T> {
|
||||||
|
self.start.clone()..=self.end.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlaps(&self, other: &Range<T>) -> bool {
|
||||||
|
self.start < other.end && other.start < self.end
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_inclusive(&self, other: &Range<T>) -> bool {
|
||||||
|
self.start <= other.start && other.end <= self.end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Ord + Clone> RangeExt<T> for RangeInclusive<T> {
|
||||||
|
fn sorted(&self) -> Self {
|
||||||
|
cmp::min(self.start(), self.end()).clone()..=cmp::max(self.start(), self.end()).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_inclusive(&self) -> RangeInclusive<T> {
|
||||||
|
self.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlaps(&self, other: &Range<T>) -> bool {
|
||||||
|
self.start() < &other.end && &other.start <= self.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_inclusive(&self, other: &Range<T>) -> bool {
|
||||||
|
self.start() <= &other.start && &other.end <= self.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "coop"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
publish.workspace = true
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "coop"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[package.metadata.packager]
|
|
||||||
name = "Coop"
|
|
||||||
product-name = "Coop"
|
|
||||||
description = "Chat Freely, Stay Private on Nostr"
|
|
||||||
identifier = "su.reya.coop"
|
|
||||||
category = "SocialNetworking"
|
|
||||||
version = "1.0.0-beta"
|
|
||||||
out-dir = "../../dist"
|
|
||||||
before-packaging-command = "cargo build --release"
|
|
||||||
resources = ["Cargo.toml", "src"]
|
|
||||||
icons = [
|
|
||||||
"resources/32x32.png",
|
|
||||||
"resources/128x128.png",
|
|
||||||
"resources/128x128@2x.png",
|
|
||||||
"resources/icon.icns",
|
|
||||||
"resources/icon.ico",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
assets = { path = "../assets" }
|
|
||||||
ui = { path = "../ui" }
|
|
||||||
title_bar = { path = "../title_bar" }
|
|
||||||
theme = { path = "../theme" }
|
|
||||||
common = { path = "../common" }
|
|
||||||
state = { path = "../state" }
|
|
||||||
device = { path = "../device" }
|
|
||||||
chat = { path = "../chat" }
|
|
||||||
chat_ui = { path = "../chat_ui" }
|
|
||||||
settings = { path = "../settings" }
|
|
||||||
auto_update = { path = "../auto_update" }
|
|
||||||
person = { path = "../person" }
|
|
||||||
relay_auth = { path = "../relay_auth" }
|
|
||||||
|
|
||||||
gpui.workspace = true
|
|
||||||
gpui_platform.workspace = true
|
|
||||||
gpui_linux.workspace = true
|
|
||||||
gpui_windows.workspace = true
|
|
||||||
gpui_macos.workspace = true
|
|
||||||
gpui_tokio.workspace = true
|
|
||||||
reqwest_client.workspace = true
|
|
||||||
|
|
||||||
nostr-connect.workspace = true
|
|
||||||
nostr-sdk.workspace = true
|
|
||||||
|
|
||||||
anyhow.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
itertools.workspace = true
|
|
||||||
log.workspace = true
|
|
||||||
smallvec.workspace = true
|
|
||||||
smol.workspace = true
|
|
||||||
futures.workspace = true
|
|
||||||
oneshot.workspace = true
|
|
||||||
webbrowser.workspace = true
|
|
||||||
|
|
||||||
indexset = "0.12.3"
|
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use common::TextUtils;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
div, img, px, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
|
||||||
FocusHandle, Focusable, Image, IntoElement, ParentElement, Render, SharedString, Styled, Task,
|
|
||||||
Window,
|
|
||||||
};
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
use state::NostrRegistry;
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
|
||||||
use ui::dock_area::ClosePanel;
|
|
||||||
use ui::notification::Notification;
|
|
||||||
use ui::{v_flex, StyledExt, WindowExtension};
|
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ConnectPanel> {
|
|
||||||
cx.new(|cx| ConnectPanel::new(window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ConnectPanel {
|
|
||||||
name: SharedString,
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
|
|
||||||
/// QR Code
|
|
||||||
qr_code: Option<Arc<Image>>,
|
|
||||||
|
|
||||||
/// Background tasks
|
|
||||||
_tasks: SmallVec<[Task<()>; 1]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConnectPanel {
|
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let weak_state = nostr.downgrade();
|
|
||||||
let (signer, uri) = nostr.read(cx).client_connect(None);
|
|
||||||
|
|
||||||
// Generate a QR code for quick connection
|
|
||||||
let qr_code = uri.to_string().to_qr();
|
|
||||||
|
|
||||||
let mut tasks = smallvec![];
|
|
||||||
|
|
||||||
tasks.push(
|
|
||||||
// Wait for nostr connect
|
|
||||||
cx.spawn_in(window, async move |_this, cx| {
|
|
||||||
let result = signer.bunker_uri().await;
|
|
||||||
|
|
||||||
weak_state
|
|
||||||
.update_in(cx, |this, window, cx| {
|
|
||||||
match result {
|
|
||||||
Ok(uri) => {
|
|
||||||
this.persist_bunker(uri, cx);
|
|
||||||
this.set_signer(signer, true, cx);
|
|
||||||
// Close the current panel after setting the signer
|
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
window.push_notification(Notification::error(e.to_string()), cx);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
name: "Nostr Connect".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
qr_code,
|
|
||||||
_tasks: tasks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for ConnectPanel {
|
|
||||||
fn panel_id(&self) -> SharedString {
|
|
||||||
self.name.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self, _cx: &App) -> AnyElement {
|
|
||||||
self.name.clone().into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for ConnectPanel {}
|
|
||||||
|
|
||||||
impl Focusable for ConnectPanel {
|
|
||||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for ConnectPanel {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.p_2()
|
|
||||||
.gap_10()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.justify_center()
|
|
||||||
.items_center()
|
|
||||||
.text_center()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.font_semibold()
|
|
||||||
.line_height(relative(1.25))
|
|
||||||
.child(SharedString::from("Continue with Nostr Connect")),
|
|
||||||
)
|
|
||||||
.child(div().text_sm().text_color(cx.theme().text_muted).child(
|
|
||||||
SharedString::from("Use Nostr Connect apps to scan the code"),
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.when_some(self.qr_code.as_ref(), |this, qr| {
|
|
||||||
this.child(
|
|
||||||
img(qr.clone())
|
|
||||||
.size(px(256.))
|
|
||||||
.rounded(cx.theme().radius_lg)
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
use anyhow::Error;
|
|
||||||
use device::DeviceRegistry;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
div, px, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
|
||||||
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window,
|
|
||||||
};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use person::{shorten_pubkey, PersonRegistry};
|
|
||||||
use state::Announcement;
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
|
||||||
use ui::notification::Notification;
|
|
||||||
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
|
|
||||||
|
|
||||||
const MSG: &str =
|
|
||||||
"Encryption Key is a special key that used to encrypt and decrypt your messages. \
|
|
||||||
Your identity is completely decoupled from all encryption processes to protect your privacy.";
|
|
||||||
|
|
||||||
const NOTICE: &str = "By resetting your encryption key, you will lose access to \
|
|
||||||
all your encrypted messages before. This action cannot be undone.";
|
|
||||||
|
|
||||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<EncryptionPanel> {
|
|
||||||
cx.new(|cx| EncryptionPanel::new(public_key, window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct EncryptionPanel {
|
|
||||||
name: SharedString,
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
|
|
||||||
/// User's public key
|
|
||||||
public_key: PublicKey,
|
|
||||||
|
|
||||||
/// Whether the panel is loading
|
|
||||||
loading: bool,
|
|
||||||
|
|
||||||
/// Tasks
|
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EncryptionPanel {
|
|
||||||
fn new(public_key: PublicKey, _window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
Self {
|
|
||||||
name: "Encryption".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
public_key,
|
|
||||||
loading: false,
|
|
||||||
tasks: vec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
|
||||||
self.loading = status;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let device = DeviceRegistry::global(cx);
|
|
||||||
let task = device.read(cx).approve(event, cx);
|
|
||||||
let id = event.id;
|
|
||||||
|
|
||||||
// Update loading status
|
|
||||||
self.set_loading(true, cx);
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
|
||||||
match task.await {
|
|
||||||
Ok(_) => {
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
// Reset loading status
|
|
||||||
this.set_loading(false, cx);
|
|
||||||
|
|
||||||
// Remove request
|
|
||||||
device.update(cx, |this, cx| {
|
|
||||||
this.remove_request(&id, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.push_notification("Approved", cx);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
this.set_loading(false, cx);
|
|
||||||
window.push_notification(Notification::error(e.to_string()), cx);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_requests(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
|
|
||||||
const TITLE: &str = "You've requested for the Encryption Key from:";
|
|
||||||
|
|
||||||
let device = DeviceRegistry::global(cx);
|
|
||||||
let requests = device.read(cx).requests.clone();
|
|
||||||
let mut items = Vec::new();
|
|
||||||
|
|
||||||
for event in requests.into_iter() {
|
|
||||||
let request = Announcement::from(&event);
|
|
||||||
let client_name = request.client_name();
|
|
||||||
let target = request.public_key();
|
|
||||||
|
|
||||||
items.push(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.child(SharedString::from(TITLE))
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.h_12()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.px_2()
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.bg(cx.theme().warning_background)
|
|
||||||
.text_color(cx.theme().warning_foreground)
|
|
||||||
.child(client_name.clone()),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.h_7()
|
|
||||||
.w_full()
|
|
||||||
.px_2()
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.bg(cx.theme().elevated_surface_background)
|
|
||||||
.child(SharedString::from(target.to_hex())),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex().justify_end().gap_2().child(
|
|
||||||
Button::new("approve")
|
|
||||||
.label("Approve")
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.disabled(self.loading)
|
|
||||||
.loading(self.loading)
|
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
|
||||||
this.approve(&event, window, cx);
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
items
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for EncryptionPanel {
|
|
||||||
fn panel_id(&self) -> SharedString {
|
|
||||||
self.name.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self, _cx: &App) -> AnyElement {
|
|
||||||
self.name.clone().into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for EncryptionPanel {}
|
|
||||||
|
|
||||||
impl Focusable for EncryptionPanel {
|
|
||||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for EncryptionPanel {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let device = DeviceRegistry::global(cx);
|
|
||||||
let state = device.read(cx).state();
|
|
||||||
let has_requests = device.read(cx).has_requests();
|
|
||||||
|
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
let profile = persons.read(cx).get(&self.public_key, cx);
|
|
||||||
|
|
||||||
let Some(announcement) = profile.announcement() else {
|
|
||||||
return div();
|
|
||||||
};
|
|
||||||
|
|
||||||
let pubkey = SharedString::from(shorten_pubkey(announcement.public_key(), 16));
|
|
||||||
let client_name = announcement.client_name();
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.p_3()
|
|
||||||
.gap_3()
|
|
||||||
.w_full()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from(MSG)),
|
|
||||||
)
|
|
||||||
.child(divider(cx))
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_3()
|
|
||||||
.text_sm()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1p5()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("Device Name:")),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.h_12()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.bg(cx.theme().elevated_surface_background)
|
|
||||||
.child(client_name.clone()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1p5()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("Encryption Public Key:")),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.h_7()
|
|
||||||
.w_full()
|
|
||||||
.px_2()
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.bg(cx.theme().elevated_surface_background)
|
|
||||||
.child(pubkey),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.when(has_requests, |this| {
|
|
||||||
this.child(divider(cx)).child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1p5()
|
|
||||||
.w_full()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("Requests:")),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.flex_1()
|
|
||||||
.w_full()
|
|
||||||
.children(self.render_requests(cx)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(divider(cx))
|
|
||||||
.when(state.requesting(), |this| {
|
|
||||||
this.child(
|
|
||||||
h_flex()
|
|
||||||
.h_8()
|
|
||||||
.justify_center()
|
|
||||||
.text_xs()
|
|
||||||
.text_center()
|
|
||||||
.text_color(cx.theme().text_accent)
|
|
||||||
.bg(cx.theme().elevated_surface_background)
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.child(SharedString::from(
|
|
||||||
"Please open other device and approve the request",
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(
|
|
||||||
Button::new("reset")
|
|
||||||
.icon(IconName::Reset)
|
|
||||||
.label("Reset")
|
|
||||||
.warning()
|
|
||||||
.small()
|
|
||||||
.font_semibold(),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.italic()
|
|
||||||
.text_size(px(10.))
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from(NOTICE)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
use chat::{ChatRegistry, InboxState};
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
|
||||||
IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
|
||||||
};
|
|
||||||
use state::{NostrRegistry, RelayState};
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::dock_area::dock::DockPlacement;
|
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
|
||||||
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
|
|
||||||
|
|
||||||
use crate::panels::{connect, import, messaging_relays, profile, relay_list};
|
|
||||||
use crate::workspace::Workspace;
|
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
|
|
||||||
cx.new(|cx| GreeterPanel::new(window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct GreeterPanel {
|
|
||||||
name: SharedString,
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GreeterPanel {
|
|
||||||
fn new(_window: &mut Window, cx: &mut App) -> Self {
|
|
||||||
Self {
|
|
||||||
name: "Onboarding".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_profile_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
|
|
||||||
if let Some(public_key) = signer.public_key() {
|
|
||||||
cx.spawn_in(window, async move |_this, cx| {
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
Workspace::add_panel(
|
|
||||||
profile::init(public_key, window, cx),
|
|
||||||
DockPlacement::Right,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for GreeterPanel {
|
|
||||||
fn panel_id(&self) -> SharedString {
|
|
||||||
self.name.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self, cx: &App) -> AnyElement {
|
|
||||||
div()
|
|
||||||
.child(
|
|
||||||
svg()
|
|
||||||
.path("brand/coop.svg")
|
|
||||||
.size_4()
|
|
||||||
.text_color(cx.theme().text_muted),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for GreeterPanel {}
|
|
||||||
|
|
||||||
impl Focusable for GreeterPanel {
|
|
||||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for GreeterPanel {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
const TITLE: &str = "Welcome to Coop!";
|
|
||||||
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
|
|
||||||
|
|
||||||
let chat = ChatRegistry::global(cx);
|
|
||||||
let nip17 = chat.read(cx).state(cx);
|
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let nip65 = nostr.read(cx).relay_list_state();
|
|
||||||
|
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
let owned = signer.owned();
|
|
||||||
|
|
||||||
let required_actions =
|
|
||||||
nip65 == RelayState::NotConfigured || nip17 == InboxState::RelayNotAvailable;
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.size_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.p_2()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.h_full()
|
|
||||||
.w_112()
|
|
||||||
.gap_6()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.mb_4()
|
|
||||||
.gap_2()
|
|
||||||
.w_full()
|
|
||||||
.child(
|
|
||||||
svg()
|
|
||||||
.path("brand/coop.svg")
|
|
||||||
.size_12()
|
|
||||||
.text_color(cx.theme().icon_muted),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.font_semibold()
|
|
||||||
.text_color(cx.theme().text)
|
|
||||||
.child(SharedString::from(TITLE)),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from(DESCRIPTION)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.when(required_actions, |this| {
|
|
||||||
this.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.w_full()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.w_full()
|
|
||||||
.text_xs()
|
|
||||||
.font_semibold()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("Required Actions"))
|
|
||||||
.child(div().flex_1().h_px().bg(cx.theme().border)),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.w_full()
|
|
||||||
.when(nip65.not_configured(), |this| {
|
|
||||||
this.child(
|
|
||||||
Button::new("relaylist")
|
|
||||||
.icon(Icon::new(IconName::Relay))
|
|
||||||
.label("Set up relay list")
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.justify_start()
|
|
||||||
.on_click(move |_ev, window, cx| {
|
|
||||||
Workspace::add_panel(
|
|
||||||
relay_list::init(window, cx),
|
|
||||||
DockPlacement::Center,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(nip17.not_configured(), |this| {
|
|
||||||
this.child(
|
|
||||||
Button::new("import")
|
|
||||||
.icon(Icon::new(IconName::Relay))
|
|
||||||
.label("Set up messaging relays")
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.justify_start()
|
|
||||||
.on_click(move |_ev, window, cx| {
|
|
||||||
Workspace::add_panel(
|
|
||||||
messaging_relays::init(window, cx),
|
|
||||||
DockPlacement::Center,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(!owned, |this| {
|
|
||||||
this.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.w_full()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.w_full()
|
|
||||||
.text_xs()
|
|
||||||
.font_semibold()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("Use your own identity"))
|
|
||||||
.child(div().flex_1().h_px().bg(cx.theme().border)),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.w_full()
|
|
||||||
.child(
|
|
||||||
Button::new("connect")
|
|
||||||
.icon(Icon::new(IconName::Door))
|
|
||||||
.label("Connect account via Nostr Connect")
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.justify_start()
|
|
||||||
.on_click(move |_ev, window, cx| {
|
|
||||||
Workspace::add_panel(
|
|
||||||
connect::init(window, cx),
|
|
||||||
DockPlacement::Center,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("import")
|
|
||||||
.icon(Icon::new(IconName::Usb))
|
|
||||||
.label("Import a secret key or bunker")
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.justify_start()
|
|
||||||
.on_click(move |_ev, window, cx| {
|
|
||||||
Workspace::add_panel(
|
|
||||||
import::init(window, cx),
|
|
||||||
DockPlacement::Center,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.w_full()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.w_full()
|
|
||||||
.text_xs()
|
|
||||||
.font_semibold()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("Get Started"))
|
|
||||||
.child(div().flex_1().h_px().bg(cx.theme().border)),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.w_full()
|
|
||||||
.child(
|
|
||||||
Button::new("profile")
|
|
||||||
.icon(Icon::new(IconName::Profile))
|
|
||||||
.label("Update profile")
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.justify_start()
|
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
|
||||||
this.add_profile_panel(window, cx)
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("invite")
|
|
||||||
.icon(Icon::new(IconName::Invite))
|
|
||||||
.label("Invite friends")
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.justify_start(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::anyhow;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
|
||||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
|
|
||||||
};
|
|
||||||
use nostr_connect::prelude::*;
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
use state::{CoopAuthUrlHandler, NostrRegistry};
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
|
||||||
use ui::dock_area::ClosePanel;
|
|
||||||
use ui::input::{InputEvent, InputState, TextInput};
|
|
||||||
use ui::notification::Notification;
|
|
||||||
use ui::{v_flex, Disableable, StyledExt, WindowExtension};
|
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ImportPanel> {
|
|
||||||
cx.new(|cx| ImportPanel::new(window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ImportPanel {
|
|
||||||
name: SharedString,
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
|
|
||||||
/// Secret key input
|
|
||||||
key_input: Entity<InputState>,
|
|
||||||
|
|
||||||
/// Password input (if required)
|
|
||||||
pass_input: Entity<InputState>,
|
|
||||||
|
|
||||||
/// Error message
|
|
||||||
error: Entity<Option<SharedString>>,
|
|
||||||
|
|
||||||
/// Countdown timer for nostr connect
|
|
||||||
countdown: Entity<Option<u64>>,
|
|
||||||
|
|
||||||
/// Whether the user is currently logging in
|
|
||||||
logging_in: bool,
|
|
||||||
|
|
||||||
/// Event subscriptions
|
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImportPanel {
|
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
|
||||||
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
|
||||||
|
|
||||||
let error = cx.new(|_| None);
|
|
||||||
let countdown = cx.new(|_| None);
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
// Subscribe to key input events and process login when the user presses enter
|
|
||||||
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
|
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
|
||||||
this.login(window, cx);
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
key_input,
|
|
||||||
pass_input,
|
|
||||||
error,
|
|
||||||
countdown,
|
|
||||||
name: "Import".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
logging_in: false,
|
|
||||||
_subscriptions: subscriptions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if self.logging_in {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
// Prevent duplicate login requests
|
|
||||||
self.set_logging_in(true, cx);
|
|
||||||
|
|
||||||
let value = self.key_input.read(cx).value();
|
|
||||||
let password = self.pass_input.read(cx).value();
|
|
||||||
|
|
||||||
if value.starts_with("bunker://") {
|
|
||||||
self.login_with_bunker(&value, window, cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if value.starts_with("ncryptsec1") {
|
|
||||||
self.login_with_password(&value, &password, window, cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(secret) = SecretKey::parse(&value) {
|
|
||||||
let keys = Keys::new(secret);
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
// Update the signer
|
|
||||||
nostr.update(cx, |this, cx| {
|
|
||||||
this.set_signer(keys, true, cx);
|
|
||||||
});
|
|
||||||
// Close the current panel after setting the signer
|
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
|
||||||
} else {
|
|
||||||
self.set_error("Invalid", cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let Ok(uri) = NostrConnectUri::parse(content) else {
|
|
||||||
self.set_error("Bunker is not valid", cx);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let weak_state = nostr.downgrade();
|
|
||||||
|
|
||||||
let app_keys = nostr.read(cx).app_keys();
|
|
||||||
let timeout = Duration::from_secs(30);
|
|
||||||
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
|
|
||||||
|
|
||||||
// Handle auth url with the default browser
|
|
||||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
|
||||||
|
|
||||||
// Start countdown
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
for i in (0..=30).rev() {
|
|
||||||
if i == 0 {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_countdown(None, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
} else {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_countdown(Some(i), cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
// Handle connection
|
|
||||||
cx.spawn_in(window, async move |_this, cx| {
|
|
||||||
let result = signer.bunker_uri().await;
|
|
||||||
|
|
||||||
weak_state
|
|
||||||
.update_in(cx, |this, window, cx| {
|
|
||||||
match result {
|
|
||||||
Ok(uri) => {
|
|
||||||
this.persist_bunker(uri, cx);
|
|
||||||
this.set_signer(signer, true, cx);
|
|
||||||
// Close the current panel after setting the signer
|
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
window.push_notification(Notification::error(e.to_string()), cx);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn login_with_password(
|
|
||||||
&mut self,
|
|
||||||
content: &str,
|
|
||||||
pwd: &str,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
if pwd.is_empty() {
|
|
||||||
self.set_error("Password is required", cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Ok(enc) = EncryptedSecretKey::from_bech32(content) else {
|
|
||||||
self.set_error("Secret Key is invalid", cx);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let password = pwd.to_owned();
|
|
||||||
|
|
||||||
// Decrypt in the background to ensure it doesn't block the UI
|
|
||||||
let task = cx.background_spawn(async move {
|
|
||||||
if let Ok(content) = enc.decrypt(&password) {
|
|
||||||
Ok(Keys::new(content))
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("Invalid password"))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let result = task.await;
|
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
match result {
|
|
||||||
Ok(keys) => {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
// Update the signer
|
|
||||||
nostr.update(cx, |this, cx| {
|
|
||||||
this.set_signer(keys, true, cx);
|
|
||||||
});
|
|
||||||
// Close the current panel after setting the signer
|
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.set_error(e.to_string(), cx);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
|
||||||
where
|
|
||||||
S: Into<SharedString>,
|
|
||||||
{
|
|
||||||
// Reset the log in state
|
|
||||||
self.set_logging_in(false, cx);
|
|
||||||
|
|
||||||
// Reset the countdown
|
|
||||||
self.set_countdown(None, cx);
|
|
||||||
|
|
||||||
// Update error message
|
|
||||||
self.error.update(cx, |this, cx| {
|
|
||||||
*this = Some(message.into());
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear the error message after 3 secs
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.error.update(cx, |this, cx| {
|
|
||||||
*this = None;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
|
|
||||||
self.logging_in = status;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
|
|
||||||
self.countdown.update(cx, |this, cx| {
|
|
||||||
*this = i;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for ImportPanel {
|
|
||||||
fn panel_id(&self) -> SharedString {
|
|
||||||
self.name.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self, _cx: &App) -> AnyElement {
|
|
||||||
self.name.clone().into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for ImportPanel {}
|
|
||||||
|
|
||||||
impl Focusable for ImportPanel {
|
|
||||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for ImportPanel {
|
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
const SECRET_WARN: &str = "* Coop doesn't store your secret key. \
|
|
||||||
It will be cleared when you close the app. \
|
|
||||||
To persist your identity, please connect via Nostr Connect.";
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.p_2()
|
|
||||||
.gap_10()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_center()
|
|
||||||
.font_semibold()
|
|
||||||
.line_height(relative(1.25))
|
|
||||||
.child(SharedString::from("Import a Secret Key or Bunker")),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.w_112()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child("nsec or bunker://")
|
|
||||||
.child(TextInput::new(&self.key_input)),
|
|
||||||
)
|
|
||||||
.when(
|
|
||||||
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|
|
||||||
|this| {
|
|
||||||
this.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child("Password:")
|
|
||||||
.child(TextInput::new(&self.pass_input)),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("login")
|
|
||||||
.label("Continue")
|
|
||||||
.primary()
|
|
||||||
.loading(self.logging_in)
|
|
||||||
.disabled(self.logging_in)
|
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
|
||||||
this.login(window, cx);
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_center()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from(format!(
|
|
||||||
"Approve connection request from your signer in {} seconds",
|
|
||||||
i
|
|
||||||
))),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_center()
|
|
||||||
.text_color(cx.theme().danger_foreground)
|
|
||||||
.child(error.clone()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.mt_2()
|
|
||||||
.italic()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from(SECRET_WARN)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,648 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use ::settings::AppSettings;
|
|
||||||
use chat::{ChatEvent, ChatRegistry, InboxState};
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
div, px, Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement,
|
|
||||||
ParentElement, Render, SharedString, Styled, Subscription, Window,
|
|
||||||
};
|
|
||||||
use person::PersonRegistry;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
use state::{NostrRegistry, RelayState};
|
|
||||||
use theme::{ActiveTheme, Theme, ThemeRegistry, SIDEBAR_WIDTH};
|
|
||||||
use title_bar::TitleBar;
|
|
||||||
use ui::avatar::Avatar;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::dock_area::dock::DockPlacement;
|
|
||||||
use ui::dock_area::panel::PanelView;
|
|
||||||
use ui::dock_area::{ClosePanel, DockArea, DockItem};
|
|
||||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
|
||||||
use ui::{h_flex, v_flex, IconName, Root, Sizable, WindowExtension};
|
|
||||||
|
|
||||||
use crate::dialogs::settings;
|
|
||||||
use crate::panels::{
|
|
||||||
backup, contact_list, encryption_key, greeter, messaging_relays, profile, relay_list,
|
|
||||||
};
|
|
||||||
use crate::sidebar;
|
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
|
|
||||||
cx.new(|cx| Workspace::new(window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
|
||||||
#[action(namespace = workspace, no_json)]
|
|
||||||
enum Command {
|
|
||||||
ToggleTheme,
|
|
||||||
|
|
||||||
RefreshRelayList,
|
|
||||||
RefreshMessagingRelays,
|
|
||||||
|
|
||||||
ShowRelayList,
|
|
||||||
ShowMessaging,
|
|
||||||
ShowEncryption,
|
|
||||||
ShowProfile,
|
|
||||||
ShowSettings,
|
|
||||||
ShowBackup,
|
|
||||||
ShowContactList,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Workspace {
|
|
||||||
/// App's Title Bar
|
|
||||||
titlebar: Entity<TitleBar>,
|
|
||||||
|
|
||||||
/// App's Dock Area
|
|
||||||
dock: Entity<DockArea>,
|
|
||||||
|
|
||||||
/// Event subscriptions
|
|
||||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Workspace {
|
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let chat = ChatRegistry::global(cx);
|
|
||||||
let titlebar = cx.new(|_| TitleBar::new());
|
|
||||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
// Observe system appearance and update theme
|
|
||||||
cx.observe_window_appearance(window, |_this, window, cx| {
|
|
||||||
Theme::sync_system_appearance(Some(window), cx);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
// Observe all events emitted by the chat registry
|
|
||||||
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
|
||||||
match ev {
|
|
||||||
ChatEvent::OpenRoom(id) => {
|
|
||||||
if let Some(room) = chat.read(cx).room(id, cx) {
|
|
||||||
this.dock.update(cx, |this, cx| {
|
|
||||||
this.add_panel(
|
|
||||||
Arc::new(chat_ui::init(room, window, cx)),
|
|
||||||
DockPlacement::Center,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ChatEvent::CloseRoom(..) => {
|
|
||||||
this.dock.update(cx, |this, cx| {
|
|
||||||
// Force focus to the tab panel
|
|
||||||
this.focus_tab_panel(window, cx);
|
|
||||||
|
|
||||||
// Dispatch the close panel action
|
|
||||||
cx.defer_in(window, |_, window, cx| {
|
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
|
||||||
window.close_all_modals(cx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
// Observe the chat registry
|
|
||||||
cx.observe(&chat, move |this, chat, cx| {
|
|
||||||
let ids = this.panel_ids(cx);
|
|
||||||
|
|
||||||
chat.update(cx, |this, cx| {
|
|
||||||
this.refresh_rooms(ids, cx);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set the default layout for app's dock
|
|
||||||
cx.defer_in(window, |this, window, cx| {
|
|
||||||
this.set_layout(window, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
|
||||||
titlebar,
|
|
||||||
dock,
|
|
||||||
_subscriptions: subscriptions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add panel to the dock
|
|
||||||
pub fn add_panel<P>(panel: P, placement: DockPlacement, window: &mut Window, cx: &mut App)
|
|
||||||
where
|
|
||||||
P: PanelView,
|
|
||||||
{
|
|
||||||
if let Some(root) = window.root::<Root>().flatten() {
|
|
||||||
if let Ok(workspace) = root.read(cx).view().clone().downcast::<Self>() {
|
|
||||||
workspace.update(cx, |this, cx| {
|
|
||||||
this.dock.update(cx, |this, cx| {
|
|
||||||
this.add_panel(Arc::new(panel), placement, window, cx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all panel ids
|
|
||||||
fn panel_ids(&self, cx: &App) -> Option<Vec<u64>> {
|
|
||||||
let ids: Vec<u64> = self
|
|
||||||
.dock
|
|
||||||
.read(cx)
|
|
||||||
.items
|
|
||||||
.panel_ids(cx)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|panel| panel.parse::<u64>().ok())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Some(ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the dock layout
|
|
||||||
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let weak_dock = self.dock.downgrade();
|
|
||||||
|
|
||||||
// Sidebar
|
|
||||||
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
|
|
||||||
|
|
||||||
// Main workspace
|
|
||||||
let center = DockItem::split_with_sizes(
|
|
||||||
Axis::Vertical,
|
|
||||||
vec![DockItem::tabs(
|
|
||||||
vec![Arc::new(greeter::init(window, cx))],
|
|
||||||
None,
|
|
||||||
&weak_dock,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
)],
|
|
||||||
vec![None],
|
|
||||||
&weak_dock,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update the dock layout
|
|
||||||
self.dock.update(cx, |this, cx| {
|
|
||||||
this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx);
|
|
||||||
this.set_center(center, window, cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
match command {
|
|
||||||
Command::ShowSettings => {
|
|
||||||
let view = settings::init(window, cx);
|
|
||||||
|
|
||||||
window.open_modal(cx, move |this, _window, _cx| {
|
|
||||||
this.width(px(520.))
|
|
||||||
.show_close(true)
|
|
||||||
.pb_4()
|
|
||||||
.title("Preferences")
|
|
||||||
.child(view.clone())
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Command::ShowProfile => {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
|
|
||||||
if let Some(public_key) = signer.public_key() {
|
|
||||||
self.dock.update(cx, |this, cx| {
|
|
||||||
this.add_panel(
|
|
||||||
Arc::new(profile::init(public_key, window, cx)),
|
|
||||||
DockPlacement::Right,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Command::ShowContactList => {
|
|
||||||
self.dock.update(cx, |this, cx| {
|
|
||||||
this.add_panel(
|
|
||||||
Arc::new(contact_list::init(window, cx)),
|
|
||||||
DockPlacement::Right,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Command::ShowBackup => {
|
|
||||||
self.dock.update(cx, |this, cx| {
|
|
||||||
this.add_panel(
|
|
||||||
Arc::new(backup::init(window, cx)),
|
|
||||||
DockPlacement::Right,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Command::ShowEncryption => {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
|
|
||||||
if let Some(public_key) = signer.public_key() {
|
|
||||||
self.dock.update(cx, |this, cx| {
|
|
||||||
this.add_panel(
|
|
||||||
Arc::new(encryption_key::init(public_key, window, cx)),
|
|
||||||
DockPlacement::Right,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Command::ShowMessaging => {
|
|
||||||
self.dock.update(cx, |this, cx| {
|
|
||||||
this.add_panel(
|
|
||||||
Arc::new(messaging_relays::init(window, cx)),
|
|
||||||
DockPlacement::Right,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Command::ShowRelayList => {
|
|
||||||
self.dock.update(cx, |this, cx| {
|
|
||||||
this.add_panel(
|
|
||||||
Arc::new(relay_list::init(window, cx)),
|
|
||||||
DockPlacement::Right,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Command::RefreshRelayList => {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
nostr.update(cx, |this, cx| {
|
|
||||||
this.ensure_relay_list(cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Command::RefreshMessagingRelays => {
|
|
||||||
let chat = ChatRegistry::global(cx);
|
|
||||||
chat.update(cx, |this, cx| {
|
|
||||||
this.ensure_messaging_relays(cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Command::ToggleTheme => {
|
|
||||||
self.theme_selector(window, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
window.open_modal(cx, move |this, _window, cx| {
|
|
||||||
let registry = ThemeRegistry::global(cx);
|
|
||||||
let themes = registry.read(cx).themes();
|
|
||||||
|
|
||||||
this.width(px(520.))
|
|
||||||
.show_close(true)
|
|
||||||
.title("Select theme")
|
|
||||||
.pb_4()
|
|
||||||
.child(v_flex().gap_2().w_full().children({
|
|
||||||
let mut items = vec![];
|
|
||||||
|
|
||||||
for (ix, (path, theme)) in themes.iter().enumerate() {
|
|
||||||
items.push(
|
|
||||||
h_flex()
|
|
||||||
.group("")
|
|
||||||
.px_2()
|
|
||||||
.h_8()
|
|
||||||
.w_full()
|
|
||||||
.justify_between()
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_1p5()
|
|
||||||
.flex_1()
|
|
||||||
.text_sm()
|
|
||||||
.child(theme.name.clone())
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.italic()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(theme.author.clone()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.invisible()
|
|
||||||
.group_hover("", |this| this.visible())
|
|
||||||
.child(
|
|
||||||
Button::new(format!("url-{ix}"))
|
|
||||||
.icon(IconName::Link)
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.on_click({
|
|
||||||
let theme = theme.clone();
|
|
||||||
move |_ev, _window, cx| {
|
|
||||||
cx.open_url(&theme.url);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new(format!("set-{ix}"))
|
|
||||||
.icon(IconName::Check)
|
|
||||||
.primary()
|
|
||||||
.small()
|
|
||||||
.on_click({
|
|
||||||
let path = path.clone();
|
|
||||||
move |_ev, window, cx| {
|
|
||||||
let settings = AppSettings::global(cx);
|
|
||||||
let path = path.clone();
|
|
||||||
|
|
||||||
settings.update(cx, |this, cx| {
|
|
||||||
this.set_theme(path, window, cx);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
items
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
let current_user = signer.public_key();
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.justify_between()
|
|
||||||
.gap_2()
|
|
||||||
.when_some(current_user.as_ref(), |this, public_key| {
|
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
let profile = persons.read(cx).get(public_key, cx);
|
|
||||||
let avatar = profile.avatar();
|
|
||||||
let name = profile.name();
|
|
||||||
|
|
||||||
this.child(
|
|
||||||
Button::new("current-user")
|
|
||||||
.child(Avatar::new(avatar.clone()).xsmall())
|
|
||||||
.small()
|
|
||||||
.caret()
|
|
||||||
.compact()
|
|
||||||
.transparent()
|
|
||||||
.dropdown_menu(move |this, _window, _cx| {
|
|
||||||
let avatar = avatar.clone();
|
|
||||||
let name = name.clone();
|
|
||||||
|
|
||||||
this.min_w(px(256.))
|
|
||||||
.item(PopupMenuItem::element(move |_window, cx| {
|
|
||||||
h_flex()
|
|
||||||
.gap_1p5()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(Avatar::new(avatar.clone()).xsmall())
|
|
||||||
.child(name.clone())
|
|
||||||
}))
|
|
||||||
.separator()
|
|
||||||
.menu_with_icon(
|
|
||||||
"Profile",
|
|
||||||
IconName::Profile,
|
|
||||||
Box::new(Command::ShowProfile),
|
|
||||||
)
|
|
||||||
.menu_with_icon(
|
|
||||||
"Contact List",
|
|
||||||
IconName::Book,
|
|
||||||
Box::new(Command::ShowContactList),
|
|
||||||
)
|
|
||||||
.menu_with_icon(
|
|
||||||
"Backup",
|
|
||||||
IconName::UserKey,
|
|
||||||
Box::new(Command::ShowBackup),
|
|
||||||
)
|
|
||||||
.menu_with_icon(
|
|
||||||
"Themes",
|
|
||||||
IconName::Sun,
|
|
||||||
Box::new(Command::ToggleTheme),
|
|
||||||
)
|
|
||||||
.separator()
|
|
||||||
.menu_with_icon(
|
|
||||||
"Settings",
|
|
||||||
IconName::Settings,
|
|
||||||
Box::new(Command::ShowSettings),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(nostr.read(cx).creating(), |this| {
|
|
||||||
this.child(div().text_xs().text_color(cx.theme().text_muted).child(
|
|
||||||
SharedString::from("Coop is creating a new identity for you..."),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.when(!nostr.read(cx).connected(), |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("Connecting...")),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn titlebar_right(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
let relay_list = nostr.read(cx).relay_list_state();
|
|
||||||
|
|
||||||
let chat = ChatRegistry::global(cx);
|
|
||||||
let inbox_state = chat.read(cx).state(cx);
|
|
||||||
|
|
||||||
let Some(pkey) = signer.public_key() else {
|
|
||||||
return div();
|
|
||||||
};
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
|
||||||
.gap_3()
|
|
||||||
.child(
|
|
||||||
Button::new("key")
|
|
||||||
.icon(IconName::UserKey)
|
|
||||||
.tooltip("Decoupled encryption key")
|
|
||||||
.small()
|
|
||||||
.ghost()
|
|
||||||
.on_click(|_ev, window, cx| {
|
|
||||||
window.dispatch_action(Box::new(Command::ShowEncryption), cx);
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.map(|this| match inbox_state {
|
|
||||||
InboxState::Checking => this.child(div().child(
|
|
||||||
SharedString::from("Fetching user's messaging relay list..."),
|
|
||||||
)),
|
|
||||||
InboxState::RelayNotAvailable => {
|
|
||||||
this.child(div().text_color(cx.theme().warning_active).child(
|
|
||||||
SharedString::from(
|
|
||||||
"User hasn't configured a messaging relay list",
|
|
||||||
),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
_ => this,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("inbox")
|
|
||||||
.icon(IconName::Inbox)
|
|
||||||
.tooltip("Inbox")
|
|
||||||
.small()
|
|
||||||
.ghost()
|
|
||||||
.when(inbox_state.subscribing(), |this| this.indicator())
|
|
||||||
.dropdown_menu(move |this, _window, cx| {
|
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
let profile = persons.read(cx).get(&pkey, cx);
|
|
||||||
let urls: Vec<SharedString> = profile
|
|
||||||
.messaging_relays()
|
|
||||||
.iter()
|
|
||||||
.map(|url| SharedString::from(url.to_string()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Header
|
|
||||||
let menu = this.min_w(px(260.)).label("Messaging Relays");
|
|
||||||
|
|
||||||
// Content
|
|
||||||
let menu = urls.into_iter().fold(menu, |this, url| {
|
|
||||||
this.item(PopupMenuItem::element(move |_window, _cx| {
|
|
||||||
h_flex()
|
|
||||||
.px_1()
|
|
||||||
.w_full()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.child(
|
|
||||||
div().size_1p5().rounded_full().bg(gpui::green()),
|
|
||||||
)
|
|
||||||
.child(url.clone())
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
menu.separator()
|
|
||||||
.menu_with_icon(
|
|
||||||
"Reload",
|
|
||||||
IconName::Refresh,
|
|
||||||
Box::new(Command::RefreshMessagingRelays),
|
|
||||||
)
|
|
||||||
.menu_with_icon(
|
|
||||||
"Update relays",
|
|
||||||
IconName::Settings,
|
|
||||||
Box::new(Command::ShowMessaging),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.map(|this| match relay_list {
|
|
||||||
RelayState::Checking => this
|
|
||||||
.child(div().child(SharedString::from(
|
|
||||||
"Fetching user's relay list...",
|
|
||||||
))),
|
|
||||||
RelayState::NotConfigured => {
|
|
||||||
this.child(div().text_color(cx.theme().warning_active).child(
|
|
||||||
SharedString::from("User hasn't configured a relay list"),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
_ => this,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("relay-list")
|
|
||||||
.icon(IconName::Relay)
|
|
||||||
.tooltip("User's relay list")
|
|
||||||
.small()
|
|
||||||
.ghost()
|
|
||||||
.when(relay_list.configured(), |this| this.indicator())
|
|
||||||
.dropdown_menu(move |this, _window, cx| {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let urls = nostr.read(cx).read_only_relays(&pkey, cx);
|
|
||||||
|
|
||||||
// Header
|
|
||||||
let menu = this.min_w(px(260.)).label("Relays");
|
|
||||||
|
|
||||||
// Content
|
|
||||||
let menu = urls.into_iter().fold(menu, |this, url| {
|
|
||||||
this.item(PopupMenuItem::element(move |_window, _cx| {
|
|
||||||
h_flex()
|
|
||||||
.px_1()
|
|
||||||
.w_full()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.child(
|
|
||||||
div().size_1p5().rounded_full().bg(gpui::green()),
|
|
||||||
)
|
|
||||||
.child(url.clone())
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
menu.separator()
|
|
||||||
.menu_with_icon(
|
|
||||||
"Reload",
|
|
||||||
IconName::Refresh,
|
|
||||||
Box::new(Command::RefreshRelayList),
|
|
||||||
)
|
|
||||||
.menu_with_icon(
|
|
||||||
"Update relay list",
|
|
||||||
IconName::Settings,
|
|
||||||
Box::new(Command::ShowRelayList),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for Workspace {
|
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let modal_layer = Root::render_modal_layer(window, cx);
|
|
||||||
let notification_layer = Root::render_notification_layer(window, cx);
|
|
||||||
|
|
||||||
// Titlebar elements
|
|
||||||
let left = self.titlebar_left(window, cx).into_any_element();
|
|
||||||
let right = self.titlebar_right(window, cx).into_any_element();
|
|
||||||
|
|
||||||
// Update title bar children
|
|
||||||
self.titlebar.update(cx, |this, _cx| {
|
|
||||||
this.set_children(vec![left, right]);
|
|
||||||
});
|
|
||||||
|
|
||||||
div()
|
|
||||||
.id(SharedString::from("workspace"))
|
|
||||||
.on_action(cx.listener(Self::on_command))
|
|
||||||
.relative()
|
|
||||||
.size_full()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.relative()
|
|
||||||
.size_full()
|
|
||||||
// Title Bar
|
|
||||||
.child(self.titlebar.clone())
|
|
||||||
// Dock
|
|
||||||
.child(self.dock.clone()),
|
|
||||||
)
|
|
||||||
// Notifications
|
|
||||||
.children(notification_layer)
|
|
||||||
// Modals
|
|
||||||
.children(modal_layer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,15 +8,17 @@ publish.workspace = true
|
|||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
person = { path = "../person" }
|
person = { path = "../person" }
|
||||||
|
ui = { path = "../ui" }
|
||||||
|
theme = { path = "../theme" }
|
||||||
|
settings = { path = "../settings" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
instant.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
smol.workspace = true
|
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
smol.workspace = true
|
||||||
|
|||||||
+505
-401
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,8 @@ state = { path = "../state" }
|
|||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
instant.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
smol.workspace = true
|
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|||||||
+119
-127
@@ -1,21 +1,20 @@
|
|||||||
use std::cell::RefCell;
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::rc::Rc;
|
use std::sync::RwLock;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{Error, anyhow};
|
||||||
use common::EventUtils;
|
use common::EventExt;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
|
||||||
|
use instant::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{Announcement, NostrRegistry, BOOTSTRAP_RELAYS, TIMEOUT};
|
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
|
||||||
|
|
||||||
mod person;
|
mod person;
|
||||||
|
|
||||||
pub use person::*;
|
pub use person::*;
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
|
PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GlobalPersonRegistry(Entity<PersonRegistry>);
|
struct GlobalPersonRegistry(Entity<PersonRegistry>);
|
||||||
@@ -24,9 +23,9 @@ impl Global for GlobalPersonRegistry {}
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum Dispatch {
|
enum Dispatch {
|
||||||
Person(Box<Person>),
|
Person(Person),
|
||||||
Announcement(Box<Event>),
|
Announcement(Event),
|
||||||
Relays(Box<Event>),
|
Relays(Event),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Person Registry
|
/// Person Registry
|
||||||
@@ -36,13 +35,13 @@ pub struct PersonRegistry {
|
|||||||
persons: HashMap<PublicKey, Entity<Person>>,
|
persons: HashMap<PublicKey, Entity<Person>>,
|
||||||
|
|
||||||
/// Set of public keys that have been seen
|
/// Set of public keys that have been seen
|
||||||
seen: Rc<RefCell<HashSet<PublicKey>>>,
|
seen: RwLock<HashSet<PublicKey>>,
|
||||||
|
|
||||||
/// Sender for requesting metadata
|
/// Sender for requesting metadata
|
||||||
sender: flume::Sender<PublicKey>,
|
sender: flume::Sender<PublicKey>,
|
||||||
|
|
||||||
/// Tasks for asynchronous operations
|
/// Tasks for asynchronous operations
|
||||||
_tasks: SmallVec<[Task<()>; 4]>,
|
tasks: SmallVec<[Task<()>; 4]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PersonRegistry {
|
impl PersonRegistry {
|
||||||
@@ -57,87 +56,55 @@ impl PersonRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new person registry instance
|
/// Create a new person registry instance
|
||||||
fn new(cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
// Channel for communication between nostr and gpui
|
// Channel for communication between nostr and gpui
|
||||||
let (tx, rx) = flume::bounded::<Dispatch>(100);
|
let (tx, rx) = flume::bounded::<Dispatch>(100);
|
||||||
let (mta_tx, mta_rx) = flume::bounded::<PublicKey>(100);
|
let (metadata_tx, metadata_rx) = flume::unbounded::<PublicKey>();
|
||||||
|
|
||||||
let mut tasks = smallvec![];
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
tasks.push(
|
let client2 = client.clone();
|
||||||
// Handle nostr notifications
|
tasks.push(cx.background_spawn(async move {
|
||||||
cx.background_spawn({
|
Self::handle_notifications(&client2, &tx).await;
|
||||||
let client = client.clone();
|
}));
|
||||||
|
|
||||||
async move {
|
let client3 = client.clone();
|
||||||
Self::handle_notifications(&client, &tx).await;
|
tasks.push(cx.background_spawn(async move {
|
||||||
}
|
Self::handle_requests(&client3, &metadata_rx).await;
|
||||||
}),
|
}));
|
||||||
);
|
|
||||||
|
|
||||||
tasks.push(
|
tasks.push(cx.spawn(async move |this, cx| {
|
||||||
// Handle metadata requests
|
while let Ok(event) = rx.recv_async().await {
|
||||||
cx.background_spawn({
|
this.update(cx, |this, cx| {
|
||||||
let client = client.clone();
|
match event {
|
||||||
|
Dispatch::Person(person) => {
|
||||||
|
this.insert(person, cx);
|
||||||
|
}
|
||||||
|
Dispatch::Announcement(event) => {
|
||||||
|
this.set_announcement(&event, cx);
|
||||||
|
}
|
||||||
|
Dispatch::Relays(event) => {
|
||||||
|
this.set_messaging_relays(&event, cx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
async move {
|
// Load all user profiles from the database
|
||||||
Self::handle_requests(&client, &mta_rx).await;
|
cx.defer_in(window, |this, _window, cx| {
|
||||||
}
|
this.load(cx);
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
tasks.push(
|
|
||||||
// Update GPUI state
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
while let Ok(event) = rx.recv_async().await {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
match event {
|
|
||||||
Dispatch::Person(person) => {
|
|
||||||
this.insert(*person, cx);
|
|
||||||
}
|
|
||||||
Dispatch::Announcement(event) => {
|
|
||||||
this.set_announcement(&event, cx);
|
|
||||||
}
|
|
||||||
Dispatch::Relays(event) => {
|
|
||||||
this.set_messaging_relays(&event, cx);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
tasks.push(
|
|
||||||
// Load all user profiles from the database
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
let result = cx
|
|
||||||
.background_executor()
|
|
||||||
.await_on_background(async move { load_persons(&client).await })
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(persons) => {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.bulk_inserts(persons, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to load all persons from the database: {e}");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
persons: HashMap::new(),
|
persons: HashMap::new(),
|
||||||
seen: Rc::new(RefCell::new(HashSet::new())),
|
seen: RwLock::new(HashSet::new()),
|
||||||
sender: mta_tx,
|
sender: metadata_tx,
|
||||||
_tasks: tasks,
|
tasks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +119,7 @@ impl PersonRegistry {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
if let RelayMessage::Event { event, .. } = message {
|
if let RelayMessage::Event { event, .. } = *message {
|
||||||
// Skip if the event has already been processed
|
// Skip if the event has already been processed
|
||||||
if !processed.insert(event.id) {
|
if !processed.insert(event.id) {
|
||||||
continue;
|
continue;
|
||||||
@@ -162,28 +129,25 @@ impl PersonRegistry {
|
|||||||
Kind::Metadata => {
|
Kind::Metadata => {
|
||||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||||
let person = Person::new(event.pubkey, metadata);
|
let person = Person::new(event.pubkey, metadata);
|
||||||
let val = Box::new(person);
|
if tx.send_async(Dispatch::Person(person)).await.is_err() {
|
||||||
|
log::warn!("PersonRegistry channel closed, dropping metadata event");
|
||||||
// Send
|
}
|
||||||
tx.send_async(Dispatch::Person(val)).await.ok();
|
|
||||||
}
|
}
|
||||||
Kind::ContactList => {
|
Kind::ContactList => {
|
||||||
let public_keys = event.extract_public_keys();
|
let public_keys = event.extract_public_keys();
|
||||||
|
if let Err(e) = get_metadata(client, public_keys).await {
|
||||||
// Get metadata for all public keys
|
log::warn!("Failed to get metadata for contact list: {e}");
|
||||||
get_metadata(client, public_keys).await.ok();
|
}
|
||||||
}
|
}
|
||||||
Kind::InboxRelays => {
|
Kind::InboxRelays => {
|
||||||
let val = Box::new(event.into_owned());
|
tx.send_async(Dispatch::Relays(event.into_owned()))
|
||||||
|
.await
|
||||||
// Send
|
.ok();
|
||||||
tx.send_async(Dispatch::Relays(val)).await.ok();
|
|
||||||
}
|
}
|
||||||
Kind::Custom(10044) => {
|
Kind::Custom(10044) => {
|
||||||
let val = Box::new(event.into_owned());
|
tx.send_async(Dispatch::Announcement(event.into_owned()))
|
||||||
|
.await
|
||||||
// Send
|
.ok();
|
||||||
tx.send_async(Dispatch::Announcement(val)).await.ok();
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -198,50 +162,95 @@ impl PersonRegistry {
|
|||||||
loop {
|
loop {
|
||||||
match flume::Selector::new()
|
match flume::Selector::new()
|
||||||
.recv(rx, |result| result.ok())
|
.recv(rx, |result| result.ok())
|
||||||
.wait_timeout(Duration::from_secs(2))
|
.wait_timeout(Duration::from_secs(TIMEOUT))
|
||||||
{
|
{
|
||||||
Ok(Some(public_key)) => {
|
Ok(Some(public_key)) => {
|
||||||
batch.insert(public_key);
|
batch.insert(public_key);
|
||||||
// Process the batch if it's full
|
// Process the batch if it's full
|
||||||
if batch.len() >= 20 {
|
if batch.len() >= 20
|
||||||
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||||
|
{
|
||||||
|
log::warn!("Failed to get metadata batch: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
get_metadata(client, std::mem::take(&mut batch)).await.ok();
|
if !batch.is_empty()
|
||||||
|
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||||
|
{
|
||||||
|
log::warn!("Failed to get metadata batch: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load all user profiles from the database
|
||||||
|
fn load(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
let task: Task<Result<Vec<Person>, Error>> = cx.background_spawn(async move {
|
||||||
|
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||||
|
let events = client.database().query(filter).await?;
|
||||||
|
let persons = events
|
||||||
|
.into_iter()
|
||||||
|
.map(|event| {
|
||||||
|
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||||
|
Person::new(event.pubkey, metadata)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(persons)
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
if let Ok(persons) = task.await {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.bulk_insert(persons, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/// Set profile encryption keys announcement
|
/// Set profile encryption keys announcement
|
||||||
fn set_announcement(&mut self, event: &Event, cx: &mut App) {
|
fn set_announcement(&mut self, event: &Event, cx: &mut App) {
|
||||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
let announcement = Announcement::from(event);
|
||||||
let announcement = Announcement::from(event);
|
|
||||||
|
|
||||||
|
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||||
person.update(cx, |person, cx| {
|
person.update(cx, |person, cx| {
|
||||||
person.set_announcement(announcement);
|
person.set_announcement(announcement);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
let person =
|
||||||
|
Person::new(event.pubkey, Metadata::default()).with_announcement(announcement);
|
||||||
|
self.insert(person, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set messaging relays for a person
|
/// Set messaging relays for a person
|
||||||
fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) {
|
fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) {
|
||||||
if let Some(person) = self.persons.get(&event.pubkey) {
|
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).collect();
|
||||||
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).cloned().collect();
|
|
||||||
|
|
||||||
|
if let Some(person) = self.persons.get(&event.pubkey) {
|
||||||
person.update(cx, |person, cx| {
|
person.update(cx, |person, cx| {
|
||||||
person.set_messaging_relays(urls);
|
person.set_messaging_relays(urls);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
let person = Person::new(event.pubkey, Metadata::default()).with_messaging_relays(urls);
|
||||||
|
self.insert(person, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert batch of persons
|
/// Insert batch of persons
|
||||||
fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
||||||
for person in persons.into_iter() {
|
for person in persons.into_iter() {
|
||||||
self.persons.insert(person.public_key(), cx.new(|_| person));
|
let public_key = person.public_key();
|
||||||
|
self.persons
|
||||||
|
.entry(public_key)
|
||||||
|
.or_insert_with(|| cx.new(|_| person));
|
||||||
}
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
@@ -270,15 +279,14 @@ impl PersonRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let public_key = *public_key;
|
let public_key = *public_key;
|
||||||
let mut seen = self.seen.borrow_mut();
|
|
||||||
|
|
||||||
if seen.insert(public_key) {
|
if self.seen.write().unwrap().insert(public_key) {
|
||||||
let sender = self.sender.clone();
|
let sender = self.sender.clone();
|
||||||
|
|
||||||
// Spawn background task to request metadata
|
// Spawn background task to request metadata
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
if let Err(e) = sender.send_async(public_key).await {
|
if let Err(e) = sender.send_async(public_key).await {
|
||||||
log::warn!("Failed to send public key for metadata request: {}", e);
|
log::warn!("Failed to send public key for metadata request: {e}");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
@@ -322,19 +330,3 @@ where
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load all user profiles from the database
|
|
||||||
async fn load_persons(client: &Client) -> Result<Vec<Person>, Error> {
|
|
||||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
|
||||||
let events = client.database().query(filter).await?;
|
|
||||||
|
|
||||||
let mut persons = vec![];
|
|
||||||
|
|
||||||
for event in events.into_iter() {
|
|
||||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
|
||||||
let person = Person::new(event.pubkey, metadata);
|
|
||||||
persons.push(person);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(persons)
|
|
||||||
}
|
|
||||||
|
|||||||
+43
-35
@@ -5,8 +5,6 @@ use gpui::SharedString;
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::Announcement;
|
use state::Announcement;
|
||||||
|
|
||||||
const IMAGE_RESIZER: &str = "https://wsrv.nl";
|
|
||||||
|
|
||||||
/// Person
|
/// Person
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Person {
|
pub struct Person {
|
||||||
@@ -65,6 +63,21 @@ impl Person {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build profile encryption keys announcement
|
||||||
|
pub fn with_announcement(mut self, announcement: Announcement) -> Self {
|
||||||
|
self.announcement = Some(announcement);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build profile messaging relays
|
||||||
|
pub fn with_messaging_relays<I>(mut self, relays: I) -> Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = RelayUrl>,
|
||||||
|
{
|
||||||
|
self.messaging_relays = relays.into_iter().collect();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Get profile public key
|
/// Get profile public key
|
||||||
pub fn public_key(&self) -> PublicKey {
|
pub fn public_key(&self) -> PublicKey {
|
||||||
self.public_key
|
self.public_key
|
||||||
@@ -75,21 +88,11 @@ impl Person {
|
|||||||
self.metadata.clone()
|
self.metadata.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set profile metadata
|
|
||||||
pub fn set_metadata(&mut self, metadata: Metadata) {
|
|
||||||
self.metadata = metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get profile encryption keys announcement
|
/// Get profile encryption keys announcement
|
||||||
pub fn announcement(&self) -> Option<Announcement> {
|
pub fn announcement(&self) -> Option<Announcement> {
|
||||||
self.announcement.clone()
|
self.announcement.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set profile encryption keys announcement
|
|
||||||
pub fn set_announcement(&mut self, announcement: Announcement) {
|
|
||||||
self.announcement = Some(announcement);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get profile messaging relays
|
/// Get profile messaging relays
|
||||||
pub fn messaging_relays(&self) -> &Vec<RelayUrl> {
|
pub fn messaging_relays(&self) -> &Vec<RelayUrl> {
|
||||||
&self.messaging_relays
|
&self.messaging_relays
|
||||||
@@ -100,45 +103,50 @@ impl Person {
|
|||||||
self.messaging_relays.first().cloned()
|
self.messaging_relays.first().cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set profile messaging relays
|
|
||||||
pub fn set_messaging_relays<I>(&mut self, relays: I)
|
|
||||||
where
|
|
||||||
I: IntoIterator<Item = RelayUrl>,
|
|
||||||
{
|
|
||||||
self.messaging_relays = relays.into_iter().collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get profile avatar
|
/// Get profile avatar
|
||||||
pub fn avatar(&self) -> SharedString {
|
pub fn avatar(&self) -> SharedString {
|
||||||
self.metadata()
|
self.metadata()
|
||||||
.picture
|
.picture
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|picture| !picture.is_empty())
|
.filter(|picture| !picture.is_empty())
|
||||||
.map(|picture| {
|
.map(|picture| picture.into())
|
||||||
let url = format!(
|
|
||||||
"{IMAGE_RESIZER}/?url={picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
|
|
||||||
);
|
|
||||||
url.into()
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get profile name
|
/// Get profile name
|
||||||
pub fn name(&self) -> SharedString {
|
pub fn name(&self) -> SharedString {
|
||||||
if let Some(display_name) = self.metadata().display_name.as_ref() {
|
if let Some(display_name) = self.metadata().display_name.as_ref()
|
||||||
if !display_name.is_empty() {
|
&& !display_name.is_empty()
|
||||||
return SharedString::from(display_name);
|
{
|
||||||
}
|
return SharedString::from(display_name.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(name) = self.metadata().name.as_ref() {
|
if let Some(name) = self.metadata().name.as_ref()
|
||||||
if !name.is_empty() {
|
&& !name.is_empty()
|
||||||
return SharedString::from(name);
|
{
|
||||||
}
|
return SharedString::from(name.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
SharedString::from(shorten_pubkey(self.public_key(), 4))
|
SharedString::from(shorten_pubkey(self.public_key(), 4))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set profile metadata
|
||||||
|
pub fn set_metadata(&mut self, metadata: Metadata) {
|
||||||
|
self.metadata = metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set profile encryption keys announcement
|
||||||
|
pub fn set_announcement(&mut self, announcement: Announcement) {
|
||||||
|
self.announcement = Some(announcement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set profile messaging relays
|
||||||
|
pub fn set_messaging_relays<I>(&mut self, relays: I)
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = RelayUrl>,
|
||||||
|
{
|
||||||
|
self.messaging_relays = relays.into_iter().collect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shorten a [`PublicKey`] to a string with the first and last `len` characters
|
/// Shorten a [`PublicKey`] to a string with the first and last `len` characters
|
||||||
@@ -148,7 +156,7 @@ pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
|
|||||||
let Ok(pubkey) = public_key.to_bech32();
|
let Ok(pubkey) = public_key.to_bech32();
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
"{}:{}",
|
"{}...{}",
|
||||||
&pubkey[0..(len + 1)],
|
&pubkey[0..(len + 1)],
|
||||||
&pubkey[pubkey.len() - len..]
|
&pubkey[pubkey.len() - len..]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,378 +0,0 @@
|
|||||||
use std::borrow::Cow;
|
|
||||||
use std::cell::Cell;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::hash::Hash;
|
|
||||||
use std::rc::Rc;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
|
||||||
use gpui::{
|
|
||||||
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
|
|
||||||
Task, Window,
|
|
||||||
};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use settings::{AppSettings, AuthMode};
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
use state::NostrRegistry;
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::notification::Notification;
|
|
||||||
use ui::{v_flex, Disableable, IconName, Sizable, WindowExtension};
|
|
||||||
|
|
||||||
const AUTH_MESSAGE: &str =
|
|
||||||
"Approve the authentication request to allow Coop to continue sending or receiving events.";
|
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) {
|
|
||||||
RelayAuth::set_global(cx.new(|cx| RelayAuth::new(window, cx)), cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Authentication request
|
|
||||||
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
struct AuthRequest {
|
|
||||||
url: RelayUrl,
|
|
||||||
challenge: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AuthRequest {
|
|
||||||
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
|
|
||||||
Self {
|
|
||||||
challenge: challenge.into(),
|
|
||||||
url,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn url(&self) -> &RelayUrl {
|
|
||||||
&self.url
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn challenge(&self) -> &str {
|
|
||||||
&self.challenge
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
enum Signal {
|
|
||||||
Auth(Arc<AuthRequest>),
|
|
||||||
Pending((EventId, RelayUrl)),
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GlobalRelayAuth(Entity<RelayAuth>);
|
|
||||||
|
|
||||||
impl Global for GlobalRelayAuth {}
|
|
||||||
|
|
||||||
// Relay authentication
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct RelayAuth {
|
|
||||||
/// Pending events waiting for resend after authentication
|
|
||||||
pending_events: HashSet<(EventId, RelayUrl)>,
|
|
||||||
|
|
||||||
/// Tasks for asynchronous operations
|
|
||||||
_tasks: SmallVec<[Task<()>; 2]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RelayAuth {
|
|
||||||
/// Retrieve the global relay auth state
|
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
|
||||||
cx.global::<GlobalRelayAuth>().0.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the global relay auth instance
|
|
||||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
|
||||||
cx.set_global(GlobalRelayAuth(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new relay auth instance
|
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let client = nostr.read(cx).client();
|
|
||||||
|
|
||||||
let mut tasks = smallvec![];
|
|
||||||
|
|
||||||
// Channel for communication between nostr and gpui
|
|
||||||
let (tx, rx) = flume::bounded::<Signal>(256);
|
|
||||||
|
|
||||||
tasks.push(cx.background_spawn(async move {
|
|
||||||
let mut notifications = client.notifications();
|
|
||||||
let mut challenges: HashSet<Cow<'_, str>> = HashSet::default();
|
|
||||||
|
|
||||||
while let Some(notification) = notifications.next().await {
|
|
||||||
if let ClientNotification::Message { relay_url, message } = notification {
|
|
||||||
match message {
|
|
||||||
RelayMessage::Auth { challenge } => {
|
|
||||||
if challenges.insert(challenge.clone()) {
|
|
||||||
let request = Arc::new(AuthRequest::new(challenge, relay_url));
|
|
||||||
let signal = Signal::Auth(request);
|
|
||||||
|
|
||||||
tx.send_async(signal).await.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RelayMessage::Closed {
|
|
||||||
subscription_id,
|
|
||||||
message,
|
|
||||||
} => {
|
|
||||||
let msg = MachineReadablePrefix::parse(&message);
|
|
||||||
|
|
||||||
if let Some(MachineReadablePrefix::AuthRequired) = msg {
|
|
||||||
if let Ok(Some(relay)) = client.relay(&relay_url).await {
|
|
||||||
// Send close message to relay
|
|
||||||
relay
|
|
||||||
.send_msg(ClientMessage::Close(subscription_id))
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RelayMessage::Ok {
|
|
||||||
event_id, message, ..
|
|
||||||
} => {
|
|
||||||
let msg = MachineReadablePrefix::parse(&message);
|
|
||||||
|
|
||||||
// Handle authentication messages
|
|
||||||
if let Some(MachineReadablePrefix::AuthRequired) = msg {
|
|
||||||
let signal = Signal::Pending((event_id, relay_url));
|
|
||||||
tx.send_async(signal).await.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
tasks.push(cx.spawn_in(window, async move |this, cx| {
|
|
||||||
while let Ok(signal) = rx.recv_async().await {
|
|
||||||
match signal {
|
|
||||||
Signal::Auth(req) => {
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
this.handle_auth(&req, window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
Signal::Pending((event_id, relay_url)) => {
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
|
||||||
this.insert_pending_event(event_id, relay_url, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
Self {
|
|
||||||
pending_events: HashSet::default(),
|
|
||||||
_tasks: tasks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert a pending event waiting for resend after authentication
|
|
||||||
fn insert_pending_event(&mut self, id: EventId, relay: RelayUrl, cx: &mut Context<Self>) {
|
|
||||||
self.pending_events.insert((id, relay));
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all pending events for a specific relay,
|
|
||||||
fn get_pending_events(&self, relay: &RelayUrl, _cx: &App) -> Vec<EventId> {
|
|
||||||
self.pending_events
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, pending_relay)| pending_relay == relay)
|
|
||||||
.map(|(id, _relay)| id)
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all pending events for a specific relay,
|
|
||||||
fn clear_pending_events(&mut self, relay: &RelayUrl, cx: &mut Context<Self>) {
|
|
||||||
self.pending_events
|
|
||||||
.retain(|(_, pending_relay)| pending_relay != relay);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle authentication request
|
|
||||||
fn handle_auth(&mut self, req: &Arc<AuthRequest>, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let settings = AppSettings::global(cx);
|
|
||||||
let trusted_relay = settings.read(cx).trusted_relay(req.url(), cx);
|
|
||||||
let mode = AppSettings::get_auth_mode(cx);
|
|
||||||
|
|
||||||
if trusted_relay && mode == AuthMode::Auto {
|
|
||||||
// Automatically authenticate if the relay is authenticated before
|
|
||||||
self.response(req, window, cx);
|
|
||||||
} else {
|
|
||||||
// Otherwise open the auth request popup
|
|
||||||
self.ask_for_approval(req, window, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send auth response and wait for confirmation
|
|
||||||
fn auth(&self, req: &Arc<AuthRequest>, cx: &App) -> Task<Result<(), Error>> {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let client = nostr.read(cx).client();
|
|
||||||
let req = req.clone();
|
|
||||||
|
|
||||||
// Get all pending events for the relay
|
|
||||||
let pending_events = self.get_pending_events(req.url(), cx);
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
// Construct event
|
|
||||||
let builder = EventBuilder::auth(req.challenge(), req.url().clone());
|
|
||||||
let event = client.sign_event_builder(builder).await?;
|
|
||||||
|
|
||||||
// Get the event ID
|
|
||||||
let id = event.id;
|
|
||||||
|
|
||||||
// Get the relay
|
|
||||||
let relay = client.relay(req.url()).await?.context("Relay not found")?;
|
|
||||||
|
|
||||||
// Subscribe to notifications
|
|
||||||
let mut notifications = relay.notifications();
|
|
||||||
|
|
||||||
// Send the AUTH message
|
|
||||||
relay
|
|
||||||
.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
log::info!("Sending AUTH event");
|
|
||||||
|
|
||||||
while let Some(notification) = notifications.next().await {
|
|
||||||
match notification {
|
|
||||||
RelayNotification::Message {
|
|
||||||
message: RelayMessage::Ok { event_id, .. },
|
|
||||||
} => {
|
|
||||||
if id != event_id {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all subscriptions
|
|
||||||
let subscriptions = relay.subscriptions().await;
|
|
||||||
|
|
||||||
// Re-subscribe to previous subscriptions
|
|
||||||
for (id, filters) in subscriptions.into_iter() {
|
|
||||||
if !filters.is_empty() {
|
|
||||||
relay.send_msg(ClientMessage::req(id, filters)).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-send pending events
|
|
||||||
for id in pending_events {
|
|
||||||
if let Some(event) = client.database().event_by_id(&id).await? {
|
|
||||||
relay.send_event(&event).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
RelayNotification::AuthenticationFailed => break,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(anyhow!("Authentication failed"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Respond to an authentication request.
|
|
||||||
fn response(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
|
|
||||||
let settings = AppSettings::global(cx);
|
|
||||||
let req = req.clone();
|
|
||||||
let challenge = req.challenge().to_string();
|
|
||||||
|
|
||||||
// Create a task for authentication
|
|
||||||
let task = self.auth(&req, cx);
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let result = task.await;
|
|
||||||
let url = req.url();
|
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
window.clear_notification(challenge, cx);
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(_) => {
|
|
||||||
// Clear pending events for the authenticated relay
|
|
||||||
this.clear_pending_events(url, cx);
|
|
||||||
|
|
||||||
// Save the authenticated relay to automatically authenticate future requests
|
|
||||||
settings.update(cx, |this, cx| {
|
|
||||||
this.add_trusted_relay(url, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.push_notification(format!("{} has been authenticated", url), cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
window.push_notification(Notification::error(e.to_string()), cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push a popup to approve the authentication request.
|
|
||||||
fn ask_for_approval(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
|
|
||||||
let notification = self.notification(req, cx);
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |_this, cx| {
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
window.push_notification(notification, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a notification for the authentication request.
|
|
||||||
fn notification(&self, req: &Arc<AuthRequest>, cx: &Context<Self>) -> Notification {
|
|
||||||
let req = req.clone();
|
|
||||||
let url = SharedString::from(req.url().to_string());
|
|
||||||
let entity = cx.entity().downgrade();
|
|
||||||
let loading = Rc::new(Cell::new(false));
|
|
||||||
|
|
||||||
Notification::new()
|
|
||||||
.custom_id(SharedString::from(&req.challenge))
|
|
||||||
.autohide(false)
|
|
||||||
.icon(IconName::Info)
|
|
||||||
.title(SharedString::from("Authentication Required"))
|
|
||||||
.content(move |_window, cx| {
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.child(SharedString::from(AUTH_MESSAGE))
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.py_1()
|
|
||||||
.px_1p5()
|
|
||||||
.rounded_sm()
|
|
||||||
.text_xs()
|
|
||||||
.bg(cx.theme().warning_background)
|
|
||||||
.text_color(cx.theme().warning_foreground)
|
|
||||||
.child(url.clone()),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
})
|
|
||||||
.action(move |_window, _cx| {
|
|
||||||
let view = entity.clone();
|
|
||||||
let req = req.clone();
|
|
||||||
|
|
||||||
Button::new("approve")
|
|
||||||
.label("Approve")
|
|
||||||
.small()
|
|
||||||
.primary()
|
|
||||||
.loading(loading.get())
|
|
||||||
.disabled(loading.get())
|
|
||||||
.on_click({
|
|
||||||
let loading = Rc::clone(&loading);
|
|
||||||
|
|
||||||
move |_ev, window, cx| {
|
|
||||||
// Set loading state to true
|
|
||||||
loading.set(true);
|
|
||||||
|
|
||||||
// Process to approve the request
|
|
||||||
view.update(cx, |this, cx| {
|
|
||||||
this.response(&req, window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ common = { path = "../common" }
|
|||||||
|
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
smol.workspace = true
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
@@ -18,3 +17,6 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
||||||
paste = "1.0.15"
|
paste = "1.0.15"
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
smol.workspace = true
|
||||||
|
|||||||
+80
-86
@@ -1,13 +1,11 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::fmt::Display;
|
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{Error, anyhow};
|
||||||
use common::config_dir;
|
use common::config_dir;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use theme::{Theme, ThemeFamily, ThemeMode};
|
use theme::{Theme, ThemeFamily, ThemeMode};
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
@@ -20,13 +18,15 @@ macro_rules! setting_accessors {
|
|||||||
$(
|
$(
|
||||||
paste::paste! {
|
paste::paste! {
|
||||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||||
Self::global(cx).read(cx).values.$field.clone()
|
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn [<update_ $field>](value: $type, cx: &mut App) {
|
pub fn [<update_ $field>](value: $type, cx: &mut App) {
|
||||||
Self::global(cx).update(cx, |this, cx| {
|
Self::global(cx).update(cx, |this, cx| {
|
||||||
this.values.$field = value;
|
this.inner.update(cx, |inner, cx| {
|
||||||
cx.notify();
|
inner.$field = value;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,36 +40,18 @@ setting_accessors! {
|
|||||||
pub theme_mode: ThemeMode,
|
pub theme_mode: ThemeMode,
|
||||||
pub hide_avatar: bool,
|
pub hide_avatar: bool,
|
||||||
pub screening: bool,
|
pub screening: bool,
|
||||||
pub auth_mode: AuthMode,
|
pub nip4e: bool,
|
||||||
pub trusted_relays: HashSet<RelayUrl>,
|
pub trusted_relays: Vec<String>,
|
||||||
pub room_configs: HashMap<u64, RoomConfig>,
|
|
||||||
pub file_server: Url,
|
pub file_server: Url,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authentication mode
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub enum AuthMode {
|
|
||||||
#[default]
|
|
||||||
Auto,
|
|
||||||
Manual,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for AuthMode {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
AuthMode::Auto => write!(f, "Auto"),
|
|
||||||
AuthMode::Manual => write!(f, "Ask every time"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signer kind
|
/// Signer kind
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum SignerKind {
|
pub enum SignerKind {
|
||||||
#[default]
|
|
||||||
Auto,
|
Auto,
|
||||||
User,
|
|
||||||
Encryption,
|
Encryption,
|
||||||
|
#[default]
|
||||||
|
User,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SignerKind {
|
impl SignerKind {
|
||||||
@@ -97,7 +79,7 @@ impl RoomConfig {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
backup: true,
|
backup: true,
|
||||||
signer_kind: SignerKind::Auto,
|
signer_kind: SignerKind::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,14 +119,11 @@ pub struct Settings {
|
|||||||
/// Enable screening for unknown chat requests
|
/// Enable screening for unknown chat requests
|
||||||
pub screening: bool,
|
pub screening: bool,
|
||||||
|
|
||||||
/// Authentication mode
|
/// Enable decoupling encryption key
|
||||||
pub auth_mode: AuthMode,
|
pub nip4e: bool,
|
||||||
|
|
||||||
/// Trusted relays; Coop will automatically authenticate with these relays
|
/// Trusted relays; Coop will automatically authenticate with these relays
|
||||||
pub trusted_relays: HashSet<RelayUrl>,
|
pub trusted_relays: Vec<String>,
|
||||||
|
|
||||||
/// Configuration for each chat room
|
|
||||||
pub room_configs: HashMap<u64, RoomConfig>,
|
|
||||||
|
|
||||||
/// Server for blossom media attachments
|
/// Server for blossom media attachments
|
||||||
pub file_server: Url,
|
pub file_server: Url,
|
||||||
@@ -157,9 +136,8 @@ impl Default for Settings {
|
|||||||
theme_mode: ThemeMode::default(),
|
theme_mode: ThemeMode::default(),
|
||||||
hide_avatar: false,
|
hide_avatar: false,
|
||||||
screening: true,
|
screening: true,
|
||||||
auth_mode: AuthMode::default(),
|
nip4e: false,
|
||||||
trusted_relays: HashSet::default(),
|
trusted_relays: vec![],
|
||||||
room_configs: HashMap::default(),
|
|
||||||
file_server: Url::parse("https://blossom.band/").unwrap(),
|
file_server: Url::parse("https://blossom.band/").unwrap(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,7 +156,7 @@ impl Global for GlobalAppSettings {}
|
|||||||
/// Application settings
|
/// Application settings
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
/// Settings
|
/// Settings
|
||||||
values: Settings,
|
inner: Entity<Settings>,
|
||||||
|
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
@@ -196,11 +174,12 @@ impl AppSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let inner = cx.new(|_| Settings::default());
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Observe and automatically save settings on changes
|
// Observe and automatically save settings on changes
|
||||||
cx.observe_self(|this, cx| {
|
cx.observe(&inner, |this, _inner, cx| {
|
||||||
this.save(cx);
|
this.save(cx);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -211,27 +190,30 @@ impl AppSettings {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
values: Settings::default(),
|
inner,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update settings
|
/// Update settings
|
||||||
fn set_settings(&mut self, settings: Settings, cx: &mut Context<Self>) {
|
fn set_settings(&mut self, settings: Settings, cx: &mut Context<Self>) {
|
||||||
self.values = settings;
|
self.inner.update(cx, |this, cx| {
|
||||||
cx.notify();
|
*this = settings;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load settings
|
/// Load settings
|
||||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
||||||
let path = config_dir().join(".settings");
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
if let Ok(content) = smol::fs::read_to_string(&path).await {
|
let path = config_dir().join(".settings");
|
||||||
Ok(serde_json::from_str(&content)?)
|
if let Ok(content) = smol::fs::read_to_string(&path).await {
|
||||||
} else {
|
return Ok(serde_json::from_str(&content)?);
|
||||||
Err(anyhow!("Not found"))
|
}
|
||||||
}
|
}
|
||||||
|
Err(anyhow!("Not found"))
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
@@ -249,19 +231,15 @@ impl AppSettings {
|
|||||||
|
|
||||||
/// Save settings
|
/// Save settings
|
||||||
pub fn save(&mut self, cx: &mut Context<Self>) {
|
pub fn save(&mut self, cx: &mut Context<Self>) {
|
||||||
let settings = self.values.clone();
|
let settings = self.inner.read(cx);
|
||||||
|
if let Ok(content) = serde_json::to_string(&settings) {
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let path = config_dir().join(".settings");
|
cx.background_spawn(async move {
|
||||||
let content = serde_json::to_string(&settings)?;
|
let path = config_dir().join(".settings");
|
||||||
|
smol::fs::write(&path, content).await.ok();
|
||||||
// Write settings to file
|
})
|
||||||
smol::fs::write(&path, content).await?;
|
.detach();
|
||||||
|
}
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
task.detach();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set theme
|
/// Set theme
|
||||||
@@ -270,50 +248,66 @@ impl AppSettings {
|
|||||||
T: Into<String>,
|
T: Into<String>,
|
||||||
{
|
{
|
||||||
// Update settings
|
// Update settings
|
||||||
self.values.theme = Some(theme.into());
|
self.inner.update(cx, |this, cx| {
|
||||||
cx.notify();
|
this.theme = Some(theme.into());
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
// Apply the new theme
|
// Apply the new theme
|
||||||
self.apply_theme(window, cx);
|
self.apply_theme(window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reset theme
|
||||||
|
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.inner.update(cx, |this, cx| {
|
||||||
|
this.theme = None;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
self.apply_theme(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply theme
|
/// Apply theme
|
||||||
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if let Some(name) = self.values.theme.as_ref() {
|
if let Some(name) = self.inner.read(cx).theme.as_ref() {
|
||||||
|
let mode = self.inner.read(cx).theme_mode;
|
||||||
|
|
||||||
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
|
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
|
||||||
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
|
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
|
||||||
|
Theme::change(mode, Some(window), cx);
|
||||||
|
} else {
|
||||||
|
log::info!("Failed to load theme: {name}");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx);
|
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset theme
|
/// Check if decoupling encryption key is enabled
|
||||||
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn is_nip4e_enabled(&self, cx: &App) -> bool {
|
||||||
self.values.theme = None;
|
self.inner.read(cx).nip4e
|
||||||
self.apply_theme(window, cx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the given relay is already authenticated
|
/// Check if the given relay is already authenticated
|
||||||
pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
|
pub fn trusted_relay(&self, url: &RelayUrl, cx: &App) -> bool {
|
||||||
self.values.trusted_relays.iter().any(|relay| {
|
self.inner
|
||||||
relay.as_str_without_trailing_slash() == url.as_str_without_trailing_slash()
|
.read(cx)
|
||||||
})
|
.trusted_relays
|
||||||
|
.iter()
|
||||||
|
.any(|relay| relay == url.as_str_without_trailing_slash())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a relay to the trusted list
|
/// Add a relay to the trusted list
|
||||||
pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
||||||
self.values.trusted_relays.insert(url.clone());
|
self.inner.update(cx, |this, cx| {
|
||||||
cx.notify();
|
if !this
|
||||||
}
|
.trusted_relays
|
||||||
|
.iter()
|
||||||
/// Add a room configuration
|
.any(|relay| relay == url.as_str_without_trailing_slash())
|
||||||
pub fn add_room_config(&mut self, id: u64, config: RoomConfig, cx: &mut Context<Self>) {
|
{
|
||||||
self.values
|
this.trusted_relays
|
||||||
.room_configs
|
.push(url.as_str_without_trailing_slash().to_string());
|
||||||
.entry(id)
|
cx.notify();
|
||||||
.and_modify(|this| *this = config)
|
}
|
||||||
.or_default();
|
});
|
||||||
cx.notify();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-8
@@ -9,21 +9,27 @@ common = { path = "../common" }
|
|||||||
|
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
nostr-lmdb.workspace = true
|
nostr-gossip-memory.workspace = true
|
||||||
nostr-connect.workspace = true
|
|
||||||
nostr-blossom.workspace = true
|
nostr-blossom.workspace = true
|
||||||
|
nostr-connect.workspace = true
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui_tokio.workspace = true
|
instant.workspace = true
|
||||||
smol.workspace = true
|
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
webbrowser.workspace = true
|
webbrowser.workspace = true
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
||||||
rustls = "0.23"
|
|
||||||
petname = "2.0.2"
|
|
||||||
whoami = "1.6.1"
|
|
||||||
mime_guess = "2.0.4"
|
mime_guess = "2.0.4"
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
nostr-memory.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
||||||
|
nostr-lmdb.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
gpui_tokio.workspace = true
|
||||||
|
rustls = "0.23"
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{Error, anyhow};
|
||||||
use gpui::AsyncApp;
|
use gpui::AsyncApp;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
use mime_guess::from_path;
|
use mime_guess::from_path;
|
||||||
use nostr_blossom::prelude::*;
|
use nostr_blossom::prelude::*;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
|
||||||
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
let content_type = from_path(&path).first_or_octet_stream().to_string();
|
||||||
let data = smol::fs::read(path).await?;
|
let data = smol::fs::read(path).await?;
|
||||||
@@ -25,3 +27,8 @@ pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Er
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("Upload error: {e}"))?
|
.map_err(|e| anyhow!("Upload error: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
|
||||||
|
Err(anyhow!("File upload not supported on web"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
/// Client name (Application name)
|
/// Client name (Application name)
|
||||||
pub const CLIENT_NAME: &str = "Coop";
|
pub const CLIENT_NAME: &str = "Coop";
|
||||||
|
|
||||||
@@ -10,51 +8,46 @@ pub const COOP_PUBKEY: &str = "npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5w
|
|||||||
pub const APP_ID: &str = "su.reya.coop";
|
pub const APP_ID: &str = "su.reya.coop";
|
||||||
|
|
||||||
/// Keyring name
|
/// Keyring name
|
||||||
pub const KEYRING: &str = "Coop Safe Storage";
|
pub const MASTER_KEYRING: &str = "Coop Master Key";
|
||||||
|
pub const USER_KEYRING: &str = "Coop User Credential";
|
||||||
|
|
||||||
/// Default timeout for subscription
|
/// Default timeout for subscription
|
||||||
pub const TIMEOUT: u64 = 2;
|
pub const TIMEOUT: u64 = 2;
|
||||||
|
|
||||||
|
/// Default image cache size
|
||||||
|
pub const IMAGE_CACHE_SIZE: usize = 20;
|
||||||
|
|
||||||
/// Default delay for searching
|
/// Default delay for searching
|
||||||
pub const FIND_DELAY: u64 = 600;
|
pub const FIND_DELAY: u64 = 600;
|
||||||
|
|
||||||
/// Default limit for searching
|
/// Default limit for searching
|
||||||
pub const FIND_LIMIT: usize = 20;
|
pub const FIND_LIMIT: usize = 20;
|
||||||
|
|
||||||
/// Default timeout for Nostr Connect
|
|
||||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
|
|
||||||
|
|
||||||
/// Default Nostr Connect relay
|
|
||||||
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
|
|
||||||
|
|
||||||
/// Default subscription id for device gift wrap events
|
/// Default subscription id for device gift wrap events
|
||||||
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
|
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
|
||||||
|
|
||||||
/// Default subscription id for user gift wrap events
|
/// Default subscription id for user gift wrap events
|
||||||
pub const USER_GIFTWRAP: &str = "user-gift-wraps";
|
pub const USER_GIFTWRAP: &str = "user-gift-wraps";
|
||||||
|
|
||||||
|
/// Default timeout for Nostr Connect
|
||||||
|
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||||
|
|
||||||
|
/// Default Nostr Connect relay
|
||||||
|
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com";
|
||||||
|
|
||||||
/// Default vertex relays
|
/// Default vertex relays
|
||||||
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
||||||
|
|
||||||
|
/// Default search relays
|
||||||
|
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||||
|
|
||||||
/// Default search relays
|
/// Default search relays
|
||||||
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
|
||||||
|
|
||||||
/// Default bootstrap relays
|
/// Default bootstrap relays
|
||||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||||
"wss://nos.lol",
|
"wss://relay.ditto.pub",
|
||||||
"wss://relay.damus.io",
|
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
"wss://user.kindpag.es",
|
"wss://relay.nostr.net",
|
||||||
|
"wss://profiles.nostr1.com",
|
||||||
];
|
];
|
||||||
|
|
||||||
static APP_NAME: OnceLock<String> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Get the app name
|
|
||||||
pub fn app_name() -> &'static String {
|
|
||||||
APP_NAME.get_or_init(|| {
|
|
||||||
let devicename = whoami::devicename();
|
|
||||||
let platform = whoami::platform();
|
|
||||||
|
|
||||||
format!("{CLIENT_NAME} on {platform} ({devicename})")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
|
|
||||||
use gpui::SharedString;
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
|
|
||||||
/// Gossip
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct Gossip {
|
|
||||||
relays: HashMap<PublicKey, HashSet<(RelayUrl, Option<RelayMetadata>)>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Gossip {
|
|
||||||
pub fn read_only_relays(&self, public_key: &PublicKey) -> Vec<SharedString> {
|
|
||||||
self.relays
|
|
||||||
.get(public_key)
|
|
||||||
.map(|relays| {
|
|
||||||
relays
|
|
||||||
.iter()
|
|
||||||
.map(|(url, _)| url.to_string().into())
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get read relays for a given public key
|
|
||||||
pub fn read_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
|
|
||||||
self.relays
|
|
||||||
.get(public_key)
|
|
||||||
.map(|relays| {
|
|
||||||
relays
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(url, metadata)| {
|
|
||||||
if metadata.is_none() || metadata == &Some(RelayMetadata::Read) {
|
|
||||||
Some(url.to_owned())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get write relays for a given public key
|
|
||||||
pub fn write_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
|
|
||||||
self.relays
|
|
||||||
.get(public_key)
|
|
||||||
.map(|relays| {
|
|
||||||
relays
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(url, metadata)| {
|
|
||||||
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
|
|
||||||
Some(url.to_owned())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert gossip relays for a public key
|
|
||||||
pub fn insert_relays(&mut self, event: &Event) {
|
|
||||||
self.relays.entry(event.pubkey).or_default().extend(
|
|
||||||
event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.filter_map(|tag| {
|
|
||||||
if let Some(TagStandard::RelayMetadata {
|
|
||||||
relay_url,
|
|
||||||
metadata,
|
|
||||||
}) = tag.clone().to_standardized()
|
|
||||||
{
|
|
||||||
Some((relay_url, metadata))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.take(3),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+300
-692
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
|
use futures::io::AsyncReadExt;
|
||||||
use gpui::http_client::{AsyncBody, HttpClient};
|
use gpui::http_client::{AsyncBody, HttpClient};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smol::io::AsyncReadExt;
|
use serde_json::Value;
|
||||||
|
|
||||||
#[allow(async_fn_in_trait)]
|
#[allow(async_fn_in_trait)]
|
||||||
pub trait NostrAddress {
|
pub trait NostrAddress {
|
||||||
|
|||||||
@@ -1,28 +1,6 @@
|
|||||||
use gpui::SharedString;
|
use gpui::SharedString;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
|
|
||||||
pub enum DeviceState {
|
|
||||||
#[default]
|
|
||||||
Idle,
|
|
||||||
Requesting,
|
|
||||||
Set,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DeviceState {
|
|
||||||
pub fn idle(&self) -> bool {
|
|
||||||
matches!(self, DeviceState::Idle)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn requesting(&self) -> bool {
|
|
||||||
matches!(self, DeviceState::Requesting)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set(&self) -> bool {
|
|
||||||
matches!(self, DeviceState::Set)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Announcement
|
/// Announcement
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub struct Announcement {
|
pub struct Announcement {
|
||||||
@@ -38,14 +16,15 @@ impl From<&Event> for Announcement {
|
|||||||
let public_key = val
|
let public_key = val
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.iter()
|
||||||
.find(|tag| tag.kind().as_str() == "n")
|
.find(|tag| tag.kind() == "n")
|
||||||
.and_then(|tag| tag.content())
|
.and_then(|tag| tag.content())
|
||||||
.and_then(|c| PublicKey::parse(c).ok())
|
.and_then(|c| PublicKey::parse(c).ok())
|
||||||
.unwrap_or(val.pubkey);
|
.unwrap_or(val.pubkey);
|
||||||
|
|
||||||
let client_name = val
|
let client_name = val
|
||||||
.tags
|
.tags
|
||||||
.find(TagKind::Client)
|
.iter()
|
||||||
|
.find(|tag| tag.kind() == "client")
|
||||||
.and_then(|tag| tag.content())
|
.and_then(|tag| tag.content())
|
||||||
.map(|c| c.to_string());
|
.map(|c| c.to_string());
|
||||||
|
|
||||||
+178
-118
@@ -1,146 +1,206 @@
|
|||||||
use std::borrow::Cow;
|
use std::error::Error;
|
||||||
use std::result::Result;
|
use std::fmt;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::future::Future;
|
||||||
use std::sync::Arc;
|
use std::pin::Pin;
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
|
use nostr_connect::client::AuthUrlHandler;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smol::lock::RwLock;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct CoopSigner {
|
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
|
||||||
/// User's signer
|
|
||||||
signer: RwLock<Arc<dyn NostrSigner>>,
|
|
||||||
|
|
||||||
/// User's signer public key
|
impl fmt::Display for UniversalSignerError {
|
||||||
signer_pkey: RwLock<Option<PublicKey>>,
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
/// Specific signer for encryption purposes
|
}
|
||||||
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>,
|
|
||||||
|
|
||||||
/// By default, Coop generates a new signer for new users.
|
|
||||||
///
|
|
||||||
/// This flag indicates whether the signer is user-owned or Coop-generated.
|
|
||||||
owned: AtomicBool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CoopSigner {
|
impl Error for UniversalSignerError {
|
||||||
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
|
Some(&*self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UniversalSignerError {
|
||||||
|
pub fn new<E>(err: E) -> Self
|
||||||
|
where
|
||||||
|
E: Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
UniversalSignerError(Box::new(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct UniversalSigner {
|
||||||
|
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UniversalSigner {
|
||||||
pub fn new<T>(signer: T) -> Self
|
pub fn new<T>(signer: T) -> Self
|
||||||
where
|
where
|
||||||
T: IntoNostrSigner,
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||||
|
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
||||||
|
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
|
||||||
|
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
Self {
|
Self {
|
||||||
signer: RwLock::new(signer.into_nostr_signer()),
|
inner: Arc::new(RwLock::new(Arc::new(InnerSignerImpl(signer)))),
|
||||||
signer_pkey: RwLock::new(None),
|
|
||||||
encryption_signer: RwLock::new(None),
|
|
||||||
owned: AtomicBool::new(false),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current signer.
|
/// Swap the inner signer in-place. All clones see the new signer.
|
||||||
pub async fn get(&self) -> Arc<dyn NostrSigner> {
|
pub fn swap_inner<T>(&self, new_signer: T)
|
||||||
self.signer.read().await.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the encryption signer.
|
|
||||||
pub async fn get_encryption_signer(&self) -> Option<Arc<dyn NostrSigner>> {
|
|
||||||
self.encryption_signer.read().await.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get public key
|
|
||||||
pub fn public_key(&self) -> Option<PublicKey> {
|
|
||||||
self.signer_pkey.read_blocking().to_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the flag indicating whether the signer is user-owned.
|
|
||||||
pub fn owned(&self) -> bool {
|
|
||||||
self.owned.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Switch the current signer to a new signer.
|
|
||||||
pub async fn switch<T>(&self, new: T, owned: bool)
|
|
||||||
where
|
where
|
||||||
T: IntoNostrSigner,
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||||
|
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
||||||
|
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
|
||||||
|
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let new_signer = new.into_nostr_signer();
|
*self.inner.write().expect("RwLock poisoned") = Arc::new(InnerSignerImpl(new_signer));
|
||||||
let public_key = new_signer.get_public_key().await.ok();
|
|
||||||
let mut signer = self.signer.write().await;
|
|
||||||
let mut signer_pkey = self.signer_pkey.write().await;
|
|
||||||
let mut encryption_signer = self.encryption_signer.write().await;
|
|
||||||
|
|
||||||
// Switch to the new signer
|
|
||||||
*signer = new_signer;
|
|
||||||
|
|
||||||
// Update the public key
|
|
||||||
*signer_pkey = public_key;
|
|
||||||
|
|
||||||
// Reset the encryption signer
|
|
||||||
*encryption_signer = None;
|
|
||||||
|
|
||||||
// Update the owned flag
|
|
||||||
self.owned.store(owned, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the encryption signer.
|
|
||||||
pub async fn set_encryption_signer<T>(&self, new: T)
|
|
||||||
where
|
|
||||||
T: IntoNostrSigner,
|
|
||||||
{
|
|
||||||
let mut encryption_signer = self.encryption_signer.write().await;
|
|
||||||
*encryption_signer = Some(new.into_nostr_signer());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NostrSigner for CoopSigner {
|
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
||||||
#[allow(mismatched_lifetime_syntaxes)]
|
fn get_public_key_async(
|
||||||
fn backend(&self) -> SignerBackend {
|
&self,
|
||||||
SignerBackend::Custom(Cow::Borrowed("custom"))
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
|
||||||
}
|
fn sign_event_async(
|
||||||
|
&self,
|
||||||
fn get_public_key<'a>(&'a self) -> BoxedFuture<'a, Result<PublicKey, SignerError>> {
|
|
||||||
Box::pin(async move { self.get().await.get_public_key().await })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sign_event<'a>(
|
|
||||||
&'a self,
|
|
||||||
unsigned: UnsignedEvent,
|
unsigned: UnsignedEvent,
|
||||||
) -> BoxedFuture<'a, Result<Event, SignerError>> {
|
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
|
||||||
Box::pin(async move { self.get().await.sign_event(unsigned).await })
|
fn nip44_encrypt_async<'a>(
|
||||||
}
|
|
||||||
|
|
||||||
fn nip04_encrypt<'a>(
|
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
content: &'a str,
|
content: &'a str,
|
||||||
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||||
Box::pin(async move { self.get().await.nip04_encrypt(public_key, content).await })
|
fn nip44_decrypt_async<'a>(
|
||||||
}
|
|
||||||
|
|
||||||
fn nip04_decrypt<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
encrypted_content: &'a str,
|
|
||||||
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
|
||||||
Box::pin(async move {
|
|
||||||
self.get()
|
|
||||||
.await
|
|
||||||
.nip04_decrypt(public_key, encrypted_content)
|
|
||||||
.await
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nip44_encrypt<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
content: &'a str,
|
|
||||||
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
|
||||||
Box::pin(async move { self.get().await.nip44_encrypt(public_key, content).await })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nip44_decrypt<'a>(
|
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
payload: &'a str,
|
payload: &'a str,
|
||||||
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||||
Box::pin(async move { self.get().await.nip44_decrypt(public_key, payload).await })
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct InnerSignerImpl<T>(T);
|
||||||
|
|
||||||
|
impl<T> InnerSigner for InnerSignerImpl<T>
|
||||||
|
where
|
||||||
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static,
|
||||||
|
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
|
||||||
|
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
|
||||||
|
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
fn get_public_key_async(
|
||||||
|
&self,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
AsyncGetPublicKey::get_public_key_async(&self.0)
|
||||||
|
.await
|
||||||
|
.map_err(UniversalSignerError::new)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_event_async(
|
||||||
|
&self,
|
||||||
|
unsigned: UnsignedEvent,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
||||||
|
.await
|
||||||
|
.map_err(UniversalSignerError::new)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nip44_encrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
content: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
||||||
|
.await
|
||||||
|
.map_err(UniversalSignerError::new)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nip44_decrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
payload: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
||||||
|
.await
|
||||||
|
.map_err(UniversalSignerError::new)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UniversalSigner {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn with_inner<R>(&self, f: impl FnOnce(&dyn InnerSigner) -> R) -> R {
|
||||||
|
let guard = self.inner.read().expect("RwLock poisoned");
|
||||||
|
f(&**guard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncGetPublicKey for UniversalSigner {
|
||||||
|
type Error = UniversalSignerError;
|
||||||
|
|
||||||
|
fn get_public_key_async(
|
||||||
|
&self,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
|
||||||
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
|
Box::pin(async move { inner.get_public_key_async().await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncSignEvent for UniversalSigner {
|
||||||
|
type Error = UniversalSignerError;
|
||||||
|
|
||||||
|
fn sign_event_async(
|
||||||
|
&self,
|
||||||
|
unsigned: UnsignedEvent,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
|
||||||
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
|
Box::pin(async move { inner.sign_event_async(unsigned).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncNip44 for UniversalSigner {
|
||||||
|
type Error = UniversalSignerError;
|
||||||
|
|
||||||
|
fn nip44_encrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
content: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||||
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
|
Box::pin(async move { inner.nip44_encrypt_async(public_key, content).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nip44_decrypt_async<'a>(
|
||||||
|
&'a self,
|
||||||
|
public_key: &'a PublicKey,
|
||||||
|
payload: &'a str,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||||
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
|
Box::pin(async move { inner.nip44_decrypt_async(public_key, payload).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CoopAuthUrlHandler;
|
||||||
|
|
||||||
|
impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||||
|
fn on_auth_url(
|
||||||
|
&self,
|
||||||
|
auth_url: Url,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
webbrowser::open(auth_url.as_str()).unwrap();
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-18
@@ -1,4 +1,4 @@
|
|||||||
use gpui::{hsla, Hsla, Rgba};
|
use gpui::{Hsla, Rgba, hsla};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -30,6 +30,8 @@ pub struct ThemeColors {
|
|||||||
pub text_muted: Hsla,
|
pub text_muted: Hsla,
|
||||||
pub text_placeholder: Hsla,
|
pub text_placeholder: Hsla,
|
||||||
pub text_accent: Hsla,
|
pub text_accent: Hsla,
|
||||||
|
pub text_danger: Hsla,
|
||||||
|
pub text_warning: Hsla,
|
||||||
|
|
||||||
// Icon colors
|
// Icon colors
|
||||||
pub icon: Hsla,
|
pub icon: Hsla,
|
||||||
@@ -77,11 +79,11 @@ pub struct ThemeColors {
|
|||||||
pub ghost_element_disabled: Hsla,
|
pub ghost_element_disabled: Hsla,
|
||||||
|
|
||||||
// Tab colors
|
// Tab colors
|
||||||
pub tab_inactive_background: Hsla,
|
pub tab_background: Hsla,
|
||||||
pub tab_inactive_foreground: Hsla,
|
pub tab_foreground: Hsla,
|
||||||
|
pub tab_hover_background: Hsla,
|
||||||
pub tab_active_background: Hsla,
|
pub tab_active_background: Hsla,
|
||||||
pub tab_active_foreground: Hsla,
|
pub tab_active_foreground: Hsla,
|
||||||
pub tab_hover_foreground: Hsla,
|
|
||||||
|
|
||||||
// Scrollbar colors
|
// Scrollbar colors
|
||||||
pub scrollbar_thumb_background: Hsla,
|
pub scrollbar_thumb_background: Hsla,
|
||||||
@@ -111,7 +113,7 @@ impl ThemeColors {
|
|||||||
panel_background: neutral().light().step_1(),
|
panel_background: neutral().light().step_1(),
|
||||||
overlay: neutral().light_alpha().step_3(),
|
overlay: neutral().light_alpha().step_3(),
|
||||||
title_bar: neutral().light().step_2(),
|
title_bar: neutral().light().step_2(),
|
||||||
title_bar_inactive: neutral().light().step_3(),
|
title_bar_inactive: neutral().light().step_1(),
|
||||||
window_border: hsl(240.0, 5.9, 78.0),
|
window_border: hsl(240.0, 5.9, 78.0),
|
||||||
|
|
||||||
border: neutral().light().step_6(),
|
border: neutral().light().step_6(),
|
||||||
@@ -125,7 +127,9 @@ impl ThemeColors {
|
|||||||
text: neutral().light().step_12(),
|
text: neutral().light().step_12(),
|
||||||
text_muted: neutral().light().step_11(),
|
text_muted: neutral().light().step_11(),
|
||||||
text_placeholder: neutral().light().step_10(),
|
text_placeholder: neutral().light().step_10(),
|
||||||
text_accent: brand().light().step_11(),
|
text_accent: brand().light().step_9(),
|
||||||
|
text_danger: danger().light().step_9(),
|
||||||
|
text_warning: warning().light().step_9(),
|
||||||
|
|
||||||
icon: neutral().light().step_11(),
|
icon: neutral().light().step_11(),
|
||||||
icon_muted: neutral().light().step_10(),
|
icon_muted: neutral().light().step_10(),
|
||||||
@@ -166,17 +170,17 @@ impl ThemeColors {
|
|||||||
ghost_element_selected: neutral().light().step_5(),
|
ghost_element_selected: neutral().light().step_5(),
|
||||||
ghost_element_disabled: neutral().light_alpha().step_2(),
|
ghost_element_disabled: neutral().light_alpha().step_2(),
|
||||||
|
|
||||||
tab_inactive_background: neutral().light().step_2(),
|
tab_background: neutral().light().step_3(),
|
||||||
tab_inactive_foreground: neutral().light().step_11(),
|
tab_foreground: neutral().light().step_11(),
|
||||||
|
tab_hover_background: neutral().light_alpha().step_4(),
|
||||||
tab_active_background: neutral().light().step_1(),
|
tab_active_background: neutral().light().step_1(),
|
||||||
tab_active_foreground: neutral().light().step_12(),
|
tab_active_foreground: neutral().light().step_12(),
|
||||||
tab_hover_foreground: brand().light().step_9(),
|
|
||||||
|
|
||||||
scrollbar_thumb_background: neutral().light_alpha().step_3(),
|
scrollbar_thumb_background: neutral().light_alpha().step_3(),
|
||||||
scrollbar_thumb_hover_background: neutral().light_alpha().step_4(),
|
scrollbar_thumb_hover_background: neutral().light_alpha().step_4(),
|
||||||
scrollbar_thumb_border: gpui::transparent_black(),
|
scrollbar_thumb_border: gpui::transparent_black(),
|
||||||
scrollbar_track_background: gpui::transparent_black(),
|
scrollbar_track_background: gpui::transparent_black(),
|
||||||
scrollbar_track_border: neutral().light().step_5(),
|
scrollbar_track_border: gpui::transparent_black(),
|
||||||
|
|
||||||
drop_target_background: brand().light_alpha().step_2(),
|
drop_target_background: brand().light_alpha().step_2(),
|
||||||
cursor: hsl(200., 100., 50.),
|
cursor: hsl(200., 100., 50.),
|
||||||
@@ -192,9 +196,9 @@ impl ThemeColors {
|
|||||||
background: neutral().dark().step_1(),
|
background: neutral().dark().step_1(),
|
||||||
surface_background: neutral().dark().step_2(),
|
surface_background: neutral().dark().step_2(),
|
||||||
elevated_surface_background: neutral().dark().step_3(),
|
elevated_surface_background: neutral().dark().step_3(),
|
||||||
panel_background: gpui::black(),
|
panel_background: neutral().dark().step_1(),
|
||||||
overlay: neutral().dark_alpha().step_3(),
|
overlay: neutral().dark_alpha().step_3(),
|
||||||
title_bar: gpui::transparent_black(),
|
title_bar: neutral().dark().step_2(),
|
||||||
title_bar_inactive: neutral().dark().step_1(),
|
title_bar_inactive: neutral().dark().step_1(),
|
||||||
window_border: hsl(240.0, 3.7, 28.0),
|
window_border: hsl(240.0, 3.7, 28.0),
|
||||||
|
|
||||||
@@ -209,7 +213,9 @@ impl ThemeColors {
|
|||||||
text: neutral().dark().step_12(),
|
text: neutral().dark().step_12(),
|
||||||
text_muted: neutral().dark().step_11(),
|
text_muted: neutral().dark().step_11(),
|
||||||
text_placeholder: neutral().dark().step_10(),
|
text_placeholder: neutral().dark().step_10(),
|
||||||
text_accent: brand().dark().step_11(),
|
text_accent: brand().dark().step_9(),
|
||||||
|
text_danger: danger().dark().step_9(),
|
||||||
|
text_warning: warning().dark().step_9(),
|
||||||
|
|
||||||
icon: neutral().dark().step_11(),
|
icon: neutral().dark().step_11(),
|
||||||
icon_muted: neutral().dark().step_10(),
|
icon_muted: neutral().dark().step_10(),
|
||||||
@@ -250,17 +256,17 @@ impl ThemeColors {
|
|||||||
ghost_element_selected: neutral().dark().step_5(),
|
ghost_element_selected: neutral().dark().step_5(),
|
||||||
ghost_element_disabled: neutral().dark_alpha().step_2(),
|
ghost_element_disabled: neutral().dark_alpha().step_2(),
|
||||||
|
|
||||||
tab_inactive_background: neutral().dark().step_2(),
|
tab_background: neutral().dark().step_3(),
|
||||||
tab_inactive_foreground: neutral().dark().step_11(),
|
tab_foreground: neutral().dark().step_11(),
|
||||||
tab_active_background: neutral().dark().step_3(),
|
tab_hover_background: neutral().dark_alpha().step_4(),
|
||||||
|
tab_active_background: neutral().dark().step_1(),
|
||||||
tab_active_foreground: neutral().dark().step_12(),
|
tab_active_foreground: neutral().dark().step_12(),
|
||||||
tab_hover_foreground: brand().dark().step_9(),
|
|
||||||
|
|
||||||
scrollbar_thumb_background: neutral().dark_alpha().step_3(),
|
scrollbar_thumb_background: neutral().dark_alpha().step_3(),
|
||||||
scrollbar_thumb_hover_background: neutral().dark_alpha().step_4(),
|
scrollbar_thumb_hover_background: neutral().dark_alpha().step_4(),
|
||||||
scrollbar_thumb_border: gpui::transparent_black(),
|
scrollbar_thumb_border: gpui::transparent_black(),
|
||||||
scrollbar_track_background: gpui::transparent_black(),
|
scrollbar_track_background: gpui::transparent_black(),
|
||||||
scrollbar_track_border: neutral().dark().step_5(),
|
scrollbar_track_border: gpui::transparent_black(),
|
||||||
|
|
||||||
drop_target_background: brand().dark_alpha().step_2(),
|
drop_target_background: brand().dark_alpha().step_2(),
|
||||||
cursor: hsl(200., 100., 50.),
|
cursor: hsl(200., 100., 50.),
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
use std::fmt::{self, Debug, Display, Formatter};
|
||||||
|
|
||||||
|
use gpui::{AbsoluteLength, Axis, Length, Pixels};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A enum for defining the placement of the element.
|
||||||
|
///
|
||||||
|
/// See also: [`Side`] if you need to define the left, right side.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Placement {
|
||||||
|
#[serde(rename = "top")]
|
||||||
|
Top,
|
||||||
|
#[serde(rename = "bottom")]
|
||||||
|
Bottom,
|
||||||
|
#[serde(rename = "left")]
|
||||||
|
Left,
|
||||||
|
#[serde(rename = "right")]
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Placement {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Placement::Top => write!(f, "Top"),
|
||||||
|
Placement::Bottom => write!(f, "Bottom"),
|
||||||
|
Placement::Left => write!(f, "Left"),
|
||||||
|
Placement::Right => write!(f, "Right"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Placement {
|
||||||
|
#[inline]
|
||||||
|
pub fn is_horizontal(&self) -> bool {
|
||||||
|
matches!(self, Placement::Left | Placement::Right)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn is_vertical(&self) -> bool {
|
||||||
|
matches!(self, Placement::Top | Placement::Bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn axis(&self) -> Axis {
|
||||||
|
match self {
|
||||||
|
Placement::Top | Placement::Bottom => Axis::Vertical,
|
||||||
|
Placement::Left | Placement::Right => Axis::Horizontal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A enum for defining the side of the element.
|
||||||
|
///
|
||||||
|
/// See also: [`Placement`] if you need to define the 4 edges.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Side {
|
||||||
|
#[serde(rename = "left")]
|
||||||
|
Left,
|
||||||
|
#[serde(rename = "right")]
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Side {
|
||||||
|
/// Returns true if the side is left.
|
||||||
|
#[inline]
|
||||||
|
pub fn is_left(&self) -> bool {
|
||||||
|
matches!(self, Self::Left)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the side is right.
|
||||||
|
#[inline]
|
||||||
|
pub fn is_right(&self) -> bool {
|
||||||
|
matches!(self, Self::Right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trait to extend the [`Axis`] enum with utility methods.
|
||||||
|
pub trait AxisExt {
|
||||||
|
#[allow(clippy::wrong_self_convention)]
|
||||||
|
fn is_horizontal(self) -> bool;
|
||||||
|
#[allow(clippy::wrong_self_convention)]
|
||||||
|
fn is_vertical(self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AxisExt for Axis {
|
||||||
|
#[inline]
|
||||||
|
fn is_horizontal(self) -> bool {
|
||||||
|
self == Axis::Horizontal
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_vertical(self) -> bool {
|
||||||
|
self == Axis::Vertical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trait for converting [`Pixels`] to `f32` and `f64`.
|
||||||
|
pub trait PixelsExt {
|
||||||
|
fn as_f32(&self) -> f32;
|
||||||
|
#[allow(clippy::wrong_self_convention)]
|
||||||
|
fn as_f64(self) -> f64;
|
||||||
|
}
|
||||||
|
impl PixelsExt for Pixels {
|
||||||
|
fn as_f32(&self) -> f32 {
|
||||||
|
f32::from(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_f64(self) -> f64 {
|
||||||
|
f64::from(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trait to extend the [`Length`] enum with utility methods.
|
||||||
|
pub trait LengthExt {
|
||||||
|
/// Converts the [`Length`] to [`Pixels`] based on a given `base_size` and `rem_size`.
|
||||||
|
///
|
||||||
|
/// If the [`Length`] is [`Length::Auto`], it returns `None`.
|
||||||
|
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LengthExt for Length {
|
||||||
|
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels> {
|
||||||
|
match self {
|
||||||
|
Length::Auto => None,
|
||||||
|
Length::Definite(len) => Some(len.to_pixels(base_size, rem_size)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A struct for defining the edges of an element.
|
||||||
|
///
|
||||||
|
/// A extend version of [`gpui::Edges`] to serialize/deserialize.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq)]
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct Edges<T: Clone + Debug + Default + PartialEq> {
|
||||||
|
/// The size of the top edge.
|
||||||
|
pub top: T,
|
||||||
|
/// The size of the right edge.
|
||||||
|
pub right: T,
|
||||||
|
/// The size of the bottom edge.
|
||||||
|
pub bottom: T,
|
||||||
|
/// The size of the left edge.
|
||||||
|
pub left: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Edges<T>
|
||||||
|
where
|
||||||
|
T: Clone + Debug + Default + PartialEq,
|
||||||
|
{
|
||||||
|
/// Creates a new `Edges` instance with all edges set to the same value.
|
||||||
|
pub fn all(value: T) -> Self {
|
||||||
|
Self {
|
||||||
|
top: value.clone(),
|
||||||
|
right: value.clone(),
|
||||||
|
bottom: value.clone(),
|
||||||
|
left: value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-4
@@ -1,9 +1,11 @@
|
|||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::{px, App, Global, Pixels, SharedString, Window};
|
use gpui::{App, Global, Pixels, SharedString, Window, px};
|
||||||
|
|
||||||
mod colors;
|
mod colors;
|
||||||
|
mod geometry;
|
||||||
|
mod notification;
|
||||||
mod platform_kind;
|
mod platform_kind;
|
||||||
mod registry;
|
mod registry;
|
||||||
mod scale;
|
mod scale;
|
||||||
@@ -11,6 +13,8 @@ mod scrollbar_mode;
|
|||||||
mod theme;
|
mod theme;
|
||||||
|
|
||||||
pub use colors::*;
|
pub use colors::*;
|
||||||
|
pub use geometry::*;
|
||||||
|
pub use notification::*;
|
||||||
pub use platform_kind::PlatformKind;
|
pub use platform_kind::PlatformKind;
|
||||||
pub use registry::*;
|
pub use registry::*;
|
||||||
pub use scale::*;
|
pub use scale::*;
|
||||||
@@ -30,7 +34,7 @@ pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
|
|||||||
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
|
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
|
||||||
|
|
||||||
/// Defines workspace tabbar height
|
/// Defines workspace tabbar height
|
||||||
pub const TABBAR_HEIGHT: Pixels = px(28.0);
|
pub const TABBAR_HEIGHT: Pixels = px(44.0);
|
||||||
|
|
||||||
/// Defines default sidebar width
|
/// Defines default sidebar width
|
||||||
pub const SIDEBAR_WIDTH: Pixels = px(240.);
|
pub const SIDEBAR_WIDTH: Pixels = px(240.);
|
||||||
@@ -82,6 +86,9 @@ pub struct Theme {
|
|||||||
/// Show the scrollbar mode, default: scrolling
|
/// Show the scrollbar mode, default: scrolling
|
||||||
pub scrollbar_mode: ScrollbarMode,
|
pub scrollbar_mode: ScrollbarMode,
|
||||||
|
|
||||||
|
/// Notification settings
|
||||||
|
pub notification: NotificationSettings,
|
||||||
|
|
||||||
/// Platform kind
|
/// Platform kind
|
||||||
pub platform: PlatformKind,
|
pub platform: PlatformKind,
|
||||||
}
|
}
|
||||||
@@ -185,7 +192,6 @@ impl From<ThemeFamily> for Theme {
|
|||||||
let mode = ThemeMode::default();
|
let mode = ThemeMode::default();
|
||||||
|
|
||||||
// Define the font family based on the platform.
|
// Define the font family based on the platform.
|
||||||
// TODO: Use native fonts on Linux too.
|
|
||||||
let font_family = match platform {
|
let font_family = match platform {
|
||||||
PlatformKind::Linux => "Inter",
|
PlatformKind::Linux => "Inter",
|
||||||
_ => ".SystemUIFont",
|
_ => ".SystemUIFont",
|
||||||
@@ -200,10 +206,11 @@ impl From<ThemeFamily> for Theme {
|
|||||||
Theme {
|
Theme {
|
||||||
font_size: px(15.),
|
font_size: px(15.),
|
||||||
font_family: font_family.into(),
|
font_family: font_family.into(),
|
||||||
radius: px(5.),
|
radius: px(6.),
|
||||||
radius_lg: px(10.),
|
radius_lg: px(10.),
|
||||||
shadow: true,
|
shadow: true,
|
||||||
scrollbar_mode: ScrollbarMode::default(),
|
scrollbar_mode: ScrollbarMode::default(),
|
||||||
|
notification: NotificationSettings::default(),
|
||||||
mode,
|
mode,
|
||||||
colors: *colors,
|
colors: *colors,
|
||||||
theme: Rc::new(family),
|
theme: Rc::new(family),
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
use gpui::{Anchor, Pixels, px};
|
||||||
|
|
||||||
|
use crate::{Edges, TITLEBAR_HEIGHT};
|
||||||
|
|
||||||
|
/// The settings for notifications.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NotificationSettings {
|
||||||
|
/// The placement of the notification, default: [`Anchor::TopRight`]
|
||||||
|
pub placement: Anchor,
|
||||||
|
/// The margins of the notification with respect to the window edges.
|
||||||
|
pub margins: Edges<Pixels>,
|
||||||
|
/// The maximum number of notifications to show at once, default: 10
|
||||||
|
pub max_items: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NotificationSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
let offset = px(16.);
|
||||||
|
Self {
|
||||||
|
placement: Anchor::TopRight,
|
||||||
|
margins: Edges {
|
||||||
|
top: TITLEBAR_HEIGHT + offset, // avoid overlap with title bar
|
||||||
|
right: offset,
|
||||||
|
bottom: offset,
|
||||||
|
left: offset,
|
||||||
|
},
|
||||||
|
max_items: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ pub enum PlatformKind {
|
|||||||
Mac,
|
Mac,
|
||||||
Linux,
|
Linux,
|
||||||
Windows,
|
Windows,
|
||||||
|
Web,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlatformKind {
|
impl PlatformKind {
|
||||||
@@ -11,22 +12,21 @@ impl PlatformKind {
|
|||||||
Self::Linux
|
Self::Linux
|
||||||
} else if cfg!(target_os = "windows") {
|
} else if cfg!(target_os = "windows") {
|
||||||
Self::Windows
|
Self::Windows
|
||||||
} else {
|
} else if cfg!(target_os = "macos") {
|
||||||
Self::Mac
|
Self::Mac
|
||||||
|
} else {
|
||||||
|
Self::Web
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn is_linux(&self) -> bool {
|
pub fn is_linux(&self) -> bool {
|
||||||
matches!(self, Self::Linux)
|
matches!(self, Self::Linux)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn is_windows(&self) -> bool {
|
pub fn is_windows(&self) -> bool {
|
||||||
matches!(self, Self::Windows)
|
matches!(self, Self::Windows)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn is_mac(&self) -> bool {
|
pub fn is_mac(&self) -> bool {
|
||||||
matches!(self, Self::Mac)
|
matches!(self, Self::Mac)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "title_bar"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
publish.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
common = { path = "../common" }
|
|
||||||
theme = { path = "../theme" }
|
|
||||||
ui = { path = "../ui" }
|
|
||||||
|
|
||||||
nostr-sdk.workspace = true
|
|
||||||
gpui.workspace = true
|
|
||||||
smol.workspace = true
|
|
||||||
smallvec.workspace = true
|
|
||||||
anyhow.workspace = true
|
|
||||||
log.workspace = true
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
|
||||||
windows = { version = "0.61", features = ["Wdk_System_SystemServices"] }
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
|
||||||
linicon = "2.3.0"
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
use gpui::MouseButton;
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
use gpui::Pixels;
|
|
||||||
use gpui::{
|
|
||||||
px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement,
|
|
||||||
ParentElement, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea,
|
|
||||||
};
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
use theme::{ActiveTheme, PlatformKind, CLIENT_SIDE_DECORATION_ROUNDING};
|
|
||||||
use ui::h_flex;
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
use crate::platforms::linux::LinuxWindowControls;
|
|
||||||
use crate::platforms::mac::TRAFFIC_LIGHT_PADDING;
|
|
||||||
use crate::platforms::windows::WindowsWindowControls;
|
|
||||||
|
|
||||||
mod platforms;
|
|
||||||
|
|
||||||
/// Titlebar
|
|
||||||
pub struct TitleBar {
|
|
||||||
/// Children elements of the title bar.
|
|
||||||
children: SmallVec<[AnyElement; 2]>,
|
|
||||||
|
|
||||||
/// Whether the title bar is currently being moved.
|
|
||||||
should_move: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TitleBar {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
children: smallvec![],
|
|
||||||
should_move: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
pub fn height(&self, window: &mut Window) -> Pixels {
|
|
||||||
(1.75 * window.rem_size()).max(px(34.))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
pub fn height(&self, _window: &mut Window) -> Pixels {
|
|
||||||
px(32.)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn titlebar_color(&self, window: &mut Window, cx: &mut Context<Self>) -> Hsla {
|
|
||||||
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
|
||||||
if window.is_window_active() && !self.should_move {
|
|
||||||
cx.theme().title_bar
|
|
||||||
} else {
|
|
||||||
cx.theme().title_bar_inactive
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cx.theme().title_bar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_children<T>(&mut self, children: T)
|
|
||||||
where
|
|
||||||
T: IntoIterator<Item = AnyElement>,
|
|
||||||
{
|
|
||||||
self.children = children.into_iter().collect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TitleBar {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for TitleBar {
|
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let height = self.height(window);
|
|
||||||
let color = self.titlebar_color(window, cx);
|
|
||||||
let children = std::mem::take(&mut self.children);
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
let supported_controls = window.window_controls();
|
|
||||||
let decorations = window.window_decorations();
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.window_control_area(WindowControlArea::Drag)
|
|
||||||
.h(height)
|
|
||||||
.w_full()
|
|
||||||
.map(|this| {
|
|
||||||
if window.is_fullscreen() {
|
|
||||||
this.px_2()
|
|
||||||
} else if cx.theme().platform.is_mac() {
|
|
||||||
this.pr_2().pl(px(TRAFFIC_LIGHT_PADDING))
|
|
||||||
} else {
|
|
||||||
this.px_2()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map(|this| match decorations {
|
|
||||||
Decorations::Server => this,
|
|
||||||
Decorations::Client { tiling } => this
|
|
||||||
.when(!(tiling.top || tiling.right), |div| {
|
|
||||||
div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
|
|
||||||
})
|
|
||||||
.when(!(tiling.top || tiling.left), |div| {
|
|
||||||
div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.bg(color)
|
|
||||||
.border_b_1()
|
|
||||||
.border_color(cx.theme().border)
|
|
||||||
.content_stretch()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.id("title-bar")
|
|
||||||
.justify_between()
|
|
||||||
.w_full()
|
|
||||||
.when(cx.theme().platform.is_mac(), |this| {
|
|
||||||
this.on_click(|event, window, _| {
|
|
||||||
if event.click_count() == 2 {
|
|
||||||
window.titlebar_double_click();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.when(cx.theme().platform.is_linux(), |this| {
|
|
||||||
this.on_click(|event, window, _| {
|
|
||||||
if event.click_count() == 2 {
|
|
||||||
window.zoom_window();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
|
||||||
.children(children),
|
|
||||||
)
|
|
||||||
.when(!window.is_fullscreen(), |this| match cx.theme().platform {
|
|
||||||
PlatformKind::Linux => {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
if matches!(decorations, Decorations::Client { .. }) {
|
|
||||||
this.child(LinuxWindowControls::new(None))
|
|
||||||
.when(supported_controls.window_menu, |this| {
|
|
||||||
this.on_mouse_down(MouseButton::Right, move |ev, window, _| {
|
|
||||||
window.show_window_menu(ev.position)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.on_mouse_move(cx.listener(move |this, _ev, window, _| {
|
|
||||||
if this.should_move {
|
|
||||||
this.should_move = false;
|
|
||||||
window.start_window_move();
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.on_mouse_down_out(cx.listener(move |this, _ev, _window, _cx| {
|
|
||||||
this.should_move = false;
|
|
||||||
}))
|
|
||||||
.on_mouse_up(
|
|
||||||
MouseButton::Left,
|
|
||||||
cx.listener(move |this, _ev, _window, _cx| {
|
|
||||||
this.should_move = false;
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.on_mouse_down(
|
|
||||||
MouseButton::Left,
|
|
||||||
cx.listener(move |this, _ev, _window, _cx| {
|
|
||||||
this.should_move = true;
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
this
|
|
||||||
}
|
|
||||||
PlatformKind::Windows => this.child(WindowsWindowControls::new(height)),
|
|
||||||
PlatformKind::Mac => this,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
svg, Action, App, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
|
|
||||||
SharedString, StatefulInteractiveElement, Styled, Window,
|
|
||||||
};
|
|
||||||
use linicon::{lookup_icon, IconType};
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::{h_flex, Icon, IconName, Sizable};
|
|
||||||
|
|
||||||
#[derive(IntoElement)]
|
|
||||||
pub struct LinuxWindowControls {
|
|
||||||
close_window_action: Option<Box<dyn Action>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LinuxWindowControls {
|
|
||||||
pub fn new(close_window_action: Option<Box<dyn Action>>) -> Self {
|
|
||||||
Self {
|
|
||||||
close_window_action,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderOnce for LinuxWindowControls {
|
|
||||||
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
|
||||||
let supported_controls = window.window_controls();
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.id("linux-window-controls")
|
|
||||||
.gap_2()
|
|
||||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
|
||||||
.when(supported_controls.minimize, |this| {
|
|
||||||
this.child(WindowControl::new(
|
|
||||||
LinuxControl::Minimize,
|
|
||||||
IconName::WindowMinimize,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.when(supported_controls.maximize, |this| {
|
|
||||||
this.child({
|
|
||||||
if window.is_maximized() {
|
|
||||||
WindowControl::new(LinuxControl::Restore, IconName::WindowRestore)
|
|
||||||
} else {
|
|
||||||
WindowControl::new(LinuxControl::Maximize, IconName::WindowMaximize)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
WindowControl::new(LinuxControl::Close, IconName::WindowClose)
|
|
||||||
.when_some(self.close_window_action, |this, close_action| {
|
|
||||||
this.close_action(close_action)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(IntoElement)]
|
|
||||||
pub struct WindowControl {
|
|
||||||
kind: LinuxControl,
|
|
||||||
fallback: IconName,
|
|
||||||
close_action: Option<Box<dyn Action>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WindowControl {
|
|
||||||
pub fn new(kind: LinuxControl, fallback: IconName) -> Self {
|
|
||||||
Self {
|
|
||||||
kind,
|
|
||||||
fallback,
|
|
||||||
close_action: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn close_action(mut self, action: Box<dyn Action>) -> Self {
|
|
||||||
self.close_action = Some(action);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_gnome(&self) -> bool {
|
|
||||||
matches!(detect_desktop_environment(), DesktopEnvironment::Gnome)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderOnce for WindowControl {
|
|
||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
||||||
let is_gnome = self.is_gnome();
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.id(self.kind.as_icon_name())
|
|
||||||
.group("")
|
|
||||||
.justify_center()
|
|
||||||
.items_center()
|
|
||||||
.rounded_full()
|
|
||||||
.size_6()
|
|
||||||
.when(is_gnome, |this| {
|
|
||||||
this.bg(cx.theme().ghost_element_background_alt)
|
|
||||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
|
||||||
.active(|this| this.bg(cx.theme().ghost_element_active))
|
|
||||||
})
|
|
||||||
.map(|this| {
|
|
||||||
if let Some(Some(path)) = linux_controls().get(&self.kind).cloned() {
|
|
||||||
this.child(
|
|
||||||
svg()
|
|
||||||
.external_path(SharedString::from(path))
|
|
||||||
.size_4()
|
|
||||||
.text_color(cx.theme().text),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this.child(Icon::new(self.fallback).small().text_color(cx.theme().text))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on_mouse_move(|_, _window, cx| cx.stop_propagation())
|
|
||||||
.on_click(move |_, window, cx| {
|
|
||||||
cx.stop_propagation();
|
|
||||||
match self.kind {
|
|
||||||
LinuxControl::Minimize => window.minimize_window(),
|
|
||||||
LinuxControl::Restore => window.zoom_window(),
|
|
||||||
LinuxControl::Maximize => window.zoom_window(),
|
|
||||||
LinuxControl::Close => cx.quit(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static DE: OnceLock<DesktopEnvironment> = OnceLock::new();
|
|
||||||
static LINUX_CONTROLS: OnceLock<HashMap<LinuxControl, Option<String>>> = OnceLock::new();
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub enum DesktopEnvironment {
|
|
||||||
Gnome,
|
|
||||||
Kde,
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect the current desktop environment
|
|
||||||
pub fn detect_desktop_environment() -> &'static DesktopEnvironment {
|
|
||||||
DE.get_or_init(|| {
|
|
||||||
// Try to use environment variables first
|
|
||||||
if let Ok(output) = std::env::var("XDG_CURRENT_DESKTOP") {
|
|
||||||
let desktop = output.to_lowercase();
|
|
||||||
if desktop.contains("gnome") {
|
|
||||||
return DesktopEnvironment::Gnome;
|
|
||||||
} else if desktop.contains("kde") {
|
|
||||||
return DesktopEnvironment::Kde;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback detection methods
|
|
||||||
if let Ok(output) = std::env::var("DESKTOP_SESSION") {
|
|
||||||
let session = output.to_lowercase();
|
|
||||||
if session.contains("gnome") {
|
|
||||||
return DesktopEnvironment::Gnome;
|
|
||||||
} else if session.contains("kde") || session.contains("plasma") {
|
|
||||||
return DesktopEnvironment::Kde;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DesktopEnvironment::Unknown
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
|
|
||||||
pub enum LinuxControl {
|
|
||||||
Minimize,
|
|
||||||
Restore,
|
|
||||||
Maximize,
|
|
||||||
Close,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LinuxControl {
|
|
||||||
pub fn as_icon_name(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
LinuxControl::Close => "window-close",
|
|
||||||
LinuxControl::Minimize => "window-minimize",
|
|
||||||
LinuxControl::Maximize => "window-maximize",
|
|
||||||
LinuxControl::Restore => "window-restore",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn linux_controls() -> &'static HashMap<LinuxControl, Option<String>> {
|
|
||||||
LINUX_CONTROLS.get_or_init(|| {
|
|
||||||
let mut icons = HashMap::new();
|
|
||||||
icons.insert(LinuxControl::Close, None);
|
|
||||||
icons.insert(LinuxControl::Minimize, None);
|
|
||||||
icons.insert(LinuxControl::Maximize, None);
|
|
||||||
icons.insert(LinuxControl::Restore, None);
|
|
||||||
|
|
||||||
let icon_names = [
|
|
||||||
(LinuxControl::Close, vec!["window-close", "dialog-close"]),
|
|
||||||
(
|
|
||||||
LinuxControl::Minimize,
|
|
||||||
vec!["window-minimize", "window-lower"],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
LinuxControl::Maximize,
|
|
||||||
vec!["window-maximize", "window-expand"],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
LinuxControl::Restore,
|
|
||||||
vec!["window-restore", "window-return"],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (control, icon_names) in icon_names {
|
|
||||||
for icon_name in icon_names {
|
|
||||||
// Try GNOME-style naming first
|
|
||||||
let mut control_icon = lookup_icon(format!("{icon_name}-symbolic"))
|
|
||||||
.find(|icon| matches!(icon, Ok(icon) if icon.icon_type == IconType::SVG));
|
|
||||||
|
|
||||||
// If not found, try KDE-style naming
|
|
||||||
if control_icon.is_none() {
|
|
||||||
control_icon = lookup_icon(icon_name)
|
|
||||||
.find(|icon| matches!(icon, Ok(icon) if icon.icon_type == IconType::SVG));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(Ok(icon)) = control_icon {
|
|
||||||
icons
|
|
||||||
.entry(control)
|
|
||||||
.and_modify(|v| *v = Some(icon.path.to_string_lossy().to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
icons
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/// Use pixels here instead of a rem-based size because the macOS traffic
|
|
||||||
/// lights are a static size, and don't scale with the rest of the UI.
|
|
||||||
///
|
|
||||||
/// Magic number: There is one extra pixel of padding on the left side due to
|
|
||||||
/// the 1px border around the window on macOS apps.
|
|
||||||
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub mod linux;
|
|
||||||
pub mod mac;
|
|
||||||
pub mod windows;
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
div, px, App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels,
|
|
||||||
RenderOnce, Rgba, StatefulInteractiveElement, Styled, Window, WindowControlArea,
|
|
||||||
};
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::h_flex;
|
|
||||||
|
|
||||||
#[derive(IntoElement)]
|
|
||||||
pub struct WindowsWindowControls {
|
|
||||||
button_height: Pixels,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WindowsWindowControls {
|
|
||||||
pub fn new(button_height: Pixels) -> Self {
|
|
||||||
Self { button_height }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
fn get_font() -> &'static str {
|
|
||||||
"Segoe Fluent Icons"
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn get_font() -> &'static str {
|
|
||||||
use windows::Wdk::System::SystemServices::RtlGetVersion;
|
|
||||||
|
|
||||||
let mut version = unsafe { std::mem::zeroed() };
|
|
||||||
let status = unsafe { RtlGetVersion(&mut version) };
|
|
||||||
|
|
||||||
if status.is_ok() && version.dwBuildNumber >= 22000 {
|
|
||||||
"Segoe Fluent Icons"
|
|
||||||
} else {
|
|
||||||
"Segoe MDL2 Assets"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderOnce for WindowsWindowControls {
|
|
||||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
||||||
let close_button_hover_color = Rgba {
|
|
||||||
r: 232.0 / 255.0,
|
|
||||||
g: 17.0 / 255.0,
|
|
||||||
b: 32.0 / 255.0,
|
|
||||||
a: 1.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let button_hover_color = cx.theme().ghost_element_hover;
|
|
||||||
let button_active_color = cx.theme().ghost_element_active;
|
|
||||||
|
|
||||||
div()
|
|
||||||
.id("windows-window-controls")
|
|
||||||
.font_family(Self::get_font())
|
|
||||||
.flex()
|
|
||||||
.flex_row()
|
|
||||||
.justify_center()
|
|
||||||
.content_stretch()
|
|
||||||
.max_h(self.button_height)
|
|
||||||
.min_h(self.button_height)
|
|
||||||
.child(WindowsCaptionButton::new(
|
|
||||||
"minimize",
|
|
||||||
WindowsCaptionButtonIcon::Minimize,
|
|
||||||
button_hover_color,
|
|
||||||
button_active_color,
|
|
||||||
))
|
|
||||||
.child(WindowsCaptionButton::new(
|
|
||||||
"maximize-or-restore",
|
|
||||||
if window.is_maximized() {
|
|
||||||
WindowsCaptionButtonIcon::Restore
|
|
||||||
} else {
|
|
||||||
WindowsCaptionButtonIcon::Maximize
|
|
||||||
},
|
|
||||||
button_hover_color,
|
|
||||||
button_active_color,
|
|
||||||
))
|
|
||||||
.child(WindowsCaptionButton::new(
|
|
||||||
"close",
|
|
||||||
WindowsCaptionButtonIcon::Close,
|
|
||||||
close_button_hover_color,
|
|
||||||
button_active_color,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
|
|
||||||
enum WindowsCaptionButtonIcon {
|
|
||||||
Minimize,
|
|
||||||
Restore,
|
|
||||||
Maximize,
|
|
||||||
Close,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(IntoElement)]
|
|
||||||
struct WindowsCaptionButton {
|
|
||||||
id: ElementId,
|
|
||||||
icon: WindowsCaptionButtonIcon,
|
|
||||||
hover_background_color: Hsla,
|
|
||||||
active_background_color: Hsla,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WindowsCaptionButton {
|
|
||||||
pub fn new(
|
|
||||||
id: impl Into<ElementId>,
|
|
||||||
icon: WindowsCaptionButtonIcon,
|
|
||||||
hover_background_color: impl Into<Hsla>,
|
|
||||||
active_background_color: impl Into<Hsla>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
id: id.into(),
|
|
||||||
icon,
|
|
||||||
hover_background_color: hover_background_color.into(),
|
|
||||||
active_background_color: active_background_color.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderOnce for WindowsCaptionButton {
|
|
||||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
|
||||||
h_flex()
|
|
||||||
.id(self.id)
|
|
||||||
.justify_center()
|
|
||||||
.content_center()
|
|
||||||
.occlude()
|
|
||||||
.w(px(36.))
|
|
||||||
.h_full()
|
|
||||||
.text_size(px(10.0))
|
|
||||||
.hover(|style| style.bg(self.hover_background_color))
|
|
||||||
.active(|style| style.bg(self.active_background_color))
|
|
||||||
.map(|this| match self.icon {
|
|
||||||
WindowsCaptionButtonIcon::Close => {
|
|
||||||
this.window_control_area(WindowControlArea::Close)
|
|
||||||
}
|
|
||||||
WindowsCaptionButtonIcon::Maximize | WindowsCaptionButtonIcon::Restore => {
|
|
||||||
this.window_control_area(WindowControlArea::Max)
|
|
||||||
}
|
|
||||||
WindowsCaptionButtonIcon::Minimize => {
|
|
||||||
this.window_control_area(WindowControlArea::Min)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.child(match self.icon {
|
|
||||||
WindowsCaptionButtonIcon::Minimize => "\u{e921}",
|
|
||||||
WindowsCaptionButtonIcon::Restore => "\u{e923}",
|
|
||||||
WindowsCaptionButtonIcon::Maximize => "\u{e922}",
|
|
||||||
WindowsCaptionButtonIcon::Close => "\u{e8bb}",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,9 +9,8 @@ common = { path = "../common" }
|
|||||||
theme = { path = "../theme" }
|
theme = { path = "../theme" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
smol.workspace = true
|
instant.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
@@ -20,7 +19,10 @@ log.workspace = true
|
|||||||
unicode-segmentation = "1.12.0"
|
unicode-segmentation = "1.12.0"
|
||||||
uuid = "1.10"
|
uuid = "1.10"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
image = "0.25.1"
|
|
||||||
lsp-types = "0.97.0"
|
lsp-types = "0.97.0"
|
||||||
rope = { git = "https://github.com/zed-industries/zed" }
|
ropey = { version = "=2.0.0-beta.1", features = ["metric_lines_lf", "metric_utf16"] }
|
||||||
sum_tree = { git = "https://github.com/zed-industries/zed" }
|
sum_tree = { git = "https://github.com/zed-industries/zed" }
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
smol.workspace = true
|
||||||
|
tree-sitter = "0.26"
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
Copyright 2024 Longbridge <https://longbridge.com>
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
|
|
||||||
|
|
||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
//! This is a fork of gpui's anchored element that adds support for offsetting
|
|
||||||
//! https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/elements/anchored.rs
|
|
||||||
use gpui::{
|
|
||||||
point, px, AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, Half,
|
|
||||||
InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style,
|
|
||||||
Window,
|
|
||||||
};
|
|
||||||
use smallvec::SmallVec;
|
|
||||||
|
|
||||||
use crate::Anchor;
|
|
||||||
|
|
||||||
/// The state that the anchored element element uses to track its children.
|
|
||||||
pub struct AnchoredState {
|
|
||||||
child_layout_ids: SmallVec<[LayoutId; 4]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An anchored element that can be used to display UI that
|
|
||||||
/// will avoid overflowing the window bounds.
|
|
||||||
pub(crate) struct Anchored {
|
|
||||||
children: SmallVec<[AnyElement; 2]>,
|
|
||||||
anchor_corner: Anchor,
|
|
||||||
fit_mode: AnchoredFitMode,
|
|
||||||
anchor_position: Option<Point<Pixels>>,
|
|
||||||
position_mode: AnchoredPositionMode,
|
|
||||||
offset: Option<Point<Pixels>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// anchored gives you an element that will avoid overflowing the window bounds.
|
|
||||||
/// Its children should have no margin to avoid measurement issues.
|
|
||||||
pub(crate) fn anchored() -> Anchored {
|
|
||||||
Anchored {
|
|
||||||
children: SmallVec::new(),
|
|
||||||
anchor_corner: Anchor::TopLeft,
|
|
||||||
fit_mode: AnchoredFitMode::SwitchAnchor,
|
|
||||||
anchor_position: None,
|
|
||||||
position_mode: AnchoredPositionMode::Window,
|
|
||||||
offset: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl Anchored {
|
|
||||||
/// Sets which corner of the anchored element should be anchored to the current position.
|
|
||||||
pub fn anchor(mut self, anchor: Anchor) -> Self {
|
|
||||||
self.anchor_corner = anchor;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the position in window coordinates
|
|
||||||
/// (otherwise the location the anchored element is rendered is used)
|
|
||||||
pub fn position(mut self, anchor: Point<Pixels>) -> Self {
|
|
||||||
self.anchor_position = Some(anchor);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Offset the final position by this amount.
|
|
||||||
/// Useful when you want to anchor to an element but offset from it, such as in PopoverMenu.
|
|
||||||
pub fn offset(mut self, offset: Point<Pixels>) -> Self {
|
|
||||||
self.offset = Some(offset);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the position mode for this anchored element. Local will have this
|
|
||||||
/// interpret its [`Anchored::position`] as relative to the parent element.
|
|
||||||
/// While Window will have it interpret the position as relative to the window.
|
|
||||||
pub fn position_mode(mut self, mode: AnchoredPositionMode) -> Self {
|
|
||||||
self.position_mode = mode;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Snap to window edge instead of switching anchor corner when an overflow would occur.
|
|
||||||
pub fn snap_to_window(mut self) -> Self {
|
|
||||||
self.fit_mode = AnchoredFitMode::SnapToWindow;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Snap to window edge and leave some margins.
|
|
||||||
pub fn snap_to_window_with_margin(mut self, edges: impl Into<Edges<Pixels>>) -> Self {
|
|
||||||
self.fit_mode = AnchoredFitMode::SnapToWindowWithMargin(edges.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ParentElement for Anchored {
|
|
||||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
|
||||||
self.children.extend(elements)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Element for Anchored {
|
|
||||||
type PrepaintState = ();
|
|
||||||
type RequestLayoutState = AnchoredState;
|
|
||||||
|
|
||||||
fn id(&self) -> Option<gpui::ElementId> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request_layout(
|
|
||||||
&mut self,
|
|
||||||
_id: Option<&GlobalElementId>,
|
|
||||||
_inspector_id: Option<&InspectorElementId>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
|
||||||
let child_layout_ids = self
|
|
||||||
.children
|
|
||||||
.iter_mut()
|
|
||||||
.map(|child| child.request_layout(window, cx))
|
|
||||||
.collect::<SmallVec<_>>();
|
|
||||||
|
|
||||||
let anchored_style = Style {
|
|
||||||
position: Position::Absolute,
|
|
||||||
display: Display::Flex,
|
|
||||||
..Style::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let layout_id = window.request_layout(anchored_style, child_layout_ids.iter().copied(), cx);
|
|
||||||
|
|
||||||
(layout_id, AnchoredState { child_layout_ids })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prepaint(
|
|
||||||
&mut self,
|
|
||||||
_id: Option<&GlobalElementId>,
|
|
||||||
_inspector_id: Option<&InspectorElementId>,
|
|
||||||
bounds: Bounds<Pixels>,
|
|
||||||
request_layout: &mut Self::RequestLayoutState,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
if request_layout.child_layout_ids.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut child_min = point(Pixels::MAX, Pixels::MAX);
|
|
||||||
let mut child_max = Point::default();
|
|
||||||
for child_layout_id in &request_layout.child_layout_ids {
|
|
||||||
let child_bounds = window.layout_bounds(*child_layout_id);
|
|
||||||
child_min = child_min.min(&child_bounds.origin);
|
|
||||||
child_max = child_max.max(&child_bounds.bottom_right());
|
|
||||||
}
|
|
||||||
let size: Size<Pixels> = (child_max - child_min).into();
|
|
||||||
|
|
||||||
let (origin, mut desired) = self.position_mode.get_position_and_bounds(
|
|
||||||
self.anchor_position,
|
|
||||||
self.anchor_corner,
|
|
||||||
size,
|
|
||||||
bounds,
|
|
||||||
self.offset,
|
|
||||||
);
|
|
||||||
|
|
||||||
let limits = Bounds {
|
|
||||||
origin: Point::default(),
|
|
||||||
size: window.viewport_size(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if self.fit_mode == AnchoredFitMode::SwitchAnchor {
|
|
||||||
let mut anchor_corner = self.anchor_corner;
|
|
||||||
|
|
||||||
if desired.left() < limits.left() || desired.right() > limits.right() {
|
|
||||||
let switched = Bounds::from_corner_and_size(
|
|
||||||
anchor_corner
|
|
||||||
.other_side_corner_along(Axis::Horizontal)
|
|
||||||
.into(),
|
|
||||||
origin,
|
|
||||||
size,
|
|
||||||
);
|
|
||||||
if !(switched.left() < limits.left() || switched.right() > limits.right()) {
|
|
||||||
anchor_corner = anchor_corner.other_side_corner_along(Axis::Horizontal);
|
|
||||||
desired = switched
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if desired.top() < limits.top() || desired.bottom() > limits.bottom() {
|
|
||||||
let switched = Bounds::from_corner_and_size(
|
|
||||||
anchor_corner.other_side_corner_along(Axis::Vertical).into(),
|
|
||||||
origin,
|
|
||||||
size,
|
|
||||||
);
|
|
||||||
if !(switched.top() < limits.top() || switched.bottom() > limits.bottom()) {
|
|
||||||
desired = switched;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let client_inset = window.client_inset().unwrap_or(px(0.));
|
|
||||||
let edges = match self.fit_mode {
|
|
||||||
AnchoredFitMode::SnapToWindowWithMargin(edges) => edges,
|
|
||||||
_ => Edges::default(),
|
|
||||||
}
|
|
||||||
.map(|edge| *edge + client_inset);
|
|
||||||
|
|
||||||
// Snap the horizontal edges of the anchored element to the horizontal edges of the window if
|
|
||||||
// its horizontal bounds overflow, aligning to the left if it is wider than the limits.
|
|
||||||
if desired.right() > limits.right() {
|
|
||||||
desired.origin.x -= desired.right() - limits.right() + edges.right;
|
|
||||||
}
|
|
||||||
if desired.left() < limits.left() {
|
|
||||||
desired.origin.x = limits.origin.x + edges.left;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snap the vertical edges of the anchored element to the vertical edges of the window if
|
|
||||||
// its vertical bounds overflow, aligning to the top if it is taller than the limits.
|
|
||||||
if desired.bottom() > limits.bottom() {
|
|
||||||
desired.origin.y -= desired.bottom() - limits.bottom() + edges.bottom;
|
|
||||||
}
|
|
||||||
if desired.top() < limits.top() {
|
|
||||||
desired.origin.y = limits.origin.y + edges.top;
|
|
||||||
}
|
|
||||||
|
|
||||||
let offset = desired.origin - bounds.origin;
|
|
||||||
let offset = point(offset.x.round(), offset.y.round());
|
|
||||||
|
|
||||||
window.with_element_offset(offset, |window| {
|
|
||||||
for child in &mut self.children {
|
|
||||||
child.prepaint(window, cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn paint(
|
|
||||||
&mut self,
|
|
||||||
_id: Option<&GlobalElementId>,
|
|
||||||
_inspector_id: Option<&InspectorElementId>,
|
|
||||||
_bounds: Bounds<Pixels>,
|
|
||||||
_request_layout: &mut Self::RequestLayoutState,
|
|
||||||
_prepaint: &mut Self::PrepaintState,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
for child in &mut self.children {
|
|
||||||
child.paint(window, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoElement for Anchored {
|
|
||||||
type Element = Self;
|
|
||||||
|
|
||||||
fn into_element(self) -> Self::Element {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which algorithm to use when fitting the anchored element to be inside the window.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Copy, Clone, PartialEq)]
|
|
||||||
pub enum AnchoredFitMode {
|
|
||||||
/// Snap the anchored element to the window edge.
|
|
||||||
SnapToWindow,
|
|
||||||
/// Snap to window edge and leave some margins.
|
|
||||||
SnapToWindowWithMargin(Edges<Pixels>),
|
|
||||||
/// Switch which corner anchor this anchored element is attached to.
|
|
||||||
SwitchAnchor,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which algorithm to use when positioning the anchored element.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Copy, Clone, PartialEq)]
|
|
||||||
pub enum AnchoredPositionMode {
|
|
||||||
/// Position the anchored element relative to the window.
|
|
||||||
Window,
|
|
||||||
/// Position the anchored element relative to its parent.
|
|
||||||
Local,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AnchoredPositionMode {
|
|
||||||
fn get_position_and_bounds(
|
|
||||||
&self,
|
|
||||||
anchor_position: Option<Point<Pixels>>,
|
|
||||||
anchor_corner: Anchor,
|
|
||||||
size: Size<Pixels>,
|
|
||||||
bounds: Bounds<Pixels>,
|
|
||||||
offset: Option<Point<Pixels>>,
|
|
||||||
) -> (Point<Pixels>, Bounds<Pixels>) {
|
|
||||||
let offset = offset.unwrap_or_default();
|
|
||||||
|
|
||||||
match self {
|
|
||||||
AnchoredPositionMode::Window => {
|
|
||||||
let anchor_position = anchor_position.unwrap_or(bounds.origin);
|
|
||||||
let bounds =
|
|
||||||
Self::from_corner_and_size(anchor_corner, anchor_position + offset, size);
|
|
||||||
(anchor_position, bounds)
|
|
||||||
}
|
|
||||||
AnchoredPositionMode::Local => {
|
|
||||||
let anchor_position = anchor_position.unwrap_or_default();
|
|
||||||
let bounds = Self::from_corner_and_size(
|
|
||||||
anchor_corner,
|
|
||||||
bounds.origin + anchor_position + offset,
|
|
||||||
size,
|
|
||||||
);
|
|
||||||
(anchor_position, bounds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ref https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/geometry.rs#L863
|
|
||||||
fn from_corner_and_size(
|
|
||||||
anchor: Anchor,
|
|
||||||
origin: Point<Pixels>,
|
|
||||||
size: Size<Pixels>,
|
|
||||||
) -> Bounds<Pixels> {
|
|
||||||
let origin = match anchor {
|
|
||||||
Anchor::TopLeft => origin,
|
|
||||||
Anchor::TopCenter => Point {
|
|
||||||
x: origin.x - size.width.half(),
|
|
||||||
y: origin.y,
|
|
||||||
},
|
|
||||||
Anchor::TopRight => Point {
|
|
||||||
x: origin.x - size.width,
|
|
||||||
y: origin.y,
|
|
||||||
},
|
|
||||||
Anchor::BottomLeft => Point {
|
|
||||||
x: origin.x,
|
|
||||||
y: origin.y - size.height,
|
|
||||||
},
|
|
||||||
Anchor::BottomCenter => Point {
|
|
||||||
x: origin.x - size.width.half(),
|
|
||||||
y: origin.y - size.height,
|
|
||||||
},
|
|
||||||
Anchor::BottomRight => Point {
|
|
||||||
x: origin.x - size.width,
|
|
||||||
y: origin.y - size.height,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
Bounds { origin, size }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+19
-8
@@ -1,12 +1,12 @@
|
|||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, img, px, AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement,
|
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
|
||||||
Interactivity, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
|
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
|
||||||
Window,
|
Window, div, img, px,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::{Sizable, Size};
|
use crate::{Selectable, Sizable, Size};
|
||||||
|
|
||||||
/// Returns the size of the avatar based on the given [`Size`].
|
/// Returns the size of the avatar based on the given [`Size`].
|
||||||
pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
||||||
@@ -26,9 +26,7 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
|||||||
/// ```
|
/// ```
|
||||||
/// use ui::{Avatar};
|
/// use ui::{Avatar};
|
||||||
///
|
///
|
||||||
/// Avatar::new("path/to/image.png")
|
/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red());
|
||||||
/// .grayscale(true)
|
|
||||||
/// .border_color(gpui::red());
|
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct Avatar {
|
pub struct Avatar {
|
||||||
@@ -37,6 +35,7 @@ pub struct Avatar {
|
|||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
size: Size,
|
size: Size,
|
||||||
border_color: Option<Hsla>,
|
border_color: Option<Hsla>,
|
||||||
|
selected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Avatar {
|
impl Avatar {
|
||||||
@@ -48,6 +47,7 @@ impl Avatar {
|
|||||||
style: StyleRefinement::default(),
|
style: StyleRefinement::default(),
|
||||||
size: Size::Medium,
|
size: Size::Medium,
|
||||||
border_color: None,
|
border_color: None,
|
||||||
|
selected: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +89,17 @@ impl Styled for Avatar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Selectable for Avatar {
|
||||||
|
fn is_selected(&self) -> bool {
|
||||||
|
self.selected
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected(mut self, selected: bool) -> Self {
|
||||||
|
self.selected = selected;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl InteractiveElement for Avatar {
|
impl InteractiveElement for Avatar {
|
||||||
fn interactivity(&mut self) -> &mut Interactivity {
|
fn interactivity(&mut self) -> &mut Interactivity {
|
||||||
self.base.interactivity()
|
self.base.interactivity()
|
||||||
@@ -117,7 +128,7 @@ impl RenderOnce for Avatar {
|
|||||||
self.image
|
self.image
|
||||||
.size(image_size)
|
.size(image_size)
|
||||||
.rounded_full()
|
.rounded_full()
|
||||||
.object_fit(gpui::ObjectFit::Fill)
|
.object_fit(ObjectFit::Cover)
|
||||||
.bg(cx.theme().ghost_element_background)
|
.bg(cx.theme().ghost_element_background)
|
||||||
.with_fallback(move || {
|
.with_fallback(move || {
|
||||||
img("brand/avatar.png")
|
img("brand/avatar.png")
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, relative, AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement,
|
AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement, IntoElement,
|
||||||
IntoElement, ParentElement, RenderOnce, SharedString, Stateful,
|
ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _,
|
||||||
StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
|
StyleRefinement, Styled, Window, div, relative,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::indicator::Indicator;
|
use crate::indicator::Indicator;
|
||||||
use crate::tooltip::Tooltip;
|
use crate::tooltip::Tooltip;
|
||||||
use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt};
|
use crate::{Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct ButtonCustomVariant {
|
pub struct ButtonCustomVariant {
|
||||||
@@ -617,7 +617,7 @@ impl ButtonVariant {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let fg = match self {
|
let fg = match self {
|
||||||
ButtonVariant::Primary => cx.theme().text_muted, // TODO: use a different color?
|
ButtonVariant::Primary => cx.theme().text_muted,
|
||||||
_ => cx.theme().text_muted,
|
_ => cx.theme().text_muted,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::Duration;
|
use instant::Duration;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
|
|||||||
@@ -3,20 +3,26 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, px, App, AppContext, Axis, Context, Element, Entity, IntoElement, MouseMoveEvent,
|
App, AppContext, Axis, Context, Element, Empty, Entity, IntoElement, MouseMoveEvent,
|
||||||
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, Styled as _, WeakEntity,
|
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, StyleRefinement, Styled as _,
|
||||||
Window,
|
WeakEntity, Window, div, px,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{DockArea, DockItem};
|
use super::{DockArea, DockItem};
|
||||||
use crate::dock_area::panel::PanelView;
|
|
||||||
use crate::dock_area::tab_panel::TabPanel;
|
|
||||||
use crate::resizable::{resize_handle, PANEL_MIN_SIZE};
|
|
||||||
use crate::StyledExt;
|
use crate::StyledExt;
|
||||||
|
use crate::dock::panel::PanelView;
|
||||||
|
use crate::dock::tab_panel::TabPanel;
|
||||||
|
use crate::resizable::{PANEL_MIN_SIZE, resize_handle};
|
||||||
|
|
||||||
#[derive(Clone, Render)]
|
#[derive(Clone)]
|
||||||
struct ResizePanel;
|
struct ResizePanel;
|
||||||
|
|
||||||
|
impl Render for ResizePanel {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
Empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum DockPlacement {
|
pub enum DockPlacement {
|
||||||
Center,
|
Center,
|
||||||
@@ -208,6 +214,8 @@ impl Dock {
|
|||||||
pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.open = open;
|
self.open = open;
|
||||||
let item = self.panel.clone();
|
let item = self.panel.clone();
|
||||||
|
// Use defer_in (not window.defer) so the callback is cancelled
|
||||||
|
// if this Dock entity is dropped before the deferred frame runs.
|
||||||
cx.defer_in(window, move |_, window, cx| {
|
cx.defer_in(window, move |_, window, cx| {
|
||||||
item.set_collapsed(!open, window, cx);
|
item.set_collapsed(!open, window, cx);
|
||||||
});
|
});
|
||||||
@@ -265,22 +273,22 @@ impl Dock {
|
|||||||
let mut right_dock_size = px(0.0);
|
let mut right_dock_size = px(0.0);
|
||||||
|
|
||||||
// Get the size of the left dock if it's open and not the current dock
|
// Get the size of the left dock if it's open and not the current dock
|
||||||
if let Some(left_dock) = &dock_area.left_dock {
|
if let Some(left_dock) = &dock_area.left_dock
|
||||||
if left_dock.entity_id() != cx.entity().entity_id() {
|
&& left_dock.entity_id() != cx.entity().entity_id()
|
||||||
let left_dock_read = left_dock.read(cx);
|
{
|
||||||
if left_dock_read.is_open() {
|
let left_dock_read = left_dock.read(cx);
|
||||||
left_dock_size = left_dock_read.size;
|
if left_dock_read.is_open() {
|
||||||
}
|
left_dock_size = left_dock_read.size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the size of the right dock if it's open and not the current dock
|
// Get the size of the right dock if it's open and not the current dock
|
||||||
if let Some(right_dock) = &dock_area.right_dock {
|
if let Some(right_dock) = &dock_area.right_dock
|
||||||
if right_dock.entity_id() != cx.entity().entity_id() {
|
&& right_dock.entity_id() != cx.entity().entity_id()
|
||||||
let right_dock_read = right_dock.read(cx);
|
{
|
||||||
if right_dock_read.is_open() {
|
let right_dock_read = right_dock.read(cx);
|
||||||
right_dock_size = right_dock_read.size;
|
if right_dock_read.is_open() {
|
||||||
}
|
right_dock_size = right_dock_read.size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,6 +329,8 @@ impl Render for Dock {
|
|||||||
return div();
|
return div();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cache_style = StyleRefinement::default().absolute().size_full();
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.relative()
|
.relative()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
@@ -336,7 +346,7 @@ impl Render for Dock {
|
|||||||
.map(|this| match &self.panel {
|
.map(|this| match &self.panel {
|
||||||
DockItem::Split { view, .. } => this.child(view.clone()),
|
DockItem::Split { view, .. } => this.child(view.clone()),
|
||||||
DockItem::Tabs { view, .. } => this.child(view.clone()),
|
DockItem::Tabs { view, .. } => this.child(view.clone()),
|
||||||
DockItem::Panel { view, .. } => this.child(view.clone().view()),
|
DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)),
|
||||||
})
|
})
|
||||||
.child(self.render_resize_handle(window, cx))
|
.child(self.render_resize_handle(window, cx))
|
||||||
.child(DockElement {
|
.child(DockElement {
|
||||||
@@ -2,21 +2,23 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
actions, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity,
|
AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity, EntityId,
|
||||||
EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _,
|
EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _, Pixels,
|
||||||
Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window,
|
Render, SharedString, Styled, Subscription, WeakEntity, Window, actions, div, px,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::dock_area::dock::{Dock, DockPlacement};
|
|
||||||
use crate::dock_area::panel::{Panel, PanelEvent, PanelStyle, PanelView};
|
|
||||||
use crate::dock_area::stack_panel::StackPanel;
|
|
||||||
use crate::dock_area::tab_panel::TabPanel;
|
|
||||||
use crate::ElementExt;
|
use crate::ElementExt;
|
||||||
|
|
||||||
pub mod dock;
|
#[allow(clippy::module_inception)]
|
||||||
pub mod panel;
|
mod dock;
|
||||||
pub mod stack_panel;
|
mod panel;
|
||||||
pub mod tab_panel;
|
mod stack_panel;
|
||||||
|
mod tab_panel;
|
||||||
|
|
||||||
|
pub use dock::*;
|
||||||
|
pub use panel::*;
|
||||||
|
pub use stack_panel::*;
|
||||||
|
pub use tab_panel::*;
|
||||||
|
|
||||||
actions!(dock, [ToggleZoom, ClosePanel]);
|
actions!(dock, [ToggleZoom, ClosePanel]);
|
||||||
|
|
||||||
@@ -107,15 +109,8 @@ impl DockItem {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut items = items;
|
|
||||||
|
|
||||||
let stack_panel = cx.new(|cx| {
|
let stack_panel = cx.new(|cx| {
|
||||||
let mut stack_panel = StackPanel::new(axis, window, cx);
|
let mut stack_panel = StackPanel::new(axis, window, cx);
|
||||||
for (i, item) in items.iter_mut().enumerate() {
|
|
||||||
let view = item.view();
|
|
||||||
let size = sizes.get(i).copied().flatten();
|
|
||||||
stack_panel.add_panel(view.clone(), size, dock_area.clone(), window, cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i, item) in items.iter().enumerate() {
|
for (i, item) in items.iter().enumerate() {
|
||||||
let view = item.view();
|
let view = item.view();
|
||||||
@@ -202,19 +197,16 @@ impl DockItem {
|
|||||||
/// Returns all panel ids
|
/// Returns all panel ids
|
||||||
pub fn panel_ids(&self, cx: &App) -> Vec<SharedString> {
|
pub fn panel_ids(&self, cx: &App) -> Vec<SharedString> {
|
||||||
match self {
|
match self {
|
||||||
Self::Tabs { view, .. } => view.read(cx).panel_ids(cx),
|
|
||||||
Self::Split { items, .. } => {
|
|
||||||
let mut total = vec![];
|
|
||||||
|
|
||||||
for item in items.iter() {
|
|
||||||
if let DockItem::Tabs { view, .. } = item {
|
|
||||||
total.extend(view.read(cx).panel_ids(cx));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
total
|
|
||||||
}
|
|
||||||
Self::Panel { .. } => vec![],
|
Self::Panel { .. } => vec![],
|
||||||
|
Self::Tabs { view, .. } => view.read(cx).panel_ids(cx),
|
||||||
|
Self::Split { items, .. } => items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| match item {
|
||||||
|
DockItem::Tabs { view, .. } => Some(view.read(cx).panel_ids(cx)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,7 +742,6 @@ impl Render for DockArea {
|
|||||||
.id("dock-area")
|
.id("dock-area")
|
||||||
.relative()
|
.relative()
|
||||||
.size_full()
|
.size_full()
|
||||||
.overflow_hidden()
|
|
||||||
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds))
|
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds))
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
if let Some(zoom_view) = self.zoom_view.clone() {
|
if let Some(zoom_view) = self.zoom_view.clone() {
|
||||||
@@ -761,7 +752,7 @@ impl Render for DockArea {
|
|||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_row()
|
.flex_row()
|
||||||
.h_full()
|
.size_full()
|
||||||
// Left dock
|
// Left dock
|
||||||
.when_some(self.left_dock.clone(), |this, dock| {
|
.when_some(self.left_dock.clone(), |this, dock| {
|
||||||
this.child(div().flex().flex_none().child(dock))
|
this.child(div().flex().flex_none().child(dock))
|
||||||
@@ -772,14 +763,8 @@ impl Render for DockArea {
|
|||||||
.flex()
|
.flex()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.overflow_hidden()
|
|
||||||
// Top center
|
// Top center
|
||||||
.child(
|
.child(div().flex_1().child(self.render_items(window, cx)))
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.overflow_hidden()
|
|
||||||
.child(self.render_items(window, cx)),
|
|
||||||
)
|
|
||||||
// Bottom Dock
|
// Bottom Dock
|
||||||
.when_some(self.bottom_dock.clone(), |this, dock| {
|
.when_some(self.bottom_dock.clone(), |this, dock| {
|
||||||
this.child(dock)
|
this.child(dock)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Hsla, Render,
|
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Render,
|
||||||
SharedString, Window,
|
SharedString, Window,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,12 +21,6 @@ pub enum PanelStyle {
|
|||||||
TabBar,
|
TabBar,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct TitleStyle {
|
|
||||||
pub background: Hsla,
|
|
||||||
pub foreground: Hsla,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
|
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
|
||||||
/// The name of the panel used to serialize, deserialize and identify the panel.
|
/// The name of the panel used to serialize, deserialize and identify the panel.
|
||||||
///
|
///
|
||||||
@@ -1,22 +1,21 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
App, AppContext, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
IntoElement, ParentElement, Pixels, Render, SharedString, Styled, Subscription, WeakEntity,
|
IntoElement, ParentElement, Pixels, Render, SharedString, Styled, Subscription, WeakEntity,
|
||||||
Window,
|
Window,
|
||||||
};
|
};
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING};
|
use theme::{AxisExt as _, Placement};
|
||||||
|
|
||||||
use super::{DockArea, PanelEvent};
|
use super::{DockArea, PanelEvent};
|
||||||
use crate::dock_area::panel::{Panel, PanelView};
|
use crate::dock::panel::{Panel, PanelView};
|
||||||
use crate::dock_area::tab_panel::TabPanel;
|
use crate::dock::tab_panel::TabPanel;
|
||||||
|
use crate::h_flex;
|
||||||
use crate::resizable::{
|
use crate::resizable::{
|
||||||
resizable_panel, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState,
|
PANEL_MIN_SIZE, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState,
|
||||||
PANEL_MIN_SIZE,
|
resizable_panel,
|
||||||
};
|
};
|
||||||
use crate::{h_flex, AxisExt as _, Placement};
|
|
||||||
|
|
||||||
pub struct StackPanel {
|
pub struct StackPanel {
|
||||||
pub(super) parent: Option<WeakEntity<StackPanel>>,
|
pub(super) parent: Option<WeakEntity<StackPanel>>,
|
||||||
@@ -70,10 +69,10 @@ impl StackPanel {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(parent) = &self.parent {
|
if let Some(parent) = &self.parent
|
||||||
if let Some(parent) = parent.upgrade() {
|
&& let Some(parent) = parent.upgrade()
|
||||||
return parent.read(cx).is_last_panel(cx);
|
{
|
||||||
}
|
return parent.read(cx).is_last_panel(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
true
|
true
|
||||||
@@ -297,12 +296,11 @@ impl StackPanel {
|
|||||||
|
|
||||||
/// Find the first top left in the stack.
|
/// Find the first top left in the stack.
|
||||||
pub fn left_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
|
pub fn left_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
|
||||||
if check_parent {
|
if check_parent
|
||||||
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
|
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
|
||||||
if let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx) {
|
&& let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx)
|
||||||
return Some(panel);
|
{
|
||||||
}
|
return Some(panel);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let first_panel = self.panels.first();
|
let first_panel = self.panels.first();
|
||||||
@@ -321,12 +319,11 @@ impl StackPanel {
|
|||||||
|
|
||||||
/// Find the first top right in the stack.
|
/// Find the first top right in the stack.
|
||||||
pub fn right_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
|
pub fn right_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
|
||||||
if check_parent {
|
if check_parent
|
||||||
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
|
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
|
||||||
if let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx) {
|
&& let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx)
|
||||||
return Some(panel);
|
{
|
||||||
}
|
return Some(panel);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let panel = if self.axis.is_vertical() {
|
let panel = if self.axis.is_vertical() {
|
||||||
@@ -371,26 +368,20 @@ impl Focusable for StackPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for StackPanel {}
|
impl EventEmitter<PanelEvent> for StackPanel {}
|
||||||
|
|
||||||
impl EventEmitter<DismissEvent> for StackPanel {}
|
impl EventEmitter<DismissEvent> for StackPanel {}
|
||||||
|
|
||||||
impl Render for StackPanel {
|
impl Render for StackPanel {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
h_flex()
|
h_flex().size_full().overflow_hidden().child(
|
||||||
.size_full()
|
ResizablePanelGroup::new("stack-panel-group")
|
||||||
.overflow_hidden()
|
.with_state(&self.state)
|
||||||
.bg(cx.theme().panel_background)
|
.axis(self.axis)
|
||||||
.when(cx.theme().platform.is_linux(), |this| {
|
.children(self.panels.clone().into_iter().map(|panel| {
|
||||||
this.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
|
resizable_panel()
|
||||||
})
|
.child(panel.view())
|
||||||
.child(
|
.visible(panel.visible(cx))
|
||||||
ResizablePanelGroup::new("stack-panel-group")
|
})),
|
||||||
.with_state(&self.state)
|
)
|
||||||
.axis(self.axis)
|
|
||||||
.children(self.panels.clone().into_iter().map(|panel| {
|
|
||||||
resizable_panel()
|
|
||||||
.child(panel.view())
|
|
||||||
.visible(panel.visible(cx))
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,22 +2,22 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, px, rems, App, AppContext, Context, Corner, DefiniteLength, DismissEvent, DragMoveEvent,
|
Anchor, App, AppContext, Context, DefiniteLength, DismissEvent, DragMoveEvent, Empty, Entity,
|
||||||
Empty, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement,
|
EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, MouseButton,
|
||||||
MouseButton, ParentElement, Pixels, Render, ScrollHandle, SharedString,
|
ParentElement, Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled,
|
||||||
StatefulInteractiveElement, Styled, WeakEntity, Window,
|
WeakEntity, Window, div, px, rems,
|
||||||
};
|
};
|
||||||
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT};
|
use theme::{ActiveTheme, AxisExt, Placement, TABBAR_HEIGHT};
|
||||||
|
|
||||||
use crate::button::{Button, ButtonVariants as _};
|
use crate::button::{Button, ButtonVariants as _};
|
||||||
use crate::dock_area::dock::DockPlacement;
|
use crate::dock::dock::DockPlacement;
|
||||||
use crate::dock_area::panel::{Panel, PanelView};
|
use crate::dock::panel::{Panel, PanelView};
|
||||||
use crate::dock_area::stack_panel::StackPanel;
|
use crate::dock::stack_panel::StackPanel;
|
||||||
use crate::dock_area::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom};
|
use crate::dock::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom};
|
||||||
use crate::menu::{DropdownMenu, PopupMenu};
|
use crate::menu::{DropdownMenu, PopupMenu};
|
||||||
use crate::tab::tab_bar::TabBar;
|
|
||||||
use crate::tab::Tab;
|
use crate::tab::Tab;
|
||||||
use crate::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt};
|
use crate::tab::tab_bar::TabBar;
|
||||||
|
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct TabState {
|
struct TabState {
|
||||||
@@ -42,22 +42,20 @@ impl DragPanel {
|
|||||||
|
|
||||||
impl Render for DragPanel {
|
impl Render for DragPanel {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div()
|
h_flex()
|
||||||
.id("drag-panel")
|
.id("drag-panel")
|
||||||
.cursor_grab()
|
.cursor_grab()
|
||||||
.py_1()
|
.p_2()
|
||||||
.px_2()
|
.min_w_24()
|
||||||
.w_24()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.whitespace_nowrap()
|
.whitespace_nowrap()
|
||||||
.rounded(cx.theme().radius_lg)
|
.rounded(cx.theme().radius)
|
||||||
.text_xs()
|
.text_sm()
|
||||||
.when(cx.theme().shadow, |this| this.shadow_lg())
|
.text_color(cx.theme().text)
|
||||||
|
.text_ellipsis()
|
||||||
|
.when(cx.theme().shadow, |this| this.shadow_xs())
|
||||||
.bg(cx.theme().background)
|
.bg(cx.theme().background)
|
||||||
.text_color(cx.theme().text_accent)
|
|
||||||
.child(self.panel.title(cx))
|
.child(self.panel.title(cx))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,14 +232,13 @@ impl TabPanel {
|
|||||||
.any(|p| p.panel_id(cx) == panel.panel_id(cx))
|
.any(|p| p.panel_id(cx) == panel.panel_id(cx))
|
||||||
{
|
{
|
||||||
// Set the active panel to the matched panel
|
// Set the active panel to the matched panel
|
||||||
if active {
|
if active
|
||||||
if let Some(ix) = self
|
&& let Some(ix) = self
|
||||||
.panels
|
.panels
|
||||||
.iter()
|
.iter()
|
||||||
.position(|p| p.panel_id(cx) == panel.panel_id(cx))
|
.position(|p| p.panel_id(cx) == panel.panel_id(cx))
|
||||||
{
|
{
|
||||||
self.set_active_ix(ix, window, cx);
|
self.set_active_ix(ix, window, cx);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -315,6 +312,7 @@ impl TabPanel {
|
|||||||
|
|
||||||
cx.emit(PanelEvent::ZoomOut);
|
cx.emit(PanelEvent::ZoomOut);
|
||||||
cx.emit(PanelEvent::LayoutChanged);
|
cx.emit(PanelEvent::LayoutChanged);
|
||||||
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detach_panel(
|
fn detach_panel(
|
||||||
@@ -324,10 +322,22 @@ impl TabPanel {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
let panel_view = panel.view();
|
let panel_view = panel.view();
|
||||||
|
let removed_ix = self.panels.iter().position(|p| p.view() == panel_view);
|
||||||
self.panels.retain(|p| p.view() != panel_view);
|
self.panels.retain(|p| p.view() != panel_view);
|
||||||
|
|
||||||
if self.active_ix >= self.panels.len() {
|
if self.active_ix >= self.panels.len() {
|
||||||
self.set_active_ix(self.panels.len().saturating_sub(1), window, cx)
|
self.set_active_ix(self.panels.len().saturating_sub(1), window, cx)
|
||||||
|
} else if let Some(removed_ix) = removed_ix {
|
||||||
|
if removed_ix < self.active_ix {
|
||||||
|
self.active_ix = self.active_ix.saturating_sub(1);
|
||||||
|
} else if removed_ix == self.active_ix {
|
||||||
|
// The active panel was removed and another panel shifted into
|
||||||
|
// its position. Activate the new panel at the same index.
|
||||||
|
if let Some(new_active) = self.panels.get(self.active_ix) {
|
||||||
|
new_active.set_active(true, cx);
|
||||||
|
}
|
||||||
|
self.focus_active_panel(window, cx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,12 +384,11 @@ impl TabPanel {
|
|||||||
|
|
||||||
/// Return true if self or parent only have last panel.
|
/// Return true if self or parent only have last panel.
|
||||||
fn is_last_panel(&self, cx: &App) -> bool {
|
fn is_last_panel(&self, cx: &App) -> bool {
|
||||||
if let Some(parent) = &self.stack_panel {
|
if let Some(parent) = &self.stack_panel
|
||||||
if let Some(stack_panel) = parent.upgrade() {
|
&& let Some(stack_panel) = parent.upgrade()
|
||||||
if !stack_panel.read(cx).is_last_panel(cx) {
|
&& !stack_panel.read(cx).is_last_panel(cx)
|
||||||
return false;
|
{
|
||||||
}
|
return false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.panels.len() <= 1
|
self.panels.len() <= 1
|
||||||
@@ -425,14 +434,13 @@ impl TabPanel {
|
|||||||
let view = cx.entity().clone();
|
let view = cx.entity().clone();
|
||||||
let build_popup_menu = move |this, cx: &App| view.read(cx).popup_menu(this, cx);
|
let build_popup_menu = move |this, cx: &App| view.read(cx).popup_menu(this, cx);
|
||||||
let toolbar = self.toolbar_buttons(window, cx);
|
let toolbar = self.toolbar_buttons(window, cx);
|
||||||
let has_toolbar = !toolbar.is_empty();
|
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.p_0p5()
|
.p_0p5()
|
||||||
.gap_1()
|
.gap_1p5()
|
||||||
.occlude()
|
.occlude()
|
||||||
.rounded_full()
|
.rounded_full()
|
||||||
.children(toolbar.into_iter().map(|btn| btn.small().ghost().rounded()))
|
.children(toolbar.into_iter().map(|btn| btn.small().ghost()))
|
||||||
.when(self.zoomed, |this| {
|
.when(self.zoomed, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("zoom")
|
Button::new("zoom")
|
||||||
@@ -445,15 +453,11 @@ impl TabPanel {
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(has_toolbar, |this| {
|
|
||||||
this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
|
|
||||||
})
|
|
||||||
.child(
|
.child(
|
||||||
Button::new("menu")
|
Button::new("menu")
|
||||||
.icon(IconName::Ellipsis)
|
.icon(IconName::Ellipsis)
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
.rounded()
|
|
||||||
.dropdown_menu({
|
.dropdown_menu({
|
||||||
let zoomable = state.zoomable;
|
let zoomable = state.zoomable;
|
||||||
let closable = state.closable;
|
let closable = state.closable;
|
||||||
@@ -469,7 +473,7 @@ impl TabPanel {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.anchor(Corner::TopRight),
|
.anchor(Anchor::TopRight),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,8 +580,10 @@ impl TabPanel {
|
|||||||
let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, window, cx);
|
let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, window, cx);
|
||||||
let bottom_dock_button = self.render_dock_toggle_button(DockPlacement::Bottom, window, cx);
|
let bottom_dock_button = self.render_dock_toggle_button(DockPlacement::Bottom, window, cx);
|
||||||
let right_dock_button = self.render_dock_toggle_button(DockPlacement::Right, window, cx);
|
let right_dock_button = self.render_dock_toggle_button(DockPlacement::Right, window, cx);
|
||||||
|
|
||||||
let has_extend_dock_button = left_dock_button.is_some() || bottom_dock_button.is_some();
|
let has_extend_dock_button = left_dock_button.is_some() || bottom_dock_button.is_some();
|
||||||
let tabs_count = self.panels.len();
|
let tabs_count = self.panels.len();
|
||||||
|
let is_bottom_dock = bottom_dock_button.is_some();
|
||||||
|
|
||||||
if tabs_count == 1 && dock_area.read(cx).panel_style == PanelStyle::Default {
|
if tabs_count == 1 && dock_area.read(cx).panel_style == PanelStyle::Default {
|
||||||
let panel = self.panels.first().unwrap();
|
let panel = self.panels.first().unwrap();
|
||||||
@@ -594,6 +600,7 @@ impl TabPanel {
|
|||||||
.py_2()
|
.py_2()
|
||||||
.pl_3()
|
.pl_3()
|
||||||
.pr_2()
|
.pr_2()
|
||||||
|
.rounded_t(cx.theme().radius_lg)
|
||||||
.bg(cx.theme().panel_background)
|
.bg(cx.theme().panel_background)
|
||||||
.when(left_dock_button.is_some(), |this| this.pl_2())
|
.when(left_dock_button.is_some(), |this| this.pl_2())
|
||||||
.when(right_dock_button.is_some(), |this| this.pr_2())
|
.when(right_dock_button.is_some(), |this| this.pr_2())
|
||||||
@@ -619,7 +626,7 @@ impl TabPanel {
|
|||||||
div()
|
div()
|
||||||
.w_full()
|
.w_full()
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.text_xs()
|
.text_sm()
|
||||||
.child(panel.title(cx)),
|
.child(panel.title(cx)),
|
||||||
)
|
)
|
||||||
.when(state.draggable, |this| {
|
.when(state.draggable, |this| {
|
||||||
@@ -646,21 +653,19 @@ impl TabPanel {
|
|||||||
.into_any_element();
|
.into_any_element();
|
||||||
}
|
}
|
||||||
|
|
||||||
TabBar::new()
|
TabBar::new("tab-bar")
|
||||||
.track_scroll(&self.tab_bar_scroll_handle)
|
.track_scroll(&self.tab_bar_scroll_handle)
|
||||||
.h(TABBAR_HEIGHT)
|
.h(TABBAR_HEIGHT)
|
||||||
|
.bg(cx.theme().panel_background)
|
||||||
|
.rounded_t(cx.theme().radius_lg)
|
||||||
.when(has_extend_dock_button, |this| {
|
.when(has_extend_dock_button, |this| {
|
||||||
this.prefix(
|
this.prefix(
|
||||||
h_flex()
|
h_flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.top_0()
|
.top_0()
|
||||||
.right(-px(1.))
|
.right(-px(1.))
|
||||||
.border_r_1()
|
.pl_0p5()
|
||||||
.border_b_1()
|
.pr_1()
|
||||||
.h_full()
|
|
||||||
.border_color(cx.theme().border)
|
|
||||||
.bg(cx.theme().surface_background)
|
|
||||||
.px_2()
|
|
||||||
.children(left_dock_button)
|
.children(left_dock_button)
|
||||||
.children(bottom_dock_button),
|
.children(bottom_dock_button),
|
||||||
)
|
)
|
||||||
@@ -682,16 +687,44 @@ impl TabPanel {
|
|||||||
Some(
|
Some(
|
||||||
Tab::new()
|
Tab::new()
|
||||||
.ix(ix)
|
.ix(ix)
|
||||||
.label(panel.title(cx))
|
.tab_bar_prefix(has_extend_dock_button)
|
||||||
.py_2()
|
.child(panel.title(cx))
|
||||||
.selected(active)
|
.selected(active)
|
||||||
.disabled(disabled)
|
.disabled(disabled)
|
||||||
|
.suffix(
|
||||||
|
Button::new("close-{ix}")
|
||||||
|
.icon(IconName::Close)
|
||||||
|
.tooltip("Close panel")
|
||||||
|
.ghost()
|
||||||
|
.xsmall()
|
||||||
|
.on_click(cx.listener({
|
||||||
|
let panel = panel.clone();
|
||||||
|
move |view, _ev, window, cx| {
|
||||||
|
cx.stop_propagation();
|
||||||
|
view.remove_panel(&panel, window, cx);
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.on_click(cx.listener({
|
||||||
|
let is_collapsed = self.collapsed;
|
||||||
|
let dock_area = self.dock_area.clone();
|
||||||
|
move |view, _, window, cx| {
|
||||||
|
view.set_active_ix(ix, window, cx);
|
||||||
|
|
||||||
|
// Open dock if clicked on the collapsed bottom dock
|
||||||
|
if is_bottom_dock && is_collapsed {
|
||||||
|
_ = dock_area.update(cx, |dock_area, cx| {
|
||||||
|
dock_area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
.when(!disabled, |this| {
|
.when(!disabled, |this| {
|
||||||
this.on_mouse_down(
|
this.on_mouse_down(
|
||||||
MouseButton::Middle,
|
MouseButton::Middle,
|
||||||
cx.listener({
|
cx.listener({
|
||||||
let panel = panel.clone();
|
let panel = panel.clone();
|
||||||
move |view, _, window, cx| {
|
move |view, _ev, window, cx| {
|
||||||
view.remove_panel(&panel, window, cx);
|
view.remove_panel(&panel, window, cx);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -730,7 +763,7 @@ impl TabPanel {
|
|||||||
div()
|
div()
|
||||||
.id("tab-bar-empty-space")
|
.id("tab-bar-empty-space")
|
||||||
.h_full()
|
.h_full()
|
||||||
.flex_grow()
|
.flex_grow_1()
|
||||||
.min_w_16()
|
.min_w_16()
|
||||||
.when(state.droppable, |this| {
|
.when(state.droppable, |this| {
|
||||||
let view = cx.entity();
|
let view = cx.entity();
|
||||||
@@ -757,14 +790,11 @@ impl TabPanel {
|
|||||||
this.suffix(
|
this.suffix(
|
||||||
h_flex()
|
h_flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.px_2()
|
|
||||||
.gap_1()
|
|
||||||
.top_0()
|
.top_0()
|
||||||
.right_0()
|
.right_0()
|
||||||
.h_full()
|
.h_full()
|
||||||
.border_color(cx.theme().border)
|
.px_0p5()
|
||||||
.border_l_1()
|
.gap_1()
|
||||||
.border_b_1()
|
|
||||||
.child(self.render_toolbar(state, window, cx))
|
.child(self.render_toolbar(state, window, cx))
|
||||||
.when_some(right_dock_button, |this, btn| this.child(btn)),
|
.when_some(right_dock_button, |this, btn| this.child(btn)),
|
||||||
)
|
)
|
||||||
@@ -794,10 +824,8 @@ impl TabPanel {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.size_full()
|
.size_full()
|
||||||
|
.rounded_b(cx.theme().radius_lg)
|
||||||
.bg(cx.theme().panel_background)
|
.bg(cx.theme().panel_background)
|
||||||
.when(cx.theme().platform.is_linux(), |this| {
|
|
||||||
this.rounded_b(CLIENT_SIDE_DECORATION_ROUNDING)
|
|
||||||
})
|
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(
|
.child(
|
||||||
active_panel
|
active_panel
|
||||||
@@ -1080,7 +1108,9 @@ impl TabPanel {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
if let Some(panel) = self.active_panel(cx) {
|
if self.panels.len() > 1
|
||||||
|
&& let Some(panel) = self.active_panel(cx)
|
||||||
|
{
|
||||||
self.remove_panel(&panel, window, cx);
|
self.remove_panel(&panel, window, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1097,6 +1127,7 @@ impl Focusable for TabPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<DismissEvent> for TabPanel {}
|
impl EventEmitter<DismissEvent> for TabPanel {}
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for TabPanel {}
|
impl EventEmitter<PanelEvent> for TabPanel {}
|
||||||
|
|
||||||
impl Render for TabPanel {
|
impl Render for TabPanel {
|
||||||
@@ -1116,17 +1147,24 @@ impl Render for TabPanel {
|
|||||||
state.closable = false;
|
state.closable = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
v_flex()
|
div()
|
||||||
.when(!self.collapsed, |this| {
|
.when(!self.collapsed, |this| {
|
||||||
this.on_action(cx.listener(Self::on_action_toggle_zoom))
|
this.on_action(cx.listener(Self::on_action_toggle_zoom))
|
||||||
.on_action(cx.listener(Self::on_action_close_panel))
|
.on_action(cx.listener(Self::on_action_close_panel))
|
||||||
})
|
})
|
||||||
.id("tab-panel")
|
.id("tab-panel")
|
||||||
.tab_group()
|
|
||||||
.track_focus(&focus_handle)
|
.track_focus(&focus_handle)
|
||||||
.size_full()
|
.size_full()
|
||||||
|
.p_1()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(self.render_title_bar(&state, window, cx))
|
.child(
|
||||||
.child(self.render_active_panel(&state, window, cx))
|
v_flex()
|
||||||
|
.rounded(cx.theme().radius_lg)
|
||||||
|
.when(cx.theme().shadow, |this| this.shadow_xs())
|
||||||
|
.size_full()
|
||||||
|
.tab_group()
|
||||||
|
.child(self.render_title_bar(&state, window, cx))
|
||||||
|
.child(self.render_active_panel(&state, window, cx)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
use std::fmt::{self, Debug, Display, Formatter};
|
|
||||||
|
|
||||||
use gpui::{AbsoluteLength, Axis, Corner, Length, Pixels};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// A enum for defining the placement of the element.
|
|
||||||
///
|
|
||||||
/// See also: [`Side`] if you need to define the left, right side.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum Placement {
|
|
||||||
#[serde(rename = "top")]
|
|
||||||
Top,
|
|
||||||
#[serde(rename = "bottom")]
|
|
||||||
Bottom,
|
|
||||||
#[serde(rename = "left")]
|
|
||||||
Left,
|
|
||||||
#[serde(rename = "right")]
|
|
||||||
Right,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for Placement {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Placement::Top => write!(f, "Top"),
|
|
||||||
Placement::Bottom => write!(f, "Bottom"),
|
|
||||||
Placement::Left => write!(f, "Left"),
|
|
||||||
Placement::Right => write!(f, "Right"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Placement {
|
|
||||||
#[inline]
|
|
||||||
pub fn is_horizontal(&self) -> bool {
|
|
||||||
matches!(self, Placement::Left | Placement::Right)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn is_vertical(&self) -> bool {
|
|
||||||
matches!(self, Placement::Top | Placement::Bottom)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn axis(&self) -> Axis {
|
|
||||||
match self {
|
|
||||||
Placement::Top | Placement::Bottom => Axis::Vertical,
|
|
||||||
Placement::Left | Placement::Right => Axis::Horizontal,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The anchor position of an element.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|
||||||
pub enum Anchor {
|
|
||||||
#[default]
|
|
||||||
#[serde(rename = "top-left")]
|
|
||||||
TopLeft,
|
|
||||||
#[serde(rename = "top-center")]
|
|
||||||
TopCenter,
|
|
||||||
#[serde(rename = "top-right")]
|
|
||||||
TopRight,
|
|
||||||
#[serde(rename = "bottom-left")]
|
|
||||||
BottomLeft,
|
|
||||||
#[serde(rename = "bottom-center")]
|
|
||||||
BottomCenter,
|
|
||||||
#[serde(rename = "bottom-right")]
|
|
||||||
BottomRight,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for Anchor {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Anchor::TopLeft => write!(f, "TopLeft"),
|
|
||||||
Anchor::TopCenter => write!(f, "TopCenter"),
|
|
||||||
Anchor::TopRight => write!(f, "TopRight"),
|
|
||||||
Anchor::BottomLeft => write!(f, "BottomLeft"),
|
|
||||||
Anchor::BottomCenter => write!(f, "BottomCenter"),
|
|
||||||
Anchor::BottomRight => write!(f, "BottomRight"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Anchor {
|
|
||||||
/// Returns true if the anchor is at the top.
|
|
||||||
#[inline]
|
|
||||||
pub fn is_top(&self) -> bool {
|
|
||||||
matches!(self, Self::TopLeft | Self::TopCenter | Self::TopRight)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the anchor is at the bottom.
|
|
||||||
#[inline]
|
|
||||||
pub fn is_bottom(&self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Self::BottomLeft | Self::BottomCenter | Self::BottomRight
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the anchor is at the left.
|
|
||||||
#[inline]
|
|
||||||
pub fn is_left(&self) -> bool {
|
|
||||||
matches!(self, Self::TopLeft | Self::BottomLeft)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the anchor is at the right.
|
|
||||||
#[inline]
|
|
||||||
pub fn is_right(&self) -> bool {
|
|
||||||
matches!(self, Self::TopRight | Self::BottomRight)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the anchor is at the center.
|
|
||||||
#[inline]
|
|
||||||
pub fn is_center(&self) -> bool {
|
|
||||||
matches!(self, Self::TopCenter | Self::BottomCenter)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Swaps the vertical position of the anchor.
|
|
||||||
pub fn swap_vertical(&self) -> Self {
|
|
||||||
match self {
|
|
||||||
Anchor::TopLeft => Anchor::BottomLeft,
|
|
||||||
Anchor::TopCenter => Anchor::BottomCenter,
|
|
||||||
Anchor::TopRight => Anchor::BottomRight,
|
|
||||||
Anchor::BottomLeft => Anchor::TopLeft,
|
|
||||||
Anchor::BottomCenter => Anchor::TopCenter,
|
|
||||||
Anchor::BottomRight => Anchor::TopRight,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Swaps the horizontal position of the anchor.
|
|
||||||
pub fn swap_horizontal(&self) -> Self {
|
|
||||||
match self {
|
|
||||||
Anchor::TopLeft => Anchor::TopRight,
|
|
||||||
Anchor::TopCenter => Anchor::TopCenter,
|
|
||||||
Anchor::TopRight => Anchor::TopLeft,
|
|
||||||
Anchor::BottomLeft => Anchor::BottomRight,
|
|
||||||
Anchor::BottomCenter => Anchor::BottomCenter,
|
|
||||||
Anchor::BottomRight => Anchor::BottomLeft,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn other_side_corner_along(&self, axis: Axis) -> Anchor {
|
|
||||||
match axis {
|
|
||||||
Axis::Vertical => match self {
|
|
||||||
Self::TopLeft => Self::BottomLeft,
|
|
||||||
Self::TopCenter => Self::BottomCenter,
|
|
||||||
Self::TopRight => Self::BottomRight,
|
|
||||||
Self::BottomLeft => Self::TopLeft,
|
|
||||||
Self::BottomCenter => Self::TopCenter,
|
|
||||||
Self::BottomRight => Self::TopRight,
|
|
||||||
},
|
|
||||||
Axis::Horizontal => match self {
|
|
||||||
Self::TopLeft => Self::TopRight,
|
|
||||||
Self::TopCenter => Self::TopCenter,
|
|
||||||
Self::TopRight => Self::TopLeft,
|
|
||||||
Self::BottomLeft => Self::BottomRight,
|
|
||||||
Self::BottomCenter => Self::BottomCenter,
|
|
||||||
Self::BottomRight => Self::BottomLeft,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Corner> for Anchor {
|
|
||||||
fn from(corner: Corner) -> Self {
|
|
||||||
match corner {
|
|
||||||
Corner::TopLeft => Anchor::TopLeft,
|
|
||||||
Corner::TopRight => Anchor::TopRight,
|
|
||||||
Corner::BottomLeft => Anchor::BottomLeft,
|
|
||||||
Corner::BottomRight => Anchor::BottomRight,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Anchor> for Corner {
|
|
||||||
fn from(anchor: Anchor) -> Self {
|
|
||||||
match anchor {
|
|
||||||
Anchor::TopLeft => Corner::TopLeft,
|
|
||||||
Anchor::TopRight => Corner::TopRight,
|
|
||||||
Anchor::BottomLeft => Corner::BottomLeft,
|
|
||||||
Anchor::BottomRight => Corner::BottomRight,
|
|
||||||
Anchor::TopCenter => Corner::TopLeft,
|
|
||||||
Anchor::BottomCenter => Corner::BottomLeft,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A enum for defining the side of the element.
|
|
||||||
///
|
|
||||||
/// See also: [`Placement`] if you need to define the 4 edges.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum Side {
|
|
||||||
#[serde(rename = "left")]
|
|
||||||
Left,
|
|
||||||
#[serde(rename = "right")]
|
|
||||||
Right,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Side {
|
|
||||||
/// Returns true if the side is left.
|
|
||||||
#[inline]
|
|
||||||
pub fn is_left(&self) -> bool {
|
|
||||||
matches!(self, Self::Left)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the side is right.
|
|
||||||
#[inline]
|
|
||||||
pub fn is_right(&self) -> bool {
|
|
||||||
matches!(self, Self::Right)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A trait to extend the [`Axis`] enum with utility methods.
|
|
||||||
pub trait AxisExt {
|
|
||||||
#[allow(clippy::wrong_self_convention)]
|
|
||||||
fn is_horizontal(self) -> bool;
|
|
||||||
#[allow(clippy::wrong_self_convention)]
|
|
||||||
fn is_vertical(self) -> bool;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AxisExt for Axis {
|
|
||||||
#[inline]
|
|
||||||
fn is_horizontal(self) -> bool {
|
|
||||||
self == Axis::Horizontal
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn is_vertical(self) -> bool {
|
|
||||||
self == Axis::Vertical
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A trait for converting [`Pixels`] to `f32` and `f64`.
|
|
||||||
pub trait PixelsExt {
|
|
||||||
fn as_f32(&self) -> f32;
|
|
||||||
#[allow(clippy::wrong_self_convention)]
|
|
||||||
fn as_f64(self) -> f64;
|
|
||||||
}
|
|
||||||
impl PixelsExt for Pixels {
|
|
||||||
fn as_f32(&self) -> f32 {
|
|
||||||
f32::from(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_f64(self) -> f64 {
|
|
||||||
f64::from(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A trait to extend the [`Length`] enum with utility methods.
|
|
||||||
pub trait LengthExt {
|
|
||||||
/// Converts the [`Length`] to [`Pixels`] based on a given `base_size` and `rem_size`.
|
|
||||||
///
|
|
||||||
/// If the [`Length`] is [`Length::Auto`], it returns `None`.
|
|
||||||
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LengthExt for Length {
|
|
||||||
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels> {
|
|
||||||
match self {
|
|
||||||
Length::Auto => None,
|
|
||||||
Length::Definite(len) => Some(len.to_pixels(base_size, rem_size)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A struct for defining the edges of an element.
|
|
||||||
///
|
|
||||||
/// A extend version of [`gpui::Edges`] to serialize/deserialize.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq)]
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct Edges<T: Clone + Debug + Default + PartialEq> {
|
|
||||||
/// The size of the top edge.
|
|
||||||
pub top: T,
|
|
||||||
/// The size of the right edge.
|
|
||||||
pub right: T,
|
|
||||||
/// The size of the bottom edge.
|
|
||||||
pub bottom: T,
|
|
||||||
/// The size of the left edge.
|
|
||||||
pub left: T,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> Edges<T>
|
|
||||||
where
|
|
||||||
T: Clone + Debug + Default + PartialEq,
|
|
||||||
{
|
|
||||||
/// Creates a new `Edges` instance with all edges set to the same value.
|
|
||||||
pub fn all(value: T) -> Self {
|
|
||||||
Self {
|
|
||||||
top: value.clone(),
|
|
||||||
right: value.clone(),
|
|
||||||
bottom: value.clone(),
|
|
||||||
left: value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
use std::time::{Duration, Instant};
|
use instant::{Duration, Instant};
|
||||||
|
|
||||||
|
/// A HistoryItem represents a single change in the history.
|
||||||
|
/// It must implement Clone and PartialEq to be used in the History.
|
||||||
pub trait HistoryItem: Clone + PartialEq {
|
pub trait HistoryItem: Clone + PartialEq {
|
||||||
fn version(&self) -> usize;
|
fn version(&self) -> usize;
|
||||||
fn set_version(&mut self, version: usize);
|
fn set_version(&mut self, version: usize);
|
||||||
@@ -22,10 +24,11 @@ pub struct History<I: HistoryItem> {
|
|||||||
redos: Vec<I>,
|
redos: Vec<I>,
|
||||||
last_changed_at: Instant,
|
last_changed_at: Instant,
|
||||||
version: usize,
|
version: usize,
|
||||||
max_undo: usize,
|
pub(crate) ignore: bool,
|
||||||
|
max_undos: usize,
|
||||||
group_interval: Option<Duration>,
|
group_interval: Option<Duration>,
|
||||||
|
grouping: bool,
|
||||||
unique: bool,
|
unique: bool,
|
||||||
pub ignore: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I> History<I>
|
impl<I> History<I>
|
||||||
@@ -39,15 +42,16 @@ where
|
|||||||
ignore: false,
|
ignore: false,
|
||||||
last_changed_at: Instant::now(),
|
last_changed_at: Instant::now(),
|
||||||
version: 0,
|
version: 0,
|
||||||
max_undo: 1000,
|
max_undos: 1000,
|
||||||
group_interval: None,
|
group_interval: None,
|
||||||
|
grouping: false,
|
||||||
unique: false,
|
unique: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the maximum number of undo steps to keep, defaults to 1000.
|
/// Set the maximum number of undo steps to keep, defaults to 1000.
|
||||||
pub fn max_undo(mut self, max_undo: usize) -> Self {
|
pub fn max_undos(mut self, max_undos: usize) -> Self {
|
||||||
self.max_undo = max_undo;
|
self.max_undos = max_undos;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +68,20 @@ where
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start grouping changes, this will prevent the version from being incremented until `end_grouping` is called.
|
||||||
|
pub fn start_grouping(&mut self) {
|
||||||
|
self.grouping = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End grouping changes, this will allow the version to be incremented again.
|
||||||
|
pub fn end_grouping(&mut self) {
|
||||||
|
self.grouping = false;
|
||||||
|
}
|
||||||
|
|
||||||
/// Increment the version number if the last change was made more than `GROUP_INTERVAL` milliseconds ago.
|
/// Increment the version number if the last change was made more than `GROUP_INTERVAL` milliseconds ago.
|
||||||
fn inc_version(&mut self) -> usize {
|
fn inc_version(&mut self) -> usize {
|
||||||
let t = Instant::now();
|
let t = Instant::now();
|
||||||
if Some(self.last_changed_at.elapsed()) > self.group_interval {
|
if !self.grouping && Some(self.last_changed_at.elapsed()) > self.group_interval {
|
||||||
self.version += 1;
|
self.version += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +94,11 @@ where
|
|||||||
self.version
|
self.version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Push a new change to the history.
|
||||||
pub fn push(&mut self, item: I) {
|
pub fn push(&mut self, item: I) {
|
||||||
let version = self.inc_version();
|
let version = self.inc_version();
|
||||||
|
|
||||||
if self.undos.len() >= self.max_undo {
|
if self.undos.len() >= self.max_undos {
|
||||||
self.undos.remove(0);
|
self.undos.remove(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +128,7 @@ where
|
|||||||
self.redos.clear();
|
self.redos.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Undo the last change and return the changes that were undone.
|
||||||
pub fn undo(&mut self) -> Option<Vec<I>> {
|
pub fn undo(&mut self) -> Option<Vec<I>> {
|
||||||
if let Some(first_change) = self.undos.pop() {
|
if let Some(first_change) = self.undos.pop() {
|
||||||
let mut changes = vec![first_change.clone()];
|
let mut changes = vec![first_change.clone()];
|
||||||
@@ -135,6 +151,7 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Redo the last undone change and return the changes that were redone.
|
||||||
pub fn redo(&mut self) -> Option<Vec<I>> {
|
pub fn redo(&mut self) -> Option<Vec<I>> {
|
||||||
if let Some(first_change) = self.redos.pop() {
|
if let Some(first_change) = self.redos.pop() {
|
||||||
let mut changes = vec![first_change.clone()];
|
let mut changes = vec![first_change.clone()];
|
||||||
|
|||||||
+10
-2
@@ -1,7 +1,7 @@
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
svg, AnyElement, App, AppContext, Context, Entity, Hsla, IntoElement, Radians, Render,
|
AnyElement, App, AppContext, Context, Entity, Hsla, IntoElement, Radians, Render, RenderOnce,
|
||||||
RenderOnce, SharedString, StyleRefinement, Styled, Svg, Transformation, Window,
|
SharedString, StyleRefinement, Styled, Svg, Transformation, Window, svg,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
@@ -34,10 +34,12 @@ pub enum IconName {
|
|||||||
CloseCircle,
|
CloseCircle,
|
||||||
CloseCircleFill,
|
CloseCircleFill,
|
||||||
Copy,
|
Copy,
|
||||||
|
Device,
|
||||||
Door,
|
Door,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
Emoji,
|
Emoji,
|
||||||
Eye,
|
Eye,
|
||||||
|
Input,
|
||||||
Info,
|
Info,
|
||||||
Invite,
|
Invite,
|
||||||
Inbox,
|
Inbox,
|
||||||
@@ -52,12 +54,14 @@ pub enum IconName {
|
|||||||
Relay,
|
Relay,
|
||||||
Reply,
|
Reply,
|
||||||
Refresh,
|
Refresh,
|
||||||
|
Scan,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Settings2,
|
Settings2,
|
||||||
Sun,
|
Sun,
|
||||||
Ship,
|
Ship,
|
||||||
Shield,
|
Shield,
|
||||||
|
Group,
|
||||||
UserKey,
|
UserKey,
|
||||||
Upload,
|
Upload,
|
||||||
Usb,
|
Usb,
|
||||||
@@ -102,10 +106,12 @@ impl IconNamed for IconName {
|
|||||||
Self::CloseCircle => "icons/close-circle.svg",
|
Self::CloseCircle => "icons/close-circle.svg",
|
||||||
Self::CloseCircleFill => "icons/close-circle-fill.svg",
|
Self::CloseCircleFill => "icons/close-circle-fill.svg",
|
||||||
Self::Copy => "icons/copy.svg",
|
Self::Copy => "icons/copy.svg",
|
||||||
|
Self::Device => "icons/device.svg",
|
||||||
Self::Door => "icons/door.svg",
|
Self::Door => "icons/door.svg",
|
||||||
Self::Ellipsis => "icons/ellipsis.svg",
|
Self::Ellipsis => "icons/ellipsis.svg",
|
||||||
Self::Emoji => "icons/emoji.svg",
|
Self::Emoji => "icons/emoji.svg",
|
||||||
Self::Eye => "icons/eye.svg",
|
Self::Eye => "icons/eye.svg",
|
||||||
|
Self::Input => "icons/input.svg",
|
||||||
Self::Info => "icons/info.svg",
|
Self::Info => "icons/info.svg",
|
||||||
Self::Invite => "icons/invite.svg",
|
Self::Invite => "icons/invite.svg",
|
||||||
Self::Inbox => "icons/inbox.svg",
|
Self::Inbox => "icons/inbox.svg",
|
||||||
@@ -120,6 +126,7 @@ impl IconNamed for IconName {
|
|||||||
Self::Relay => "icons/relay.svg",
|
Self::Relay => "icons/relay.svg",
|
||||||
Self::Reply => "icons/reply.svg",
|
Self::Reply => "icons/reply.svg",
|
||||||
Self::Refresh => "icons/refresh.svg",
|
Self::Refresh => "icons/refresh.svg",
|
||||||
|
Self::Scan => "icons/scan.svg",
|
||||||
Self::Search => "icons/search.svg",
|
Self::Search => "icons/search.svg",
|
||||||
Self::Settings => "icons/settings.svg",
|
Self::Settings => "icons/settings.svg",
|
||||||
Self::Settings2 => "icons/settings2.svg",
|
Self::Settings2 => "icons/settings2.svg",
|
||||||
@@ -129,6 +136,7 @@ impl IconNamed for IconName {
|
|||||||
Self::UserKey => "icons/user-key.svg",
|
Self::UserKey => "icons/user-key.svg",
|
||||||
Self::Upload => "icons/upload.svg",
|
Self::Upload => "icons/upload.svg",
|
||||||
Self::Usb => "icons/usb.svg",
|
Self::Usb => "icons/usb.svg",
|
||||||
|
Self::Group => "icons/group.svg",
|
||||||
Self::PanelLeft => "icons/panel-left.svg",
|
Self::PanelLeft => "icons/panel-left.svg",
|
||||||
Self::PanelLeftOpen => "icons/panel-left-open.svg",
|
Self::PanelLeftOpen => "icons/panel-left-open.svg",
|
||||||
Self::PanelRight => "icons/panel-right.svg",
|
Self::PanelRight => "icons/panel-right.svg",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::time::Duration;
|
use instant::Duration;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
use std::time::Duration;
|
use instant::Duration;
|
||||||
|
|
||||||
use gpui::{px, Context, Pixels};
|
use gpui::{Context, Pixels, Task, px};
|
||||||
|
|
||||||
static INTERVAL: Duration = Duration::from_millis(500);
|
static INTERVAL: Duration = Duration::from_millis(500);
|
||||||
static PAUSE_DELAY: Duration = Duration::from_millis(300);
|
static PAUSE_DELAY: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
|
// On Windows, Linux, we should use integer to avoid blurry cursor.
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
pub(super) const CURSOR_WIDTH: Pixels = px(2.);
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
pub(super) const CURSOR_WIDTH: Pixels = px(1.5);
|
pub(super) const CURSOR_WIDTH: Pixels = px(1.5);
|
||||||
|
|
||||||
/// To manage the Input cursor blinking.
|
/// To manage the Input cursor blinking.
|
||||||
@@ -12,10 +17,12 @@ pub(super) const CURSOR_WIDTH: Pixels = px(1.5);
|
|||||||
/// Every loop will notify the view to update the `visible`, and Input will observe this update to touch repaint.
|
/// Every loop will notify the view to update the `visible`, and Input will observe this update to touch repaint.
|
||||||
///
|
///
|
||||||
/// The input painter will check if this in visible state, then it will draw the cursor.
|
/// The input painter will check if this in visible state, then it will draw the cursor.
|
||||||
pub struct BlinkCursor {
|
pub(crate) struct BlinkCursor {
|
||||||
visible: bool,
|
visible: bool,
|
||||||
paused: bool,
|
paused: bool,
|
||||||
epoch: usize,
|
epoch: usize,
|
||||||
|
|
||||||
|
_task: Task<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BlinkCursor {
|
impl BlinkCursor {
|
||||||
@@ -24,6 +31,7 @@ impl BlinkCursor {
|
|||||||
visible: false,
|
visible: false,
|
||||||
paused: false,
|
paused: false,
|
||||||
epoch: 0,
|
epoch: 0,
|
||||||
|
_task: Task::ready(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,14 +61,12 @@ impl BlinkCursor {
|
|||||||
|
|
||||||
// Schedule the next blink
|
// Schedule the next blink
|
||||||
let epoch = self.next_epoch();
|
let epoch = self.next_epoch();
|
||||||
cx.spawn(async move |this, cx| {
|
self._task = cx.spawn(async move |this, cx| {
|
||||||
cx.background_executor().timer(INTERVAL).await;
|
cx.background_executor().timer(INTERVAL).await;
|
||||||
|
|
||||||
if let Some(this) = this.upgrade() {
|
if let Some(this) = this.upgrade() {
|
||||||
this.update(cx, |this, cx| this.blink(epoch, cx));
|
this.update(cx, |this, cx| this.blink(epoch, cx));
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
.detach();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn visible(&self) -> bool {
|
pub fn visible(&self) -> bool {
|
||||||
@@ -76,7 +82,7 @@ impl BlinkCursor {
|
|||||||
|
|
||||||
// delay 500ms to start the blinking
|
// delay 500ms to start the blinking
|
||||||
let epoch = self.next_epoch();
|
let epoch = self.next_epoch();
|
||||||
cx.spawn(async move |this, cx| {
|
self._task = cx.spawn(async move |this, cx| {
|
||||||
cx.background_executor().timer(PAUSE_DELAY).await;
|
cx.background_executor().timer(PAUSE_DELAY).await;
|
||||||
|
|
||||||
if let Some(this) = this.upgrade() {
|
if let Some(this) = this.upgrade() {
|
||||||
@@ -85,13 +91,6 @@ impl BlinkCursor {
|
|||||||
this.blink(epoch, cx);
|
this.blink(epoch, cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for BlinkCursor {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
|
|
||||||
use crate::history::HistoryItem;
|
use crate::{history::HistoryItem, input::Selection};
|
||||||
use crate::input::cursor::Selection;
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Clone)]
|
#[derive(Debug, PartialEq, Clone)]
|
||||||
pub struct Change {
|
pub struct Change {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
use gpui::{App, Styled};
|
use gpui::{App, Styled};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::button::{Button, ButtonVariants};
|
use crate::button::{Button, ButtonVariants as _};
|
||||||
use crate::{Icon, IconName, Sizable};
|
use crate::{Icon, IconName, Sizable as _};
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub(crate) fn clear_button(cx: &App) -> Button {
|
pub(crate) fn clear_button(cx: &App) -> Button {
|
||||||
Button::new("clean")
|
Button::new("clean")
|
||||||
.icon(Icon::new(IconName::CloseCircle))
|
.icon(Icon::new(IconName::CloseCircle))
|
||||||
.tooltip("Clear")
|
.ghost()
|
||||||
.small()
|
.xsmall()
|
||||||
.transparent()
|
.tab_stop(false)
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().icon_muted)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user