Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a7afbe3e2 | ||
|
|
2f9559228f | ||
|
|
7e89f63a7c | ||
|
|
baea9cd9ff | ||
|
|
9ec8b128db | ||
|
|
4166b30319 | ||
|
|
6fc7732128 | ||
|
|
41b6c80057 | ||
|
|
98a12e438a | ||
|
|
c1757bbcae | ||
|
|
74d548e5c6 | ||
|
|
690dc37fc5 | ||
|
|
2f2661ecd2 | ||
|
|
d045a7ef3a | ||
|
|
23d7017f25 | ||
|
|
af2a276b1b | ||
|
|
69ca199a7d | ||
|
|
83a440d893 | ||
|
|
77b2a5f3cd | ||
|
|
b0121f8df2 | ||
|
|
bfa34959cf | ||
|
|
4d0e2f5050 | ||
|
|
0824b1825b | ||
|
|
3212951ed4 | ||
|
|
ceb69660be | ||
|
|
8f35ae92b7 | ||
|
|
30da985ac1 |
@@ -20,9 +20,6 @@ jobs:
|
||||
- platform: windows-arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- platform: macos-x64
|
||||
os: macos-15-intel
|
||||
target: x86_64-apple-darwin
|
||||
- platform: macos-arm64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
|
||||
@@ -30,3 +30,26 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
|
||||
check-wasm:
|
||||
name: Check web target
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust nightly
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: nightly-2026-10-31
|
||||
targets: wasm32-unknown-unknown
|
||||
components: llvm-tools-preview
|
||||
|
||||
- name: Install wasm C toolchain
|
||||
run: sudo apt-get update && sudo apt-get install -y clang llvm
|
||||
|
||||
- name: Check web target
|
||||
env:
|
||||
CC_wasm32_unknown_unknown: clang
|
||||
AR_wasm32_unknown_unknown: llvm-ar
|
||||
run: cargo +nightly-2026-10-31 check -p coop_web --target wasm32-unknown-unknown
|
||||
|
||||
@@ -24,3 +24,4 @@ dist/
|
||||
vendor/
|
||||
wasm/
|
||||
node_modules/
|
||||
.wrangler/
|
||||
|
||||
@@ -4,27 +4,23 @@ members = ["crates/*", "desktop", "web"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "2.0.0-alpha"
|
||||
version = "2.0.0-alpha3"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
gpui = { package = "gpui-pre", version = "0.3.8" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { package = "gpui-pre-linux", version = "0.3.8" }
|
||||
gpui_windows = { package = "gpui-pre-windows", version = "0.3.8" }
|
||||
gpui_macos = { package = "gpui-pre-macos", version = "0.3.8" }
|
||||
gpui_web = { package = "gpui-pre-web", version = "0.3.8" }
|
||||
gpui_util = { package = "gpui-pre-util", version = "0.3.8" }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
|
||||
gpui = { package = "gpui-pre", version = "0.3.10" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.10", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { package = "gpui-pre-linux", version = "0.3.10" }
|
||||
gpui_windows = { package = "gpui-pre-windows", version = "0.3.10" }
|
||||
gpui_macos = { package = "gpui-pre-macos", version = "0.3.10" }
|
||||
gpui_web = { package = "gpui-pre-web", version = "0.3.10" }
|
||||
gpui_util = { package = "gpui-pre-util", version = "0.3.10" }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.10" }
|
||||
gpui_tokio = { path = "crates/gpui_tokio" }
|
||||
gpui-base = "0.7.1"
|
||||
gpui-kit = "0.7.3"
|
||||
|
||||
# Native OS theme reading; the connector is written against gpui-component 0.7.x
|
||||
# and gpui-pre 0.3.8, both satisfied by the versions above (0.7.3 / 0.3.9).
|
||||
native-theme = { version = "0.6.1", features = ["watch"] }
|
||||
native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] }
|
||||
gpui-base = "0.8.1"
|
||||
gpui-kit = "0.8.1"
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
@@ -32,8 +28,10 @@ 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-browser-signer = { 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", "nip98" ] }
|
||||
nostr-sqlite = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip60", "nip98" ] }
|
||||
|
||||
# Crypto (NIP-17 encrypted file messages)
|
||||
aes-gcm = "0.10"
|
||||
@@ -46,9 +44,13 @@ chacha20 = "0.9"
|
||||
hmac = "0.12"
|
||||
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
|
||||
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng", "thread_rng" ] }
|
||||
# Cashu (NIP-60).
|
||||
cdk = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
|
||||
cdk-redb = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
tts = { git = "https://github.com/ndarilek/tts-rs" }
|
||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
futures = "0.3"
|
||||
itertools = "0.13.0"
|
||||
@@ -64,6 +66,12 @@ webbrowser = "1.0.4"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
instant = "0.1"
|
||||
|
||||
# cdk's registry nostr/nostr-sdk requirements are redirected onto the same
|
||||
# git repo coop already uses, keeping a single copy of each in the graph.
|
||||
[patch.crates-io]
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M10 5.75L3.75 12L10 18.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.5 12H20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path
|
||||
d="M10 5.75L3.75 12L10 18.25"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.5 12H20.25"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 319 B After Width: | Height: | Size: 418 B |
@@ -1,13 +1 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M9.25 9.75C7.317 9.75 5.75 8.183 5.75 6.25C5.75 4.317 7.317 2.75 9.25 2.75C11.183 2.75 12.75 4.317 12.75 6.25C12.75 8.183 11.183 9.75 9.25 9.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 2.75C16.933 2.75 18.5 4.317 18.5 6.25C18.5 8.183 16.933 9.75 15 9.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.24806 12.75C5.46395 12.75 2.27489 15.1517 1.30259 18.2496C0.971822 19.3035 1.89349 20.2187 2.99806 20.2188H15.4981C16.6026 20.2188 17.5243 19.3035 17.1935 18.2496C16.2212 15.1517 13.0322 12.75 9.24806 12.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.75 20.25H21.25C22.3546 20.25 23.2748 19.3347 22.9405 18.2819C22.2598 16.1389 20.512 14.31 18.25 13.5088" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 310 B After Width: | Height: | Size: 1014 B |
@@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75" stroke="black" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 191 B After Width: | Height: | Size: 201 B |
@@ -1,14 +1,3 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5"/><ellipse cx="12" cy="12" rx="3.5" ry="9.25" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 9.25H20.5" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 14.75H20.5" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 351 B After Width: | Height: | Size: 377 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 10V9" />
|
||||
<path d="M7.084 14.302a5.12 5.12 0 009.833 0 .24.24 0 00-.235-.302H7.32a.24.24 0 00-.235.302" />
|
||||
<path d="M9 10V9" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 390 B |
@@ -1,14 +1,3 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9.75027 5.52371L10.7168 4.55722C13.1264 2.14759 17.0332 2.14759 19.4428 4.55722C21.8524 6.96684 21.8524 10.8736 19.4428 13.2832L18.4742 14.2519M5.52886 9.74513L4.55722 10.7168C2.14759 13.1264 2.1476 17.0332 4.55722 19.4428C6.96684 21.8524 10.8736 21.8524 13.2832 19.4428L14.2478 18.4782M9.5 14.5L14.5 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 359 B After Width: | Height: | Size: 462 B |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
@@ -1,14 +1,3 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 8.75003C11.1716 8.75003 10.5 9.4216 10.5 10.25C10.5 11.0785 11.1716 11.75 12 11.75C12.8284 11.75 13.5 11.0785 13.5 10.25C13.5 9.4216 12.8284 8.75003 12 8.75003ZM12 8.75003V14.75M20.25 11.9124V6.94155C20.25 6.08069 19.6991 5.31641 18.8825 5.04418L12.6325 2.96085C12.2219 2.824 11.7781 2.824 11.3675 2.96085L5.11754 5.04418C4.30086 5.31641 3.75 6.08069 3.75 6.94155V11.9124C3.75 16.8848 8 19.25 12 21.4079C16 19.25 20.25 16.8848 20.25 11.9124Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 309 B After Width: | Height: | Size: 626 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12.2126 10.7642L7.07919 15.8829C6.51216 16.4483 6.51216 17.365 7.07919 17.9304C7.64621 18.4958 8.56554 18.4958 9.13257 17.9304L19.3995 7.69303C19.9664 7.12769 20.25 6.38673 20.25 5.64575C20.25 4.90465 19.9665 4.16353 19.3995 3.59809C18.8324 3.03271 18.0893 2.75001 17.3461 2.75C16.6029 2.74999 15.8597 3.03269 15.2927 3.59809L5.02581 13.8354C4.17522 14.6836 3.74995 15.7952 3.75 16.9068C3.75005 18.0183 4.17532 19.1298 5.02581 19.9779C5.87635 20.826 6.99113 21.25 8.10591 21.25C9.22067 21.25 10.3354 20.8259 11.1859 19.9779L16.3194 14.8592" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 697 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 5.25V12M12 12V18.75M12 12H5.25M12 12H18.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M12 6.75V12M12 12V17.25M12 12H6.75M12 12H17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 203 B After Width: | Height: | Size: 203 B |
@@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M20.25 20.25L16.1265 16.1265M16.1265 16.1265C17.4385 14.8145 18.25 13.002 18.25 11C18.25 6.99594 15.0041 3.75 11 3.75C6.99594 3.75 3.75 6.99594 3.75 11C3.75 15.0041 6.99594 18.25 11 18.25C13.002 18.25 14.8145 17.4385 16.1265 16.1265Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20 20L16.1265 16.1265M16.1265 16.1265C17.4385 14.8145 18.25 13.002 18.25 11C18.25 6.99594 15.0041 3.75 11 3.75C6.99594 3.75 3.75 6.99594 3.75 11C3.75 15.0041 6.99594 18.25 11 18.25C13.002 18.25 14.8145 17.4385 16.1265 16.1265Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 404 B After Width: | Height: | Size: 408 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M13 19.25H5.95C4.82989 19.25 4.26984 19.25 3.84202 19.032C3.46569 18.8403 3.15973 18.5343 2.96799 18.158C2.75 17.7302 2.75 17.1701 2.75 16.05V7.95C2.75 6.82989 2.75 6.26984 2.96799 5.84202C3.15973 5.46569 3.46569 5.15973 3.84202 4.96799C4.26984 4.75 4.8299 4.75 5.95 4.75H18.05C19.1701 4.75 19.7302 4.75 20.158 4.96799C20.5343 5.15973 20.8403 5.46569 21.032 5.84202C21.25 6.26984 21.25 6.8299 21.25 7.95V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3 5.63635L10.9761 10.3898C11.6069 10.7657 12.3931 10.7657 13.0239 10.3898L21 5.63635" stroke="currentColor" stroke-width="1.5"/><path d="M21.8148 15.375L21.1669 15.7491M16.1856 18.625L16.8335 18.2509M21.8147 18.625L21.1669 18.251M16.1855 15.375L16.8335 15.7491M19.0002 20.25L19.0002 19.4375M19.0002 13.75V14.5625M21.1669 17C21.1669 16.6053 21.0613 16.2352 20.8769 15.9165C20.5022 15.269 19.8021 14.8333 19.0002 14.8333C18.1983 14.8333 17.4981 15.269 17.1235 15.9165C16.9391 16.2352 16.8335 16.6053 16.8335 17C16.8335 17.3947 16.9391 17.7648 17.1235 18.0835C17.4982 18.731 18.1983 19.1667 19.0002 19.1667C19.8021 19.1667 20.5022 18.731 20.8769 18.0835C21.0613 17.7648 21.1669 17.3947 21.1669 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M10.75 14.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14.25 7.75V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.75 10.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M21.25 12.75V15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.75 5.25V3.75H7M11.25 5.25V3.75H7M7 3.75V12.25M7 12.25H5.25M7 12.25H8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 726 B |
@@ -1,15 +1 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 3v12" />
|
||||
<path d="m17 8-5-5-5 5" />
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M11.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25V5.75C3.75 4.64543 4.64543 3.75 5.75 3.75H18.25C19.3546 3.75 20.25 4.64543 20.25 5.75V11.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3.75 16.25L6.58579 13.4142C7.36683 12.6332 8.63317 12.6332 9.41421 13.4142L12 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M14.5 11.5C15.6046 11.5 16.5 10.6046 16.5 9.5C16.5 8.39543 15.6046 7.5 14.5 7.5C13.3954 7.5 12.5 8.39543 12.5 9.5C12.5 10.6046 13.3954 11.5 14.5 11.5Z" stroke="currentColor" stroke-width="1.5"/><path d="M15.75 18.5H21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M18.5 15.75V21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 849 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<line x1="19" x2="19" y1="8" y2="14" />
|
||||
<line x1="22" x2="16" y1="11" y2="11" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M21.25 10.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V10.75M21.25 10.75H18.75M21.25 10.75V6.75C21.25 5.64543 20.3546 4.75 19.25 4.75H4.75C3.64543 4.75 2.75 5.64543 2.75 6.75V10.75M2.75 10.75H5.25M5.25 10.75H7.13177C7.53452 10.75 7.92789 10.8716 8.26039 11.0989L9.43406 11.9011C9.76655 12.1284 10.1599 12.25 10.5627 12.25H13.4373C13.8401 12.25 14.2334 12.1284 14.5659 11.9011L15.7396 11.0989C16.0721 10.8716 16.4655 10.75 16.8682 10.75H18.75M5.25 10.75V7.75C5.25 7.47386 5.47386 7.25 5.75 7.25H18.25C18.5261 7.25 18.75 7.47386 18.75 7.75V10.75" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 748 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5"/><path d="M11.3121 12.3511L11.0582 7.9983C11.0266 7.45662 11.4574 7 12 7C12.5426 7 12.9734 7.45662 12.9418 7.9983L12.6879 12.3511C12.6666 12.7154 12.365 13 12 13C11.635 13 11.3334 12.7154 11.3121 12.3511Z" fill="currentColor"/><circle cx="11.9999" cy="15.8998" r="1.1" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 445 B |
@@ -0,0 +1,307 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Adventure",
|
||||
"author": "iTerm2-Color-Schemes",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Adventure",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#25292d",
|
||||
"accent.foreground": "#feffff",
|
||||
"background": "#040404",
|
||||
"border": "#282828",
|
||||
"danger.background": "#d84a33",
|
||||
"foreground": "#feffff",
|
||||
"input.border": "#333333",
|
||||
"link.active.foreground": "#417AB3",
|
||||
"link.foreground": "#417AB3",
|
||||
"link.hover.foreground": "#417AB3",
|
||||
"list.active.background": "#5da60222",
|
||||
"list.active.border": "#5da602",
|
||||
"list.even.background": "#0e0e0e",
|
||||
"muted.background": "#171717",
|
||||
"muted.foreground": "#5d6165",
|
||||
"panel.background": "#003a5b",
|
||||
"popover.background": "#040404",
|
||||
"popover.foreground": "#feffff",
|
||||
"primary.active.background": "#0265a799",
|
||||
"primary.background": "#4384ad",
|
||||
"primary.foreground": "#feffff",
|
||||
"primary.hover.background": "#417AB3",
|
||||
"scrollbar.background": "#003a5b00",
|
||||
"scrollbar.thumb.background": "#606060",
|
||||
"secondary.background": "#171a1c",
|
||||
"secondary.active.background": "#191d1e",
|
||||
"secondary.foreground": "#e4d5c7",
|
||||
"secondary.hover.background": "#1e2224",
|
||||
"tab.active.background": "#040404",
|
||||
"tab.active.foreground": "#feffff",
|
||||
"tab.background": "#04040400",
|
||||
"tab.foreground": "#677179",
|
||||
"tab_bar.background": "#040404",
|
||||
"table.background": "#040404",
|
||||
"table.head.foreground": "#feffffb3",
|
||||
"table.row.border": "#4b647970",
|
||||
"title_bar.background": "#0f1112",
|
||||
"title_bar.border": "#282828",
|
||||
"ring": "#5da602",
|
||||
"base.red": "#d84a33",
|
||||
"base.red.light": "#d76b42",
|
||||
"base.green": "#5da602",
|
||||
"base.green.light": "#99b52c",
|
||||
"base.blue": "#417ab3",
|
||||
"base.blue.light": "#97d7ef",
|
||||
"base.cyan": "#41b3a9",
|
||||
"base.cyan.light": "#97efd6",
|
||||
"base.magenta": "#882252",
|
||||
"base.magenta.light": "#e599bc",
|
||||
"base.yellow": "#aa7900",
|
||||
"base.yellow.light": "#ffb670"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#feffff",
|
||||
"editor.background": "#040404",
|
||||
"editor.active_line.background": "#0e0e0e",
|
||||
"editor.line_number": "#5d6165",
|
||||
"editor.active_line_number": "#feffff",
|
||||
"editor.invisible": "#5d616566",
|
||||
"conflict": "#d84a33",
|
||||
"created": "#5da602",
|
||||
"deleted": "#d84a33",
|
||||
"error": "#d84a33",
|
||||
"hidden": "#5d6165",
|
||||
"hint": "#99b52c",
|
||||
"ignored": "#5d6165",
|
||||
"info": "#417ab3",
|
||||
"modified": "#eebb6e",
|
||||
"predictive": "#5d6165",
|
||||
"renamed": "#5da602",
|
||||
"success": "#5da602",
|
||||
"unreachable": "#5d6165",
|
||||
"warning": "#eebb6e",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#5da602"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#5d6165",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#5d6165",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#d84a33"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#feffff"
|
||||
},
|
||||
"function": {
|
||||
"color": "#5da602"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#417ab3"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#97d7ef",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#4b6479",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#d84a33"
|
||||
},
|
||||
"string": {
|
||||
"color": "#5da602"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#5da602"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#5da602"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#d84a33"
|
||||
},
|
||||
"title": {
|
||||
"color": "#97d7ef",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"property": {
|
||||
"color": "#feffff"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#d84a33"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Adventure Time",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#36345f",
|
||||
"accent.foreground": "#C7C7D4",
|
||||
"background": "#1f1d45",
|
||||
"border": "#333150",
|
||||
"foreground": "#C7C7D4",
|
||||
"input.border": "#555465",
|
||||
"link.active.foreground": "#4b61bf",
|
||||
"link.foreground": "#5f72c6",
|
||||
"link.hover.foreground": "#8593d3",
|
||||
"list.active.background": "#4ab11822",
|
||||
"list.active.border": "#549235",
|
||||
"list.even.background": "#1c1a37",
|
||||
"muted.background": "#29274a",
|
||||
"muted.foreground": "#717192",
|
||||
"panel.background": "#003a5b",
|
||||
"popover.background": "#1f1d45",
|
||||
"popover.foreground": "#C7C7D4",
|
||||
"primary.active.background": "#5f72c699",
|
||||
"primary.background": "#5f72c6",
|
||||
"primary.foreground": "#ffffff",
|
||||
"primary.hover.background": "#5f72c6aa",
|
||||
"scrollbar.background": "#003a5b00",
|
||||
"scrollbar.thumb.background": "#555465",
|
||||
"secondary.background": "#2e2c51",
|
||||
"secondary.active.background": "#36345f",
|
||||
"secondary.foreground": "#C7C7D4",
|
||||
"secondary.hover.background": "#34325b",
|
||||
"tab.active.background": "#1f1d45",
|
||||
"tab.active.foreground": "#C7C7D4",
|
||||
"tab.background": "#1f1d4500",
|
||||
"tab.foreground": "#aeab9e",
|
||||
"tab_bar.background": "#1f1d45",
|
||||
"table.active.background": "#4ab11811",
|
||||
"table.active.border": "#549235",
|
||||
"table.head.foreground": "#f8dcc0b3",
|
||||
"table.row.border": "#333333",
|
||||
"title_bar.background": "#1c1a3e",
|
||||
"title_bar.border": "#333150",
|
||||
"base.red": "#a02733",
|
||||
"base.red.light": "#fc5f5a",
|
||||
"base.green": "#549235",
|
||||
"base.green.light": "#9eff6e",
|
||||
"base.blue": "#2b53ab",
|
||||
"base.blue.light": "#1997c6",
|
||||
"base.cyan": "#26977b",
|
||||
"base.cyan.light": "#5afcd4",
|
||||
"base.magenta": "#665993",
|
||||
"base.magenta.light": "#9b5953",
|
||||
"base.yellow": "#ce7837",
|
||||
"base.yellow.light": "#efc11a"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#f8dcc0",
|
||||
"editor.background": "#1f1d45",
|
||||
"editor.active_line.background": "#36345f",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#5d616566",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Alduin",
|
||||
"author": "AlessandroYorba",
|
||||
"url": "https://github.com/AlessandroYorba/Alduin",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Alduin",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2e2b29",
|
||||
"accent.foreground": "#ebdbb2",
|
||||
"background": "#1C1C1C",
|
||||
"border": "#3a3a3a",
|
||||
"danger.background": "#cc241d",
|
||||
"danger.active.background": "#cc241d",
|
||||
"danger.hover.background": "#fb4934",
|
||||
"foreground": "#9E9E9E",
|
||||
"input.border": "#504945",
|
||||
"link.active.foreground": "#83a598",
|
||||
"link.foreground": "#83a598",
|
||||
"link.hover.foreground": "#83a598",
|
||||
"list.active.background": "#262626",
|
||||
"list.active.border": "#9E9E9E",
|
||||
"list.even.background": "#262626",
|
||||
"list.head.background": "#1C1C1C",
|
||||
"muted.background": "#262626",
|
||||
"muted.foreground": "#878787",
|
||||
"panel.background": "#282828",
|
||||
"primary.active.background": "#45858899",
|
||||
"primary.background": "#458588",
|
||||
"primary.foreground": "#F0F0F0",
|
||||
"primary.hover.background": "#458588AA",
|
||||
"scrollbar.background": "#28282800",
|
||||
"scrollbar.thumb.background": "#504945",
|
||||
"secondary.background": "#282828",
|
||||
"secondary.active.background": "#35322f",
|
||||
"secondary.foreground": "#9E9E9E",
|
||||
"secondary.hover.background": "#36323099",
|
||||
"tab.active.background": "#1C1C1C",
|
||||
"tab.active.foreground": "#ebdbb2",
|
||||
"tab.background": "#14141400",
|
||||
"tab.foreground": "#848382",
|
||||
"table.row.border": "#50494570",
|
||||
"title_bar.background": "#262626",
|
||||
"title_bar.border": "#3a3a3a",
|
||||
"ring": "#9E9E9E",
|
||||
"base.blue": "#87afaf",
|
||||
"base.cyan": "#878787",
|
||||
"base.green": "#7a875f",
|
||||
"base.magenta": "#af8787",
|
||||
"base.magenta.light": "#af878788",
|
||||
"base.red": "#8b5f61",
|
||||
"base.yellow": "#9d906c"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#9E9E9E66",
|
||||
"conflict": "#8b5f61",
|
||||
"created": "#87afaf",
|
||||
"error": "#8b5f61",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#af8787",
|
||||
"info": "#878787",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Asciinema",
|
||||
"author": "asciinema.org",
|
||||
"url": "https://asciinema.org",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Asciinema",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#262829",
|
||||
"accent.foreground": "#cccccc",
|
||||
"background": "#121314",
|
||||
"border": "#303030",
|
||||
"danger.background": "#dd3c69",
|
||||
"danger.active.background": "#dd3c69",
|
||||
"danger.hover.background": "#e94f7a",
|
||||
"foreground": "#cccccc",
|
||||
"input.border": "#3a3a3a",
|
||||
"link.active.foreground": "#26b0d7",
|
||||
"link.foreground": "#26b0d7",
|
||||
"link.hover.foreground": "#3cc0e7",
|
||||
"list.active.background": "#1a1b1c",
|
||||
"list.active.border": "#cccccc",
|
||||
"list.even.background": "#1a1b1c",
|
||||
"list.head.background": "#121314",
|
||||
"muted.background": "#1a1b1c",
|
||||
"muted.foreground": "#6d6d6d",
|
||||
"panel.background": "#181919",
|
||||
"primary.active.background": "#26b0d799",
|
||||
"primary.background": "#26b0d7",
|
||||
"primary.foreground": "#ffffff",
|
||||
"primary.hover.background": "#26b0d7AA",
|
||||
"scrollbar.background": "#12131400",
|
||||
"scrollbar.thumb.background": "#2a2a2a",
|
||||
"secondary.background": "#181919",
|
||||
"secondary.active.background": "#1f2020",
|
||||
"secondary.foreground": "#cccccc",
|
||||
"secondary.hover.background": "#1f202099",
|
||||
"tab.active.background": "#121314",
|
||||
"tab.active.foreground": "#cccccc",
|
||||
"tab.background": "#12131400",
|
||||
"tab.foreground": "#6d6d6d",
|
||||
"table.row.border": "#2a2a2a70",
|
||||
"title_bar.background": "#181919",
|
||||
"title_bar.border": "#303030",
|
||||
"ring": "#5d5d5d",
|
||||
"base.blue": "#26b0d7",
|
||||
"base.cyan": "#54e1b9",
|
||||
"base.green": "#4ebf22",
|
||||
"base.magenta": "#b954e1",
|
||||
"base.red": "#dd3c69",
|
||||
"base.yellow": "#ddaf3c"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#cccccc",
|
||||
"editor.background": "#121314",
|
||||
"editor.active_line.background": "#1a1b1c",
|
||||
"editor.line_number": "#6d6d6d",
|
||||
"editor.active_line_number": "#cccccc",
|
||||
"editor.invisible": "#6d6d6d66",
|
||||
"conflict": "#dd3c69",
|
||||
"created": "#4ebf22",
|
||||
"error": "#dd3c69",
|
||||
"hidden": "#6d6d6d",
|
||||
"hint": "#b954e1",
|
||||
"info": "#26b0d7",
|
||||
"modified": "#ddaf3c",
|
||||
"predictive": "#2a2a2a",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#ddaf3c"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#b954e1"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#5d5d5d"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#5d5d5d"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#b954e1"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#26b0d7"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#cccccc"
|
||||
},
|
||||
"function": {
|
||||
"color": "#54e1b9"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#dd3c69"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#26b0d7",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#54e1b9",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#b954e1"
|
||||
},
|
||||
"string": {
|
||||
"color": "#4ebf22"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#54e1b9"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#4ebf22"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#ddaf3c"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#ddaf3c"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#dd3c69"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#cccccc"
|
||||
},
|
||||
"title": {
|
||||
"color": "#ddaf3c",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#26b0d7"
|
||||
},
|
||||
"property": {
|
||||
"color": "#cccccc"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#dd3c69"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Aurora",
|
||||
"author": "Longbridge",
|
||||
"url": "https://github.com/longbridge/gpui-kit",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Aurora Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#F1F5F9",
|
||||
"accent.foreground": "#0F172A",
|
||||
"accordion.background": "#FFFFFF",
|
||||
"accordion.hover.background": "#F8FAFC",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E2E8F0",
|
||||
"button.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
|
||||
"button.hover.background": "linear-gradient(180deg, #F8FAFC, #F1F5F9)",
|
||||
"button.active.background": "linear-gradient(180deg, #F1F5F9, #E2E8F0)",
|
||||
"button.foreground": "#0F172A",
|
||||
"button.primary.background": "linear-gradient(180deg, #1E293B, #0F172A)",
|
||||
"button.primary.hover.background": "linear-gradient(180deg, #334155, #1E293B)",
|
||||
"button.primary.active.background": "linear-gradient(180deg, #0F172A, #020617)",
|
||||
"button.primary.foreground": "#F8FAFC",
|
||||
"button.secondary.background": "linear-gradient(180deg, #F1F5F9, #E2E8F0)",
|
||||
"button.secondary.hover.background": "linear-gradient(180deg, #E2E8F0, #CBD5E1)",
|
||||
"button.secondary.active.background": "linear-gradient(180deg, #CBD5E1, #94A3B8)",
|
||||
"button.secondary.foreground": "#0F172A",
|
||||
"button.danger.background": "linear-gradient(180deg, #FEF2F2, #FEE2E2)",
|
||||
"button.danger.hover.background": "linear-gradient(180deg, #FEE2E2, #FECACA)",
|
||||
"button.danger.active.background": "linear-gradient(180deg, #FECACA, #FCA5A5)",
|
||||
"button.danger.foreground": "#B91C1C",
|
||||
"button.info.background": "linear-gradient(180deg, #ECFEFF, #CFFAFE)",
|
||||
"button.info.hover.background": "linear-gradient(180deg, #CFFAFE, #A5F3FC)",
|
||||
"button.info.active.background": "linear-gradient(180deg, #A5F3FC, #67E8F9)",
|
||||
"button.info.foreground": "#0E7490",
|
||||
"button.success.background": "linear-gradient(180deg, #F0FDF4, #DCFCE7)",
|
||||
"button.success.hover.background": "linear-gradient(180deg, #DCFCE7, #BBF7D0)",
|
||||
"button.success.active.background": "linear-gradient(180deg, #BBF7D0, #86EFAC)",
|
||||
"button.success.foreground": "#15803D",
|
||||
"button.warning.background": "linear-gradient(180deg, #FEFCE8, #FEF3C7)",
|
||||
"button.warning.hover.background": "linear-gradient(180deg, #FEF3C7, #FDE68A)",
|
||||
"button.warning.active.background": "linear-gradient(180deg, #FDE68A, #FCD34D)",
|
||||
"button.warning.foreground": "#A16207",
|
||||
"caret": "#0F172A",
|
||||
"chart.1": "#93C5FD",
|
||||
"chart.2": "#3B82F6",
|
||||
"chart.3": "#2563EB",
|
||||
"chart.4": "#1D4ED8",
|
||||
"chart.5": "#1E40AF",
|
||||
"chart.bullish": "green-600",
|
||||
"chart.bearish": "red-600",
|
||||
"danger.background": "linear-gradient(180deg, #EF4444, #DC2626)",
|
||||
"danger.active.background": "linear-gradient(180deg, #DC2626, #B91C1C)",
|
||||
"danger.foreground": "#FFFFFF",
|
||||
"danger.hover.background": "linear-gradient(180deg, #F87171, #EF4444)",
|
||||
"description_list.label.background": "#F8FAFC",
|
||||
"description_list.label.foreground": "#0F172A",
|
||||
"drag.border": "#3B82F6",
|
||||
"drop_target.background": "#3B82F640",
|
||||
"foreground": "#020617",
|
||||
"group_box.background": "#F8FAFC",
|
||||
"group_box.foreground": "#0F172A",
|
||||
"info.background": "linear-gradient(180deg, #06B6D4, #0891B2)",
|
||||
"info.active.background": "linear-gradient(180deg, #0891B2, #0E7490)",
|
||||
"info.foreground": "#FFFFFF",
|
||||
"info.hover.background": "linear-gradient(180deg, #22D3EE, #06B6D4)",
|
||||
"input.border": "#E2E8F0",
|
||||
"link": "#0F172A",
|
||||
"link.active": "#020617",
|
||||
"link.hover": "#334155",
|
||||
"list.background": "#FFFFFF",
|
||||
"list.active.background": "#BFDBFE33",
|
||||
"list.active.border": "#60A5FA",
|
||||
"list.even.background": "#F8FAFC",
|
||||
"list.head.background": "#F8FAFC",
|
||||
"list.hover.background": "#F1F5F9",
|
||||
"muted.background": "#F1F5F9",
|
||||
"muted.foreground": "#64748B",
|
||||
"popover.background": "#FFFFFF",
|
||||
"popover.foreground": "#020617",
|
||||
"primary.background": "linear-gradient(180deg, #1E293B, #0F172A)",
|
||||
"primary.active.background": "linear-gradient(180deg, #0F172A, #020617)",
|
||||
"primary.foreground": "#F8FAFC",
|
||||
"primary.hover.background": "linear-gradient(180deg, #334155, #1E293B)",
|
||||
"progress.bar.background": "linear-gradient(180deg, #1E293B, #0F172A)",
|
||||
"ring": "#0F172A",
|
||||
"scrollbar.background": "#F8FAFC00",
|
||||
"scrollbar.thumb.background": "linear-gradient(to right, #CBD5E173, #94A3B873)",
|
||||
"scrollbar.thumb.hover.background": "linear-gradient(to right, #CBD5E199, #94A3B899)",
|
||||
"secondary.background": "#E2E8F0",
|
||||
"secondary.active.background": "#CBD5E1",
|
||||
"secondary.foreground": "#0F172A",
|
||||
"secondary.hover.background": "#E2E8F0",
|
||||
"selection.background": "#55A0FC",
|
||||
"sidebar.background": "#F8FAFC",
|
||||
"sidebar.accent.background": "#E2E8F0",
|
||||
"sidebar.accent.foreground": "#0F172A",
|
||||
"sidebar.border": "#E2E8F0",
|
||||
"sidebar.foreground": "#0F172A",
|
||||
"sidebar.primary.background": "#0F172A",
|
||||
"sidebar.primary.foreground": "#F8FAFC",
|
||||
"skeleton.background": "#F1F5F9",
|
||||
"slider.background": "#0F172A",
|
||||
"slider.thumb.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
|
||||
"success.background": "linear-gradient(180deg, #22C55E, #16A34A)",
|
||||
"success.active.background": "linear-gradient(180deg, #16A34A, #15803D)",
|
||||
"success.foreground": "#FFFFFF",
|
||||
"success.hover.background": "linear-gradient(180deg, #4ADE80, #22C55E)",
|
||||
"switch.background": "linear-gradient(180deg, #E2E8F0, #CBD5E1)",
|
||||
"switch.thumb.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
|
||||
"tab.background": "#00000000",
|
||||
"tab.active.background": "#FFFFFF",
|
||||
"tab.active.foreground": "#0F172A",
|
||||
"tab.foreground": "#334155",
|
||||
"tab_bar.background": "#F1F5F9",
|
||||
"tab_bar.segmented.background": "#F1F5F9",
|
||||
"table.background": "#FFFFFF",
|
||||
"table.active.background": "#BFDBFE33",
|
||||
"table.active.border": "#60A5FA",
|
||||
"table.even.background": "#F8FAFC",
|
||||
"table.head.background": "#F8FAFC",
|
||||
"table.head.foreground": "#64748B",
|
||||
"table.foot.background": "#F8FAFC",
|
||||
"table.foot.foreground": "#64748B",
|
||||
"table.hover.background": "#F1F5F9",
|
||||
"table.row.border": "#E2E8F0B3",
|
||||
"title_bar.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
|
||||
"title_bar.border": "#E2E8F0",
|
||||
"status_bar.background": "linear-gradient(180deg, #F8FAFC, #F1F5F9)",
|
||||
"status_bar.border": "#E2E8F0",
|
||||
"warning.background": "linear-gradient(180deg, #EAB308, #CA8A04)",
|
||||
"warning.active.background": "linear-gradient(180deg, #CA8A04, #A16207)",
|
||||
"warning.hover.background": "linear-gradient(180deg, #FACC15, #EAB308)",
|
||||
"warning.foreground": "#422006",
|
||||
"overlay": "#0000000D",
|
||||
"window.border": "#E2E8F0",
|
||||
"base.red": "red-600",
|
||||
"base.red.light": "red-400",
|
||||
"base.green": "green-600",
|
||||
"base.green.light": "green-400",
|
||||
"base.blue": "blue-600",
|
||||
"base.blue.light": "blue-400",
|
||||
"base.yellow": "yellow-600",
|
||||
"base.yellow.light": "yellow-400",
|
||||
"base.magenta": "purple-600",
|
||||
"base.magenta.light": "purple-400",
|
||||
"base.cyan": "cyan-600",
|
||||
"base.cyan.light": "cyan-400"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#020617",
|
||||
"editor.background": "#FFFFFF",
|
||||
"editor.active_line.background": "#F8FAFC",
|
||||
"editor.line_number": "#94A3B8",
|
||||
"editor.active_line_number": "#0F172A",
|
||||
"editor.invisible": "#64748B66",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#957931"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#007fff"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#007fff"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"emphasis": {
|
||||
"font_style": "italic"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"font_weight": 700
|
||||
},
|
||||
"function": {
|
||||
"color": "#0000A2"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#0000A2",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6A7293",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"string": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#6F42C1"
|
||||
},
|
||||
"text.code.span": {
|
||||
"color": "#6F42C1"
|
||||
},
|
||||
"title": {
|
||||
"color": "#0433FF"
|
||||
},
|
||||
"type": {
|
||||
"color": "#6f42c1"
|
||||
},
|
||||
"property": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#C5060B"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Ayu",
|
||||
"author": "Zed Industries",
|
||||
"url": "https://github.com/zed-industries/zed/blob/e62dd2a0e584154886ff86cc1e9e3e060558b977/assets/themes/ayu",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Ayu Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#dadadc",
|
||||
"accent.foreground": "#5C6773",
|
||||
"background": "#FCFCFC",
|
||||
"foreground": "#5c6166",
|
||||
"border": "#cfd1d2ff",
|
||||
"ring": "#FF9940",
|
||||
"input.border": "#D9DCE3",
|
||||
"list.active.background": "#55B4D422",
|
||||
"list.active.border": "#55B4D4",
|
||||
"list.even.background": "#E6E6E6",
|
||||
"list.head.background": "#F4F4F599",
|
||||
"muted.background": "#F4F4F5",
|
||||
"muted.foreground": "#99a0a6",
|
||||
"panel.background": "#F3F4F5",
|
||||
"popover.background": "#ECECED",
|
||||
"popover.foreground": "#5C6773",
|
||||
"primary.active.background": "#42accf",
|
||||
"primary.background": "#55b4d3",
|
||||
"primary.foreground": "#FAFAFA",
|
||||
"primary.hover.background": "#5fb9d7",
|
||||
"scrollbar.background": "#FAFAFA00",
|
||||
"scrollbar.thumb.background": "#5c61664c",
|
||||
"secondary.active.background": "#CECECE",
|
||||
"secondary.background": "#ECECED",
|
||||
"secondary.foreground": "#5C6773",
|
||||
"secondary.hover.background": "#e0e0e0",
|
||||
"tab.active.background": "#FCFCFC",
|
||||
"tab.active.foreground": "#5C6773",
|
||||
"tab.background": "#ECECED00",
|
||||
"tab_bar.background": "#F4F4F5",
|
||||
"title_bar.background": "#ECECED",
|
||||
"title_bar.border": "#CFD1D2",
|
||||
"base.yellow": "#F1AD49",
|
||||
"base.red": "#F07171",
|
||||
"base.blue": "#55b4d3",
|
||||
"base.green": "#85b304",
|
||||
"base.magenta": "#9371f0",
|
||||
"base.cyan": "#4dbf99"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#5C6773",
|
||||
"editor.background": "#FCFCFC",
|
||||
"editor.active_line.background": "#ECECED",
|
||||
"editor.line_number": "#ABB0B6",
|
||||
"editor.active_line_number": "#5C6773",
|
||||
"editor.invisible": "#73777b66",
|
||||
"conflict": "#f1ad49ff",
|
||||
"conflict.background": "#ffeedaff",
|
||||
"conflict.border": "#ffe1beff",
|
||||
"created": "#85b304ff",
|
||||
"created.background": "#e9efd2ff",
|
||||
"created.border": "#d7e3aeff",
|
||||
"deleted": "#ef7271ff",
|
||||
"deleted.background": "#ffe3e1ff",
|
||||
"deleted.border": "#ffcdcaff",
|
||||
"error": "#ef7271ff",
|
||||
"error.background": "#ffe3e1ff",
|
||||
"error.border": "#ffcdcaff",
|
||||
"hidden": "#a9acaeff",
|
||||
"hidden.background": "#dcdddeff",
|
||||
"hidden.border": "#d5d6d8ff",
|
||||
"hint": "#8ca7c2ff",
|
||||
"hint.background": "#deebfaff",
|
||||
"hint.border": "#c4daf6ff",
|
||||
"ignored": "#a9acaeff",
|
||||
"ignored.background": "#dcdddeff",
|
||||
"ignored.border": "#cfd1d2ff",
|
||||
"info": "#3b9ee5ff",
|
||||
"info.background": "#deebfaff",
|
||||
"info.border": "#c4daf6ff",
|
||||
"modified": "#f1ad49ff",
|
||||
"modified.background": "#ffeedaff",
|
||||
"modified.border": "#ffe1beff",
|
||||
"predictive": "#9eb9d3ff",
|
||||
"predictive.background": "#e9efd2ff",
|
||||
"predictive.border": "#d7e3aeff",
|
||||
"renamed": "#3b9ee5ff",
|
||||
"renamed.background": "#deebfaff",
|
||||
"renamed.border": "#c4daf6ff",
|
||||
"success": "#85b304ff",
|
||||
"success.background": "#e9efd2ff",
|
||||
"success.border": "#d7e3aeff",
|
||||
"unreachable": "#8b8e92ff",
|
||||
"unreachable.background": "#dcdddeff",
|
||||
"unreachable.border": "#cfd1d2ff",
|
||||
"warning": "#f1ad49ff",
|
||||
"warning.background": "#ffeedaff",
|
||||
"warning.border": "#ffe1beff",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#a37accff"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#898d90ff"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#898d90ff"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#a37accff"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#5c6166ff"
|
||||
},
|
||||
"emphasis": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"color": "#3b9ee5ff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"enum": {
|
||||
"color": "#f98d3fff"
|
||||
},
|
||||
"function": {
|
||||
"color": "#f2ad48ff"
|
||||
},
|
||||
"hint": {
|
||||
"color": "#8ca7c2ff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#fa8d3eff"
|
||||
},
|
||||
"label": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#f98d3fff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#85b304ff"
|
||||
},
|
||||
"namespace": {
|
||||
"color": "#5c6166ff"
|
||||
},
|
||||
"number": {
|
||||
"color": "#a37accff"
|
||||
},
|
||||
"operator": {
|
||||
"color": "#ed9365ff"
|
||||
},
|
||||
"predictive": {
|
||||
"color": "#9eb9d3ff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"preproc": {
|
||||
"color": "#5c6166ff"
|
||||
},
|
||||
"primary": {
|
||||
"color": "#5c6166ff"
|
||||
},
|
||||
"property": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"punctuation": {
|
||||
"color": "#73777bff"
|
||||
},
|
||||
"punctuation.bracket": {
|
||||
"color": "#73777bff"
|
||||
},
|
||||
"punctuation.delimiter": {
|
||||
"color": "#73777bff"
|
||||
},
|
||||
"punctuation.list_marker": {
|
||||
"color": "#73777bff"
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#73777bff"
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#a37accff"
|
||||
},
|
||||
"selector": {
|
||||
"color": "#a37accff"
|
||||
},
|
||||
"selector.pseudo": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"string": {
|
||||
"color": "#86b300ff"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#898d90ff"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#4bbf98ff"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e6ba7eff"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#f98d3fff"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#f98d3fff"
|
||||
},
|
||||
"title": {
|
||||
"color": "#5c6166ff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"type": {
|
||||
"color": "#389ee6ff"
|
||||
},
|
||||
"variable": {
|
||||
"color": "#5c6166ff"
|
||||
},
|
||||
"variant": {
|
||||
"color": "#3b9ee5ff"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Ayu Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#20242b",
|
||||
"accent.foreground": "#B3B1AD",
|
||||
"background": "#0D1016",
|
||||
"border": "#292a2c",
|
||||
"ring": "#FFB454",
|
||||
"foreground": "#B3B1AD",
|
||||
"input.border": "#2D2F34",
|
||||
"list.active.background": "#36A3D922",
|
||||
"list.active.border": "#36A3D9",
|
||||
"list.even.background": "#191F2A99",
|
||||
"muted.background": "#16191F",
|
||||
"muted.foreground": "#52514f",
|
||||
"popover.background": "#0D1016",
|
||||
"popover.foreground": "#B3B1AD",
|
||||
"primary.active.background": "#36A3D9",
|
||||
"primary.background": "#5ac1fe",
|
||||
"primary.foreground": "#1F2430",
|
||||
"primary.hover.background": "#3DAEE9",
|
||||
"scrollbar.background": "#0A0E1400",
|
||||
"scrollbar.thumb.background": "#bfbdb64c",
|
||||
"secondary.active.background": "#26282f",
|
||||
"secondary.background": "#1F2127",
|
||||
"secondary.foreground": "#B3B1AD",
|
||||
"secondary.hover.background": "#26282f99",
|
||||
"tab.active.foreground": "#B3B1AD",
|
||||
"tab.active.background": "#0D1016",
|
||||
"tab_bar.background": "#16191F",
|
||||
"title_bar.background": "#16191F",
|
||||
"title_bar.border": "#292a2c",
|
||||
"base.yellow": "#FEB454",
|
||||
"base.red": "#ef7177",
|
||||
"base.blue": "#5ac1fe",
|
||||
"base.green": "#aad84c",
|
||||
"base.magenta": "#d2a6ff",
|
||||
"base.cyan": "#5a728b"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#bfbdb6",
|
||||
"editor.background": "#0d1016",
|
||||
"editor.active_line.background": "#1f2127bf",
|
||||
"editor.line_number": "#4b4c4e",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#73777b66",
|
||||
"conflict": "#feb454ff",
|
||||
"conflict.background": "#572815ff",
|
||||
"conflict.border": "#754221ff",
|
||||
"created": "#aad84cff",
|
||||
"created.background": "#294113ff",
|
||||
"created.border": "#405c1cff",
|
||||
"deleted": "#ef7177ff",
|
||||
"deleted.background": "#48161bff",
|
||||
"deleted.border": "#66272dff",
|
||||
"error": "#ef7177ff",
|
||||
"error.background": "#48161bff",
|
||||
"error.border": "#66272dff",
|
||||
"hidden": "#696a6aff",
|
||||
"hidden.background": "#313337ff",
|
||||
"hidden.border": "#383a3eff",
|
||||
"hint": "#628b80ff",
|
||||
"hint.background": "#0d2f4eff",
|
||||
"hint.border": "#1b4a6eff",
|
||||
"ignored": "#696a6aff",
|
||||
"ignored.background": "#313337ff",
|
||||
"ignored.border": "#3f4043ff",
|
||||
"info": "#5ac1feff",
|
||||
"info.background": "#0d2f4eff",
|
||||
"info.border": "#1b4a6eff",
|
||||
"modified": "#feb454ff",
|
||||
"modified.background": "#572815ff",
|
||||
"modified.border": "#754221ff",
|
||||
"predictive": "#5a728bff",
|
||||
"predictive.background": "#294113ff",
|
||||
"predictive.border": "#405c1cff",
|
||||
"renamed": "#5ac1feff",
|
||||
"renamed.background": "#0d2f4eff",
|
||||
"renamed.border": "#1b4a6eff",
|
||||
"success": "#aad84cff",
|
||||
"success.background": "#294113ff",
|
||||
"success.border": "#405c1cff",
|
||||
"unreachable": "#8a8986ff",
|
||||
"unreachable.background": "#313337ff",
|
||||
"unreachable.border": "#3f4043ff",
|
||||
"warning": "#feb454ff",
|
||||
"warning.background": "#572815ff",
|
||||
"warning.border": "#754221ff",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#d2a6ffff"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#8c8b88ff"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#8c8b88ff"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#d2a6ffff"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#bfbdb6ff"
|
||||
},
|
||||
"emphasis": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"color": "#5ac1feff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"enum": {
|
||||
"color": "#fe8f40ff"
|
||||
},
|
||||
"function": {
|
||||
"color": "#ffb353ff"
|
||||
},
|
||||
"hint": {
|
||||
"color": "#628b80ff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#ff8f3fff"
|
||||
},
|
||||
"label": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#fe8f40ff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#aad84cff"
|
||||
},
|
||||
"namespace": {
|
||||
"color": "#bfbdb6ff"
|
||||
},
|
||||
"number": {
|
||||
"color": "#d2a6ffff"
|
||||
},
|
||||
"operator": {
|
||||
"color": "#f29668ff"
|
||||
},
|
||||
"predictive": {
|
||||
"color": "#5a728bff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"preproc": {
|
||||
"color": "#bfbdb6ff"
|
||||
},
|
||||
"primary": {
|
||||
"color": "#bfbdb6ff"
|
||||
},
|
||||
"property": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"punctuation": {
|
||||
"color": "#a6a5a0ff"
|
||||
},
|
||||
"punctuation.bracket": {
|
||||
"color": "#a6a5a0ff"
|
||||
},
|
||||
"punctuation.delimiter": {
|
||||
"color": "#a6a5a0ff"
|
||||
},
|
||||
"punctuation.list_marker": {
|
||||
"color": "#a6a5a0ff"
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#a6a5a0ff"
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#d2a6ffff"
|
||||
},
|
||||
"selector": {
|
||||
"color": "#d2a6ffff"
|
||||
},
|
||||
"selector.pseudo": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"string": {
|
||||
"color": "#a9d94bff"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#8c8b88ff"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#95e6cbff"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e5b572ff"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#fe8f40ff"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#fe8f40ff"
|
||||
},
|
||||
"title": {
|
||||
"color": "#bfbdb6ff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"type": {
|
||||
"color": "#59c2ffff"
|
||||
},
|
||||
"variable": {
|
||||
"color": "#bfbdb6ff"
|
||||
},
|
||||
"variant": {
|
||||
"color": "#5ac1feff"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Catppuccin",
|
||||
"author": "Catppuccino",
|
||||
"url": "https://github.com/catppuccin/catppuccin",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Catppuccin Latte",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#d3d8e0",
|
||||
"accent.foreground": "#4c4f69",
|
||||
"background": "#E5E9EF",
|
||||
"border": "#CCD0DA",
|
||||
"ring": "#7287fd",
|
||||
"foreground": "#4c4f69",
|
||||
"input.border": "#acb0be",
|
||||
"link.active.foreground": "#7287fd",
|
||||
"link.foreground": "#7287fd",
|
||||
"link.hover.foreground": "#7287fd",
|
||||
"list.active.background": "#7287fd22",
|
||||
"list.active.border": "#7287fd",
|
||||
"list.even.background": "#EFF1F5",
|
||||
"list.head.background": "#dce0e8",
|
||||
"muted.background": "#dce0e8",
|
||||
"muted.foreground": "#9a9db2",
|
||||
"panel.background": "#dce0e8",
|
||||
"primary.active.background": "#7287fd",
|
||||
"primary.background": "#7287fd",
|
||||
"primary.foreground": "#EFF1F5",
|
||||
"scrollbar.background": "#EFF1F500",
|
||||
"scrollbar.thumb.background": "#acb0be",
|
||||
"secondary.active.background": "#CCD2DE",
|
||||
"secondary.background": "#dce0e8",
|
||||
"secondary.foreground": "#4c4f69",
|
||||
"secondary.hover.background": "#CCD2DE99",
|
||||
"tab.active.background": "#E5E9EF",
|
||||
"tab.active.foreground": "#4c4f69",
|
||||
"tab.background": "#D2D7E200",
|
||||
"tab.foreground": "#82848c",
|
||||
"tab_bar.background": "#DCE0E8",
|
||||
"title_bar.background": "#DCE0E8",
|
||||
"title_bar.border": "#bec3d0",
|
||||
"base.red": "#d26a53",
|
||||
"base.green": "#5aa93b",
|
||||
"base.yellow": "#df8e1d",
|
||||
"base.blue": "#78acdc",
|
||||
"base.magenta": "#8778dc",
|
||||
"base.magenta.light": "#9978dc66",
|
||||
"base.cyan": "#53d2b0"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#4c4f69",
|
||||
"editor.background": "#EFF1F5",
|
||||
"editor.active_line.background": "#dce0e8",
|
||||
"editor.line_number": "#9a9db2",
|
||||
"editor.active_line_number": "#4c4f69",
|
||||
"editor.invisible": "#7c7f9366",
|
||||
"conflict": "#d20f39",
|
||||
"created": "#85dc78",
|
||||
"deleted": "#dc8a78",
|
||||
"error": "#d20f39",
|
||||
"hidden": "#9a9db2",
|
||||
"hint": "#7287fd",
|
||||
"ignored": "#acb0be",
|
||||
"info": "#1e66f5",
|
||||
"modified": "#df8e1d",
|
||||
"predictive": "#9a9db2",
|
||||
"renamed": "#9978dc",
|
||||
"success": "#85dc78",
|
||||
"unreachable": "#acb0be",
|
||||
"warning": "#df8e1d",
|
||||
"syntax": {
|
||||
"variable": {
|
||||
"color": "#4c4f69"
|
||||
},
|
||||
"variable.builtin": {
|
||||
"color": "#d20f39"
|
||||
},
|
||||
"variable.parameter": {
|
||||
"color": "#e64553"
|
||||
},
|
||||
"variable.member": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#d20f39",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"constant.builtin": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"constant.macro": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"module": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"label": {
|
||||
"color": "#209fb5"
|
||||
},
|
||||
"string": {
|
||||
"color": "#85dc78"
|
||||
},
|
||||
"string.documentation": {
|
||||
"color": "#179299"
|
||||
},
|
||||
"string.regexp": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"string.special.path": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#dd7878"
|
||||
},
|
||||
"string.special.url": {
|
||||
"color": "#dc8a78",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"character": {
|
||||
"color": "#179299"
|
||||
},
|
||||
"character.special": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"number": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"number.float": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"type": {
|
||||
"color": "#df8e1d"
|
||||
},
|
||||
"type.builtin": {
|
||||
"color": "#8839ef",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"type.definition": {
|
||||
"color": "#df8e1d"
|
||||
},
|
||||
"type.interface": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"type.super": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"attribute": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"property": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"function.builtin": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"function.call": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"function.macro": {
|
||||
"color": "#179299"
|
||||
},
|
||||
"function.method": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"function.method.call": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#dd7878"
|
||||
},
|
||||
"operator": {
|
||||
"color": "#04a5e5"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.modifier": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.type": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.coroutine": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.function": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.operator": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.import": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.repeat": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.return": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.debug": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.exception": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.conditional": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.conditional.ternary": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.directive": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"keyword.directive.define": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"keyword.export": {
|
||||
"color": "#04a5e5"
|
||||
},
|
||||
"punctuation": {
|
||||
"color": "#7c7f93"
|
||||
},
|
||||
"punctuation.delimiter": {
|
||||
"color": "#7c7f93"
|
||||
},
|
||||
"punctuation.bracket": {
|
||||
"color": "#7c7f93"
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"punctuation.special.symbol": {
|
||||
"color": "#dd7878"
|
||||
},
|
||||
"punctuation.list_marker": {
|
||||
"color": "#179299"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#7c7f93",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#7c7f93",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.documentation": {
|
||||
"color": "#7c7f93",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.error": {
|
||||
"color": "#d20f39",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.warning": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.hint": {
|
||||
"color": "#1e66f5",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.todo": {
|
||||
"color": "#dd7878",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.note": {
|
||||
"color": "#dc8a78",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"diff.plus": {
|
||||
"color": "#40a02b"
|
||||
},
|
||||
"diff.minus": {
|
||||
"color": "#d20f39"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"tag.attribute": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"tag.delimiter": {
|
||||
"color": "#179299"
|
||||
},
|
||||
"parameter": {
|
||||
"color": "#e64553"
|
||||
},
|
||||
"field": {
|
||||
"color": "#7287fd"
|
||||
},
|
||||
"namespace": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"float": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"symbol": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"text": {
|
||||
"color": "#4c4f69"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"color": "#e64553",
|
||||
"font_weight": 700
|
||||
},
|
||||
"emphasis": {
|
||||
"color": "#e64553",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#e64553"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#40a02b"
|
||||
},
|
||||
"concept": {
|
||||
"color": "#209fb5"
|
||||
},
|
||||
"enum": {
|
||||
"color": "#179299",
|
||||
"font_weight": 700
|
||||
},
|
||||
"function.decorator": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"type.class.definition": {
|
||||
"color": "#df8e1d",
|
||||
"font_weight": 700
|
||||
},
|
||||
"hint": {
|
||||
"color": "#acb0be",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#7287fd"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#1e66f5",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"parent": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"predictive": {
|
||||
"color": "#9ca0b0"
|
||||
},
|
||||
"predoc": {
|
||||
"color": "#d20f39"
|
||||
},
|
||||
"primary": {
|
||||
"color": "#e64553"
|
||||
},
|
||||
"tag.doctype": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"string.doc": {
|
||||
"color": "#179299",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"title": {
|
||||
"color": "#4c4f69",
|
||||
"font_weight": 800
|
||||
},
|
||||
"variant": {
|
||||
"color": "#d20f39"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Catppuccin Frappe",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#3b3f4f",
|
||||
"accent.foreground": "#c6d0f5",
|
||||
"background": "#232634",
|
||||
"border": "#3e4255",
|
||||
"ring": "#f2d5cf",
|
||||
"foreground": "#c6d0f5",
|
||||
"input.border": "#51576d",
|
||||
"list.active.background": "#8caaee15",
|
||||
"list.even.background": "#303446",
|
||||
"list.head.background": "#2A2C3C",
|
||||
"muted.background": "#2c303f",
|
||||
"muted.foreground": "#979db5",
|
||||
"panel.background": "#414559",
|
||||
"popover.background": "#1E202B",
|
||||
"popover.foreground": "#c6d0f5",
|
||||
"primary.active.background": "#8caaee",
|
||||
"primary.background": "#8caaee",
|
||||
"primary.foreground": "#232634",
|
||||
"primary.hover.background": "#8caaee",
|
||||
"scrollbar.background": "#30344600",
|
||||
"scrollbar.thumb.background": "#51576d",
|
||||
"secondary.active.background": "#313445",
|
||||
"secondary.background": "#414559",
|
||||
"secondary.foreground": "#c6d0f5",
|
||||
"secondary.hover.background": "#31344599",
|
||||
"tab.active.background": "#232634",
|
||||
"tab.active.foreground": "#dce2f9",
|
||||
"tab.background": "#1E202B00",
|
||||
"tab.foreground": "#abb5d9",
|
||||
"tab_bar.background": "#1E202B",
|
||||
"title_bar.background": "#1D202B",
|
||||
"title_bar.border": "#30354b",
|
||||
"base.red": "#e78284",
|
||||
"base.green": "#a6d189",
|
||||
"base.yellow": "#e7d682",
|
||||
"base.blue": "#8caaee",
|
||||
"base.magenta": "#9882e7",
|
||||
"base.magenta.light": "#9882e766",
|
||||
"base.cyan": "#81c8be"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#c6d0f5",
|
||||
"editor.background": "#232634",
|
||||
"editor.active_line.background": "#41455977",
|
||||
"editor.line_number": "#979db5",
|
||||
"editor.active_line_number": "#c6d0f5",
|
||||
"editor.invisible": "#7c7f9366",
|
||||
"conflict": "#e78284",
|
||||
"created": "#a6d189",
|
||||
"deleted": "#3b2e2e",
|
||||
"error": "#3b2e2e",
|
||||
"hidden": "#51576d",
|
||||
"hint": "#8caaee",
|
||||
"info": "#1e2b4d",
|
||||
"modified": "#e7d682",
|
||||
"predictive": "#51576d",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#e7d682"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#e78284"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#51576d"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#51576d"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#e7d682"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#a6d189"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#c6d0f5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#8caaee"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#e78284"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#8caaee",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#51576d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e78284"
|
||||
},
|
||||
"string": {
|
||||
"color": "#a6d189"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#a6d189"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#a6d189"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e7d682"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#e7d682"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#8caaee"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#c6d0f5"
|
||||
},
|
||||
"title": {
|
||||
"color": "#e78284",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#8caaee"
|
||||
},
|
||||
"property": {
|
||||
"color": "#c6d0f5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#e78284"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Catppuccin Macchiato",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#30344d",
|
||||
"accent.foreground": "#cad3f5",
|
||||
"action_bar.background": "#1E2030",
|
||||
"background": "#1E2030",
|
||||
"border": "#494d64",
|
||||
"ring": "#f4dbd6",
|
||||
"danger.background": "#ed8796",
|
||||
"foreground": "#cad3f5",
|
||||
"input.border": "#494d64",
|
||||
"link.active.foreground": "#8aadf4",
|
||||
"link.foreground": "#8aadf4",
|
||||
"link.hover.foreground": "#8aadf4",
|
||||
"list.active.background": "#8aadf420",
|
||||
"list.active.border": "#8aadf4",
|
||||
"list.even.background": "#24273a",
|
||||
"list.head.background": "#363a4f33",
|
||||
"muted.background": "#282a3a",
|
||||
"muted.foreground": "#b8c0e0",
|
||||
"popover.foreground": "#cad3f5",
|
||||
"primary.active.background": "#8aadf499",
|
||||
"primary.background": "#8aadf4",
|
||||
"primary.foreground": "#24273a",
|
||||
"primary.hover.background": "#8aadf4",
|
||||
"scrollbar.background": "#24273a00",
|
||||
"scrollbar.thumb.background": "#494d64",
|
||||
"secondary.active.background": "#3a3f55",
|
||||
"secondary.background": "#363a4f",
|
||||
"secondary.foreground": "#cad3f5",
|
||||
"secondary.hover.background": "#24273a99",
|
||||
"tab.background": "#17192600",
|
||||
"tab.foreground": "#aeb8db",
|
||||
"tab_bar.background": "#171926",
|
||||
"title_bar.background": "#171926",
|
||||
"title_bar.border": "#363A4F",
|
||||
"base.red": "#ed8796",
|
||||
"base.green": "#a6da95",
|
||||
"base.yellow": "#eed49f",
|
||||
"base.blue": "#8aadf4",
|
||||
"base.magenta": "#f5bde6",
|
||||
"base.magenta.light": "#f5bde666",
|
||||
"base.cyan": "#8bd5ca"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#cad3f5",
|
||||
"editor.background": "#1E2030",
|
||||
"editor.active_line.background": "#363a4f",
|
||||
"editor.line_number": "#b8c0e0",
|
||||
"editor.active_line_number": "#cad3f5",
|
||||
"editor.invisible": "#7c7f9366",
|
||||
"conflict": "#ed8796",
|
||||
"created": "#a6da95",
|
||||
"deleted": "#ed8796",
|
||||
"error": "#ed8796",
|
||||
"hidden": "#b8c0e0",
|
||||
"hint": "#8aadf4",
|
||||
"ignored": "#494d64",
|
||||
"info": "#8aadf4",
|
||||
"modified": "#eed49f",
|
||||
"predictive": "#b8c0e0",
|
||||
"renamed": "#f5bde6",
|
||||
"success": "#a6da95",
|
||||
"unreachable": "#b8c0e0",
|
||||
"warning": "#eed49f",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#eed49f"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#f5bde6"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#b8c0e0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#b8c0e0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#eed49f"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#8aadf4"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#cad3f5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#8aadf4"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#f5bde6"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#8aadf4",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#b8c0e0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#eed49f"
|
||||
},
|
||||
"string": {
|
||||
"color": "#a6da95"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#a6da95"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#a6da95"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#eed49f"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#eed49f"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#8aadf4"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#cad3f5"
|
||||
},
|
||||
"title": {
|
||||
"color": "#f5bde6",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#8aadf4"
|
||||
},
|
||||
"property": {
|
||||
"color": "#cad3f5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#f5bde6"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Catppuccin Mocha",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2e2e3e",
|
||||
"accent.foreground": "#cdd6f4",
|
||||
"background": "#181825",
|
||||
"border": "#313244",
|
||||
"ring": "#cba6f7",
|
||||
"danger.background": "#f38ba8",
|
||||
"danger.active.background": "#eba0ac",
|
||||
"danger.hover.background": "#f38ba8",
|
||||
"foreground": "#cdd6f4",
|
||||
"input.border": "#6c7086",
|
||||
"link.active.foreground": "#89b4fa",
|
||||
"link.foreground": "#89b4fa",
|
||||
"link.hover.foreground": "#89b4fa",
|
||||
"list.active.background": "#89b4fa15",
|
||||
"list.active.border": "#89b4fa77",
|
||||
"list.hover.background": "#202031",
|
||||
"list.even.background": "#161622",
|
||||
"list.head.background": "#1E1E2E",
|
||||
"muted.background": "#302d41",
|
||||
"muted.foreground": "#6c7086",
|
||||
"panel.background": "#302d41",
|
||||
"popover.background": "#1e1e2e",
|
||||
"popover.foreground": "#cdd6f4",
|
||||
"primary.active.background": "#89b4fa",
|
||||
"primary.background": "#89b4fa",
|
||||
"primary.foreground": "#1e1e2e",
|
||||
"primary.hover.background": "#74c7ec",
|
||||
"scrollbar.background": "#1e1e2e00",
|
||||
"scrollbar.thumb.background": "#4e4e5e",
|
||||
"secondary.active.background": "#45475a",
|
||||
"secondary.background": "#302d41",
|
||||
"secondary.foreground": "#cdd6f4",
|
||||
"secondary.hover.background": "#575268",
|
||||
"tab.background": "#0B0B1100",
|
||||
"tab.foreground": "#aeb7d5",
|
||||
"tab_bar.background": "#0B0B11",
|
||||
"title_bar.background": "#11111B",
|
||||
"title_bar.border": "#313244",
|
||||
"base.red": "#f38ba8",
|
||||
"base.green": "#a6e3a1",
|
||||
"base.yellow": "#f9e2af",
|
||||
"base.blue": "#89b4fa",
|
||||
"base.magenta": "#f5c2e7",
|
||||
"base.magenta.light": "#f38ba866",
|
||||
"base.cyan": "#94e2d5"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#cdd6f4",
|
||||
"editor.background": "#181825",
|
||||
"editor.active_line.background": "#222230AA",
|
||||
"editor.line_number": "#6c7086",
|
||||
"editor.active_line_number": "#cdd6f4",
|
||||
"editor.invisible": "#7c7f9366",
|
||||
"conflict": "#f38ba8",
|
||||
"created": "#a6e3a1",
|
||||
"deleted": "#f38ba8",
|
||||
"error": "#f38ba8",
|
||||
"hidden": "#6c7086",
|
||||
"hint": "#89b4fa",
|
||||
"ignored": "#6c7086",
|
||||
"info": "#89b4fa",
|
||||
"modified": "#f9e2af",
|
||||
"predictive": "#6c7086",
|
||||
"renamed": "#f5c2e7",
|
||||
"success": "#a6e3a1",
|
||||
"unreachable": "#6c7086",
|
||||
"warning": "#f9e2af",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#f9e2af"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#f5c2e7"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#6c7086",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#6c7086",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#f9e2af"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#89b4fa"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#cdd6f4"
|
||||
},
|
||||
"function": {
|
||||
"color": "#89b4fa"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#f5c2e7"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#89b4fa",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6c7086",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#f9e2af"
|
||||
},
|
||||
"string": {
|
||||
"color": "#a6e3a1"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#a6e3a1"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#a6e3a1"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#f9e2af"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#f9e2af"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#89b4fa"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#cdd6f4"
|
||||
},
|
||||
"title": {
|
||||
"color": "#f5c2e7",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#89b4fa"
|
||||
},
|
||||
"property": {
|
||||
"color": "#cdd6f4"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#f5c2e7"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Everforest",
|
||||
"author": "sainnhe",
|
||||
"url": "https://github.com/sainnhe/everforest",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Everforest Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#E7E5D4",
|
||||
"accent.foreground": "#5F6D75",
|
||||
"background": "#FEFCEE",
|
||||
"border": "#E7E5D4",
|
||||
"danger.background": "#e67e80",
|
||||
"danger.foreground": "#ffffff",
|
||||
"foreground": "#5F6D75",
|
||||
"input.border": "#E7E5D4",
|
||||
"link.active.foreground": "#7fbbb3",
|
||||
"link.foreground": "#7fbbb3",
|
||||
"link.hover.foreground": "#7fbbb3",
|
||||
"list.active.background": "#a7c08025",
|
||||
"list.active.border": "#a7c080",
|
||||
"list.even.background": "#F4F1E2",
|
||||
"list.head.background": "#F4F1E2",
|
||||
"muted.background": "#f1f0e2",
|
||||
"muted.foreground": "#959a9d",
|
||||
"popover.background": "#FEFCEE",
|
||||
"popover.foreground": "#5F6D75",
|
||||
"primary.background": "#e69875",
|
||||
"primary.foreground": "#FEFCEE",
|
||||
"scrollbar.background": "#e0e0e000",
|
||||
"scrollbar.thumb.background": "#D7D5C4",
|
||||
"secondary.background": "#EEEADA",
|
||||
"secondary.foreground": "#5F6D75",
|
||||
"tab.active.background": "#FEFCEE",
|
||||
"tab.active.foreground": "#5F6D75",
|
||||
"tab.background": "#F4F1E200",
|
||||
"tab.foreground": "#6b7b84",
|
||||
"tab_bar.background": "#F4F1E2",
|
||||
"title_bar.background": "#F9F5E4",
|
||||
"title_bar.border": "#E7E5D4",
|
||||
"status_bar.background": "#F9F5E4",
|
||||
"base.red": "#e67e80",
|
||||
"base.green": "#a7c080",
|
||||
"base.yellow": "#dbbc7f",
|
||||
"base.blue": "#7fbbb3",
|
||||
"base.magenta": "#d699b6",
|
||||
"base.magenta.light": "#d699b699",
|
||||
"base.cyan": "#83c092"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#5F6D75",
|
||||
"editor.background": "#FEFCEE",
|
||||
"editor.active_line.background": "#E7E5D4",
|
||||
"editor.line_number": "#959a9d",
|
||||
"editor.active_line_number": "#5F6D75",
|
||||
"editor.invisible": "#959a9d66",
|
||||
"conflict": "#e67e80",
|
||||
"created": "#a7c080",
|
||||
"deleted": "#e67e80",
|
||||
"error": "#e67e80",
|
||||
"hidden": "#959a9d",
|
||||
"hint": "#7fbbb3",
|
||||
"ignored": "#959a9d",
|
||||
"info": "#7fbbb3",
|
||||
"modified": "#dbbc7f",
|
||||
"predictive": "#5F6D75",
|
||||
"renamed": "#a7c080",
|
||||
"success": "#a7c080",
|
||||
"unreachable": "#959a9d",
|
||||
"warning": "#dbbc7f",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#dbbc7f"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#e69875"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#959a9d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#959a9d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#e69875"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#5F6D75"
|
||||
},
|
||||
"function": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#e67e80"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#7fbbb3",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#5F6D75",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e69875"
|
||||
},
|
||||
"string": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#dbbc7f"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#dbbc7f"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#dbbc7f"
|
||||
},
|
||||
"title": {
|
||||
"color": "#e69875",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#7fbbb3"
|
||||
},
|
||||
"property": {
|
||||
"color": "#5F6D75"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#e67e80"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Everforest Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#3c4448",
|
||||
"accent.foreground": "#d3c6aa",
|
||||
"background": "#262E34",
|
||||
"border": "#40484c",
|
||||
"foreground": "#d3c6aa",
|
||||
"input.border": "#485156",
|
||||
"link.active.foreground": "#7fbbb3",
|
||||
"link.foreground": "#7fbbb3",
|
||||
"link.hover.foreground": "#7fbbb3",
|
||||
"list.active.background": "#a7c08022",
|
||||
"list.active.border": "#a7c08088",
|
||||
"list.even.background": "#2E383B",
|
||||
"list.head.background": "#2E383B",
|
||||
"muted.background": "#2E383B",
|
||||
"muted.foreground": "#6D7873",
|
||||
"panel.background": "#2E383B",
|
||||
"popover.background": "#262E34",
|
||||
"popover.foreground": "#d3c6aa",
|
||||
"primary.background": "#e69875",
|
||||
"primary.foreground": "#262E34",
|
||||
"scrollbar.background": "#2E383B00",
|
||||
"scrollbar.thumb.background": "#485156",
|
||||
"secondary.background": "#2E383B",
|
||||
"secondary.foreground": "#849087",
|
||||
"secondary.active.background": "#303b3e",
|
||||
"secondary.hover.background": "#3E474B99",
|
||||
"tab.active.background": "#262E34",
|
||||
"tab.active.foreground": "#d3c6aa",
|
||||
"tab.background": "#262E3400",
|
||||
"tab.foreground": "#849087",
|
||||
"tab_bar.background": "#2E383B",
|
||||
"title_bar.background": "#1f262b",
|
||||
"title_bar.border": "#40484c",
|
||||
"base.red": "#e67e80",
|
||||
"base.green": "#a7c080",
|
||||
"base.yellow": "#dbbc7f",
|
||||
"base.blue": "#7fbbb3",
|
||||
"base.magenta": "#844d64",
|
||||
"base.magenta.light": "#844d6499",
|
||||
"base.cyan": "#83c092"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#d3c6aa",
|
||||
"editor.background": "#262E34",
|
||||
"editor.active_line.background": "#3c4448",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#959a9d66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Fahrenheit",
|
||||
"author": "iTerm2-Color-Schemes",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Fahrenheit",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#171717",
|
||||
"accent.foreground": "#FFFFCE",
|
||||
"background": "#000000",
|
||||
"border": "#252525",
|
||||
"danger.background": "#593002",
|
||||
"foreground": "#FFFFCE",
|
||||
"input.border": "#252525",
|
||||
"list.active.background": "#72020222",
|
||||
"list.active.border": "#720202",
|
||||
"list.even.background": "#090909",
|
||||
"list.hover.background": "#111111",
|
||||
"muted.background": "#20202099",
|
||||
"muted.foreground": "#828282",
|
||||
"panel.background": "#1e1e1e",
|
||||
"popover.background": "#090909",
|
||||
"popover.foreground": "#FFFFCE",
|
||||
"primary.background": "#720202",
|
||||
"primary.foreground": "#FFFFCE",
|
||||
"scrollbar.background": "#1e1e1e00",
|
||||
"scrollbar.thumb.background": "#3e3e3e",
|
||||
"secondary.background": "#202020",
|
||||
"secondary.active.background": "#202020DD",
|
||||
"secondary.foreground": "#979797",
|
||||
"secondary.hover.background": "#20202099",
|
||||
"tab.foreground": "#808080",
|
||||
"title_bar.background": "#0e0e0e",
|
||||
"table.background": "#1e1e1e00",
|
||||
"table.hover.background": "#1E1E1E",
|
||||
"ring": "#570101",
|
||||
"base.red": "#723202",
|
||||
"base.red.light": "#c97636",
|
||||
"base.green": "#027225",
|
||||
"base.green.light": "#39c936",
|
||||
"base.yellow": "#726302",
|
||||
"base.yellow.light": "#c9b536",
|
||||
"base.blue": "#022d72",
|
||||
"base.blue.light": "#0551cb",
|
||||
"base.magenta": "#3a286c",
|
||||
"base.magenta.light": "#58197a",
|
||||
"base.cyan": "#027272",
|
||||
"base.cyan.light": "#36c9c9"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#FFFFCE",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#1e1e1e",
|
||||
"editor.line_number": "#828282",
|
||||
"editor.invisible": "#82828266",
|
||||
"warning.border": "#720202",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#fecf75"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#828282"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#828282"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#cb4a05"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#dcdcdc"
|
||||
},
|
||||
"function": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#cb4a05"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#cda074",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#9e744d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#cb4a05"
|
||||
},
|
||||
"string": {
|
||||
"color": "#cc734d"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#cc734d"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#cc734d"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#cb4a05"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"title": {
|
||||
"color": "#cda074",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#cda074"
|
||||
},
|
||||
"property": {
|
||||
"color": "#dcdcdc"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#cb4a05"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Flexoki",
|
||||
"author": "kepano",
|
||||
"url": "https://github.com/kepano/flexoki",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Flexoki Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#E8E4CE",
|
||||
"accent.foreground": "#100F0F",
|
||||
"background": "#FFFCF0",
|
||||
"foreground": "#100F0F",
|
||||
"border": "#E6E4D9",
|
||||
"ring": "#D0A215",
|
||||
"selection.background": "#D0A21577",
|
||||
"input.border": "#E6E4D9",
|
||||
"list.active.background": "#D0A21520",
|
||||
"list.active.border": "#D0A215",
|
||||
"list.background": "#FFFCF0",
|
||||
"list.even.background": "#F2F0E5",
|
||||
"muted.background": "#F2F0E5",
|
||||
"muted.foreground": "#6F6E69",
|
||||
"primary.background": "#3AA99F",
|
||||
"primary.foreground": "#FAFAFA",
|
||||
"info.background": "#4385BE",
|
||||
"scrollbar.background": "#FAFAFA00",
|
||||
"scrollbar.thumb.background": "#E6E4D9",
|
||||
"scrollbar.thumb.hover.background": "#DAD8CE",
|
||||
"secondary.active.background": "#DAD8CE",
|
||||
"secondary.background": "#F2F0E5",
|
||||
"secondary.foreground": "#100F0F",
|
||||
"secondary.hover.background": "#E6E4D9",
|
||||
"tab.active.background": "#FFFCF0",
|
||||
"tab.active.foreground": "#100F0F",
|
||||
"tab.background": "#ECECED00",
|
||||
"tab.foreground": "#8d8986",
|
||||
"tab_bar.background": "#F2F0E5",
|
||||
"title_bar.background": "#F2F0E5",
|
||||
"title_bar.border": "#DAD8CE",
|
||||
"base.yellow": "#D0A215",
|
||||
"base.red": "#D14D41",
|
||||
"base.blue": "#4385BE",
|
||||
"base.green": "#879A39",
|
||||
"base.magenta": "#CE5D97",
|
||||
"base.cyan": "#3AA99F"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#100F0F",
|
||||
"editor.background": "#FFFCF0",
|
||||
"editor.active_line.background": "#F2F0E5",
|
||||
"editor.line_number": "#B7B5AC",
|
||||
"editor.active_line_number": "#24837B",
|
||||
"editor.invisible": "#B7B5AC66",
|
||||
"conflict": "#AD8301",
|
||||
"conflict.background": "#FAEEC6",
|
||||
"conflict.border": "#AD8301",
|
||||
"created": "#66800B",
|
||||
"created.background": "#EDEECF",
|
||||
"created.border": "#66800B",
|
||||
"deleted": "#AF3029",
|
||||
"deleted.background": "#FFE1D5",
|
||||
"deleted.border": "#AF3029",
|
||||
"error": "#AF3029",
|
||||
"error.background": "#FFE1D5",
|
||||
"error.border": "#AF3029",
|
||||
"hidden": "#B7B5AC",
|
||||
"hidden.background": "#F2F0E5",
|
||||
"hidden.border": "#B7B5AC",
|
||||
"hint": "#B7B5AC",
|
||||
"hint.background": "#F2F0E5",
|
||||
"hint.border": "#B7B5AC",
|
||||
"ignored": "#B7B5AC",
|
||||
"ignored.background": "#F2F0E5",
|
||||
"ignored.border": "#B7B5AC",
|
||||
"info": "#24837B",
|
||||
"info.background": "#DDF1E4",
|
||||
"info.border": "#24837B",
|
||||
"modified": "#AD8301",
|
||||
"modified.background": "#FAEEC6",
|
||||
"modified.border": "#AD8301",
|
||||
"predictive": "#B7B5AC",
|
||||
"renamed": "#24837B",
|
||||
"renamed.background": "#DDF1E4",
|
||||
"renamed.border": "#24837B",
|
||||
"success": "#66800B",
|
||||
"success.background": "#EDEECF",
|
||||
"success.border": "#66800B",
|
||||
"unreachable": "#6F6E69",
|
||||
"unreachable.background": "#F2F0E5",
|
||||
"unreachable.border": "#6F6E69",
|
||||
"warning": "#AD8301",
|
||||
"warning.background": "#FAEEC6",
|
||||
"warning.border": "#AD8301",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#205EA6"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#BC5215"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#B7B5AC"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#B7B5AC"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#F07171"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#205EA6"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#5C6773"
|
||||
},
|
||||
"function": {
|
||||
"color": "#BC5215"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#66800B"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"number": {
|
||||
"color": "#5E409D"
|
||||
},
|
||||
"string": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#205EA6"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"title": {
|
||||
"color": "#24837B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#AD8301"
|
||||
},
|
||||
"property": {
|
||||
"color": "#BC5215"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#205EA6"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Flexoki Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#242221",
|
||||
"accent.foreground": "#CECDC3",
|
||||
"background": "#100F0F",
|
||||
"border": "#282726",
|
||||
"ring": "#AD8301",
|
||||
"selection.background": "#D0A21577",
|
||||
"foreground": "#CECDC3",
|
||||
"input.border": "#282726",
|
||||
"list.active.background": "#D0A21515",
|
||||
"list.active.border": "#AD8301",
|
||||
"list.even.background": "#1C1B1A",
|
||||
"muted.background": "#1C1B1A",
|
||||
"muted.foreground": "#878580",
|
||||
"panel.background": "#1C1B1A",
|
||||
"popover.background": "#100F0F",
|
||||
"popover.foreground": "#CECDC3",
|
||||
"primary.background": "#24837B",
|
||||
"primary.foreground": "#FFFCF0",
|
||||
"info.background": "#205EA6",
|
||||
"scrollbar.background": "#100F0F00",
|
||||
"scrollbar.thumb.background": "#282726",
|
||||
"scrollbar.thumb.hover.background": "#343331",
|
||||
"secondary.background": "#232221",
|
||||
"secondary.active.background": "#262423",
|
||||
"secondary.foreground": "#CECDC3",
|
||||
"secondary.hover.background": "#232221",
|
||||
"tab.active.background": "#100F0F",
|
||||
"tab.active.foreground": "#CECDC3",
|
||||
"tab.background": "#1C1B1A",
|
||||
"tab.foreground": "#878580",
|
||||
"tab_bar.background": "#1C1B1A99",
|
||||
"title_bar.background": "#191818",
|
||||
"base.yellow": "#AD8301",
|
||||
"base.yellow.light": "#D0A215",
|
||||
"base.red": "#AF3029",
|
||||
"base.red.light": "#D14D41",
|
||||
"base.blue": "#205EA6",
|
||||
"base.blue.light": "#4385BE",
|
||||
"base.green": "#66800B",
|
||||
"base.green.light": "#879A39",
|
||||
"base.magenta": "#A02F6F",
|
||||
"base.magenta.light": "#CE5D97",
|
||||
"base.cyan": "#24837B",
|
||||
"base.cyan.light": "#3AA99F"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#CECDC3",
|
||||
"editor.background": "#100F0F",
|
||||
"editor.active_line.background": "#1C1B1A",
|
||||
"editor.line_number": "#575653",
|
||||
"editor.active_line_number": "#3AA99F",
|
||||
"editor.invisible": "#B7B5AC66",
|
||||
"conflict": "#D0A215",
|
||||
"created": "#879A39",
|
||||
"hidden": "#575653",
|
||||
"hint": "#575653",
|
||||
"modified": "#D0A215",
|
||||
"predictive": "#878580",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#4385BE"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#DA702C"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#575653"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#575653"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#CE5D97"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#4385BE"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#878580"
|
||||
},
|
||||
"function": {
|
||||
"color": "#DA702C"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#879A39"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"number": {
|
||||
"color": "#8B7EC8"
|
||||
},
|
||||
"string": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#4385BE"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"title": {
|
||||
"color": "#D0A215",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#D0A215"
|
||||
},
|
||||
"property": {
|
||||
"color": "#DA702C"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#4385BE"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Gruvbox",
|
||||
"author": "Pavel Pertsev",
|
||||
"url": "https://github.com/morhetz/gruvbox",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Gruvbox Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#e6d19e",
|
||||
"accent.foreground": "#3c3836",
|
||||
"background": "#fbf1c7",
|
||||
"border": "#d5c4a1",
|
||||
"danger.background": "#fb4934",
|
||||
"danger.active.background": "#cc241d",
|
||||
"danger.foreground": "#fbf1c7",
|
||||
"foreground": "#3c3836",
|
||||
"input.border": "#d5c4a1",
|
||||
"link.active.foreground": "#458588",
|
||||
"link.foreground": "#458588",
|
||||
"link.hover.foreground": "#458588",
|
||||
"list.active.background": "#d7992122",
|
||||
"list.hover.background": "#ebdbb2",
|
||||
"list.active.border": "#d79921",
|
||||
"list.even.background": "#f9ecba",
|
||||
"muted.background": "#d5c4a166",
|
||||
"muted.foreground": "#928374",
|
||||
"panel.background": "#ebdbb2",
|
||||
"popover.background": "#fbf1c7",
|
||||
"popover.foreground": "#3c3836",
|
||||
"primary.active.background": "#b7841f",
|
||||
"primary.background": "#d79921",
|
||||
"primary.foreground": "#fbf1c7",
|
||||
"primary.hover.background": "#d79921ee",
|
||||
"ring": "#d79921",
|
||||
"scrollbar.background": "#fbf1c700",
|
||||
"scrollbar.thumb.background": "#d5c4a1",
|
||||
"secondary.background": "#EBDBB2",
|
||||
"secondary.active.background": "#d5c4a1",
|
||||
"secondary.foreground": "#3c3836",
|
||||
"secondary.hover.background": "#e8d5a6",
|
||||
"tab.active.background": "#fbf1c7",
|
||||
"tab.active.foreground": "#3c3836",
|
||||
"tab.background": "#ebdbb200",
|
||||
"tab.foreground": "#928374",
|
||||
"tab_bar.background": "#ebdbb2",
|
||||
"title_bar.background": "#ebdbb2",
|
||||
"title_bar.border": "#d5c4a1",
|
||||
"base.magenta.light": "#D3869B66",
|
||||
"base.red": "#fb4934",
|
||||
"base.green": "#67a64f",
|
||||
"base.yellow": "#b57614",
|
||||
"base.blue": "#076678",
|
||||
"base.magenta": "#8f3f71",
|
||||
"base.cyan": "#427b58"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#3c3836",
|
||||
"editor.background": "#fbf1c7",
|
||||
"editor.active_line.background": "#ebdbb2",
|
||||
"editor.line_number": "#928374",
|
||||
"editor.active_line_number": "#3c3836",
|
||||
"editor.invisible": "#92837466",
|
||||
"conflict": "#cc241d",
|
||||
"created": "#79740e",
|
||||
"deleted": "#9d0006",
|
||||
"error": "#cc241d",
|
||||
"hidden": "#928374",
|
||||
"hint": "#458588",
|
||||
"ignored": "#928374",
|
||||
"info": "#458588",
|
||||
"modified": "#b57614",
|
||||
"predictive": "#928374",
|
||||
"renamed": "#b57614",
|
||||
"success": "#79740e",
|
||||
"unreachable": "#928374",
|
||||
"warning": "#d79921",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#928374",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#928374",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#3c3836"
|
||||
},
|
||||
"function": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#cc241d"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#458588",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#076678",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#cc241d"
|
||||
},
|
||||
"string": {
|
||||
"color": "#79740e"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#79740e"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#79740e"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"title": {
|
||||
"color": "#b57614",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"property": {
|
||||
"color": "#3c3836"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#cc241d"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Gruvbox Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#303030",
|
||||
"accent.foreground": "#ebdbb2",
|
||||
"background": "#1d2021",
|
||||
"border": "#3e3936",
|
||||
"danger.active.background": "#fb4934",
|
||||
"foreground": "#ebdbb2",
|
||||
"input.border": "#504945",
|
||||
"link.active.foreground": "#83a598",
|
||||
"link.foreground": "#83a598",
|
||||
"link.hover.foreground": "#83a598",
|
||||
"list.active.background": "#d7992111",
|
||||
"list.active.border": "#b17f1b",
|
||||
"list.even.background": "#282828",
|
||||
"muted.background": "#3c383655",
|
||||
"muted.foreground": "#928374",
|
||||
"panel.background": "#282828",
|
||||
"popover.background": "#1d2021",
|
||||
"popover.foreground": "#ebdbb2",
|
||||
"primary.background": "#d79921",
|
||||
"primary.foreground": "#282828",
|
||||
"primary.hover.background": "#fabd2f",
|
||||
"ring": "#b17f1b",
|
||||
"scrollbar.background": "#1d202100",
|
||||
"scrollbar.thumb.background": "#504945",
|
||||
"secondary.active.background": "#333332",
|
||||
"secondary.background": "#282828",
|
||||
"secondary.foreground": "#ebdbb2",
|
||||
"secondary.hover.background": "#33333299",
|
||||
"tab.active.background": "#1d2021",
|
||||
"tab.active.foreground": "#ebdbb2",
|
||||
"tab.background": "#28282800",
|
||||
"tab.foreground": "#928374",
|
||||
"tab_bar.background": "#28282899",
|
||||
"title_bar.background": "#252525",
|
||||
"title_bar.border": "#3e3936",
|
||||
"base.magenta.light": "#D3869B66",
|
||||
"base.red": "#f06555",
|
||||
"base.green": "#98971a",
|
||||
"base.yellow": "#d79921",
|
||||
"base.blue": "#458588",
|
||||
"base.magenta": "#b16286",
|
||||
"base.cyan": "#689d6a"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#1d2021",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#92837466",
|
||||
"conflict": "#f06555",
|
||||
"created": "#3f72e2",
|
||||
"deleted": "#9d0006",
|
||||
"error": "#cc241d",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"ignored": "#928374",
|
||||
"info": "#458588",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"renamed": "#b57614",
|
||||
"success": "#79740e",
|
||||
"unreachable": "#928374",
|
||||
"warning": "#d79921",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Harper",
|
||||
"author": "iTerm2-Color-Schemes",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Harper",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#27222c",
|
||||
"accent.foreground": "#a8a49d",
|
||||
"background": "#010101",
|
||||
"border": "#333333",
|
||||
"foreground": "#a8a49d",
|
||||
"input.border": "#333333",
|
||||
"list.active.background": "#B196C622",
|
||||
"list.active.border": "#B196C699",
|
||||
"list.even.background": "#18151B77",
|
||||
"muted.background": "#171717",
|
||||
"muted.foreground": "#726E69",
|
||||
"panel.background": "#003a5b",
|
||||
"popover.background": "#010101",
|
||||
"popover.foreground": "#a8a49d",
|
||||
"primary.background": "#B196C6",
|
||||
"primary.foreground": "#000000",
|
||||
"scrollbar.background": "#003a5b00",
|
||||
"scrollbar.thumb.background": "#726E69",
|
||||
"secondary.background": "#1b1b1b",
|
||||
"secondary.active.background": "#2c2632",
|
||||
"secondary.foreground": "#A8A49D",
|
||||
"secondary.hover.background": "#23232399",
|
||||
"tab.active.background": "#010101",
|
||||
"tab.active.foreground": "#a8a49d",
|
||||
"tab.foreground": "#787878",
|
||||
"title_bar.background": "#101010",
|
||||
"base.red": "#ff5874",
|
||||
"base.red.light": "#ff587499",
|
||||
"base.green": "#489e48",
|
||||
"base.green.light": "#489e4899",
|
||||
"base.blue": "#7fb5e1",
|
||||
"base.blue.light": "#7fb5e199",
|
||||
"base.cyan": "#bff5e5",
|
||||
"base.cyan.light": "#bff5e599",
|
||||
"base.magenta": "#b296c6",
|
||||
"base.magenta.light": "#b296c699",
|
||||
"base.yellow": "#f8b63f",
|
||||
"base.yellow.light": "#f8b63f99"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#a8a49d",
|
||||
"editor.background": "#010101",
|
||||
"editor.active_line.background": "#18151B",
|
||||
"editor.line_number": "#726E69",
|
||||
"editor.active_line_number": "#a8a49d",
|
||||
"editor.invisible": "#726E6966",
|
||||
"conflict": "#ff5874",
|
||||
"created": "#489e48",
|
||||
"deleted": "#ff5874",
|
||||
"error": "#ff5874",
|
||||
"hidden": "#726E69",
|
||||
"hint": "#7fb5e1",
|
||||
"ignored": "#726E69",
|
||||
"info": "#7fb5e1",
|
||||
"modified": "#b296c6",
|
||||
"predictive": "#726E69",
|
||||
"renamed": "#b296c6",
|
||||
"success": "#489e48",
|
||||
"unreachable": "#726E69",
|
||||
"warning": "#f8b63f",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#b296c6"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#726E69",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#726E69",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b296c6"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#a8a49d"
|
||||
},
|
||||
"function": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#ff5874"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#7fb5e1",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#bff5e5",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#ff5874"
|
||||
},
|
||||
"string": {
|
||||
"color": "#489e48"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#489e48"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#489e48"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b296c6"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"title": {
|
||||
"color": "#b296c6",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#b296c6"
|
||||
},
|
||||
"property": {
|
||||
"color": "#a8a49d"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#ff5874"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Hybrid",
|
||||
"author": "Andrew Wong",
|
||||
"url": "https://github.com/w0ng/vim-hybrid",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Hybrid Light",
|
||||
"mode": "light",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#c8ced1",
|
||||
"accent.foreground": "#1c1c1c",
|
||||
"background": "#E4E4E4",
|
||||
"border": "#CACACA",
|
||||
"danger.background": "#ff5f5f",
|
||||
"danger.foreground": "#ffffff",
|
||||
"foreground": "#1c1c1c",
|
||||
"input.border": "#CACACA",
|
||||
"link.active.foreground": "#005f87",
|
||||
"link.foreground": "#005f87",
|
||||
"link.hover.foreground": "#005f87",
|
||||
"list.active.background": "#79792C20",
|
||||
"list.active.border": "#79792C",
|
||||
"list.hover.background": "#D5D5D5",
|
||||
"list.even.background": "#DFDFDF",
|
||||
"muted.background": "#d7d7d7",
|
||||
"muted.foreground": "#5f5f5f",
|
||||
"primary.background": "#005f87",
|
||||
"primary.foreground": "#ffffff",
|
||||
"scrollbar.background": "#E0E0E000",
|
||||
"scrollbar.thumb.background": "#8f8f8f",
|
||||
"secondary.background": "#D0D0D0",
|
||||
"secondary.active.background": "#c4c4c4",
|
||||
"secondary.foreground": "#1c1c1c",
|
||||
"secondary.hover.background": "#c4c4c499",
|
||||
"tab.active.background": "#E4E4E4",
|
||||
"tab.active.foreground": "#1c1c1c",
|
||||
"tab.background": "#e8e8e800",
|
||||
"tab.foreground": "#5f5f5f",
|
||||
"tab_bar.background": "#e8e8e8",
|
||||
"title_bar.background": "#D0D0D0",
|
||||
"title_bar.border": "#B3B3B3",
|
||||
"status_bar.background": "#DADADA",
|
||||
"base.red": "#5F0000",
|
||||
"base.green": "#005F00",
|
||||
"base.yellow": "#948000",
|
||||
"base.blue": "#00195f",
|
||||
"base.magenta": "#5f1c51",
|
||||
"base.magenta.light": "#5f1c5111",
|
||||
"base.cyan": "#005a5f"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#1c1c1c",
|
||||
"editor.background": "#E4E4E4",
|
||||
"editor.active_line.background": "#D3D3D3",
|
||||
"editor.line_number": "#5F5F5F",
|
||||
"editor.active_line_number": "#1C1C1C",
|
||||
"editor.invisible": "#5F5F5F66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3F72E2",
|
||||
"deleted": "#FF5F5F",
|
||||
"error": "#FF5F5F",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#5F87AF",
|
||||
"ignored": "#B3B3B3",
|
||||
"info": "#005F87",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"renamed": "#5F87AF",
|
||||
"success": "#005F00",
|
||||
"unreachable": "#9E9E9E",
|
||||
"warning": "#948000",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#948000"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#005F00"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#5F5F5F",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#5F5F5F",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#005F87"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#79792C"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#1C1C1C"
|
||||
},
|
||||
"function": {
|
||||
"color": "#005F87"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#5F1C51"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#005F87",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#005F87",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#005F00"
|
||||
},
|
||||
"string": {
|
||||
"color": "#005F00"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#005F00"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#005F00"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#005F87"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#005F87"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#79792C"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#005F87"
|
||||
},
|
||||
"title": {
|
||||
"color": "#005F87",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#5F1C51"
|
||||
},
|
||||
"property": {
|
||||
"color": "#1C1C1C"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#5F1C51"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Hybrid Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#31393a",
|
||||
"accent.foreground": "#e8e8e8",
|
||||
"background": "#1D1F21",
|
||||
"border": "#34373c",
|
||||
"foreground": "#e8e8e8",
|
||||
"input.border": "#34373c",
|
||||
"link.active.foreground": "#87d7ff",
|
||||
"link.foreground": "#81A2BE",
|
||||
"link.hover.foreground": "#006f9f",
|
||||
"list.active.background": "#15678a10",
|
||||
"list.active.border": "#15678a",
|
||||
"list.even.background": "#282A2E",
|
||||
"muted.background": "#282A2E99",
|
||||
"muted.foreground": "#878787",
|
||||
"panel.background": "#1D1F21",
|
||||
"popover.background": "#1D1F21",
|
||||
"popover.foreground": "#e8e8e8",
|
||||
"primary.background": "#15678a",
|
||||
"primary.foreground": "#e8e8e8",
|
||||
"scrollbar.background": "#1D1F2100",
|
||||
"scrollbar.thumb.background": "#5f5f5f",
|
||||
"secondary.background": "#303336",
|
||||
"secondary.active.background": "#33363b",
|
||||
"secondary.foreground": "#e8e8e8",
|
||||
"secondary.hover.background": "#373b3e",
|
||||
"tab.active.background": "#1D1F21",
|
||||
"tab.active.foreground": "#d4d4d4",
|
||||
"tab.background": "#24262700",
|
||||
"tab.foreground": "#999999",
|
||||
"tab_bar.background": "#242627",
|
||||
"title_bar.background": "#242629",
|
||||
"title_bar.border": "#34373c",
|
||||
"base.red": "#8a1515",
|
||||
"base.red.light": "#ff5f5f",
|
||||
"base.green": "#7c8a15",
|
||||
"base.green.light": "#b3bf5a",
|
||||
"base.yellow": "#8a7c15",
|
||||
"base.yellow.light": "#e4b55e",
|
||||
"base.blue": "#15678a",
|
||||
"base.blue.light": "#6e90b0",
|
||||
"base.magenta": "#8a1567",
|
||||
"base.magenta.light": "#B294BB",
|
||||
"base.cyan": "#15678a",
|
||||
"base.cyan.light": "#7fbfb4"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#5F5F5F66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"created.background": "#0C4619",
|
||||
"deleted.background": "#46190C",
|
||||
"error.background": "#46190C",
|
||||
"error.border": "#802207",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"hint.background": "#250c4b",
|
||||
"hint.border": "#3f0891",
|
||||
"info.background": "#0C194D",
|
||||
"info.border": "#082190",
|
||||
"modified": "#B0A878",
|
||||
"modified.background": "#3A310E",
|
||||
"predictive": "#5D5945",
|
||||
"success.background": "#0C4619",
|
||||
"warning.background": "#3A310E",
|
||||
"warning.border": "#7B6508",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Jellybeans",
|
||||
"author": "nanotech",
|
||||
"url": "https://github.com/nanotech/jellybeans.vim",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Jellybeans",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#292929",
|
||||
"accent.foreground": "#e8e8e8",
|
||||
"background": "#151515",
|
||||
"border": "#2e2e2e",
|
||||
"danger.foreground": "#151515",
|
||||
"foreground": "#E8E8D3",
|
||||
"input.border": "#4E4847",
|
||||
"link.active.foreground": "#97bedc",
|
||||
"link.foreground": "#97bedc",
|
||||
"link.hover.foreground": "#97bedc",
|
||||
"list.active.background": "#97bedc15",
|
||||
"list.active.border": "#97bedc",
|
||||
"list.even.background": "#1C1C1C",
|
||||
"muted.background": "#242424",
|
||||
"muted.foreground": "#767676",
|
||||
"panel.background": "#151515",
|
||||
"popover.background": "#151515",
|
||||
"popover.foreground": "#e8e8e8",
|
||||
"primary.background": "#97bedc",
|
||||
"primary.foreground": "#151515",
|
||||
"scrollbar.background": "#26262600",
|
||||
"scrollbar.thumb.background": "#6e6e6e",
|
||||
"secondary.background": "#2F2F2F",
|
||||
"secondary.active.background": "#2f2f2f",
|
||||
"secondary.foreground": "#e8e8e8",
|
||||
"secondary.hover.background": "#252525",
|
||||
"tab.active.background": "#151515",
|
||||
"tab.active.foreground": "#e8e8e8",
|
||||
"tab.background": "#10101000",
|
||||
"tab.foreground": "#969696",
|
||||
"tab_bar.background": "#101010",
|
||||
"title_bar.background": "#101010",
|
||||
"title_bar.border": "#2e2e2e",
|
||||
"base.red": "#e27373",
|
||||
"base.red.light": "#ffa1a1",
|
||||
"base.green": "#94b979",
|
||||
"base.green.light": "#bddeab",
|
||||
"base.yellow": "#ffba7b",
|
||||
"base.yellow.light": "#ffdca0",
|
||||
"base.blue": "#97bedc",
|
||||
"base.blue.light": "#b1d8f6",
|
||||
"base.magenta": "#B294BB",
|
||||
"base.magenta.light": "#fbdaff",
|
||||
"base.cyan": "#00988e",
|
||||
"base.cyan.light": "#1ab2a8"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#9E9E9E66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Kibble",
|
||||
"author": "iTerm2-Color-Schemes",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Kibble",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#252525",
|
||||
"accent.foreground": "#f7f7f7",
|
||||
"background": "#0e100a",
|
||||
"border": "#292a24",
|
||||
"danger.background": "#e07b5c",
|
||||
"foreground": "#f7f7f7",
|
||||
"input.border": "#333333",
|
||||
"list.active.background": "#2BCF1315",
|
||||
"list.active.border": "#2BCF13",
|
||||
"list.hover.background": "#2c292b54",
|
||||
"list.even.background": "#24222355",
|
||||
"muted.background": "#292a2455",
|
||||
"muted.foreground": "#777777",
|
||||
"panel.background": "#003a5b",
|
||||
"popover.background": "#0e100a",
|
||||
"popover.foreground": "#f7f7f7",
|
||||
"primary.active.background": "#6ce05c99",
|
||||
"primary.background": "#6ce05c",
|
||||
"primary.foreground": "#101010",
|
||||
"scrollbar.background": "#003a5b00",
|
||||
"scrollbar.thumb.background": "#726E69",
|
||||
"secondary.background": "#22231f",
|
||||
"secondary.active.background": "#292825",
|
||||
"secondary.foreground": "#f7f7f7",
|
||||
"secondary.hover.background": "#22231f",
|
||||
"success.background": "#5c6ee0",
|
||||
"warning.background": "#e0c85c",
|
||||
"info.background": "#5cd1e0",
|
||||
"selection.background": "#9ba787",
|
||||
"tab.active.background": "#0e100a",
|
||||
"tab.active.foreground": "#f7f7f7",
|
||||
"tab.foreground": "#969696",
|
||||
"title_bar.background": "#161712",
|
||||
"base.red": "#C70231",
|
||||
"base.red.light": "#e07b5c",
|
||||
"base.green": "#2BCF13",
|
||||
"base.green.light": "#6ce05c",
|
||||
"base.blue": "#3449d1",
|
||||
"base.blue.light": "#5c6ee0",
|
||||
"base.cyan": "#0798ab",
|
||||
"base.cyan.light": "#5cd1e0",
|
||||
"base.magenta": "#8400ff",
|
||||
"base.magenta.light": "#C495F0",
|
||||
"base.yellow": "#c7a302",
|
||||
"base.yellow.light": "#e0c85c"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#f7f7f7",
|
||||
"editor.background": "#0e100a",
|
||||
"editor.active_line.background": "#242223",
|
||||
"editor.line_number": "#726E69",
|
||||
"editor.active_line_number": "#f7f7f7",
|
||||
"editor.invisible": "#726E6966",
|
||||
"conflict": "#c70031",
|
||||
"created": "#2BCF13",
|
||||
"deleted": "#c70031",
|
||||
"error": "#c70031",
|
||||
"hidden": "#726E69",
|
||||
"hint": "#0798ab",
|
||||
"ignored": "#726E69",
|
||||
"info": "#0798ab",
|
||||
"modified": "#3449d1",
|
||||
"predictive": "#726E69",
|
||||
"renamed": "#3449d1",
|
||||
"success": "#2BCF13",
|
||||
"unreachable": "#726E69",
|
||||
"warning": "#c7a302",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#3449d1"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#726E69",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#726E69",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#3449d1"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#f7f7f7"
|
||||
},
|
||||
"function": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#c70031"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#0798ab",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#68f2e0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#c70031"
|
||||
},
|
||||
"string": {
|
||||
"color": "#2BCF13"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#2BCF13"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#2BCF13"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#3449d1"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"title": {
|
||||
"color": "#3449d1",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#3449d1"
|
||||
},
|
||||
"property": {
|
||||
"color": "#f7f7f7"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#c70031"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "macOS Classic",
|
||||
"author": "huacnlee",
|
||||
"url": "https://github.com/huacnlee/zed-theme-macos-classic",
|
||||
"themes": [
|
||||
{
|
||||
"name": "macOS Classic Light",
|
||||
"mode": "light",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#E0E0E0",
|
||||
"accent.foreground": "#000000",
|
||||
"background": "#F9F9F9",
|
||||
"foreground": "#000000",
|
||||
"border": "#D2D2D2",
|
||||
"ring": "#0060de",
|
||||
"danger.foreground": "#FFFFFF",
|
||||
"list.active.background": "#0060de15",
|
||||
"list.active.border": "#0060de",
|
||||
"list.even.background": "#EFEFEF",
|
||||
"list.hover.background": "#E7E7E7",
|
||||
"muted.background": "#EAEAEA",
|
||||
"muted.foreground": "#707070",
|
||||
"popover.background": "#F5F5F5",
|
||||
"popover.foreground": "#000000",
|
||||
"primary.background": "#0060de",
|
||||
"primary.foreground": "#FFFFFF",
|
||||
"scrollbar.background": "#FFFFFF00",
|
||||
"scrollbar.thumb.background": "#C8C8C8",
|
||||
"secondary.active.background": "#D9D9D9",
|
||||
"secondary.background": "#E0E0E0",
|
||||
"secondary.foreground": "#000000",
|
||||
"secondary.hover.background": "#E5E5E5",
|
||||
"tab.active.foreground": "#000000",
|
||||
"tab.background": "#E9E9E9",
|
||||
"tab.foreground": "#606060",
|
||||
"tab_bar.background": "#E9E9E9",
|
||||
"title_bar.background": "#F7F7F7",
|
||||
"title_bar.border": "#DADADA",
|
||||
"status_bar.background": "#E9E9E9",
|
||||
"base.yellow": "#B59A00",
|
||||
"base.red": "#d21f07",
|
||||
"base.blue": "#0060de",
|
||||
"base.green": "#319a00",
|
||||
"base.magenta": "#9A0068",
|
||||
"base.cyan": "#007E8A"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#000000",
|
||||
"editor.background": "#ffffff",
|
||||
"editor.active_line.background": "#F5F5F5",
|
||||
"editor.line_number": "#929292",
|
||||
"editor.active_line_number": "#000000",
|
||||
"editor.invisible": "#007fff66",
|
||||
"conflict": "#d21f07",
|
||||
"created": "#0060de",
|
||||
"hidden": "#6D6D6D",
|
||||
"hint": "#9A0068",
|
||||
"modified": "#319a00",
|
||||
"predictive": "#A4ABB6",
|
||||
"warning": "#B59A00",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#957931"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#007fff"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#007fff"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"function": {
|
||||
"color": "#0000A2"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#0000A2",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6A7293",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"string": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#6F42C1"
|
||||
},
|
||||
"title": {
|
||||
"color": "#0433FF"
|
||||
},
|
||||
"type": {
|
||||
"color": "#6f42c1"
|
||||
},
|
||||
"property": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#C5060B"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "macOS Classic Dark",
|
||||
"mode": "dark",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#282629",
|
||||
"accent.foreground": "#CACCCA",
|
||||
"background": "#131313",
|
||||
"border": "#303030",
|
||||
"ring": "#077CFD",
|
||||
"foreground": "#DEDEDE",
|
||||
"danger.background": "#E44A4F",
|
||||
"list.even.background": "#232323",
|
||||
"list.active.background": "#0059D115",
|
||||
"list.active.border": "#077CFD",
|
||||
"muted.background": "#202020",
|
||||
"muted.foreground": "#9D9D9D",
|
||||
"popover.background": "#101010",
|
||||
"popover.foreground": "#CACCCA",
|
||||
"primary.background": "#077CFD",
|
||||
"primary.foreground": "#F2F9FF",
|
||||
"switch.background": "#393939",
|
||||
"switch.thumb.background": "#DAECFF",
|
||||
"scrollbar.background": "#13131300",
|
||||
"scrollbar.thumb.background": "#9F9F9F",
|
||||
"secondary.active.background": "#353535",
|
||||
"secondary.background": "#353535",
|
||||
"secondary.foreground": "#DEDEDE",
|
||||
"secondary.hover.background": "#35353599",
|
||||
"link": "#419CFF",
|
||||
"tab_bar.background": "#1C1C1E",
|
||||
"tab.active.background": "#131313",
|
||||
"tab.foreground": "#8F8F8F",
|
||||
"title_bar.background": "#171717",
|
||||
"selection.background": "#3F638B",
|
||||
"base.blue": "#419CFF",
|
||||
"base.cyan": "#07FDD3",
|
||||
"base.green": "#30D158",
|
||||
"base.magenta": "#A550A7",
|
||||
"base.red": "#FF5257",
|
||||
"base.yellow": "#FFC600"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#CACCCA",
|
||||
"editor.background": "#131313",
|
||||
"editor.active_line.background": "#35343666",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#007fff66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"created.background": "#0C4619",
|
||||
"deleted.background": "#46190C",
|
||||
"error.background": "#46190C",
|
||||
"error.border": "#E44A4F",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"hint.background": "#250c4b",
|
||||
"hint.border": "#3f0891",
|
||||
"info.background": "#0059D1",
|
||||
"info.border": "#0059D1",
|
||||
"modified": "#B0A878",
|
||||
"modified.background": "#3A310E",
|
||||
"predictive": "#5D5945",
|
||||
"success.background": "#0C4619",
|
||||
"warning.background": "#3A310E",
|
||||
"warning.border": "#7B6508",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#7FAEF9"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#CC9E00"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9D9D9D"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9D9D9D"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#CC9E00"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#CBA6F7"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#B3C5F3"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#87B1F6"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#419CFF",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#7faef9",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#CC9E00"
|
||||
},
|
||||
"string": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"text.code.span": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#68DC7C"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#68DC7C"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#419CFF"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"title": {
|
||||
"color": "#CC9E00",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#CBA6F7"
|
||||
},
|
||||
"property": {
|
||||
"color": "#BCC4E0"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#419CFF"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Mellifluous",
|
||||
"author": "Ramojus Lapinskas",
|
||||
"url": "https://github.com/ramojus/mellifluous.nvim",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Mellifluous Light",
|
||||
"mode": "light",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#d4d4d4",
|
||||
"accent.foreground": "#383a42",
|
||||
"background": "#E7E7E7",
|
||||
"border": "#CACACA",
|
||||
"danger.active.background": "#e06c75",
|
||||
"danger.hover.background": "#be5046",
|
||||
"foreground": "#383a42",
|
||||
"link.active.foreground": "#5A6599",
|
||||
"link.foreground": "#5A6599",
|
||||
"link.hover.foreground": "#5A6599",
|
||||
"list.hover.background": "#d5d5d5",
|
||||
"list.active.background": "#5A659912",
|
||||
"list.active.border": "#5A6599",
|
||||
"list.even.background": "#F0F0F0",
|
||||
"muted.background": "#E0E0E0",
|
||||
"muted.foreground": "#828997",
|
||||
"panel.background": "#fafafa",
|
||||
"popover.background": "#E4E4E4",
|
||||
"popover.foreground": "#383a42",
|
||||
"primary.active.background": "#5A6599AA",
|
||||
"primary.background": "#5A6599",
|
||||
"primary.foreground": "#F0F0F0",
|
||||
"primary.hover.background": "#626ca2",
|
||||
"scrollbar.background": "#fafafa00",
|
||||
"scrollbar.thumb.background": "#c0c0c0",
|
||||
"secondary.background": "#d0d0d0",
|
||||
"secondary.active.background": "#cccccc",
|
||||
"secondary.foreground": "#383a42",
|
||||
"secondary.hover.background": "#d7d7d7",
|
||||
"tab.active.background": "#E7E7E7",
|
||||
"tab.active.foreground": "#383a42",
|
||||
"tab.background": "#F0F0F000",
|
||||
"tab.foreground": "#727272",
|
||||
"tab_bar.background": "#E7E7E7",
|
||||
"title_bar.background": "#dfdfdf",
|
||||
"title_bar.border": "#CACACA",
|
||||
"status_bar.background": "#dfdfdf",
|
||||
"base.red": "#C95954",
|
||||
"base.green": "#828040",
|
||||
"base.yellow": "#c98f54",
|
||||
"base.blue": "#a8a1be",
|
||||
"base.magenta": "#b39fb0",
|
||||
"base.magenta.light": "#9C699588",
|
||||
"base.cyan": "#54c981"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#383a42",
|
||||
"editor.background": "#E7E7E7",
|
||||
"editor.active_line.background": "#d5d5d5",
|
||||
"editor.line_number": "#727272",
|
||||
"editor.active_line_number": "#383a42",
|
||||
"editor.invisible": "#A0A0A066",
|
||||
"conflict": "#e06c75",
|
||||
"created": "#828040",
|
||||
"deleted": "#be5046",
|
||||
"error": "#e06c75",
|
||||
"hidden": "#727272",
|
||||
"hint": "#5A6599",
|
||||
"ignored": "#727272",
|
||||
"info": "#5A6599",
|
||||
"modified": "#727272",
|
||||
"predictive": "#727272",
|
||||
"renamed": "#727272",
|
||||
"success": "#828040",
|
||||
"unreachable": "#727272",
|
||||
"warning": "#cbaa89",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#727272"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#A0A0A0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#A0A0A0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#727272"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#383a42"
|
||||
},
|
||||
"function": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#e06c75"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#5A6599",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#5A6599",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e06c75"
|
||||
},
|
||||
"string": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#727272"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"title": {
|
||||
"color": "#727272",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#727272"
|
||||
},
|
||||
"property": {
|
||||
"color": "#383a42"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#e06c75"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Mellifluous Dark",
|
||||
"mode": "dark",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#2f2f2f",
|
||||
"accent.foreground": "#abb2bf",
|
||||
"background": "#1A1A1A",
|
||||
"border": "#444444",
|
||||
"foreground": "#abb2bf",
|
||||
"input.border": "#444444",
|
||||
"link.active.foreground": "#5A6599",
|
||||
"link.foreground": "#5A6599",
|
||||
"link.hover.foreground": "#5A6599",
|
||||
"list.active.background": "#5A659922",
|
||||
"list.active.border": "#5A6599",
|
||||
"list.even.background": "#29292988",
|
||||
"muted.background": "#44444455",
|
||||
"muted.foreground": "#828997",
|
||||
"panel.background": "#282c34",
|
||||
"popover.background": "#1A1A1A",
|
||||
"popover.foreground": "#abb2bf",
|
||||
"primary.active.background": "#5A6599AA",
|
||||
"primary.background": "#5A6599",
|
||||
"primary.foreground": "#ecedf4",
|
||||
"primary.hover.background": "#626ca2",
|
||||
"scrollbar.background": "#282c3400",
|
||||
"scrollbar.thumb.background": "#444444",
|
||||
"secondary.background": "#292929",
|
||||
"secondary.active.background": "#323232",
|
||||
"secondary.foreground": "#abb2bf",
|
||||
"secondary.hover.background": "#292929",
|
||||
"tab.active.background": "#1A1A1A",
|
||||
"tab.active.foreground": "#abb2bf",
|
||||
"tab.background": "#1A1A1A00",
|
||||
"tab.foreground": "#abb2bf",
|
||||
"title_bar.background": "#171717",
|
||||
"title_bar.border": "#444444",
|
||||
"base.red": "#C95954",
|
||||
"base.green": "#828040",
|
||||
"base.yellow": "#c98d54",
|
||||
"base.blue": "#5481c9",
|
||||
"base.magenta": "#9C6995",
|
||||
"base.magenta.light": "#9C699577",
|
||||
"base.cyan": "#54c9bd"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#abb2bf",
|
||||
"editor.background": "#1A1A1A",
|
||||
"editor.active_line.background": "#29292977",
|
||||
"editor.line_number": "#828997",
|
||||
"editor.active_line_number": "#abb2bf",
|
||||
"editor.invisible": "#A0A0A066",
|
||||
"conflict": "#e06c75",
|
||||
"conflict.background": "#1A1A1A",
|
||||
"conflict.border": "#be5046",
|
||||
"created": "#828040",
|
||||
"created.background": "#292929",
|
||||
"created.border": "#929292",
|
||||
"deleted": "#be5046",
|
||||
"deleted.background": "#1A1A1A",
|
||||
"deleted.border": "#e06c75",
|
||||
"error": "#e06c75",
|
||||
"error.background": "#1A1A1A",
|
||||
"error.border": "#be5046",
|
||||
"hidden": "#828997",
|
||||
"hidden.background": "#292929",
|
||||
"hidden.border": "#444444",
|
||||
"hint": "#5A6599",
|
||||
"hint.background": "#292929",
|
||||
"hint.border": "#626ca2",
|
||||
"ignored": "#828997",
|
||||
"ignored.background": "#292929",
|
||||
"ignored.border": "#444444",
|
||||
"info": "#5A6599",
|
||||
"info.background": "#292929",
|
||||
"info.border": "#626ca2",
|
||||
"modified": "#929292",
|
||||
"modified.background": "#292929",
|
||||
"modified.border": "#444444",
|
||||
"predictive": "#828997",
|
||||
"predictive.background": "#292929",
|
||||
"predictive.border": "#444444",
|
||||
"renamed": "#929292",
|
||||
"renamed.background": "#292929",
|
||||
"renamed.border": "#444444",
|
||||
"success": "#828040",
|
||||
"success.background": "#292929",
|
||||
"success.border": "#929292",
|
||||
"unreachable": "#828997",
|
||||
"unreachable.background": "#292929",
|
||||
"unreachable.border": "#444444",
|
||||
"warning": "#d19a66",
|
||||
"warning.background": "#292929",
|
||||
"warning.border": "#929292",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#929292"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#828997",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#828997",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#929292"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#abb2bf"
|
||||
},
|
||||
"function": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#e06c75"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#5A6599",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#5A6599",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e06c75"
|
||||
},
|
||||
"string": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#929292"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"title": {
|
||||
"color": "#929292",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#929292"
|
||||
},
|
||||
"property": {
|
||||
"color": "#abb2bf"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#e06c75"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Molokai",
|
||||
"author": "Tomas Restrepo",
|
||||
"url": "https://github.com/tomasr/molokai",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Molokai Light",
|
||||
"mode": "light",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#EBE4E1",
|
||||
"accent.foreground": "#060606",
|
||||
"background": "#FEFAF9",
|
||||
"border": "#E4DEDA",
|
||||
"window_border": "#d6d6d6",
|
||||
"ring": "#a0a0ab",
|
||||
"foreground": "#0a0a0a",
|
||||
"input.border": "#E4DEDA",
|
||||
"list.active.background": "#E1477411",
|
||||
"list.active.border": "#E14774AA",
|
||||
"list.even.background": "#E4DEDA33",
|
||||
"muted.background": "#f1eded",
|
||||
"muted.foreground": "#767676",
|
||||
"panel.background": "#FEFAF9",
|
||||
"popover.background": "#FEFAF9",
|
||||
"popover.foreground": "#0a0a0a",
|
||||
"primary.background": "#E14774",
|
||||
"primary.foreground": "#fafafa",
|
||||
"scrollbar.background": "#FEFAF900",
|
||||
"scrollbar.thumb.background": "#ADADB0",
|
||||
"secondary.background": "#E4DEDA",
|
||||
"secondary.foreground": "#060606",
|
||||
"secondary.hover.background": "#E4DEDA99",
|
||||
"title_bar.background": "#e8e3e0",
|
||||
"title_bar.border": "#d5ccc6",
|
||||
"base.red": "#e17047",
|
||||
"base.green": "#82cc0a",
|
||||
"base.yellow": "#ccac0a",
|
||||
"base.blue": "#e16032",
|
||||
"base.blue.light": "#e16032",
|
||||
"base.magenta": "#7058be",
|
||||
"base.cyan": "#0acc78"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#0a0a0a",
|
||||
"editor.background": "#FEFAF9",
|
||||
"editor.active_line.background": "#E4DEDA",
|
||||
"editor.line_number": "#767676",
|
||||
"editor.active_line_number": "#0a0a0a",
|
||||
"editor.invisible": "#76767666",
|
||||
"conflict": "#e14775",
|
||||
"created": "#269D69",
|
||||
"deleted": "#e14775",
|
||||
"error": "#e14775",
|
||||
"hidden": "#767676",
|
||||
"hint": "#7058be",
|
||||
"ignored": "#767676",
|
||||
"info": "#1c8ca8",
|
||||
"modified": "#cc7a0a",
|
||||
"predictive": "#ADADB0",
|
||||
"renamed": "#E14774",
|
||||
"success": "#269D69",
|
||||
"unreachable": "#767676",
|
||||
"warning": "#cc7a0a",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#e14775"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#cc7a0a"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#767676",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#767676",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#7058be"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#269D69"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#1c8ca8"
|
||||
},
|
||||
"function": {
|
||||
"color": "#e14775"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#e14775",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#cc7a0a",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#7058be",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#cc7a0a"
|
||||
},
|
||||
"string": {
|
||||
"color": "#269D69"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#7058be"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#269D69"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e14775"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#e14775"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#e14775"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#7058be"
|
||||
},
|
||||
"title": {
|
||||
"color": "#cc7a0a",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#1c8ca8"
|
||||
},
|
||||
"property": {
|
||||
"color": "#7058be"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#e14775"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Molokai Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2b2f30",
|
||||
"accent.foreground": "#f8f8f2",
|
||||
"background": "#1b1d1e",
|
||||
"border": "#393939",
|
||||
"foreground": "#f8f8f2",
|
||||
"input.border": "#3b3b3b",
|
||||
"list.active.background": "#f9267211",
|
||||
"list.active.border": "#f9267288",
|
||||
"list.even.background": "#292c2e",
|
||||
"muted.background": "#232526",
|
||||
"muted.foreground": "#5b5a54",
|
||||
"panel.background": "#1b1d1e",
|
||||
"popover.background": "#1b1d1e",
|
||||
"popover.foreground": "#f8f8f2",
|
||||
"primary.background": "#dc1860",
|
||||
"primary.foreground": "#f8f8f2",
|
||||
"scrollbar.background": "#1b1d1e00",
|
||||
"scrollbar.thumb.background": "#4b4b4b",
|
||||
"secondary.background": "#292c2e",
|
||||
"secondary.active.background": "#303436",
|
||||
"secondary.foreground": "#f5f5f4",
|
||||
"secondary.hover.background": "#30343699",
|
||||
"table.row.border": "#3b3b3b70",
|
||||
"title_bar.background": "#202223",
|
||||
"title_bar.border": "#3b3b3b",
|
||||
"base.red": "#dc1860",
|
||||
"base.green": "#82cc0a",
|
||||
"base.yellow": "#807607",
|
||||
"base.blue": "#075880",
|
||||
"base.magenta": "#800780",
|
||||
"base.cyan": "#07807e"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#76767666",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"created.background": "#0C4619",
|
||||
"deleted.background": "#46190C",
|
||||
"error.background": "#46190C",
|
||||
"error.border": "#802207",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"hint.background": "#250c4b",
|
||||
"hint.border": "#3f0891",
|
||||
"info.background": "#0C194D",
|
||||
"info.border": "#082190",
|
||||
"modified": "#B0A878",
|
||||
"modified.background": "#3A310E",
|
||||
"predictive": "#5D5945",
|
||||
"success.background": "#0C4619",
|
||||
"warning.background": "#3A310E",
|
||||
"warning.border": "#7B6508",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Solarized",
|
||||
"author": "Ethan Schoonover",
|
||||
"url": "https://ethanschoonover.com/solarized",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Solarized Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#ebe4ce",
|
||||
"accent.foreground": "#073642",
|
||||
"background": "#FDF6E3",
|
||||
"border": "#DCD4BC",
|
||||
"foreground": "#586E75",
|
||||
"input.border": "#DCD4BC",
|
||||
"link.foreground": "#587573",
|
||||
"list.active.background": "#586E75",
|
||||
"list.active.border": "#586E75",
|
||||
"list.even.background": "#EEE8D599",
|
||||
"muted.background": "#eee8d5",
|
||||
"muted.foreground": "#93a1a1",
|
||||
"panel.background": "#fdf6e3",
|
||||
"popover.background": "#fdf6e3",
|
||||
"popover.foreground": "#073642",
|
||||
"primary.background": "#586E75",
|
||||
"primary.foreground": "#fdf6e3",
|
||||
"scrollbar.background": "#EEE8D500",
|
||||
"scrollbar.thumb.background": "#d5cbaf",
|
||||
"secondary.background": "#EEE8D5",
|
||||
"secondary.active.background": "#DCD4BC",
|
||||
"secondary.foreground": "#073642",
|
||||
"secondary.hover.background": "#DCD4BC88",
|
||||
"tab.active.background": "#fdf6e3",
|
||||
"tab.active.foreground": "#073642",
|
||||
"tab.background": "#fdf6e300",
|
||||
"tab.foreground": "#657b83",
|
||||
"tab_bar.background": "#eee8d5",
|
||||
"title_bar.background": "#f4ecd7",
|
||||
"title_bar.border": "#dcd4bc",
|
||||
"chart.grid": "#ebe5d4",
|
||||
"base.red": "#755858",
|
||||
"base.green": "#717558",
|
||||
"base.yellow": "#756e58",
|
||||
"base.blue": "#586975",
|
||||
"base.magenta": "#755866",
|
||||
"base.cyan": "#587573"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#586E75",
|
||||
"editor.background": "#FDF6E3",
|
||||
"editor.active_line.background": "#EEE8D5",
|
||||
"editor.line_number": "#93A1A1",
|
||||
"editor.active_line_number": "#073642",
|
||||
"editor.invisible": "#93A1A166",
|
||||
"conflict": "#DC322F",
|
||||
"created": "#859900",
|
||||
"deleted": "#DC322F",
|
||||
"error": "#DC322F",
|
||||
"hidden": "#93A1A1",
|
||||
"hint": "#2AA198",
|
||||
"ignored": "#93A1A1",
|
||||
"info": "#268BD2",
|
||||
"modified": "#B58900",
|
||||
"predictive": "#586E75",
|
||||
"renamed": "#2AA198",
|
||||
"success": "#859900",
|
||||
"unreachable": "#DC322F",
|
||||
"warning": "#B58900",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#B58900"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#CB4B16"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#93A1A1",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#93A1A1",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#268BD2"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#859900"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#2AA198"
|
||||
},
|
||||
"function": {
|
||||
"color": "#268BD2"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#DC322F"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#268BD2",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#2AA198",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#D33682"
|
||||
},
|
||||
"string": {
|
||||
"color": "#859900"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#2AA198"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#D33682"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#B58900"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#B58900"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#268BD2"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#586E75"
|
||||
},
|
||||
"title": {
|
||||
"color": "#073642",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#2AA198"
|
||||
},
|
||||
"property": {
|
||||
"color": "#586E75"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#DC322F"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Solarized Dark",
|
||||
"author": "Ethan Schoonover",
|
||||
"url": "https://ethanschoonover.com/solarized",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#073945",
|
||||
"accent.foreground": "#fdf6e3",
|
||||
"background": "#002B36",
|
||||
"border": "#103a44",
|
||||
"foreground": "#fdf6e3",
|
||||
"input.border": "#083e4c",
|
||||
"list.active.background": "#0d667d25",
|
||||
"list.active.border": "#0d667d",
|
||||
"list.even.background": "#15333A99",
|
||||
"muted.background": "#073642",
|
||||
"muted.foreground": "#839496",
|
||||
"panel.background": "#002b36",
|
||||
"popover.background": "#002b36",
|
||||
"popover.foreground": "#fdf6e3",
|
||||
"primary.background": "#0d667d",
|
||||
"primary.foreground": "#fdf6e3",
|
||||
"scrollbar.background": "#002b3600",
|
||||
"scrollbar.thumb.background": "#586e75",
|
||||
"secondary.background": "#073642",
|
||||
"secondary.active.background": "#083e4b",
|
||||
"secondary.foreground": "#fdf6e3",
|
||||
"secondary.hover.background": "#073945",
|
||||
"tab.active.background": "#002b36",
|
||||
"tab.active.foreground": "#fdf6e3",
|
||||
"tab.foreground": "#93a1a1",
|
||||
"tab_bar.background": "#07364266",
|
||||
"title_bar.background": "#09323d",
|
||||
"title_bar.border": "#16404b",
|
||||
"base.red": "#7d2f0d",
|
||||
"base.green": "#0d7d3f",
|
||||
"base.yellow": "#7d650d",
|
||||
"base.blue": "#0d507d",
|
||||
"base.magenta": "#7d0d43",
|
||||
"base.cyan": "#0d7d70"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#002B36",
|
||||
"editor.active_line.background": "#073642",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#93A1A166",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"created.background": "#0C4619",
|
||||
"deleted.background": "#46190C",
|
||||
"error.background": "#46190C",
|
||||
"error.border": "#802207",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"hint.background": "#250c4b",
|
||||
"hint.border": "#3f0891",
|
||||
"info.background": "#0C194D",
|
||||
"info.border": "#082190",
|
||||
"modified": "#B0A878",
|
||||
"modified.background": "#3A310E",
|
||||
"predictive": "#5D5945",
|
||||
"success.background": "#0C4619",
|
||||
"warning.background": "#3A310E",
|
||||
"warning.border": "#7B6508",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"name": "Spaceduck",
|
||||
"author": "Guillermo Rodriguez",
|
||||
"url": "https://github.com/pineapplegiant/spaceduck",
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Spaceduck",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#212440",
|
||||
"accent.foreground": "#a3a49d",
|
||||
"background": "#0F111B",
|
||||
"border": "#272C42",
|
||||
"foreground": "#a3a49d",
|
||||
"input.border": "#272C42",
|
||||
"link.active.foreground": "#089CC5",
|
||||
"link.foreground": "#089CC5",
|
||||
"link.hover.foreground": "#089CC5",
|
||||
"list.active.background": "#089CC511",
|
||||
"list.active.border": "#089CC5",
|
||||
"list.even.background": "#161928",
|
||||
"muted.background": "#272C4255",
|
||||
"muted.foreground": "#4b6479",
|
||||
"panel.background": "#003a5b",
|
||||
"popover.background": "#0F111B",
|
||||
"popover.foreground": "#a3a49d",
|
||||
"primary.background": "#089CC5",
|
||||
"primary.foreground": "#ecf0c1",
|
||||
"scrollbar.background": "#003a5b00",
|
||||
"scrollbar.thumb.background": "#24303a",
|
||||
"secondary.background": "#242547",
|
||||
"secondary.active.background": "#292b51",
|
||||
"secondary.foreground": "#a3a49d",
|
||||
"secondary.hover.background": "#242547",
|
||||
"tab.active.background": "#0F111B",
|
||||
"tab.foreground": "#838182",
|
||||
"title_bar.background": "#141724",
|
||||
"title_bar.border": "#272C42",
|
||||
"base.red": "#e33400",
|
||||
"base.green": "#5ccc96",
|
||||
"base.blue": "#00a3cc",
|
||||
"base.cyan": "#089CC5",
|
||||
"base.magenta": "#C86D8C",
|
||||
"base.magenta.light": "#C86D8C33",
|
||||
"base.yellow": "#b89c00"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#ecf0c1",
|
||||
"editor.background": "#0F111B",
|
||||
"editor.active_line.background": "#1c1d39",
|
||||
"editor.line_number": "#4b6479",
|
||||
"editor.active_line_number": "#ecf0c1",
|
||||
"editor.invisible": "#4b647966",
|
||||
"conflict": "#e33400",
|
||||
"created": "#5ccc96",
|
||||
"deleted": "#e33400",
|
||||
"error": "#e33400",
|
||||
"hidden": "#4b6479",
|
||||
"hint": "#C86D8C",
|
||||
"ignored": "#4b6479",
|
||||
"info": "#089CC5",
|
||||
"modified": "#f2ce00",
|
||||
"predictive": "#5D5945",
|
||||
"renamed": "#089CC5",
|
||||
"success": "#5ccc96",
|
||||
"unreachable": "#4b6479",
|
||||
"warning": "#f2ce00",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#f2ce00"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#f2ce00"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#4b6479",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#4b6479",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#e33400"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#5ccc96"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#ecf0c1"
|
||||
},
|
||||
"function": {
|
||||
"color": "#089CC5"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#C86D8C"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#089CC5",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#089CC5",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#f2ce00"
|
||||
},
|
||||
"string": {
|
||||
"color": "#5ccc96"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#5ccc96"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#5ccc96"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#f2ce00"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#f2ce00"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#C86D8C"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#ecf0c1"
|
||||
},
|
||||
"title": {
|
||||
"color": "#f2ce00",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#089CC5"
|
||||
},
|
||||
"property": {
|
||||
"color": "#ecf0c1"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#C86D8C"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
{
|
||||
"name": "Tokyo",
|
||||
"author": "Folke Lemaitre",
|
||||
"url": "https://github.com/folke/tokyonight.nvim",
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Tokyo Night",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2C3045",
|
||||
"accent.foreground": "#c0caf5",
|
||||
"background": "#1a1b26",
|
||||
"border": "#292e42",
|
||||
"window_border": "#3d4462",
|
||||
"ring": "#7aa2f7",
|
||||
"foreground": "#c0caf5",
|
||||
"link.active.foreground": "#7aa2f722",
|
||||
"link.foreground": "#7aa2f7",
|
||||
"link.hover.foreground": "#7aa2f7",
|
||||
"list.active.background": "#7aa2f722",
|
||||
"list.active.border": "#7aa2f7",
|
||||
"list.even.background": "#2C304599",
|
||||
"muted.background": "#2C304544",
|
||||
"muted.foreground": "#565f89",
|
||||
"panel.background": "#292e42",
|
||||
"popover.background": "#1a1b26",
|
||||
"popover.foreground": "#c0caf5",
|
||||
"primary.background": "#7aa2f7",
|
||||
"primary.foreground": "#1a1b26",
|
||||
"scrollbar.background": "#1a1b2600",
|
||||
"scrollbar.thumb.background": "#414868",
|
||||
"secondary.active.background": "#292e42",
|
||||
"secondary.background": "#292e42",
|
||||
"secondary.foreground": "#C0CAF5",
|
||||
"secondary.hover.background": "#2d3349",
|
||||
"title_bar.background": "#161720",
|
||||
"title_bar.border": "#292e42",
|
||||
"chart.grid": "#222332",
|
||||
"base.red": "#f7768e",
|
||||
"base.green": "#9ece6a",
|
||||
"base.yellow": "#e0af68",
|
||||
"base.blue": "#7aa2f7",
|
||||
"base.magenta": "#565f89",
|
||||
"base.cyan": "#7dcfff"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#c0caf5",
|
||||
"editor.background": "#1a1b26",
|
||||
"editor.active_line.background": "#292e42",
|
||||
"editor.line_number": "#565f89",
|
||||
"editor.active_line_number": "#c0caf5",
|
||||
"editor.invisible": "#565f8966",
|
||||
"conflict": "#f7768e",
|
||||
"created": "#9ece6a",
|
||||
"deleted": "#f7768e",
|
||||
"error": "#f7768e",
|
||||
"hidden": "#565f89",
|
||||
"hint": "#7dcfff",
|
||||
"ignored": "#565f89",
|
||||
"info": "#7aa2f7",
|
||||
"modified": "#e0af68",
|
||||
"predictive": "#565f89",
|
||||
"renamed": "#7aa2f7",
|
||||
"success": "#9ece6a",
|
||||
"unreachable": "#565f89",
|
||||
"warning": "#e0af68",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#9ece6a"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#565f89",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#565f89",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#7aa2f7"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#7aa2f7"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#f7768e"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#7dcfff",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#7aa2f7",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"string": {
|
||||
"color": "#9ece6a"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#7dcfff"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#9ece6a"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#f7768e"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"title": {
|
||||
"color": "#7aa2f7",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#7dcfff"
|
||||
},
|
||||
"property": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#f7768e"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Tokyo Storm",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#414868",
|
||||
"accent.foreground": "#c0caf5",
|
||||
"background": "#24283b",
|
||||
"border": "#3d4462",
|
||||
"window_border": "#3d4462",
|
||||
"ring": "#7aa2f7",
|
||||
"foreground": "#c0caf5",
|
||||
"input.border": "#414868",
|
||||
"link.active.foreground": "#7aa2f7",
|
||||
"link.foreground": "#7aa2f7",
|
||||
"link.hover.foreground": "#7aa2f7",
|
||||
"list.active.background": "#7aa2f711",
|
||||
"list.active.border": "#7aa2f7",
|
||||
"list.even.background": "#41486844",
|
||||
"muted.background": "#292e42",
|
||||
"muted.foreground": "#565f89",
|
||||
"panel.background": "#292e42",
|
||||
"popover.background": "#24283b",
|
||||
"popover.foreground": "#c0caf5",
|
||||
"primary.background": "#7aa2f7",
|
||||
"primary.foreground": "#24283b",
|
||||
"scrollbar.background": "#24283b00",
|
||||
"scrollbar.thumb.background": "#414868",
|
||||
"secondary.active.background": "#323750",
|
||||
"secondary.background": "#292e42",
|
||||
"secondary.foreground": "#c0caf5",
|
||||
"secondary.hover.background": "#32375099",
|
||||
"title_bar.background": "#202435",
|
||||
"title_bar.border": "#3d4462",
|
||||
"base.red": "#f7768e",
|
||||
"base.green": "#9ece6a",
|
||||
"base.yellow": "#e0af68",
|
||||
"base.blue": "#7aa2f7",
|
||||
"base.magenta": "#b283f8",
|
||||
"base.cyan": "#7dcfff"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#B0B9E2",
|
||||
"editor.background": "#24283B",
|
||||
"editor.active_line.background": "#292E42",
|
||||
"editor.line_number": "#363C58",
|
||||
"editor.active_line_number": "#B0B9E2",
|
||||
"editor.invisible": "#565f8966",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"deleted": "#f7768e",
|
||||
"error": "#f7768e",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"success": "#9ece6a",
|
||||
"warning": "#e0af68",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Tokyo Moon",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#32364E",
|
||||
"accent.foreground": "#c0caf5",
|
||||
"background": "#222436",
|
||||
"border": "#323752",
|
||||
"window_border": "#404668",
|
||||
"ring": "#82aaff",
|
||||
"foreground": "#c0caf5",
|
||||
"input.border": "#444b6a",
|
||||
"link.active.foreground": "#82aaff",
|
||||
"link.foreground": "#82aaff",
|
||||
"link.hover.foreground": "#82aaff",
|
||||
"list.active.background": "#82aaff22",
|
||||
"list.active.border": "#82aaff",
|
||||
"list.background": "#222436",
|
||||
"list.even.background": "#282a40",
|
||||
"muted.background": "#2d3149",
|
||||
"muted.foreground": "#6e738d",
|
||||
"panel.background": "#2d3149",
|
||||
"popover.background": "#222436",
|
||||
"popover.foreground": "#c0caf5",
|
||||
"primary.background": "#82aaff",
|
||||
"primary.foreground": "#222436",
|
||||
"scrollbar.background": "#22243600",
|
||||
"scrollbar.thumb.background": "#444b6a",
|
||||
"secondary.active.background": "#2f334c",
|
||||
"secondary.background": "#2d3149",
|
||||
"secondary.foreground": "#8c94b5",
|
||||
"secondary.hover.background": "#31354f",
|
||||
"table.row.border": "#2d3149",
|
||||
"title_bar.background": "#1e2030",
|
||||
"title_bar.border": "#323752",
|
||||
"base.red": "#ff757f",
|
||||
"base.green": "#c3e88d",
|
||||
"base.yellow": "#ffc777",
|
||||
"base.blue": "#82aaff",
|
||||
"base.magenta": "#6e738d",
|
||||
"base.cyan": "#86e1fc"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#c0caf5",
|
||||
"editor.background": "#222436",
|
||||
"editor.active_line.background": "#2d3149",
|
||||
"editor.line_number": "#6e738d",
|
||||
"editor.active_line_number": "#c0caf5",
|
||||
"editor.invisible": "#565f8966",
|
||||
"conflict": "#ed8796",
|
||||
"created": "#c3e88d",
|
||||
"deleted": "#ed8796",
|
||||
"error": "#ed8796",
|
||||
"hidden": "#6e738d",
|
||||
"hint": "#86e1fc",
|
||||
"ignored": "#6e738d",
|
||||
"info": "#82aaff",
|
||||
"modified": "#ffc777",
|
||||
"predictive": "#6e738d",
|
||||
"renamed": "#82aaff",
|
||||
"success": "#c3e88d",
|
||||
"unreachable": "#6e738d",
|
||||
"warning": "#ffc777",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#c3e88d"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#6e738d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#6e738d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#82aaff"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#82aaff"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#ed8796"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#86e1fc",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#82aaff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"string": {
|
||||
"color": "#c3e88d"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#86e1fc"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#c3e88d"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#ed8796"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"title": {
|
||||
"color": "#82aaff",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#86e1fc"
|
||||
},
|
||||
"property": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#ed8796"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Twilight",
|
||||
"author": "MacroMates",
|
||||
"url": "https://macromates.com",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Twilight",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2b2b2b",
|
||||
"accent.foreground": "#dcdcdc",
|
||||
"background": "#141414",
|
||||
"border": "#343434",
|
||||
"foreground": "#dcdcdc",
|
||||
"input.border": "#3e3e3e",
|
||||
"list.active.background": "#CDA86911",
|
||||
"list.active.border": "#CDA86988",
|
||||
"list.even.background": "#1E1E1E99",
|
||||
"muted.background": "#2e2e2e88",
|
||||
"muted.foreground": "#828282",
|
||||
"primary.background": "#CDA869",
|
||||
"primary.foreground": "#1e1e1e",
|
||||
"scrollbar.background": "#1e1e1e00",
|
||||
"scrollbar.thumb.background": "#3e3e3e",
|
||||
"secondary.background": "#262626",
|
||||
"secondary.active.background": "#2a2a2a",
|
||||
"secondary.foreground": "#dcdcdc",
|
||||
"secondary.hover.background": "#272727",
|
||||
"tab.active.background": "#141414",
|
||||
"tab.active.foreground": "#dcdcdc",
|
||||
"tab.background": "#1E1E1E00",
|
||||
"tab.foreground": "#A9A9A9",
|
||||
"tab_bar.background": "#1E1E1E",
|
||||
"table.background": "#1e1e1e00",
|
||||
"title_bar.background": "#1e1e1e",
|
||||
"title_bar.border": "#343434",
|
||||
"base.red": "#c06d44",
|
||||
"base.red.light": "#de7c4c",
|
||||
"base.green": "#afb97a",
|
||||
"base.green.light": "#ccd88c",
|
||||
"base.yellow": "#c2a86c",
|
||||
"base.yellow.light": "#e2c47e",
|
||||
"base.blue": "#44474a",
|
||||
"base.blue.light": "#5a5e62",
|
||||
"base.magenta": "#b4be7c",
|
||||
"base.magenta.light": "#d0dc8e99",
|
||||
"base.cyan": "#778385",
|
||||
"base.cyan.light": "#8a989b"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#9E9E9E66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,3 +9,11 @@ gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
rust-embed.workspace = true
|
||||
gpui-kit.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
# On wasm there is no filesystem to read from, so rust-embed's debug-mode
|
||||
# disk loading returns nothing; embed the assets even in debug builds.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
rust-embed = { workspace = true, features = ["debug-embed"] }
|
||||
|
||||
@@ -8,6 +8,7 @@ use rust_embed::RustEmbed;
|
||||
#[include = "fonts/**/*"]
|
||||
#[include = "brand/**/*"]
|
||||
#[include = "icons/**/*"]
|
||||
#[include = "themes/**/*"]
|
||||
#[exclude = "*.DS_Store"]
|
||||
pub struct Assets;
|
||||
|
||||
@@ -20,12 +21,39 @@ impl AssetSource for CombinedAssets {
|
||||
if let Ok(Some(data)) = Assets.load(path) {
|
||||
return Ok(Some(data));
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// The kit bundle fetches icons over HTTP on the web; a single
|
||||
// instance keeps its download cache alive across loads.
|
||||
use std::sync::OnceLock;
|
||||
static KIT_ASSETS: OnceLock<gpui_kit::assets::Assets> = OnceLock::new();
|
||||
KIT_ASSETS
|
||||
.get_or_init(gpui_kit::assets::Assets::default)
|
||||
.load(path)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
gpui_kit::assets::Assets.load(path)
|
||||
}
|
||||
|
||||
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
||||
let mut paths = Assets.list(path).unwrap_or_default();
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use std::sync::OnceLock;
|
||||
static KIT_ASSETS: OnceLock<gpui_kit::assets::Assets> = OnceLock::new();
|
||||
paths.extend(
|
||||
KIT_ASSETS
|
||||
.get_or_init(gpui_kit::assets::Assets::default)
|
||||
.list(path)?,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
paths.extend(gpui_kit::assets::Assets.list(path)?);
|
||||
|
||||
Ok(paths)
|
||||
}
|
||||
}
|
||||
@@ -47,3 +75,29 @@ impl AssetSource for Assets {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui_kit::component::ThemeSet;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Every bundled theme file must parse against gpui-kit's schema.
|
||||
#[test]
|
||||
fn bundled_themes_parse() {
|
||||
let mut count = 0;
|
||||
|
||||
for entry in Assets::iter().filter(|p| p.starts_with("themes/")) {
|
||||
let file = Assets::get(&entry).expect("theme file exists");
|
||||
let content = std::str::from_utf8(&file.data).expect("theme is UTF-8");
|
||||
let set: ThemeSet = serde_json::from_str(content).expect("theme parses");
|
||||
assert!(!set.themes.is_empty(), "{entry} has no themes");
|
||||
count += set.themes.len();
|
||||
}
|
||||
|
||||
assert!(
|
||||
count >= 36,
|
||||
"expected the bundled gpui-kit themes, got {count}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
use gpui::AssetSource;
|
||||
|
||||
#[test]
|
||||
fn icons_resolve_from_the_vendored_folder() {
|
||||
// Icons coop's code and the kit components draw, served by the vendored
|
||||
// folder (signed's set, filled with the kit bundle where coop needs more).
|
||||
let vendored = [
|
||||
"icons/search.svg",
|
||||
"icons/close.svg",
|
||||
"icons/panel-left.svg",
|
||||
"icons/window-minimize.svg",
|
||||
"icons/settings-2.svg",
|
||||
"icons/x.svg",
|
||||
"icons/compass.svg",
|
||||
"icons/radio-tower.svg",
|
||||
];
|
||||
for path in vendored {
|
||||
let own = assets::Assets
|
||||
.load(path)
|
||||
.unwrap_or_else(|e| panic!("{path} errored in own embed: {e}"));
|
||||
assert!(own.is_some(), "own embed is missing {path}");
|
||||
|
||||
let combined = assets::CombinedAssets.load(path).unwrap();
|
||||
assert!(combined.is_some(), "CombinedAssets fails to serve {path}");
|
||||
}
|
||||
|
||||
// Coop's own files still load through the combined source.
|
||||
let own = [
|
||||
"brand/coop.svg",
|
||||
"brand/bg1.jpg",
|
||||
"fonts/Inter/Inter-Medium.ttf",
|
||||
];
|
||||
for path in own {
|
||||
assert!(
|
||||
matches!(assets::CombinedAssets.load(path), Ok(Some(_))),
|
||||
"CombinedAssets fails to serve coop asset {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
futures.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
gpui-updater-pre = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -155,7 +155,7 @@ impl AutoUpdater {
|
||||
pub fn status(&self) -> SharedString {
|
||||
match &self.status {
|
||||
UpdateStatus::Idle | UpdateStatus::UpToDate => "Up to date".into(),
|
||||
UpdateStatus::Checking => "Checking for updates…".into(),
|
||||
UpdateStatus::Checking => "Checking for updates".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);
|
||||
@@ -165,7 +165,7 @@ impl AutoUpdater {
|
||||
None => format!("Downloading {downloaded_mb:.1} MB").into(),
|
||||
}
|
||||
}
|
||||
UpdateStatus::Installing => "Installing update…".into(),
|
||||
UpdateStatus::Installing => "Installing update".into(),
|
||||
UpdateStatus::Staged(version) => {
|
||||
format!("Version {version} ready — restart to apply").into()
|
||||
}
|
||||
|
||||
@@ -37,9 +37,8 @@ pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
|
||||
};
|
||||
|
||||
let arch = match (os, arch) {
|
||||
// cargo-packager names the disk images `aarch64`/`x64`.
|
||||
// cargo-packager names the disk images `aarch64`.
|
||||
("macos", "aarch64") => "aarch64",
|
||||
("macos", "x86_64") => "x64",
|
||||
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
|
||||
("linux", "aarch64") => "aarch64",
|
||||
("linux", "x86_64") => "x86_64",
|
||||
@@ -243,7 +242,7 @@ mod tests {
|
||||
#[test]
|
||||
fn picks_the_artifact_matching_os_and_architecture() {
|
||||
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
|
||||
assert_eq!(selected("macos", "x86_64"), Some("Coop_1.0.1_x64.dmg"));
|
||||
assert_eq!(selected("macos", "x86_64"), None);
|
||||
assert_eq!(
|
||||
selected("linux", "aarch64"),
|
||||
Some("coop-linux-aarch64.tar.gz")
|
||||
@@ -258,6 +257,7 @@ mod tests {
|
||||
fn has_no_target_for_windows_or_unknown_platforms() {
|
||||
assert_eq!(asset_filter_for("windows", "x86_64"), None);
|
||||
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
|
||||
assert_eq!(asset_filter_for("macos", "x86_64"), None);
|
||||
assert_eq!(asset_filter_for("macos", "riscv64"), None);
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
|
||||
<div id="nip07-status" class="status status--checking">
|
||||
<div class="status__dot"></div>
|
||||
<span id="nip07-status-text">Checking extension…</span>
|
||||
<span id="nip07-status-text">Checking extension</span>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
|
||||
@@ -11,12 +11,12 @@ use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
||||
WeakEntity,
|
||||
};
|
||||
use instant::Duration;
|
||||
use instant::{Duration, Instant};
|
||||
use nip4e::{DeviceEvent, DeviceRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP, UniversalSigner};
|
||||
|
||||
mod message;
|
||||
mod room;
|
||||
@@ -29,7 +29,10 @@ pub use state::FileAttachment;
|
||||
|
||||
use crate::search::MessageSearchIndex;
|
||||
|
||||
/// A static keypair used only for signing locally-cached rumor events.
|
||||
/// Upper bound on signals applied to the UI in a single update
|
||||
const MAX_SIGNAL_BATCH: usize = 256;
|
||||
|
||||
/// Keypair used only for identifying locally-cached rumor events.
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
@@ -44,6 +47,7 @@ impl Global for GlobalChatRegistry {}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ChatEvent {
|
||||
Ping,
|
||||
Loaded,
|
||||
InboxRelayNotFound,
|
||||
Error(String),
|
||||
}
|
||||
@@ -74,6 +78,13 @@ impl Signal {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which signer to attempt when unwrapping a gift wrap
|
||||
enum UnwrapRoute {
|
||||
User,
|
||||
Device,
|
||||
Both,
|
||||
}
|
||||
|
||||
/// Chat Registry
|
||||
#[derive(Debug)]
|
||||
pub struct ChatRegistry {
|
||||
@@ -216,32 +227,34 @@ impl ChatRegistry {
|
||||
let tx = self.signal_tx.clone();
|
||||
let rx = self.signal_rx.clone();
|
||||
|
||||
let executor = cx.background_executor().clone();
|
||||
|
||||
self.notification_listener = Some(cx.background_spawn(async move {
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed_events = HashSet::new();
|
||||
const MAX_PROCESSED: usize = 10_000;
|
||||
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed_events = HashSet::new();
|
||||
|
||||
let mut user_key = signer.get_public_key_async().await.ok();
|
||||
let device_key = match device_signer.as_ref() {
|
||||
Some(device) => device.get_public_key_async().await.ok(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut last_yield = Instant::now();
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
if last_yield.elapsed() >= Duration::from_millis(8) {
|
||||
executor.timer(Duration::from_millis(0)).await;
|
||||
last_yield = Instant::now();
|
||||
}
|
||||
|
||||
let ClientNotification::Message { message, relay_url } = notification else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match *message {
|
||||
RelayMessage::Event {
|
||||
subscription_id,
|
||||
event,
|
||||
..
|
||||
} => {
|
||||
let chat_sub = subscription_id.as_str() != sub_id1.as_str();
|
||||
let device_sub = subscription_id.as_str() != sub_id2.as_str();
|
||||
|
||||
// Concord wraps are also kind 1059.
|
||||
//
|
||||
// Only the two gift wrap subscriptions carry NIP-59 wraps for this account.
|
||||
if event.kind == Kind::GiftWrap && chat_sub && device_sub {
|
||||
continue;
|
||||
}
|
||||
|
||||
RelayMessage::Event { event, .. } => {
|
||||
// Prune the dedup set before it grows unbounded
|
||||
if processed_events.len() >= MAX_PROCESSED {
|
||||
processed_events.clear();
|
||||
@@ -253,8 +266,10 @@ impl ChatRegistry {
|
||||
|
||||
// Handle msg relays event to determine when the app is ready to subscribe
|
||||
if event.kind == Kind::InboxRelays {
|
||||
let current_user = signer.get_public_key_async().await?;
|
||||
if event.pubkey == current_user {
|
||||
if user_key.is_none() {
|
||||
user_key = signer.get_public_key_async().await.ok();
|
||||
}
|
||||
if Some(event.pubkey) == user_key {
|
||||
tx.send_async(Signal::InboxReady).await?;
|
||||
}
|
||||
}
|
||||
@@ -270,15 +285,39 @@ impl ChatRegistry {
|
||||
seen.entry(event.id).or_default().insert(relay_url);
|
||||
}
|
||||
|
||||
// Route to the signer matching the recipient tag
|
||||
// so a wrap is never decrypted twice.
|
||||
let recipient = event.tags.public_keys().next();
|
||||
let route = match recipient {
|
||||
Some(key) if Some(key) == device_key => UnwrapRoute::Device,
|
||||
Some(key) if Some(key) == user_key => UnwrapRoute::User,
|
||||
Some(_) if user_key.is_some() && device_key.is_some() => continue,
|
||||
_ => UnwrapRoute::Both,
|
||||
};
|
||||
|
||||
// Extract the rumor from the gift wrap event
|
||||
match extract_rumor(
|
||||
&client,
|
||||
&signer,
|
||||
device_signer.as_ref(),
|
||||
event.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let result = match route {
|
||||
UnwrapRoute::User => {
|
||||
extract_rumor(&client, &signer, None, event.as_ref()).await
|
||||
}
|
||||
UnwrapRoute::Device => match device_signer.as_ref() {
|
||||
Some(device) => {
|
||||
extract_rumor(&client, device, None, event.as_ref()).await
|
||||
}
|
||||
None => continue,
|
||||
},
|
||||
UnwrapRoute::Both => {
|
||||
extract_rumor(
|
||||
&client,
|
||||
&signer,
|
||||
device_signer.as_ref(),
|
||||
event.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(rumor) => {
|
||||
let Some(rumor_id) = rumor.id else {
|
||||
log::error!("Rumor missing id after ensure_id");
|
||||
@@ -305,9 +344,10 @@ impl ChatRegistry {
|
||||
}
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(id)
|
||||
if (id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2) =>
|
||||
{
|
||||
RelayMessage::EndOfStoredEvents {
|
||||
subscription_id: id,
|
||||
..
|
||||
} if (id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2) => {
|
||||
tx.send_async(Signal::Eose).await?;
|
||||
}
|
||||
_ => {}
|
||||
@@ -317,35 +357,57 @@ impl ChatRegistry {
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
|
||||
while let Ok(message) = rx.recv_async().await {
|
||||
match message {
|
||||
Signal::Message(message) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.new_message(message, cx);
|
||||
})?;
|
||||
}
|
||||
Signal::InboxReady => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_messages(cx);
|
||||
})?;
|
||||
}
|
||||
Signal::Eose => {
|
||||
this.update(cx, |this, _cx| {
|
||||
this.tracking.store(false, Ordering::Release);
|
||||
})?;
|
||||
let tracking = self.tracking.clone();
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
})?;
|
||||
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
|
||||
while let Ok(first) = rx.recv_async().await {
|
||||
// Batch whatever has already arrived so the UI updates once per burst
|
||||
let mut messages = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let mut inbox_ready = false;
|
||||
let mut eose = false;
|
||||
|
||||
let mut pending = Some(first);
|
||||
while let Some(signal) = pending.take() {
|
||||
match signal {
|
||||
Signal::Message(message) => messages.push(message),
|
||||
Signal::InboxReady => inbox_ready = true,
|
||||
Signal::Eose => eose = true,
|
||||
Signal::Error(failed) => errors.push(failed),
|
||||
}
|
||||
Signal::Error(failed) => {
|
||||
trash.update(cx, |this, cx| {
|
||||
this.insert(failed);
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
if messages.len() + errors.len() >= MAX_SIGNAL_BATCH {
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
pending = rx.try_recv().ok();
|
||||
}
|
||||
|
||||
if inbox_ready {
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_messages(cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
if !messages.is_empty() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.new_messages(messages, cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
trash.update(cx, |trash, cx| {
|
||||
trash.extend(errors);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
|
||||
if eose && tracking.swap(false, Ordering::AcqRel) {
|
||||
this.update(cx, |this, cx| {
|
||||
cx.emit(ChatEvent::Loaded);
|
||||
this.get_rooms(cx);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -669,6 +731,7 @@ impl ChatRegistry {
|
||||
self.rooms.clear();
|
||||
self.room_index.clear();
|
||||
self.search.write().unwrap().clear();
|
||||
self.tracking.store(true, Ordering::Release);
|
||||
self.trash.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
@@ -748,6 +811,7 @@ impl ChatRegistry {
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let search = self.search.clone();
|
||||
let executor = cx.background_executor().clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
@@ -775,7 +839,15 @@ impl ChatRegistry {
|
||||
let events = client.database().query(filter).await?;
|
||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||
|
||||
// Keep the single-threaded web event loop responsive during large rescans
|
||||
let mut last_yield = Instant::now();
|
||||
|
||||
for raw in events.into_iter() {
|
||||
if last_yield.elapsed() >= Duration::from_millis(8) {
|
||||
executor.timer(Duration::from_millis(0)).await;
|
||||
last_yield = Instant::now();
|
||||
}
|
||||
|
||||
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
|
||||
&& rumor.tags.public_keys().next().is_some()
|
||||
{
|
||||
@@ -811,12 +883,17 @@ impl ChatRegistry {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a nostr event into a message and push it to the belonging room
|
||||
/// Parse nostr events into messages and push them to the belonging rooms
|
||||
///
|
||||
/// - If the room doesn't exist, it will be created.
|
||||
/// - Updates room ordering based on the most recent messages.
|
||||
pub(crate) fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
self.search.write().unwrap().insert(&message.rumor);
|
||||
/// - If a room doesn't exist, it will be created.
|
||||
/// - Room ordering is updated once after the whole batch.
|
||||
pub(crate) fn new_messages(&mut self, messages: Vec<NewMessage>, cx: &mut Context<Self>) {
|
||||
{
|
||||
let mut search = self.search.write().unwrap();
|
||||
for message in &messages {
|
||||
search.insert(&message.rumor);
|
||||
}
|
||||
}
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
@@ -824,37 +901,42 @@ impl ChatRegistry {
|
||||
return;
|
||||
};
|
||||
|
||||
match self.room_index.get(&message.room).cloned() {
|
||||
Some(room) => {
|
||||
room.update(cx, |this, cx| {
|
||||
if this.kind == RoomKind::Request
|
||||
&& (message.rumor.pubkey == public_key || this.auto_trusted(public_key, cx))
|
||||
{
|
||||
this.set_ongoing(cx);
|
||||
}
|
||||
this.push_message(message, cx);
|
||||
});
|
||||
self.sort(cx);
|
||||
}
|
||||
None => {
|
||||
// Push the new room to the front of the list
|
||||
self.add_room(message.rumor, cx);
|
||||
for message in messages {
|
||||
match self.room_index.get(&message.room).cloned() {
|
||||
Some(room) => {
|
||||
room.update(cx, |this, cx| {
|
||||
if this.kind == RoomKind::Request
|
||||
&& (message.rumor.pubkey == public_key
|
||||
|| this.auto_trusted(public_key, cx))
|
||||
{
|
||||
this.set_ongoing(cx);
|
||||
}
|
||||
this.push_message(message, cx);
|
||||
});
|
||||
}
|
||||
None => {
|
||||
// Push the new room to the front of the list
|
||||
self.add_room(message.rumor, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.sort(cx);
|
||||
}
|
||||
|
||||
/// Parse a nostr event into a message and push it to the belonging room
|
||||
pub(crate) fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
self.new_messages(vec![message], cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unwraps a gift-wrapped event and processes its contents.
|
||||
async fn extract_rumor<S, D>(
|
||||
async fn extract_rumor(
|
||||
client: &Client,
|
||||
signer: &S,
|
||||
device_signer: Option<&D>,
|
||||
signer: &UniversalSigner,
|
||||
device_signer: Option<&UniversalSigner>,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnsignedEvent, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
D: AsyncNip44 + ?Sized,
|
||||
{
|
||||
) -> Result<UnsignedEvent, Error> {
|
||||
// Try to get cached rumor first
|
||||
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
|
||||
return Ok(rumor);
|
||||
@@ -866,7 +948,11 @@ where
|
||||
let Some(device_signer) = device_signer else {
|
||||
return Err(error);
|
||||
};
|
||||
try_unwrap_with(device_signer, gift_wrap).await?
|
||||
match try_unwrap_with(device_signer, gift_wrap).await {
|
||||
Ok(unwrapped) => unwrapped,
|
||||
// The wrap may be sealed to a key retired by a rotation
|
||||
Err(_) => try_unwrap_archived(client, signer, gift_wrap).await?,
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut rumor = unwrapped.rumor;
|
||||
@@ -888,10 +974,10 @@ where
|
||||
}
|
||||
|
||||
/// Attempts to unwrap a gift wrap event with a given signer.
|
||||
async fn try_unwrap_with<S>(signer: &S, gift_wrap: &Event) -> Result<UnwrappedGift, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
async fn try_unwrap_with(
|
||||
signer: &UniversalSigner,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnwrappedGift, Error> {
|
||||
// Get the sealed event
|
||||
let seal = signer
|
||||
.nip44_decrypt_async(&gift_wrap.pubkey, &gift_wrap.content)
|
||||
@@ -922,21 +1008,50 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
/// Unwrap with an encryption key retired by a rotation; peers may still seal
|
||||
/// to the previous key until their announcement cache refreshes
|
||||
async fn try_unwrap_archived(
|
||||
client: &Client,
|
||||
signer: &UniversalSigner,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnwrappedGift, Error> {
|
||||
for keys in DeviceRegistry::archived_keys(client, signer).await? {
|
||||
let archived = UniversalSigner::new(keys);
|
||||
if let Ok(unwrapped) = try_unwrap_with(&archived, gift_wrap).await {
|
||||
return Ok(unwrapped);
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"No archived encryption key could unwrap the gift wrap"
|
||||
))
|
||||
}
|
||||
|
||||
/// Stores an unwrapped event in local database with reference to original
|
||||
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
|
||||
let room_id = rumor.uniq_id().to_string();
|
||||
|
||||
let tags = vec![
|
||||
let tags = Tags::from_list(vec![
|
||||
Tag::identifier(id),
|
||||
Tag::public_key(rumor.pubkey),
|
||||
Tag::custom("r", [room_id]),
|
||||
Tag::custom("k", [rumor.kind.to_string()]),
|
||||
];
|
||||
]);
|
||||
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
|
||||
.tags(tags)
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
let public_key = LOCAL_KEYS.public_key();
|
||||
let created_at = Timestamp::now();
|
||||
let kind = Kind::ApplicationSpecificData;
|
||||
let content = rumor.as_json();
|
||||
|
||||
let event = Event::new(
|
||||
EventId::compute(&public_key, &created_at, &kind, &tags, &content),
|
||||
public_key,
|
||||
created_at,
|
||||
kind,
|
||||
tags,
|
||||
content,
|
||||
Signature::from_byte_array([0u8; 64]),
|
||||
);
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
use std::hash::Hash;
|
||||
use std::ops::Range;
|
||||
|
||||
use common::{EventExt, NostrParser, emojis_from_tags, extract_and_remove_media_urls};
|
||||
use common::{EventExt, NostrParser, emojis_from_tags, extract_and_remove_media_urls, nevent_refs};
|
||||
use gpui::{SharedString, SharedUri};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::FileAttachment;
|
||||
|
||||
pub(crate) const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
||||
|
||||
const SCHEME_WITH_COLON: &str = "nostr:";
|
||||
const NEVENT_PREFIX: &str = "nevent1";
|
||||
|
||||
/// Rendered message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Message {
|
||||
@@ -248,61 +245,6 @@ fn extract_mentions(content: &str) -> Vec<Mention> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A `nevent` reference found in a content string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NeventRef {
|
||||
/// Range of the reference, including the optional `nostr:` scheme
|
||||
pub range: Range<usize>,
|
||||
/// Compact display label, like `https://njump.me/nevent1...d9sp`
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// Extracts valid `nevent1...` and `nostr:nevent1...` references from content.
|
||||
pub fn nevent_refs(content: &str) -> Vec<NeventRef> {
|
||||
let mut references = Vec::new();
|
||||
|
||||
for (match_start, _) in content.match_indices(NEVENT_PREFIX) {
|
||||
// Only lowercase references are matched, matching NIP-19 encodings in practice
|
||||
let start = if content[..match_start].ends_with(SCHEME_WITH_COLON) {
|
||||
match_start - SCHEME_WITH_COLON.len()
|
||||
} else {
|
||||
match_start
|
||||
};
|
||||
|
||||
// Skip matches glued to word characters, like the `nevent1` in `xnevent1...`
|
||||
if content[..start].ends_with(char::is_alphanumeric) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let end = match_start
|
||||
+ content[match_start..]
|
||||
.bytes()
|
||||
.take_while(u8::is_ascii_alphanumeric)
|
||||
.count();
|
||||
|
||||
// The bech32 token excludes the optional `nostr:` scheme
|
||||
let bech32 = &content[match_start..end];
|
||||
if Nip19Event::from_bech32(bech32).is_err() {
|
||||
continue;
|
||||
}
|
||||
|
||||
references.push(NeventRef {
|
||||
range: start..end,
|
||||
label: nevent_label(bech32),
|
||||
});
|
||||
}
|
||||
|
||||
references
|
||||
}
|
||||
|
||||
/// Compact display of a nevent link, like `https://njump.me/nevent1...d9sp`.
|
||||
fn nevent_label(bech32: &str) -> String {
|
||||
match bech32.len() {
|
||||
0..=4 => format!("https://njump.me/{bech32}"),
|
||||
_ => format!("https://njump.me/nevent1...{}", &bech32[bech32.len() - 4..]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts all reply (ids) from the event tags.
|
||||
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
||||
let mut replies_to = vec![];
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
|
||||
|
||||
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
|
||||
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key yet. Set one up in the settings to message them.";
|
||||
const KEY_STALE: &str = "Your encryption key is out of date; sync it from another device in the settings to keep messaging.";
|
||||
|
||||
/// The outcome of delivering an encrypted rumor to a single receiver.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -553,6 +554,7 @@ impl Room {
|
||||
let config = self.config.clone();
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let resyncing = device.read(cx).is_resyncing();
|
||||
let encryption_signer = device.read(cx).signer(cx);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -574,6 +576,28 @@ impl Room {
|
||||
Some(cx.background_spawn(async move {
|
||||
let signer_kind = config.signer_kind();
|
||||
|
||||
// Block sends while the local encryption key is stale against the
|
||||
// current announcement; the replacement is being synced from
|
||||
// another device
|
||||
if resyncing && matches!(signer_kind, SignerKind::Auto | SignerKind::Encryption) {
|
||||
let mut reports: Vec<SendReport> = receivers
|
||||
.into_iter()
|
||||
.map(|receiver| SendReport {
|
||||
receiver: receiver.public_key(),
|
||||
result: Err(KEY_STALE.into()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
if config.backup() {
|
||||
reports.push(SendReport {
|
||||
receiver: sender.public_key(),
|
||||
result: Err(KEY_STALE.into()),
|
||||
});
|
||||
}
|
||||
|
||||
return reports;
|
||||
}
|
||||
|
||||
// Deliver to every receiver concurrently
|
||||
let sends = receivers.into_iter().map(|receiver| {
|
||||
deliver(
|
||||
|
||||
@@ -6,14 +6,17 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
state = { path = "../state" }
|
||||
speech = { path = "../speech" }
|
||||
ui = { path = "../ui" }
|
||||
gpui-kit.workspace = true
|
||||
gpui-kit = { workspace = true, features = ["speech"] }
|
||||
common = { path = "../common" }
|
||||
log.workspace = true
|
||||
person = { path = "../person" }
|
||||
chat = { path = "../chat" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
instant.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
@@ -21,3 +24,6 @@ smallvec.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
regex = "1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
nip60_ui = { path = "../nip60_ui" }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use gpui::Action;
|
||||
use gpui::{Action, actions};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use settings::SignerKind;
|
||||
@@ -15,3 +15,6 @@ pub enum Command {
|
||||
Njump(PublicKey),
|
||||
Trace(EventId),
|
||||
}
|
||||
|
||||
// Start or stop dictation in the focused composer
|
||||
actions!(chat, [ToggleDictation]);
|
||||
|
||||
@@ -1,32 +1,70 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chat::FileAttachment;
|
||||
use anyhow::Error;
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::{FileAttachment, LocalFile, PickedFile};
|
||||
|
||||
/// A file attachment that has been uploaded, but not sent yet.
|
||||
///
|
||||
/// The local `path` is kept around so the composer can preview
|
||||
/// The `local` handle is kept around so the composer can preview
|
||||
/// the file without downloading and decrypting it again.
|
||||
pub(crate) struct PendingFile {
|
||||
pub file: FileAttachment,
|
||||
pub path: PathBuf,
|
||||
pub local: LocalFile,
|
||||
}
|
||||
|
||||
/// State of the encrypted file attachment of a message
|
||||
pub(crate) enum DecryptedFile {
|
||||
Loading,
|
||||
Ready(PathBuf),
|
||||
Ready(LocalFile),
|
||||
Failed(SharedString),
|
||||
}
|
||||
|
||||
/// Result of an upload, either plain or encrypted
|
||||
pub(crate) enum Uploaded {
|
||||
Url(Url),
|
||||
File(FileAttachment, PathBuf),
|
||||
File(Box<UploadedFile>),
|
||||
}
|
||||
|
||||
/// A `file://` url for a decrypted file, so it can be opened by the OS
|
||||
pub(crate) fn file_url(path: &Path) -> String {
|
||||
format!("file://{}", path.display())
|
||||
/// An encrypted upload and the handle to preview it locally
|
||||
pub(crate) struct UploadedFile {
|
||||
pub file: FileAttachment,
|
||||
pub local: LocalFile,
|
||||
}
|
||||
|
||||
impl Uploaded {
|
||||
/// Build the upload result for a picked file, encrypted when the
|
||||
/// attachment is the whole message
|
||||
pub(crate) async fn from_pick(
|
||||
encrypted: bool,
|
||||
server: Url,
|
||||
picked: PickedFile,
|
||||
cx: &gpui::AsyncApp,
|
||||
) -> Result<Self, Error> {
|
||||
if encrypted {
|
||||
let UploadedFileParts { file, local } = upload_encrypted(server, picked, cx).await?;
|
||||
|
||||
Ok(Self::File(Box::new(UploadedFile { file, local })))
|
||||
} else {
|
||||
state::upload_file(server, picked, cx).await.map(Self::Url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pieces of an encrypted upload, split so the local handle survives the
|
||||
/// picked file being consumed by the upload
|
||||
struct UploadedFileParts {
|
||||
file: FileAttachment,
|
||||
local: LocalFile,
|
||||
}
|
||||
|
||||
async fn upload_encrypted(
|
||||
server: Url,
|
||||
picked: PickedFile,
|
||||
cx: &gpui::AsyncApp,
|
||||
) -> Result<UploadedFileParts, Error> {
|
||||
let local = picked.local.clone();
|
||||
|
||||
state::upload_encrypted_file(server, picked, cx)
|
||||
.await
|
||||
.map(|file| UploadedFileParts { file, local })
|
||||
}
|
||||
|
||||
@@ -16,3 +16,7 @@ futures.workspace = true
|
||||
|
||||
dirs = "5.0"
|
||||
regex = "1.10"
|
||||
anyhow.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
web-sys = { version = "0.3", features = ["Window", "Storage"] }
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
pub use debounced_delay::*;
|
||||
pub use display::*;
|
||||
pub use event::*;
|
||||
pub use media_extractor::*;
|
||||
pub use parser::*;
|
||||
pub use paths::*;
|
||||
|
||||
mod debounced_delay;
|
||||
mod display;
|
||||
mod event;
|
||||
mod media_extractor;
|
||||
mod parser;
|
||||
mod paths;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod web_store;
|
||||
|
||||
pub use debounced_delay::*;
|
||||
pub use display::*;
|
||||
pub use event::*;
|
||||
pub use media_extractor::*;
|
||||
pub use parser::*;
|
||||
pub use paths::*;
|
||||
|
||||
@@ -4,6 +4,62 @@ use nostr::prelude::*;
|
||||
|
||||
const BECH32_SEPARATOR: u8 = b'1';
|
||||
const SCHEME_WITH_COLON: &str = "nostr:";
|
||||
const NEVENT_PREFIX: &str = "nevent1";
|
||||
|
||||
/// A `nevent` reference found in a content string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NeventRef {
|
||||
/// Range of the reference, including the optional `nostr:` scheme
|
||||
pub range: Range<usize>,
|
||||
/// Compact display label, like `https://njump.me/nevent1...d9sp`
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// Extracts valid `nevent1...` and `nostr:nevent1...` references from content.
|
||||
pub fn nevent_refs(content: &str) -> Vec<NeventRef> {
|
||||
let mut references = Vec::new();
|
||||
|
||||
for (match_start, _) in content.match_indices(NEVENT_PREFIX) {
|
||||
// Only lowercase references are matched, matching NIP-19 encodings in practice
|
||||
let start = if content[..match_start].ends_with(SCHEME_WITH_COLON) {
|
||||
match_start - SCHEME_WITH_COLON.len()
|
||||
} else {
|
||||
match_start
|
||||
};
|
||||
|
||||
// Skip matches glued to word characters, like the `nevent1` in `xnevent1...`
|
||||
if content[..start].ends_with(char::is_alphanumeric) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let end = match_start
|
||||
+ content[match_start..]
|
||||
.bytes()
|
||||
.take_while(u8::is_ascii_alphanumeric)
|
||||
.count();
|
||||
|
||||
// The bech32 token excludes the optional `nostr:` scheme
|
||||
let bech32 = &content[match_start..end];
|
||||
if Nip19Event::from_bech32(bech32).is_err() {
|
||||
continue;
|
||||
}
|
||||
|
||||
references.push(NeventRef {
|
||||
range: start..end,
|
||||
label: nevent_label(bech32),
|
||||
});
|
||||
}
|
||||
|
||||
references
|
||||
}
|
||||
|
||||
/// Compact display of a nevent link, like `https://njump.me/nevent1...d9sp`.
|
||||
fn nevent_label(bech32: &str) -> String {
|
||||
match bech32.len() {
|
||||
0..=4 => format!("https://njump.me/{bech32}"),
|
||||
_ => format!("https://njump.me/nevent1...{}", &bech32[bech32.len() - 4..]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nostr parsed token with its range in the original text
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use anyhow::{Error, anyhow};
|
||||
|
||||
const LOCAL_STORAGE: &str = "localStorage";
|
||||
|
||||
fn storage() -> Result<web_sys::Storage, Error> {
|
||||
web_sys::window()
|
||||
.ok_or_else(|| anyhow!("no browser window"))?
|
||||
.local_storage()
|
||||
.map_err(|e| anyhow!("{LOCAL_STORAGE} unavailable: {e:?}"))?
|
||||
.ok_or_else(|| anyhow!("{LOCAL_STORAGE} unavailable"))
|
||||
}
|
||||
|
||||
/// Read a value previously written with [`set`]
|
||||
pub fn get(key: &str) -> Result<Option<String>, Error> {
|
||||
storage()?
|
||||
.get_item(key)
|
||||
.map_err(|e| anyhow!("{LOCAL_STORAGE} read failed: {e:?}"))
|
||||
}
|
||||
|
||||
/// Write a value, replacing any previous one
|
||||
pub fn set(key: &str, value: &str) -> Result<(), Error> {
|
||||
storage()?
|
||||
.set_item(key, value)
|
||||
.map_err(|e| anyhow!("{LOCAL_STORAGE} write failed: {e:?}"))
|
||||
}
|
||||
|
||||
/// Remove a value if it exists
|
||||
pub fn remove(key: &str) -> Result<(), Error> {
|
||||
storage()?
|
||||
.remove_item(key)
|
||||
.map_err(|e| anyhow!("{LOCAL_STORAGE} delete failed: {e:?}"))
|
||||
}
|
||||
@@ -1208,7 +1208,10 @@ impl GroupsRegistry {
|
||||
tx.send_async(signal).await.ok();
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(id) if is_own_subscription(&id) => {
|
||||
RelayMessage::EndOfStoredEvents {
|
||||
subscription_id: id,
|
||||
..
|
||||
} if is_own_subscription(&id) => {
|
||||
tx.send_async(Signal::Eose(id.into_owned())).await.ok();
|
||||
}
|
||||
RelayMessage::Closed {
|
||||
@@ -1222,7 +1225,7 @@ impl GroupsRegistry {
|
||||
RelayMessage::NegMsg { .. } => {
|
||||
tx.send_async(Signal::Syncing(relay_url)).await.ok();
|
||||
}
|
||||
RelayMessage::NegErr { .. } | RelayMessage::EndOfStoredEvents(_) => {
|
||||
RelayMessage::NegErr { .. } | RelayMessage::EndOfStoredEvents { .. } => {
|
||||
tx.send_async(Signal::Synced(relay_url)).await.ok();
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -11,11 +11,13 @@ common = { path = "../common" }
|
||||
nip29 = { path = "../nip29" }
|
||||
person = { path = "../person" }
|
||||
settings = { path = "../settings" }
|
||||
speech = { path = "../speech" }
|
||||
state = { path = "../state" }
|
||||
gpui-kit.workspace = true
|
||||
gpui-kit = { workspace = true, features = ["speech"] }
|
||||
ui = { path = "../ui" }
|
||||
|
||||
gpui.workspace = true
|
||||
instant.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
use gpui::{
|
||||
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px,
|
||||
relative,
|
||||
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div,
|
||||
rems,
|
||||
};
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::description_list::{DescriptionItem, DescriptionList};
|
||||
use gpui_kit::component::tag::Tag;
|
||||
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
|
||||
use nip29::{Group, GroupMetadata};
|
||||
use ui::Avatar;
|
||||
|
||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
this.max_h(px(600.))
|
||||
this.max_h(rems(37.5))
|
||||
.close_button(true)
|
||||
.title("About Group")
|
||||
.title("About group")
|
||||
.child(body(&group, cx))
|
||||
});
|
||||
}
|
||||
@@ -22,6 +25,7 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
|
||||
|
||||
let group = entity.read(cx);
|
||||
let mut body = v_flex().gap_3().text_xs().child(header(group, cx));
|
||||
let mut items = Vec::new();
|
||||
|
||||
if let Some(metadata) = group.metadata() {
|
||||
if let Some(about) = metadata.about.as_ref() {
|
||||
@@ -35,86 +39,100 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
|
||||
let flags = flags(metadata);
|
||||
|
||||
if !flags.is_empty() {
|
||||
body = body.child(section(
|
||||
"Flags",
|
||||
wrap(flags.into_iter().map(|f| chip(f, cx))),
|
||||
cx,
|
||||
));
|
||||
items.push(
|
||||
DescriptionItem::new("Flags").value(wrap(
|
||||
flags
|
||||
.into_iter()
|
||||
.map(|flag| Tag::secondary().child(flag).into_any_element()),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(parent) = metadata.parent.as_ref() {
|
||||
body = body.child(section(
|
||||
"Parent",
|
||||
div()
|
||||
.child(SharedString::from(parent.as_str().to_owned()))
|
||||
.into_any_element(),
|
||||
cx,
|
||||
));
|
||||
items.push(
|
||||
DescriptionItem::new("Parent").value(
|
||||
div()
|
||||
.child(SharedString::from(parent.as_str().to_owned()))
|
||||
.into_any_element(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if !metadata.children.is_empty() {
|
||||
body = body.child(section(
|
||||
"Children",
|
||||
wrap(
|
||||
metadata
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| chip(child.as_str().to_owned(), cx)),
|
||||
),
|
||||
cx,
|
||||
));
|
||||
items.push(
|
||||
DescriptionItem::new("Children").value(wrap(metadata.children.iter().map(
|
||||
|child| {
|
||||
Tag::secondary()
|
||||
.child(SharedString::from(child.as_str().to_owned()))
|
||||
.into_any_element()
|
||||
},
|
||||
))),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(kinds) = metadata.supported_kinds.as_ref() {
|
||||
let items: Vec<AnyElement> = kinds
|
||||
let values: Vec<AnyElement> = kinds
|
||||
.iter()
|
||||
.map(|kind| chip(kind.to_string(), cx))
|
||||
.map(|kind| {
|
||||
Tag::secondary()
|
||||
.child(SharedString::from(kind.to_string()))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect();
|
||||
|
||||
body = body.child(section(
|
||||
"Supported kinds",
|
||||
if items.is_empty() {
|
||||
items.push(
|
||||
DescriptionItem::new("Supported kinds").value(if values.is_empty() {
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("None")
|
||||
.into_any_element()
|
||||
} else {
|
||||
wrap(items)
|
||||
},
|
||||
cx,
|
||||
));
|
||||
wrap(values)
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let roles = group.roles().roles();
|
||||
if !roles.is_empty() {
|
||||
body = body.child(section(
|
||||
"Roles",
|
||||
wrap(roles.iter().map(|role| chip(role.name.clone(), cx))),
|
||||
cx,
|
||||
));
|
||||
items.push(
|
||||
DescriptionItem::new("Roles").value(wrap(roles.iter().map(|role| {
|
||||
Tag::secondary()
|
||||
.child(SharedString::from(role.name.clone()))
|
||||
.into_any_element()
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
let pins = group.pins().pins();
|
||||
if !pins.is_empty() {
|
||||
body = body.child(section(
|
||||
"Pinned",
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.children(
|
||||
pins.iter()
|
||||
.map(|pin| {
|
||||
div()
|
||||
.truncate()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(crate::util::pin_label(group, pin))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_any_element(),
|
||||
cx,
|
||||
));
|
||||
items.push(
|
||||
DescriptionItem::new("Pinned").value(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.children(
|
||||
pins.iter()
|
||||
.map(|pin| {
|
||||
div()
|
||||
.truncate()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(crate::util::pin_label(group, pin))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if !items.is_empty() {
|
||||
body = body.child(
|
||||
DescriptionList::vertical()
|
||||
.bordered(false)
|
||||
.columns(1)
|
||||
.children(items),
|
||||
);
|
||||
}
|
||||
|
||||
body.into_any_element()
|
||||
@@ -137,7 +155,7 @@ fn header(group: &Group, cx: &App) -> AnyElement {
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.font_semibold()
|
||||
.truncate()
|
||||
.child(group.display_name()),
|
||||
)
|
||||
@@ -156,20 +174,6 @@ fn header(group: &Group, cx: &App) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn section(label: &'static str, content: AnyElement, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::MEDIUM)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(label),
|
||||
)
|
||||
.child(content)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
|
||||
h_flex()
|
||||
.flex_wrap()
|
||||
@@ -178,19 +182,6 @@ fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn chip(label: impl Into<SharedString>, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().secondary)
|
||||
.text_color(cx.theme().foreground)
|
||||
.text_xs()
|
||||
.line_height(relative(1.))
|
||||
.child(label.into())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn flags(metadata: &GroupMetadata) -> Vec<&'static str> {
|
||||
let mut flags = Vec::new();
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, ClipboardItem, Entity, IntoElement, ParentElement, SharedString, Styled,
|
||||
WeakEntity, Window, div, px,
|
||||
WeakEntity, Window, div, px, rems,
|
||||
};
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::input::InputState;
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
|
||||
@@ -24,7 +25,7 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
||||
});
|
||||
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
this.max_h(px(600.))
|
||||
this.max_h(rems(37.5))
|
||||
.close_button(true)
|
||||
.title("Invites")
|
||||
.child(body(group.clone(), input.clone(), cx))
|
||||
@@ -126,19 +127,52 @@ fn copy_button(group: &WeakEntity<Group>, code: &str) -> Button {
|
||||
|
||||
fn revoke_button(group: &WeakEntity<Group>, id: nostr_sdk::prelude::EventId, code: &str) -> Button {
|
||||
let group = group.clone();
|
||||
let code = code.to_owned();
|
||||
|
||||
Button::new(SharedString::from(format!("invite-revoke-{code}")))
|
||||
.label("Revoke")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(move |_event, window, cx| {
|
||||
match group.update(cx, |group, cx| group.delete_event(id, cx)) {
|
||||
Ok(task) => report(window, cx, task),
|
||||
Err(error) => log::warn!("nip29: revoking an invite failed: {error}"),
|
||||
}
|
||||
confirm_revoke(group.clone(), id, code.clone(), window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
/// Confirm revoking an invite, then delete it and surface any relay error.
|
||||
fn confirm_revoke(
|
||||
group: WeakEntity<Group>,
|
||||
id: nostr_sdk::prelude::EventId,
|
||||
code: String,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
window.open_alert_dialog(cx, move |this, _window, _cx| {
|
||||
let group = group.clone();
|
||||
|
||||
this.confirm()
|
||||
.width(px(360.))
|
||||
.title(format!("Revoke invite {code}?"))
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Revoke")
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.child("Nobody will be able to ask to join with this code."),
|
||||
)
|
||||
.on_ok(move |_event, window, cx| {
|
||||
match group.update(cx, |group, cx| group.delete_event(id, cx)) {
|
||||
Ok(task) => report(window, cx, task),
|
||||
Err(error) => log::warn!("nip29: revoking an invite failed: {error}"),
|
||||
}
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn create_row(group: WeakEntity<Group>, input: Entity<InputState>, cx: &App) -> impl IntoElement {
|
||||
h_flex()
|
||||
.items_end()
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use chat_ui::message::WelcomeMessage;
|
||||
use chat_ui::text::{RenderedText, rendered_text};
|
||||
use chat_ui::{Command, ToggleDictation};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
|
||||
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div,
|
||||
list, px,
|
||||
Action, Anchor, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, FollowMode, InteractiveElement, IntoElement, ListAlignment,
|
||||
ListScrollEvent, ListState, ParentElement, Render, SharedString, Styled, Subscription, Task,
|
||||
WeakEntity, Window, div, list, px, rems,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||
use gpui_kit::component::input::{InputEvent, Textarea, TextareaState};
|
||||
use gpui_kit::component::menu::DropdownMenu;
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::scroll::Scrollbar;
|
||||
use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
||||
use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row};
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -24,8 +27,9 @@ use person::Directory;
|
||||
use serde::Deserialize;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{NostrRegistry, upload};
|
||||
use ui::Avatar;
|
||||
use speech::speaker::{END_GRACE, Speaker};
|
||||
use state::{NostrRegistry, pick_file, upload_file};
|
||||
use ui::{Avatar, REACTION_EMOJIS, RenderedText, RevealText, WelcomeMessage, rendered_text};
|
||||
use util::{attachment_name, display_name, opens_run, pin_label, report};
|
||||
|
||||
mod details;
|
||||
@@ -163,7 +167,7 @@ pub fn actions(group: WeakEntity<Group>, cx: &App) -> AnyElement {
|
||||
)
|
||||
.child(
|
||||
Button::new("group-delete")
|
||||
.icon(IconName::CircleX)
|
||||
.icon(IconName::Trash)
|
||||
.tooltip("Delete group")
|
||||
.ghost()
|
||||
.small()
|
||||
@@ -257,17 +261,43 @@ pub fn confirm_delete(group: WeakEntity<Group>, window: &mut Window, cx: &mut Ap
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove a member from the group and surface any relay error as a notification.
|
||||
pub fn remove_member(
|
||||
/// Confirm removing a member, then remove them and surface any relay error.
|
||||
pub fn confirm_remove_member(
|
||||
group: WeakEntity<Group>,
|
||||
public_key: PublicKey,
|
||||
member: SharedString,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
match group.update(cx, |group, cx| group.remove_user(public_key, cx)) {
|
||||
Ok(task) => report(window, cx, task),
|
||||
Err(error) => log::warn!("nip29: removing a group member failed: {error}"),
|
||||
}
|
||||
let Some(group_name) = group.read_with(cx, |group, _cx| group.display_name()).ok() else {
|
||||
return;
|
||||
};
|
||||
|
||||
window.open_alert_dialog(cx, move |this, _window, _cx| {
|
||||
let group = group.clone();
|
||||
|
||||
this.confirm()
|
||||
.width(px(360.))
|
||||
.title(format!("Remove {member} from {group_name}?"))
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Remove")
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.child("The relay will remove them from this group."),
|
||||
)
|
||||
.on_ok(move |_event, window, cx| {
|
||||
match group.update(cx, |group, cx| group.remove_user(public_key, cx)) {
|
||||
Ok(task) => report(window, cx, task),
|
||||
Err(error) => log::warn!("nip29: removing a group member failed: {error}"),
|
||||
}
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Grant a member the admin role and surface any relay error as a notification.
|
||||
@@ -314,6 +344,16 @@ pub struct GroupPanel {
|
||||
reply_to: Option<EventId>,
|
||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||
input: Entity<TextareaState>,
|
||||
/// Dictation session for the composer input
|
||||
speech: Entity<SpeechState>,
|
||||
/// When the running dictation started, for the elapsed counter
|
||||
speech_started: Option<instant::Instant>,
|
||||
/// Reads messages aloud
|
||||
speaker: Speaker,
|
||||
/// The message being revealed in step with speech, with its estimated duration
|
||||
speech_reveal: Option<(EventId, Duration)>,
|
||||
/// Fallback reveal end for backends that cannot report utterance end
|
||||
reveal_timeout: Option<Task<Result<()>>>,
|
||||
/// Uploaded, non-encrypted attachments waiting to be sent.
|
||||
attachments: Vec<Url>,
|
||||
/// Whether an upload is in flight.
|
||||
@@ -321,7 +361,7 @@ pub struct GroupPanel {
|
||||
/// Whether the user closed the encryption notice this visit
|
||||
notice_dismissed: bool,
|
||||
tasks: SmallVec<[Task<Result<()>>; 4]>,
|
||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||
}
|
||||
|
||||
impl GroupPanel {
|
||||
@@ -340,6 +380,18 @@ impl GroupPanel {
|
||||
.clean_on_escape()
|
||||
});
|
||||
|
||||
let speech = cx.new(|cx| {
|
||||
let state = SpeechState::new(cx)
|
||||
.recognizer(speech::recognizer())
|
||||
// Batch transcription of a long recording can take a while.
|
||||
.stop_timeout(Duration::from_secs(180));
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let state = state.input(speech::input());
|
||||
|
||||
state
|
||||
});
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
@@ -350,6 +402,34 @@ impl GroupPanel {
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&speech, window, |this, _, event, window, cx| {
|
||||
match event {
|
||||
SpeechEvent::Started => {
|
||||
this.speech_started = Some(instant::Instant::now());
|
||||
cx.notify();
|
||||
}
|
||||
SpeechEvent::Final(text) => {
|
||||
this.speech_started = None;
|
||||
|
||||
if !text.is_empty() {
|
||||
this.input.update(cx, |input, cx| {
|
||||
input.insert(text.clone(), window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
SpeechEvent::Error(error) => {
|
||||
this.speech_started = None;
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
}
|
||||
SpeechEvent::Cancelled => {
|
||||
this.speech_started = None;
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(cx.subscribe(&group, |this, _group, event, cx| {
|
||||
if !matches!(event, GroupEvent::Sent(_)) {
|
||||
this.sync(cx);
|
||||
@@ -373,9 +453,24 @@ impl GroupPanel {
|
||||
}
|
||||
},
|
||||
));
|
||||
this.track_participants(cx);
|
||||
});
|
||||
|
||||
let mut this = Self {
|
||||
// End the speech reveal when the utterance finishes ahead of its estimate
|
||||
let speaker = Speaker::new();
|
||||
let end_events = speaker.end_events();
|
||||
let reveal_end_task = cx.spawn(async move |this, cx| {
|
||||
while end_events.recv_async().await.is_ok() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.speech_reveal = None;
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
group: group.downgrade(),
|
||||
directory: Directory::new(cx),
|
||||
@@ -385,19 +480,19 @@ impl GroupPanel {
|
||||
notice_dismissed: false,
|
||||
rendered_texts_by_id: BTreeMap::new(),
|
||||
input,
|
||||
speech,
|
||||
speech_started: None,
|
||||
speaker,
|
||||
speech_reveal: None,
|
||||
reveal_timeout: None,
|
||||
attachments: Vec::new(),
|
||||
uploading: false,
|
||||
tasks: smallvec![],
|
||||
tasks: smallvec![reveal_end_task],
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
|
||||
this.track_participants(cx);
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/// Track the keys rendered by this panel: members, message authors, and
|
||||
/// activity subjects
|
||||
/// Track the keys rendered by this panel.
|
||||
fn track_participants(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(group) = self.group.upgrade() else {
|
||||
return;
|
||||
@@ -490,6 +585,39 @@ impl GroupPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a message aloud, tracking its reveal state until utterance end.
|
||||
fn speak(
|
||||
&mut self,
|
||||
id: EventId,
|
||||
text: SharedString,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match self.speaker.speak(&text) {
|
||||
Ok(duration) => {
|
||||
self.speech_reveal = Some((id, duration));
|
||||
|
||||
let session = (id, duration);
|
||||
let timer = cx.background_executor().timer(duration + END_GRACE);
|
||||
|
||||
self.reveal_timeout = Some(cx.spawn(async move |this, cx| {
|
||||
timer.await;
|
||||
this.update(cx, |this, cx| {
|
||||
if this.speech_reveal.as_ref() == Some(&session) {
|
||||
this.speech_reveal = None;
|
||||
cx.notify();
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
Err(error) => window.push_notification(Notification::error(error.to_string()), cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_reply(&mut self, id: EventId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.reply_to = Some(id);
|
||||
self.input.update(cx, |input, cx| {
|
||||
@@ -554,22 +682,17 @@ impl GroupPanel {
|
||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let server = AppSettings::get_file_server(cx);
|
||||
|
||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: None,
|
||||
});
|
||||
let picker = pick_file(cx);
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
// Selecting no file means the prompt was cancelled
|
||||
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
||||
let Some(picked) = picker.await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| this.set_uploading(true, cx))?;
|
||||
|
||||
match upload(server, path, cx).await {
|
||||
match upload_file(server, picked, cx).await {
|
||||
Ok(url) => {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.attachments.push(url);
|
||||
@@ -652,18 +775,46 @@ impl GroupPanel {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Ok(delete) = group.update(cx, |group, cx| group.delete_event(id, cx)) else {
|
||||
return;
|
||||
};
|
||||
window.open_alert_dialog(cx, move |this, _window, _cx| {
|
||||
let group = group.clone();
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
if let Err(error) = delete.await {
|
||||
this.update_in(cx, |_this, window, cx| {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
this.confirm()
|
||||
.width(px(360.))
|
||||
.title("Delete message?")
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Delete")
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.child("The relay will delete this message for everyone."),
|
||||
)
|
||||
.on_ok(move |_event, window, cx| {
|
||||
let Ok(delete) = group.update(cx, |group, cx| group.delete_event(id, cx))
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
|
||||
window
|
||||
.spawn(cx, async move |cx| -> Result<()> {
|
||||
if let Err(error) = delete.await {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.detach();
|
||||
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn toggle_pin(
|
||||
@@ -814,16 +965,11 @@ impl GroupPanel {
|
||||
.py_1p5()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.children(pins.iter().map(|pin| {
|
||||
div()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().primary)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(pin_label(group, pin))
|
||||
}))
|
||||
.children(
|
||||
pins.iter().map(|pin| {
|
||||
gpui_kit::component::tag::Tag::secondary().child(pin_label(group, pin))
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -874,7 +1020,7 @@ impl GroupPanel {
|
||||
.child(
|
||||
Button::new("load-older")
|
||||
.label(if loading {
|
||||
"Loading earlier messages…"
|
||||
"Loading earlier messages"
|
||||
} else {
|
||||
"Load earlier messages"
|
||||
})
|
||||
@@ -932,7 +1078,7 @@ impl GroupPanel {
|
||||
row.child("You're in preview mode. Join this group to talk.")
|
||||
.child(
|
||||
Button::new("join")
|
||||
.label("Request to Join")
|
||||
.label("Request to join")
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
@@ -980,19 +1126,33 @@ impl GroupPanel {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let directory = self.directory.clone();
|
||||
|
||||
let content = self
|
||||
.rendered_texts_by_id
|
||||
.entry(message.id)
|
||||
.or_insert_with(|| {
|
||||
rendered_text(
|
||||
&message.content,
|
||||
&message.mentions,
|
||||
&message.emojis,
|
||||
&directory,
|
||||
let content = match self
|
||||
.speech_reveal
|
||||
.as_ref()
|
||||
.filter(|(reveal_id, _)| *reveal_id == message.id)
|
||||
{
|
||||
Some((_, duration)) => RevealText::new(message.preview(), *duration).into_any_element(),
|
||||
None => self
|
||||
.rendered_texts_by_id
|
||||
.entry(message.id)
|
||||
.or_insert_with(|| {
|
||||
rendered_text(
|
||||
&message.content,
|
||||
message
|
||||
.mentions
|
||||
.iter()
|
||||
.map(|mention| (mention.range.clone(), mention.public_key)),
|
||||
&message.emojis,
|
||||
&directory,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.element(
|
||||
SharedString::from(format!("text-{}", message.id.to_hex())).into(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.element(ix.into(), window, cx);
|
||||
),
|
||||
};
|
||||
|
||||
let admin = nostr
|
||||
.read(cx)
|
||||
@@ -1013,13 +1173,41 @@ impl GroupPanel {
|
||||
panel: cx.weak_entity(),
|
||||
admin,
|
||||
pinned,
|
||||
speaking: self.speech_reveal.is_some(),
|
||||
persons: self.directory.clone(),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
fn toggle_dictation(
|
||||
&mut self,
|
||||
_ev: &ToggleDictation,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.speech.update(cx, |speech, cx| {
|
||||
speech.toggle(cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match command {
|
||||
Command::Insert(content) => {
|
||||
self.input.update(cx, |input, cx| {
|
||||
let new_value = format!("{} {}", input.value(), content);
|
||||
input.set_value(new_value, window, cx);
|
||||
});
|
||||
}
|
||||
Command::Copy(public_key) => match public_key.to_bech32() {
|
||||
Ok(content) => cx.write_to_clipboard(ClipboardItem::new_string(content)),
|
||||
Err(error) => log::warn!("nip29: encoding the author key failed: {error}"),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_composer(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let notice = self.group.upgrade().and_then(|group| {
|
||||
let group = group.read(cx);
|
||||
|
||||
@@ -1044,46 +1232,111 @@ impl GroupPanel {
|
||||
return self.render_notice(¬ice, cx);
|
||||
}
|
||||
|
||||
let dictating = self.speech.read(cx).status().is_active();
|
||||
|
||||
v_flex()
|
||||
.flex_shrink_0()
|
||||
.w_full()
|
||||
.p_2()
|
||||
.gap_1()
|
||||
.gap_1p5()
|
||||
.when_some(self.reply_to, |this, target| {
|
||||
this.child(self.render_reply_target(target, cx))
|
||||
})
|
||||
.children(self.render_attachments(cx))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_end()
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(IconName::Upload)
|
||||
.tooltip("Upload file")
|
||||
.icon(IconName::Paperclip)
|
||||
.tooltip("Upload media")
|
||||
.loading(self.uploading)
|
||||
.disabled(self.uploading)
|
||||
.disabled(dictating || self.uploading)
|
||||
.ghost()
|
||||
.large()
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(Textarea::new(&self.input).appearance(false).flex_1())
|
||||
.when(dictating, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.h_8()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.pl_1()
|
||||
.child(SpeechWaveform::new(&self.speech).small().flex_1())
|
||||
.when_some(self.speech_started, |this, started| {
|
||||
this.child(speech::elapsed(started, cx))
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(!dictating, |this| {
|
||||
this.child(
|
||||
Textarea::new(&self.input)
|
||||
.appearance(false)
|
||||
.small()
|
||||
.min_h_8()
|
||||
.py_1()
|
||||
.flex_1(),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new("send")
|
||||
.icon(IconName::SendHorizontal)
|
||||
.tooltip("Send")
|
||||
.ghost()
|
||||
.large()
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.send(window, cx);
|
||||
})),
|
||||
h_flex()
|
||||
.pl_1()
|
||||
.gap_1()
|
||||
.child(SpeechButton::new(&self.speech))
|
||||
.child(self.render_emoji_menu(dictating, window, cx))
|
||||
.child(
|
||||
Button::new("send")
|
||||
.icon(IconName::SendHorizontal)
|
||||
.tooltip("Send")
|
||||
.primary()
|
||||
.disabled(dictating || self.uploading)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.send(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_emoji_menu(
|
||||
&self,
|
||||
disabled: bool,
|
||||
_window: &Window,
|
||||
cx: &Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let current_user = nostr.read(cx).current_user();
|
||||
|
||||
let emojis = current_user
|
||||
.map(|current_user| {
|
||||
self.directory
|
||||
.read(cx)
|
||||
.person(¤t_user, cx)
|
||||
.emojis()
|
||||
.to_vec()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Button::new("emoji")
|
||||
.icon(IconName::FaceGrinning)
|
||||
.tooltip("Emoji")
|
||||
.ghost()
|
||||
.disabled(disabled)
|
||||
.dropdown_menu_with_anchor(Anchor::BottomLeft, move |this, _window, _cx| {
|
||||
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
|
||||
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
|
||||
});
|
||||
|
||||
emojis.iter().fold(menu, |this, (shortcode, _)| {
|
||||
let label = format!(":{shortcode}:");
|
||||
this.menu(label.clone(), Box::new(Command::Insert(label)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn render_reply_target(&self, target: EventId, cx: &mut Context<Self>) -> AnyElement {
|
||||
let context = self.group.upgrade().and_then(|entity| {
|
||||
entity.read_with(cx, |group, cx| {
|
||||
@@ -1124,9 +1377,9 @@ impl GroupPanel {
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.max_w(px(140.))
|
||||
.max_w(rems(8.75))
|
||||
.truncate()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(author),
|
||||
)
|
||||
@@ -1140,7 +1393,7 @@ impl GroupPanel {
|
||||
)
|
||||
.child(
|
||||
Button::new("cancel-reply")
|
||||
.icon(IconName::X)
|
||||
.icon(IconName::Close)
|
||||
.tooltip("Cancel reply")
|
||||
.ghost()
|
||||
.small()
|
||||
@@ -1154,8 +1407,7 @@ impl GroupPanel {
|
||||
fn render_attachments(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
||||
self.attachments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, url)| {
|
||||
.map(|url| {
|
||||
let url = url.clone();
|
||||
|
||||
h_flex()
|
||||
@@ -1171,13 +1423,13 @@ impl GroupPanel {
|
||||
.child(Icon::new(IconName::Link).xsmall())
|
||||
.child(
|
||||
div()
|
||||
.max_w(px(200.))
|
||||
.max_w(rems(12.5))
|
||||
.truncate()
|
||||
.child(attachment_name(&url)),
|
||||
)
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("remove-attachment-{ix}")))
|
||||
.icon(IconName::X)
|
||||
Button::new(SharedString::from(format!("remove-attachment-{}", url)))
|
||||
.icon(IconName::Close)
|
||||
.tooltip("Remove")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
@@ -1266,13 +1518,15 @@ impl Focusable for GroupPanel {
|
||||
}
|
||||
|
||||
impl Render for GroupPanel {
|
||||
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 {
|
||||
let notice = self.notice(cx);
|
||||
let empty = self.item_count(cx) == 2;
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.min_w_0()
|
||||
.on_action(cx.listener(Self::on_command))
|
||||
.on_action(cx.listener(Self::toggle_dictation))
|
||||
.child(self.render_encryption_notice(cx))
|
||||
.child(self.render_banner(cx))
|
||||
.child(self.render_pins(cx))
|
||||
@@ -1312,6 +1566,6 @@ impl Render for GroupPanel {
|
||||
})
|
||||
.child(Scrollbar::vertical(&self.list_state)),
|
||||
)
|
||||
.child(self.render_composer(cx))
|
||||
.child(self.render_composer(window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
use chat::Message;
|
||||
use chat_ui::message::{MessageMedia, MessageRow, ReactionBar, ReplyPreview, group_reactions};
|
||||
use chat_ui::Command;
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, Entity, InteractiveElement, IntoElement, ParentElement, SharedString, Styled,
|
||||
WeakEntity, div,
|
||||
AnyElement, App, ClipboardItem, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
SharedString, Styled, WeakEntity, div,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem};
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
|
||||
use nip29::{Activity, Group, Reaction};
|
||||
use nostr_sdk::prelude::{EventId, Kind};
|
||||
use nostr_sdk::prelude::{EventId, Kind, ToBech32};
|
||||
use person::Directory;
|
||||
use settings::AppSettings;
|
||||
use state::NostrRegistry;
|
||||
use ui::Avatar;
|
||||
use ui::{
|
||||
Avatar, COMPACT_REACTION_EMOJIS, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS,
|
||||
ReactionBar, ReplyPreview, group_reactions, react_menu,
|
||||
};
|
||||
|
||||
use crate::GroupPanel;
|
||||
|
||||
const REACTIONS: [&str; 6] = ["👍", "❤️", "😂", "🎉", "😮", "😢"];
|
||||
|
||||
pub(crate) struct Actions {
|
||||
pub group: WeakEntity<Group>,
|
||||
pub panel: WeakEntity<GroupPanel>,
|
||||
pub admin: bool,
|
||||
pub pinned: bool,
|
||||
/// Whether a message is currently being spoken, disabling the speech buttons
|
||||
pub speaking: bool,
|
||||
pub persons: Entity<Directory>,
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
ix: usize,
|
||||
_ix: usize,
|
||||
message: &Message,
|
||||
content: AnyElement,
|
||||
show_author: bool,
|
||||
@@ -54,38 +58,107 @@ pub(crate) fn render(
|
||||
nostr.read(cx).current_user(),
|
||||
);
|
||||
|
||||
let row = MessageRow::new(ix)
|
||||
.show_author(show_author)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.author(author.name())
|
||||
.timestamp(message.created_at.to_human_time())
|
||||
.when(!message.replies_to.is_empty(), |this| {
|
||||
this.children(replies(
|
||||
&message.replies_to,
|
||||
&persons,
|
||||
&actions.group,
|
||||
&actions.panel,
|
||||
cx,
|
||||
))
|
||||
})
|
||||
.child(content)
|
||||
.when(!message.media.is_empty(), |this| {
|
||||
this.child(MessageMedia::new(&message.media))
|
||||
})
|
||||
.when(!reaction_groups.is_empty(), |this| {
|
||||
this.child(ReactionBar::new(reaction_groups))
|
||||
});
|
||||
let row = MessageRow::new(SharedString::from(format!(
|
||||
"message-{}",
|
||||
message.id.to_hex()
|
||||
)))
|
||||
.show_author(show_author)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.author(author.name())
|
||||
.timestamp(message.created_at.to_human_time())
|
||||
.when(!message.replies_to.is_empty(), |this| {
|
||||
this.children(replies(
|
||||
&message.replies_to,
|
||||
&persons,
|
||||
&actions.group,
|
||||
&actions.panel,
|
||||
cx,
|
||||
))
|
||||
})
|
||||
.child(content)
|
||||
.when(!message.media.is_empty(), |this| {
|
||||
this.child(MessageMedia::new(&message.media))
|
||||
})
|
||||
.when(!reaction_groups.is_empty(), |this| {
|
||||
this.child(ReactionBar::new(reaction_groups))
|
||||
});
|
||||
|
||||
let id = message.id;
|
||||
let admin = actions.admin;
|
||||
let pinned = actions.pinned;
|
||||
let group = actions.group;
|
||||
let panel = actions.panel;
|
||||
let speech_panel = panel.clone();
|
||||
let speech_text = message.preview();
|
||||
let speech_id = message.id;
|
||||
let speaking = actions.speaking;
|
||||
let author = message.author;
|
||||
|
||||
let mut bar = MessageActions::new();
|
||||
|
||||
for emoji in COMPACT_REACTION_EMOJIS {
|
||||
let emoji = *emoji;
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
bar = bar.reaction(emoji, move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: sending a reaction failed: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let reply_panel = panel.clone();
|
||||
let bar = bar.reply(move |_event, _window, cx| {
|
||||
if let Err(error) = reply_panel.update_in(cx, |panel, window, cx| {
|
||||
panel.start_reply(id, window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: starting a reply failed: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
let bar = bar
|
||||
.copy(
|
||||
SharedString::from(format!("message-copy-{id}")),
|
||||
message.preview(),
|
||||
)
|
||||
.speech(speaking, {
|
||||
let speech_text = speech_text.clone();
|
||||
|
||||
move |_event, _window, cx| {
|
||||
if let Err(error) = speech_panel.update_in(cx, |panel, window, cx| {
|
||||
panel.speak(speech_id, speech_text.clone(), window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: reading the message aloud failed: {error}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let row = row.overlay(bar.dropdown(move |menu, _window, _cx| {
|
||||
menu.menu("Copy author", Box::new(Command::Copy(author)))
|
||||
}));
|
||||
|
||||
let custom = nostr
|
||||
.read(cx)
|
||||
.current_user()
|
||||
.map(|current_user| {
|
||||
persons
|
||||
.read(cx)
|
||||
.person(¤t_user, cx)
|
||||
.emojis()
|
||||
.iter()
|
||||
.map(|(shortcode, _)| format!(":{shortcode}:"))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let row = div()
|
||||
.id(SharedString::from(format!("message-menu-{}", id.to_hex())))
|
||||
@@ -97,16 +170,53 @@ pub(crate) fn render(
|
||||
let panel = panel.clone();
|
||||
|
||||
PopupMenuItem::new("Reply").on_click(move |_event, _window, cx| {
|
||||
let _ = panel.update_in(cx, |panel, window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.start_reply(id, window, cx);
|
||||
});
|
||||
}) {
|
||||
log::warn!("nip29: starting a reply failed: {error}");
|
||||
}
|
||||
})
|
||||
})
|
||||
.submenu("React", window, cx, {
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
let custom = custom.clone();
|
||||
|
||||
move |menu, _window, cx| react_menu(menu, group.clone(), panel.clone(), id, cx)
|
||||
move |menu, _window, _cx| {
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
react_menu(menu, REACTION_EMOJIS, &custom, move |emoji, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: sending a reaction failed: {error}");
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.item({
|
||||
let panel = panel.clone();
|
||||
let speech_text = speech_text.clone();
|
||||
let speech_id = id;
|
||||
|
||||
PopupMenuItem::new("Read aloud")
|
||||
.disabled(speaking)
|
||||
.on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.speak(speech_id, speech_text.clone(), window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: reading the message aloud failed: {error}");
|
||||
}
|
||||
})
|
||||
})
|
||||
.item({
|
||||
let author = author;
|
||||
|
||||
PopupMenuItem::new("Copy author").on_click(move |_event, _window, cx| {
|
||||
let npub = author.to_bech32().unwrap_or_default();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(npub));
|
||||
})
|
||||
});
|
||||
|
||||
if !admin {
|
||||
@@ -141,69 +251,8 @@ pub(crate) fn render(
|
||||
row.into_any_element()
|
||||
}
|
||||
|
||||
fn react_menu(
|
||||
menu: PopupMenu,
|
||||
group: WeakEntity<Group>,
|
||||
panel: WeakEntity<GroupPanel>,
|
||||
id: EventId,
|
||||
cx: &App,
|
||||
) -> PopupMenu {
|
||||
let menu = REACTIONS.iter().fold(menu, |menu, emoji| {
|
||||
let emoji = *emoji;
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
menu.item(
|
||||
PopupMenuItem::new(emoji).on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: sending a reaction failed: {error}");
|
||||
}
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
let emojis = panel
|
||||
.upgrade()
|
||||
.and_then(|panel| {
|
||||
let current_user = NostrRegistry::global(cx).read(cx).current_user()?;
|
||||
Some(
|
||||
panel
|
||||
.read(cx)
|
||||
.directory
|
||||
.read(cx)
|
||||
.person(¤t_user, cx)
|
||||
.emojis()
|
||||
.to_vec(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if emojis.is_empty() {
|
||||
return menu;
|
||||
}
|
||||
|
||||
let menu = menu.separator();
|
||||
emojis.into_iter().fold(menu, |menu, (shortcode, _)| {
|
||||
let emoji = format!(":{shortcode}:");
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
menu.item(
|
||||
PopupMenuItem::new(emoji.clone()).on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.react(group.clone(), id, emoji.clone(), window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: sending a reaction failed: {error}");
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_activity(
|
||||
ix: usize,
|
||||
_ix: usize,
|
||||
activity: &Activity,
|
||||
persons: &Entity<Directory>,
|
||||
cx: &App,
|
||||
@@ -212,12 +261,14 @@ pub(crate) fn render_activity(
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.id(SharedString::from(format!(
|
||||
"activity-{}",
|
||||
activity.id.to_hex()
|
||||
)))
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.py_1()
|
||||
.px_3()
|
||||
.hover(|this| this.bg(cx.theme().primary))
|
||||
.child(badge(activity, cx))
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(
|
||||
@@ -232,7 +283,7 @@ pub(crate) fn render_activity(
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.font_semibold()
|
||||
.child(subject.name()),
|
||||
)
|
||||
.child(
|
||||
@@ -294,7 +345,7 @@ fn replies(
|
||||
|
||||
let mut items = Vec::with_capacity(targets.len());
|
||||
|
||||
for (ix, id) in targets.iter().enumerate() {
|
||||
for id in targets.iter() {
|
||||
let context = entity.read_with(cx, |group, cx| {
|
||||
let message = group.message(*id)?;
|
||||
let author = persons.read(cx).person(&message.author, cx);
|
||||
@@ -313,15 +364,19 @@ fn replies(
|
||||
let panel = panel.clone();
|
||||
|
||||
items.push(
|
||||
ReplyPreview::new(ix, author, preview)
|
||||
.on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update(cx, |panel, cx| {
|
||||
panel.scroll_to(&id, cx);
|
||||
}) {
|
||||
log::warn!("nip29: scrolling to message failed: {error}");
|
||||
}
|
||||
})
|
||||
.into_any_element(),
|
||||
ReplyPreview::new(
|
||||
SharedString::from(format!("reply-{}", id.to_hex())),
|
||||
author,
|
||||
preview,
|
||||
)
|
||||
.on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update(cx, |panel, cx| {
|
||||
panel.scroll_to(&id, cx);
|
||||
}) {
|
||||
log::warn!("nip29: scrolling to message failed: {error}");
|
||||
}
|
||||
})
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ use gpui::{
|
||||
};
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::form::field as form_field;
|
||||
use gpui_kit::component::input::InputState;
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::switch::Switch;
|
||||
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||
use nip29::{Group, GroupId, GroupMetadata};
|
||||
use nostr_sdk::prelude::RelayUrl;
|
||||
use ui::settings_row;
|
||||
|
||||
use crate::util::field;
|
||||
|
||||
@@ -100,7 +100,7 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
||||
let supported = *livekit.read(cx) != Some(false);
|
||||
|
||||
this.close_button(true)
|
||||
.title("Edit Group")
|
||||
.title("Edit group")
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
@@ -126,73 +126,64 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
form_field()
|
||||
.label("Private")
|
||||
.description("Only members can read group messages")
|
||||
.child(
|
||||
Switch::new("private")
|
||||
.checked(current.private)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.private)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
form_field()
|
||||
.label("Restricted")
|
||||
.description("Only members can post messages")
|
||||
.child(
|
||||
Switch::new("restricted")
|
||||
.checked(current.restricted)
|
||||
.on_click(toggle(&flags, |flags| {
|
||||
&mut flags.restricted
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
form_field()
|
||||
.label("Hidden")
|
||||
.description("Hidden from non-members, not discoverable")
|
||||
.child(
|
||||
Switch::new("hidden")
|
||||
.checked(current.hidden)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.hidden)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
form_field()
|
||||
.label("Closed")
|
||||
.description("Join requests are ignored (invite-only)")
|
||||
.child(
|
||||
Switch::new("closed")
|
||||
.checked(current.closed)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.closed)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
form_field()
|
||||
.label("LiveKit")
|
||||
.description(if supported {
|
||||
"Announce audio and video support."
|
||||
} else {
|
||||
"The relay doesn't announce LiveKit support."
|
||||
})
|
||||
.child(
|
||||
Switch::new("livekit").checked(current.livekit).on_click({
|
||||
let flags = flags.clone();
|
||||
let livekit = livekit.clone();
|
||||
.child(settings_row(
|
||||
"Private",
|
||||
"Only members can read group messages",
|
||||
Switch::new("private")
|
||||
.checked(current.private)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.private)),
|
||||
cx,
|
||||
))
|
||||
.child(settings_row(
|
||||
"Restricted",
|
||||
"Only members can post messages",
|
||||
Switch::new("restricted")
|
||||
.checked(current.restricted)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.restricted)),
|
||||
cx,
|
||||
))
|
||||
.child(settings_row(
|
||||
"Hidden",
|
||||
"Hidden from non-members, not discoverable",
|
||||
Switch::new("hidden")
|
||||
.checked(current.hidden)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.hidden)),
|
||||
cx,
|
||||
))
|
||||
.child(settings_row(
|
||||
"Closed",
|
||||
"Join requests are ignored (invite-only)",
|
||||
Switch::new("closed")
|
||||
.checked(current.closed)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.closed)),
|
||||
cx,
|
||||
))
|
||||
.child(settings_row(
|
||||
"LiveKit",
|
||||
if supported {
|
||||
"Announce audio and video support"
|
||||
} else {
|
||||
"The relay doesn't announce LiveKit support."
|
||||
},
|
||||
Switch::new("livekit")
|
||||
.checked(current.livekit)
|
||||
.disabled(!supported)
|
||||
.on_click({
|
||||
let flags = flags.clone();
|
||||
let livekit = livekit.clone();
|
||||
|
||||
move |next, _window, cx| {
|
||||
if *next && *livekit.read(cx) == Some(false) {
|
||||
return;
|
||||
}
|
||||
flags.update(cx, |flags, cx| {
|
||||
flags.livekit = *next;
|
||||
cx.notify();
|
||||
});
|
||||
move |next, _window, cx| {
|
||||
if *next && *livekit.read(cx) == Some(false) {
|
||||
return;
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
flags.update(cx, |flags, cx| {
|
||||
flags.livekit = *next;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}),
|
||||
cx,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.on_ok({
|
||||
@@ -305,10 +296,10 @@ fn children_section(children: &Entity<Vec<GroupId>>, cx: &App) -> AnyElement {
|
||||
.flex_shrink_0()
|
||||
.gap_1()
|
||||
.when(ix > 0, |this| {
|
||||
this.child(move_button(children.clone(), ix, ix - 1, "up"))
|
||||
this.child(move_button(children.clone(), child, ix, ix - 1, "up"))
|
||||
})
|
||||
.when(ix + 1 < list.len(), |this| {
|
||||
this.child(move_button(children.clone(), ix, ix + 1, "down"))
|
||||
this.child(move_button(children.clone(), child, ix, ix + 1, "down"))
|
||||
});
|
||||
|
||||
section = section.child(
|
||||
@@ -331,11 +322,12 @@ fn children_section(children: &Entity<Vec<GroupId>>, cx: &App) -> AnyElement {
|
||||
|
||||
fn move_button(
|
||||
children: Entity<Vec<GroupId>>,
|
||||
child: &GroupId,
|
||||
ix: usize,
|
||||
target: usize,
|
||||
direction: &'static str,
|
||||
) -> Button {
|
||||
Button::new(SharedString::from(format!("child-{direction}-{ix}")))
|
||||
Button::new(SharedString::from(format!("child-{direction}-{child}")))
|
||||
.label(if direction == "up" { "Up" } else { "Down" })
|
||||
.ghost()
|
||||
.small()
|
||||
|
||||
@@ -35,7 +35,7 @@ pub(crate) fn opens_run(rows: &[Row], index: usize) -> bool {
|
||||
};
|
||||
|
||||
match (current, previous) {
|
||||
(Row::Message(current), Row::Message(previous)) => chat_ui::message::opens_run(
|
||||
(Row::Message(current), Row::Message(previous)) => ui::opens_run(
|
||||
Some((previous.author, previous.created_at.as_secs())),
|
||||
(current.author, current.created_at.as_secs()),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
use anyhow::{Error, anyhow};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// The storage identifier for the device encryption key
|
||||
const IDENTIFIER: &str = "coop:device";
|
||||
/// The storage identifier for encryption keys retired by a rotation
|
||||
const ARCHIVE_IDENTIFIER: &str = "coop:device-archive";
|
||||
/// Keep the archived key list bounded
|
||||
const ARCHIVE_LIMIT: usize = 10;
|
||||
/// Seconds in a day, for rotation intervals
|
||||
pub(crate) const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
|
||||
|
||||
/// Device encryption key material with its creation time
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct DeviceKeys {
|
||||
pub(crate) keys: Keys,
|
||||
pub(crate) created_at: Timestamp,
|
||||
}
|
||||
|
||||
/// Encode the stored key content: secret hex and creation time
|
||||
fn encode_keys_content(secret: &str, created_at: &Timestamp) -> String {
|
||||
format!("{secret}\n{}", created_at.as_secs())
|
||||
}
|
||||
|
||||
/// Parse stored key content.
|
||||
fn parse_keys_content(content: &str) -> Result<(SecretKey, Timestamp), Error> {
|
||||
match content.split_once('\n') {
|
||||
Some((secret, created_at)) => {
|
||||
let created_at = created_at
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.unwrap_or_else(|_| Timestamp::now().as_secs());
|
||||
Ok((
|
||||
SecretKey::from_hex(secret)?,
|
||||
Timestamp::from_secs(created_at),
|
||||
))
|
||||
}
|
||||
None => Ok((SecretKey::from_hex(content)?, Timestamp::now())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse archived secrets from stored content, one hex secret per line
|
||||
fn parse_archived_keys(content: &str) -> Vec<Keys> {
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| SecretKey::from_hex(line.trim()).ok())
|
||||
.map(Keys::new)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Load the device keys with their creation time from the local database.
|
||||
pub(crate) async fn load_device_keys<S>(client: &Client, signer: &S) -> Result<DeviceKeys, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(IDENTIFIER)
|
||||
.author(public_key);
|
||||
|
||||
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
|
||||
return Err(anyhow!("Key not found"));
|
||||
};
|
||||
|
||||
let content = signer
|
||||
.nip44_decrypt_async(&public_key, &event.content)
|
||||
.await?;
|
||||
let (secret, created_at) = parse_keys_content(&content)?;
|
||||
|
||||
Ok(DeviceKeys {
|
||||
keys: Keys::new(secret),
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encrypt and store device keys in the local database.
|
||||
pub(crate) async fn set_keys<S>(
|
||||
client: &Client,
|
||||
signer: &S,
|
||||
secret: &str,
|
||||
created_at: Timestamp,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
let content = signer
|
||||
.nip44_encrypt_async(&public_key, &encode_keys_content(secret, &created_at))
|
||||
.await?;
|
||||
|
||||
// Construct the application data event
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||
.tag(Tag::identifier(IDENTIFIER))
|
||||
.finalize_async(signer)
|
||||
.await?;
|
||||
|
||||
// Save the event to the database
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append an encryption secret to the bounded archive of retired keys.
|
||||
pub(crate) async fn archive_key<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(ARCHIVE_IDENTIFIER)
|
||||
.author(public_key);
|
||||
|
||||
let mut archived: Vec<String> = match client.database().query(filter).await?.into_iter().next()
|
||||
{
|
||||
Some(event) => {
|
||||
let content = signer
|
||||
.nip44_decrypt_async(&public_key, &event.content)
|
||||
.await?;
|
||||
content.lines().map(str::to_string).collect()
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
archived.insert(0, secret.to_string());
|
||||
archived.truncate(ARCHIVE_LIMIT);
|
||||
|
||||
let content = signer
|
||||
.nip44_encrypt_async(&public_key, &archived.join("\n"))
|
||||
.await?;
|
||||
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||
.tag(Tag::identifier(ARCHIVE_IDENTIFIER))
|
||||
.finalize_async(signer)
|
||||
.await?;
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get device keys from the local database.
|
||||
pub(crate) async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
Ok(load_device_keys(client, signer).await?.keys)
|
||||
}
|
||||
|
||||
/// Load encryption keys retired by a rotation from the local database.
|
||||
pub(crate) async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(ARCHIVE_IDENTIFIER)
|
||||
.author(public_key);
|
||||
|
||||
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
|
||||
let content = signer
|
||||
.nip44_decrypt_async(&public_key, &event.content)
|
||||
.await?;
|
||||
|
||||
Ok(parse_archived_keys(&content))
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "nip60"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
state = { path = "../state" }
|
||||
common = { path = "../common" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smallvec.workspace = true
|
||||
log.workspace = true
|
||||
flume.workspace = true
|
||||
serde_json.workspace = true
|
||||
futures.workspace = true
|
||||
hkdf.workspace = true
|
||||
sha2.workspace = true
|
||||
tokio = { version = "1", default-features = false, features = [ "rt" ] }
|
||||
|
||||
cdk.workspace = true
|
||||
cdk-redb.workspace = true
|
||||
@@ -0,0 +1,369 @@
|
||||
use anyhow::{Error, anyhow};
|
||||
use hkdf::Hkdf;
|
||||
use nostr::nips::nip60::TokenEvent;
|
||||
use nostr_sdk::prelude::*;
|
||||
use sha2::Sha256;
|
||||
|
||||
const SEED_SALT: &[u8] = b"coop nip60 seed salt";
|
||||
const SEED_INFO: &[u8] = b"coop cashu seed";
|
||||
|
||||
/// Parsed 17375 wallet event: the wallet key and its mint list.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct WalletData {
|
||||
pub(crate) event_id: EventId,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) privkey: SecretKey,
|
||||
pub(crate) mints: Vec<Url>,
|
||||
}
|
||||
|
||||
impl WalletData {
|
||||
/// A new wallet with a fresh key.
|
||||
pub(crate) fn generate(first_mint: Url) -> Self {
|
||||
Self {
|
||||
event_id: EventId::from_byte_array([0; 32]),
|
||||
created_at: Timestamp::from_secs(0),
|
||||
privkey: SecretKey::generate(),
|
||||
mints: vec![first_mint],
|
||||
}
|
||||
}
|
||||
|
||||
/// The cdk seed, derived deterministically from the wallet key.
|
||||
pub(crate) fn derive_seed(privkey: &SecretKey) -> Result<[u8; 64], Error> {
|
||||
let ikm = privkey.to_secret_bytes();
|
||||
let hk = Hkdf::<Sha256>::new(Some(SEED_SALT), &ikm);
|
||||
let mut seed = [0u8; 64];
|
||||
hk.expand(SEED_INFO, &mut seed)
|
||||
.map_err(|error| anyhow!("seed derivation failed: {error}"))?;
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
/// Decrypt and parse a 17375 event published by this account.
|
||||
pub(crate) async fn decrypt<S>(signer: &S, me: &PublicKey, event: &Event) -> Result<Self, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
if event.pubkey != *me {
|
||||
return Err(anyhow!("wallet event was not published by this account"));
|
||||
}
|
||||
|
||||
let content = signer.nip44_decrypt_async(me, &event.content).await?;
|
||||
let (privkey, mints) = parse_wallet_content(&content)?;
|
||||
|
||||
Ok(Self {
|
||||
event_id: event.id,
|
||||
created_at: event.created_at,
|
||||
privkey,
|
||||
mints,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the encrypted 17375 event for this wallet data.
|
||||
pub(crate) async fn to_event<S>(&self, signer: &S, me: &PublicKey) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let mut rows = vec![vec!["privkey".to_string(), self.privkey.to_secret_hex()]];
|
||||
for mint in &self.mints {
|
||||
rows.push(vec!["mint".to_string(), mint.to_string()]);
|
||||
}
|
||||
|
||||
let content = signer
|
||||
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
|
||||
.await?;
|
||||
|
||||
Ok(EventBuilder::new(Kind::CashuWallet, content)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_wallet_content(content: &str) -> Result<(SecretKey, Vec<Url>), Error> {
|
||||
let rows: Vec<Vec<String>> =
|
||||
serde_json::from_str(content).map_err(|error| anyhow!("invalid wallet event: {error}"))?;
|
||||
|
||||
let mut privkey = None;
|
||||
let mut mints = Vec::new();
|
||||
for row in rows {
|
||||
let (Some(key), Some(value)) = (row.first(), row.get(1)) else {
|
||||
continue;
|
||||
};
|
||||
match key.as_str() {
|
||||
"privkey" if privkey.is_none() => privkey = Some(SecretKey::from_hex(value)?),
|
||||
"mint" => {
|
||||
if let Ok(mint) = Url::parse(value) {
|
||||
mints.push(mint);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(privkey) = privkey else {
|
||||
return Err(anyhow!("wallet event is missing the private key"));
|
||||
};
|
||||
if mints.is_empty() {
|
||||
return Err(anyhow!("wallet event is missing mints"));
|
||||
}
|
||||
|
||||
Ok((privkey, mints))
|
||||
}
|
||||
|
||||
pub(crate) async fn decrypt_token_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
event: &Event,
|
||||
) -> Result<TokenEvent, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
if event.pubkey != *me {
|
||||
return Err(anyhow!("token event was not published by this account"));
|
||||
}
|
||||
|
||||
let content = signer.nip44_decrypt_async(me, &event.content).await?;
|
||||
|
||||
let token =
|
||||
serde_json::from_str(&content).map_err(|error| anyhow!("invalid token event: {error}"))?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub(crate) async fn build_token_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
token: &TokenEvent,
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let content = signer
|
||||
.nip44_encrypt_async(me, &serde_json::to_string(token)?)
|
||||
.await?;
|
||||
|
||||
Ok(EventBuilder::new(Kind::CashuWalletUnspentProof, content)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// NIP-09 deletion carrying the `k: 7375` tag required by NIP-60.
|
||||
pub(crate) async fn build_token_deletion<S>(signer: &S, ids: &[EventId]) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let mut tags: Vec<Tag> = ids.iter().map(|id| Tag::event(*id)).collect();
|
||||
tags.push(Tag::custom("k", ["7375".to_string()]));
|
||||
|
||||
Ok(EventBuilder::new(Kind::EventDeletion, "")
|
||||
.tags(tags)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Decrypted 7376 payload, enough for the wallet panel's history list.
|
||||
pub(crate) struct HistoryPayload {
|
||||
pub(crate) direction: TransactionDirection,
|
||||
pub(crate) amount: u64,
|
||||
}
|
||||
|
||||
pub(crate) async fn decrypt_history_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
event: &Event,
|
||||
) -> Result<HistoryPayload, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
if event.pubkey != *me {
|
||||
return Err(anyhow!("history event was not published by this account"));
|
||||
}
|
||||
|
||||
let content = signer.nip44_decrypt_async(me, &event.content).await?;
|
||||
let rows: Vec<Vec<String>> = serde_json::from_str(&content)
|
||||
.map_err(|error| anyhow!("invalid history event: {error}"))?;
|
||||
|
||||
let mut direction = None;
|
||||
let mut amount = None;
|
||||
for row in rows {
|
||||
let (Some(key), Some(value)) = (row.first(), row.get(1)) else {
|
||||
continue;
|
||||
};
|
||||
match key.as_str() {
|
||||
"direction" if direction.is_none() => direction = value.parse().ok(),
|
||||
"amount" if amount.is_none() => amount = value.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(direction) = direction else {
|
||||
return Err(anyhow!("history event is missing the direction"));
|
||||
};
|
||||
let Some(amount) = amount else {
|
||||
return Err(anyhow!("history event is missing the amount"));
|
||||
};
|
||||
|
||||
Ok(HistoryPayload { direction, amount })
|
||||
}
|
||||
|
||||
/// Build the public kind 9321 nutzap event carrying P2PK-locked proofs.
|
||||
pub(crate) async fn build_nutzap_event<S>(
|
||||
signer: &S,
|
||||
recipient: PublicKey,
|
||||
mint: &str,
|
||||
proofs: &[String],
|
||||
target: Option<EventId>,
|
||||
comment: &str,
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let mut tags: Vec<Tag> = vec![
|
||||
Tag::custom("p", [recipient.to_hex()]),
|
||||
Tag::custom("u", [mint.to_string()]),
|
||||
Tag::custom("unit", ["sat".to_string()]),
|
||||
];
|
||||
|
||||
for proof in proofs {
|
||||
tags.push(Tag::custom("proof", [proof.clone()]));
|
||||
}
|
||||
|
||||
if let Some(target) = target {
|
||||
tags.push(Tag::custom("e", [target.to_hex()]));
|
||||
}
|
||||
|
||||
Ok(EventBuilder::new(Kind::Custom(9321), comment)
|
||||
.tags(tags)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Encrypted 7376 history payload for one state change.
|
||||
pub(crate) struct History {
|
||||
pub(crate) direction: TransactionDirection,
|
||||
pub(crate) amount: u64,
|
||||
/// NIP-61 nutzap events redeemed by this state change
|
||||
pub(crate) redeemed: Vec<EventId>,
|
||||
/// Author of the redeemed nutzap, for the unencrypted `p` tag
|
||||
pub(crate) sender: Option<PublicKey>,
|
||||
}
|
||||
|
||||
pub(crate) async fn build_history_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
history: &History,
|
||||
created: EventId,
|
||||
destroyed: &[EventId],
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let mut rows = vec![
|
||||
vec!["direction".to_string(), history.direction.to_string()],
|
||||
vec!["amount".to_string(), history.amount.to_string()],
|
||||
];
|
||||
|
||||
for id in destroyed {
|
||||
rows.push(vec![
|
||||
"e".to_string(),
|
||||
id.to_hex(),
|
||||
String::new(),
|
||||
"destroyed".to_string(),
|
||||
]);
|
||||
}
|
||||
|
||||
rows.push(vec![
|
||||
"e".to_string(),
|
||||
created.to_hex(),
|
||||
String::new(),
|
||||
"created".to_string(),
|
||||
]);
|
||||
|
||||
let mut tags: Vec<Tag> = history
|
||||
.redeemed
|
||||
.iter()
|
||||
.map(|id| Tag::custom("e", [id.to_hex(), String::new(), "redeemed".to_string()]))
|
||||
.collect();
|
||||
|
||||
if let Some(sender) = history.sender {
|
||||
tags.push(Tag::custom("p", [sender.to_hex()]));
|
||||
}
|
||||
|
||||
let content = signer
|
||||
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
|
||||
.await?;
|
||||
|
||||
Ok(EventBuilder::new(Kind::CashuWalletSpendingHistory, content)
|
||||
.tags(tags)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub(crate) async fn build_nutzap_info_event<S>(
|
||||
signer: &S,
|
||||
mints: &[Url],
|
||||
relays: &[RelayUrl],
|
||||
wallet_pubkey: PublicKey,
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let mut tags: Vec<Tag> = relays
|
||||
.iter()
|
||||
.map(|relay| Tag::custom("relay", [relay.to_string()]))
|
||||
.collect();
|
||||
|
||||
for mint in mints {
|
||||
tags.push(Tag::custom("mint", [mint.to_string(), "sat".to_string()]));
|
||||
}
|
||||
|
||||
tags.push(Tag::custom(
|
||||
"pubkey",
|
||||
[format!("02{}", wallet_pubkey.to_hex())],
|
||||
));
|
||||
|
||||
Ok(EventBuilder::new(Kind::Custom(10019), "")
|
||||
.tags(tags)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_wallet_content() {
|
||||
let privkey = SecretKey::generate();
|
||||
let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
|
||||
|
||||
let content = serde_json::to_string(&vec![
|
||||
vec!["privkey".to_string(), privkey.to_secret_hex()],
|
||||
vec!["mint".to_string(), mint.to_string()],
|
||||
])
|
||||
.expect("serialization cannot fail");
|
||||
|
||||
let (parsed_key, mints) = parse_wallet_content(&content).expect("valid content");
|
||||
assert_eq!(parsed_key.to_secret_hex(), privkey.to_secret_hex());
|
||||
assert_eq!(mints, vec![mint]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_content_rejects_missing_privkey() {
|
||||
let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
|
||||
let content = serde_json::to_string(&vec![vec!["mint".to_string(), mint.to_string()]])
|
||||
.expect("serialization cannot fail");
|
||||
|
||||
assert!(parse_wallet_content(&content).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_is_deterministic_per_key() {
|
||||
let key = SecretKey::generate();
|
||||
let first = WalletData::derive_seed(&key).expect("seed derivation cannot fail");
|
||||
let second = WalletData::derive_seed(&key).expect("seed derivation cannot fail");
|
||||
assert_eq!(first, second);
|
||||
|
||||
let other =
|
||||
WalletData::derive_seed(&SecretKey::generate()).expect("seed derivation cannot fail");
|
||||
assert_ne!(first, other);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use cdk::Amount;
|
||||
use cdk::amount::SplitTarget;
|
||||
use cdk::mint_url::MintUrl;
|
||||
use cdk::nuts::nut00::KnownMethod;
|
||||
use cdk::nuts::{
|
||||
CurrencyUnit, Id, KeySetInfo, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs,
|
||||
SecretKey, SpendingConditions, State,
|
||||
};
|
||||
use cdk::secret::Secret;
|
||||
use cdk::wallet::types::ProofInfo;
|
||||
use cdk::wallet::{ReceiveOptions, SendOptions, Wallet};
|
||||
use futures::StreamExt;
|
||||
use nostr::nips::nip60::{CashuProof, TokenEvent, TransactionDirection};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::keys;
|
||||
|
||||
/// How long to wait for relays when fetching a recipient's wallet info
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// A job mutating the wallet store or reconciling it with relays.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Job {
|
||||
Reconcile,
|
||||
CreateQuote {
|
||||
mint: MintUrl,
|
||||
amount: u64,
|
||||
},
|
||||
ClaimQuote {
|
||||
mint: MintUrl,
|
||||
quote_id: String,
|
||||
},
|
||||
Melt {
|
||||
mint: MintUrl,
|
||||
invoice: String,
|
||||
},
|
||||
SendToken {
|
||||
mint: MintUrl,
|
||||
amount: u64,
|
||||
},
|
||||
SendNutzap {
|
||||
recipient: PublicKey,
|
||||
amount: u64,
|
||||
target: Option<EventId>,
|
||||
comment: Option<String>,
|
||||
},
|
||||
Receive {
|
||||
mint: MintUrl,
|
||||
token: String,
|
||||
},
|
||||
RedeemNutzap {
|
||||
mint: MintUrl,
|
||||
proofs: Proofs,
|
||||
nutzap_id: EventId,
|
||||
sender: PublicKey,
|
||||
},
|
||||
}
|
||||
|
||||
impl Job {
|
||||
pub(crate) async fn run<S>(self, ctx: JobCtx<S>) -> Result<Outcome, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static,
|
||||
{
|
||||
match self {
|
||||
Job::Reconcile => ctx.reconcile().await,
|
||||
Job::CreateQuote { mint, amount } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let quote = wallet
|
||||
.mint_quote(
|
||||
PaymentMethod::Known(KnownMethod::Bolt11),
|
||||
Some(Amount::from(amount)),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::QuoteCreated {
|
||||
mint,
|
||||
quote_id: quote.id,
|
||||
request: quote.request,
|
||||
amount,
|
||||
})
|
||||
}
|
||||
Job::ClaimQuote { mint, quote_id } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let quote = wallet.check_mint_quote("e_id).await?;
|
||||
|
||||
if quote.state != MintQuoteState::Paid {
|
||||
return Ok(Outcome::Idle);
|
||||
}
|
||||
|
||||
let proofs = wallet.mint("e_id, SplitTarget::default(), None).await?;
|
||||
let amount: u64 = proofs.iter().map(|proof| u64::from(proof.amount)).sum();
|
||||
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::In,
|
||||
amount: u64::from(quote.amount.unwrap_or_default()),
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::QuoteMinted {
|
||||
quote_id,
|
||||
update,
|
||||
amount,
|
||||
})
|
||||
}
|
||||
Job::Melt { mint, invoice } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let quote = wallet
|
||||
.melt_quote(
|
||||
PaymentMethod::Known(KnownMethod::Bolt11),
|
||||
invoice,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let finalized = wallet
|
||||
.prepare_melt("e.id, HashMap::new())
|
||||
.await?
|
||||
.confirm()
|
||||
.await?;
|
||||
|
||||
if finalized.state() != MeltQuoteState::Paid {
|
||||
return Err(anyhow!("melt did not settle: {:?}", finalized.state()));
|
||||
}
|
||||
|
||||
let amount = u64::from(finalized.total_amount());
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::Out,
|
||||
amount,
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::Melted { update, amount })
|
||||
}
|
||||
Job::SendToken { mint, amount } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let prepared = wallet
|
||||
.prepare_send(Amount::from(amount), SendOptions::default())
|
||||
.await?;
|
||||
let token = prepared.confirm(None).await?.to_string();
|
||||
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::Out,
|
||||
amount,
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::Sent { update, token })
|
||||
}
|
||||
Job::SendNutzap {
|
||||
recipient,
|
||||
amount,
|
||||
target,
|
||||
comment,
|
||||
} => {
|
||||
let (mint_url, mint_tag, p2pk) = recipient_wallet_info(&ctx, recipient).await?;
|
||||
let wallet = ctx.wallet(&mint_url)?;
|
||||
|
||||
let keysets = wallet
|
||||
.keysets(cdk::wallet::types::KeysetLoadPolicy::CacheOnly)
|
||||
.await?;
|
||||
|
||||
let infos: Vec<KeySetInfo> = keysets
|
||||
.iter()
|
||||
.map(|keyset| KeySetInfo {
|
||||
id: keyset.id,
|
||||
unit: keyset.unit.clone(),
|
||||
active: keyset.active.unwrap_or(true),
|
||||
input_fee_ppk: keyset.input_fee_ppk,
|
||||
final_expiry: keyset.final_expiry,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let options = SendOptions {
|
||||
conditions: Some(SpendingConditions::new_p2pk(p2pk, None)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let token = wallet
|
||||
.prepare_send(Amount::from(amount), options)
|
||||
.await?
|
||||
.confirm(None)
|
||||
.await?;
|
||||
let proofs = token.proofs(&infos)?;
|
||||
|
||||
let mut tags = Vec::new();
|
||||
for proof in &proofs {
|
||||
tags.push(serde_json::to_string(proof)?);
|
||||
}
|
||||
|
||||
let event = keys::build_nutzap_event(
|
||||
&ctx.signer,
|
||||
recipient,
|
||||
&mint_tag,
|
||||
&tags,
|
||||
target,
|
||||
comment.as_deref().unwrap_or(""),
|
||||
)
|
||||
.await?;
|
||||
ctx.client.send_event(&event).await?;
|
||||
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint_url)?,
|
||||
&ctx.previous_ids(&mint_url),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::Out,
|
||||
amount,
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::NutzapSent { update, amount })
|
||||
}
|
||||
Job::Receive { mint, token } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let received = wallet.receive(&token, ReceiveOptions::default()).await?;
|
||||
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::In,
|
||||
amount: u64::from(received),
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::Received {
|
||||
update,
|
||||
amount: u64::from(received),
|
||||
})
|
||||
}
|
||||
Job::RedeemNutzap {
|
||||
mint,
|
||||
proofs,
|
||||
nutzap_id,
|
||||
sender,
|
||||
} => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let options = ReceiveOptions {
|
||||
p2pk_signing_keys: vec![ctx.wallet_key.clone()],
|
||||
..Default::default()
|
||||
};
|
||||
let received = wallet.receive_proofs(proofs, options, None, None).await?;
|
||||
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::In,
|
||||
amount: u64::from(received),
|
||||
redeemed: vec![nutzap_id],
|
||||
sender: Some(sender),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::NutzapRedeemed {
|
||||
update,
|
||||
nutzap_id,
|
||||
amount: u64::from(received),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct TokenUpdate {
|
||||
pub(crate) event_id: EventId,
|
||||
pub(crate) token: TokenEvent,
|
||||
pub(crate) destroyed: Vec<EventId>,
|
||||
}
|
||||
|
||||
pub(crate) enum Outcome {
|
||||
/// Nothing changed (e.g. an unpaid quote)
|
||||
Idle,
|
||||
/// Token events published by a reconcile pass
|
||||
Reconciled {
|
||||
updates: Vec<TokenUpdate>,
|
||||
},
|
||||
QuoteCreated {
|
||||
mint: MintUrl,
|
||||
quote_id: String,
|
||||
request: String,
|
||||
amount: u64,
|
||||
},
|
||||
QuoteMinted {
|
||||
quote_id: String,
|
||||
update: TokenUpdate,
|
||||
amount: u64,
|
||||
},
|
||||
Melted {
|
||||
update: TokenUpdate,
|
||||
amount: u64,
|
||||
},
|
||||
Sent {
|
||||
update: TokenUpdate,
|
||||
token: String,
|
||||
},
|
||||
NutzapSent {
|
||||
update: TokenUpdate,
|
||||
amount: u64,
|
||||
},
|
||||
Received {
|
||||
update: TokenUpdate,
|
||||
amount: u64,
|
||||
},
|
||||
NutzapRedeemed {
|
||||
update: TokenUpdate,
|
||||
nutzap_id: EventId,
|
||||
amount: u64,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct JobCtx<S> {
|
||||
pub(crate) wallets: HashMap<MintUrl, Wallet>,
|
||||
/// Wallet signing key, unlocking P2PK-locked nutzap proofs
|
||||
pub(crate) wallet_key: SecretKey,
|
||||
pub(crate) signer: S,
|
||||
pub(crate) client: Client,
|
||||
/// Relay token events at schedule time.
|
||||
pub(crate) tokens: HashMap<EventId, TokenEvent>,
|
||||
}
|
||||
|
||||
impl<S> JobCtx<S>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync,
|
||||
{
|
||||
fn wallet(&self, mint: &MintUrl) -> Result<&Wallet, Error> {
|
||||
self.wallets
|
||||
.get(mint)
|
||||
.ok_or_else(|| anyhow!("no open wallet for mint {mint}"))
|
||||
}
|
||||
|
||||
fn previous_ids(&self, mint: &MintUrl) -> Vec<EventId> {
|
||||
previous_ids(&self.tokens, mint)
|
||||
}
|
||||
|
||||
/// Publish the store's current unspent set as a fresh token event.
|
||||
async fn sync_token_events(
|
||||
&self,
|
||||
wallet: &Wallet,
|
||||
mint: Url,
|
||||
previous: &[EventId],
|
||||
history: Option<keys::History>,
|
||||
) -> Result<TokenUpdate, Error> {
|
||||
let me = self.signer.get_public_key_async().await?;
|
||||
let unspent = wallet.get_unspent_proofs().await?;
|
||||
|
||||
let proofs = unspent
|
||||
.iter()
|
||||
.map(cashu_proof)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let mut token = TokenEvent::new(mint, proofs);
|
||||
token.del = previous.iter().map(|id| id.to_hex()).collect();
|
||||
let event = keys::build_token_event(&self.signer, &me, &token).await?;
|
||||
self.client.send_event(&event).await?;
|
||||
|
||||
if !previous.is_empty() {
|
||||
let deletion = keys::build_token_deletion(&self.signer, previous).await?;
|
||||
self.client.send_event(&deletion).await?;
|
||||
}
|
||||
|
||||
if let Some(history) = history {
|
||||
let history_event =
|
||||
keys::build_history_event(&self.signer, &me, &history, event.id, previous).await?;
|
||||
self.client.send_event(&history_event).await?;
|
||||
}
|
||||
|
||||
Ok(TokenUpdate {
|
||||
event_id: event.id,
|
||||
token,
|
||||
destroyed: previous.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Claim paid quotes, then converge each mint.
|
||||
async fn reconcile(self) -> Result<Outcome, Error> {
|
||||
let mut tokens = self.tokens.clone();
|
||||
let mut updates = Vec::new();
|
||||
|
||||
for (mint_url, wallet) in &self.wallets {
|
||||
if let Err(error) = wallet.recover_incomplete_sagas().await {
|
||||
log::warn!("saga recovery failed for {mint_url}: {error:#}");
|
||||
}
|
||||
|
||||
let minted = wallet.mint_unissued_quotes().await?;
|
||||
if minted == Amount::ZERO {
|
||||
continue;
|
||||
}
|
||||
|
||||
let update = self
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(mint_url)?,
|
||||
&previous_ids(&tokens, mint_url),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::In,
|
||||
amount: u64::from(minted),
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for id in &update.destroyed {
|
||||
tokens.remove(id);
|
||||
}
|
||||
tokens.insert(update.event_id, update.token.clone());
|
||||
updates.push(update);
|
||||
}
|
||||
|
||||
for (mint_url, wallet) in &self.wallets {
|
||||
let mut relay_ys = HashSet::new();
|
||||
let mut added = Vec::new();
|
||||
|
||||
for token in tokens
|
||||
.values()
|
||||
.filter(|token| token.mint.as_str() == mint_url.to_string())
|
||||
{
|
||||
for proof in &token.proofs {
|
||||
let info = match proof_info(proof, mint_url) {
|
||||
Ok(info) => info,
|
||||
Err(error) => {
|
||||
log::warn!("skipping proof: {error:#}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if relay_ys.insert(info.y) {
|
||||
added.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let unspent = wallet.get_unspent_proofs().await?;
|
||||
let removed = unspent
|
||||
.iter()
|
||||
.filter(|proof| match proof.y() {
|
||||
Ok(y) => !relay_ys.contains(&y),
|
||||
Err(_) => false,
|
||||
})
|
||||
.map(|proof| proof.y())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
wallet.localstore.update_proofs(added, removed).await?;
|
||||
}
|
||||
|
||||
Ok(Outcome::Reconciled { updates })
|
||||
}
|
||||
}
|
||||
|
||||
fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<EventId> {
|
||||
tokens
|
||||
.iter()
|
||||
.filter(|(_, token)| token.mint.as_str() == mint.to_string())
|
||||
.map(|(id, _)| *id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve a recipient's 10019 wallet info and pick a mint we share with them.
|
||||
async fn recipient_wallet_info<S>(
|
||||
ctx: &JobCtx<S>,
|
||||
recipient: PublicKey,
|
||||
) -> Result<(MintUrl, String, cdk::nuts::PublicKey), Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync,
|
||||
{
|
||||
let filter = Filter::new()
|
||||
.author(recipient)
|
||||
.kind(Kind::Custom(10019))
|
||||
.limit(1);
|
||||
|
||||
let info = match ctx
|
||||
.client
|
||||
.database()
|
||||
.query(filter.clone())
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
{
|
||||
Some(event) => event,
|
||||
None => {
|
||||
let mut stream = ctx
|
||||
.client
|
||||
.stream_events(filter)
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
.await?;
|
||||
let mut found: Option<Event> = None;
|
||||
|
||||
while let Some((_url, result)) = stream.next().await {
|
||||
if let Ok(event) = result {
|
||||
let newer = found
|
||||
.as_ref()
|
||||
.is_none_or(|current| event.created_at > current.created_at);
|
||||
|
||||
if newer {
|
||||
found = Some(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
found.ok_or_else(|| anyhow!("recipient has no wallet info"))?
|
||||
}
|
||||
};
|
||||
|
||||
let (p2pk, mints) = parse_nutzap_info(&info)?;
|
||||
|
||||
for mint in mints {
|
||||
let Ok(mint_url) = MintUrl::from_str(&mint) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if ctx.wallets.contains_key(&mint_url) {
|
||||
return Ok((mint_url, mint, p2pk));
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("no shared mint with the recipient"))
|
||||
}
|
||||
|
||||
fn parse_nutzap_info(event: &Event) -> Result<(cdk::nuts::PublicKey, Vec<String>), Error> {
|
||||
let mut p2pk = None;
|
||||
let mut mints = Vec::new();
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
let Some(value) = tag.content() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match tag.kind() {
|
||||
"pubkey" if p2pk.is_none() => {
|
||||
p2pk = Some(cdk::nuts::PublicKey::from_str(value)?);
|
||||
}
|
||||
"mint" => mints.push(value.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(p2pk) = p2pk else {
|
||||
return Err(anyhow!("recipient has no wallet pubkey"));
|
||||
};
|
||||
|
||||
Ok((p2pk, mints))
|
||||
}
|
||||
|
||||
fn cashu_proof(proof: &Proof) -> Result<CashuProof, Error> {
|
||||
Ok(CashuProof {
|
||||
id: proof.keyset_id.to_string(),
|
||||
amount: u64::from(proof.amount),
|
||||
secret: proof.secret.to_string(),
|
||||
c: proof.c.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn nostr_mint(mint: &MintUrl) -> Result<Url, Error> {
|
||||
Url::parse(&mint.to_string()).map_err(|error| anyhow!("invalid mint url: {error}"))
|
||||
}
|
||||
|
||||
fn proof_info(proof: &CashuProof, mint: &MintUrl) -> Result<ProofInfo, Error> {
|
||||
let keyset_id = Id::from_str(&proof.id)?;
|
||||
let c = cdk::nuts::PublicKey::from_str(&proof.c)?;
|
||||
let info = ProofInfo::new(
|
||||
Proof::new(
|
||||
Amount::from(proof.amount),
|
||||
keyset_id,
|
||||
Secret::new(proof.secret.clone()),
|
||||
c,
|
||||
),
|
||||
mint.clone(),
|
||||
State::Unspent,
|
||||
CurrencyUnit::Sat,
|
||||
)?;
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "nip60_ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
nip60 = { path = "../nip60" }
|
||||
person = { path = "../person" }
|
||||
settings = { path = "../settings" }
|
||||
state = { path = "../state" }
|
||||
ui = { path = "../ui" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui-kit.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smallvec.workspace = true
|
||||
@@ -0,0 +1,104 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, div, px};
|
||||
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::input::{InputState, NumberInput};
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::{WindowExt, h_flex, v_flex};
|
||||
use nip60::WalletRegistry;
|
||||
use nostr_sdk::prelude::{EventId, PublicKey};
|
||||
|
||||
pub mod wallet;
|
||||
|
||||
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
|
||||
|
||||
pub fn open_nutzap(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
recipient: PublicKey,
|
||||
target: Option<EventId>,
|
||||
) {
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Amount (sats)")
|
||||
.step(1.)
|
||||
.min(1.)
|
||||
});
|
||||
|
||||
let send: SubmitFn = {
|
||||
let input = input.clone();
|
||||
Rc::new(move |_, window, cx| {
|
||||
let amount = match input.read(cx).value().trim().parse::<u64>() {
|
||||
Ok(amount) if amount > 0 => amount,
|
||||
_ => {
|
||||
window.push_notification(Notification::error("Enter an amount in sats"), cx);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
window.open_alert_dialog(cx, move |this, _window, _cx| {
|
||||
this.confirm()
|
||||
.width(px(360.))
|
||||
.title(format!("Send {amount} sats?"))
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Send")
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.child("A nutzap cannot be undone once sent."),
|
||||
)
|
||||
.on_ok(move |_event, window, cx| {
|
||||
let wallet = WalletRegistry::global(cx);
|
||||
wallet.update(cx, |registry, cx| {
|
||||
registry.send_nutzap(recipient, amount, target, None, cx);
|
||||
});
|
||||
|
||||
// Close the confirmation, then the amount dialog beneath it.
|
||||
window.close_dialog(cx);
|
||||
window.close_dialog(cx);
|
||||
true
|
||||
})
|
||||
});
|
||||
|
||||
// The amount dialog stays open beneath the confirmation.
|
||||
false
|
||||
})
|
||||
};
|
||||
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
this.width(px(380.))
|
||||
.title("Send eCash")
|
||||
.on_ok({
|
||||
let send = send.clone();
|
||||
move |event, window, cx| send(event, window, cx)
|
||||
})
|
||||
.child(
|
||||
v_flex().gap_3().child(NumberInput::new(&input)).child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.justify_end()
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.ghost()
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
)
|
||||
.child(Button::new("send").label("Send").primary().on_click({
|
||||
let send = send.clone();
|
||||
move |event, window, cx| {
|
||||
if send(event, window, cx) {
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
}
|
||||
})),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
use std::ops::Range;
|
||||
use std::rc::Rc;
|
||||
|
||||
use ::nip60::{HistoryEntry, WalletEvent, WalletRegistry};
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||
relative, uniform_list,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants as _};
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
|
||||
use gpui_kit::component::list::ListItem;
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::separator::Separator;
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme, Disableable as _, Icon, Sizable, WindowExt, h_flex, v_flex,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::Directory;
|
||||
use settings::AppSettings;
|
||||
use smallvec::SmallVec;
|
||||
use state::NostrRegistry;
|
||||
use ui::Avatar;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<WalletPanel> {
|
||||
cx.new(|cx| WalletPanel::new(window, cx))
|
||||
}
|
||||
|
||||
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
|
||||
|
||||
pub struct WalletPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl WalletPanel {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let wallet = WalletRegistry::global(cx);
|
||||
let mut subscriptions = SmallVec::new();
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&wallet,
|
||||
window,
|
||||
|_this, _wallet, event, window, cx| match event {
|
||||
WalletEvent::Updated => cx.notify(),
|
||||
WalletEvent::Error(error) => {
|
||||
window.push_notification(Notification::error(error.clone()), cx);
|
||||
}
|
||||
WalletEvent::TokenCreated(_) => {}
|
||||
},
|
||||
));
|
||||
|
||||
Self {
|
||||
name: "Wallet".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_balance(&self, cx: &Context<Self>) -> impl IntoElement {
|
||||
let balance = WalletRegistry::global(cx).read(cx).balance();
|
||||
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_3xl()
|
||||
.line_height(relative(1.0))
|
||||
.child(SharedString::from(balance.to_string())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.italic()
|
||||
.line_height(relative(1.0))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("sats"),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_actions(&self) -> impl IntoElement {
|
||||
h_flex().w_full().justify_center().child(
|
||||
h_flex()
|
||||
.w_80()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("send")
|
||||
.label("Send")
|
||||
.secondary()
|
||||
.rounded_full()
|
||||
.flex_1()
|
||||
.on_click(|_, window, cx| open_send_dialog(window, cx)),
|
||||
)
|
||||
.child(
|
||||
Button::new("receive")
|
||||
.label("Receive")
|
||||
.secondary()
|
||||
.rounded_full()
|
||||
.flex_1()
|
||||
.on_click(|_, window, cx| open_receive_dialog(window, cx)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_history(
|
||||
&mut self,
|
||||
history: Vec<HistoryEntry>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
if history.is_empty() {
|
||||
return v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h_32()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.border_1()
|
||||
.border_dashed()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.child("No transactions yet")
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
uniform_list(
|
||||
"wallet-history",
|
||||
history.len(),
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.render_history_rows(range, &history, cx)
|
||||
}),
|
||||
)
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_history_rows(
|
||||
&self,
|
||||
range: std::ops::Range<usize>,
|
||||
history: &[HistoryEntry],
|
||||
cx: &Context<Self>,
|
||||
) -> Vec<AnyElement> {
|
||||
range
|
||||
.map(|ix| {
|
||||
self.render_history_entry(&history[ix], cx)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_history_entry(&self, entry: &HistoryEntry, cx: &Context<Self>) -> impl IntoElement {
|
||||
let (label, color) = match entry.direction {
|
||||
TransactionDirection::In => ("Received", cx.theme().success),
|
||||
TransactionDirection::Out => ("Sent", cx.theme().danger),
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.w_full()
|
||||
.py_0p5()
|
||||
.text_xs()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(div().text_color(color).child(label))
|
||||
.child(SharedString::from(format!("{} sats", entry.amount))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(entry.created_at.to_human_time()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick a contact and an amount, then send a NIP-61 nutzap
|
||||
fn open_send_dialog(window: &mut Window, cx: &mut App) {
|
||||
let view = cx.new(|cx| SendDialog::new(window, cx));
|
||||
|
||||
window.open_dialog(cx, {
|
||||
let view = view.clone();
|
||||
move |this, _window, _cx| this.width(px(420.)).title("Send eCash").child(view.clone())
|
||||
});
|
||||
}
|
||||
|
||||
struct SendDialog {
|
||||
/// Search input, filters contacts or accepts an npub / NIP-05 address
|
||||
input: Entity<InputState>,
|
||||
amount_input: Entity<InputState>,
|
||||
/// Current user's contacts
|
||||
contacts: Option<Vec<PublicKey>>,
|
||||
/// Person directory for contact names and avatars
|
||||
directory: Entity<Directory>,
|
||||
/// The contact chosen to receive the nutzap
|
||||
selected: Option<PublicKey>,
|
||||
/// Whether a NIP-05 address is being resolved
|
||||
resolving: bool,
|
||||
error: Option<SharedString>,
|
||||
tasks: Vec<Task<Result<(), anyhow::Error>>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl SendDialog {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx).placeholder("Search or enter npub or user@address.me")
|
||||
});
|
||||
let amount_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Amount (sats)")
|
||||
.step(1.)
|
||||
.min(1.)
|
||||
});
|
||||
|
||||
let subscriptions = vec![
|
||||
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.submit(window, cx);
|
||||
}
|
||||
}),
|
||||
cx.subscribe_in(&amount_input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.confirm_send(window, cx);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
// Load the contacts once the view is mounted
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load_contacts(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
input,
|
||||
amount_input,
|
||||
contacts: None,
|
||||
directory: Directory::new(cx),
|
||||
selected: None,
|
||||
resolving: false,
|
||||
error: None,
|
||||
tasks: Vec::new(),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the signed-in user's contact list.
|
||||
fn load_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.contacts.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(current_user) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<Vec<PublicKey>, anyhow::Error>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.author(current_user)
|
||||
.kind(Kind::ContactList)
|
||||
.limit(1);
|
||||
|
||||
let contacts: Vec<PublicKey> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|event| event.tags.public_keys().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(contacts)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let contacts = task.await;
|
||||
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
match contacts {
|
||||
Ok(contacts) => {
|
||||
this.directory.update(cx, |directory, cx| {
|
||||
directory.track(contacts.iter().copied(), cx)
|
||||
});
|
||||
this.contacts = Some(contacts);
|
||||
}
|
||||
Err(error) => this.set_error(error.to_string(), cx),
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// The contacts matching the current input, or every contact when empty.
|
||||
fn filtered_contacts(&self, cx: &Context<Self>) -> Vec<PublicKey> {
|
||||
let Some(contacts) = self.contacts.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let query = self.input.read(cx).value().trim().to_lowercase();
|
||||
|
||||
if query.is_empty() {
|
||||
return contacts.clone();
|
||||
}
|
||||
|
||||
contacts
|
||||
.iter()
|
||||
.filter(|public_key| {
|
||||
if public_key.to_hex().to_lowercase().contains(&query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let name = self.directory.read(cx).person(public_key, cx).name();
|
||||
name.to_lowercase().contains(&query)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve the input into a contact selection.
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.resolving {
|
||||
return;
|
||||
}
|
||||
|
||||
let query = self.input.read(cx).value().trim().to_string();
|
||||
|
||||
if query.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// A raw public key or an npub can be selected directly.
|
||||
if let Ok(public_key) = PublicKey::parse(&query) {
|
||||
self.select(public_key, cx);
|
||||
self.clear_input(window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// A NIP-05 address is resolved before being selected.
|
||||
if let Ok(address) = Nip05Address::parse(&query) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let task = nostr.read(cx).query_address(address, cx);
|
||||
|
||||
self.set_resolving(true, window, cx);
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_resolving(false, window, cx);
|
||||
|
||||
match result {
|
||||
Ok(public_key) => {
|
||||
this.select(public_key, cx);
|
||||
this.clear_input(window, cx);
|
||||
}
|
||||
Err(error) => this.set_error(error.to_string(), cx),
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to a local search by contact name.
|
||||
let matches = self.filtered_contacts(cx);
|
||||
match matches.len() {
|
||||
0 => self.set_error("Enter a valid npub or NIP-05 address", cx),
|
||||
1 => {
|
||||
let public_key = matches[0];
|
||||
self.select(public_key, cx);
|
||||
self.clear_input(window, cx);
|
||||
}
|
||||
_ => self.set_error("Multiple contacts match, pick one from the list", cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a single recipient for the nutzap.
|
||||
fn select(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
if nostr.read(cx).current_user() == Some(public_key) {
|
||||
self.set_error("You cannot send to yourself", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
self.directory
|
||||
.update(cx, |directory, cx| directory.track([public_key], cx));
|
||||
self.selected = Some(public_key);
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Toggle a contact's selection.
|
||||
fn toggle_selection(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
|
||||
if self.selected == Some(*public_key) {
|
||||
self.selected = None;
|
||||
cx.notify();
|
||||
} else {
|
||||
self.select(*public_key, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the form, then confirm before the irreversible nutzap.
|
||||
fn confirm_send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(recipient) = self.selected else {
|
||||
self.set_error("Select a contact to send to", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(amount) = parse_amount(&self.amount_input, cx) else {
|
||||
self.set_error("Enter an amount in sats", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let recipient_name = self.directory.read(cx).person(&recipient, cx).name();
|
||||
|
||||
window.open_alert_dialog(cx, move |this, _window, _cx| {
|
||||
this.confirm()
|
||||
.width(px(360.))
|
||||
.title(format!("Send {amount} sats to {recipient_name}?"))
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Send")
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.child("A nutzap cannot be undone once sent."),
|
||||
)
|
||||
.on_ok(move |_event, window, cx| {
|
||||
WalletRegistry::global(cx).update(cx, |registry, cx| {
|
||||
registry.send_nutzap(recipient, amount, None, None, cx);
|
||||
});
|
||||
// Close the confirmation, then the send dialog beneath it.
|
||||
window.close_dialog(cx);
|
||||
window.close_dialog(cx);
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn clear_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Toggle the input's loading spinner while a NIP-05 address is resolved.
|
||||
fn set_resolving(&mut self, resolving: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(resolving, window, cx);
|
||||
});
|
||||
self.resolving = resolving;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||
self.error = Some(message.into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_contacts(
|
||||
&self,
|
||||
range: Range<usize>,
|
||||
contacts: &[PublicKey],
|
||||
cx: &mut Context<Self>,
|
||||
) -> Vec<AnyElement> {
|
||||
contacts
|
||||
.get(range)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|public_key| {
|
||||
let profile = self.directory.read(cx).person(public_key, cx);
|
||||
let public_key = *public_key;
|
||||
let selected = self.selected == Some(public_key);
|
||||
let id = SharedString::from(format!("contact-{}", public_key.to_hex()));
|
||||
|
||||
let mut item = ListItem::new(id)
|
||||
.accessibility_label(profile.name())
|
||||
.selected(selected)
|
||||
.cursor_pointer()
|
||||
.w_full()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.toggle_selection(&public_key, cx);
|
||||
}));
|
||||
|
||||
if !AppSettings::get_hide_avatar(cx) {
|
||||
item = item.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.small(),
|
||||
);
|
||||
}
|
||||
|
||||
item.child(div().flex_1().min_w_0().truncate().child(profile.name()))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_selected(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(public_key) = self.selected else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let profile = self.directory.read(cx).person(&public_key, cx);
|
||||
let id = SharedString::from(format!("selected-{}", public_key.to_hex()));
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.p_0p5()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().secondary)
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.xsmall(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.max_w_32()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.line_height(relative(1.0))
|
||||
.child(profile.name()),
|
||||
)
|
||||
.child(
|
||||
Button::new(id)
|
||||
.icon(IconName::Close)
|
||||
.tooltip("Remove recipient")
|
||||
.accessibility_label("Remove recipient")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.selected = None;
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SendDialog {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const NOTICE: &str = "Recipients need a NIP-60 wallet on a mint you both use.";
|
||||
|
||||
let contacts = Rc::new(self.filtered_contacts(cx));
|
||||
let contact_count = contacts.len();
|
||||
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(NumberInput::new(&self.amount_input))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(Input::new(&self.input).flex_1().cleanable(true))
|
||||
.child(
|
||||
Button::new("add")
|
||||
.icon(IconName::Plus)
|
||||
.tooltip("Add person")
|
||||
.ghost()
|
||||
.large()
|
||||
.disabled(self.resolving)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.submit(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.when(self.selected.is_some(), |this| {
|
||||
this.child(self.render_selected(cx))
|
||||
})
|
||||
.child(
|
||||
uniform_list(
|
||||
"send-contacts",
|
||||
contact_count,
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.render_contacts(range, contacts.as_slice(), cx)
|
||||
}),
|
||||
)
|
||||
.h_64(),
|
||||
)
|
||||
.map(|this| {
|
||||
if let Some(error) = self.error.clone() {
|
||||
this.child(div().text_xs().text_color(cx.theme().danger).child(error))
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(NOTICE),
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.justify_end()
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.secondary()
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
)
|
||||
.child(
|
||||
Button::new("send")
|
||||
.label("Send")
|
||||
.primary()
|
||||
.on_click(cx.listener(move |this, _event, window, cx| {
|
||||
this.confirm_send(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a lightning invoice or redeem a pasted cashu token
|
||||
fn open_receive_dialog(window: &mut Window, cx: &mut App) {
|
||||
let amount_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Amount (sats)")
|
||||
.step(1.)
|
||||
.min(1.)
|
||||
});
|
||||
let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu"));
|
||||
|
||||
let create_invoice: SubmitFn = {
|
||||
let wallet = WalletRegistry::global(cx);
|
||||
let input = amount_input.clone();
|
||||
Rc::new(move |_, window, cx| {
|
||||
let Some(amount) = parse_amount(&input, cx) else {
|
||||
window.push_notification(Notification::error("Enter an amount in sats"), cx);
|
||||
return false;
|
||||
};
|
||||
|
||||
wallet.update(cx, |registry, cx| {
|
||||
registry.create_mint_quote(amount, cx);
|
||||
});
|
||||
|
||||
true
|
||||
})
|
||||
};
|
||||
|
||||
let redeem: SubmitFn = {
|
||||
let wallet = WalletRegistry::global(cx);
|
||||
let input = token_input.clone();
|
||||
Rc::new(move |_, window, cx| {
|
||||
let token = input.read(cx).value().trim().to_string();
|
||||
|
||||
if token.is_empty() {
|
||||
window.push_notification(Notification::error("Paste a cashu token"), cx);
|
||||
return false;
|
||||
}
|
||||
|
||||
wallet.update(cx, |registry, cx| {
|
||||
registry.receive_token(token, cx);
|
||||
});
|
||||
|
||||
true
|
||||
})
|
||||
};
|
||||
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
this.width(px(420.))
|
||||
.title("Receive")
|
||||
.on_ok({
|
||||
let token_input = token_input.clone();
|
||||
let create_invoice = create_invoice.clone();
|
||||
let redeem = redeem.clone();
|
||||
move |_event, window, cx| {
|
||||
if !token_input.read(cx).value().trim().is_empty() {
|
||||
redeem(_event, window, cx)
|
||||
} else {
|
||||
create_invoice(_event, window, cx)
|
||||
}
|
||||
}
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(div().text_xs().font_semibold().child("Lightning invoice:"))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(NumberInput::new(&amount_input))
|
||||
.child(
|
||||
Button::new("invoice")
|
||||
.label("Invoice")
|
||||
.secondary()
|
||||
.w_24()
|
||||
.on_click({
|
||||
let create_invoice = create_invoice.clone();
|
||||
move |event, window, cx| {
|
||||
if create_invoice(event, window, cx) {
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(Separator::horizontal())
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(div().text_xs().font_semibold().child("Cashu token:"))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(Input::new(&token_input).cleanable(true))
|
||||
.child(
|
||||
Button::new("redeem")
|
||||
.label("Redeem")
|
||||
.secondary()
|
||||
.w_24()
|
||||
.on_click({
|
||||
let redeem = redeem.clone();
|
||||
move |event, window, cx| {
|
||||
if redeem(event, window, cx) {
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_amount(input: &Entity<InputState>, cx: &App) -> Option<u64> {
|
||||
match input.read(cx).value().trim().parse::<u64>() {
|
||||
Ok(amount) if amount > 0 => Some(amount),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui_kit::component::dock::BasePanel for WalletPanel {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"Wallet"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for WalletPanel {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
Icon::new(IconName::Wallet)
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(self.name.clone())
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for WalletPanel {}
|
||||
|
||||
impl Focusable for WalletPanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for WalletPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let history = WalletRegistry::global(cx).read(cx).history().to_vec();
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.p_3()
|
||||
.gap_4()
|
||||
.text_xs()
|
||||
.child(self.render_balance(cx))
|
||||
.child(self.render_actions())
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("History"),
|
||||
)
|
||||
.child(self.render_history(history, cx)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ publish.workspace = true
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
instant.workspace = true
|
||||
|
||||
@@ -3,7 +3,10 @@ use std::sync::RwLock;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::emojis_from_tags;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||
use futures::{FutureExt, pin_mut};
|
||||
use gpui::{
|
||||
App, AppContext, BackgroundExecutor, Context, Entity, EventEmitter, Global, Subscription, Task,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
@@ -51,7 +54,7 @@ impl EventEmitter<PersonEvent> for PersonRegistry {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Dispatch {
|
||||
Person(Person),
|
||||
Person(Box<Person>),
|
||||
Announcement(Event),
|
||||
Relays(Event),
|
||||
Emojis(Event),
|
||||
@@ -98,8 +101,9 @@ impl PersonRegistry {
|
||||
}));
|
||||
|
||||
let client3 = client.clone();
|
||||
let executor = cx.background_executor().clone();
|
||||
tasks.push(cx.background_spawn(async move {
|
||||
Self::handle_requests(&client3, &metadata_rx).await;
|
||||
Self::handle_requests(&client3, &metadata_rx, executor).await;
|
||||
}));
|
||||
|
||||
tasks.push(cx.spawn(async move |this, cx| {
|
||||
@@ -107,7 +111,7 @@ impl PersonRegistry {
|
||||
this.update(cx, |this, cx| {
|
||||
match event {
|
||||
Dispatch::Person(person) => {
|
||||
this.insert(person, cx);
|
||||
this.insert(*person, cx);
|
||||
}
|
||||
Dispatch::Announcement(event) => {
|
||||
this.set_announcement(&event, cx);
|
||||
@@ -171,7 +175,11 @@ impl PersonRegistry {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
let person = Person::new(event.pubkey, metadata);
|
||||
|
||||
if tx.send_async(Dispatch::Person(person)).await.is_err() {
|
||||
if tx
|
||||
.send_async(Dispatch::Person(Box::new(person)))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
log::warn!("Channel closed, dropping metadata event");
|
||||
}
|
||||
}
|
||||
@@ -193,30 +201,45 @@ impl PersonRegistry {
|
||||
}
|
||||
|
||||
/// Handle request for metadata
|
||||
async fn handle_requests(client: &Client, rx: &flume::Receiver<PublicKey>) {
|
||||
async fn handle_requests(
|
||||
client: &Client,
|
||||
rx: &flume::Receiver<PublicKey>,
|
||||
executor: BackgroundExecutor,
|
||||
) {
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
|
||||
loop {
|
||||
match flume::Selector::new()
|
||||
.recv(rx, |result| result.ok())
|
||||
.wait_timeout(Duration::from_secs(TIMEOUT))
|
||||
{
|
||||
Ok(Some(public_key)) => {
|
||||
batch.insert(public_key);
|
||||
// Process the batch if it's full
|
||||
if batch.len() >= 20
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
let timer = executor.timer(Duration::from_secs(TIMEOUT)).fuse();
|
||||
pin_mut!(timer);
|
||||
|
||||
let request = futures::select! {
|
||||
result = rx.recv_async().fuse() => match result {
|
||||
Ok(public_key) => public_key,
|
||||
Err(_) => {
|
||||
if !batch.is_empty()
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
},
|
||||
() = timer => {
|
||||
if !batch.is_empty()
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
batch.insert(request);
|
||||
// Process the batch if it's full
|
||||
if batch.len() >= 20
|
||||
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
|
||||
{
|
||||
log::warn!("Failed to get metadata batch: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,13 @@ impl Person {
|
||||
|
||||
/// Set profile encryption keys announcement
|
||||
pub(crate) fn set_announcement(&mut self, announcement: Announcement) {
|
||||
// A delayed or repeated announcement must not roll the key back
|
||||
if let Some(current) = &self.announcement
|
||||
&& !announcement.is_newer_than(current)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.announcement = Some(announcement);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use gpui::{Context, EventEmitter, Task};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
use state::{BOOTSTRAP_RELAYS, NostrRegistry};
|
||||
|
||||
/// Disable scoring when the follow set exceeds this size
|
||||
const MAX_FOLLOWS: usize = 2000;
|
||||
@@ -14,6 +14,10 @@ const MAX_TAGS_PER_LIST: usize = 5000;
|
||||
const SYNC_INTERVAL: u64 = 15 * 60;
|
||||
/// Longest wait between retries while the graph has no data yet
|
||||
const MAX_RETRY_WAIT: u64 = 60;
|
||||
/// Time for a relay to prove negentropy support before the reconcile gives up
|
||||
const SYNC_INITIAL_TIMEOUT: u64 = 30;
|
||||
/// Longest silence from a relay before the reconcile gives up
|
||||
const SYNC_IDLE_TIMEOUT: u64 = 60;
|
||||
|
||||
/// The web of trust graph changed
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -77,6 +81,36 @@ impl Snapshot {
|
||||
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Build the snapshot from the contact lists the local database holds
|
||||
async fn load(client: &Client, self_key: PublicKey) -> Option<Self> {
|
||||
let own = stored_contact_list(client, self_key).await?;
|
||||
let follows: HashSet<PublicKey> = own.tags.public_keys().take(MAX_FOLLOWS + 1).collect();
|
||||
|
||||
let events: Vec<Event> = if follows.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let authors: Vec<PublicKey> = follows.iter().copied().collect();
|
||||
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
|
||||
|
||||
client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect()
|
||||
};
|
||||
|
||||
let snapshot = Self::build(self_key, follows, events);
|
||||
log::debug!(
|
||||
"wot loaded from database: {} follows, {} scored keys",
|
||||
snapshot.follows.len(),
|
||||
snapshot.scores.len()
|
||||
);
|
||||
|
||||
Some(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<WoTEvent> for WebOfTrust {}
|
||||
@@ -88,8 +122,10 @@ impl EventEmitter<WoTEvent> for WebOfTrust {}
|
||||
pub struct WebOfTrust {
|
||||
/// Current user's public key
|
||||
self_key: Option<PublicKey>,
|
||||
/// Latest built snapshot, none until the first refresh completes
|
||||
/// Latest built snapshot, none until the first load completes
|
||||
snapshot: Option<Snapshot>,
|
||||
/// Whether a reconciliation with the relays is in flight
|
||||
syncing: bool,
|
||||
/// In-flight refresh, replaced by newer ones
|
||||
refresh_task: Option<Task<()>>,
|
||||
/// Keeps the periodic reconciliation loop alive
|
||||
@@ -110,9 +146,9 @@ impl WebOfTrust {
|
||||
|
||||
let mut has_data = false;
|
||||
|
||||
// Ignore errors, the next refresh will retry
|
||||
// Ignore errors, the next sync will retry
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.refresh(cx);
|
||||
this.sync(cx);
|
||||
has_data = this.has_data();
|
||||
});
|
||||
|
||||
@@ -133,34 +169,14 @@ impl WebOfTrust {
|
||||
Self {
|
||||
self_key: None,
|
||||
snapshot: None,
|
||||
syncing: false,
|
||||
refresh_task: None,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the graph for the current account
|
||||
/// Load the graph for the current account from the local database
|
||||
pub(crate) fn hydrate(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let Some(self_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.self_key = Some(self_key);
|
||||
self.refresh(cx);
|
||||
}
|
||||
|
||||
/// Clear the graph on sign out or account switch
|
||||
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.self_key = None;
|
||||
self.snapshot = None;
|
||||
self.refresh_task = None;
|
||||
|
||||
cx.emit(WoTEvent);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Reconcile the follow set's contact lists with the relays and rebuild.
|
||||
fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
@@ -168,6 +184,56 @@ impl WebOfTrust {
|
||||
return;
|
||||
};
|
||||
|
||||
self.self_key = Some(self_key);
|
||||
// Replacing the task below cancels any in-flight work
|
||||
self.syncing = false;
|
||||
|
||||
self.refresh_task = Some(cx.spawn(async move |this, cx| {
|
||||
let Some(snapshot) = Snapshot::load(&client, self_key).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.snapshot = Some(snapshot);
|
||||
cx.emit(WoTEvent);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
/// Clear the graph on sign out or account switch
|
||||
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.self_key = None;
|
||||
self.snapshot = None;
|
||||
self.syncing = false;
|
||||
self.refresh_task = None;
|
||||
|
||||
cx.emit(WoTEvent);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Reconcile the follow set's contact lists with the bootstrap relays and rebuild.
|
||||
fn sync(&mut self, cx: &mut Context<Self>) {
|
||||
if self.syncing {
|
||||
return;
|
||||
}
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(self_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// The defaults are too short for relays streaming large batches
|
||||
let opts = SyncOptions::new()
|
||||
.initial_timeout(Duration::from_secs(SYNC_INITIAL_TIMEOUT))
|
||||
.idle_timeout(Duration::from_secs(SYNC_IDLE_TIMEOUT));
|
||||
|
||||
self.syncing = true;
|
||||
cx.notify();
|
||||
|
||||
self.refresh_task = Some(cx.spawn(async move |this, cx| {
|
||||
// Scope the reconcile to the follow set known so far
|
||||
let known: HashSet<PublicKey> = stored_contact_list(&client, self_key)
|
||||
@@ -178,60 +244,37 @@ impl WebOfTrust {
|
||||
let mut authors: Vec<PublicKey> = known.iter().copied().collect();
|
||||
authors.push(self_key);
|
||||
|
||||
// Negentropy transfers only the lists the relays hold
|
||||
// and the database is missing
|
||||
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
|
||||
if let Err(error) = client.sync(filter).await {
|
||||
log::warn!("Failed to reconcile contact lists: {error}");
|
||||
match client.sync(filter).with(BOOTSTRAP_RELAYS).opts(opts).await {
|
||||
Ok(output) => {
|
||||
for (url, error) in output.failed {
|
||||
log::warn!("Failed to reconcile contact lists with {url}: {error}");
|
||||
}
|
||||
}
|
||||
Err(error) => log::warn!("Failed to reconcile contact lists: {error}"),
|
||||
}
|
||||
|
||||
// The follow set after the reconcile
|
||||
let Some(own) = stored_contact_list(&client, self_key).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let follows: HashSet<PublicKey> =
|
||||
own.tags.public_keys().take(MAX_FOLLOWS + 1).collect();
|
||||
|
||||
let mut authors: Vec<PublicKey> = follows.iter().copied().collect();
|
||||
authors.push(self_key);
|
||||
|
||||
let events: Vec<Event> = if follows.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
|
||||
client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect()
|
||||
};
|
||||
|
||||
let snapshot = Snapshot::build(self_key, follows.clone(), events);
|
||||
log::debug!(
|
||||
"Web of trust rebuilt: {} follows, {} scored keys",
|
||||
snapshot.follows.len(),
|
||||
snapshot.scores.len()
|
||||
);
|
||||
|
||||
// Newly followed authors were not part of the reconcile
|
||||
let grew = !known.is_superset(&follows);
|
||||
let snapshot = Snapshot::load(&client, self_key).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.snapshot = Some(snapshot);
|
||||
cx.emit(WoTEvent);
|
||||
this.syncing = false;
|
||||
|
||||
if let Some(snapshot) = snapshot {
|
||||
// Newly followed authors were not part of the reconcile
|
||||
let grew = !known.is_superset(&snapshot.follows);
|
||||
|
||||
this.snapshot = Some(snapshot);
|
||||
cx.emit(WoTEvent);
|
||||
|
||||
if grew {
|
||||
this.sync(cx);
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
|
||||
if grew {
|
||||
this.update(cx, |this, cx| {
|
||||
this.refresh(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -273,6 +316,11 @@ impl WebOfTrust {
|
||||
.is_some_and(|snapshot| !snapshot.disabled)
|
||||
}
|
||||
|
||||
/// Whether a reconciliation with the relays is in flight
|
||||
pub fn is_syncing(&self) -> bool {
|
||||
self.syncing
|
||||
}
|
||||
|
||||
/// Whether the snapshot covers a follow set
|
||||
fn has_data(&self) -> bool {
|
||||
self.snapshot
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task, px};
|
||||
use gpui_kit::component::{Theme, ThemeConfig, ThemeMode, ThemeRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
|
||||
/// Browser storage key for the settings JSON
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const SETTINGS_KEY: &str = "coop.settings";
|
||||
|
||||
/// The default base font size for the interface
|
||||
pub const DEFAULT_FONT_SIZE: f32 = 16.0;
|
||||
|
||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||
/// Minimum days between automatic encryption key rotations
|
||||
pub const NIP4E_ROTATION_MIN_DAYS: u32 = 1;
|
||||
/// Maximum days between automatic encryption key rotations
|
||||
pub const NIP4E_ROTATION_MAX_DAYS: u32 = 90;
|
||||
/// Days between automatic encryption key rotations when enabled
|
||||
pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30;
|
||||
/// Built-in image shown behind chat and group panels
|
||||
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||
|
||||
/// The user's appearance preference, persisted with the rest of the settings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum AppearanceMode {
|
||||
@@ -31,88 +52,6 @@ impl AppearanceMode {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
||||
}
|
||||
|
||||
/// Read the persisted appearance mode synchronously.
|
||||
///
|
||||
/// The async load fills the global settings later; the native theme must be
|
||||
/// applied before the first window opens, so boot reads the mode straight
|
||||
/// from the settings file instead.
|
||||
pub fn persisted_appearance_mode() -> AppearanceMode {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = config_dir().join(".settings");
|
||||
if let Ok(content) = std::fs::read_to_string(path)
|
||||
&& let Ok(settings) = serde_json::from_str::<Settings>(&content)
|
||||
{
|
||||
return settings.appearance_mode;
|
||||
}
|
||||
}
|
||||
AppearanceMode::default()
|
||||
}
|
||||
|
||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||
|
||||
/// Built-in image shown behind chat and group panels
|
||||
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||
|
||||
macro_rules! setting_accessors {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
|
||||
pub fn [<update_ $field>](value: $type, cx: &mut App) {
|
||||
Self::global(cx).update(cx, |this, cx| {
|
||||
this.inner.update(cx, |inner, cx| {
|
||||
inner.$field = value;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! setting_getters {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setting_accessors! {
|
||||
pub appearance_mode: AppearanceMode,
|
||||
pub hide_avatar: bool,
|
||||
pub screening: bool,
|
||||
pub wot_auto_trust: bool,
|
||||
pub nip4e: bool,
|
||||
pub hide_group_notice: bool,
|
||||
pub file_server: Url,
|
||||
pub panel_background: bool,
|
||||
pub panel_background_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
setting_getters! {
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
pub pinned_groups: Vec<String>,
|
||||
}
|
||||
|
||||
/// Signer kind
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum SignerKind {
|
||||
@@ -128,6 +67,30 @@ impl SignerKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// User tuning applied on top of the native OS theme
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ThemeSettings {
|
||||
/// The base UI font size in pixels
|
||||
pub font_size: f32,
|
||||
/// Draw a ring around focused controls
|
||||
pub focus_ring: bool,
|
||||
/// Corner radius for general elements; follows the OS theme when unset
|
||||
pub radius: Option<f32>,
|
||||
/// Corner radius for large elements like dialogs; follows the OS theme when unset
|
||||
pub radius_lg: Option<f32>,
|
||||
}
|
||||
|
||||
impl Default for ThemeSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
font_size: DEFAULT_FONT_SIZE,
|
||||
focus_ring: false,
|
||||
radius: None,
|
||||
radius_lg: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Room configuration
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct RoomConfig {
|
||||
@@ -176,9 +139,21 @@ pub struct Settings {
|
||||
pub wot_auto_trust: bool,
|
||||
/// Enable decoupling encryption key
|
||||
pub nip4e: bool,
|
||||
/// Days between automatic encryption key rotations; None disables rotation
|
||||
#[serde(default)]
|
||||
pub nip4e_rotation_days: Option<u32>,
|
||||
/// Hide the notice that messages in public groups are not encrypted
|
||||
#[serde(default)]
|
||||
pub hide_group_notice: bool,
|
||||
/// Tuning applied on top of the native OS theme
|
||||
#[serde(default)]
|
||||
pub theme: ThemeSettings,
|
||||
/// Bundled theme applied in light appearance; None uses the default light theme
|
||||
#[serde(default)]
|
||||
pub theme_light: Option<String>,
|
||||
/// Bundled theme applied in dark appearance; None uses the default dark theme
|
||||
#[serde(default)]
|
||||
pub theme_dark: Option<String>,
|
||||
/// Show an image behind chat and group panels
|
||||
#[serde(default)]
|
||||
pub panel_background: bool,
|
||||
@@ -205,7 +180,11 @@ impl Default for Settings {
|
||||
screening: true,
|
||||
wot_auto_trust: true,
|
||||
nip4e: false,
|
||||
nip4e_rotation_days: None,
|
||||
hide_group_notice: false,
|
||||
theme: ThemeSettings::default(),
|
||||
theme_light: None,
|
||||
theme_dark: None,
|
||||
panel_background: true,
|
||||
panel_background_path: None,
|
||||
trusted_relays: vec![],
|
||||
@@ -228,6 +207,64 @@ pub struct AppSettings {
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
macro_rules! setting_accessors {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
|
||||
pub fn [<update_ $field>](value: $type, cx: &mut App) {
|
||||
Self::global(cx).update(cx, |this, cx| {
|
||||
this.inner.update(cx, |inner, cx| {
|
||||
inner.$field = value;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! setting_getters {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setting_accessors! {
|
||||
pub appearance_mode: AppearanceMode,
|
||||
pub hide_avatar: bool,
|
||||
pub screening: bool,
|
||||
pub wot_auto_trust: bool,
|
||||
pub nip4e: bool,
|
||||
pub nip4e_rotation_days: Option<u32>,
|
||||
pub hide_group_notice: bool,
|
||||
pub file_server: Url,
|
||||
pub panel_background: bool,
|
||||
pub panel_background_path: Option<PathBuf>,
|
||||
pub theme_light: Option<String>,
|
||||
pub theme_dark: Option<String>,
|
||||
}
|
||||
|
||||
setting_getters! {
|
||||
pub theme: ThemeSettings,
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
pub pinned_groups: Vec<String>,
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
/// Retrieve the global settings instance
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
@@ -270,6 +307,50 @@ impl AppSettings {
|
||||
});
|
||||
}
|
||||
|
||||
/// Edit the theme tuning
|
||||
pub fn update_theme(edit: impl FnOnce(&mut ThemeSettings), cx: &mut App) {
|
||||
Self::global(cx).update(cx, |this, cx| {
|
||||
this.inner.update(cx, |inner, cx| {
|
||||
edit(&mut inner.theme);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn apply_theme_style(cx: &mut App) {
|
||||
Self::apply_theme_tuning(Self::get_theme(cx), cx);
|
||||
}
|
||||
|
||||
fn apply_theme_tuning(tuning: ThemeSettings, cx: &mut App) {
|
||||
{
|
||||
let styled = Theme::global_mut(cx);
|
||||
styled.font_size = px(tuning.font_size);
|
||||
styled.focus_ring = tuning.focus_ring;
|
||||
if let Some(radius) = tuning.radius {
|
||||
styled.radius = px(radius);
|
||||
}
|
||||
if let Some(radius_lg) = tuning.radius_lg {
|
||||
styled.radius_lg = px(radius_lg);
|
||||
}
|
||||
}
|
||||
|
||||
Theme::update(cx, |styled| {
|
||||
let background = styled.background;
|
||||
let foreground = styled.foreground;
|
||||
let (hover_alpha, active_alpha) = if styled.is_dark() {
|
||||
(0.08, 0.12)
|
||||
} else {
|
||||
(0.04, 0.06)
|
||||
};
|
||||
if styled.list_hover == background {
|
||||
styled.list_hover = foreground.alpha(hover_alpha);
|
||||
}
|
||||
if styled.tab_active == background {
|
||||
styled.tab_active = foreground.alpha(active_alpha);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Load settings
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
|
||||
@@ -280,6 +361,14 @@ impl AppSettings {
|
||||
return Ok(serde_json::from_str(&content)?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if let Some(content) = common::web_store::get(SETTINGS_KEY)? {
|
||||
return Ok(serde_json::from_str(&content)?);
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Not found"))
|
||||
});
|
||||
|
||||
@@ -312,20 +401,87 @@ impl AppSettings {
|
||||
smol::fs::write(&path, content).await.ok();
|
||||
})
|
||||
.detach();
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if let Err(e) = common::web_store::set(SETTINGS_KEY, &content) {
|
||||
log::warn!("failed to persist settings: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the persisted appearance mode to the app
|
||||
/// Apply the persisted appearance mode and theme selection to the app
|
||||
pub fn apply_theme(&mut self, cx: &mut Context<Self>) {
|
||||
use gpui_kit::component::Theme;
|
||||
|
||||
let mode = self.inner.read(cx).appearance_mode;
|
||||
match mode {
|
||||
let appearance = self.inner.read(cx).appearance_mode;
|
||||
match appearance {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
AppearanceMode::Light => Theme::change(gpui_kit::component::ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(gpui_kit::component::ThemeMode::Dark, None, cx),
|
||||
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
}
|
||||
cx.refresh_windows();
|
||||
|
||||
let (light, dark) = {
|
||||
let inner = self.inner.read(cx);
|
||||
(inner.theme_light.clone(), inner.theme_dark.clone())
|
||||
};
|
||||
apply_theme_selection(light.as_deref(), dark.as_deref(), cx);
|
||||
|
||||
let tuning = self.inner.read(cx).theme;
|
||||
Self::apply_theme_tuning(tuning, cx);
|
||||
}
|
||||
|
||||
/// Select a registered theme by name.
|
||||
pub fn select_theme(name: &str, cx: &mut App) {
|
||||
let Some(config) = registered_theme(name, cx) else {
|
||||
log::warn!("theme is not registered: {name}");
|
||||
return;
|
||||
};
|
||||
|
||||
let dark = config.mode.is_dark();
|
||||
|
||||
if dark {
|
||||
Self::update_theme_dark(Some(name.to_string()), cx);
|
||||
Self::update_appearance_mode(AppearanceMode::Dark, cx);
|
||||
} else {
|
||||
Self::update_theme_light(Some(name.to_string()), cx);
|
||||
Self::update_appearance_mode(AppearanceMode::Light, cx);
|
||||
}
|
||||
|
||||
Theme::change(
|
||||
if dark {
|
||||
ThemeMode::Dark
|
||||
} else {
|
||||
ThemeMode::Light
|
||||
},
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
Self::reapply_theme(cx);
|
||||
}
|
||||
|
||||
/// Select the theme applied in light appearance; `None` restores the default
|
||||
pub fn set_theme_light(name: Option<String>, cx: &mut App) {
|
||||
Self::update_theme_light(name, cx);
|
||||
Self::reapply_theme(cx);
|
||||
}
|
||||
|
||||
/// Select the theme applied in dark appearance; `None` restores the default
|
||||
pub fn set_theme_dark(name: Option<String>, cx: &mut App) {
|
||||
Self::update_theme_dark(name, cx);
|
||||
Self::reapply_theme(cx);
|
||||
}
|
||||
|
||||
/// Re-apply the selected themes and tuning from the current settings
|
||||
fn reapply_theme(cx: &mut App) {
|
||||
let (light, dark, tuning) = AppSettings::global(cx).read_with(cx, |this, cx| {
|
||||
let inner = this.inner.read(cx);
|
||||
(
|
||||
inner.theme_light.clone(),
|
||||
inner.theme_dark.clone(),
|
||||
inner.theme,
|
||||
)
|
||||
});
|
||||
|
||||
apply_theme_selection(light.as_deref(), dark.as_deref(), cx);
|
||||
Self::apply_theme_tuning(tuning, cx);
|
||||
}
|
||||
|
||||
/// Check if decoupling encryption key is enabled
|
||||
@@ -382,3 +538,161 @@ impl AppSettings {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
||||
}
|
||||
|
||||
/// Register the bundled gpui-kit themes with the theme registry
|
||||
pub fn init_themes(cx: &mut App) {
|
||||
let assets = cx.asset_source().clone();
|
||||
|
||||
for path in assets.list("themes").unwrap_or_default() {
|
||||
let Some(content) = assets.load(&path).unwrap_or_default() else {
|
||||
log::warn!("bundled theme {path} is listed but missing");
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(content) = std::str::from_utf8(&content) else {
|
||||
log::warn!("bundled theme {path} is not valid UTF-8");
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Err(error) = ThemeRegistry::global_mut(cx).load_themes_from_str(content) {
|
||||
log::warn!("failed to load bundled theme {path} ({error})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the persisted appearance mode synchronously.
|
||||
fn persisted_settings() -> Option<Settings> {
|
||||
let content = persisted_content();
|
||||
content.and_then(|content| serde_json::from_str(&content).ok())
|
||||
}
|
||||
|
||||
/// The persisted settings JSON, from the config file or browser storage
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn persisted_content() -> Option<String> {
|
||||
let path = config_dir().join(".settings");
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn persisted_content() -> Option<String> {
|
||||
common::web_store::get(SETTINGS_KEY).ok().flatten()
|
||||
}
|
||||
|
||||
pub fn persisted_appearance_mode() -> AppearanceMode {
|
||||
persisted_settings()
|
||||
.map(|settings| settings.appearance_mode)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the persisted theme tuning synchronously.
|
||||
pub fn persisted_theme() -> ThemeSettings {
|
||||
persisted_settings()
|
||||
.map(|settings| settings.theme)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the persisted theme selection for light appearance.
|
||||
pub fn persisted_theme_light() -> Option<String> {
|
||||
persisted_settings().and_then(|settings| settings.theme_light)
|
||||
}
|
||||
|
||||
/// Read the persisted theme selection for dark appearance.
|
||||
pub fn persisted_theme_dark() -> Option<String> {
|
||||
persisted_settings().and_then(|settings| settings.theme_dark)
|
||||
}
|
||||
|
||||
/// The registered theme for a persisted theme name, if any
|
||||
fn registered_theme(name: &str, cx: &App) -> Option<Rc<ThemeConfig>> {
|
||||
ThemeRegistry::global(cx).themes().get(name).cloned()
|
||||
}
|
||||
|
||||
/// Swap the selected themes into the light/dark slots and reload the active mode.
|
||||
/// An unset or unknown theme falls back to the registry's default theme.
|
||||
fn apply_theme_selection(light: Option<&str>, dark: Option<&str>, cx: &mut App) {
|
||||
let light_config = light
|
||||
.and_then(|name| registered_theme(name, cx))
|
||||
.or_else(|| Some(ThemeRegistry::global(cx).default_light_theme().clone()));
|
||||
let dark_config = dark
|
||||
.and_then(|name| registered_theme(name, cx))
|
||||
.or_else(|| Some(ThemeRegistry::global(cx).default_dark_theme().clone()));
|
||||
|
||||
{
|
||||
let theme = Theme::global_mut(cx);
|
||||
if let Some(config) = light_config {
|
||||
theme.light_theme = config;
|
||||
}
|
||||
if let Some(config) = dark_config {
|
||||
theme.dark_theme = config;
|
||||
}
|
||||
}
|
||||
|
||||
let mode = Theme::global(cx).mode;
|
||||
Theme::change(mode, None, cx);
|
||||
}
|
||||
|
||||
/// Apply the persisted appearance, theme selection and tuning synchronously.
|
||||
pub fn apply_persisted_theme(cx: &mut App) {
|
||||
match persisted_appearance_mode() {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
}
|
||||
|
||||
let light = persisted_theme_light();
|
||||
let dark = persisted_theme_dark();
|
||||
apply_theme_selection(light.as_deref(), dark.as_deref(), cx);
|
||||
|
||||
AppSettings::apply_theme_tuning(persisted_theme(), cx);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Settings written before theme tuning existed must keep loading.
|
||||
#[test]
|
||||
fn settings_without_theme_keep_defaults() {
|
||||
let settings: Settings = serde_json::from_str(
|
||||
r#"{
|
||||
"appearance_mode": "Dark",
|
||||
"hide_avatar": false,
|
||||
"screening": true,
|
||||
"wot_auto_trust": true,
|
||||
"nip4e": false,
|
||||
"trusted_relays": [],
|
||||
"file_server": "https://nostr.download/"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(settings.theme, ThemeSettings::default());
|
||||
assert_eq!(settings.theme_light, None);
|
||||
assert_eq!(settings.theme_dark, None);
|
||||
}
|
||||
|
||||
/// The theme selection round-trips through serde.
|
||||
#[test]
|
||||
fn theme_selection_persists() {
|
||||
let settings: Settings = serde_json::from_str(
|
||||
r#"{
|
||||
"appearance_mode": "Dark",
|
||||
"hide_avatar": false,
|
||||
"screening": true,
|
||||
"wot_auto_trust": true,
|
||||
"nip4e": false,
|
||||
"trusted_relays": [],
|
||||
"file_server": "https://nostr.download/",
|
||||
"theme_light": "Solarized Light",
|
||||
"theme_dark": "Tokyo Night"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(settings.theme_light.as_deref(), Some("Solarized Light"));
|
||||
assert_eq!(settings.theme_dark.as_deref(), Some("Tokyo Night"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "speech"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui-kit = { workspace = true, features = ["speech"] }
|
||||
flume.workspace = true
|
||||
instant.workspace = true
|
||||
tts.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
common = { path = "../common" }
|
||||
parakeet-rs = "=0.3.8"
|
||||
ort = "=2.0.0-rc.13"
|
||||
reqwest = { version = "0.12", default-features = false, features = [
|
||||
"blocking",
|
||||
"rustls-tls",
|
||||
] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen = "0.2"
|
||||
web-sys = { version = "0.3", features = ["Window"] }
|
||||
@@ -0,0 +1,54 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod model;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod parakeet;
|
||||
pub mod speaker;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt as _, AnyElement, App, IntoElement as _, ParentElement as _,
|
||||
Styled as _, div,
|
||||
};
|
||||
use gpui_kit::component::ActiveTheme as _;
|
||||
use gpui_kit::component::speech::SpeechRecognizer;
|
||||
|
||||
/// A ticking `M:SS` counter for the dictation in progress.
|
||||
pub fn elapsed(started: instant::Instant, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.with_animation(
|
||||
"dictation-elapsed",
|
||||
Animation::new(Duration::from_secs(1))
|
||||
.repeat()
|
||||
.with_max_fps(1.),
|
||||
move |counter, _| counter.child(format_elapsed(started)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn format_elapsed(started: instant::Instant) -> String {
|
||||
let seconds = started.elapsed().as_secs();
|
||||
format!("{}:{:02}", seconds / 60, seconds % 60)
|
||||
}
|
||||
|
||||
/// The recognizer backing the composer's microphone button.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn recognizer() -> impl SpeechRecognizer {
|
||||
parakeet::ParakeetRecognizer
|
||||
}
|
||||
|
||||
/// The recognizer backing the composer's microphone button.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn recognizer() -> impl SpeechRecognizer {
|
||||
web::WebSpeechRecognizer
|
||||
}
|
||||
|
||||
/// The audio source backing the composer's microphone button on the web.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn input() -> impl gpui_kit::component::speech::AudioInput {
|
||||
web::SelfCapturedInput
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"repository": "istupakov/parakeet-tdt-0.6b-v3-onnx",
|
||||
"revision": "8f23f0c03c8761650bdb5b40aaf3e40d2c15f1ce",
|
||||
"source": "nvidia/parakeet-tdt-0.6b-v3",
|
||||
"license": "CC-BY-4.0",
|
||||
"files": [
|
||||
{
|
||||
"name": "decoder_joint-model.int8.onnx",
|
||||
"size": 18202004,
|
||||
"sha256": "eea7483ee3d1a30375daedc8ed83e3960c91b098812127a0d99d1c8977667a70"
|
||||
},
|
||||
{
|
||||
"name": "encoder-model.int8.onnx",
|
||||
"size": 652183999,
|
||||
"sha256": "6139d2fa7e1b086097b277c7149725edbab89cc7c7ae64b23c741be4055aff09"
|
||||
},
|
||||
{
|
||||
"name": "vocab.txt",
|
||||
"size": 93939,
|
||||
"sha256": "d58544679ea4bc6ac563d1f545eb7d474bd6cfa467f0a6e2c1dc1c7d37e3c35d"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::{fs, io};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// How the model is currently available.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
NotInstalled,
|
||||
Downloading { downloaded: u64, total: u64 },
|
||||
Installed,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn is_downloading(self) -> bool {
|
||||
matches!(self, Status::Downloading { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Manifest {
|
||||
repository: String,
|
||||
revision: String,
|
||||
files: Vec<Artifact>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Artifact {
|
||||
name: String,
|
||||
size: u64,
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
fn manifest() -> &'static Manifest {
|
||||
static MANIFEST: OnceLock<Manifest> = OnceLock::new();
|
||||
|
||||
MANIFEST.get_or_init(|| {
|
||||
serde_json::from_str(include_str!("model.json")).expect("pinned model manifest")
|
||||
})
|
||||
}
|
||||
|
||||
/// The directory the model artifacts are downloaded into.
|
||||
pub fn dir() -> PathBuf {
|
||||
common::config_dir()
|
||||
.join("speech")
|
||||
.join("parakeet-tdt-0.6b-v3")
|
||||
}
|
||||
|
||||
/// Total download size in bytes.
|
||||
pub fn total_size() -> u64 {
|
||||
manifest().files.iter().map(|file| file.size).sum()
|
||||
}
|
||||
|
||||
/// The revision of the installed model, if its artifacts are complete.
|
||||
fn installed_revision() -> Option<String> {
|
||||
let manifest = manifest();
|
||||
let dir = dir();
|
||||
|
||||
let complete = manifest.files.iter().all(|file| {
|
||||
fs::metadata(dir.join(&file.name)).is_ok_and(|metadata| metadata.len() == file.size)
|
||||
});
|
||||
|
||||
if complete
|
||||
&& fs::read_to_string(dir.join("verified"))
|
||||
.is_ok_and(|revision| revision == manifest.revision)
|
||||
{
|
||||
Some(manifest.revision.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the model is ready to load.
|
||||
pub fn installed() -> bool {
|
||||
status() == Status::Installed
|
||||
}
|
||||
|
||||
/// The current status, reconciling the download state with the disk.
|
||||
pub fn status() -> Status {
|
||||
let state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||
|
||||
if let Download::Running {
|
||||
downloaded, total, ..
|
||||
} = state.download
|
||||
{
|
||||
return Status::Downloading { downloaded, total };
|
||||
}
|
||||
|
||||
if installed_revision().is_some() {
|
||||
Status::Installed
|
||||
} else {
|
||||
Status::NotInstalled
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the last download failure, if it has not been reported yet.
|
||||
pub fn take_error() -> Option<String> {
|
||||
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||
state.error.take()
|
||||
}
|
||||
|
||||
/// Start downloading the model, unless a download is already running.
|
||||
pub fn start_download() {
|
||||
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||
|
||||
if matches!(state.download, Download::Running { .. }) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
state.error = None;
|
||||
state.download = Download::Running {
|
||||
downloaded: 0,
|
||||
total: total_size(),
|
||||
cancel: cancel.clone(),
|
||||
};
|
||||
|
||||
if let Err(error) = std::thread::Builder::new()
|
||||
.name("speech-model-download".into())
|
||||
.spawn(move || download(cancel))
|
||||
{
|
||||
state.download = Download::Idle;
|
||||
state.error = Some(error.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Request cancellation of the running download.
|
||||
pub fn cancel_download() {
|
||||
let state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||
|
||||
if let Download::Running { cancel, .. } = &state.download {
|
||||
cancel.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the downloaded model and any partial download.
|
||||
pub fn delete() {
|
||||
super::parakeet::unload();
|
||||
cancel_download();
|
||||
|
||||
if let Err(error) = fs::remove_dir_all(dir())
|
||||
&& error.kind() != io::ErrorKind::NotFound
|
||||
{
|
||||
log::warn!("failed to remove the speech model: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn download(cancel: Arc<AtomicBool>) {
|
||||
let result = download_inner(&cancel);
|
||||
|
||||
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||
state.download = Download::Idle;
|
||||
|
||||
// A cancelled download must not report a spurious failure.
|
||||
if !cancelled(&cancel)
|
||||
&& let Err(error) = result
|
||||
{
|
||||
state.error = Some(format!("{error:#}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn download_inner(cancel: &AtomicBool) -> Result<()> {
|
||||
let manifest = manifest();
|
||||
let dir = dir();
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(20))
|
||||
.build()?;
|
||||
|
||||
fs::create_dir_all(&dir)?;
|
||||
|
||||
for file in &manifest.files {
|
||||
if cancelled(cancel) {
|
||||
bail!("Cancelled");
|
||||
}
|
||||
|
||||
let staging = dir.join(format!("{}.part", file.name));
|
||||
let result = download_file(&client, manifest, file, &staging, cancel);
|
||||
|
||||
if result.is_err() || cancelled(cancel) {
|
||||
let _unused = fs::remove_file(&staging);
|
||||
result?;
|
||||
bail!("Cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(dir.join("verified"), &manifest.revision)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download_file(
|
||||
client: &reqwest::blocking::Client,
|
||||
manifest: &Manifest,
|
||||
file: &Artifact,
|
||||
staging: &Path,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<()> {
|
||||
use std::io::{Read as _, Write as _};
|
||||
|
||||
use sha2::Digest as _;
|
||||
|
||||
let url = format!(
|
||||
"https://huggingface.co/{}/resolve/{}/{}",
|
||||
manifest.repository, manifest.revision, file.name
|
||||
);
|
||||
|
||||
let mut response = client.get(url).send()?.error_for_status()?;
|
||||
let mut out = fs::File::create(staging)?;
|
||||
let mut hash = sha2::Sha256::new();
|
||||
let mut size = 0u64;
|
||||
|
||||
loop {
|
||||
if cancelled(cancel) {
|
||||
bail!("Cancelled");
|
||||
}
|
||||
|
||||
let mut chunk = [0u8; 64 * 1024];
|
||||
let read = response.read(&mut chunk)?;
|
||||
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
size += read as u64;
|
||||
if size > file.size {
|
||||
bail!("Unexpected model size");
|
||||
}
|
||||
|
||||
hash.update(&chunk[..read]);
|
||||
out.write_all(&chunk[..read])?;
|
||||
|
||||
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||
if let Download::Running { downloaded, .. } = &mut state.download {
|
||||
*downloaded += read as u64;
|
||||
}
|
||||
}
|
||||
|
||||
if size != file.size || format!("{:x}", hash.finalize()) != file.sha256 {
|
||||
bail!("Model checksum verification failed");
|
||||
}
|
||||
|
||||
out.sync_all()?;
|
||||
fs::rename(staging, dir().join(&file.name))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cancelled(cancel: &AtomicBool) -> bool {
|
||||
cancel.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
enum Download {
|
||||
Idle,
|
||||
Running {
|
||||
downloaded: u64,
|
||||
total: u64,
|
||||
cancel: Arc<AtomicBool>,
|
||||
},
|
||||
}
|
||||
|
||||
struct State {
|
||||
download: Download,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn state() -> &'static Mutex<State> {
|
||||
static STATE: Mutex<State> = Mutex::new(State {
|
||||
download: Download::Idle,
|
||||
error: None,
|
||||
});
|
||||
|
||||
&STATE
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn manifest_pins_v3_artifacts_with_exact_sizes() {
|
||||
let manifest = manifest();
|
||||
|
||||
assert_eq!(manifest.repository, "istupakov/parakeet-tdt-0.6b-v3-onnx");
|
||||
assert_eq!(manifest.revision.len(), 40);
|
||||
assert_eq!(manifest.files.len(), 3);
|
||||
assert_eq!(total_size(), 670_479_942);
|
||||
assert!(
|
||||
manifest
|
||||
.files
|
||||
.iter()
|
||||
.all(|file| file.sha256.len() == 64 && !file.name.contains('/'))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use gpui::{App, Task};
|
||||
use gpui_kit::component::speech::{
|
||||
AudioFormat, RecognitionSession, SpeechError, SpeechRecognizer, SpeechSink,
|
||||
};
|
||||
use parakeet_rs::{ParakeetTDT, Transcriber as _};
|
||||
|
||||
const MODEL_RATE: u32 = 16_000;
|
||||
const MAX_SECONDS: usize = 120;
|
||||
|
||||
/// The on-device recognizer.
|
||||
pub struct ParakeetRecognizer;
|
||||
|
||||
impl SpeechRecognizer for ParakeetRecognizer {
|
||||
fn audio_format(&self) -> AudioFormat {
|
||||
AudioFormat::new(MODEL_RATE, 1)
|
||||
}
|
||||
|
||||
fn is_available(&self, _cx: &App) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn start(
|
||||
&self,
|
||||
sink: SpeechSink,
|
||||
cx: &mut App,
|
||||
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
|
||||
if super::model::installed() {
|
||||
worker().send_or_log(Command::Warm(super::model::dir()));
|
||||
} else if super::model::status().is_downloading() {
|
||||
return Err(SpeechError::recognizer(anyhow!(
|
||||
"The speech model is still downloading. Try again when it finishes."
|
||||
)));
|
||||
} else {
|
||||
return Err(SpeechError::recognizer(anyhow!(
|
||||
"No speech model is installed. Download it from Settings."
|
||||
)));
|
||||
}
|
||||
|
||||
// The session buffers everything it is pushed,
|
||||
// so audio is accepted right away.
|
||||
sink.ready(cx);
|
||||
|
||||
Ok(Box::new(ParakeetSession {
|
||||
sink,
|
||||
samples: Vec::new(),
|
||||
_result: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
struct ParakeetSession {
|
||||
sink: SpeechSink,
|
||||
samples: Vec<f32>,
|
||||
_result: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl RecognitionSession for ParakeetSession {
|
||||
fn push_audio(&mut self, samples: &[i16], _cx: &mut App) {
|
||||
if self.samples.len() < MAX_SECONDS * MODEL_RATE as usize {
|
||||
self.samples
|
||||
.extend(samples.iter().map(|sample| f32::from(*sample) / 32_768.0));
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, cx: &mut App) {
|
||||
let samples = std::mem::take(&mut self.samples);
|
||||
let sink = self.sink.clone();
|
||||
|
||||
// Speech too short to transcribe ends the session at once.
|
||||
let silent = samples.iter().all(|sample| sample.abs() < 0.0001);
|
||||
if samples.len() < MODEL_RATE as usize / 5 || silent {
|
||||
sink.finish(cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let (sender, receiver) = flume::unbounded();
|
||||
worker().send_or_log(Command::Transcribe { samples, sender });
|
||||
|
||||
self._result = Some(cx.spawn(async move |cx| {
|
||||
let result = receiver.recv_async().await;
|
||||
|
||||
cx.update(|cx| match result {
|
||||
Ok(Ok(text)) if !text.is_empty() => {
|
||||
sink.phrase(text, cx);
|
||||
sink.finish(cx);
|
||||
}
|
||||
Ok(Ok(_)) => sink.finish(cx),
|
||||
Ok(Err(error)) => sink.error(SpeechError::recognizer(error), cx),
|
||||
Err(_) => sink.error(
|
||||
SpeechError::recognizer(anyhow!("The speech worker is unavailable")),
|
||||
cx,
|
||||
),
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
enum Command {
|
||||
Warm(PathBuf),
|
||||
Transcribe {
|
||||
samples: Vec<f32>,
|
||||
sender: flume::Sender<Result<String, anyhow::Error>>,
|
||||
},
|
||||
Unload,
|
||||
}
|
||||
|
||||
/// A handle to the single inference worker.
|
||||
fn worker() -> &'static flume::Sender<Command> {
|
||||
static WORKER: OnceLock<flume::Sender<Command>> = OnceLock::new();
|
||||
|
||||
WORKER.get_or_init(|| {
|
||||
let (sender, receiver) = flume::unbounded::<Command>();
|
||||
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("speech-parakeet".into())
|
||||
.spawn(move || run_worker(receiver));
|
||||
|
||||
if let Err(error) = spawned {
|
||||
log::error!("failed to spawn the speech worker: {error}");
|
||||
}
|
||||
|
||||
sender
|
||||
})
|
||||
}
|
||||
|
||||
fn run_worker(receiver: flume::Receiver<Command>) {
|
||||
let mut cached: Option<(PathBuf, ParakeetTDT)> = None;
|
||||
|
||||
while let Ok(command) = receiver.recv_timeout(Duration::from_secs(30)) {
|
||||
match command {
|
||||
Command::Unload => {
|
||||
cached = None;
|
||||
}
|
||||
Command::Warm(dir) => {
|
||||
let loaded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
load(&mut cached, &dir).map(|_| ())
|
||||
}));
|
||||
|
||||
match flatten_panic(loaded) {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) | Err(error) => {
|
||||
log::warn!("failed to load the speech model: {error:#}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Transcribe { samples, sender } => {
|
||||
let transcribed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
transcribe(&mut cached, samples)
|
||||
}));
|
||||
|
||||
let result = match flatten_panic(transcribed) {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
cached = None;
|
||||
Err(anyhow!("Dictation stopped unexpectedly. Try again."))
|
||||
}
|
||||
};
|
||||
|
||||
if sender.send(result).is_err() {
|
||||
log::debug!("speech session was cancelled before its result arrived");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load<'a>(
|
||||
cached: &'a mut Option<(PathBuf, ParakeetTDT)>,
|
||||
dir: &PathBuf,
|
||||
) -> Result<&'a mut ParakeetTDT> {
|
||||
if cached.as_ref().is_none_or(|(path, _)| path != dir) {
|
||||
let model = ParakeetTDT::from_pretrained(dir, None)
|
||||
.map_err(|error| anyhow!("Could not load the speech model: {error}"))?;
|
||||
*cached = Some((dir.clone(), model));
|
||||
}
|
||||
|
||||
let (_, model) = cached.as_mut().context("speech model was unloaded")?;
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
fn transcribe(cached: &mut Option<(PathBuf, ParakeetTDT)>, samples: Vec<f32>) -> Result<String> {
|
||||
let model = load(cached, &super::model::dir())?;
|
||||
|
||||
model
|
||||
.transcribe_samples(samples, MODEL_RATE, 1, None)
|
||||
.map(|result| result.text)
|
||||
.map_err(|error| anyhow!("Could not transcribe this recording: {error}"))
|
||||
}
|
||||
|
||||
trait SendOrLog {
|
||||
fn send_or_log(&self, command: Command);
|
||||
}
|
||||
|
||||
impl SendOrLog for flume::Sender<Command> {
|
||||
fn send_or_log(&self, command: Command) {
|
||||
if self.send(command).is_err() {
|
||||
log::debug!("speech worker is gone; command dropped");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retire cached weights, e.g. after the model was deleted.
|
||||
pub(crate) fn unload() {
|
||||
worker().send_or_log(Command::Unload);
|
||||
}
|
||||
|
||||
fn flatten_panic<T>(result: std::thread::Result<T>) -> Result<T> {
|
||||
result.map_err(|_| anyhow!("The speech worker panicked"))
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use tts::Tts;
|
||||
|
||||
/// Words per minute of an engine at its normal rate, for estimating speech duration.
|
||||
const NORMAL_WPM: f32 = 160.0;
|
||||
|
||||
/// Extra time past the estimate before speech is considered finished, for backends that
|
||||
/// cannot report utterance end.
|
||||
pub const END_GRACE: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Reads text aloud through the platform's speech backend, created on first use.
|
||||
pub struct Speaker {
|
||||
tts: Option<Tts>,
|
||||
end_tx: flume::Sender<()>,
|
||||
end_rx: flume::Receiver<()>,
|
||||
}
|
||||
|
||||
impl Speaker {
|
||||
pub fn new() -> Self {
|
||||
let (end_tx, end_rx) = flume::unbounded();
|
||||
|
||||
Self {
|
||||
tts: None,
|
||||
end_tx,
|
||||
end_rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cloneable stream of utterance-finish events.
|
||||
pub fn end_events(&self) -> flume::Receiver<()> {
|
||||
self.end_rx.clone()
|
||||
}
|
||||
|
||||
/// Speak the text, interrupting what this speaker is already saying.
|
||||
pub fn speak(&mut self, text: &str) -> Result<Duration, tts::Error> {
|
||||
let end_tx = self.end_tx.clone();
|
||||
let tts = match self.tts.as_ref() {
|
||||
Some(tts) => tts,
|
||||
None => self.tts.insert({
|
||||
let tts = Tts::default()?;
|
||||
if let Err(error) = tts.on_utterance_end(move |_| {
|
||||
// A send error only means the panel dropped its event loop.
|
||||
let _ = end_tx.send(());
|
||||
}) {
|
||||
log::warn!("utterance end events unavailable ({error})");
|
||||
}
|
||||
tts
|
||||
}),
|
||||
};
|
||||
tts.speak(text, true)?;
|
||||
Ok(Self::estimate(tts, text))
|
||||
}
|
||||
|
||||
/// Stop speaking, a speaker that never spoke is already silent.
|
||||
pub fn stop(&mut self) -> Result<(), tts::Error> {
|
||||
match self.tts.as_ref() {
|
||||
Some(tts) => tts.stop()?,
|
||||
None => return Ok(()),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort speaking state, a speaker that never spoke is silent.
|
||||
pub fn speaking(&self) -> bool {
|
||||
self.tts
|
||||
.as_ref()
|
||||
.and_then(|tts| tts.is_speaking().ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// How long speaking the text is expected to take at the engine's current speed.
|
||||
fn estimate(tts: &Tts, text: &str) -> Duration {
|
||||
let words = text.split_whitespace().count().max(1) as f32;
|
||||
let speed = match (tts.get_rate(), tts.normal_rate()) {
|
||||
(Ok(rate), Ok(normal)) if normal > 0. => (rate / normal).clamp(0.2, 4.),
|
||||
_ => 1.,
|
||||
};
|
||||
Duration::from_secs_f32(words / NORMAL_WPM * 60. / speed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Speaker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use gpui::App;
|
||||
use gpui_kit::Subscription;
|
||||
use gpui_kit::component::speech::{
|
||||
AudioFormat, AudioInput, AudioSink, RecognitionSession, SpeechError, SpeechRecognizer,
|
||||
SpeechSink,
|
||||
};
|
||||
use js_sys::{Array, Function, JsString, Reflect};
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wasm_bindgen::{JsCast as _, JsValue};
|
||||
use web_sys::window;
|
||||
|
||||
/// The recognizer backed by `SpeechRecognition` (or its `webkit` prefix).
|
||||
pub struct WebSpeechRecognizer;
|
||||
|
||||
impl SpeechRecognizer for WebSpeechRecognizer {
|
||||
fn audio_format(&self) -> AudioFormat {
|
||||
AudioFormat::default()
|
||||
}
|
||||
|
||||
fn is_available(&self, _cx: &App) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn start(
|
||||
&self,
|
||||
sink: SpeechSink,
|
||||
cx: &mut App,
|
||||
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
|
||||
let recognition = Recognition::open(sink, cx).map_err(SpeechError::recognizer)?;
|
||||
|
||||
Ok(Box::new(recognition))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SelfCapturedInput;
|
||||
|
||||
impl AudioInput for SelfCapturedInput {
|
||||
fn start(
|
||||
&self,
|
||||
_format: AudioFormat,
|
||||
_sink: AudioSink,
|
||||
_cx: &mut App,
|
||||
) -> Result<Subscription, SpeechError> {
|
||||
Ok(Subscription::new(|| {}))
|
||||
}
|
||||
}
|
||||
|
||||
enum Event {
|
||||
Hypothesis(String),
|
||||
Phrase(String),
|
||||
Error(String),
|
||||
End,
|
||||
}
|
||||
|
||||
struct Recognition {
|
||||
recognition: JsValue,
|
||||
on_result: Option<Closure<dyn FnMut(JsValue)>>,
|
||||
on_error: Option<Closure<dyn FnMut(JsValue)>>,
|
||||
on_end: Option<Closure<dyn FnMut(JsValue)>>,
|
||||
_task: gpui::Task<()>,
|
||||
}
|
||||
|
||||
impl Recognition {
|
||||
fn open(sink: SpeechSink, cx: &mut App) -> anyhow::Result<Self> {
|
||||
let window = window().ok_or_else(|| anyhow!("No browser window"))?;
|
||||
|
||||
let constructor = Reflect::get(&window, &JsString::from("SpeechRecognition"))
|
||||
.ok()
|
||||
.filter(|value| value.is_function())
|
||||
.or_else(|| {
|
||||
Reflect::get(&window, &JsString::from("webkitSpeechRecognition"))
|
||||
.ok()
|
||||
.filter(|value| value.is_function())
|
||||
})
|
||||
.ok_or_else(|| anyhow!("This browser does not support speech recognition"))?;
|
||||
|
||||
let recognition =
|
||||
Reflect::construct(constructor.unchecked_ref::<Function>(), &Array::new())
|
||||
.map_err(|error| js_error("constructing the recognizer", error))?;
|
||||
|
||||
let (sender, receiver) = flume::unbounded::<Event>();
|
||||
let error_sender = sender.clone();
|
||||
let end_sender = sender.clone();
|
||||
|
||||
let finalized = RefCell::new(0usize);
|
||||
let on_result = Closure::wrap(Box::new(move |event: JsValue| {
|
||||
let read = |value: &JsValue, property: &str| -> Option<JsValue> {
|
||||
Reflect::get(value, &JsString::from(property)).ok()
|
||||
};
|
||||
|
||||
let Some(results) = read(&event, "results") else {
|
||||
return;
|
||||
};
|
||||
let length = read(&results, "length")
|
||||
.and_then(|length| length.as_f64())
|
||||
.unwrap_or(0.0) as usize;
|
||||
|
||||
let mut finalized = finalized.borrow_mut();
|
||||
let mut interim = String::new();
|
||||
|
||||
for index in *finalized..length {
|
||||
let Some(result) = read(&results, &index.to_string()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(alternative) = read(&result, "0") else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(text) = read(&alternative, "transcript").and_then(|text| text.as_string())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let is_final = read(&result, "isFinal")
|
||||
.and_then(|final_value| final_value.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_final {
|
||||
*finalized += 1;
|
||||
if sender.send(Event::Phrase(text)).is_err() {
|
||||
log::debug!("speech session was cancelled mid-utterance");
|
||||
}
|
||||
} else {
|
||||
interim.push_str(&text);
|
||||
}
|
||||
}
|
||||
|
||||
if !interim.is_empty() && sender.send(Event::Hypothesis(interim)).is_err() {
|
||||
log::debug!("speech session was cancelled mid-utterance");
|
||||
}
|
||||
}) as Box<dyn FnMut(JsValue)>);
|
||||
|
||||
let on_error = Closure::wrap(Box::new(move |event: JsValue| {
|
||||
let message = Reflect::get(&event, &JsString::from("error"))
|
||||
.ok()
|
||||
.and_then(|error| error.as_string())
|
||||
.unwrap_or_else(|| "unknown speech error".into());
|
||||
|
||||
if error_sender.send(Event::Error(message)).is_err() {
|
||||
log::debug!("speech session was cancelled before its error arrived");
|
||||
}
|
||||
}) as Box<dyn FnMut(JsValue)>);
|
||||
|
||||
let on_end = Closure::wrap(Box::new(move |_event: JsValue| {
|
||||
if end_sender.send(Event::End).is_err() {
|
||||
log::debug!("speech session was cancelled before its end arrived");
|
||||
}
|
||||
}) as Box<dyn FnMut(JsValue)>);
|
||||
|
||||
set_callback(&recognition, "onresult", on_result.as_ref().unchecked_ref())?;
|
||||
set_callback(&recognition, "onerror", on_error.as_ref().unchecked_ref())?;
|
||||
set_callback(&recognition, "onend", on_end.as_ref().unchecked_ref())?;
|
||||
set_bool(&recognition, "continuous", true)?;
|
||||
set_bool(&recognition, "interimResults", true)?;
|
||||
|
||||
call_method(&recognition, "start")?;
|
||||
|
||||
// The browser consumes audio as soon as it has permission
|
||||
sink.ready(cx);
|
||||
|
||||
let task = cx.spawn(async move |cx| {
|
||||
while let Ok(event) = receiver.recv_async().await {
|
||||
let done = matches!(event, Event::End);
|
||||
|
||||
// The speech state ignores sink calls once its session ended,
|
||||
// so a late event after a cancel is harmless.
|
||||
cx.update(|cx| match event {
|
||||
Event::Hypothesis(text) => sink.hypothesis(text, cx),
|
||||
Event::Phrase(text) => sink.phrase(text, cx),
|
||||
Event::Error(message) => {
|
||||
sink.error(SpeechError::recognizer(anyhow!("{message}")), cx)
|
||||
}
|
||||
Event::End => sink.finish(cx),
|
||||
});
|
||||
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
recognition,
|
||||
on_result: Some(on_result),
|
||||
on_error: Some(on_error),
|
||||
on_end: Some(on_end),
|
||||
_task: task,
|
||||
})
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
self.on_result = None;
|
||||
self.on_error = None;
|
||||
self.on_end = None;
|
||||
|
||||
let _ignored = call_method(&self.recognition, "abort");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Recognition {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
impl RecognitionSession for Recognition {
|
||||
fn push_audio(&mut self, _samples: &[i16], _cx: &mut App) {
|
||||
// The browser recognizer captures the microphone itself.
|
||||
// the state still pushes audio through its input, which is a no-op here.
|
||||
}
|
||||
|
||||
fn finish(&mut self, _cx: &mut App) {
|
||||
// The end handler reports completion, so no result is fabricated here.
|
||||
let _ignored = call_method(&self.recognition, "stop");
|
||||
}
|
||||
}
|
||||
|
||||
fn set_callback(target: &JsValue, property: &str, callback: &Function) -> anyhow::Result<()> {
|
||||
Reflect::set(target, &JsString::from(property), callback)
|
||||
.map_err(|error| js_error(property, error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_bool(target: &JsValue, property: &str, value: bool) -> anyhow::Result<()> {
|
||||
Reflect::set(target, &JsString::from(property), &JsValue::from(value))
|
||||
.map_err(|error| js_error(property, error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn call_method(target: &JsValue, method: &str) -> anyhow::Result<()> {
|
||||
let function = Reflect::get(target, &JsString::from(method))
|
||||
.map_err(|error| js_error(method, error))?
|
||||
.unchecked_into::<Function>();
|
||||
|
||||
function
|
||||
.call0(target)
|
||||
.map(|_| ())
|
||||
.map_err(|error| js_error(method, error))
|
||||
}
|
||||
|
||||
fn js_error(what: &str, error: JsValue) -> anyhow::Error {
|
||||
let description = error
|
||||
.as_string()
|
||||
.or_else(|| {
|
||||
Reflect::get(&error, &JsString::from("message"))
|
||||
.ok()
|
||||
.and_then(|message| message.as_string())
|
||||
})
|
||||
.unwrap_or_else(|| "unknown error".into());
|
||||
|
||||
anyhow!("{what} failed: {description}")
|
||||
}
|
||||
@@ -20,6 +20,7 @@ futures.workspace = true
|
||||
anyhow.workspace = true
|
||||
webbrowser.workspace = true
|
||||
serde_json.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
mime_guess = "2.0.4"
|
||||
|
||||
@@ -28,7 +29,24 @@ sha2.workspace = true
|
||||
data-encoding.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3"
|
||||
nostr-browser-signer.workspace = true
|
||||
nostr-memory.workspace = true
|
||||
nostr-sqlite.workspace = true
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Blob",
|
||||
"BlobPropertyBag",
|
||||
"File",
|
||||
"FileList",
|
||||
"HtmlAnchorElement",
|
||||
"HtmlInputElement",
|
||||
"Url",
|
||||
"Window",
|
||||
] }
|
||||
sqlite-wasm-rs = "0.5"
|
||||
sqlite-wasm-vfs = "0.2"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use gpui::AsyncApp;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui_tokio::Tokio;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use mime_guess::from_path;
|
||||
use nostr_blossom::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::file::sha256_hex;
|
||||
|
||||
/// Upload a blob to a blossom server and return its URL
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) async fn upload_blob(
|
||||
@@ -41,16 +34,26 @@ pub(crate) async fn upload_blob(
|
||||
.map_err(|e| anyhow!("Upload error: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
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 data = smol::fs::read(&path).await?;
|
||||
let sha256 = sha256_hex(&data);
|
||||
|
||||
upload_blob(&server, data, &content_type, &sha256, cx).await
|
||||
}
|
||||
|
||||
/// Upload a blob to a blossom server and return its URL.
|
||||
#[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"))
|
||||
pub(crate) async fn upload_blob(
|
||||
server: &Url,
|
||||
data: Vec<u8>,
|
||||
content_type: &str,
|
||||
sha256: &str,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<Url, Error> {
|
||||
let client = BlossomClient::new(server.clone());
|
||||
let keys = Keys::generate();
|
||||
let base = server.clone();
|
||||
let hash = sha256.to_string();
|
||||
|
||||
match client
|
||||
.upload_blob(data, Some(content_type.to_string()), None, Some(&keys))
|
||||
.await
|
||||
{
|
||||
Ok(blob) => Ok(blob.url),
|
||||
Err(e) if e.to_string().contains("201 Created") => Ok(base.join(&hash)?),
|
||||
Err(e) => Err(anyhow!(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
use anyhow::Result;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use common::web_store;
|
||||
use gpui::{App, AppContext as _, Task};
|
||||
|
||||
use crate::USER_KEYRING;
|
||||
|
||||
/// The storage key for the log of handled NIP-4e key-sync request ids
|
||||
const PROCESSED_REQUESTS_KEY: &str = "coop/nip4e/processed-requests";
|
||||
|
||||
/// Read the stored credential as `(username, secret)`
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) fn read(cx: &App) -> Task<Result<Option<(String, Vec<u8>)>>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return cx.read_credentials(USER_KEYRING);
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = cx;
|
||||
Task::ready(
|
||||
web_store::get(USER_KEYRING)
|
||||
.map(|stored| stored.map(|content| (String::new(), content.into_bytes()))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the stored credential
|
||||
pub(crate) fn write(cx: &App, username: &str, secret: &[u8]) -> Task<Result<()>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return cx.write_credentials(USER_KEYRING, username, secret);
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (cx, username);
|
||||
let content = match std::str::from_utf8(secret) {
|
||||
Ok(content) => content,
|
||||
Err(e) => return Task::ready(Err(anyhow::anyhow!("credential is not UTF-8: {e}"))),
|
||||
};
|
||||
|
||||
Task::ready(web_store::set(USER_KEYRING, content))
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the stored credential
|
||||
pub(crate) fn delete(cx: &App) -> Task<Result<()>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return cx.delete_credentials(USER_KEYRING);
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = cx;
|
||||
Task::ready(web_store::remove(USER_KEYRING))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read named bytes from the platform keyring,
|
||||
/// falling back to browser storage on web where no keyring exists
|
||||
fn read_named(cx: &App, key: &str) -> Task<Result<Option<Vec<u8>>>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let key = key.to_owned();
|
||||
let read = cx.read_credentials(&key);
|
||||
cx.background_spawn(async move { Ok(read.await?.map(|(_, secret)| secret)) })
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = cx;
|
||||
let stored = web_store::get(key).map(|content| content.map(|s| s.into_bytes()));
|
||||
Task::ready(stored)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write named bytes to the platform keyring,
|
||||
/// falling back to browser storage on web where no keyring exists
|
||||
fn write_named(cx: &App, key: &str, secret: &[u8]) -> Task<Result<()>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let key = key.to_owned();
|
||||
let secret = secret.to_vec();
|
||||
let write = cx.write_credentials(&key, "", &secret);
|
||||
cx.background_spawn(write)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = cx;
|
||||
let content = match std::str::from_utf8(secret) {
|
||||
Ok(content) => content,
|
||||
Err(e) => return Task::ready(Err(anyhow::anyhow!("secret is not UTF-8: {e}"))),
|
||||
};
|
||||
|
||||
Task::ready(web_store::set(key, content))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the log of handled key-sync request ids, as concatenated hex ids
|
||||
pub fn read_processed_requests(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
|
||||
read_named(cx, PROCESSED_REQUESTS_KEY)
|
||||
}
|
||||
|
||||
/// Persist the log of handled key-sync request ids, as concatenated hex ids
|
||||
pub fn write_processed_requests(cx: &App, ids: &[u8]) -> Task<Result<()>> {
|
||||
write_named(cx, PROCESSED_REQUESTS_KEY, ids)
|
||||
}
|
||||
@@ -7,8 +7,10 @@ use aes_gcm::{Aes256Gcm, AesGcm, Nonce};
|
||||
use anyhow::{Error, anyhow, bail};
|
||||
use data_encoding::HEXLOWER;
|
||||
use futures::AsyncReadExt;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui::PathPromptOptions;
|
||||
use gpui::http_client::AsyncBody;
|
||||
use gpui::{AsyncApp, SharedString};
|
||||
use gpui::{AppContext as _, AsyncApp, SharedString, SharedUri, Task};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use mime_guess::from_path;
|
||||
use nostr::nips::nip94::Sha256Hash;
|
||||
@@ -115,6 +117,56 @@ impl FileAttachment {
|
||||
}
|
||||
}
|
||||
|
||||
/// A file the user picked, read into memory.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PickedFile {
|
||||
pub name: String,
|
||||
pub mime: String,
|
||||
pub data: Vec<u8>,
|
||||
pub local: LocalFile,
|
||||
}
|
||||
|
||||
/// A handle to a file stored on the user's device
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LocalFile {
|
||||
/// A path on disk (desktop)
|
||||
Path(PathBuf),
|
||||
/// An in-memory blob URL (web)
|
||||
Url(String),
|
||||
}
|
||||
|
||||
impl LocalFile {
|
||||
/// The source an `img` element can render
|
||||
pub fn image_source(&self) -> gpui::ImageSource {
|
||||
match self {
|
||||
LocalFile::Path(path) => path.clone().into(),
|
||||
LocalFile::Url(url) => SharedUri::from(url.clone()).into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL the platform opener understands
|
||||
pub fn open_url(&self) -> String {
|
||||
match self {
|
||||
LocalFile::Path(path) => format!("file://{}", path.display()),
|
||||
LocalFile::Url(url) => url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the file with the platform handler
|
||||
pub fn open(&self, cx: &gpui::App) -> Result<(), Error> {
|
||||
match self {
|
||||
LocalFile::Path(_) => {
|
||||
cx.open_url(&self.open_url());
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
LocalFile::Url(url) => crate::web_file::open_url(url),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
LocalFile::Url(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
||||
let key = Aes256Gcm::generate_key(OsRng);
|
||||
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
|
||||
@@ -165,21 +217,63 @@ pub(crate) fn sha256_hex(data: &[u8]) -> String {
|
||||
Sha256Hash::from_byte_array(hash).to_hex()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn upload_encrypted(
|
||||
/// Ask the user for a single file and read it into memory
|
||||
pub fn pick_file(cx: &gpui::App) -> Task<Result<Option<PickedFile>, Error>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let dialog = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: None,
|
||||
});
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let Some(path) = dialog.await?? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = path.into_iter().next().ok_or_else(|| anyhow!("No file"))?;
|
||||
|
||||
let mime = from_path(&path).first_or_octet_stream().to_string();
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
let data = smol::fs::read(&path).await?;
|
||||
|
||||
Ok(Some(PickedFile {
|
||||
name,
|
||||
mime,
|
||||
data,
|
||||
local: LocalFile::Path(path),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = cx;
|
||||
cx.background_spawn(async move { crate::web_file::pick_file().await })
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a file unencrypted and return its URL
|
||||
pub async fn upload_file(server: Url, file: PickedFile, cx: &AsyncApp) -> Result<Url, Error> {
|
||||
let PickedFile { mime, data, .. } = file;
|
||||
let sha256 = sha256_hex(&data);
|
||||
|
||||
crate::blossom::upload_blob(&server, data, &mime, &sha256, cx).await
|
||||
}
|
||||
|
||||
/// Upload a file encrypted as an opaque blob, returning the attachment metadata
|
||||
pub async fn upload_encrypted_file(
|
||||
server: Url,
|
||||
path: PathBuf,
|
||||
file: PickedFile,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<FileAttachment, Error> {
|
||||
let mime = from_path(&path).first_or_octet_stream().to_string();
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned());
|
||||
let data = smol::fs::read(&path).await?;
|
||||
|
||||
let encrypted = encrypt(&data)?;
|
||||
let encrypted = encrypt(&file.data)?;
|
||||
let sha256 = sha256_hex(&encrypted.data);
|
||||
let original_sha256 = sha256_hex(&data);
|
||||
let original_sha256 = sha256_hex(&file.data);
|
||||
let size = encrypted.data.len() as u64;
|
||||
let base_url = server.to_string();
|
||||
|
||||
@@ -207,26 +301,17 @@ pub async fn upload_encrypted(
|
||||
|
||||
Ok(FileAttachment {
|
||||
url,
|
||||
mime,
|
||||
mime: file.mime,
|
||||
key: encrypted.key,
|
||||
nonce: encrypted.nonce,
|
||||
sha256: Some(sha256),
|
||||
original_sha256: Some(original_sha256),
|
||||
size: Some(size),
|
||||
dim: None,
|
||||
name,
|
||||
name: Some(file.name),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn upload_encrypted(
|
||||
_server: Url,
|
||||
_path: PathBuf,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<FileAttachment, Error> {
|
||||
Err(anyhow!("File upload not supported on web"))
|
||||
}
|
||||
|
||||
pub(crate) async fn download_and_decrypt(
|
||||
url: &Url,
|
||||
key: &str,
|
||||
@@ -261,33 +346,49 @@ pub(crate) async fn download_and_decrypt(
|
||||
decrypt(&data, key, nonce)
|
||||
}
|
||||
|
||||
/// Download and decrypt a file attachment into a temporary file.
|
||||
///
|
||||
/// The same attachment always maps to the same path, so callers can render the
|
||||
/// result directly (e.g. with `img`) without downloading it more than once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn download_and_decrypt_to_file(
|
||||
file: &FileAttachment,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
let name = file
|
||||
.sha256
|
||||
.clone()
|
||||
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
|
||||
/// A downloaded and decrypted attachment on the user's device
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DownloadedFile {
|
||||
pub local: LocalFile,
|
||||
pub mime: String,
|
||||
pub name: String,
|
||||
/// The decrypted bytes, kept so the web can offer them as a download
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) data: Vec<u8>,
|
||||
}
|
||||
|
||||
let extension = mime_guess::get_mime_extensions_str(&file.mime)
|
||||
.and_then(|extensions| extensions.first())
|
||||
.copied()
|
||||
.unwrap_or("bin");
|
||||
|
||||
let path = std::env::temp_dir()
|
||||
.join("coop-files")
|
||||
.join(format!("{name}.{extension}"));
|
||||
|
||||
if smol::fs::metadata(&path).await.is_ok() {
|
||||
return Ok(path);
|
||||
impl DownloadedFile {
|
||||
pub fn open(&self, cx: &gpui::App) -> Result<(), Error> {
|
||||
match &self.local {
|
||||
LocalFile::Path(path) => {
|
||||
cx.open_with_system(path);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
LocalFile::Url(url) => {
|
||||
if self.mime.starts_with("image/") {
|
||||
crate::web_file::open_url(url)
|
||||
} else {
|
||||
crate::web_file::download(&self.name, &self.mime, &self.data)
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
LocalFile::Url(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the file to the user's downloads (web)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn save(&self) -> Result<(), Error> {
|
||||
crate::web_file::download(&self.name, &self.mime, &self.data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Download and decrypt a file attachment to the user's device
|
||||
pub async fn download_and_decrypt_file(
|
||||
file: &FileAttachment,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<DownloadedFile, Error> {
|
||||
let data = download_and_decrypt(
|
||||
&file.url,
|
||||
&file.key,
|
||||
@@ -297,25 +398,66 @@ pub async fn download_and_decrypt_to_file(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(parent) = path.parent() else {
|
||||
bail!("Invalid file path");
|
||||
};
|
||||
smol::fs::create_dir_all(parent).await?;
|
||||
let name = file
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_name(&file.url));
|
||||
|
||||
// Write under a temporary name first, so an interrupted download is never reused
|
||||
let partial = path.with_extension("download");
|
||||
smol::fs::write(&partial, data).await?;
|
||||
smol::fs::rename(&partial, &path).await?;
|
||||
let local = store_file(&name, &file.mime, &data).await?;
|
||||
|
||||
Ok(path)
|
||||
Ok(DownloadedFile {
|
||||
local,
|
||||
mime: file.mime.clone(),
|
||||
name,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn download_and_decrypt_to_file(
|
||||
_file: &FileAttachment,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
Err(anyhow!("File download not supported on web"))
|
||||
/// Persist decrypted bytes somewhere the platform can render or open them
|
||||
async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Error> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let digest = sha256_hex(name.as_bytes());
|
||||
let extension = mime_guess::get_mime_extensions_str(mime)
|
||||
.and_then(|extensions| extensions.first())
|
||||
.copied()
|
||||
.unwrap_or("bin");
|
||||
|
||||
let path = std::env::temp_dir()
|
||||
.join("coop-files")
|
||||
.join(format!("{digest}.{extension}"));
|
||||
|
||||
if smol::fs::metadata(&path).await.is_err() {
|
||||
let Some(parent) = path.parent() else {
|
||||
bail!("Invalid file path");
|
||||
};
|
||||
smol::fs::create_dir_all(parent).await?;
|
||||
|
||||
// Write under a temporary name first, so an interrupted download is never reused
|
||||
let partial = path.with_extension("download");
|
||||
smol::fs::write(&partial, data).await?;
|
||||
smol::fs::rename(&partial, &path).await?;
|
||||
}
|
||||
|
||||
Ok(LocalFile::Path(path))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = name;
|
||||
let url = crate::web_file::blob_url(mime, data)?;
|
||||
Ok(LocalFile::Url(url))
|
||||
}
|
||||
}
|
||||
|
||||
/// A file name derived from the attachment URL's last path segment
|
||||
fn fallback_name(url: &Url) -> String {
|
||||
url.path()
|
||||
.rsplit('/')
|
||||
.find(|segment| !segment.is_empty())
|
||||
.unwrap_or("attachment")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||
|
||||
@@ -4,26 +4,33 @@ use std::sync::Arc;
|
||||
use anyhow::{Error, anyhow};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use browser_signer_proxy::prelude::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui_tokio::Tokio;
|
||||
use instant::Duration;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use nostr_browser_signer::BrowserSigner;
|
||||
use nostr_connect::prelude::*;
|
||||
use nostr_gossip_memory::prelude::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use nostr_lmdb::prelude::*;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use nostr_memory::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
mod blossom;
|
||||
mod constants;
|
||||
pub mod credentials;
|
||||
mod file;
|
||||
mod nip05;
|
||||
mod nip4e;
|
||||
mod signer;
|
||||
|
||||
pub use blossom::*;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod web_database;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod web_file;
|
||||
|
||||
pub use constants::*;
|
||||
pub use file::*;
|
||||
pub use nip4e::*;
|
||||
@@ -107,7 +114,6 @@ impl NostrRegistry {
|
||||
let signer = UniversalSigner::new(Keys::generate());
|
||||
let authenticator = SignerAuthenticator::new(signer.clone());
|
||||
|
||||
// Construct the nostr lmdb instance
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let database = cx.foreground_executor().block_on(async move {
|
||||
NostrLmdb::open(config_dir().join("nostr"))
|
||||
@@ -116,7 +122,7 @@ impl NostrRegistry {
|
||||
});
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let database = MemoryDatabase::unbounded();
|
||||
let database = web_database::take();
|
||||
|
||||
// Construct the nostr client
|
||||
let client = ClientBuilder::default()
|
||||
@@ -137,9 +143,7 @@ impl NostrRegistry {
|
||||
let _ = entity.update(cx, |this, cx| {
|
||||
this.connect_bootstrap_relays(cx);
|
||||
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
} else if let Some(secret) = cli_key {
|
||||
if let Some(secret) = cli_key {
|
||||
// Use CLI-provided key -- same path as restore_session
|
||||
let keys = Keys::new(secret);
|
||||
this.set_signer(keys, cx);
|
||||
@@ -241,7 +245,7 @@ impl NostrRegistry {
|
||||
|
||||
/// Restore the signer from the stored credential
|
||||
fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||
let user_keyring = cx.read_credentials(USER_KEYRING);
|
||||
let user_keyring = credentials::read(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = async {
|
||||
@@ -278,6 +282,11 @@ impl NostrRegistry {
|
||||
this.update(cx, |this, cx| {
|
||||
this.connect_proxy(cx);
|
||||
})?;
|
||||
} else if content == "extension" {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
this.update(cx, |this, cx| {
|
||||
this.connect_extension(cx);
|
||||
})?;
|
||||
} else {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
@@ -313,7 +322,7 @@ impl NostrRegistry {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PublicKey, Error>> {
|
||||
let password = password.to_owned();
|
||||
let user_keyring = cx.read_credentials(USER_KEYRING);
|
||||
let user_keyring = credentials::read(cx);
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let content = user_keyring
|
||||
@@ -372,9 +381,8 @@ impl NostrRegistry {
|
||||
let (keys, ncryptsec) = job.await?;
|
||||
let public_key = keys.public_key();
|
||||
|
||||
let write = cx.update(|cx| {
|
||||
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
|
||||
});
|
||||
let write =
|
||||
cx.update(|cx| credentials::write(cx, &public_key.to_hex(), ncryptsec.as_bytes()));
|
||||
write.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -411,9 +419,8 @@ impl NostrRegistry {
|
||||
let keys = decrypt_task.await?;
|
||||
let public_key = keys.public_key();
|
||||
|
||||
let write = cx.update(|cx| {
|
||||
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), stored.as_bytes())
|
||||
});
|
||||
let write =
|
||||
cx.update(|cx| credentials::write(cx, &public_key.to_hex(), stored.as_bytes()));
|
||||
write.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -439,7 +446,7 @@ impl NostrRegistry {
|
||||
|
||||
let keys = Keys::generate();
|
||||
let credential = with_master_key(&uri_string, &keys);
|
||||
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
||||
let write = credentials::write(cx, "bunker", credential.as_bytes());
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let signer = NostrConnect::builder(connect_uri, keys)
|
||||
@@ -463,12 +470,50 @@ impl NostrRegistry {
|
||||
self.current_user = None;
|
||||
self.passphrase_required = false;
|
||||
|
||||
cx.delete_credentials(USER_KEYRING).detach_and_log_err(cx);
|
||||
credentials::delete(cx).detach_and_log_err(cx);
|
||||
cx.emit(StateEvent::SignerChanged);
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Connect the browser extension signer (NIP-07).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn connect_extension(&mut self, cx: &mut Context<Self>) {
|
||||
let signer = match BrowserSigner::new() {
|
||||
Ok(signer) => signer,
|
||||
Err(e) => {
|
||||
cx.emit(StateEvent::error(format!("No Nostr extension found: {e}.")));
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match signer.get_public_key_async().await {
|
||||
Ok(public_key) => {
|
||||
this.update(cx, |this, cx| {
|
||||
credentials::write(cx, "extension", b"extension").detach_and_log_err(cx);
|
||||
this.signer.swap_inner(signer);
|
||||
this.current_user = Some(public_key);
|
||||
this.passphrase_required = false;
|
||||
|
||||
cx.emit(StateEvent::SignerChanged);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_, cx| {
|
||||
credentials::delete(cx).detach_and_log_err(cx);
|
||||
cx.emit(StateEvent::error(e.to_string()));
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
})?;
|
||||
}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Start the browser proxy
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
||||
|
||||
@@ -5,6 +5,10 @@ use nostr_sdk::prelude::*;
|
||||
pub struct Announcement {
|
||||
/// The public key of the device that created this announcement.
|
||||
public_key: PublicKey,
|
||||
/// The id of the announcement event, for ordering
|
||||
event_id: EventId,
|
||||
/// The creation time of the announcement event
|
||||
created_at: Timestamp,
|
||||
}
|
||||
|
||||
impl Announcement {
|
||||
@@ -16,11 +20,20 @@ impl Announcement {
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|c| PublicKey::parse(c).ok())?;
|
||||
|
||||
Some(Self { public_key })
|
||||
Some(Self {
|
||||
public_key,
|
||||
event_id: event.id,
|
||||
created_at: event.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the public key of the device that created this announcement.
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
}
|
||||
|
||||
pub fn is_newer_than(&self, other: &Self) -> bool {
|
||||
self.created_at > other.created_at
|
||||
|| (self.created_at == other.created_at && self.event_id < other.event_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +188,13 @@ impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||
auth_url: Url,
|
||||
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
crate::web_file::open_url(auth_url.as_str())
|
||||
.map_err(nostr_connect::error::Error::other)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nostr_memory::prelude::*;
|
||||
use nostr_sqlite::prelude::NostrSqlite;
|
||||
use sqlite_wasm_rs::WasmOsCallback;
|
||||
use sqlite_wasm_vfs::relaxed_idb;
|
||||
|
||||
const DATABASE_PATH: &str = "coop.sqlite3";
|
||||
const VFS_NAME: &str = "coop-relaxed-idb";
|
||||
|
||||
thread_local! {
|
||||
static DATABASE: RefCell<Option<NostrSqlite>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Install the IndexedDB storage backend and open the database.
|
||||
pub async fn open() {
|
||||
match open_persistent().await {
|
||||
Some(database) => DATABASE.with(|slot| *slot.borrow_mut() = Some(database)),
|
||||
None => log::error!("Falling back to the in-memory nostr database"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand the opened database to the client constructor
|
||||
pub(crate) fn take() -> Arc<dyn NostrDatabase> {
|
||||
let database = DATABASE.with(|slot| slot.borrow_mut().take());
|
||||
|
||||
match database {
|
||||
Some(database) => Arc::new(database),
|
||||
None => {
|
||||
log::error!("Persistent web database was not opened, falling back to memory");
|
||||
Arc::new(MemoryDatabase::unbounded())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the database on IndexedDB storage, or `None` when unavailable
|
||||
async fn open_persistent() -> Option<NostrSqlite> {
|
||||
let options = relaxed_idb::RelaxedIdbCfgBuilder::new()
|
||||
.vfs_name(VFS_NAME)
|
||||
.build();
|
||||
|
||||
if let Err(e) = relaxed_idb::install::<WasmOsCallback>(&options, true).await {
|
||||
log::warn!("IndexedDB storage unavailable: {e:?}");
|
||||
return None;
|
||||
}
|
||||
|
||||
match NostrSqlite::builder()
|
||||
.persistent_with_vfs(DATABASE_PATH, VFS_NAME)
|
||||
.build()
|
||||
.await
|
||||
{
|
||||
Ok(database) => {
|
||||
log::info!("Nostr database opened on IndexedDB storage");
|
||||
Some(database)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to open the nostr database on IndexedDB: {e:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Browser file interoperability: a persistent hidden `<input type="file">`
|
||||
//! picker, blob URLs for rendering, and anchor-based downloads.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use flume::Sender;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use web_sys::{File, HtmlAnchorElement, HtmlInputElement, Url as DomUrl, window as js_window};
|
||||
|
||||
use crate::file::PickedFile;
|
||||
|
||||
type PickResult = Result<Option<PickedFile>, Error>;
|
||||
|
||||
thread_local! {
|
||||
/// The picker input lives for the whole page so its change closure does
|
||||
/// not have to be leaked or re-registered on every pick
|
||||
static PICKER: RefCell<Option<Picker>> = const { RefCell::new(None) };
|
||||
/// The sender the active pick delivers through
|
||||
static PENDING: RefCell<Option<Sender<PickResult>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct Picker {
|
||||
input: HtmlInputElement,
|
||||
/// Keep the change handler alive for the life of the page
|
||||
_on_change: Closure<dyn FnMut()>,
|
||||
}
|
||||
|
||||
/// Ask the user for a single file and read its bytes
|
||||
pub async fn pick_file() -> PickResult {
|
||||
let (sender, receiver) = flume::bounded(1);
|
||||
|
||||
PICKER.with(|picker| -> Result<(), Error> {
|
||||
let mut picker = picker.borrow_mut();
|
||||
if picker.is_none() {
|
||||
*picker = Some(init_picker()?);
|
||||
}
|
||||
let picker = picker.as_ref().expect("picker just initialized");
|
||||
|
||||
PENDING.with(|pending| *pending.borrow_mut() = Some(sender));
|
||||
picker.input.click();
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
receiver
|
||||
.recv_async()
|
||||
.await
|
||||
.map_err(|_| anyhow!("File picker closed"))?
|
||||
}
|
||||
|
||||
fn init_picker() -> Result<Picker, Error> {
|
||||
let document = js_window()
|
||||
.ok_or_else(|| anyhow!("no browser window"))?
|
||||
.document()
|
||||
.ok_or_else(|| anyhow!("no browser document"))?;
|
||||
|
||||
let input = document
|
||||
.create_element("input")
|
||||
.map_err(|e| anyhow!("failed to create file input: {e:?}"))?
|
||||
.dyn_into::<HtmlInputElement>()
|
||||
.map_err(|_| anyhow!("created element is not an input"))?;
|
||||
|
||||
input.set_type("file");
|
||||
input.style().set_property("display", "none").ok();
|
||||
|
||||
let on_change = Closure::wrap(Box::new(move || {
|
||||
let Some(sender) = PENDING.with(|pending| pending.borrow_mut().take()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
sender.send_async(read_selected_file().await).await.ok();
|
||||
});
|
||||
}) as Box<dyn FnMut()>);
|
||||
|
||||
input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
|
||||
|
||||
let body = document.body().ok_or_else(|| anyhow!("no browser body"))?;
|
||||
body.append_child(&input)
|
||||
.map_err(|e| anyhow!("failed to mount file input: {e:?}"))?;
|
||||
|
||||
Ok(Picker {
|
||||
input,
|
||||
_on_change: on_change,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the first file of the picker input, if one was chosen
|
||||
async fn read_selected_file() -> PickResult {
|
||||
let picker = PICKER
|
||||
.with(|picker| picker.borrow().as_ref().map(|picker| picker.input.clone()))
|
||||
.ok_or_else(|| anyhow!("file picker is not initialized"))?;
|
||||
|
||||
let Some(file) = picker.files().and_then(|files| files.get(0)) else {
|
||||
// The dialog was dismissed without choosing a file
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mime = non_empty(file.type_());
|
||||
let data = read_file_bytes(&file).await?;
|
||||
let local = crate::file::LocalFile::Url(blob_url(&mime, &data)?);
|
||||
|
||||
Ok(Some(PickedFile {
|
||||
name: file.name(),
|
||||
mime,
|
||||
data,
|
||||
local,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Read a browser `File` into memory
|
||||
async fn read_file_bytes(file: &File) -> Result<Vec<u8>, Error> {
|
||||
let promise = file.array_buffer();
|
||||
let buffer = wasm_bindgen_futures::JsFuture::from(promise)
|
||||
.await
|
||||
.map_err(|e| anyhow!("failed to read file: {e:?}"))?;
|
||||
|
||||
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
|
||||
}
|
||||
|
||||
/// Create an object URL for bytes, so images and downloads render from memory
|
||||
pub fn blob_url(mime: &str, data: &[u8]) -> Result<String, Error> {
|
||||
let array = js_sys::Uint8Array::from(data);
|
||||
let parts = js_sys::Array::new();
|
||||
parts.push(&array);
|
||||
|
||||
let bag = web_sys::BlobPropertyBag::new();
|
||||
bag.set_type(mime);
|
||||
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts.into(), &bag)
|
||||
.map_err(|e| anyhow!("failed to create blob: {e:?}"))?;
|
||||
|
||||
DomUrl::create_object_url_with_blob(&blob).map_err(|e| anyhow!("failed to create url: {e:?}"))
|
||||
}
|
||||
|
||||
/// Offer bytes to the user as a browser download
|
||||
pub fn download(name: &str, mime: &str, data: &[u8]) -> Result<(), Error> {
|
||||
let window = js_window().ok_or_else(|| anyhow!("no browser window"))?;
|
||||
let document = window
|
||||
.document()
|
||||
.ok_or_else(|| anyhow!("no browser document"))?;
|
||||
|
||||
let url = blob_url(mime, data)?;
|
||||
|
||||
let anchor: HtmlAnchorElement = document
|
||||
.create_element("a")
|
||||
.map_err(|e| anyhow!("failed to create download link: {e:?}"))?
|
||||
.dyn_into()
|
||||
.map_err(|_| anyhow!("created element is not an anchor"))?;
|
||||
anchor.set_href(&url);
|
||||
anchor.set_download(name);
|
||||
anchor.style().set_property("display", "none").ok();
|
||||
|
||||
let body = document.body().ok_or_else(|| anyhow!("no browser body"))?;
|
||||
body.append_child(&anchor)
|
||||
.map_err(|e| anyhow!("failed to mount download link: {e:?}"))?;
|
||||
anchor.click();
|
||||
body.remove_child(&anchor)
|
||||
.map_err(|e| anyhow!("failed to remove download link: {e:?}"))?;
|
||||
|
||||
// Keep the blob alive briefly so the download is not cancelled
|
||||
let callback = Closure::wrap(Box::new(move || {
|
||||
DomUrl::revoke_object_url(&url).ok();
|
||||
}) as Box<dyn FnMut()>);
|
||||
window
|
||||
.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
callback.as_ref().unchecked_ref(),
|
||||
10_000,
|
||||
)
|
||||
.map_err(|e| anyhow!("failed to schedule cleanup: {e:?}"))?;
|
||||
// The closure only runs once, after which it can be released
|
||||
callback.forget();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a URL in a new browser tab
|
||||
pub fn open_url(url: &str) -> Result<(), Error> {
|
||||
js_window()
|
||||
.ok_or_else(|| anyhow!("no browser window"))?
|
||||
.open_with_url_and_target(url, "_blank")
|
||||
.map_err(|e| anyhow!("failed to open url: {e:?}"))?
|
||||
.ok_or_else(|| anyhow!("the browser blocked opening the url"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn non_empty(value: impl Into<String>) -> String {
|
||||
let value: String = value.into();
|
||||
|
||||
if value.is_empty() {
|
||||
"application/octet-stream".to_string()
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,12 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
common = { path = "../common" }
|
||||
person = { path = "../person" }
|
||||
gpui.workspace = true
|
||||
gpui-base.workspace = true
|
||||
gpui-kit.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
web-time = "1.1"
|
||||
smallvec.workspace = true
|
||||
log.workspace = true
|
||||
|
||||