21 Commits
Author SHA1 Message Date
reya 5500082426 chore: update metadata 2026-09-16 10:23:53 +07:00
reya 7a34d66c7c fix: release endpoint 2026-09-16 10:19:53 +07:00
reya 57c85f5b99 fix: auto updater (#48)
Reviewed-on: #48
2026-09-16 02:53:16 +00:00
reya bf7080654d chore: use gpui image cache (#47)
Reviewed-on: #47
2026-09-16 02:21:12 +00:00
reya 42983383b0 chore: update script 2026-09-16 09:05:09 +07:00
reya e2b10c173e fix: markdown rendering (#46)
Reviewed-on: #46
2026-09-16 01:52:52 +00:00
reya 9e33da717b feat: add support for encrypted attachment (#45)
Reviewed-on: #45
2026-09-16 01:25:14 +00:00
reya 584ab34df6 chore: release version 1.0.1 2026-09-07 13:58:43 +07:00
reya 0514c1d982 fix: panic on flatpak installations 2026-09-07 13:49:36 +07:00
reya 2f834a0bcc chore: update deps 2026-08-31 16:54:38 +07:00
reya 49cd5cb9a0 chore: clean up 2026-08-03 09:53:12 +07:00
reya d082f4fad9 chore: release version 1.0.0 2026-08-03 09:19:51 +07:00
reya 8c59eabba3 chore: migrate to gpui updater (#42)
Reviewed-on: https://git.reya.su/reya/coop/pulls/42
2026-08-03 01:54:25 +00:00
reya bc6bdb3c35 feat: add support for logging in via the web extension (#41)
Reviewed-on: https://git.reya.su/reya/coop/pulls/41
2026-08-03 00:31:03 +00:00
reya dbfee32d55 feat: add support for launch arguments (#40)
Reviewed-on: https://git.reya.su/reya/coop/pulls/40
2026-08-02 07:10:44 +00:00
reya 4f52fc52df feat: add native notification (#39)
Reviewed-on: https://git.reya.su/reya/coop/pulls/39
2026-08-02 04:13:11 +00:00
reya fbf06f2c81 chore: refine some ui components (#38)
Reviewed-on: https://git.reya.su/reya/coop/pulls/38
2026-08-01 10:38:08 +00:00
reya b0d1521c49 feat: add support for reaction (#37)
Reviewed-on: https://git.reya.su/reya/coop/pulls/37
2026-07-31 02:58:42 +00:00
reya 9dbac5308d chore: update deps 2026-07-31 07:34:12 +07:00
reya c8cabb7cd2 chore: release version 1.0.0-rc 2026-07-30 15:48:12 +07:00
reya 6d9284b37a chore: improve web support (#36)
Reviewed-on: https://git.reya.su/reya/coop/pulls/36
2026-07-30 08:47:30 +00:00
137 changed files with 6292 additions and 7203 deletions
+13 -1
View File
@@ -152,11 +152,23 @@ jobs:
echo "Artifacts structure:"
find artifacts -type f -exec ls -la {} \;
- name: Generate SHA256SUMS
run: |
# One `<sha256> <path>` line per artifact. The in-app updater reads
# this to verify a download before installing it, so it must be
# published alongside every release. Written outside the directory
# being hashed so the checksums file never includes itself.
find artifacts -type f ! -name SHA256SUMS -print0 \
| sort -z \
| xargs -0 sha256sum > SHA256SUMS.raw
mv SHA256SUMS.raw artifacts/SHA256SUMS
cat artifacts/SHA256SUMS
- name: Create draft release
id: create_release
uses: akkuman/gitea-release-action@v1
with:
server_url: "https://git.reya.su/"
server_url: "https://git.reya.info/"
repository: "reya/coop"
token: ${{ secrets.GITEA_TOKEN }}
draft: true
+2 -2
View File
@@ -19,8 +19,8 @@ dist/
# Useless stuffs
.DS_Store
# Added by goreleaser init:
.intentionally-empty-file.o
.cargo/
vendor/
wasm/
node_modules/
+150
View File
@@ -0,0 +1,150 @@
# Rust coding guidelines
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
- Propagate errors with `?` when the calling function should handle them
- Use `.log_err()` or similar when you need to ignore errors but want visibility
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
* Avoid creative additions unless explicitly requested
* Use full words for variable names (no abbreviations like "q" for "queue")
* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references.
Example:
```rust
executor.spawn({
let task_ran = task_ran.clone();
async move {
*task_ran.borrow_mut() = true;
}
});
```
# Timers in tests
* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`:
- Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher.
- Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping.
# GPUI
GPUI is a UI framework which also provides primitives for state and concurrency management.
## Context
Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter.
* `App` is the root context type, providing access to global state and read and update of entities.
* `Context<T>` is provided when updating an `Entity<T>`. This context dereferences into `App`, so functions which take `&App` can also take `&Context<T>`.
* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points.
## `Window`
`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc.
## Entities
An `Entity<T>` is a handle to state of type `T`. With `thing: Entity<T>`:
* `thing.entity_id()` returns `EntityId`
* `thing.downgrade()` returns `WeakEntity<T>`
* `thing.read(cx: &App)` returns `&T`.
* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value.
* `thing.update(cx, |thing: &mut T, cx: &mut Context<T>| ...)` allows the closure to mutate the state, and provides a `Context<T>` for interacting with the entity. It returns the closure's return value.
* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context<T>| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`.
Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows.
Trying to update an entity while it's already being updated must be avoided as this will cause a panic.
`WeakEntity<T>` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped.
## Concurrency
All use of entities and UI rendering occurs on a single foreground thread.
`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is `&mut AsyncApp`.
When the outer cx is a `Context<T>`, the use of `spawn` instead looks like `cx.spawn(async move |this, cx| ...)`, where `this: WeakEntity<T>` and `cx: &mut AsyncApp`.
To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state.
Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done:
* Awaiting the task in some other async context.
* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely.
* Storing the task in a field, if the work should be halted when the struct is dropped.
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
## Elements
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
Example:
```
struct TextWithBorder(SharedString);
impl Render for TextWithBorder {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().border_1().child(self.0.clone())
}
}
```
Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc<str>`.
UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context<Self>`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children.
The style methods on elements are similar to those used by Tailwind CSS.
If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value.
## Input events
Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`.
Often event handlers will want to update the entity that's in the current `Context<T>`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context<T>| ...)`.
## Actions
Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
## Notify
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
## Entity events
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
# Pull request hygiene
When an agent opens or updates a pull request, it must:
- Use a clear, correctly capitalized, imperative PR title (for example, `fix crash in project panel`).
- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.).
- Avoid trailing punctuation in PR titles.
- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `workspace: add history view`).
- Include a `Release Notes:` section as the final section in the PR body.
- Use one bullet under `Release Notes:`:
- `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or
- `- N/A` for docs-only and other non-user-facing changes.
- Format release notes exactly with a blank line after the heading, for example:
```
Release Notes:
- N/A
```
Generated
+929 -858
View File
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"]
default-members = ["desktop"]
[workspace.package]
version = "1.0.0-beta5"
version = "1.0.2"
edition = "2024"
publish = false
@@ -24,13 +24,17 @@ nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
# Crypto (NIP-17 encrypted file messages)
aes-gcm = "0.10"
sha2 = "0.10"
data-encoding = "2"
# Others
anyhow = "1.0.44"
chrono = "0.4.38"
chrono = { version = "0.4.38", features = ["wasmbind"] }
futures = "0.3"
itertools = "0.13.0"
log = "0.4"
@@ -43,7 +47,10 @@ schemars = "1"
smallvec = "1.14.0"
smol = "2"
webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
errno = { version = "0.3.14", default-features = false }
instant = "0.1"
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] }
[patch.crates-io]
# Use stacker's psm version which may have better WASM support
-120
View File
@@ -1,125 +1,5 @@
![Coop](/docs/coop.png)
<p>
<a href="https://github.com/reyakov/coop/actions/workflows/rust.yml">
<img alt="Actions" src="https://github.com/reyakov/coop/actions/workflows/rust.yml/badge.svg">
</a>
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/reyakov/coop">
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/reyakov/coop">
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/reyakov/coop">
</p>
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
### Screenshots
<p float="left">
<img src="/docs/mac_01.png" width="250" />
<img src="/docs/mac_02.png" width="250" />
<img src="/docs/mac_03.png" width="250" />
<img src="/docs/mac_04.png" width="250" />
<img src="/docs/mac_05.png" width="250" />
<img src="/docs/mac_06.png" width="250" />
<img src="/docs/mac_07.png" width="250" />
<img src="/docs/mac_08.png" width="250" />
<img src="/docs/mac_09.png" width="250" />
<img src="/docs/linux_01.png" width="250" />
<img src="/docs/linux_02.png" width="250" />
<img src="/docs/linux_03.png" width="250" />
<img src="/docs/linux_04.png" width="250" />
<img src="/docs/linux_05.png" width="250" />
</p>
### Installation
To install Coop, follow these steps:
1. **Download the Latest Release**:
- Visit the [Coop Releases page on GitHub](https://github.com/reyakov/coop/releases).
- Download the package that matches your operating system (Windows, macOS, or Linux).
2. **Install**:
- **Windows**: Run the downloaded `.exe` installer and follow the on-screen instructions.
- **macOS**: Open the downloaded `.dmg` file and drag Coop to your Applications folder.
- **Linux**: Run the downloaded `.flatpak` or `.snap` installer and follow the on-screen instructions.
3. **Run Coop**:
- Launch Coop from your Applications folder (macOS) or by double-clicking the executable (Windows/Linux).
For more detailed instructions, refer to the [Release Notes](#) on GitHub.
### Developing Coop
Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nostr SDK](https://github.com/rust-nostr/nostr)
#### Prerequisites
- **Rust Toolchain**: Ensure you have Rust installed. If not, you can install it using [rustup](https://rustup.rs/).
- **Cargo**: Rust's package manager, which comes bundled with the Rust installation.
- **Git**: To clone the repository and manage version control.
#### Setting Up the Development Environment
1. Clone the repository:
```bash
git clone https://github.com/reyakov/coop.git
cd coop
```
2.1 Install Linux dependencies:
```bash
./script/linux
```
2.2 Install FreeBSD dependencies:
```bash
./script/freebsd
```
3. Install Rust dependencies:
```bash
cargo build
```
4. Run the app:
```bash
cargo run
```
#### Building for Production
To build Coop for production, use the following command:
```bash
cargo build --release
```
This will generate an optimized binary in the `target/release` directory.
#### Contributing Code
If you'd like to contribute to Coop, please follow these steps:
1. Fork the repository.
2. Create a new branch for your feature or bugfix.
3. Make your changes and ensure all tests pass.
4. Submit a pull request with a detailed description of your changes.
For more information, see the [Contributing](#contributing) section.
#### Additional Resources
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
- [GPUI](https://www.gpui.rs/)
- [GPUI Components](https://github.com/longbridge/gpui-component/)
- [Coop Issue Tracker](https://github.com/reyakov/coop/issues/)
### License
Copyright (C) 2025 Ren Amamiya & other Coop contributors
+3
View File
@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 626 B

+1 -1
View File
@@ -2,7 +2,7 @@
"id": "aurora",
"name": "Aurora",
"author": "Coop",
"url": "https://github.com/lumehq/coop",
"url": "https://coopchat.xyz",
"light": {
"background": "#fdfcfeff",
"surface_background": "#f8f8ffff",
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "forest",
"name": "Forest",
"author": "Coop",
"url": "https://github.com/lumehq/coop",
"url": "https://coopchat.xyz",
"light": {
"background": "#fbfefcff",
"surface_background": "#f4fbf6ff",
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "ocean",
"name": "Ocean",
"author": "Coop",
"url": "https://github.com/lumehq/coop",
"url": "https://coopchat.xyz",
"light": {
"background": "#fafefeff",
"surface_background": "#f2fbfaff",
+4 -10
View File
@@ -5,17 +5,11 @@ edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
gpui.workspace = true
anyhow.workspace = true
instant.workspace = true
log.workspace = true
smallvec.workspace = true
serde = { workspace = true, features = ["derive"] }
serde.workspace = true
serde_json.workspace = true
ureq.workspace = true
semver = "1.0.27"
tempfile = "3.23.0"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" }
+258 -495
View File
@@ -1,563 +1,326 @@
#![cfg(not(target_arch = "wasm32"))]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::http_client::{AsyncBody, HttpClient};
use gpui::{
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
Window,
};
use semver::Version;
use serde::Deserialize;
use smallvec::{SmallVec, smallvec};
use smol::fs::File;
use smol::io::AsyncReadExt;
use smol::process::Command;
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window};
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
use instant::Duration;
use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
mod source;
pub use gpui_updater_core::UpdateStatus as AutoUpdateStatus;
const GITEA_API_BASE: &str = "https://git.reya.info/api/v1";
const GITEA_REPO_OWNER: &str = "reya";
const GITEA_REPO_NAME: &str = "coop";
/// Delay before the automatic check that runs on startup.
const AUTO_CHECK_DELAY: Duration = Duration::from_secs(120);
/// How long a failure stays visible before the status reverts to "Up to date".
const ERROR_DISPLAY_DURATION: Duration = Duration::from_secs(5);
const GITHUB_API_URL: &str = "https://api.github.com";
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
fn get_github_repo_owner() -> String {
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
}
fn get_github_repo_name() -> String {
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
}
fn is_flatpak_installation() -> bool {
// Check if app is installed via Flatpak
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
fn uses_managed_updates() -> bool {
// The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
std::env::var("FLATPAK_ID").is_ok()
// Allow opting out of in-app updates via an explicit environment variable.
|| std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
// The Snap package sets `COOP_BUNDLE_TYPE=snap` (see snapcraft.yaml.in).
|| std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value| value == "snap")
}
/// Initialize the auto-update system.
pub fn init(window: &mut Window, cx: &mut App) {
// Skip auto-update initialization if installed via Flatpak
if is_flatpak_installation() {
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
if uses_managed_updates() {
log::info!(
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)"
);
return;
}
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(window, cx)), cx);
let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
let Some(filter) = asset_filter_for(os, arch) else {
log::info!(
"Skipping auto-update initialization: no installable release artifact is published for {os}/{arch}"
);
return;
};
let Ok(version) = Version::parse(env!("CARGO_PKG_VERSION")) else {
log::error!(
"Skipping auto-update initialization: crate version {:?} is not valid semver",
env!("CARGO_PKG_VERSION")
);
return;
};
AutoUpdater::set_global(
cx.new(|cx| AutoUpdater::new(window, version, filter, cx)),
cx,
);
}
struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {}
#[cfg(not(target_os = "windows"))]
struct InstallerDir(tempfile::TempDir);
#[cfg(not(target_os = "windows"))]
impl InstallerDir {
async fn new() -> Result<Self, Error> {
Ok(Self(
tempfile::Builder::new()
.prefix("coop-auto-update")
.tempdir()?,
))
}
fn path(&self) -> &Path {
self.0.path()
}
}
#[cfg(target_os = "windows")]
struct InstallerDir(PathBuf);
#[cfg(target_os = "windows")]
impl InstallerDir {
async fn new() -> Result<Self, Error> {
let installer_dir = std::env::current_exe()?
.parent()
.context("No parent dir for Coop.exe")?
.join("updates");
if smol::fs::metadata(&installer_dir).await.is_ok() {
smol::fs::remove_dir_all(&installer_dir).await?;
}
smol::fs::create_dir(&installer_dir).await?;
Ok(Self(installer_dir))
}
fn path(&self) -> &Path {
self.0.as_path()
}
}
struct MacOsUnmounter<'a> {
mount_path: PathBuf,
background_executor: &'a BackgroundExecutor,
}
impl Drop for MacOsUnmounter<'_> {
fn drop(&mut self) {
let mount_path = std::mem::take(&mut self.mount_path);
self.background_executor
.spawn(async move {
let unmount_output = Command::new("hdiutil")
.args(["detach", "-force"])
.arg(&mount_path)
.output()
.await;
match unmount_output {
Ok(output) if output.status.success() => {
log::info!("Successfully unmounted the disk image");
}
Ok(output) => {
log::error!(
"Failed to unmount disk image: {:?}",
String::from_utf8_lossy(&output.stderr)
);
}
Err(error) => {
log::error!("Error while trying to unmount disk image: {:?}", error);
}
}
})
.detach();
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum AutoUpdateStatus {
Idle,
Checking,
Checked { download_url: String },
Installing,
Updated,
Errored { msg: Box<String> },
}
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
fn as_ref(&self) -> &AutoUpdateStatus {
self
}
}
impl AutoUpdateStatus {
pub fn is_updating(&self) -> bool {
matches!(self, Self::Checked { .. } | Self::Installing)
}
pub fn is_updated(&self) -> bool {
matches!(self, Self::Updated)
}
pub fn checked(download_url: String) -> Self {
Self::Checked { download_url }
}
pub fn error(e: String) -> Self {
Self::Errored { msg: Box::new(e) }
}
}
#[derive(Debug, Deserialize)]
pub struct GitHubRelease {
pub tag_name: String,
pub assets: Vec<GitHubAsset>,
}
#[derive(Debug, Deserialize)]
pub struct GitHubAsset {
pub name: String,
pub browser_download_url: String,
}
#[derive(Debug)]
pub struct AutoUpdater {
/// Current status of the auto updater
pub status: AutoUpdateStatus,
/// Current version of the application
/// The blocking engine, driven on the background executor.
engine: Arc<UpdateEngine<GiteaSource>>,
status: UpdateStatus,
/// The newer release found by the last successful check, if any.
available: Option<Release>,
/// Currently running app version.
pub version: Version,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
/// The in-flight check or download, if any.
task: Option<Task<()>>,
}
impl AutoUpdater {
/// Retrieve the global auto updater instance
/// Whether auto-update is available for this installation.
pub fn is_available(cx: &App) -> bool {
cx.try_global::<GlobalAutoUpdater>().is_some()
}
/// Retrieve the global auto updater instance, if one was initialized.
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
cx.try_global::<GlobalAutoUpdater>()
.map(|global| global.0.clone())
}
/// Retrieve the global auto updater instance.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAutoUpdater>().0.clone()
}
/// Set the global auto updater instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAutoUpdater(state));
}
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
let mut subscriptions = smallvec![];
fn new(
window: &mut Window,
version: Version,
filter: AssetFilter,
cx: &mut Context<Self>,
) -> Self {
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
let engine = Arc::new(UpdateEngine::new(source, config));
subscriptions.push(
// Observe the status
cx.observe_self(|this, cx| {
if let AutoUpdateStatus::Checked { download_url } = this.status.clone() {
this.download_and_install(&download_url, cx);
}
}),
);
// Run at the end of current cycle
cx.defer_in(window, |this, _window, cx| {
this.check(cx);
// Schedule an auto-check after a 2-minute delay
cx.defer_in(window, |_this, _window, cx| {
cx.spawn(async move |this, cx| {
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
this.update(cx, |this, cx| this.check(cx)).ok();
})
.detach();
});
Self {
status: AutoUpdateStatus::Idle,
engine,
status: UpdateStatus::Idle,
available: None,
version,
tasks: vec![],
_subscriptions: subscriptions,
task: None,
}
}
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
self.status = status;
cx.notify();
/// Whether nothing is happening, so the UI can hide the status line.
pub fn idle(&self) -> bool {
matches!(self.status, UpdateStatus::Idle)
}
fn check(&mut self, cx: &mut Context<Self>) {
let version = self.version.clone();
let duration = Duration::from_secs(120);
let task = self.check_for_updates(version, cx);
/// Whether a verified update is installed and waiting for a restart.
pub fn staged(&self) -> bool {
matches!(self.status, UpdateStatus::Staged(_))
}
// Check for updates after 2 minutes
self.tasks.push(cx.spawn(async move |this, cx| {
cx.background_executor().timer(duration).await;
// Update the status to checking
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Checking, cx);
})?;
match task.await {
Ok(download_url) => {
// Update the status to checked with download URL
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::checked(download_url), cx);
})?;
}
Err(e) => {
log::warn!("Failed to check for updates: {e}");
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Idle, cx);
})?;
/// A short, human-readable description of the current status.
pub fn status(&self) -> SharedString {
match &self.status {
UpdateStatus::Idle | UpdateStatus::UpToDate => "Up to date".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);
let downloaded_mb = *downloaded as f64 / 1_048_576.0;
match total_mb {
Some(t) => format!("Downloading {downloaded_mb:.1} / {t:.1} MB").into(),
None => format!("Downloading {downloaded_mb:.1} MB").into(),
}
}
UpdateStatus::Installing => "Installing update…".into(),
UpdateStatus::Staged(version) => {
format!("Version {version} ready — restart to apply").into()
}
UpdateStatus::Errored(message) => format!("Update failed: {message}").into(),
}
}
Ok(())
/// Check the release host for a newer version, then download and install it.
pub fn check(&mut self, cx: &mut Context<Self>) {
if self.status.is_busy() {
return;
}
self.set_status(UpdateStatus::Checking, cx);
let engine = self.engine.clone();
self.task = Some(cx.spawn(async move |this, cx| {
let result = cx
.background_executor()
.spawn(async move { engine.check() })
.await;
this.update(cx, |this, cx| {
this.task = None;
match result {
Ok(Some(release)) => {
log::info!("Update {} is available", release.version);
let version = release.version.clone();
this.available = Some(release);
this.set_status(UpdateStatus::Available(version), cx);
this.download_and_install(cx);
}
Ok(None) => this.set_status(UpdateStatus::UpToDate, cx),
Err(error) => {
log::warn!("Update check failed: {error}");
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
}
}
})
.ok();
}));
}
fn check_for_updates(&self, version: Version, cx: &App) -> Task<Result<String, Error>> {
let http_client = cx.http_client();
let repo_owner = get_github_repo_owner();
let repo_name = get_github_repo_name();
/// Download the available update, verify it, and swap it into place.
fn download_and_install(&mut self, cx: &mut Context<Self>) {
if self.status.is_busy() {
return;
}
let Some(release) = self.available.clone() else {
return;
};
cx.background_spawn(async move {
let url = format!(
"{}/repos/{}/{}/releases/latest",
GITHUB_API_URL, repo_owner, repo_name
);
let engine = self.engine.clone();
self.set_status(
UpdateStatus::Downloading {
downloaded: 0,
total: None,
},
cx,
);
let async_body = AsyncBody::default();
let mut body = Vec::new();
let mut response = http_client.get(&url, async_body, false).await?;
self.task = Some(cx.spawn(async move |this, cx| {
let downloaded = Arc::new(AtomicU64::new(0));
let total = Arc::new(AtomicU64::new(0)); // 0 = unknown
let done = Arc::new(AtomicBool::new(false));
// Read the response body into a vector
response.body_mut().read_to_end(&mut body).await?;
let download_task = {
let (engine, release) = (engine.clone(), release.clone());
let (downloaded, total, done) = (downloaded.clone(), total.clone(), done.clone());
cx.background_executor().spawn(async move {
let result = engine.download(&release, |got, expected| {
downloaded.store(got, Ordering::Relaxed);
total.store(expected.unwrap_or(0), Ordering::Relaxed);
});
done.store(true, Ordering::Relaxed);
result
})
};
if !response.status().is_success() {
return Err(anyhow!("GitHub API returned error: {}", response.status()));
}
// Parse the response body as JSON
let release: GitHubRelease = serde_json::from_slice(&body)?;
// Parse version from tag (remove 'v' prefix if present)
let tag_version = release.tag_name.trim_start_matches('v');
let new_version = Version::parse(tag_version).context(format!(
"Failed to parse version from tag: {}",
release.tag_name
))?;
if new_version > version {
// Find the appropriate asset for the current platform
let current_os = std::env::consts::OS;
let asset_name = match current_os {
"macos" => "Coop.dmg",
"linux" => "coop.tar.gz",
"windows" => "Coop.exe",
_ => return Err(anyhow!("Unsupported OS: {}", current_os)),
};
let download_url = release
.assets
.iter()
.find(|asset| asset.name == asset_name)
.map(|asset| asset.browser_download_url.clone())
.context(format!(
"No {} asset found in release {}",
asset_name, release.tag_name
))?;
Ok(download_url)
} else {
Err(anyhow!(
"No update available. Current: {}, Latest: {}",
version,
new_version
))
}
})
}
fn download_and_install(&mut self, download_url: &str, cx: &mut Context<Self>) {
let http_client = cx.http_client();
let download_url = download_url.to_string();
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
let installer_dir = InstallerDir::new().await?;
let target_path = Self::target_path(&installer_dir).await?;
// Download the release
download(&download_url, &target_path, http_client).await?;
Ok((installer_dir, target_path))
});
self.tasks.push(
// Install the new release
cx.spawn(async move |this, cx| {
loop {
let got = downloaded.load(Ordering::Relaxed);
let total = total.load(Ordering::Relaxed);
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Installing, cx);
})?;
this.set_status(
UpdateStatus::Downloading {
downloaded: got,
total: (total != 0).then_some(total),
},
cx,
);
})
.ok();
if done.load(Ordering::Relaxed) {
break;
}
cx.background_executor()
.timer(Duration::from_millis(120))
.await;
}
match task.await {
Ok((installer_dir, target_path)) => {
if Self::install(installer_dir, target_path, cx).await.is_ok() {
// Update the status to updated
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Updated, cx);
})?;
let artifact = match download_task.await {
Ok(artifact) => artifact,
Err(error) => {
log::warn!("Update download failed: {error}");
this.update(cx, |this, cx| {
this.task = None;
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
})
.ok();
return;
}
};
let _ = this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx));
let installed = {
let engine = engine.clone();
cx.background_executor()
.spawn(async move { engine.install(&artifact) })
.await
};
this.update(cx, |this, cx| {
this.task = None;
match installed {
Ok(installed) => {
if let Some(path) = installed.restart_path {
cx.set_restart_path(path);
}
let version = release.version.clone();
this.set_status(UpdateStatus::Staged(version), cx);
}
Err(e) => {
// Update the status to error including the error message
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
})?;
Err(error) => {
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
}
}
Ok(())
}),
);
})
.ok();
}));
}
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
let filename = match std::env::consts::OS {
"macos" => anyhow::Ok("Coop.dmg"),
"linux" => Ok("coop.tar.gz"),
"windows" => Ok("Coop.exe"),
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
}?;
Ok(installer_dir.path().join(filename))
}
async fn install(
installer_dir: InstallerDir,
target_path: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
match std::env::consts::OS {
"macos" => install_release_macos(&installer_dir, target_path, cx).await,
"linux" => install_release_linux(&installer_dir, target_path, cx).await,
"windows" => install_release_windows(target_path).await,
unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"),
/// Relaunch into the staged update.
pub fn restart(&mut self, cx: &mut Context<Self>) {
if !self.staged() {
log::warn!("Ignoring restart request: no update is staged");
return;
}
cx.restart();
}
}
async fn download(
url: &str,
target_path: &std::path::Path,
client: Arc<dyn HttpClient>,
) -> Result<(), Error> {
let body = AsyncBody::default();
let mut target_file = File::create(&target_path).await?;
let mut response = client.get(url, body, true).await?;
fn set_status(&mut self, status: UpdateStatus, cx: &mut Context<Self>) {
let errored = matches!(status, UpdateStatus::Errored(_));
self.status = status;
// Copy the response body to the target file
smol::io::copy(response.body_mut(), &mut target_file).await?;
Ok(())
}
async fn install_release_macos(
temp_dir: &InstallerDir,
downloaded_dmg: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
let running_app_path = cx.update(|cx| cx.app_path())?;
let running_app_filename = running_app_path
.file_name()
.with_context(|| format!("invalid running app path {running_app_path:?}"))?;
let mount_path = temp_dir.path().join("Coop");
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
mounted_app_path.push("/");
let output = Command::new("hdiutil")
.args(["attach", "-nobrowse"])
.arg(&downloaded_dmg)
.arg("-mountroot")
.arg(temp_dir.path())
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to mount: {:?}",
String::from_utf8_lossy(&output.stderr)
);
// Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
let _unmounter = MacOsUnmounter {
mount_path: mount_path.clone(),
background_executor: cx.background_executor(),
};
let output = Command::new("rsync")
.args(["-av", "--delete"])
.arg(&mounted_app_path)
.arg(&running_app_path)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to copy app: {:?}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
async fn install_release_linux(
temp_dir: &InstallerDir,
downloaded_tar_gz: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
let running_app_path = cx.update(|cx| cx.app_path())?;
// Extract the tar.gz file
let extracted = temp_dir.path().join("coop");
smol::fs::create_dir_all(&extracted)
.await
.context("failed to create directory to extract update")?;
let output = Command::new("tar")
.arg("-xzf")
.arg(&downloaded_tar_gz)
.arg("-C")
.arg(&extracted)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to extract {:?} to {:?}: {:?}",
downloaded_tar_gz,
extracted,
String::from_utf8_lossy(&output.stderr)
);
// Find the extracted app directory
let mut entries = smol::fs::read_dir(&extracted).await?;
let mut app_dir = None;
use smol::stream::StreamExt;
while let Some(entry) = entries.next().await {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
app_dir = Some(path);
break;
if errored {
cx.spawn(async move |this, cx| {
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
this.update(cx, |this, cx| {
this.set_status(UpdateStatus::Idle, cx);
})
.ok();
})
.detach();
}
cx.notify();
}
let from = app_dir.context("No app directory found in archive")?;
// Copy to the current installation directory
let output = Command::new("rsync")
.args(["-av", "--delete"])
.arg(&from)
.arg(
running_app_path
.parent()
.context("No parent directory for app")?,
)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to copy app from {:?} to {:?}: {:?}",
from,
running_app_path.parent(),
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
//const CREATE_NO_WINDOW: u32 = 0x08000000;
let system_root = std::env::var("SYSTEMROOT");
let powershell_path = system_root.as_ref().map_or_else(
|_| "powershell.exe".to_string(),
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
);
let mut installer_path = std::ffi::OsString::new();
installer_path.push("\"");
installer_path.push(&downloaded_installer);
installer_path.push("\"");
let output = Command::new(powershell_path)
//.creation_flags(CREATE_NO_WINDOW)
.args(["-NoProfile", "-WindowStyle", "Hidden"])
.args(["Start-Process"])
.arg(installer_path)
.arg("-ArgumentList")
.args(["/P", "/R"])
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to start installer: {:?}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
+369
View File
@@ -0,0 +1,369 @@
use gpui_updater_core::{Asset, Error, Release, Result, UpdateSource, parse_tag};
use serde::Deserialize;
const CHECKSUMS_ASSET: &str = "SHA256SUMS";
const RELEASE_PAGE_SIZE: usize = 20;
/// Which published artifact belongs to a target platform.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AssetFilter {
extension: &'static str,
arch: &'static str,
}
impl AssetFilter {
/// Whether `name` is the installable artifact for this target.
fn matches(&self, name: &str) -> bool {
let name = name.to_ascii_lowercase();
name.ends_with(self.extension) && name.contains(self.arch)
}
}
pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
let extension = match os {
"macos" => ".dmg",
"linux" => ".tar.gz",
_ => return None,
};
let arch = match (os, arch) {
// cargo-packager names the disk images `aarch64`/`x64`.
("macos", "aarch64") => "aarch64",
("macos", "x86_64") => "x64",
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
("linux", "aarch64") => "aarch64",
("linux", "x86_64") => "x86_64",
_ => return None,
};
Some(AssetFilter { extension, arch })
}
/// Reads releases from a Gitea repository's Releases.
pub struct GiteaSource {
api_base: String,
owner: String,
repo: String,
filter: AssetFilter,
}
impl GiteaSource {
/// Build a source for `owner/repo` on the Gitea instance at `api_base`
/// (e.g. `https://git.reya.info/api/v1`).
pub fn new(
api_base: impl Into<String>,
owner: impl Into<String>,
repo: impl Into<String>,
filter: AssetFilter,
) -> Self {
Self {
api_base: api_base.into().trim_end_matches('/').to_string(),
owner: owner.into(),
repo: repo.into(),
filter,
}
}
fn releases_url(&self) -> String {
format!(
"{}/repos/{}/{}/releases?limit={RELEASE_PAGE_SIZE}",
self.api_base, self.owner, self.repo
)
}
}
impl UpdateSource for GiteaSource {
fn fetch_latest(&self) -> Result<Release> {
let releases: Vec<GiteaRelease> = http::get_json(&self.releases_url())?;
let release = newest_published(&releases)
.ok_or_else(|| Error::Parse("repository has no published releases".to_string()))?;
let asset = release
.assets
.iter()
.find(|asset| self.filter.matches(&asset.name))
.ok_or(Error::NoMatchingAsset {
target_os: std::env::consts::OS,
target_arch: std::env::consts::ARCH,
})?;
// Resolve the published checksum so the engine can reject a truncated or substituted download.
let sha256 = release
.assets
.iter()
.find(|candidate| candidate.name.eq_ignore_ascii_case(CHECKSUMS_ASSET))
.map(|sums| http::get_string(&sums.browser_download_url))
.transpose()?
.and_then(|sums| sha256_for(&sums, &asset.name));
Ok(Release {
version: parse_tag(&release.tag_name)?,
notes: release
.body
.clone()
.filter(|body| !body.trim().is_empty())
.or_else(|| release.name.clone()),
asset: Asset {
name: asset.name.clone(),
url: asset.browser_download_url.clone(),
size: asset.size,
},
signature: None,
signature_url: None,
sha256,
})
}
}
fn newest_published(releases: &[GiteaRelease]) -> Option<&GiteaRelease> {
releases
.iter()
.filter(|release| !release.draft && !release.prerelease)
.filter_map(|release| {
parse_tag(&release.tag_name)
.ok()
.map(|version| (version, release))
})
.max_by(|(left, _), (right, _)| left.cmp(right))
.map(|(_, release)| release)
}
/// The SHA-256 recorded for `asset_name` in a `shasum`-style checksums file.
fn sha256_for(sums: &str, asset_name: &str) -> Option<String> {
sums.lines().find_map(|line| {
let mut parts = line.split_whitespace();
let (hash, path) = (parts.next()?, parts.next()?);
let path = path.strip_prefix('*').unwrap_or(path);
let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
(base == asset_name).then(|| hash.to_ascii_lowercase())
})
}
/// A release as returned by the Gitea API.
#[derive(Debug, Deserialize)]
struct GiteaRelease {
tag_name: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
body: Option<String>,
#[serde(default)]
draft: bool,
#[serde(default)]
prerelease: bool,
#[serde(default)]
assets: Vec<GiteaAsset>,
}
/// A release asset as returned by the Gitea API.
#[derive(Debug, Deserialize)]
struct GiteaAsset {
name: String,
browser_download_url: String,
#[serde(default)]
size: u64,
}
/// Blocking HTTP helpers for release metadata.
mod http {
use std::time::Duration;
use gpui_updater_core::{Error, Result};
use serde::de::DeserializeOwned;
use ureq::Agent;
use ureq::tls::{RootCerts, TlsConfig};
const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION"));
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
fn agent() -> Agent {
Agent::config_builder()
.user_agent(USER_AGENT)
.tls_config(
TlsConfig::builder()
.root_certs(RootCerts::PlatformVerifier)
.build(),
)
.timeout_resolve(Some(CONNECT_TIMEOUT))
.timeout_connect(Some(CONNECT_TIMEOUT))
.timeout_recv_response(Some(RESPONSE_TIMEOUT))
.build()
.into()
}
fn get_bytes(url: &str) -> Result<Vec<u8>> {
let mut response = agent().get(url).call().map_err(|error| match error {
ureq::Error::StatusCode(code) => Error::Http(format!("GET {url} -> {code}")),
other => Error::Http(other.to_string()),
})?;
response
.body_mut()
.read_to_vec()
.map_err(|error| Error::Http(format!("GET {url} -> {error}")))
}
pub(super) fn get_json<T: DeserializeOwned>(url: &str) -> Result<T> {
serde_json::from_slice(&get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
}
pub(super) fn get_string(url: &str) -> Result<String> {
String::from_utf8(get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
}
}
#[cfg(test)]
mod tests {
use gpui_updater_core::Version;
use super::*;
const PUBLISHED: &[&str] = &[
"Coop_1.0.1_aarch64.dmg",
"Coop_1.0.1_x64.dmg",
"coop-linux-aarch64.tar.gz",
"coop-linux-x86_64.tar.gz",
"coop_1.0.1_aarch64.snap",
"coop_1.0.1_arm64-setup.exe",
"coop_1.0.1_x64-setup.exe",
"coop_1.0.1_x86_64.snap",
"su.reya.coop_aarch64.flatpak",
"su.reya.coop_x86_64.flatpak",
];
fn selected(os: &str, arch: &str) -> Option<&'static str> {
let filter = asset_filter_for(os, arch)?;
PUBLISHED.iter().copied().find(|name| filter.matches(name))
}
#[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("linux", "aarch64"),
Some("coop-linux-aarch64.tar.gz")
);
assert_eq!(
selected("linux", "x86_64"),
Some("coop-linux-x86_64.tar.gz")
);
}
#[test]
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", "riscv64"), None);
}
#[test]
fn ignores_package_formats_and_sidecars_that_are_not_the_artifact() {
let macos = asset_filter_for("macos", "aarch64").unwrap();
assert!(!macos.matches("coop_1.0.1_aarch64.snap"));
assert!(!macos.matches("su.reya.coop_aarch64.flatpak"));
assert!(!macos.matches("Coop_1.0.1_aarch64.dmg.minisig"));
let linux = asset_filter_for("linux", "x86_64").unwrap();
assert!(!linux.matches("coop_1.0.1_x64-setup.exe"));
assert!(!linux.matches("coop_1.0.1_x86_64.snap"));
}
#[test]
fn reads_checksums_by_basename_ignoring_directory_prefix() {
let sums = "\
abcdef macos-arm64-artifacts/Coop_1.0.1_aarch64.dmg
123456 *linux-x64-artifacts/coop-linux-x86_64.tar.gz
789abc SHA256SUMS
";
assert_eq!(
sha256_for(sums, "Coop_1.0.1_aarch64.dmg").as_deref(),
Some("abcdef")
);
assert_eq!(
sha256_for(sums, "coop-linux-x86_64.tar.gz").as_deref(),
Some("123456")
);
assert_eq!(sha256_for(sums, "coop_1.0.1_x64-setup.exe"), None);
}
#[test]
fn newest_published_skips_drafts_prereleases_and_bad_tags() {
let releases: Vec<GiteaRelease> = serde_json::from_str(
r#"[
{
"tag_name": "v1.0.2",
"draft": true,
"assets": []
},
{
"tag_name": "v2.0.0-rc.1",
"prerelease": true,
"assets": []
},
{
"tag_name": "nightly",
"assets": []
},
{
"tag_name": "v1.0.0",
"assets": [
{
"name": "coop-linux-x86_64.tar.gz",
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.0/coop-linux-x86_64.tar.gz",
"size": 26160329
}
]
},
{
"tag_name": "v1.0.1",
"name": "v1.0.1",
"body": "Fixed app panic on flatpak installations",
"assets": [
{
"name": "coop-linux-x86_64.tar.gz",
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.1/coop-linux-x86_64.tar.gz",
"size": 26160329
}
]
}
]"#,
)
.unwrap();
let newest = newest_published(&releases).unwrap();
assert_eq!(newest.tag_name, "v1.0.1");
assert_eq!(parse_tag(&newest.tag_name).unwrap().to_string(), "1.0.1");
assert_eq!(newest.assets.len(), 1);
assert_eq!(newest.assets[0].size, 26160329);
}
#[test]
#[ignore = "requires network access to the release host"]
fn live_release_source_resolves_the_running_platform() {
let filter = asset_filter_for(std::env::consts::OS, std::env::consts::ARCH)
.expect("this platform should be supported");
let source = GiteaSource::new("https://git.reya.info/api/v1", "reya", "coop", filter);
let release = source
.fetch_latest()
.expect("release lookup should succeed");
assert!(
release.version >= Version::new(1, 0, 0),
"unexpected version {}",
release.version
);
assert!(
source.filter.matches(&release.asset.name),
"unexpected artifact {}",
release.asset.name
);
assert!(
release.asset.url.starts_with("https://"),
"{} ",
release.asset.url
);
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "browser-signer-proxy"
version = "0.1.0"
edition.workspace = true
description = "Nostr browser signer (NIP-07) proxy using smol async runtime"
license = "MIT"
repository = "https://github.com/nostrdevkit/nostr"
publish = false
[dependencies]
atomic-destructor = "0.2"
event-listener = "5"
nostr.workspace = true
opaquerr = { version = "0.1", features = ["alloc"] }
serde.workspace = true
serde_json.workspace = true
smol.workspace = true
tracing = { version = "0.1", features = ["std"] }
uuid = { version = "1.23", features = ["serde", "v4"] }
+55
View File
@@ -0,0 +1,55 @@
# browser-signer-proxy
Proxy to use Nostr Browser signer ([NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)) in native applications.
This is a re-implementation of [`nostr-browser-signer-proxy`](https://github.com/nostrdevkit/nostr/tree/master/signer/nostr-browser-signer-proxy)
using the [`smol`](https://github.com/smol-rs/smol) async runtime instead of tokio.
## Description
This crate provides a local HTTP proxy that communicates with a NIP-07 browser extension
(e.g., Alby, nos2x) running in a browser tab. Native applications can use this proxy to
request public keys, sign events, and perform NIP-04/NIP-44 encryption/decryption through
the browser extension.
The HTTP server is implemented with a minimal, dependency-free approach using `smol::net::TcpListener`
and manual HTTP/1.1 parsing — avoiding heavy HTTP framework dependencies entirely.
## Usage
```rust
use browser_signer_proxy::prelude::*;
async fn example() -> Result<(), Error> {
// Create the proxy with default options (localhost:7400)
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
// Open the proxy URL in a browser
webbrowser::open(&proxy.url())?;
// Start the proxy server
proxy.start().await?;
// Use it as an async Nostr signer
let public_key = proxy.get_public_key_async().await?;
println!("Connected with public key: {public_key}");
Ok(())
}
```
## Differences from the tokio-based version
| Feature | tokio (original) | smol (this crate) |
|---|---|---|
| Async runtime | `tokio` | `smol` |
| HTTP server | `hyper` | `smol::net::TcpListener` + manual HTTP/1.1 |
| Mutex | `tokio::sync::Mutex` | `smol::lock::Mutex` |
| Shutdown signal | `tokio::sync::Notify` | `event_listener::Event` |
| Request-response channel | `tokio::sync::oneshot` | `smol::channel::bounded(1)` |
| Timeout | `tokio::time::timeout` | `smol::future::or` + `smol::Timer` |
| Task spawning | `tokio::spawn` | `smol::spawn` |
## License
This project is distributed under the MIT software license.
+185
View File
@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Coop — Web Signer Proxy</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&display=swap" rel="stylesheet">
<style>
:root {
--brand: #F8FF37;
--ink: #111111;
--ink-soft: #333333;
--muted: #666666;
--paper: #FFFFFF;
--edge: rgba(17, 17, 17, 0.14);
--radius-sm: 1rem;
--radius-md: 1.5rem;
--radius-lg: 2.5rem;
--green: #2E8B57;
--red: #D32F2F;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
min-height: 100%;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--ink);
background: var(--brand);
-webkit-font-smoothing: antialiased;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
.card {
background: var(--paper);
border-radius: var(--radius-md);
padding: 2.5rem;
max-width: 440px;
width: 100%;
box-shadow: 0 8px 0 rgba(17, 17, 17, 0.12), 0 2px 20px rgba(17, 17, 17, 0.06);
}
.logo {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.75rem;
}
.logo__mark {
width: 2.5rem;
height: 2.5rem;
background: var(--ink);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.2rem;
color: var(--brand);
letter-spacing: -0.02em;
}
.logo__text {
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.3rem;
letter-spacing: -0.02em;
}
.heading {
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.6rem;
letter-spacing: -0.03em;
line-height: 1.15;
margin: 0 0 0.6rem;
}
.subtitle {
font-size: 0.95rem;
color: var(--ink-soft);
margin: 0 0 1.75rem;
line-height: 1.5;
}
.status {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.95rem;
transition: background 300ms ease, color 300ms ease;
}
.status--checking {
background: rgba(17, 17, 17, 0.05);
color: var(--muted);
}
.status--connected {
background: rgba(46, 139, 87, 0.1);
color: var(--green);
}
.status--error {
background: rgba(211, 47, 47, 0.08);
color: var(--red);
}
.status__dot {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
flex-shrink: 0;
}
.status--checking .status__dot {
background: var(--muted);
animation: pulse 1.2s ease-in-out infinite;
}
.status--connected .status__dot {
background: var(--green);
}
.status--error .status__dot {
background: var(--red);
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
.hint {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--edge);
font-size: 0.8rem;
color: var(--muted);
line-height: 1.5;
}
.hint strong {
color: var(--ink-soft);
}
</style>
</head>
<body>
<div class="card">
<h1 class="heading">Web Signer</h1>
<p class="subtitle">
This page connects the app to your Nostr Web Signer extension so you can sign in and use Coop securely.
</p>
<div id="nip07-status" class="status status--checking">
<div class="status__dot"></div>
<span id="nip07-status-text">Checking extension…</span>
</div>
<div class="hint">
<strong>Keep this tab open</strong> while using the app — it automatically handles sign-in requests in the background.
</div>
</div>
<script src="proxy.js"></script>
</body>
</html>
+156
View File
@@ -0,0 +1,156 @@
let isPolling = false;
async function pollForRequests() {
if (isPolling) return;
isPolling = true;
try {
const response = await fetch('/api/pending');
const data = await response.json();
console.log('Polled for requests, got:', data);
// Process any new requests
if (data.requests && data.requests.length > 0) {
console.log(`Processing ${data.requests.length} requests`);
for (const request of data.requests) {
await handleNip07Request(request);
}
}
} catch (error) {
console.error('Polling error:', error);
updateStatus('Error: ' + error.message, 'error');
}
isPolling = false;
}
async function handleNip07Request(request) {
console.log('Handling request:', request);
try {
let result;
if (!window.nostr) {
throw new Error('NIP-07 extension not available');
}
switch (request.method) {
case 'get_public_key':
console.log('Calling nostr.getPublicKey()');
result = await window.nostr.getPublicKey();
console.log('Got public key:', result);
break;
case 'sign_event':
console.log('Calling nostr.signEvent() with:', request.params);
result = await window.nostr.signEvent(request.params);
console.log('Got signed event:', result);
break;
case 'nip04_encrypt':
console.log('Calling nostr.nip04.encrypt()');
result = await window.nostr.nip04.encrypt(
request.params.public_key,
request.params.content
);
break;
case 'nip04_decrypt':
console.log('Calling nostr.nip04.decrypt()');
result = await window.nostr.nip04.decrypt(
request.params.public_key,
request.params.content
);
break;
case 'nip44_encrypt':
console.log('Calling nostr.nip44.encrypt()');
result = await window.nostr.nip44.encrypt(
request.params.public_key,
request.params.content
);
break;
case 'nip44_decrypt':
console.log('Calling nostr.nip44.decrypt()');
result = await window.nostr.nip44.decrypt(
request.params.public_key,
request.params.content
);
break;
default:
throw new Error(`Unknown method: ${request.method}`);
}
// Send response back to server
const responsePayload = {
id: request.id,
result: result,
error: null
};
console.log('Sending response:', responsePayload);
await fetch('/api/response', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(responsePayload)
});
console.log('Response sent successfully');
updateStatus('Request processed successfully', 'connected');
} catch (error) {
console.error('Error handling request:', error);
// Send error response back to server
const errorPayload = {
id: request.id,
result: null,
error: error.message
};
console.log('Sending error response:', errorPayload);
await fetch('/api/response', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(errorPayload)
});
updateStatus('Error: ' + error.message, 'error');
}
}
function updateStatus(message, state) {
const container = document.getElementById('nip07-status');
const textEl = document.getElementById('nip07-status-text');
if (container && textEl) {
container.className = 'status status--' + state;
textEl.textContent = message;
}
}
// Start polling when page loads
window.addEventListener('load', () => {
console.log('NIP-07 Proxy loaded');
// Check if NIP-07 extension is available
if (window.nostr) {
console.log('NIP-07 extension detected');
updateStatus('Connected — ready', 'connected');
} else {
console.log('NIP-07 extension not found');
updateStatus('No NIP-07 extension found', 'error');
}
// Start polling every 500 ms
setInterval(pollForRequests, 500);
});
+51
View File
@@ -0,0 +1,51 @@
// Copyright (c) 2022-2023 Yuki Kishimoto
// Copyright (c) 2023-2025 Rust Nostr Developers
// Distributed under the MIT software license
//! Error types for the browser signer proxy.
opaquerr::define_kind! {
/// Nostr browser signer proxy error kind.
pub ErrorKind {
/// Nostr protocol error.
Protocol => "nostr protocol error",
/// I/O error.
IO => "I/O error",
/// JSON error.
Json => "JSON error",
/// The operation timed out.
Timeout => "timeout",
/// The operation cannot be completed in the current state.
State => "invalid state",
/// Anything not covered by the stable categories above.
Other => "other error",
}
}
opaquerr::define_error! {
/// Nostr browser signer proxy error.
pub Error(ErrorKind)
from {
nostr::error::Error => ErrorKind::Protocol,
std::io::Error => ErrorKind::IO,
serde_json::Error => ErrorKind::Json,
}
}
impl Error {
pub(crate) fn generic<S>(message: S) -> Self
where
S: Into<String>,
{
Self::new(ErrorKind::Other, message.into())
}
pub(crate) fn timeout() -> Self {
Self::simple(ErrorKind::Timeout)
}
pub(crate) fn shutdown() -> Self {
Self::with_static_message(ErrorKind::State, "server is shutdown")
}
}
+764
View File
@@ -0,0 +1,764 @@
use std::collections::HashMap;
use std::future::Future;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use atomic_destructor::{AtomicDestroyer, AtomicDestructor};
use event_listener::Event as ShutdownEvent;
use nostr::prelude::*;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize, Serializer};
use serde_json::{Value, json};
use smol::channel;
use smol::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use smol::lock::Mutex;
use smol::net::{TcpListener, TcpStream};
use uuid::Uuid;
mod error;
pub mod prelude;
pub use self::error::Error;
const DEFAULT_HTML: &str = include_str!("../index.html");
const JS: &str = include_str!("../proxy.js");
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
#[derive(Debug, Deserialize)]
struct Message {
id: Uuid,
error: Option<String>,
result: Option<Value>,
}
impl Message {
fn into_result(self) -> Result<Value, String> {
if let Some(error) = self.error {
Err(error)
} else {
Ok(self.result.unwrap_or(Value::Null))
}
}
}
#[derive(Debug, Clone, Copy)]
enum RequestMethod {
GetPublicKey,
SignEvent,
Nip04Encrypt,
Nip04Decrypt,
Nip44Encrypt,
Nip44Decrypt,
}
impl RequestMethod {
fn as_str(&self) -> &str {
match self {
Self::GetPublicKey => "get_public_key",
Self::SignEvent => "sign_event",
Self::Nip04Encrypt => "nip04_encrypt",
Self::Nip04Decrypt => "nip04_decrypt",
Self::Nip44Encrypt => "nip44_encrypt",
Self::Nip44Decrypt => "nip44_decrypt",
}
}
}
impl Serialize for RequestMethod {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize)]
struct RequestData {
id: Uuid,
method: RequestMethod,
params: Value,
}
impl RequestData {
#[inline]
fn new(method: RequestMethod, params: Value) -> Self {
Self {
id: Uuid::new_v4(),
method,
params,
}
}
}
#[derive(Serialize)]
struct Requests<'a> {
requests: &'a [RequestData],
}
impl<'a> Requests<'a> {
#[inline]
fn new(requests: &'a [RequestData]) -> Self {
Self { requests }
}
#[inline]
fn len(&self) -> usize {
self.requests.len()
}
}
/// Params for NIP-04 and NIP-44 encryption/decryption
#[derive(Serialize)]
struct CryptoParams<'a> {
public_key: &'a PublicKey,
content: &'a str,
}
impl<'a> CryptoParams<'a> {
#[inline]
fn new(public_key: &'a PublicKey, content: &'a str) -> Self {
Self {
public_key,
content,
}
}
}
#[derive(Debug)]
struct ProxyState {
/// Requests waiting to be picked up by browser
pub outgoing_requests: Mutex<Vec<RequestData>>,
/// Map of request ID to response sender
pub pending_responses: Mutex<PendingResponseMap>,
/// Last time the client asked for the pending requests
pub last_pending_request: Arc<AtomicU64>,
}
/// Configuration options for [`BrowserSignerProxy`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserSignerProxyOptions {
/// Request timeout for the signer extension. Default is 30 seconds.
pub timeout: Duration,
/// Proxy server IP address and port. Default is `127.0.0.1:7400`.
pub addr: SocketAddr,
/// Custom HTML page.
// NOTE: not `Option` to move it between threads without reference counter
pub custom_html: &'static str,
}
#[derive(Debug, Clone)]
struct InnerBrowserSignerProxy {
/// Configuration options for the proxy
options: BrowserSignerProxyOptions,
/// Internal state of the proxy including request queues
state: Arc<ProxyState>,
/// Notification trigger for graceful shutdown
shutdown: Arc<ShutdownEvent>,
/// Flag to indicate if the server is shutdown
is_shutdown: Arc<AtomicBool>,
/// Flag indicating if the server is started
is_started: Arc<AtomicBool>,
}
impl AtomicDestroyer for InnerBrowserSignerProxy {
fn on_destroy(&self) {
self.shutdown();
}
}
impl InnerBrowserSignerProxy {
#[inline]
fn is_shutdown(&self) -> bool {
self.is_shutdown.load(Ordering::SeqCst)
}
fn shutdown(&self) {
// Mark the server as shutdown
self.is_shutdown.store(true, Ordering::SeqCst);
// Notify all waiters that the proxy is shutting down
self.shutdown.notify(usize::MAX);
}
}
/// Nostr Browser Signer Proxy
///
/// Proxy to use Nostr Browser signer (NIP-07) in native applications.
#[derive(Debug, Clone)]
pub struct BrowserSignerProxy {
inner: AtomicDestructor<InnerBrowserSignerProxy>,
}
impl Default for BrowserSignerProxyOptions {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
// 7 for NIP-07 and 400 because the NIP title is 40 bytes :)
addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 7400)),
custom_html: "",
}
}
}
impl BrowserSignerProxyOptions {
/// Sets the timeout duration.
pub const fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Sets the IP address.
pub const fn ip_addr(mut self, new_ip: IpAddr) -> Self {
self.addr = SocketAddr::new(new_ip, self.addr.port());
self
}
/// Sets the port number.
pub const fn port(mut self, new_port: u16) -> Self {
self.addr = SocketAddr::new(self.addr.ip(), new_port);
self
}
/// Sets a custom html page.
///
/// The page must include `/proxy.js` script (`<script src="/proxy.js"></script>`)
/// which will handle communication with the server and update the element
/// with id `nip07-proxy-status` with the status.
pub const fn custom_html_page(mut self, custom_html: &'static str) -> Self {
self.custom_html = custom_html;
self
}
}
impl BrowserSignerProxy {
/// Construct a new browser signer proxy
pub fn new(options: BrowserSignerProxyOptions) -> Self {
let state = ProxyState {
outgoing_requests: Mutex::new(Vec::new()),
pending_responses: Mutex::new(HashMap::new()),
last_pending_request: Arc::new(AtomicU64::new(0)),
};
Self {
inner: AtomicDestructor::new(InnerBrowserSignerProxy {
options,
state: Arc::new(state),
shutdown: Arc::new(ShutdownEvent::new()),
is_shutdown: Arc::new(AtomicBool::new(false)),
is_started: Arc::new(AtomicBool::new(false)),
}),
}
}
/// Indicates whether the server is currently running.
#[inline]
pub fn is_started(&self) -> bool {
self.inner.is_started.load(Ordering::SeqCst)
}
/// Checks if there is an open browser tab ready to respond to requests by
/// verifying the time since the last pending request.
#[inline]
pub fn is_session_active(&self) -> bool {
current_time() - self.inner.state.last_pending_request.load(Ordering::SeqCst) < 2
}
/// Get the signer proxy webpage URL
#[inline]
pub fn url(&self) -> String {
format!("http://{}", self.inner.options.addr)
}
/// Start the proxy server.
///
/// If this is not called explicitly, the server will be automatically
/// started on the first interaction with the signer.
pub async fn start(&self) -> Result<(), Error> {
// Ensure is not shutdown
if self.inner.is_shutdown() {
return Err(Error::shutdown());
}
// Mark the proxy as started and check if was already started
let is_started: bool = self.inner.is_started.swap(true, Ordering::SeqCst);
// Immediately return if already started
if is_started {
return Ok(());
}
let listener: TcpListener = match TcpListener::bind(self.inner.options.addr).await {
Ok(listener) => listener,
Err(e) => {
// Undo the started flag if binding fails
self.inner.is_started.store(false, Ordering::SeqCst);
return Err(Error::from(e));
}
};
let addr: SocketAddr = self.inner.options.addr;
let state: Arc<ProxyState> = self.inner.state.clone();
let custom_html: &'static str = self.inner.options.custom_html;
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
smol::spawn(async move {
tracing::info!("Starting proxy server on {addr}");
loop {
// Race between accepting a new connection and shutdown signal
let shutdown_listener = shutdown.listen();
enum AcceptEvent {
Connection(Result<(TcpStream, SocketAddr), std::io::Error>),
Shutdown,
}
let event = smol::future::or(
async { AcceptEvent::Connection(listener.accept().await) },
async {
shutdown_listener.await;
AcceptEvent::Shutdown
},
)
.await;
match event {
AcceptEvent::Connection(Ok((stream, _))) => {
let state: Arc<ProxyState> = state.clone();
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
smol::spawn(async move {
let shutdown_listener = shutdown.listen();
smol::future::or(
async {
handle_connection(stream, state, custom_html).await;
},
async {
shutdown_listener.await;
tracing::debug!(
"Closing connection, proxy server is shutting down."
);
},
)
.await;
})
.detach();
}
AcceptEvent::Connection(Err(e)) => {
tracing::error!("Failed to accept connection: {e}");
}
AcceptEvent::Shutdown => break,
}
}
tracing::info!("Proxy server shut down.");
})
.detach();
Ok(())
}
#[inline]
async fn store_pending_response(&self, id: Uuid, tx: channel::Sender<Result<Value, String>>) {
let mut pending_responses = self.inner.state.pending_responses.lock().await;
pending_responses.insert(id, tx);
}
#[inline]
async fn store_outgoing_request(&self, request: RequestData) {
let mut outgoing_requests = self.inner.state.outgoing_requests.lock().await;
outgoing_requests.push(request);
}
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
where
T: DeserializeOwned,
{
// Start the proxy if not already started
self.start().await?;
// Construct the request
let request: RequestData = RequestData::new(method, params);
// Create a bounded channel of size 1 as a oneshot replacement
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
// Store the response sender
self.store_pending_response(request.id, tx).await;
// Add to outgoing requests queue
self.store_outgoing_request(request).await;
// Wait for response with timeout
let response = race_timeout(self.inner.options.timeout, rx.recv()).await;
match response {
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
Ok(Err(error)) => Err(Error::generic(error)),
Err(TimeoutError) => Err(Error::timeout()),
}
}
#[inline]
async fn _get_public_key(&self) -> Result<PublicKey, Error> {
self.request(RequestMethod::GetPublicKey, json!({})).await
}
#[inline]
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
let event: Event = self
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
.await?;
event.verify()?;
Ok(event)
}
#[inline]
async fn _nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip04Encrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip04_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip04Decrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip44Encrypt, serde_json::to_value(params)?)
.await
}
#[inline]
async fn _nip44_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
let params = CryptoParams::new(public_key, content);
self.request(RequestMethod::Nip44Decrypt, serde_json::to_value(params)?)
.await
}
}
impl AsyncGetPublicKey for BrowserSignerProxy {
type Error = Error;
#[inline]
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
Box::pin(async move { self._get_public_key().await })
}
}
impl AsyncSignEvent for BrowserSignerProxy {
type Error = Error;
#[inline]
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
Box::pin(async move { self._sign_event(unsigned).await })
}
}
impl AsyncNip04 for BrowserSignerProxy {
type Error = Error;
fn nip04_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip04_encrypt(public_key, content).await })
}
fn nip04_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
encrypted_content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip04_decrypt(public_key, encrypted_content).await })
}
}
impl AsyncNip44 for BrowserSignerProxy {
type Error = Error;
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip44_encrypt(public_key, content).await })
}
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip44_decrypt(public_key, payload).await })
}
}
// ── Minimal HTTP server ──────────────────────────────────────────────────
/// Handle a single HTTP connection.
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
let mut reader = BufReader::new(stream);
// Read the request line
let mut request_line = String::new();
if reader.read_line(&mut request_line).await.is_err() {
return;
}
let request_line = request_line.trim_end().to_string();
// Parse method, path, and HTTP version from request line
let parts: Vec<&str> = request_line.split_whitespace().collect();
if parts.len() < 2 {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
let method = parts[0].to_uppercase();
let path = parts[1].to_string();
// Read headers until empty line
let mut headers = Vec::new();
let mut content_length: usize = 0;
loop {
let mut line = String::new();
if reader.read_line(&mut line).await.is_err() {
return;
}
let line = line.trim_end().to_string();
if line.is_empty() {
break;
}
if let Some(value) = line.strip_prefix("content-length:") {
content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("Content-Length:") {
content_length = value.trim().parse().unwrap_or(0);
}
headers.push(line);
}
match (method.as_str(), path.as_str()) {
// Serve the HTML proxy page
("GET", "/") => {
let html = if custom_html.is_empty() {
DEFAULT_HTML
} else {
custom_html
};
send_response(&mut reader, 200, "OK", "text/html", html).await;
}
// Serve the JS proxy script
("GET", "/proxy.js") => {
send_response(&mut reader, 200, "OK", "application/javascript", JS).await;
}
// Browser polls this endpoint to get pending requests
("GET", "/api/pending") => {
state
.last_pending_request
.store(current_time(), Ordering::SeqCst);
let mut outgoing = state.outgoing_requests.lock().await;
let requests = Requests::new(&outgoing);
let json = match serde_json::to_string(&requests) {
Ok(j) => j,
Err(e) => {
tracing::error!("Failed to serialize pending requests: {e}");
send_response(&mut reader, 500, "Internal Server Error", "", "").await;
return;
}
};
tracing::debug!("Sending {} pending requests to browser", requests.len());
// Clear the outgoing requests after sending them
outgoing.clear();
send_response_cors_json(&mut reader, 200, "OK", &json).await;
}
// Receive response from browser extension
("POST", "/api/response") => {
let mut body_bytes = vec![0u8; content_length];
if content_length > 0 && reader.read_exact(&mut body_bytes).await.is_err() {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
let message: Message = match serde_json::from_slice(&body_bytes) {
Ok(json) => json,
Err(e) => {
tracing::error!("Failed to parse response body: {e}");
send_response(&mut reader, 400, "Invalid JSON", "", "").await;
return;
}
};
tracing::debug!("Received response from browser: {message:?}");
let id: Uuid = message.id;
let mut pending = state.pending_responses.lock().await;
match pending.remove(&id) {
Some(sender) => {
// Use try_send since we already hold the lock
let _ = sender.try_send(message.into_result());
tracing::info!("Forwarded response for request {id}");
}
None => tracing::warn!("No pending request found for {id}"),
}
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
}
// CORS preflight
("OPTIONS", _) => {
let response = "HTTP/1.1 200 OK\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\
\r\n";
let _ = reader.get_mut().write_all(response.as_bytes()).await;
let _ = reader.get_mut().flush().await;
}
// 404 - not found
_ => {
send_response(&mut reader, 404, "Not Found", "", "").await;
}
}
}
/// Write an HTTP response to the stream.
async fn send_response(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
content_type: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
/// Write a response with CORS headers and JSON content type.
async fn send_response_cors_json(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
response.push_str("Content-Type: application/json\r\n");
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
/// Write a response with CORS headers.
async fn send_response_cors(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
content_type: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
// ── Timeout helper ───────────────────────────────────────────────────────
/// An error indicating that an operation timed out.
#[derive(Debug)]
struct TimeoutError;
/// Races a channel receive against a duration.
///
/// Returns the channel value on success, or [`TimeoutError`] if the duration
/// elapses first or the channel is closed.
async fn race_timeout<T>(
duration: Duration,
recv: impl Future<Output = Result<T, channel::RecvError>>,
) -> Result<T, TimeoutError> {
enum Event<T> {
Value(T),
ChannelClosed,
Timeout,
}
let event = smol::future::or(
async {
match recv.await {
Ok(value) => Event::Value(value),
Err(_) => Event::ChannelClosed,
}
},
async {
smol::Timer::after(duration).await;
Event::Timeout
},
)
.await;
match event {
Event::Value(value) => Ok(value),
Event::ChannelClosed | Event::Timeout => Err(TimeoutError),
}
}
// ── Utility ──────────────────────────────────────────────────────────────
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
/// time is before the epoch, returns 0.
#[inline]
fn current_time() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default()
}
@@ -0,0 +1,14 @@
// Copyright (c) 2022-2023 Yuki Kishimoto
// Copyright (c) 2023-2025 Rust Nostr Developers
// Distributed under the MIT software license
//! Prelude
#![allow(unknown_lints)]
#![allow(ambiguous_glob_reexports)]
#![doc(hidden)]
pub use nostr::prelude::*;
pub use crate::error::{Error, ErrorKind};
pub use crate::*;
+2
View File
@@ -13,6 +13,7 @@ settings = { path = "../settings" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
anyhow.workspace = true
itertools.workspace = true
@@ -20,6 +21,7 @@ smallvec.workspace = true
log.workspace = true
flume.workspace = true
futures.workspace = true
fuzzy-matcher = "0.3.7"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+248 -301
View File
@@ -1,13 +1,9 @@
use std::cmp::Reverse;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::Arc;
#[cfg(target_arch = "wasm32")]
use std::sync::RwLock;
use std::collections::{BTreeSet, HashMap, HashSet, hash_map};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use std::sync::{Arc, LazyLock, RwLock};
use anyhow::{Context as AnyhowContext, Error, anyhow};
use anyhow::{Error, anyhow};
use common::EventExt;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
@@ -15,10 +11,9 @@ use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
WeakEntity, Window,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
#[cfg(not(target_arch = "wasm32"))]
use smol::lock::RwLock;
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP, UniversalSigner};
mod message;
@@ -26,6 +21,10 @@ mod room;
pub use message::*;
pub use room::*;
pub use state::FileAttachment;
/// A static keypair used only for signing locally-cached rumor events.
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
pub fn init(window: &mut Window, cx: &mut App) {
ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx);
@@ -82,16 +81,19 @@ pub struct ChatRegistry {
/// Chat rooms
rooms: Vec<Entity<Room>>,
/// O(1) room lookup by room ID
room_index: HashMap<u64, Entity<Room>>,
/// Events that failed to unwrap for any reason
trashes: Entity<BTreeSet<FailedMessage>>,
trash: Entity<BTreeSet<FailedMessage>>,
/// Tracking events seen on which relays in the current session
seens: Arc<RwLock<HashMap<EventId, HashSet<RelayUrl>>>>,
seen: Arc<RwLock<HashMap<EventId, HashSet<RelayUrl>>>>,
/// Mapping of unwrapped event ids to their gift wrap event ids
event_map: Arc<RwLock<HashMap<EventId, EventId>>>,
/// Tracking the status of unwrapping gift wrap events.
/// True while the initial event backlog is still loading
tracking: Arc<AtomicBool>,
/// Channel for sending signals to the UI.
@@ -103,10 +105,37 @@ pub struct ChatRegistry {
/// Async tasks
tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
/// Notification listener task (cancelled on signer change)
notification_listener: Option<Task<Result<(), Error>>>,
/// Signal consumer task (cancelled on signer change)
signal_consumer: Option<Task<Result<(), Error>>>,
/// Fuzzy matcher for room search (cached; intentionally excluded from Debug)
#[allow(dead_code)]
matcher: CachedMatcher,
/// Subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
/// Wrapper to provide Debug for SkimMatcherV2
struct CachedMatcher(SkimMatcherV2);
impl std::fmt::Debug for CachedMatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("CachedMatcher { .. }")
}
}
impl std::ops::Deref for CachedMatcher {
type Target = SkimMatcherV2;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl EventEmitter<ChatEvent> for ChatRegistry {}
impl ChatRegistry {
@@ -140,35 +169,40 @@ impl ChatRegistry {
// Run at the end of the current cycle
cx.defer_in(window, |this, _window, cx| {
this.tracking(cx);
this.get_rooms(cx);
});
Self {
rooms: vec![],
trashes: cx.new(|_| BTreeSet::default()),
seens: Arc::new(RwLock::new(HashMap::default())),
room_index: HashMap::new(),
trash: cx.new(|_| BTreeSet::default()),
seen: Arc::new(RwLock::new(HashMap::default())),
event_map: Arc::new(RwLock::new(HashMap::default())),
tracking: Arc::new(AtomicBool::new(false)),
tracking: Arc::new(AtomicBool::new(true)),
matcher: CachedMatcher(SkimMatcherV2::default()),
signal_rx: rx,
signal_tx: tx,
tasks: smallvec![],
notification_listener: None,
signal_consumer: None,
_subscriptions: subscriptions,
}
}
/// Handle nostr notifications
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
// Cancel previous notification tasks before spawning new ones
self.notification_listener = None;
self.signal_consumer = None;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let tracking = self.tracking.clone();
let seens = self.seens.clone();
let seen = self.seen.clone();
let event_map = self.event_map.clone();
let trashes = self.trashes.downgrade();
let trash = self.trash.downgrade();
let initialized_at = Timestamp::now();
let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP);
let sub_id2 = SubscriptionId::new(USER_GIFTWRAP);
@@ -176,19 +210,22 @@ impl ChatRegistry {
let tx = self.signal_tx.clone();
let rx = self.signal_rx.clone();
self.tasks.push(cx.background_spawn(async move {
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;
while let Some(notification) = notifications.next().await {
let ClientNotification::Message { message, relay_url } = notification else {
// Skip non-message notifications
continue;
};
match *message {
RelayMessage::Event { event, .. } => {
// De-duplicate events by their ID
// Prune the dedup set before it grows unbounded
if processed_events.len() >= MAX_PROCESSED {
processed_events.clear();
}
if !processed_events.insert(event.id) {
continue;
}
@@ -196,7 +233,6 @@ 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?;
// Emit the inbox ready signal
if event.pubkey == current_user {
tx.send_async(Signal::InboxReady).await?;
}
@@ -209,33 +245,30 @@ impl ChatRegistry {
// Keep track of which relays have seen this event
{
let mut seens = seens.write().await;
seens.entry(event.id).or_default().insert(relay_url);
let mut seen = seen.write().unwrap();
seen.entry(event.id).or_default().insert(relay_url);
}
// Extract the rumor from the gift wrap event
match extract_rumor(&client, &signer, event.as_ref()).await {
Ok(rumor) => {
// Map the rumor id to the gift wrap event id for later lookup
let Some(rumor_id) = rumor.id else {
log::error!("Rumor missing id after ensure_id");
continue;
};
{
let mut event_map = event_map.write().await;
event_map.insert(rumor.id.unwrap(), event.id);
let mut event_map = event_map.write().unwrap();
event_map.insert(rumor_id, event.id);
}
// Check if the rumor has a recipient
if rumor.tags.is_empty() {
let signal = Signal::error(&event, "Recipient is missing");
tx.send_async(signal).await?;
}
// Check if the rumor was created after the chat was initialized (for detecting new messages)
if rumor.created_at >= initialized_at {
let signal = Signal::message(event.id, rumor);
tx.send_async(signal).await?;
} else {
// Mark the chat still processing new messages
tracking.store(true, Ordering::Release);
}
// Emit message for both new and backlog events
let signal = Signal::message(event.id, rumor);
tx.send_async(signal).await?;
}
Err(e) => {
let reason = format!("Failed to extract rumor: {e}");
@@ -256,7 +289,7 @@ impl ChatRegistry {
Ok(())
}));
self.tasks.push(cx.spawn(async move |this, cx| {
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
while let Ok(message) = rx.recv_async().await {
match message {
Signal::Message(message) => {
@@ -270,13 +303,17 @@ impl ChatRegistry {
})?;
}
Signal::Eose => {
this.update(cx, |this, _cx| {
this.tracking.store(false, Ordering::Release);
})?;
this.update(cx, |this, cx| {
this.get_rooms(cx);
})?;
}
Signal::Error(trash) => {
trashes.update(cx, |this, cx| {
this.insert(trash);
Signal::Error(failed) => {
trash.update(cx, |this, cx| {
this.insert(failed);
cx.notify();
})?;
}
@@ -287,25 +324,6 @@ impl ChatRegistry {
}));
}
/// Tracking the status of unwrapping gift wrap events.
fn tracking(&mut self, cx: &mut Context<Self>) {
let status = self.tracking.clone();
let tx = self.signal_tx.clone();
self.tasks.push(cx.spawn(async move |_, cx| {
let loop_duration = Duration::from_secs(15);
loop {
if status.load(Ordering::Acquire) {
_ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed);
_ = tx.send_async(Signal::Eose).await;
} else {
_ = tx.send_async(Signal::Eose).await;
}
cx.background_executor().timer(loop_duration).await;
}
}));
}
/// Get all necessary metadata from relays for current user
pub fn get_metadata(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
@@ -315,55 +333,44 @@ impl ChatRegistry {
return;
};
self.tasks.push(cx.background_spawn(async move {
self.tasks.push(cx.spawn(async move |this, cx| {
// Subscribe to metadata from relays
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
// Construct filter for msg relays
let msg_relays = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
// Construct filter for contact list
let contact_list = Filter::new()
.kind(Kind::ContactList)
.author(public_key)
.limit(1);
// Subscribe
client
_ = client
.subscribe(vec![msg_relays, contact_list])
.close_on(opts)
.await?;
.await;
Ok(())
}));
let client = nostr.read(cx).client();
// Spawn a task to verify user inbox relays after 5 seconds
self.tasks.push(cx.spawn(async move |this, cx| {
// Give relays time to respond
cx.background_executor().timer(Duration::from_secs(5)).await;
if !cx
.background_spawn(async move {
// Construct inbox relays filter
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
// Verify inbox relays were received
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
// Check the latest inbox relays event in database
client
.database()
.query(filter)
.await
.unwrap_or_default()
.first_owned()
.is_some()
})
let found = client
.database()
.query(filter)
.await
{
.unwrap_or_default()
.into_iter()
.next()
.is_some();
if !found {
this.update(cx, |_this, cx| {
cx.emit(ChatEvent::InboxRelayNotFound);
})?;
@@ -379,58 +386,53 @@ impl ChatRegistry {
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?;
// Construct inbox relays filter
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
// Get the latest inbox relays event in database
let event = client
.database()
.query(filter)
.await?
.first_owned()
.ok_or(anyhow::anyhow!("No inbox relays found"))?;
// Extract relay list from event
let relays: Vec<RelayUrl> = nip17::extract_relay_list(&event).collect();
// Ensure relay connections
for url in relays.iter() {
client.add_relay(url).and_connect().await?;
}
// Construct gift wrap event filter
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
let id = SubscriptionId::new(format!("{}-msg", public_key.to_hex()));
// Construct target for subscription
let target: HashMap<RelayUrl, Filter> = relays
.into_iter()
.map(|relay| (relay, filter.clone()))
.collect();
client.subscribe(target).with_id(id).await?;
Ok(())
});
self.tasks.push(cx.spawn(async move |this, cx| {
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
let event = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.ok_or(anyhow::anyhow!("No inbox relays found"))?;
let relays: Vec<RelayUrl> = nip17::extract_relay_list(&event).collect();
for url in relays.iter() {
client.add_relay(url).and_connect().await?;
}
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
let id = SubscriptionId::new(USER_GIFTWRAP);
let target: HashMap<RelayUrl, Filter> = relays
.into_iter()
.map(|relay| (relay, filter.clone()))
.collect();
client.subscribe(target).with_id(id).await?;
Ok(())
});
if let Err(e) = task.await {
this.update(cx, |_this, cx| {
cx.emit(ChatEvent::Error(e.to_string()));
})?;
}
Ok(())
}));
}
/// Refresh the chat registry, fetching messages and contact list from relays.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
/// Reload the chat registry, fetching messages and contact list from relays.
pub fn reload(&mut self, cx: &mut Context<Self>) {
self.reset(cx);
self.get_metadata(cx);
self.get_rooms(cx);
@@ -441,12 +443,9 @@ impl ChatRegistry {
self.tracking.load(Ordering::Acquire)
}
/// Get a weak reference to a room by its ID.
pub fn room(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
self.rooms
.iter()
.find(|this| &this.read(cx).id == id)
.map(|this| this.downgrade())
/// Get a weak reference to a room by its ID
pub fn room(&self, id: &u64, _cx: &App) -> Option<WeakEntity<Room>> {
self.room_index.get(id).map(|room| room.downgrade())
}
/// Get all rooms based on the filter.
@@ -468,35 +467,38 @@ impl ChatRegistry {
/// Count the number of messages seen by a given relay.
pub fn count_messages(&self, relay_url: &RelayUrl) -> usize {
self.seens
.read_blocking()
self.seen
.read()
.unwrap()
.values()
.filter(|seen| seen.contains(relay_url))
.filter(|s| s.contains(relay_url))
.count()
}
/// Count the number of trash messages.
pub fn count_trash_messages(&self, cx: &App) -> usize {
self.trashes.read(cx).len()
self.trash.read(cx).len()
}
/// Get the trash messages entity.
pub fn trashes(&self) -> Entity<BTreeSet<FailedMessage>> {
self.trashes.clone()
pub fn trash(&self) -> Entity<BTreeSet<FailedMessage>> {
self.trash.clone()
}
/// Get the relays that have seen a given rumor id.
pub fn rumor_seen_on(&self, id: &EventId) -> Option<HashSet<RelayUrl>> {
self.event_map
.read_blocking()
.read()
.unwrap()
.get(id)
.map(|id| self.seen_on(id))
}
/// Get the relays that have seen a given gift wrap id.
pub fn seen_on(&self, id: &EventId) -> HashSet<RelayUrl> {
self.seens
.read_blocking()
self.seen
.read()
.unwrap()
.get(id)
.cloned()
.unwrap_or_default()
@@ -505,60 +507,67 @@ impl ChatRegistry {
/// Add a new room to the start of list.
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
where
I: Into<Room> + 'static,
I: Into<Room>,
{
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
cx.spawn(async move |this, cx| {
let room: Room = room.into().organize(&public_key);
let room: Room = room.into().organize(&public_key);
let room_id = room.id;
let entity = cx.new(|_| room);
this.update(cx, |this, cx| {
this.rooms.insert(0, cx.new(|_| room));
cx.emit(ChatEvent::Ping);
cx.notify();
})
.ok()
})
.detach();
self.room_index.insert(room_id, entity.clone());
self.rooms.insert(0, entity);
cx.emit(ChatEvent::Ping);
cx.notify();
}
/// Emit an open room event.
///
/// If the room is new, add it to the registry.
pub fn emit_room(&mut self, room: &Entity<Room>, cx: &mut Context<Self>) {
pub fn emit_room(&mut self, room: &Entity<Room>, window: &mut Window, cx: &mut Context<Self>) {
// Get the room's ID.
let id = room.read(cx).id;
// If the room is new, add it to the registry.
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
self.rooms.insert(0, room.to_owned());
// If the room is new, add it to the registry and index.
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
let entity = room.to_owned();
e.insert(entity.clone());
self.rooms.insert(0, entity);
}
// Emit the open room event.
cx.emit(ChatEvent::OpenRoom(id));
// Emit the open room event deferred to avoid re-entrant reads
cx.defer_in(window, move |_this, _window, cx| {
cx.emit(ChatEvent::OpenRoom(id));
});
}
/// Close a room.
pub fn close_room(&mut self, id: u64, cx: &mut Context<Self>) {
if self.rooms.iter().any(|r| r.read(cx).id == id) {
cx.emit(ChatEvent::CloseRoom(id));
pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
if self.room_index.contains_key(&id) {
self.room_index.remove(&id);
self.rooms.retain(|r| r.read(cx).id != id);
cx.defer_in(window, move |_this, _window, cx| {
cx.emit(ChatEvent::CloseRoom(id));
});
}
}
/// Sort rooms by their created at.
/// Sort rooms by their created at. Only notifies if order changed.
pub fn sort(&mut self, cx: &mut Context<Self>) {
let before: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
cx.notify();
let after: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
if before != after {
cx.notify();
}
}
/// Finding rooms based on a query.
pub fn find(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
let matcher = SkimMatcherV2::default();
if let Ok(public_key) = PublicKey::parse(query) {
self.rooms
.iter()
@@ -569,7 +578,7 @@ impl ChatRegistry {
self.rooms
.iter()
.filter(|room| {
matcher
self.matcher
.fuzzy_match(room.read(cx).display_name(cx).as_ref(), query)
.is_some()
})
@@ -581,7 +590,8 @@ impl ChatRegistry {
/// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.rooms.clear();
self.trashes.update(cx, |this, cx| {
self.room_index.clear();
self.trash.update(cx, |this, cx| {
this.clear();
cx.notify();
});
@@ -608,7 +618,9 @@ impl ChatRegistry {
});
} else {
let new_room_id = new_room.id;
self.rooms.push(cx.new(|_| new_room));
let entity = cx.new(|_| new_room);
self.room_index.insert(new_room_id, entity.clone());
self.rooms.push(entity);
let new_index = self.rooms.len();
room_map.insert(new_room_id, new_index);
@@ -618,13 +630,7 @@ impl ChatRegistry {
/// Load all rooms from the database.
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let task = self.get_rooms_from_database(public_key, cx);
let task = self.query_chat_rooms(cx);
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
@@ -635,7 +641,9 @@ impl ChatRegistry {
})?;
}
Err(e) => {
log::error!("Failed to load rooms: {}", e);
this.update(cx, |_, cx| {
cx.emit(ChatEvent::Error(e.to_string()));
})?;
}
};
@@ -643,78 +651,65 @@ impl ChatRegistry {
}));
}
/// Create a task to load rooms from the database
fn get_rooms_from_database(
&self,
public_key: PublicKey,
cx: &App,
) -> Task<Result<HashSet<Room>, Error>> {
/// Query the chat rooms from the database
fn query_chat_rooms(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
cx.background_spawn(async move {
// Get contacts
let contacts = client
let public_key = signer.get_public_key_async().await?;
// Query the latest contact list (previously `NostrDatabaseExt::contacts_public_keys`)
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contacts: HashSet<PublicKey> = client
.database()
.contacts_public_keys(public_key)
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
// Construct authored filter
let authored_filter = Filter::new()
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key);
.custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
// Get all authored events
let authored = client.database().query(authored_filter).await?;
// Construct addressed filter
let addressed_filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key);
// Get all addressed events
let addressed = client.database().query(addressed_filter).await?;
// Merge authored and addressed events
let events = authored.merge(addressed);
// Collect results
let mut rooms: HashSet<Room> = HashSet::new();
let events = client.database().query(filter).await?;
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
// Process each event and group by room hash
for raw in events.into_iter() {
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
&& rumor.tags.public_keys().peekable().peek().is_some()
&& rumor.tags.public_keys().next().is_some()
{
if rumor.pubkey != public_key
&& !rumor.tags.public_keys().any(|k| k == public_key)
{
continue;
}
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
}
}
for (_id, mut messages) in grouped.into_iter() {
messages.sort_by_key(|m| Reverse(m.created_at));
let mut rooms = HashSet::with_capacity(grouped.len());
// Always use the latest message
let Some(latest) = messages.first() else {
continue;
};
for (_id, messages) in grouped.into_iter() {
let latest = messages.iter().max_by_key(|m| m.created_at).unwrap();
let room = Room::from(latest).organize(&public_key);
// Construct the room from the latest message.
//
// Call `.organize` to ensure the current user is at the end of the list.
let mut room = Room::from(latest).organize(&public_key);
// Check if the user has responded to the room
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
// Check if public keys are from the user's contacts
let is_contact = room.members.iter().any(|k| contacts.contains(k));
// Set the room's kind based on status
if user_sent || is_contact {
room = room.kind(RoomKind::Ongoing);
}
let room = if user_sent || is_contact {
room.kind(RoomKind::Ongoing)
} else {
room
};
rooms.insert(room);
}
@@ -725,8 +720,8 @@ impl ChatRegistry {
/// Parse a nostr event into a message and push it to the belonging room
///
/// If the room doesn't exist, it will be created.
/// Updates room ordering based on the most recent messages.
/// - If the room doesn't exist, it will be created.
/// - Updates room ordering based on the most recent messages.
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
@@ -734,7 +729,7 @@ impl ChatRegistry {
return;
};
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
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 {
@@ -775,9 +770,14 @@ async fn extract_rumor(
}
// Try to unwrap with the available signer
let unwrapped = try_unwrap(signer, gift_wrap).await?;
let unwrapped = try_unwrap_with(signer, gift_wrap).await?;
let mut rumor = unwrapped.rumor;
// Verify rumor author matches the seal sender (as per mobile implementation)
if rumor.pubkey != unwrapped.sender {
return Err(anyhow!("Rumor author does not match seal sender"));
}
// Generate event id for the rumor if it doesn't have one
rumor.ensure_id();
@@ -789,25 +789,6 @@ async fn extract_rumor(
Ok(rumor)
}
/// Helper method to try unwrapping with different signers
async fn try_unwrap(signer: &UniversalSigner, gift_wrap: &Event) -> Result<UnwrappedGift, Error> {
/*
* // Try with the device signer first
if let Some(signer) = signer.get_encryption_signer().await {
log::info!("trying with encryption key");
if let Ok(unwrapped) = try_unwrap_with(gift_wrap, &signer).await {
return Ok(unwrapped);
}
}
// Fallback to the user's signer
let user_signer = signer.get().await;
*/
let unwrapped = try_unwrap_with(signer, gift_wrap).await?;
Ok(unwrapped)
}
/// Attempts to unwrap a gift wrap event with a given signer.
async fn try_unwrap_with(
signer: &UniversalSigner,
@@ -820,7 +801,7 @@ async fn try_unwrap_with(
// Verify the sealed event
let seal: Event = Event::from_json(seal)?;
seal.verify_with_ctx(&SECP256K1)?;
seal.verify()?;
// Get the rumor event
let rumor = signer
@@ -837,39 +818,20 @@ async fn try_unwrap_with(
/// Stores an unwrapped event in local database with reference to original
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let rumor_id = rumor.id.context("Rumor is missing an event id")?;
let author = rumor.pubkey;
let conversation = conversation_id(rumor);
let room_id = rumor.uniq_id().to_string();
let mut tags = rumor.tags.clone().to_vec();
let tags = vec![
Tag::identifier(id),
Tag::public_key(rumor.pubkey),
Tag::custom("r", [room_id]),
Tag::custom("k", [rumor.kind.to_string()]),
];
// Add a unique identifier
tags.push(Tag::identifier(id));
// Add a reference to the rumor's author
tags.push(Tag::custom("a", [author]));
// Add a conversation id
tags.push(Tag::custom("c", [conversation.to_string()]));
// Add a reference to the rumor's id
tags.push(Tag::event(rumor_id));
// Add references to the rumor's participants
for receiver in rumor.tags.public_keys() {
tags.push(Tag::custom("P", [receiver]));
}
// Convert rumor to json
let content = rumor.as_json();
// Construct the event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
.tags(tags)
.finalize_async(&Keys::generate())
.finalize_async(&*LOCAL_KEYS)
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
@@ -877,26 +839,11 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
/// Retrieves a previously unwrapped event from local database
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(gift_wrap)
.limit(1);
let filter = Filter::new().identifier(gift_wrap).limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
} else {
Err(anyhow!("Event is not cached yet."))
}
}
/// Get the conversation ID for a given rumor (message).
fn conversation_id(rumor: &UnsignedEvent) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = rumor.tags.public_keys().collect();
pubkeys.push(rumor.pubkey);
pubkeys.sort();
pubkeys.dedup();
pubkeys.hash(&mut hasher);
hasher.finish()
}
+82 -39
View File
@@ -4,6 +4,9 @@ use std::ops::Range;
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
use gpui::{SharedString, SharedUri};
use nostr_sdk::prelude::*;
use state::FileAttachment;
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
/// Rendered message.
#[derive(Debug, Clone)]
@@ -21,61 +24,90 @@ pub struct Message {
pub mentions: Vec<Mention>,
/// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>,
/// Encrypted file attachment
pub file: Option<FileAttachment>,
}
impl From<&Event> for Message {
fn from(val: &Event) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self {
id: val.id,
author: val.pubkey,
content: string,
media,
created_at: val.created_at,
mentions,
replies_to,
}
from_parts(
val.id,
val.pubkey,
val.created_at,
val.kind,
&val.content,
&val.tags,
)
}
}
impl From<&UnsignedEvent> for Message {
fn from(val: &UnsignedEvent) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self {
from_parts(
// Event ID must be known
id: val.id.unwrap(),
author: val.pubkey,
content: string,
media,
created_at: val.created_at,
mentions,
replies_to,
}
val.id.unwrap(),
val.pubkey,
val.created_at,
val.kind,
&val.content,
&val.tags,
)
}
}
impl From<&NewMessage> for Message {
fn from(val: &NewMessage) -> Self {
let mentions = extract_mentions(&val.rumor.content);
let replies_to = extract_reply_ids(&val.rumor.tags);
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
Self {
from_parts(
// Event ID must be known
id: val.rumor.id.unwrap(),
author: val.rumor.pubkey,
content: string,
media,
created_at: val.rumor.created_at,
mentions,
replies_to,
}
val.rumor.id.unwrap(),
val.rumor.pubkey,
val.rumor.created_at,
val.rumor.kind,
&val.rumor.content,
&val.rumor.tags,
)
}
}
fn from_parts(
id: EventId,
author: PublicKey,
created_at: Timestamp,
kind: Kind,
content: &str,
tags: &Tags,
) -> Message {
let file = if kind == KIND_FILE_MESSAGE {
FileAttachment::from_tags(content, tags)
} else {
None
};
let has_file = file.is_some();
let replies_to = extract_reply_ids(tags);
// For file messages `.content` is the encrypted blob URL, not text or media
let mentions = if has_file {
Vec::new()
} else {
extract_mentions(content)
};
let (media, content) = if has_file {
(Vec::new(), String::new())
} else {
extract_and_remove_media_urls(content)
};
Message {
id,
author,
content,
media,
created_at,
mentions,
replies_to,
file,
}
}
@@ -105,6 +137,17 @@ impl Hash for Message {
}
}
impl Message {
/// Single-line representation for reply previews, notifications and copy.
pub fn preview(&self) -> SharedString {
if let Some(file) = &self.file {
return format!("[File] {}", file.display_name()).into();
}
self.content.clone().into()
}
}
/// New message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NewMessage {
+132 -93
View File
@@ -1,18 +1,18 @@
use std::cmp::Ordering;
use std::hash::{Hash, Hasher};
use std::time::Duration;
use anyhow::{Error, anyhow};
use common::EventExt;
use device::DeviceRegistry;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use instant::Duration;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
use crate::NewMessage;
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 or it's not available.";
@@ -271,8 +271,8 @@ impl Room {
}
/// Returns the members of the room
pub fn members(&self) -> Vec<PublicKey> {
self.members.clone()
pub fn members(&self) -> &[PublicKey] {
&self.members
}
/// Checks if the room has more than two members (group)
@@ -356,29 +356,38 @@ impl Room {
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let members = self.members();
let members = self.members().to_vec();
cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(TIMEOUT)));
for public_key in members.into_iter() {
let inbox = Filter::new()
.author(public_key)
.kind(Kind::InboxRelays)
.limit(1);
let tasks: Vec<_> = members
.into_iter()
.map(|public_key| {
let client = client.clone();
async move {
let inbox = Filter::new()
.author(public_key)
.kind(Kind::InboxRelays)
.limit(1);
let announcement = Filter::new()
.author(public_key)
.kind(Kind::Custom(10044))
.limit(1);
let announcement = Filter::new()
.author(public_key)
.kind(Kind::Custom(10044))
.limit(1);
// Subscribe to the target
client
.subscribe(vec![inbox, announcement])
.close_on(opts)
.await?;
client
.subscribe(vec![inbox, announcement])
.close_on(opts)
.await
}
})
.collect();
for result in futures::future::join_all(tasks).await {
result?;
}
Ok(())
@@ -389,12 +398,12 @@ impl Room {
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let conversation_id = self.id.to_string();
let room_id = self.id.to_string();
cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id);
.custom_tag(SingleLetterTag::LOWERCASE_R, room_id);
let messages = client
.database()
@@ -402,10 +411,6 @@ impl Room {
.await?
.into_iter()
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
.filter(|event| {
// Only process private direct messages and file messages
event.kind == Kind::PrivateDirectMessage || event.kind == Kind::Custom(15)
})
.sorted_by_key(|message| message.created_at)
.collect();
@@ -414,28 +419,71 @@ impl Room {
}
// Construct a rumor event for direct message
pub fn rumor<S, I>(&self, content: S, replies: I, cx: &App) -> Option<UnsignedEvent>
pub fn rumor<S, I>(
&self,
content: S,
replies: I,
reaction: bool,
cx: &App,
) -> Option<UnsignedEvent>
where
S: Into<String>,
I: IntoIterator<Item = EventId>,
{
let kind = Kind::PrivateDirectMessage;
let kind = if reaction {
Kind::Reaction
} else {
Kind::PrivateDirectMessage
};
let content: String = content.into();
let replies: Vec<EventId> = replies.into_iter().collect();
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
// Get current user's public key
let nostr = NostrRegistry::global(cx);
let sender = nostr.read(cx).current_user()?;
// Get all members, excluding the sender
let members: Vec<Person> = self
.members
.iter()
.filter(|public_key| public_key != &&sender)
.map(|member| persons.read(cx).get(member, cx))
.collect();
// Construct a direct message rumor event
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(kind, content)
.tags(self.conversation_tags(&replies, sender, cx))
.finalize_unsigned(sender);
// Ensure that the ID is set
event.ensure_id();
Some(event)
}
// Construct a rumor event for an encrypted file message (NIP-17 kind 15)
pub fn file_rumor<I>(&self, file: FileAttachment, replies: I, cx: &App) -> Option<UnsignedEvent>
where
I: IntoIterator<Item = EventId>,
{
let replies: Vec<EventId> = replies.into_iter().collect();
// Get current user's public key
let nostr = NostrRegistry::global(cx);
let sender = nostr.read(cx).current_user()?;
let mut tags = self.conversation_tags(&replies, sender, cx);
tags.extend(file.tags());
// Construct a file message rumor event
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(KIND_FILE_MESSAGE, file.url.to_string())
.tags(tags)
.finalize_unsigned(sender);
// Ensure that the ID is set
event.ensure_id();
Some(event)
}
// Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`)
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag> {
let persons = PersonRegistry::global(cx);
// Construct event's tags
let mut tags = vec![];
@@ -446,31 +494,44 @@ impl Room {
}
// Add all reply tags
for id in replies.into_iter() {
tags.push(Tag::event(id))
for id in replies {
tags.push(Tag::event(*id))
}
// Add all receiver tags
for member in members.into_iter() {
tags.push(
Nip01Tag::PublicKey {
public_key: member.public_key(),
relay_hint: member.messaging_relay_hint(),
// Add all receiver tags (no intermediate allocation)
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
let member = persons.read(cx).get(public_key, cx);
tags.push(Tag::from(Nip01Tag::PublicKey {
public_key: member.public_key(),
relay_hint: member.messaging_relay_hint(),
}));
}
tags
}
/// Select the appropriate signer based on signer kind and available keys.
fn select_signer(
signer_kind: &SignerKind,
has_announcement: bool,
encryption_signer: &Option<UniversalSigner>,
user_signer: &UniversalSigner,
) -> UniversalSigner {
match signer_kind {
SignerKind::Auto => {
if has_announcement {
encryption_signer
.clone()
.unwrap_or_else(|| user_signer.clone())
} else {
user_signer.clone()
}
.to_tag(),
);
}
SignerKind::Encryption => encryption_signer
.clone()
.expect("encryption signer must be set"),
SignerKind::User => user_signer.clone(),
}
// Construct a direct message rumor event
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(kind, content)
.tags(tags)
.finalize_unsigned(sender);
// Ensure that the ID is set
event.ensure_id();
Some(event)
}
/// Send rumor event to all members's messaging relays
@@ -525,23 +586,12 @@ impl Room {
}
// Determine the signer to use
let signer = match signer_kind {
SignerKind::Auto => {
if announcement.is_some()
&& let Some(encryption_signer) = encryption_signer.clone()
{
// Safe to unwrap due to earlier checks
encryption_signer
} else {
user_signer.clone()
}
}
SignerKind::Encryption => {
// Safe to unwrap due to earlier checks
encryption_signer.as_ref().unwrap().clone()
}
SignerKind::User => user_signer.clone(),
};
let signer = Self::select_signer(
signer_kind,
announcement.is_some(),
&encryption_signer,
&user_signer,
);
// Send the gift wrap event and collect the report
match send_gift_wrap(&client, &signer, &member, &rumor, signer_kind).await {
@@ -561,23 +611,12 @@ impl Room {
let public_key = sender.public_key();
// Determine the signer to use
let signer = match signer_kind {
SignerKind::Auto => {
if sender.announcement().is_some()
&& let Some(encryption_signer) = encryption_signer.clone()
{
// Safe to unwrap due to earlier checks
encryption_signer
} else {
user_signer.clone()
}
}
SignerKind::Encryption => {
// Safe to unwrap due to earlier checks
encryption_signer.as_ref().unwrap().clone()
}
SignerKind::User => user_signer.clone(),
};
let signer = Self::select_signer(
signer_kind,
sender.announcement().is_some(),
&encryption_signer,
&user_signer,
);
match send_gift_wrap(&client, &signer, &sender, &rumor, signer_kind).await {
Ok(report) => reports.push(report),
@@ -601,7 +640,7 @@ async fn send_gift_wrap(
rumor: &UnsignedEvent,
config: &SignerKind,
) -> Result<SendReport, Error> {
let k_tag = Tag::custom("k", vec!["14"]);
let k_tag = Tag::custom("k", [rumor.kind.to_string()]);
let mut extra_tags = vec![k_tag];
// Determine the receiver public key based on the config
+2 -2
View File
@@ -19,11 +19,11 @@ anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
flume.workspace = true
futures.workspace = true
log.workspace = true
serde.workspace = true
linkify = "0.10.0"
pulldown-cmark = "0.13.1"
regex = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
+32
View File
@@ -0,0 +1,32 @@
use std::path::{Path, PathBuf};
use chat::FileAttachment;
use gpui::SharedString;
use nostr_sdk::prelude::*;
/// A file attachment that has been uploaded, but not sent yet.
///
/// The local `path` 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,
}
/// State of the encrypted file attachment of a message
pub(crate) enum DecryptedFile {
Loading,
Ready(PathBuf),
Failed(SharedString),
}
/// Result of an upload, either plain or encrypted
pub(crate) enum Uploaded {
Url(Url),
File(FileAttachment, PathBuf),
}
/// 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())
}
+720 -177
View File
File diff suppressed because it is too large Load Diff
+153 -64
View File
@@ -1,17 +1,19 @@
use std::ops::Range;
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use chat::Mention;
use common::RangeExt;
use gpui::{
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
};
use person::PersonRegistry;
use regex::Regex;
use theme::ActiveTheme;
/// Matches `http://` and `https://` URLs. Only these are treated as clickable links.
static WEB_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^https?://").unwrap());
#[allow(clippy::enum_variant_names)]
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight {
Code,
@@ -39,25 +41,61 @@ impl RenderedText {
content: &str,
mentions: &[Mention],
persons: &Entity<PersonRegistry>,
markdown: bool,
cx: &App,
) -> Self {
Self::render(content, mentions, markdown, |mention| {
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name())
})
}
fn render(
content: &str,
mentions: &[Mention],
markdown: bool,
resolve_mention: impl Fn(&Mention) -> String,
) -> Self {
let mut text = String::new();
let mut highlights = Vec::new();
let mut link_ranges = Vec::new();
let mut link_urls = Vec::new();
render_plain_text_mut(
render_text_mut(
content,
mentions,
&mut text,
&mut highlights,
&mut link_ranges,
&mut link_urls,
persons,
cx,
markdown,
resolve_mention,
);
text.truncate(text.trim_end().len());
// Trim trailing whitespace and adjust highlight and link ranges.
let trimmed_len = text.trim_end().len();
// Retain highlights and link ranges that are within the trimmed text.
if trimmed_len < text.len() {
highlights.retain_mut(|(range, _)| {
range.end = range.end.min(trimmed_len);
range.start < range.end
});
let mut ix = 0;
while ix < link_ranges.len() {
let range = &mut link_ranges[ix];
range.end = range.end.min(trimmed_len);
if range.start < range.end {
ix += 1;
} else {
link_ranges.remove(ix);
link_urls.remove(ix);
}
}
text.truncate(trimmed_len);
}
RenderedText {
text: SharedString::from(text),
@@ -70,55 +108,71 @@ impl RenderedText {
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
let code_background = cx.theme().elevated_surface_background;
let color = cx.theme().text_accent;
let code_font = if cfg!(target_os = "macos") {
"Menlo"
} else if cfg!(target_os = "windows") {
"Consolas"
} else {
"monospace"
};
InteractiveText::new(
id,
StyledText::new(self.text.clone()).with_default_highlights(
&window.text_style(),
self.highlights.iter().map(|(range, highlight)| {
(
range.clone(),
match highlight {
Highlight::Code => HighlightStyle {
background_color: Some(code_background),
..Default::default()
},
Highlight::InlineCode(link) => {
if *link {
HighlightStyle {
background_color: Some(code_background),
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
StyledText::new(self.text.clone())
.with_default_highlights(
&window.text_style(),
self.highlights.iter().map(|(range, highlight)| {
(
range.clone(),
match highlight {
Highlight::Code => HighlightStyle {
background_color: Some(code_background),
..Default::default()
},
Highlight::InlineCode(link) => {
if *link {
HighlightStyle {
background_color: Some(code_background),
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
}),
..Default::default()
}
} else {
HighlightStyle {
background_color: Some(code_background),
..Default::default()
}
} else {
HighlightStyle {
background_color: Some(code_background),
..Default::default()
}
}
}
}
Highlight::Mention => HighlightStyle {
color: Some(color),
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
Highlight::Mention => HighlightStyle {
color: Some(color),
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
}),
..Default::default()
},
Highlight::Highlight(highlight) => *highlight,
},
Highlight::Highlight(highlight) => *highlight,
},
)
}),
),
)
}),
)
.with_font_family_overrides(self.highlights.iter().filter_map(
|(range, highlight)| match highlight {
Highlight::Code | Highlight::InlineCode(_) => {
Some((range.clone(), code_font.into()))
}
_ => None,
},
)),
)
.on_click(self.link_ranges.clone(), {
let link_urls = self.link_urls.clone();
move |ix, _, cx| {
let url = &link_urls[ix];
if url.starts_with("http") {
if WEB_URL.is_match(url) {
cx.open_url(url);
}
}
@@ -128,15 +182,15 @@ impl RenderedText {
}
#[allow(clippy::too_many_arguments)]
fn render_plain_text_mut(
fn render_text_mut(
block: &str,
mut mentions: &[Mention],
text: &mut String,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
persons: &Entity<PersonRegistry>,
cx: &App,
markdown: bool,
resolve_mention: impl Fn(&Mention) -> String,
) {
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
@@ -145,34 +199,58 @@ fn render_plain_text_mut(
let mut strikethrough_depth = 0;
let mut link_url = None;
let mut list_stack = Vec::new();
let mut code_block = false;
let mut options = Options::all();
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
// Only enable the extensions that make sense for chat messages. Notably this leaves
// out smart punctuation, tables, math and footnotes: they rewrite or swallow text.
let events: Box<dyn Iterator<Item = (Event<'_>, Range<usize>)> + '_> = if markdown {
Box::new(Parser::new_ext(block, Options::ENABLE_STRIKETHROUGH).into_offset_iter())
} else {
Box::new(std::iter::once((Event::Text(block.into()), 0..block.len())))
};
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
for (event, source_range) in events {
let prev_len = text.len();
match event {
Event::Text(t) => {
// Process text with mention replacements
if code_block {
text.push_str(t.as_ref());
highlights.push((prev_len..text.len(), Highlight::Code));
continue;
}
let t_str = t.as_ref();
let mut last_processed = 0;
while let Some(mention) = mentions.first() {
if !source_range.contains_inclusive(&mention.range) {
if mention.range.start >= source_range.end {
break;
}
// Calculate positions within the current text
let mention_start_in_text = mention.range.start - source_range.start;
let mention_end_in_text = mention.range.end - source_range.start;
mentions = &mentions[1..];
if mention.range.start < source_range.start
|| mention.range.end > source_range.end
{
continue;
}
let Some(token) = block.get(mention.range.clone()) else {
continue;
};
let Some(offset) = t_str[last_processed..].find(token) else {
continue;
};
let mention_start_in_text = last_processed + offset;
let mention_end_in_text = mention_start_in_text + token.len();
// Add text before this mention
if mention_start_in_text > last_processed {
let before_mention = &t_str[last_processed..mention_start_in_text];
process_text_segment(
before_mention,
prev_len + last_processed,
bold_depth,
italic_depth,
strikethrough_depth,
@@ -185,9 +263,7 @@ fn render_plain_text_mut(
}
// Process the mention replacement
let profile = persons.read(cx).get(&mention.public_key, cx);
let replacement_text = format!("@{}", profile.name());
let replacement_text = resolve_mention(mention);
let replacement_start = text.len();
text.push_str(&replacement_text);
let replacement_end = text.len();
@@ -195,7 +271,6 @@ fn render_plain_text_mut(
highlights.push((replacement_start..replacement_end, Highlight::Mention));
last_processed = mention_end_in_text;
mentions = &mentions[1..];
}
// Add any remaining text after the last mention
@@ -203,7 +278,6 @@ fn render_plain_text_mut(
let remaining_text = &t_str[last_processed..];
process_text_segment(
remaining_text,
prev_len + last_processed,
bold_depth,
italic_depth,
strikethrough_depth,
@@ -234,11 +308,14 @@ fn render_plain_text_mut(
}
Tag::CodeBlock(_kind) => {
new_paragraph(text, &mut list_stack);
code_block = true;
}
Tag::Emphasis => italic_depth += 1,
Tag::Strong => bold_depth += 1,
Tag::Strikethrough => strikethrough_depth += 1,
Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
Tag::Link { dest_url, .. } => {
link_url = WEB_URL.is_match(&dest_url).then(|| dest_url.to_string());
}
Tag::List(number) => {
list_stack.push((number, false));
}
@@ -264,6 +341,7 @@ fn render_plain_text_mut(
_ => {}
},
Event::End(tag) => match tag {
TagEnd::CodeBlock => code_block = false,
TagEnd::Heading(_) => bold_depth -= 1,
TagEnd::Emphasis => italic_depth -= 1,
TagEnd::Strong => bold_depth -= 1,
@@ -272,6 +350,11 @@ fn render_plain_text_mut(
TagEnd::List(_) => drop(list_stack.pop()),
_ => {}
},
Event::Html(t) | Event::InlineHtml(t) => text.push_str(t.as_ref()),
Event::Rule => {
new_paragraph(text, &mut list_stack);
text.push_str("────────\n");
}
Event::HardBreak => text.push('\n'),
Event::SoftBreak => text.push('\n'),
_ => {}
@@ -282,7 +365,6 @@ fn render_plain_text_mut(
#[allow(clippy::too_many_arguments)]
fn process_text_segment(
segment: &str,
segment_start: usize,
bold_depth: i32,
italic_depth: i32,
strikethrough_depth: i32,
@@ -307,7 +389,8 @@ fn process_text_segment(
});
}
// Add the text
// Ranges always refer to the rendered text, including replaced mentions.
let segment_start = text.len();
text.push_str(segment);
let text_end = text.len();
@@ -330,7 +413,10 @@ fn process_text_segment(
finder.kinds(&[linkify::LinkKind::Url]);
let mut last_link_pos = 0;
for link in finder.links(segment) {
for link in finder
.links(segment)
.filter(|link| WEB_URL.is_match(link.as_str()))
{
let start = link.start();
let end = link.end();
@@ -375,6 +461,7 @@ fn process_text_segment(
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
let mut is_subsequent_paragraph_of_list = false;
if let Some((_, has_content)) = list_stack.last_mut() {
if *has_content {
is_subsequent_paragraph_of_list = true;
@@ -390,9 +477,11 @@ fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
}
text.push('\n');
}
for _ in 0..list_stack.len().saturating_sub(1) {
text.push_str(" ");
}
if is_subsequent_paragraph_of_list {
text.push_str(" ");
}
+1
View File
@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies]
gpui.workspace = true
nostr.workspace = true
instant.workspace = true
nostr-sdk.workspace = true
itertools.workspace = true
-135
View File
@@ -1,135 +0,0 @@
use std::collections::{HashMap, VecDeque};
use std::mem::take;
use futures::FutureExt;
use gpui::{
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
};
pub fn coop_cache(id: impl Into<ElementId>, max_items: usize) -> CoopImageCacheProvider {
CoopImageCacheProvider {
id: id.into(),
max_items,
}
}
pub struct CoopImageCacheProvider {
id: ElementId,
max_items: usize,
}
impl ImageCacheProvider for CoopImageCacheProvider {
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
window
.with_global_id(self.id.clone(), |id, window| {
window.with_element_state(id, |cache, _| {
let cache = cache.unwrap_or_else(|| CoopImageCache::new(self.max_items, cx));
(cache.clone(), cache)
})
})
.into()
}
}
pub struct CoopImageCache {
max_items: usize,
usage_list: VecDeque<u64>,
cache: HashMap<u64, (ImageCacheItem, Resource)>,
}
impl CoopImageCache {
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
cx.new(|cx| {
log::info!("Creating CoopImageCache");
cx.on_release(|this: &mut Self, cx| {
for (ix, (mut image, resource)) in take(&mut this.cache) {
if let Some(Ok(image)) = image.get() {
log::info!("Dropping image {ix}");
cx.drop_image(image, None);
}
ImageSource::Resource(resource).remove_asset(cx);
}
})
.detach();
CoopImageCache {
max_items,
usage_list: VecDeque::with_capacity(max_items),
cache: HashMap::with_capacity(max_items),
}
})
}
}
impl ImageCache for CoopImageCache {
fn load(
&mut self,
resource: &Resource,
window: &mut gpui::Window,
cx: &mut gpui::App,
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
let hash = hash(resource);
if let Some(item) = self.cache.get_mut(&hash) {
let current_idx = self
.usage_list
.iter()
.position(|item| *item == hash)
.expect("cache has an item usage_list doesn't");
self.usage_list.remove(current_idx);
self.usage_list.push_front(hash);
return item.0.get();
}
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
let task = cx.background_executor().spawn(load_future).shared();
if self.usage_list.len() >= self.max_items {
log::info!("Image cache is full, evicting oldest item");
if let Some(oldest) = self.usage_list.pop_back() {
let mut image = self
.cache
.remove(&oldest)
.expect("usage_list has an item cache doesn't");
if let Some(Ok(image)) = image.0.get() {
log::info!("requesting image to be dropped");
cx.drop_image(image, Some(window));
}
ImageSource::Resource(image.1).remove_asset(cx);
}
}
self.cache.insert(
hash,
(
gpui::ImageCacheItem::Loading(task.clone()),
resource.clone(),
),
);
self.usage_list.push_front(hash);
let entity = window.current_view();
window
.spawn(cx, async move |cx| {
let result = task.await;
if let Err(err) = result {
log::error!("error loading image into cache: {:?}", err);
}
cx.on_next_frame(move |_, cx| {
cx.notify(entity);
});
})
.detach();
None
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
use std::marker::PhantomData;
use std::time::Duration;
use instant::Duration;
use futures::channel::oneshot;
use futures::FutureExt;
-2
View File
@@ -1,4 +1,3 @@
pub use caching::*;
pub use debounced_delay::*;
pub use display::*;
pub use event::*;
@@ -7,7 +6,6 @@ pub use parser::*;
pub use paths::*;
pub use range::*;
mod caching;
mod debounced_delay;
mod display;
mod event;
+1
View File
@@ -14,6 +14,7 @@ settings = { path = "../settings" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
anyhow.workspace = true
smallvec.workspace = true
log.workspace = true
+44 -48
View File
@@ -4,13 +4,13 @@ use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
SharedString, Styled, Subscription, Task, Window, div, relative,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
@@ -92,7 +92,6 @@ impl DeviceRegistry {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let settings = AppSettings::global(cx);
let nip4e_enabled = settings.read(cx).is_nip4e_enabled(cx);
let signer = cx.new(|_| None);
let mut subscriptions = smallvec![];
@@ -109,7 +108,7 @@ impl DeviceRegistry {
subscriptions.push(
// Observe the user signer
cx.subscribe(&nostr, move |this, _nostr, event, cx| {
if event.signer_changed() && nip4e_enabled {
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
this.get_announcement(cx);
}
}),
@@ -226,6 +225,11 @@ impl DeviceRegistry {
let keys = get_keys(&client, &signer).await?;
let content = keys.secret_key().to_bech32()?;
if cfg!(target_arch = "wasm32") {
return Err(anyhow!("Not supported"));
}
#[cfg(not(target_arch = "wasm32"))]
smol::fs::write(path, &content).await?;
Ok(())
@@ -258,28 +262,20 @@ impl DeviceRegistry {
}));
let announcement_existed = self.announcement_existed.clone();
let executor = cx.background_executor().clone();
self.tasks.push(cx.spawn(async move |this, cx| {
if !cx
.background_spawn(async move {
// Wait for 5 seconds
executor.timer(Duration::from_secs(5)).await;
// Wait for 5 seconds
cx.background_executor().timer(Duration::from_secs(5)).await;
// Then check if the msg relays have been found
if !announcement_existed.load(Ordering::Acquire) {
return true;
}
false
})
.await
{
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::NotSet);
})?;
// Then check if the msg relays have been found
if announcement_existed.load(Ordering::Acquire) {
return Ok(());
}
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::NotSet);
})?;
Ok(())
}));
}
@@ -404,11 +400,10 @@ impl DeviceRegistry {
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let Ok(app_keys) = get_or_init_app_keys(cx) else {
return;
};
let app_keys_task = get_or_init_app_keys(cx);
let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
let app_keys = app_keys_task.await?;
let app_pubkey = app_keys.public_key();
let public_key = signer.get_public_key_async().await?;
@@ -419,7 +414,7 @@ impl DeviceRegistry {
.pubkey(app_pubkey)
.limit(1);
match client.database().query(filter).await?.first_owned() {
match client.database().query(filter).await?.into_iter().next() {
// Found an approval event
Some(event) => Ok(Some(event)),
// No approval event found, construct a request event
@@ -489,11 +484,10 @@ impl DeviceRegistry {
/// Parse the approval event to get encryption key then set it
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
let Ok(app_keys) = get_or_init_app_keys(cx) else {
return;
};
let app_keys_task = get_or_init_app_keys(cx);
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
let app_keys = app_keys_task.await?;
let master = event
.tags
.iter()
@@ -573,7 +567,7 @@ impl DeviceRegistry {
Ok(())
});
cx.spawn_in(window, async move |_this, cx| {
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
match task.await {
Ok(_) => {
cx.update(|window, cx| {
@@ -591,8 +585,9 @@ impl DeviceRegistry {
.ok();
}
};
})
.detach();
Ok(())
}));
}
/// Handle encryption request
@@ -715,33 +710,34 @@ impl DeviceRegistry {
struct DeviceNotification;
/// Get or create new app keys
fn get_or_init_app_keys(cx: &App) -> Result<Keys, Error> {
/// Get or create new app keys (async, returns a task)
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
let read = cx.read_credentials(CLIENT_NAME);
let stored_keys: Option<Keys> = cx.foreground_executor().block_on(async move {
if let Ok(Some((_, secret))) = read.await {
SecretKey::from_slice(&secret).map(Keys::new).ok()
} else {
None
}
});
if let Some(keys) = stored_keys {
Ok(keys)
} else {
cx.spawn(async move |cx| {
if let Ok(Some((_, secret))) = read.await
&& let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
{
return Ok(keys);
}
// No stored keys found or invalid — generate new ones
let keys = Keys::generate();
let user = keys.public_key().to_hex();
let secret = keys.secret_key().to_secret_bytes();
let write = cx.write_credentials(CLIENT_NAME, &user, &secret);
cx.foreground_executor().block_on(async move {
if let Err(e) = write.await {
log::error!("Keyring not available or panic: {e}")
}
cx.update(|cx| {
let write = cx.write_credentials(CLIENT_NAME, &user, &secret);
cx.background_spawn(async move {
if let Err(e) = write.await {
log::error!("Keyring not available or panic: {e}")
}
})
.detach();
});
Ok(keys)
}
})
}
/// Encrypt and store device keys in the local database.
+1 -1
View File
@@ -10,8 +10,8 @@ state = { path = "../state" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
anyhow.workspace = true
smallvec.workspace = true
flume.workspace = true
log.workspace = true
urlencoding = "2.1.3"
+59 -71
View File
@@ -1,11 +1,10 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::time::Duration;
use std::sync::RwLock;
use anyhow::{Error, anyhow};
use common::EventExt;
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
@@ -24,9 +23,9 @@ impl Global for GlobalPersonRegistry {}
#[derive(Debug, Clone)]
enum Dispatch {
Person(Box<Person>),
Announcement(Box<Event>),
Relays(Box<Event>),
Person(Person),
Announcement(Event),
Relays(Event),
}
/// Person Registry
@@ -36,7 +35,7 @@ pub struct PersonRegistry {
persons: HashMap<PublicKey, Entity<Person>>,
/// Set of public keys that have been seen
seens: Rc<RefCell<HashSet<PublicKey>>>,
seen: RwLock<HashSet<PublicKey>>,
/// Sender for requesting metadata
sender: flume::Sender<PublicKey>,
@@ -63,53 +62,38 @@ impl PersonRegistry {
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Dispatch>(100);
let (mta_tx, mta_rx) = flume::unbounded::<PublicKey>();
let (metadata_tx, metadata_rx) = flume::unbounded::<PublicKey>();
let mut tasks = smallvec![];
tasks.push(
// Handle nostr notifications
cx.background_spawn({
let client = client.clone();
let client2 = client.clone();
tasks.push(cx.background_spawn(async move {
Self::handle_notifications(&client2, &tx).await;
}));
async move {
Self::handle_notifications(&client, &tx).await;
}
}),
);
let client3 = client.clone();
tasks.push(cx.background_spawn(async move {
Self::handle_requests(&client3, &metadata_rx).await;
}));
tasks.push(
// Handle metadata requests
cx.background_spawn({
let client = client.clone();
async move {
Self::handle_requests(&client, &mta_rx).await;
}
}),
);
tasks.push(
// Update GPUI state
cx.spawn(async move |this, cx| {
while let Ok(event) = rx.recv_async().await {
this.update(cx, |this, cx| {
match event {
Dispatch::Person(person) => {
this.insert(*person, cx);
}
Dispatch::Announcement(event) => {
this.set_announcement(&event, cx);
}
Dispatch::Relays(event) => {
this.set_messaging_relays(&event, cx);
}
};
})
.ok();
}
}),
);
tasks.push(cx.spawn(async move |this, cx| {
while let Ok(event) = rx.recv_async().await {
this.update(cx, |this, cx| {
match event {
Dispatch::Person(person) => {
this.insert(person, cx);
}
Dispatch::Announcement(event) => {
this.set_announcement(&event, cx);
}
Dispatch::Relays(event) => {
this.set_messaging_relays(&event, cx);
}
};
})
.ok();
}
}));
// Load all user profiles from the database
cx.defer_in(window, |this, _window, cx| {
@@ -118,8 +102,8 @@ impl PersonRegistry {
Self {
persons: HashMap::new(),
seens: Rc::new(RefCell::new(HashSet::new())),
sender: mta_tx,
seen: RwLock::new(HashSet::new()),
sender: metadata_tx,
tasks,
}
}
@@ -145,24 +129,25 @@ impl PersonRegistry {
Kind::Metadata => {
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let person = Person::new(event.pubkey, metadata);
let val = Box::new(person);
// Send
tx.send_async(Dispatch::Person(val)).await.ok();
if tx.send_async(Dispatch::Person(person)).await.is_err() {
log::warn!("PersonRegistry channel closed, dropping metadata event");
}
}
Kind::ContactList => {
let public_keys = event.extract_public_keys();
// Get metadata for all public keys
get_metadata(client, public_keys).await.ok();
if let Err(e) = get_metadata(client, public_keys).await {
log::warn!("Failed to get metadata for contact list: {e}");
}
}
Kind::InboxRelays => {
let val = Box::new(event.into_owned());
// Send
tx.send_async(Dispatch::Relays(val)).await.ok();
tx.send_async(Dispatch::Relays(event.into_owned()))
.await
.ok();
}
Kind::Custom(10044) => {
let val = Box::new(event.into_owned());
// Send
tx.send_async(Dispatch::Announcement(val)).await.ok();
tx.send_async(Dispatch::Announcement(event.into_owned()))
.await
.ok();
}
_ => {}
}
@@ -182,13 +167,17 @@ impl PersonRegistry {
Ok(Some(public_key)) => {
batch.insert(public_key);
// Process the batch if it's full
if batch.len() >= 20 {
get_metadata(client, std::mem::take(&mut batch)).await.ok();
if batch.len() >= 20
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
}
}
_ => {
if !batch.is_empty() {
get_metadata(client, std::mem::take(&mut batch)).await.ok();
if !batch.is_empty()
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
}
}
}
@@ -217,7 +206,7 @@ impl PersonRegistry {
self.tasks.push(cx.spawn(async move |this, cx| {
if let Ok(persons) = task.await {
this.update(cx, |this, cx| {
this.bulk_inserts(persons, cx);
this.bulk_insert(persons, cx);
})
.ok();
}
@@ -256,7 +245,7 @@ impl PersonRegistry {
}
/// Insert batch of persons
fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
for person in persons.into_iter() {
let public_key = person.public_key();
self.persons
@@ -290,15 +279,14 @@ impl PersonRegistry {
}
let public_key = *public_key;
let mut seen = self.seens.borrow_mut();
if seen.insert(public_key) {
if self.seen.write().unwrap().insert(public_key) {
let sender = self.sender.clone();
// Spawn background task to request metadata
cx.background_spawn(async move {
if let Err(e) = sender.send_async(public_key).await {
log::warn!("Failed to send public key for metadata request: {}", e);
log::warn!("Failed to send public key for metadata request: {e}");
}
})
.detach();
+1 -9
View File
@@ -5,8 +5,6 @@ use gpui::SharedString;
use nostr_sdk::prelude::*;
use state::Announcement;
const IMAGE_RESIZER: &str = "https://wsrv.nl";
/// Person
#[derive(Debug, Clone)]
pub struct Person {
@@ -111,13 +109,7 @@ impl Person {
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| {
let encoded_picture = urlencoding::encode(picture);
let url = format!(
"{IMAGE_RESIZER}/?url={encoded_picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
);
url.into()
})
.map(|picture| picture.into())
.unwrap_or_else(|| "brand/avatar.png".into())
}
+10 -25
View File
@@ -1,4 +1,3 @@
use std::fmt::Display;
use std::rc::Rc;
use anyhow::{Error, anyhow};
@@ -13,6 +12,9 @@ pub fn init(window: &mut Window, cx: &mut App) {
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
}
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
const LEGACY_FILE_SERVER: &str = "blossom.band";
macro_rules! setting_accessors {
($(pub $field:ident: $type:ty),* $(,)?) => {
impl AppSettings {
@@ -42,28 +44,10 @@ setting_accessors! {
pub hide_avatar: bool,
pub screening: bool,
pub nip4e: bool,
pub auth_mode: AuthMode,
pub trusted_relays: Vec<String>,
pub file_server: Url,
}
/// Authentication mode
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum AuthMode {
#[default]
Auto,
Manual,
}
impl Display for AuthMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthMode::Auto => write!(f, "Auto"),
AuthMode::Manual => write!(f, "Ask every time"),
}
}
}
/// Signer kind
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum SignerKind {
@@ -141,9 +125,6 @@ pub struct Settings {
/// Enable decoupling encryption key
pub nip4e: bool,
/// Authentication mode
pub auth_mode: AuthMode,
/// Trusted relays; Coop will automatically authenticate with these relays
pub trusted_relays: Vec<String>,
@@ -159,9 +140,8 @@ impl Default for Settings {
hide_avatar: false,
screening: true,
nip4e: false,
auth_mode: AuthMode::default(),
trusted_relays: vec![],
file_server: Url::parse("https://blossom.band/").unwrap(),
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
}
}
}
@@ -240,7 +220,12 @@ impl AppSettings {
});
cx.spawn_in(window, async move |this, cx| {
let settings = task.await.unwrap_or(Settings::default());
let mut settings = task.await.unwrap_or(Settings::default());
// Move settings still pointed at the old default file server over to the new one
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
settings.file_server = Url::parse(DEFAULT_FILE_SERVER).unwrap();
}
// Update settings
this.update_in(cx, |this, window, cx| {
+6
View File
@@ -14,6 +14,7 @@ nostr-blossom.workspace = true
nostr-connect.workspace = true
gpui.workspace = true
instant.workspace = true
flume.workspace = true
futures.workspace = true
log.workspace = true
@@ -23,10 +24,15 @@ serde_json.workspace = true
mime_guess = "2.0.4"
aes-gcm.workspace = true
sha2.workspace = true
data-encoding.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
nostr-memory.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
browser-signer-proxy = { path = "../browser-signer-proxy" }
nostr-lmdb.workspace = true
smol.workspace = true
gpui_tokio.workspace = true
+32 -10
View File
@@ -4,30 +4,52 @@ 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"))]
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 keys = Keys::generate();
use crate::file::sha256_hex;
// Construct the blossom client
let client = BlossomClient::new(server);
/// Upload a blob to a blossom server and return its URL
#[cfg(not(target_arch = "wasm32"))]
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 content_type = content_type.to_string();
let base = server.clone();
let hash = sha256.to_string();
Tokio::spawn(cx, async move {
let blob = client
match client
.upload_blob(data, Some(content_type), None, Some(&keys))
.await?;
Ok(blob.url)
.await
{
Ok(blob) => Ok(blob.url),
Err(e) if e.to_string().contains("201 Created") => Ok::<Url, Error>(base.join(&hash)?),
Err(e) => Err(anyhow!(e.to_string())),
}
})
.await
.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
}
#[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"))
+4 -6
View File
@@ -14,9 +14,6 @@ pub const USER_KEYRING: &str = "Coop User Credential";
/// Default timeout for subscription
pub const TIMEOUT: u64 = 2;
/// Default image cache size
pub const IMAGE_CACHE_SIZE: usize = 20;
/// Default delay for searching
pub const FIND_DELAY: u64 = 600;
@@ -39,14 +36,15 @@ pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com";
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
/// Default search relays
pub const INDEXER_RELAYS: [&str; 1] = ["wss://indexer.coracle.social"];
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
/// Default search relays
pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
/// Default bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
"wss://relay.ditto.pub",
"wss://relay.primal.net",
"wss://user.kindpag.es",
"wss://relay.nostr.net",
"wss://profiles.nostr1.com",
];
+337
View File
@@ -0,0 +1,337 @@
use std::path::PathBuf;
use aes_gcm::aead::consts::{U12, U16, U32};
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use aes_gcm::aes::Aes256;
use aes_gcm::{Aes256Gcm, AesGcm, Nonce};
use anyhow::{Error, anyhow, bail};
use data_encoding::HEXLOWER;
use futures::AsyncReadExt;
use gpui::http_client::AsyncBody;
use gpui::{AsyncApp, SharedString};
#[cfg(not(target_arch = "wasm32"))]
use mime_guess::from_path;
use nostr::nips::nip94::Sha256Hash;
use nostr_sdk::prelude::*;
use sha2::{Digest, Sha256};
pub const ALGORITHM: &str = "aes-gcm";
pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
const TAG_SHA256: &str = "x";
const TAG_ORIGINAL_SHA256: &str = "ox";
const TAG_FILE_TYPE: &str = "file-type";
const TAG_ALGORITHM: &str = "encryption-algorithm";
const TAG_KEY: &str = "decryption-key";
const TAG_NONCE: &str = "decryption-nonce";
const TAG_SIZE: &str = "size";
const TAG_DIM: &str = "dim";
const TAG_ALT: &str = "alt";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncryptedFile {
pub data: Vec<u8>,
pub key: String,
pub nonce: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileAttachment {
pub url: Url,
pub mime: String,
pub key: String,
pub nonce: String,
pub sha256: Option<String>,
pub original_sha256: Option<String>,
pub size: Option<u64>,
pub dim: Option<(u32, u32)>,
pub name: Option<String>,
}
impl FileAttachment {
pub fn tags(&self) -> Vec<Tag> {
let mut tags = vec![
Tag::custom(TAG_FILE_TYPE, [self.mime.clone()]),
Tag::custom(TAG_ALGORITHM, [ALGORITHM]),
Tag::custom(TAG_KEY, [self.key.clone()]),
Tag::custom(TAG_NONCE, [self.nonce.clone()]),
];
if let Some(sha256) = &self.sha256 {
tags.push(Tag::custom(TAG_SHA256, [sha256.clone()]));
}
if let Some(original_sha256) = &self.original_sha256 {
tags.push(Tag::custom(TAG_ORIGINAL_SHA256, [original_sha256.clone()]));
}
if let Some(size) = self.size {
tags.push(Tag::custom(TAG_SIZE, [size.to_string()]));
}
if let Some((width, height)) = self.dim {
tags.push(Tag::custom(TAG_DIM, [format!("{width}x{height}")]));
}
if let Some(name) = &self.name {
tags.push(Tag::custom(TAG_ALT, [name.clone()]));
}
tags
}
pub fn from_tags(content: &str, tags: &Tags) -> Option<Self> {
if tag_value(tags, TAG_ALGORITHM)? != ALGORITHM {
return None;
}
Some(Self {
url: Url::parse(content).ok()?,
mime: tag_value(tags, TAG_FILE_TYPE)?.to_string(),
key: tag_value(tags, TAG_KEY)?.to_string(),
nonce: tag_value(tags, TAG_NONCE)?.to_string(),
sha256: tag_value(tags, TAG_SHA256).map(str::to_string),
original_sha256: tag_value(tags, TAG_ORIGINAL_SHA256).map(str::to_string),
size: tag_value(tags, TAG_SIZE).and_then(|size| size.parse().ok()),
dim: tag_value(tags, TAG_DIM).and_then(parse_dim),
name: tag_value(tags, TAG_ALT).map(str::to_string),
})
}
pub fn is_image(&self) -> bool {
self.mime.starts_with("image/")
}
pub fn display_name(&self) -> SharedString {
if let Some(name) = &self.name {
return name.clone().into();
}
match self.size {
Some(size) => format!("{} ({size} bytes)", self.mime).into(),
None => self.mime.clone().into(),
}
}
}
pub fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
let key = Aes256Gcm::generate_key(OsRng);
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
let cipher = AesGcm::<Aes256, U16>::new(&key);
let data = cipher
.encrypt(&nonce, data)
.map_err(|_| anyhow!("Failed to encrypt file"))?;
Ok(EncryptedFile {
data,
key: HEXLOWER.encode(key.as_slice()),
nonce: HEXLOWER.encode(nonce.as_slice()),
})
}
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>, Error> {
let key = decode(key, "decryption key")?;
let nonce = decode(nonce, "decryption nonce")?;
if key.len() != 32 {
bail!(
"Invalid decryption key length: expected 32 bytes, got {}",
key.len()
);
}
match nonce.len() {
12 => Aes256Gcm::new_from_slice(&key)
.map_err(|_| anyhow!("Invalid decryption key"))?
.decrypt(Nonce::<U12>::from_slice(&nonce), data)
.map_err(|_| anyhow!("Failed to decrypt file")),
16 => AesGcm::<Aes256, U16>::new_from_slice(&key)
.map_err(|_| anyhow!("Invalid decryption key"))?
.decrypt(Nonce::<U16>::from_slice(&nonce), data)
.map_err(|_| anyhow!("Failed to decrypt file")),
32 => AesGcm::<Aes256, U32>::new_from_slice(&key)
.map_err(|_| anyhow!("Invalid decryption key"))?
.decrypt(Nonce::<U32>::from_slice(&nonce), data)
.map_err(|_| anyhow!("Failed to decrypt file")),
len => bail!("Unsupported decryption nonce length: {len} bytes"),
}
}
pub fn sha256_hex(data: &[u8]) -> String {
let hash: [u8; 32] = Sha256::digest(data).into();
Sha256Hash::from_byte_array(hash).to_hex()
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn upload_encrypted(
server: Url,
path: PathBuf,
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 sha256 = sha256_hex(&encrypted.data);
let original_sha256 = sha256_hex(&data);
let size = encrypted.data.len() as u64;
let base_url = server.to_string();
let url = crate::blossom::upload_blob(
&server,
encrypted.data,
"application/octet-stream",
&sha256,
cx,
)
.await
.map_err(|e| {
let message = e.to_string();
if !message.contains("415") {
return anyhow!(message);
}
anyhow!(
"{base_url} rejected the encrypted file. Encrypted attachments are uploaded as
opaque data, which this file server does not accept. Choose a different file
server in the settings."
)
})?;
Ok(FileAttachment {
url,
mime,
key: encrypted.key,
nonce: encrypted.nonce,
sha256: Some(sha256),
original_sha256: Some(original_sha256),
size: Some(size),
dim: None,
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 async fn download_and_decrypt(
url: &Url,
key: &str,
nonce: &str,
expected_sha256: Option<&str>,
cx: &AsyncApp,
) -> Result<Vec<u8>, Error> {
let client = cx.update(|app| app.http_client());
let response = client.get(url.as_str(), AsyncBody::default(), true).await?;
if !response.status().is_success() {
bail!("Failed to download file: HTTP {}", response.status());
}
let mut data = Vec::new();
response
.into_body()
.take(MAX_FILE_SIZE as u64 + 1)
.read_to_end(&mut data)
.await?;
if data.len() > MAX_FILE_SIZE {
bail!("File is too large (max {MAX_FILE_SIZE} bytes)");
}
if let Some(expected) = expected_sha256
&& !sha256_hex(&data).eq_ignore_ascii_case(expected)
{
bail!("File hash mismatch");
}
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()));
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);
}
let data = download_and_decrypt(
&file.url,
&file.key,
&file.nonce,
file.sha256.as_deref(),
cx,
)
.await?;
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(path)
}
#[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"))
}
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
tags.iter()
.find(|tag| tag.kind() == name)
.and_then(|tag| tag.content())
}
fn parse_dim(value: &str) -> Option<(u32, u32)> {
let (width, height) = value.split_once('x')?;
Some((width.parse().ok()?, height.parse().ok()?))
}
fn decode(value: &str, label: &str) -> Result<Vec<u8>, Error> {
HEXLOWER
.decode(value.to_ascii_lowercase().as_bytes())
.map_err(|_| anyhow!("Invalid {label} encoding"))
}
+111 -13
View File
@@ -1,9 +1,12 @@
use std::collections::HashMap;
use std::time::Duration;
use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))]
use browser_signer_proxy::prelude::*;
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
use gpui_tokio::Tokio;
use instant::Duration;
use nostr_connect::prelude::*;
use nostr_gossip_memory::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
@@ -14,17 +17,19 @@ use nostr_sdk::prelude::*;
mod blossom;
mod constants;
mod file;
mod nip05;
mod nip4e;
mod signer;
pub use blossom::*;
pub use constants::*;
pub use file::*;
pub use nip4e::*;
pub use nip05::*;
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
pub fn init(window: &mut Window, cx: &mut App) {
pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
// rustls uses the `aws_lc_rs` provider by default
// This only errors if the default provider has already
// been installed. We can ignore this `Result`.
@@ -37,7 +42,7 @@ pub fn init(window: &mut Window, cx: &mut App) {
#[cfg(not(target_arch = "wasm32"))]
gpui_tokio::init(cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx, cli_key)), cx);
}
struct GlobalNostrRegistry(Entity<NostrRegistry>);
@@ -58,16 +63,16 @@ pub enum StateEvent {
}
impl StateEvent {
pub fn signer_changed(&self) -> bool {
matches!(self, StateEvent::SignerChanged)
}
pub fn error<T>(error: T) -> Self
where
T: Into<String>,
{
Self::Error(error.into())
}
pub fn signer_changed(&self) -> bool {
matches!(self, StateEvent::SignerChanged)
}
}
/// Nostr Registry
@@ -100,7 +105,7 @@ impl NostrRegistry {
}
/// Create a new nostr instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
fn new(window: &mut Window, cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
let signer = UniversalSigner::new(Keys::generate());
let authenticator = SignerAuthenticator::new(signer.clone());
@@ -130,7 +135,16 @@ impl NostrRegistry {
// Connect to bootstrap relays after the window is ready
cx.defer_in(window, |this, _window, cx| {
this.connect_bootstrap_relays(cx);
this.get_user_credential(cx);
if cfg!(target_arch = "wasm32") {
cx.emit(StateEvent::NoSigner);
} else if let Some(secret) = cli_key {
// Use CLI-provided key -- same path as get_user_credential
let keys = Keys::new(secret);
this.set_signer(keys, cx);
} else {
this.get_user_credential(cx);
}
});
Self {
@@ -164,7 +178,7 @@ impl NostrRegistry {
<T as AsyncSignEvent>::Error: std::error::Error + Send + Sync + 'static,
<T as AsyncNip44>::Error: std::error::Error + Send + Sync + 'static,
{
cx.spawn(async move |this, cx| {
let task = cx.spawn(async move |this, cx| {
match new_signer.get_public_key_async().await {
Ok(public_key) => {
this.update(cx, |this, cx| {
@@ -181,9 +195,9 @@ impl NostrRegistry {
}
};
Ok::<(), anyhow::Error>(())
})
.detach();
Ok(())
});
self.tasks.push(task);
}
/// Connect to the bootstrapping relays
@@ -253,6 +267,11 @@ impl NostrRegistry {
this.set_signer(signer, cx);
cx.notify();
})?;
} else if content == "proxy" {
#[cfg(not(target_arch = "wasm32"))]
this.update(cx, |this, cx| {
this.connect_proxy(cx);
})?;
}
}
_ => {
@@ -268,6 +287,10 @@ impl NostrRegistry {
/// Get the master key that used for Nostr Connect
pub fn get_master_key(&self, cx: &App) -> Task<Keys> {
if cfg!(target_arch = "wasm32") {
return cx.background_spawn(async move { Keys::generate() });
}
let task = cx.read_credentials(MASTER_KEYRING);
cx.spawn(async move |cx| {
@@ -294,6 +317,81 @@ impl NostrRegistry {
})
}
/// Start the browser proxy
#[cfg(not(target_arch = "wasm32"))]
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
let (tx, rx) = flume::bounded::<String>(1);
self.tasks.push(Tokio::spawn_result(cx, {
let proxy = proxy.clone();
async move {
// Start the proxy and get the web url
proxy.start().await?;
// Notify GPUI
let url = proxy.url();
tx.send(url).ok();
Ok(())
}
}));
self.tasks.push(Tokio::spawn_result(cx, {
let proxy = proxy.clone();
async move {
loop {
if proxy.is_session_active() {
break;
}
smol::Timer::after(Duration::from_secs(1)).await;
}
Ok(())
}
}));
self.tasks.push(cx.spawn({
let proxy = proxy.clone();
async move |this, cx| {
while let Ok(url) = rx.recv_async().await {
this.update(cx, |this, cx| {
let save = cx.write_credentials(USER_KEYRING, "proxy", b"proxy");
cx.background_spawn(async move { save.await.ok() }).detach();
cx.open_url(&url);
this.set_signer(proxy.clone(), cx);
})?;
}
Ok(())
}
}));
// Monitor the session, if the browser disconnects, notify user to reconnect
self.tasks.push(cx.spawn({
let proxy = proxy.clone();
let executor = cx.background_executor().clone();
async move |this, cx| {
// Wait for the signer to be confirmed (timeout is 30s)
executor.timer(Duration::from_secs(30)).await;
loop {
executor.timer(Duration::from_secs(5)).await;
if !proxy.is_session_active() {
_ = this.update(cx, |this, cx| {
// Only notify if this proxy is still the active signer
if this.current_user.is_some() {
this.signer.swap_inner(Keys::generate());
this.current_user = None;
cx.emit(StateEvent::NoSigner);
cx.notify();
}
});
break;
}
}
Ok(())
}
}));
}
/// Get the public key of a NIP-05 address
pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
let client = self.client();
+1
View File
@@ -4,6 +4,7 @@ use anyhow::Error;
use futures::io::AsyncReadExt;
use gpui::http_client::{AsyncBody, HttpClient};
use nostr_sdk::prelude::*;
use serde_json::Value;
#[allow(async_fn_in_trait)]
pub trait NostrAddress {
+24 -14
View File
@@ -1,5 +1,7 @@
use std::error::Error;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use nostr_connect::client::AuthUrlHandler;
@@ -60,21 +62,23 @@ impl UniversalSigner {
}
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
fn get_public_key_async(&self) -> BoxedFuture<'_, Result<PublicKey, UniversalSignerError>>;
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> BoxedFuture<'_, Result<Event, UniversalSignerError>>;
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, UniversalSignerError>>;
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> BoxedFuture<'a, Result<String, UniversalSignerError>>;
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
}
#[derive(Debug)]
@@ -87,7 +91,9 @@ where
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
{
fn get_public_key_async(&self) -> BoxedFuture<'_, Result<PublicKey, UniversalSignerError>> {
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
Box::pin(async move {
AsyncGetPublicKey::get_public_key_async(&self.0)
.await
@@ -98,7 +104,7 @@ where
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> BoxedFuture<'_, Result<Event, UniversalSignerError>> {
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
Box::pin(async move {
AsyncSignEvent::sign_event_async(&self.0, unsigned)
.await
@@ -110,7 +116,7 @@ where
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, UniversalSignerError>> {
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
Box::pin(async move {
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
.await
@@ -122,7 +128,7 @@ where
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> BoxedFuture<'a, Result<String, UniversalSignerError>> {
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
Box::pin(async move {
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
.await
@@ -142,7 +148,9 @@ impl UniversalSigner {
impl AsyncGetPublicKey for UniversalSigner {
type Error = UniversalSignerError;
fn get_public_key_async(&self) -> BoxedFuture<'_, Result<PublicKey, Self::Error>> {
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
let inner = self.inner.read().expect("RwLock poisoned").clone();
Box::pin(async move { inner.get_public_key_async().await })
}
@@ -154,7 +162,7 @@ impl AsyncSignEvent for UniversalSigner {
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> BoxedFuture<'_, Result<Event, Self::Error>> {
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
let inner = self.inner.read().expect("RwLock poisoned").clone();
Box::pin(async move { inner.sign_event_async(unsigned).await })
}
@@ -167,7 +175,7 @@ impl AsyncNip44 for UniversalSigner {
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, Self::Error>> {
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
let inner = self.inner.read().expect("RwLock poisoned").clone();
Box::pin(async move { inner.nip44_encrypt_async(public_key, content).await })
}
@@ -176,7 +184,7 @@ impl AsyncNip44 for UniversalSigner {
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> BoxedFuture<'a, Result<String, Self::Error>> {
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
let inner = self.inner.read().expect("RwLock poisoned").clone();
Box::pin(async move { inner.nip44_decrypt_async(public_key, payload).await })
}
@@ -186,8 +194,10 @@ impl AsyncNip44 for UniversalSigner {
pub struct CoopAuthUrlHandler;
impl AuthUrlHandler for CoopAuthUrlHandler {
#[allow(mismatched_lifetime_syntaxes)]
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<(), nostr_connect::error::Error>> {
fn on_auth_url(
&self,
auth_url: Url,
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
Box::pin(async move {
webbrowser::open(auth_url.as_str()).unwrap();
Ok(())
+2 -2
View File
@@ -112,7 +112,7 @@ impl ThemeColors {
elevated_surface_background: neutral().light().step_3(),
panel_background: neutral().light().step_1(),
overlay: neutral().light_alpha().step_3(),
title_bar: neutral().light().step_3(),
title_bar: neutral().light().step_2(),
title_bar_inactive: neutral().light().step_1(),
window_border: hsl(240.0, 5.9, 78.0),
@@ -198,7 +198,7 @@ impl ThemeColors {
elevated_surface_background: neutral().dark().step_3(),
panel_background: neutral().dark().step_1(),
overlay: neutral().dark_alpha().step_3(),
title_bar: neutral().dark().step_3(),
title_bar: neutral().dark().step_2(),
title_bar_inactive: neutral().dark().step_1(),
window_border: hsl(240.0, 3.7, 28.0),
+1 -1
View File
@@ -34,7 +34,7 @@ pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
/// Defines workspace tabbar height
pub const TABBAR_HEIGHT: Pixels = px(28.0);
pub const TABBAR_HEIGHT: Pixels = px(44.0);
/// Defines default sidebar width
pub const SIDEBAR_WIDTH: Pixels = px(240.);
+4 -4
View File
@@ -3,6 +3,7 @@ pub enum PlatformKind {
Mac,
Linux,
Windows,
Web,
}
impl PlatformKind {
@@ -11,22 +12,21 @@ impl PlatformKind {
Self::Linux
} else if cfg!(target_os = "windows") {
Self::Windows
} else {
} else if cfg!(target_os = "macos") {
Self::Mac
} else {
Self::Web
}
}
#[allow(dead_code)]
pub fn is_linux(&self) -> bool {
matches!(self, Self::Linux)
}
#[allow(dead_code)]
pub fn is_windows(&self) -> bool {
matches!(self, Self::Windows)
}
#[allow(dead_code)]
pub fn is_mac(&self) -> bool {
matches!(self, Self::Mac)
}
+2 -2
View File
@@ -64,7 +64,7 @@ impl Default for ThemeFamily {
id: "coop".into(),
name: "Coop Default Theme".into(),
author: "Coop".into(),
url: "https://github.com/lumehq/coop".into(),
url: "https://github.com/reyakov/coop".into(),
light: ThemeColors::light(),
dark: ThemeColors::dark(),
}
@@ -186,7 +186,7 @@ mod tests {
"id": "test-theme",
"name": "Test Theme",
"author": "Coop",
"url": "https://github.com/lumehq/coop",
"url": "https://github.com/reyakov/coop",
"light": {
"background": "#ffffff",
"surface_background": "#fafafa",
+2 -1
View File
@@ -9,6 +9,7 @@ common = { path = "../common" }
theme = { path = "../theme" }
gpui.workspace = true
instant.workspace = true
serde.workspace = true
smallvec.workspace = true
anyhow.workspace = true
@@ -21,7 +22,7 @@ regex = "1"
lsp-types = "0.97.0"
ropey = { version = "=2.0.0-beta.1", features = ["metric_lines_lf", "metric_utf16"] }
sum_tree = { git = "https://github.com/zed-industries/zed" }
tree-sitter = "0.26"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
tree-sitter = "0.26"
+4 -6
View File
@@ -1,8 +1,8 @@
use gpui::prelude::FluentBuilder;
use gpui::{
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, Window, div, img,
px,
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
Window, div, img, px,
};
use theme::ActiveTheme;
@@ -26,9 +26,7 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
/// ```
/// use ui::{Avatar};
///
/// Avatar::new("path/to/image.png")
/// .grayscale(true)
/// .border_color(gpui::red());
/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red());
/// ```
#[derive(IntoElement)]
pub struct Avatar {
@@ -130,7 +128,7 @@ impl RenderOnce for Avatar {
self.image
.size(image_size)
.rounded_full()
.object_fit(gpui::ObjectFit::Fill)
.object_fit(ObjectFit::Cover)
.bg(cx.theme().ghost_element_background)
.with_fallback(move || {
img("brand/avatar.png")
+1 -1
View File
@@ -1,5 +1,5 @@
use std::rc::Rc;
use std::time::Duration;
use instant::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
+2
View File
@@ -214,6 +214,8 @@ impl Dock {
pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
self.open = open;
let item = self.panel.clone();
// Use defer_in (not window.defer) so the callback is cancelled
// if this Dock entity is dropped before the deferred frame runs.
cx.defer_in(window, move |_, window, cx| {
item.set_collapsed(!open, window, cx);
});
+6 -32
View File
@@ -2,11 +2,10 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Decorations, Edges, Entity,
EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _,
Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, actions, div, px,
AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity, EntityId,
EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _, Pixels,
Render, SharedString, Styled, Subscription, WeakEntity, Window, actions, div, px,
};
use theme::CLIENT_SIDE_DECORATION_ROUNDING;
use crate::ElementExt;
@@ -110,15 +109,8 @@ impl DockItem {
window: &mut Window,
cx: &mut App,
) -> Self {
let mut items = items;
let stack_panel = cx.new(|cx| {
let mut stack_panel = StackPanel::new(axis, window, cx);
for (i, item) in items.iter_mut().enumerate() {
let view = item.view();
let size = sizes.get(i).copied().flatten();
stack_panel.add_panel(view.clone(), size, dock_area.clone(), window, cx)
}
for (i, item) in items.iter().enumerate() {
let view = item.view();
@@ -745,34 +737,22 @@ impl EventEmitter<DockEvent> for DockArea {}
impl Render for DockArea {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity().clone();
let decorations = window.window_decorations();
div()
.id("dock-area")
.relative()
.size_full()
.overflow_hidden()
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds))
.map(|this| {
if let Some(zoom_view) = self.zoom_view.clone() {
this.map(|this| match decorations {
Decorations::Server => this,
Decorations::Client { tiling } => this
.when(!(tiling.top || tiling.right), |div| {
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
}),
})
.child(zoom_view)
this.child(zoom_view)
} else {
// render dock
this.child(
div()
.flex()
.flex_row()
.h_full()
.size_full()
// Left dock
.when_some(self.left_dock.clone(), |this, dock| {
this.child(div().flex().flex_none().child(dock))
@@ -783,14 +763,8 @@ impl Render for DockArea {
.flex()
.flex_1()
.flex_col()
.overflow_hidden()
// Top center
.child(
div()
.flex_1()
.overflow_hidden()
.child(self.render_items(window, cx)),
)
.child(div().flex_1().child(self.render_items(window, cx)))
// Bottom Dock
.when_some(self.bottom_dock.clone(), |this, dock| {
this.child(dock)
+12 -19
View File
@@ -1,13 +1,12 @@
use std::sync::Arc;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Pixels, Render, SharedString, Styled, Subscription, WeakEntity,
Window,
};
use smallvec::SmallVec;
use theme::{ActiveTheme, AxisExt as _, CLIENT_SIDE_DECORATION_ROUNDING, Placement};
use theme::{AxisExt as _, Placement};
use super::{DockArea, PanelEvent};
use crate::dock::panel::{Panel, PanelView};
@@ -369,26 +368,20 @@ impl Focusable for StackPanel {
}
impl EventEmitter<PanelEvent> for StackPanel {}
impl EventEmitter<DismissEvent> for StackPanel {}
impl Render for StackPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.overflow_hidden()
.bg(cx.theme().panel_background)
.when(cx.theme().platform.is_linux(), |this| {
this.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.child(
ResizablePanelGroup::new("stack-panel-group")
.with_state(&self.state)
.axis(self.axis)
.children(self.panels.clone().into_iter().map(|panel| {
resizable_panel()
.child(panel.view())
.visible(panel.visible(cx))
})),
)
h_flex().size_full().overflow_hidden().child(
ResizablePanelGroup::new("stack-panel-group")
.with_state(&self.state)
.axis(self.axis)
.children(self.panels.clone().into_iter().map(|panel| {
resizable_panel()
.child(panel.view())
.visible(panel.visible(cx))
})),
)
}
}
+33 -19
View File
@@ -7,7 +7,7 @@ use gpui::{
ParentElement, Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled,
WeakEntity, Window, div, px, rems,
};
use theme::{ActiveTheme, AxisExt, CLIENT_SIDE_DECORATION_ROUNDING, Placement, TABBAR_HEIGHT};
use theme::{ActiveTheme, AxisExt, Placement, TABBAR_HEIGHT};
use crate::button::{Button, ButtonVariants as _};
use crate::dock::dock::DockPlacement;
@@ -51,7 +51,7 @@ impl Render for DragPanel {
.overflow_hidden()
.whitespace_nowrap()
.rounded(cx.theme().radius)
.text_xs()
.text_sm()
.text_color(cx.theme().text)
.text_ellipsis()
.when(cx.theme().shadow, |this| this.shadow_xs())
@@ -312,6 +312,7 @@ impl TabPanel {
cx.emit(PanelEvent::ZoomOut);
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
fn detach_panel(
@@ -321,10 +322,22 @@ impl TabPanel {
cx: &mut Context<Self>,
) {
let panel_view = panel.view();
let removed_ix = self.panels.iter().position(|p| p.view() == panel_view);
self.panels.retain(|p| p.view() != panel_view);
if self.active_ix >= self.panels.len() {
self.set_active_ix(self.panels.len().saturating_sub(1), window, cx)
} else if let Some(removed_ix) = removed_ix {
if removed_ix < self.active_ix {
self.active_ix = self.active_ix.saturating_sub(1);
} else if removed_ix == self.active_ix {
// The active panel was removed and another panel shifted into
// its position. Activate the new panel at the same index.
if let Some(new_active) = self.panels.get(self.active_ix) {
new_active.set_active(true, cx);
}
self.focus_active_panel(window, cx);
}
}
}
@@ -567,6 +580,7 @@ impl TabPanel {
let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, window, cx);
let bottom_dock_button = self.render_dock_toggle_button(DockPlacement::Bottom, window, cx);
let right_dock_button = self.render_dock_toggle_button(DockPlacement::Right, window, cx);
let has_extend_dock_button = left_dock_button.is_some() || bottom_dock_button.is_some();
let tabs_count = self.panels.len();
let is_bottom_dock = bottom_dock_button.is_some();
@@ -586,6 +600,7 @@ impl TabPanel {
.py_2()
.pl_3()
.pr_2()
.rounded_t(cx.theme().radius_lg)
.bg(cx.theme().panel_background)
.when(left_dock_button.is_some(), |this| this.pl_2())
.when(right_dock_button.is_some(), |this| this.pr_2())
@@ -611,7 +626,7 @@ impl TabPanel {
div()
.w_full()
.text_ellipsis()
.text_xs()
.text_sm()
.child(panel.title(cx)),
)
.when(state.draggable, |this| {
@@ -641,17 +656,14 @@ impl TabPanel {
TabBar::new("tab-bar")
.track_scroll(&self.tab_bar_scroll_handle)
.h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background)
.rounded_t(cx.theme().radius_lg)
.when(has_extend_dock_button, |this| {
this.prefix(
h_flex()
.items_center()
.top_0()
.right(-px(1.))
.border_r_1()
.border_b_1()
.h_full()
.border_color(cx.theme().border)
.bg(cx.theme().tab_background)
.pl_0p5()
.pr_1()
.children(left_dock_button)
@@ -688,6 +700,7 @@ impl TabPanel {
.on_click(cx.listener({
let panel = panel.clone();
move |view, _ev, window, cx| {
cx.stop_propagation();
view.remove_panel(&panel, window, cx);
}
})),
@@ -780,12 +793,8 @@ impl TabPanel {
.top_0()
.right_0()
.h_full()
.border_l_1()
.border_b_1()
.px_0p5()
.gap_1()
.border_color(cx.theme().border)
.bg(cx.theme().tab_background)
.child(self.render_toolbar(state, window, cx))
.when_some(right_dock_button, |this, btn| this.child(btn)),
)
@@ -815,10 +824,8 @@ impl TabPanel {
.child(
div()
.size_full()
.rounded_b(cx.theme().radius_lg)
.bg(cx.theme().panel_background)
.when(cx.theme().platform.is_linux(), |this| {
this.rounded_b(CLIENT_SIDE_DECORATION_ROUNDING)
})
.overflow_hidden()
.child(
active_panel
@@ -1140,17 +1147,24 @@ impl Render for TabPanel {
state.closable = false;
}
v_flex()
div()
.when(!self.collapsed, |this| {
this.on_action(cx.listener(Self::on_action_toggle_zoom))
.on_action(cx.listener(Self::on_action_close_panel))
})
.id("tab-panel")
.tab_group()
.track_focus(&focus_handle)
.size_full()
.p_1()
.overflow_hidden()
.child(self.render_title_bar(&state, window, cx))
.child(self.render_active_panel(&state, window, cx))
.child(
v_flex()
.rounded(cx.theme().radius_lg)
.when(cx.theme().shadow, |this| this.shadow_xs())
.size_full()
.tab_group()
.child(self.render_title_bar(&state, window, cx))
.child(self.render_active_panel(&state, window, cx)),
)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
use std::fmt::Debug;
use std::time::{Duration, Instant};
use instant::{Duration, Instant};
/// A HistoryItem represents a single change in the history.
/// It must implement Clone and PartialEq to be used in the History.
+2
View File
@@ -46,6 +46,7 @@ pub enum IconName {
InboxFill,
Link,
Loader,
Lock,
Moon,
Plus,
PlusCircle,
@@ -118,6 +119,7 @@ impl IconNamed for IconName {
Self::InboxFill => "icons/inbox-fill.svg",
Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg",
Self::Lock => "icons/lock.svg",
Self::Moon => "icons/moon.svg",
Self::Plus => "icons/plus.svg",
Self::PlusCircle => "icons/plus-circle.svg",
+1 -1
View File
@@ -1,4 +1,4 @@
use std::time::Duration;
use instant::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
+1 -1
View File
@@ -1,4 +1,4 @@
use std::time::Duration;
use instant::Duration;
use gpui::{Context, Pixels, Task, px};
+36 -197
View File
@@ -1,202 +1,61 @@
/// DisplayMap: Public facade for Editor/Input display mapping.
///
/// This combines WrapMap and FoldMap to provide a unified API:
/// - BufferPoint ↔ DisplayPoint conversion
/// - Fold management (candidates, toggle, query)
/// - Automatic projection updates on text/layout changes
use std::ops::Range;
use gpui::{App, Font, Pixels};
use ropey::Rope;
use super::fold_map::FoldMap;
use super::folding::FoldRange;
use super::text_wrapper::{LineItem, WrapDisplayPoint};
use super::wrap_map::WrapMap;
use super::{BufferPoint, DisplayPoint};
use crate::input::Point as TreeSitterPoint;
use crate::input::display_map::WrapPoint;
use crate::input::rope_ext::RopeExt as _;
/// DisplayMap is the main interface for Editor/Input coordinate mapping.
///
/// It manages the two-layer projection:
/// 1. Buffer → Wrap (soft-wrapping)
/// 2. Wrap → Display (folding)
///
/// Editor/Input only needs to work with BufferPoint and DisplayPoint.
/// DisplayMap is the main interface for Input coordinate mapping.
pub struct DisplayMap {
wrap_map: WrapMap,
fold_map: FoldMap,
}
impl DisplayMap {
pub fn new(font: Font, font_size: Pixels, wrap_width: Option<Pixels>) -> Self {
Self {
wrap_map: WrapMap::new(font, font_size, wrap_width),
fold_map: FoldMap::new(),
}
}
// ==================== Core Coordinate Mapping ====================
/// Convert buffer position to display position
pub fn buffer_pos_to_display_pos(&self, pos: BufferPoint) -> DisplayPoint {
// Buffer → Wrap
let wrap_pos = self.wrap_map.buffer_pos_to_wrap_pos(pos);
// Wrap → Display
if let Some(display_row) = self.fold_map.wrap_row_to_display_row(wrap_pos.row) {
DisplayPoint::new(display_row, wrap_pos.col)
} else {
// Cursor is in a folded region, find nearest visible row
let display_row = self.fold_map.nearest_visible_display_row(wrap_pos.row);
DisplayPoint::new(display_row, 0) // Column 0 at fold boundary
}
}
/// Convert display position to buffer position
pub fn display_pos_to_buffer_pos(&self, pos: DisplayPoint) -> BufferPoint {
// Display → Wrap
let wrap_row = self.fold_map.display_row_to_wrap_row(pos.row).unwrap_or(0);
// Wrap → Buffer
let wrap_pos = WrapPoint::new(wrap_row, pos.col);
self.wrap_map.wrap_pos_to_buffer_pos(wrap_pos)
}
/// Get total number of visible display rows
/// Get total number of display rows (same as wrap rows without folding)
#[inline]
pub fn display_row_count(&self) -> usize {
self.fold_map.display_row_count()
self.wrap_map.wrap_row_count()
}
/// Get the buffer line for a given display row
pub fn display_row_to_buffer_line(&self, display_row: usize) -> usize {
// Display → Wrap
let wrap_row = self
.fold_map
.display_row_to_wrap_row(display_row)
.unwrap_or(0);
// Wrap → Buffer line
self.wrap_map.wrap_row_to_buffer_line(wrap_row)
self.wrap_map.wrap_row_to_buffer_line(display_row)
}
/// Get the display row range for a buffer line: [start, end)
/// Returns None if the buffer line is completely hidden
pub fn buffer_line_to_display_row_range(&self, line: usize) -> Option<Range<usize>> {
// Buffer line → Wrap row range
let wrap_row_range = self.wrap_map.buffer_line_to_wrap_row_range(line);
// Find first and last visible display rows in this range
let mut first_display_row = None;
let mut last_display_row = None;
for wrap_row in wrap_row_range {
if let Some(display_row) = self.fold_map.wrap_row_to_display_row(wrap_row) {
if first_display_row.is_none() {
first_display_row = Some(display_row);
}
last_display_row = Some(display_row);
}
}
if let (Some(start), Some(end)) = (first_display_row, last_display_row) {
Some(start..end + 1)
} else {
None // Completely folded
}
let range = self.wrap_map.buffer_line_to_wrap_row_range(line);
if range.is_empty() { None } else { Some(range) }
}
/// Check if a buffer line is completely hidden
/// Check if a buffer line is completely hidden (never true without folding)
#[inline]
pub fn is_buffer_line_hidden(&self, line: usize) -> bool {
self.buffer_line_to_display_row_range(line).is_none()
pub fn is_buffer_line_hidden(&self, _line: usize) -> bool {
false
}
/// Set fold candidates (from tree-sitter/LSP)
pub fn set_fold_candidates(&mut self, candidates: Vec<FoldRange>) {
self.fold_map.set_candidates(candidates);
self.rebuild_fold_projection();
}
/// Set a fold at the given start_line (must be in candidates)
pub fn set_folded(&mut self, start_line: usize, folded: bool) {
self.fold_map.set_folded(start_line, folded);
self.rebuild_fold_projection();
}
/// Toggle fold at the given start_line
pub fn toggle_fold(&mut self, start_line: usize) {
self.fold_map.toggle_fold(start_line);
self.rebuild_fold_projection();
}
/// Check if a line is currently folded
/// All wrap rows are visible since there's no folding.
#[inline]
pub fn is_folded_at(&self, start_line: usize) -> bool {
self.fold_map.is_folded_at(start_line)
pub fn folded_ranges(&self) -> &[()] {
&[]
}
/// Check if a line is a fold candidate
#[inline]
pub fn is_fold_candidate(&self, start_line: usize) -> bool {
self.fold_map.is_fold_candidate(start_line)
}
/// Get all currently folded ranges
#[inline]
pub fn folded_ranges(&self) -> &[FoldRange] {
self.fold_map.folded_ranges()
}
/// Clear all folds
pub fn clear_folds(&mut self) {
self.fold_map.clear_folds();
self.rebuild_fold_projection();
}
// ==================== Text and Layout Updates ====================
/// Adjust folds and candidates for a text edit before updating the wrap map.
///
/// Must be called with the OLD text (before replacement) and the edit range/new_text
/// so we can compute which old lines were affected.
pub fn adjust_folds_for_edit(&mut self, old_text: &Rope, range: &Range<usize>, new_text: &str) {
if self.fold_map.folded_ranges().is_empty() && self.fold_map.fold_candidates().is_empty() {
return;
}
let edit_start_line = old_text.offset_to_point(range.start).row;
let edit_end_line = old_text.offset_to_point(range.end.min(old_text.len())).row;
let old_lines_in_range = edit_end_line.saturating_sub(edit_start_line);
let new_lines_in_range = new_text.chars().filter(|c| *c == '\n').count();
let line_delta = new_lines_in_range as isize - old_lines_in_range as isize;
self.fold_map
.adjust_folds_for_edit(edit_start_line, edit_end_line, line_delta);
}
/// Incrementally update fold candidates after a text edit.
///
/// Extracts new fold candidates only within the edited byte range
/// and merges them with existing (already adjusted) candidates.
pub fn update_fold_candidates_for_edit(
/// Adjust folds for edit (no-op without folding)
pub fn adjust_folds_for_edit(
&mut self,
tree: &super::folding::Tree,
edit_byte_range: Range<usize>,
new_text: &Rope,
_old_text: &Rope,
_range: &Range<usize>,
_new_text: &str,
) {
let new_start_line = new_text.offset_to_point(edit_byte_range.start).row;
let new_end_line = new_text
.offset_to_point(edit_byte_range.end.min(new_text.len()))
.row;
let new_candidates = super::folding::extract_fold_ranges_in_range(tree, edit_byte_range);
self.fold_map
.merge_candidates_for_edit(new_start_line, new_end_line, new_candidates);
// No-op: no folding
}
/// Update text (incremental or full)
@@ -209,52 +68,28 @@ impl DisplayMap {
) {
self.wrap_map
.on_text_changed(changed_text, range, new_text, cx);
self.rebuild_fold_projection();
}
/// Update layout parameters (wrap width or font)
pub fn on_layout_changed(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
self.wrap_map.on_layout_changed(wrap_width, cx);
self.rebuild_fold_projection();
}
/// Set font parameters
pub fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) {
self.wrap_map.set_font(font, font_size, cx);
self.rebuild_fold_projection();
}
/// Ensure text is prepared (initializes wrapper if needed)
pub fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) {
let did_initialize = self.wrap_map.ensure_text_prepared(text, cx);
if did_initialize {
self.rebuild_fold_projection();
}
self.wrap_map.ensure_text_prepared(text, cx);
}
/// Initialize with text
pub fn set_text(&mut self, text: &Rope, cx: &mut App) {
self.wrap_map.set_text(text, cx);
self.rebuild_fold_projection();
}
// ==================== Internal Helpers ====================
/// Rebuild fold projection after wrap_map or fold state changes
/// Only rebuilds if there are actually folded ranges
fn rebuild_fold_projection(&mut self) {
if !self.fold_map.folded_ranges().is_empty() {
self.fold_map.rebuild(&self.wrap_map);
} else {
// No active folds: identity mapping (wrap_row == display_row).
// Just update cached count so query methods work without Vec allocation.
self.fold_map
.mark_dirty_with_wrap_count(self.wrap_map.wrap_row_count());
}
}
// ==================== Wrap Display Point Operations ====================
/// Convert byte offset to wrap display point (with soft wrap info).
#[inline]
pub(crate) fn offset_to_wrap_display_point(&self, offset: usize) -> WrapDisplayPoint {
@@ -273,23 +108,30 @@ impl DisplayMap {
self.wrap_map.wrapper().display_point_to_point(point)
}
/// Convert a wrap row to a display row (skipping folded rows).
/// Returns None if the wrap row is folded.
/// Since there's no folding, wrap row == display row.
#[inline]
pub fn wrap_row_to_display_row(&self, wrap_row: usize) -> Option<usize> {
self.fold_map.wrap_row_to_display_row(wrap_row)
if wrap_row < self.wrap_row_count() {
Some(wrap_row)
} else {
None
}
}
/// Find the nearest visible display row for a given wrap row.
/// Since there's no folding, nearest visible row is the row itself.
#[inline]
pub fn nearest_visible_display_row(&self, wrap_row: usize) -> usize {
self.fold_map.nearest_visible_display_row(wrap_row)
wrap_row.min(self.wrap_row_count().saturating_sub(1))
}
/// Convert a display row to a wrap row.
/// Since there's no folding, display row == wrap row.
#[inline]
pub fn display_row_to_wrap_row(&self, display_row: usize) -> Option<usize> {
self.fold_map.display_row_to_wrap_row(display_row)
if display_row < self.wrap_row_count() {
Some(display_row)
} else {
None
}
}
/// Get the longest row index (by byte length).
@@ -298,8 +140,6 @@ impl DisplayMap {
self.wrap_map.wrapper().longest_row.row
}
// ==================== Access Methods ====================
/// Get access to line items (for rendering)
#[inline]
pub(crate) fn lines(&self) -> &[LineItem] {
@@ -312,14 +152,13 @@ impl DisplayMap {
self.wrap_map.text()
}
/// Calculate how many wrap rows of a buffer line are visible (not folded)
/// Calculate how many wrap rows of a buffer line are visible
#[inline]
pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize {
self.wrap_map
.visible_wrap_row_count_for_line(line, &self.fold_map)
self.wrap_map.visible_wrap_row_count_for_buffer_line(line)
}
/// Get the wrap row count (before folding)
/// Get the wrap row count
#[inline]
pub fn wrap_row_count(&self) -> usize {
self.wrap_map.wrap_row_count()
-343
View File
@@ -1,343 +0,0 @@
/// FoldMap: Folding projection layer (Wrap rows → Display rows).
///
/// This module manages code folding by:
/// - Filtering out wrap rows that belong to folded regions
/// - Maintaining bidirectional mapping: wrap_row ↔ display_row
/// - Handling fold state changes and rebuilding the projection
use super::folding::FoldRange;
use super::wrap_map::WrapMap;
/// FoldMap projects wrap rows to display rows by hiding folded regions.
pub struct FoldMap {
/// Mapping: display_row → wrap_row
/// index = display_row, value = actual wrap_row
visible_wrap_rows: Vec<usize>,
/// Reverse mapping: wrap_row → display_row
/// index = wrap_row, value = Some(display_row) if visible, None if folded
wrap_row_to_display_row: Vec<Option<usize>>,
/// Candidate fold ranges (from tree-sitter/LSP)
/// Sorted by start_line, unique start_line
candidates: Vec<FoldRange>,
/// Currently folded ranges
/// Subset of candidates, sorted by start_line
folded: Vec<FoldRange>,
/// Flag indicating if the fold projection needs rebuilding
/// Used for lazy evaluation to avoid expensive rebuilds on every text change
needs_rebuild: bool,
/// Cached wrap_row_count from last rebuild
/// Used to detect if WrapMap changed and rebuild is needed
cached_wrap_row_count: usize,
}
impl FoldMap {
pub fn new() -> Self {
Self {
visible_wrap_rows: Vec::new(),
wrap_row_to_display_row: Vec::new(),
candidates: Vec::new(),
folded: Vec::new(),
needs_rebuild: true,
cached_wrap_row_count: 0,
}
}
/// Update cached wrap_row_count without full rebuild.
/// Used when no folds are active (identity mapping assumed).
pub(super) fn mark_dirty_with_wrap_count(&mut self, wrap_row_count: usize) {
self.needs_rebuild = true;
self.cached_wrap_row_count = wrap_row_count;
}
/// Get total number of visible display rows
pub fn display_row_count(&self) -> usize {
if self.folded.is_empty() {
return self.cached_wrap_row_count;
}
self.visible_wrap_rows.len()
}
/// Convert wrap_row to display_row
/// Returns None if the wrap_row is hidden by folding
pub fn wrap_row_to_display_row(&self, wrap_row: usize) -> Option<usize> {
if self.folded.is_empty() {
return if wrap_row < self.cached_wrap_row_count {
Some(wrap_row)
} else {
None
};
}
self.wrap_row_to_display_row
.get(wrap_row)
.copied()
.flatten()
}
/// Convert display_row to wrap_row
pub fn display_row_to_wrap_row(&self, display_row: usize) -> Option<usize> {
if self.folded.is_empty() {
return if display_row < self.cached_wrap_row_count {
Some(display_row)
} else {
None
};
}
self.visible_wrap_rows.get(display_row).copied()
}
/// Find the nearest visible display_row for a given wrap_row
pub fn nearest_visible_display_row(&self, wrap_row: usize) -> usize {
if self.folded.is_empty() {
return wrap_row.min(self.cached_wrap_row_count.saturating_sub(1));
}
if let Some(dr) = self.wrap_row_to_display_row(wrap_row) {
return dr;
}
match self.visible_wrap_rows.binary_search(&wrap_row) {
Ok(idx) => idx,
Err(insert_pos) => insert_pos.saturating_sub(1),
}
}
/// Set fold candidates (from tree-sitter/LSP), full replacement.
pub fn set_candidates(&mut self, mut candidates: Vec<FoldRange>) {
// Sort and deduplicate by start_line
candidates.sort_by_key(|r| r.start_line);
candidates.dedup_by_key(|r| r.start_line);
self.candidates = candidates;
// Remove any folded ranges that are no longer in candidates
self.folded.retain(|fold| {
self.candidates
.iter()
.any(|c| c.start_line == fold.start_line)
});
}
/// Merge new candidates extracted from an edited region into existing candidates.
///
/// Replaces candidates within [edit_start_line, edit_end_line] with `new_candidates`,
/// keeping candidates outside the edit range intact.
pub fn merge_candidates_for_edit(
&mut self,
edit_start_line: usize,
edit_end_line: usize,
new_candidates: Vec<FoldRange>,
) {
// Remove old candidates within the edit range (already done by adjust_folds_for_edit)
// But do it again in case adjust wasn't called or range differs
self.candidates
.retain(|c| c.start_line < edit_start_line || c.start_line > edit_end_line);
// Add new candidates
self.candidates.extend(new_candidates);
self.candidates.sort_by_key(|r| r.start_line);
self.candidates.dedup_by_key(|r| r.start_line);
}
/// Set a fold at the given start_line (must be in candidates)
pub fn set_folded(&mut self, start_line: usize, folded: bool) {
if folded {
// Find the candidate range for this start_line
if let Some(candidate) = self.candidates.iter().find(|c| c.start_line == start_line) {
// Add to folded if not already present
if !self.folded.iter().any(|f| f.start_line == start_line) {
self.folded.push(*candidate);
self.folded.sort_by_key(|r| r.start_line);
self.needs_rebuild = true;
}
}
} else {
// Remove from folded
self.folded.retain(|f| f.start_line != start_line);
self.needs_rebuild = true;
}
}
/// Toggle fold at the given start_line
pub fn toggle_fold(&mut self, start_line: usize) {
let is_folded = self.is_folded_at(start_line);
self.set_folded(start_line, !is_folded);
}
/// Check if a line is currently folded
pub fn is_folded_at(&self, start_line: usize) -> bool {
self.folded.iter().any(|f| f.start_line == start_line)
}
/// Check if a line is a fold candidate
pub fn is_fold_candidate(&self, start_line: usize) -> bool {
self.candidates.iter().any(|c| c.start_line == start_line)
}
/// Get all fold candidates
#[inline]
pub fn fold_candidates(&self) -> &[FoldRange] {
&self.candidates
}
/// Get all currently folded ranges
#[inline]
pub fn folded_ranges(&self) -> &[FoldRange] {
&self.folded
}
/// Clear all folds
#[inline]
pub fn clear_folds(&mut self) {
self.folded.clear();
}
/// Adjust folds and candidates after a text edit.
///
/// - Folds/candidates overlapping the edited line range are removed
/// - Folds/candidates after the edit are shifted by line_delta
///
/// This avoids expensive full tree traversal on every keystroke.
pub fn adjust_folds_for_edit(
&mut self,
edit_start_line: usize,
edit_end_line: usize,
line_delta: isize,
) {
// Adjust folded ranges
if !self.folded.is_empty() {
self.folded.retain(|fold| {
!(fold.start_line <= edit_end_line && fold.end_line >= edit_start_line)
});
if line_delta != 0 {
for fold in &mut self.folded {
if fold.start_line > edit_end_line {
fold.start_line = (fold.start_line as isize + line_delta).max(0) as usize;
fold.end_line = (fold.end_line as isize + line_delta).max(0) as usize;
}
}
}
}
// Adjust candidates the same way
if !self.candidates.is_empty() {
self.candidates
.retain(|c| !(c.start_line <= edit_end_line && c.end_line >= edit_start_line));
if line_delta != 0 {
for c in &mut self.candidates {
if c.start_line > edit_end_line {
c.start_line = (c.start_line as isize + line_delta).max(0) as usize;
c.end_line = (c.end_line as isize + line_delta).max(0) as usize;
}
}
}
}
self.needs_rebuild = true;
}
/// Rebuild the fold mapping after wrap_map or fold state changes
///
/// This is the core algorithm that projects wrap rows to display rows.
pub fn rebuild(&mut self, wrap_map: &WrapMap) {
let wrap_row_count = wrap_map.wrap_row_count();
// Performance optimization: skip rebuild if nothing changed
if !self.needs_rebuild && wrap_row_count == self.cached_wrap_row_count {
return;
}
self.cached_wrap_row_count = wrap_row_count;
self.visible_wrap_rows.clear();
self.wrap_row_to_display_row = vec![None; wrap_row_count];
if self.folded.is_empty() {
// Fast path: no folds, all wrap rows are visible
self.visible_wrap_rows = (0..wrap_row_count).collect();
for (display_row, &wrap_row) in self.visible_wrap_rows.iter().enumerate() {
self.wrap_row_to_display_row[wrap_row] = Some(display_row);
}
self.needs_rebuild = false;
return;
}
// Build set of hidden wrap_row ranges from folded buffer lines
let mut hidden_ranges = Vec::new();
for fold in &self.folded {
// Hide wrap rows from (start_line + 1) to (end_line - 1) (inclusive)
// Both the first line and last line of the fold remain visible
let hide_start_line = fold.start_line + 1;
let hide_end_line = fold.end_line.saturating_sub(1);
if hide_start_line > hide_end_line {
continue; // No middle lines to hide (0 or 1 lines between start and end)
}
// Get wrap_row ranges for the hidden buffer lines
let start_wrap_row = wrap_map.buffer_line_to_first_wrap_row(hide_start_line);
let end_wrap_row = if hide_end_line + 1 < wrap_map.buffer_line_count() {
wrap_map.buffer_line_to_first_wrap_row(hide_end_line + 1)
} else {
wrap_row_count
};
if start_wrap_row < end_wrap_row {
hidden_ranges.push(start_wrap_row..end_wrap_row);
}
}
// Merge overlapping hidden ranges
hidden_ranges.sort_by_key(|r| r.start);
let mut merged_hidden = Vec::new();
for range in hidden_ranges {
if let Some(last) = merged_hidden.last_mut() {
if range.start <= *last {
// Overlapping or adjacent, merge
*last = (*last).max(range.end);
} else {
merged_hidden.push(range.start);
merged_hidden.push(range.end);
}
} else {
merged_hidden.push(range.start);
merged_hidden.push(range.end);
}
}
// Scan all wrap rows and filter out hidden ones
let mut display_row = 0;
let mut hidden_iter = merged_hidden.chunks_exact(2);
let mut current_hidden = hidden_iter.next();
for wrap_row in 0..wrap_row_count {
// Check if wrap_row is in current hidden range
let is_hidden = if let Some(&[start, end]) = current_hidden {
if wrap_row >= end {
current_hidden = hidden_iter.next();
if let Some(&[new_start, new_end]) = current_hidden {
wrap_row >= new_start && wrap_row < new_end
} else {
false
}
} else {
wrap_row >= start && wrap_row < end
}
} else {
false
};
if !is_hidden {
self.visible_wrap_rows.push(wrap_row);
self.wrap_row_to_display_row[wrap_row] = Some(display_row);
display_row += 1;
}
}
self.needs_rebuild = false;
}
}
@@ -1,96 +0,0 @@
use std::ops::Range;
#[cfg(not(target_family = "wasm"))]
use tree_sitter::Node;
#[cfg(not(target_family = "wasm"))]
pub use tree_sitter::Tree;
#[cfg(target_family = "wasm")]
/// Stub type for tree-sitter Tree on WASM (tree-sitter not available).
pub struct Tree;
#[cfg(not(target_family = "wasm"))]
/// Minimum line span for a node to be considered foldable.
const MIN_FOLD_LINES: usize = 2;
/// A fold range representing a foldable code region.
///
/// The fold range spans from start_line to end_line (inclusive).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FoldRange {
/// Start line (inclusive)
pub start_line: usize,
/// End line (inclusive)
pub end_line: usize,
}
impl FoldRange {
pub fn new(start_line: usize, end_line: usize) -> Self {
assert!(
start_line <= end_line,
"fold start_line must be <= end_line"
);
Self {
start_line,
end_line,
}
}
}
#[cfg(not(target_family = "wasm"))]
/// Check if a named node qualifies as a fold candidate.
///
/// Uses a structural heuristic: any **named** node spanning ≥ MIN_FOLD_LINES
/// is foldable. tree-sitter already parses code into semantic units (functions,
/// classes, blocks, etc.), so named nodes naturally correspond to meaningful
/// foldable regions across all languages without a per-language node-type list.
fn is_foldable_node(node: &Node) -> bool {
let start = node.start_position().row;
let end = node.end_position().row;
end.saturating_sub(start) >= MIN_FOLD_LINES
}
#[cfg(not(target_family = "wasm"))]
/// Extract fold ranges only within a byte range (for incremental updates after edits).
///
/// Skips subtrees entirely outside the range, making it O(nodes in range)
/// instead of O(all nodes in tree).
pub fn extract_fold_ranges_in_range(tree: &Tree, byte_range: Range<usize>) -> Vec<FoldRange> {
let mut ranges = Vec::new();
let root = tree.root_node();
let mut cursor = root.walk();
// Skip the root, it's not foldable. Use named_children to skip literal tokens.
for child in root.named_children(&mut cursor) {
collect_foldable_nodes_in_range(child, &byte_range, &mut ranges);
}
ranges.sort_by_key(|r| r.start_line);
ranges.dedup_by_key(|r| r.start_line);
ranges
}
#[cfg(not(target_family = "wasm"))]
/// Recursively collect foldable nodes, skipping subtrees outside byte_range.
fn collect_foldable_nodes_in_range(
node: Node,
byte_range: &Range<usize>,
ranges: &mut Vec<FoldRange>,
) {
if node.end_byte() <= byte_range.start || node.start_byte() >= byte_range.end {
return;
}
if !is_foldable_node(&node) {
return;
}
ranges.push(FoldRange {
start_line: node.start_position().row,
end_line: node.end_position().row,
});
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
collect_foldable_nodes_in_range(child, byte_range, ranges);
}
}
-54
View File
@@ -1,61 +1,7 @@
#[allow(clippy::module_inception)]
mod display_map;
mod fold_map;
#[cfg(not(target_family = "wasm"))]
mod folding;
#[cfg(target_family = "wasm")]
pub mod folding;
mod text_wrapper;
mod wrap_map;
// Re-export public API
// Re-export FoldRange and extract_fold_ranges
pub use folding::FoldRange;
pub use self::display_map::DisplayMap;
pub(crate) use self::text_wrapper::LineLayout;
/// Position in the buffer (logical text).
///
/// - `line`: 0-based logical line number (split by `\n`)
/// - `col`: 0-based column offset (byte offset)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct BufferPoint {
pub line: usize,
pub col: usize,
}
impl BufferPoint {
pub fn new(line: usize, col: usize) -> Self {
Self { line, col }
}
}
/// Position after soft-wrapping but before folding (internal).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(super) struct WrapPoint {
pub row: usize,
pub col: usize,
}
impl WrapPoint {
pub fn new(row: usize, col: usize) -> Self {
Self { row, col }
}
}
/// Final display position (after soft-wrapping and folding).
///
/// - `row`: 0-based display row (final visible row)
/// - `col`: 0-based display column
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct DisplayPoint {
pub row: usize,
pub col: usize,
}
impl DisplayPoint {
pub fn new(row: usize, col: usize) -> Self {
Self { row, col }
}
}
+5 -55
View File
@@ -9,10 +9,7 @@ use std::ops::Range;
use gpui::{App, Font, Pixels};
use ropey::Rope;
use super::fold_map::FoldMap;
use super::text_wrapper::{LineItem, TextWrapper, WrapDisplayPoint};
use super::{BufferPoint, WrapPoint};
use crate::input::rope_ext::RopeExt;
use super::text_wrapper::{LineItem, TextWrapper};
/// WrapMap manages soft-wrapping and provides buffer ↔ wrap coordinate mapping.
pub struct WrapMap {
@@ -55,49 +52,6 @@ impl WrapMap {
self.wrapper.lines.len()
}
/// Convert buffer position to wrap position
pub(super) fn buffer_pos_to_wrap_pos(&self, pos: BufferPoint) -> WrapPoint {
let BufferPoint { line, col } = pos;
// Clamp to valid range
let line = line.min(self.buffer_line_count().saturating_sub(1));
let line_item = self.wrapper.lines.get(line);
let col = if let Some(line_item) = line_item {
col.min(line_item.len())
} else {
0
};
// Calculate offset in rope
let line_start_offset = self.wrapper.text().line_start_offset(line);
let offset = line_start_offset + col;
// Use TextWrapper's existing conversion
let display_point = self.wrapper.offset_to_display_point(offset);
WrapPoint::new(display_point.row, display_point.column)
}
/// Convert wrap position to buffer position
pub(super) fn wrap_pos_to_buffer_pos(&self, pos: WrapPoint) -> BufferPoint {
let WrapPoint { row, col } = pos;
// Clamp wrap_row to valid range
let row = row.min(self.wrap_row_count().saturating_sub(1));
// Use TextWrapper's existing conversion
let display_point = WrapDisplayPoint::new(row, 0, col);
let offset = self.wrapper.display_point_to_offset(display_point);
// Convert offset to buffer position
let point = self.wrapper.text().offset_to_point(offset);
let line_start = self.wrapper.text().line_start_offset(point.row);
let col = offset.saturating_sub(line_start);
BufferPoint::new(point.row, col)
}
/// Get the buffer line for a given wrap row
pub fn wrap_row_to_buffer_line(&self, wrap_row: usize) -> usize {
if wrap_row >= self.wrap_row_count() {
@@ -176,8 +130,6 @@ impl WrapMap {
let wrap_row_count = self.wrapper.len();
// Skip if nothing changed: both buffer line count and total wrap row count must match.
// Checking wrap_row_count is essential because soft-wrap can change the number of
// wrap rows per line without changing the buffer line count.
if line_count == self.cached_line_count
&& wrap_row_count == self.cached_wrap_row_count
&& !self.buffer_line_starts.is_empty()
@@ -212,11 +164,9 @@ impl WrapMap {
self.wrapper.text()
}
/// Calculate how many wrap rows of a buffer line are visible (not folded)
pub fn visible_wrap_row_count_for_line(&self, line: usize, fold_map: &FoldMap) -> usize {
let wrap_range = self.buffer_line_to_wrap_row_range(line);
wrap_range
.filter(|&wr| fold_map.wrap_row_to_display_row(wr).is_some())
.count()
/// Calculate how many wrap rows of a buffer line are visible.
/// Without folding, all wrap rows are visible.
pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize {
self.buffer_line_to_wrap_row_range(line).len()
}
}
+27 -681
View File
@@ -3,9 +3,8 @@ use std::rc::Rc;
use gpui::{
AnyElement, App, Bounds, Corners, Edges, Element, ElementId, ElementInputHandler, Entity,
GlobalElementId, Half, HighlightStyle, Hitbox, HitboxBehavior, Hsla, InteractiveElement,
IntoElement, LayoutId, MouseButton, MouseMoveEvent, MouseUpEvent, Path, Pixels, Point,
Position, ShapedLine, SharedString, Size, Style, Styled as _, TextAlign, TextRun, TextStyle,
GlobalElementId, Half, Hsla, IntoElement, LayoutId, MouseButton, MouseMoveEvent, MouseUpEvent,
Path, Pixels, Point, Position, SharedString, Size, Style, TextAlign, TextRun, TextStyle,
UnderlineStyle, Window, fill, point, px, relative, size,
};
use ropey::Rope;
@@ -14,19 +13,14 @@ use theme::ActiveTheme;
use super::mode::InputMode;
use super::{InputState, LastLayout, WhitespaceIndicators};
use crate::button::{Button, ButtonVariants as _};
use crate::Root;
use crate::input::RopeExt as _;
use crate::input::blink_cursor::CURSOR_WIDTH;
use crate::input::display_map::LineLayout;
use crate::scroll::Scrollbar;
use crate::{IconName, Root, Selectable, Sizable as _};
const BOTTOM_MARGIN_ROWS: usize = 3;
pub(super) const RIGHT_MARGIN: Pixels = px(10.);
pub(super) const LINE_NUMBER_RIGHT_MARGIN: Pixels = px(10.);
const FOLD_ICON_WIDTH: Pixels = px(14.);
const FOLD_ICON_HITBOX_WIDTH: Pixels = px(18.);
const MAX_HIGHLIGHT_LINE_LENGTH: usize = 10_000;
#[derive(Clone, Copy, Debug, PartialEq)]
struct EditorScrollbarLayout {
@@ -72,7 +66,7 @@ impl EditorScrollbarLayout {
let left = if line_number_width == px(0.) {
px(0.)
} else {
paddings.left + line_number_width - LINE_NUMBER_RIGHT_MARGIN
paddings.left + line_number_width
};
Self {
@@ -216,14 +210,6 @@ fn masked_display_offset(text: &Rope, original_offset: usize) -> usize {
text.offset_to_char_index(original_offset) * MASK_CHAR.len_utf8()
}
/// Layout information for fold icons.
struct FoldIconLayout {
/// Hitbox for the line number area (used for hover detection)
line_number_hitbox: Hitbox,
/// List of (display_row, is_folded, icon_element) pairs for each fold candidate
icons: Vec<(usize, bool, gpui::AnyElement)>,
}
pub(super) struct TextElement {
pub(crate) state: Entity<InputState>,
placeholder: SharedString,
@@ -285,7 +271,7 @@ impl TextElement {
scroll_size: Size<Pixels>,
_: &mut Window,
cx: &mut App,
) -> (Option<Bounds<Pixels>>, Point<Pixels>, Option<usize>) {
) -> (Option<Bounds<Pixels>>, Point<Pixels>) {
let state = self.state.read(cx);
let line_height = last_layout.line_height;
@@ -307,7 +293,6 @@ impl TextElement {
cursor = masked_display_offset(&state.text, cursor);
}
let mut current_row = None;
let mut scroll_offset = state.scroll_handle.offset();
let mut cursor_bounds = None;
@@ -330,7 +315,6 @@ impl TextElement {
let visible_buffer_lines = &last_layout.visible_buffer_lines;
let mut vi = 0; // index into visible_buffer_lines / lines
for (ix, wrap_line) in buffer_lines.iter().enumerate() {
let row = ix;
let line_origin = point(px(0.), offset_y);
// break loop if all cursor positions are found
@@ -353,7 +337,6 @@ impl TextElement {
if let Some(pos) =
line.position_for_index(offset, last_layout, state.cursor_line_end_affinity)
{
current_row = Some(row);
cursor_pos = Some(line_origin + pos);
}
}
@@ -377,7 +360,6 @@ impl TextElement {
// Not visible (before visible range or hidden/folded).
// Just increase the offset_y and prev_lines_offset for scroll tracking.
if prev_lines_offset >= cursor && cursor_pos.is_none() {
current_row = Some(row);
cursor_pos = Some(line_origin);
}
if prev_lines_offset >= selected_range.start && cursor_start.is_none() {
@@ -494,7 +476,7 @@ impl TextElement {
bounds.origin += scroll_offset;
(cursor_bounds, scroll_offset, current_row)
(cursor_bounds, scroll_offset)
}
/// Layout the match range to a Path.
@@ -642,41 +624,6 @@ impl TextElement {
builder.build().ok()
}
fn layout_search_matches(
&self,
_last_layout: &LastLayout,
_bounds: &Bounds<Pixels>,
_cx: &mut App,
) -> Vec<(Path<Pixels>, bool)> {
vec![]
}
fn layout_hover_highlight(
&self,
_last_layout: &LastLayout,
_bounds: &Bounds<Pixels>,
_cx: &mut App,
) -> Option<Path<Pixels>> {
None
}
fn layout_document_colors(
&self,
document_colors: &[(Range<usize>, Hsla)],
last_layout: &LastLayout,
bounds: &Bounds<Pixels>,
_cx: &mut App,
) -> Vec<(Path<Pixels>, Hsla)> {
let mut paths = vec![];
for (range, color) in document_colors.iter() {
if let Some(path) = Self::layout_match_range(range.clone(), last_layout, bounds) {
paths.push((path, *color));
}
}
paths
}
fn layout_selections(
&self,
last_layout: &LastLayout,
@@ -784,267 +731,20 @@ impl TextElement {
(visible_range, visible_buffer_lines, visible_top)
}
/// Return (line_number_width, line_number_len)
fn layout_line_numbers(
state: &InputState,
text: &Rope,
font_size: Pixels,
style: &TextStyle,
window: &mut Window,
) -> (Pixels, usize) {
let total_lines = text.lines_len();
let line_number_len = match total_lines {
0..=9999 => 5,
10000..=99999 => 6,
100000..=999999 => 7,
_ => 8,
};
let mut line_number_width = if state.mode.line_number() {
let empty_line_number = window.text_system().shape_line(
"+".repeat(line_number_len).into(),
font_size,
&[TextRun {
len: line_number_len,
font: style.font(),
color: gpui::black(),
background_color: None,
underline: None,
strikethrough: None,
}],
None,
);
empty_line_number.width + LINE_NUMBER_RIGHT_MARGIN
} else if state.mode.is_code_editor() && state.mode.is_multi_line() {
LINE_NUMBER_RIGHT_MARGIN
} else {
px(0.)
};
if state.mode.is_folding() {
// Add extra space for fold icons
line_number_width += FOLD_ICON_HITBOX_WIDTH
}
(line_number_width, line_number_len)
}
/// Layout shaped lines for whitespace indicators (space and tab).
///
/// Returns `WhitespaceIndicators` with shaped lines for space and tab characters.
fn layout_whitespace_indicators(
state: &InputState,
_state: &InputState,
text_size: Pixels,
style: &TextStyle,
window: &mut Window,
cx: &App,
) -> Option<WhitespaceIndicators> {
if !state.show_whitespaces {
return None;
}
let invisible_color = cx.theme().text_muted;
let space_font_size = text_size.half();
let tab_font_size = text_size;
let space_text = SharedString::new_static("");
let space = window.text_system().shape_line(
space_text.clone(),
space_font_size,
&[TextRun {
len: space_text.len(),
font: style.font(),
color: invisible_color,
background_color: None,
underline: None,
strikethrough: None,
}],
None,
);
let tab_text = SharedString::new_static("");
let tab = window.text_system().shape_line(
tab_text.clone(),
tab_font_size,
&[TextRun {
len: tab_text.len(),
font: style.font(),
color: invisible_color,
background_color: None,
underline: None,
strikethrough: None,
}],
None,
);
Some(WhitespaceIndicators { space, tab })
}
/// Compute inline completion ghost lines for rendering.
///
/// Returns (first_line, ghost_lines) where:
/// - first_line: Shaped text for the first line (goes after cursor on same line)
/// - ghost_lines: Shaped lines for subsequent lines (shift content down)
fn layout_inline_completion(
_state: &InputState,
_visible_range: &Range<usize>,
_font_size: Pixels,
_window: &mut Window,
_cx: &App,
) -> (Option<ShapedLine>, Vec<ShapedLine>) {
(None, vec![])
}
/// Return (line_number_width, line_number_len)
/// Layout fold icon hitboxes during prepaint phase.
///
/// This creates hitboxes for the fold icon area, positioned to the right of line numbers.
/// Icons are created and prepainted here to avoid panics.
fn layout_fold_icons(
&self,
origin_x: Pixels,
bounds: &Bounds<Pixels>,
last_layout: &LastLayout,
window: &mut Window,
cx: &mut App,
) -> FoldIconLayout {
// First pass: collect fold information from state
struct FoldInfo {
buffer_line: usize,
is_folded: bool,
display_row: usize,
offset_y: Pixels,
}
let line_number_hitbox = window.insert_hitbox(
Bounds::new(
point(origin_x, bounds.origin.y + last_layout.visible_top),
size(last_layout.line_number_width, bounds.size.height),
),
HitboxBehavior::Normal,
);
let mut icon_layout = FoldIconLayout {
line_number_hitbox,
icons: vec![],
};
let fold_infos: Vec<FoldInfo> = {
let state = self.state.read(cx);
if !state.mode.is_folding() {
return icon_layout;
}
let mut infos = Vec::with_capacity(last_layout.visible_buffer_lines.len());
let mut offset_y = last_layout.visible_top;
for (line, &buffer_line) in last_layout
.lines
.iter()
.zip(last_layout.visible_buffer_lines.iter())
{
if state.display_map.is_fold_candidate(buffer_line) {
let is_folded = state.display_map.is_folded_at(buffer_line);
infos.push(FoldInfo {
buffer_line,
is_folded,
display_row: buffer_line,
offset_y,
});
}
offset_y += line.wrapped_lines.len() * last_layout.line_height;
}
infos
}; // state is dropped here
// Second pass: create and prepaint icons
let line_height = last_layout.line_height;
let line_number_width =
last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN - FOLD_ICON_HITBOX_WIDTH;
let icon_relative_pos = point(
(FOLD_ICON_HITBOX_WIDTH - FOLD_ICON_WIDTH).half(),
(line_height - FOLD_ICON_WIDTH).half(),
);
for (ix, info) in fold_infos.iter().enumerate() {
// Position fold icon to the right of line numbers.
// Use origin_x (unscrolled) so icons stay fixed in the gutter during horizontal scroll.
let fold_icon_bounds = Bounds::new(
point(
origin_x + icon_relative_pos.x + line_number_width,
bounds.origin.y + icon_relative_pos.y + info.offset_y,
),
size(FOLD_ICON_HITBOX_WIDTH, line_height),
);
// Create and prepaint icon
let mut icon = Button::new(("fold", ix))
.ghost()
.icon(if info.is_folded {
IconName::CaretRight
} else {
IconName::CaretDown
})
.xsmall()
.rounded_xs()
.size(FOLD_ICON_WIDTH)
.selected(info.is_folded)
.on_mouse_down(MouseButton::Left, {
let state = self.state.clone();
let buffer_line = info.buffer_line;
move |_, _: &mut Window, cx: &mut App| {
cx.stop_propagation();
state.update(cx, |state, cx| {
state.display_map.toggle_fold(buffer_line);
cx.notify();
});
}
})
.into_any_element();
icon.prepaint_as_root(
fold_icon_bounds.origin,
fold_icon_bounds.size.into(),
window,
cx,
);
icon_layout
.icons
.push((info.display_row, info.is_folded, icon));
}
icon_layout
}
/// Paint fold icons using prepaint hitboxes.
///
/// This handles:
/// - Rendering fold icons (chevron-right for folded, chevron-down for expanded)
/// - Mouse click handling to toggle fold state
/// - Cursor style changes on hover
/// - Only show icon on hover or for current line
fn paint_fold_icons(
&mut self,
fold_icon_layout: &mut FoldIconLayout,
current_row: Option<usize>,
window: &mut Window,
cx: &mut App,
) {
let is_hovered = fold_icon_layout.line_number_hitbox.is_hovered(window);
for (display_row, is_folded, icon) in fold_icon_layout.icons.iter_mut() {
let is_current_line = current_row == Some(*display_row);
if !is_hovered && !is_current_line && !*is_folded {
continue;
}
icon.paint(window, cx);
}
// Whitespace indicators are not currently enabled.
// When re-enabled, check `state.show_whitespaces` to conditionally enable.
let _ = (text_size, style, window, cx);
None
}
#[allow(clippy::too_many_arguments)]
@@ -1098,10 +798,6 @@ impl TextElement {
}
let mut lines = Vec::with_capacity(last_layout.visible_buffer_lines.len());
// run_offset tracks position in the runs vec coordinate space (only visible line bytes).
// This is separate from the visible_text offset because runs from highlight_lines
// only cover visible (non-folded) lines.
let mut run_offset = 0;
for (vi, &buffer_line) in last_layout.visible_buffer_lines.iter().enumerate() {
let line_text: String = display_text.slice_line(buffer_line).into();
@@ -1112,9 +808,10 @@ impl TextElement {
debug_assert_eq!(line_item.len(), line_text.len());
let mut wrapped_lines = SmallVec::with_capacity(1);
let line_offset = display_text.line_start_offset(buffer_line);
for range in &line_item.wrapped_lines {
let line_runs = runs_for_range(runs, run_offset, range);
let line_runs = runs_for_range(runs, line_offset, range);
let line_runs = if bg_segments.is_empty() {
line_runs
} else {
@@ -1137,107 +834,21 @@ impl TextElement {
.lines(wrapped_lines)
.with_whitespaces(whitespace_indicators.clone());
lines.push(line_layout);
// +1 for the `\n`
run_offset += line_text.len() + 1;
}
lines
}
/// First usize is the offset of skipped.
fn highlight_lines(
&mut self,
visible_buffer_lines: &[usize],
_visible_top: Pixels,
_visible_byte_range: Range<usize>,
cx: &mut App,
) -> Option<Vec<(Range<usize>, HighlightStyle)>> {
let state = self.state.read(cx);
let text = &state.text;
let is_multi_line = state.mode.is_multi_line();
let mut styles = Vec::with_capacity(visible_buffer_lines.len());
// Helper to flush a contiguous range of lines. These ranges are disjoint,
// so appending avoids repeatedly cloning and recombining prior styles.
let flush_range = |start_line: usize, end_line: usize, _skip: bool, styles: &mut Vec<_>| {
let byte_start = text.line_start_offset(start_line);
let byte_end = if is_multi_line {
// +1 for `\n`
text.line_start_offset(end_line + 1)
} else {
text.line_end_offset(end_line)
};
let range_styles = vec![(byte_start..byte_end, HighlightStyle::default())];
styles.extend(range_styles);
};
// Group contiguous visible lines into ranges and call styles() once per range
let mut visible_iter = visible_buffer_lines.iter().peekable();
let mut range_start: Option<usize> = None;
while let Some(&line) = visible_iter.next() {
// Check if this line is too long for highlighting
let line_len = text.slice_line(line).len();
if line_len > MAX_HIGHLIGHT_LINE_LENGTH {
// Flush any accumulated range first
if let Some(start) = range_start.take() {
flush_range(start, line - 1, false, &mut styles);
}
flush_range(line, line, true, &mut styles);
continue;
}
range_start.get_or_insert(line);
// Check if next line is contiguous, if so keep accumulating
if visible_iter
.peek()
.map(|&&next| next == line + 1)
.unwrap_or(false)
{
continue;
}
// Flush the contiguous range
let start_line = range_start.take().unwrap();
flush_range(start_line, line, false, &mut styles);
}
Some(styles)
}
}
pub(super) struct PrepaintState {
/// The lines of entire lines.
last_layout: LastLayout,
/// The lines only contains the visible lines in the viewport, based on `visible_range`.
///
/// The child is the soft lines.
line_numbers: Option<Vec<SmallVec<[ShapedLine; 1]>>>,
/// Size of the scrollable area by entire lines.
scroll_size: Size<Pixels>,
cursor_bounds: Option<Bounds<Pixels>>,
cursor_scroll_offset: Point<Pixels>,
/// row index (zero based), no wrap, same line as the cursor.
current_row: Option<usize>,
selection_path: Option<Path<Pixels>>,
hover_highlight_path: Option<Path<Pixels>>,
search_match_paths: Vec<(Path<Pixels>, bool)>,
document_color_paths: Vec<(Path<Pixels>, Hsla)>,
hover_definition_hitbox: Option<Hitbox>,
indent_guides_path: Option<Path<Pixels>>,
bounds: Bounds<Pixels>,
/// Fold icon layout data
fold_icon_layout: FoldIconLayout,
// Inline completion rendering data
/// Shaped ghost lines to paint after cursor row (completion lines 2+)
ghost_lines: Vec<ShapedLine>,
/// First line of inline completion (painted after cursor on same line)
ghost_first_line: Option<ShapedLine>,
ghost_lines_height: Pixels,
}
impl PrepaintState {
@@ -1356,13 +967,6 @@ impl Element for TextElement {
.text
.line_end_offset(visible_range.end.saturating_sub(1));
let highlight_styles = self.highlight_lines(
&visible_buffer_lines,
visible_top,
visible_start_offset..visible_end_offset,
cx,
);
let state = self.state.read(cx);
let multi_line = state.mode.is_multi_line();
let text = state.text.clone();
@@ -1382,9 +986,8 @@ impl Element for TextElement {
(&text, fg)
};
// Calculate the width of the line numbers
let (line_number_width, line_number_len) =
Self::layout_line_numbers(state, &text, text_size, &text_style, window);
// Line numbers are not used (code editor mode removed)
let line_number_width = px(0.);
let mut bounds = bounds;
let wrap_width = if multi_line && state.soft_wrap {
@@ -1452,28 +1055,7 @@ impl Element for TextElement {
};
let runs = if !is_empty {
if let Some(highlight_styles) = highlight_styles {
let mut runs = Vec::with_capacity(highlight_styles.len());
runs.extend(highlight_styles.iter().map(|(range, style)| {
let mut run = text_style.clone().highlight(*style).to_run(range.len());
if let Some(ime_marked_range) = &state.ime_marked_range
&& range.start >= ime_marked_range.start
&& range.end <= ime_marked_range.end
{
run.color = marked_run.color;
run.strikethrough = marked_run.strikethrough;
run.underline = marked_run.underline;
}
run
}));
runs.into_iter().filter(|run| run.len > 0).collect()
} else {
vec![run]
}
vec![run]
} else if let Some(ime_marked_range) = &state.ime_marked_range {
// IME marked text
vec![
@@ -1498,8 +1080,6 @@ impl Element for TextElement {
vec![run]
};
let document_colors = [];
// Create shaped lines for whitespace indicators before layout
let whitespace_indicators =
Self::layout_whitespace_indicators(state, text_size, &text_style, window, cx);
@@ -1510,7 +1090,7 @@ impl Element for TextElement {
&last_layout,
text_size,
&runs,
&document_colors,
&[],
whitespace_indicators,
window,
);
@@ -1540,26 +1120,8 @@ impl Element for TextElement {
}
last_layout.lines = Rc::new(lines);
let (ghost_first_line, ghost_lines) = Self::layout_inline_completion(
state,
&last_layout.visible_range,
text_size,
window,
cx,
);
let ghost_line_count = ghost_lines.len();
let ghost_lines_height = ghost_line_count as f32 * line_height;
let total_wrapped_lines = state.display_map.wrap_row_count();
let empty_bottom_height = if state.mode.is_code_editor() {
bounds
.size
.height
.half()
.max(BOTTOM_MARGIN_ROWS * line_height)
} else {
px(0.)
};
let empty_bottom_height = px(0.);
let mut scroll_size = size(
if longest_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width {
@@ -1567,7 +1129,7 @@ impl Element for TextElement {
} else {
longest_line_width
},
(total_wrapped_lines as f32 * line_height + empty_bottom_height + ghost_lines_height)
(total_wrapped_lines as f32 * line_height + empty_bottom_height)
.max(bounds.size.height),
);
@@ -1608,75 +1170,16 @@ impl Element for TextElement {
// Calculate the scroll offset to keep the cursor in view
// Save the unscrolled x before layout_cursor modifies bounds.origin with scroll_offset.
// Fold icons and their hitboxes must use this value so they stay fixed in the gutter
// regardless of horizontal scroll position.
// Save the bounds before layout_cursor modifies bounds.origin with scroll_offset.
let input_bounds = bounds;
let original_x = bounds.origin.x;
let (cursor_bounds, cursor_scroll_offset, current_row) =
let (cursor_bounds, cursor_scroll_offset) =
self.layout_cursor(&last_layout, &mut bounds, scroll_size, window, cx);
last_layout.cursor_bounds = cursor_bounds;
let search_match_paths = self.layout_search_matches(&last_layout, &bounds, cx);
let selection_path = self.layout_selections(&last_layout, &mut bounds, window, cx);
let hover_highlight_path = self.layout_hover_highlight(&last_layout, &bounds, cx);
let document_color_paths =
self.layout_document_colors(&document_colors, &last_layout, &bounds, cx);
let state = self.state.read(cx);
let line_numbers = if state.mode.line_number() {
let mut line_numbers = Vec::with_capacity(last_layout.visible_buffer_lines.len());
let other_line_runs = vec![TextRun {
len: line_number_len,
font: style.font(),
color: cx.theme().text_muted,
background_color: None,
underline: None,
strikethrough: None,
}];
let current_line_runs = vec![TextRun {
len: line_number_len,
font: style.font(),
color: cx.theme().text,
background_color: None,
underline: None,
strikethrough: None,
}];
// build line numbers
for (line, &buffer_line) in last_layout
.lines
.iter()
.zip(last_layout.visible_buffer_lines.iter())
{
let line_no: SharedString =
format!("{:>width$}", buffer_line + 1, width = line_number_len).into();
let runs = if current_row == Some(buffer_line) {
&current_line_runs
} else {
&other_line_runs
};
let mut sub_lines: SmallVec<[ShapedLine; 1]> = SmallVec::new();
sub_lines.push(
window
.text_system()
.shape_line(line_no, text_size, runs, None),
);
for _ in 0..line.wrapped_lines.len().saturating_sub(1) {
sub_lines.push(ShapedLine::default());
}
line_numbers.push(sub_lines);
}
Some(line_numbers)
} else {
None
};
let indent_guides_path =
self.layout_indent_guides(state, &bounds, &last_layout, &text_style, window);
state
.editor_scrollbar_snapshot
@@ -1688,27 +1191,13 @@ impl Element for TextElement {
state,
)));
let fold_icon_layout =
self.layout_fold_icons(original_x, &bounds, &last_layout, window, cx);
PrepaintState {
bounds,
last_layout,
scroll_size,
line_numbers,
cursor_bounds,
cursor_scroll_offset,
current_row,
selection_path,
search_match_paths,
hover_highlight_path,
hover_definition_hitbox: None,
document_color_paths,
indent_guides_path,
fold_icon_layout,
ghost_first_line,
ghost_lines,
ghost_lines_height,
}
}
@@ -1765,56 +1254,15 @@ impl Element for TextElement {
let invisible_top_padding = prepaint.last_layout.visible_top;
// Paint active line
let mut offset_y = px(0.);
if let Some(line_numbers) = prepaint.line_numbers.as_ref() {
offset_y += invisible_top_padding;
// Each item is the normal lines.
for (lines, _) in line_numbers
.iter()
.zip(prepaint.last_layout.visible_buffer_lines.iter())
{
let height = line_height * lines.len() as f32;
offset_y += height;
}
}
// Paint indent guides
if let Some(path) = prepaint.indent_guides_path.take() {
window.paint_path(path, cx.theme().border.opacity(0.85));
}
// Paint selections
if window.is_window_active() {
let secondary_selection = cx.theme().selection;
for (path, is_active) in prepaint.search_match_paths.iter() {
window.paint_path(path.clone(), secondary_selection);
if *is_active {
window.paint_path(path.clone(), cx.theme().selection);
}
}
if let Some(path) = prepaint.selection_path.take() {
window.paint_path(path, cx.theme().selection);
}
// Paint hover highlight
if let Some(path) = prepaint.hover_highlight_path.take() {
window.paint_path(path, secondary_selection);
}
if window.is_window_active()
&& let Some(path) = prepaint.selection_path.take()
{
window.paint_path(path, cx.theme().selection);
}
// Paint document colors
for (path, color) in prepaint.document_color_paths.iter() {
window.paint_path(path.clone(), *color);
}
// Paint text with inline completion ghost line support
// Paint text
let mut offset_y = invisible_top_padding;
let ghost_lines = &prepaint.ghost_lines;
let has_ghost_lines = !ghost_lines.is_empty();
// Keep scrollbar offset always be positiveStart from the left position
let scroll_offset = if text_align == TextAlign::Right {
@@ -1827,16 +1275,12 @@ impl Element for TextElement {
px(0.)
};
// Track the y-position of the cursor row for positioning the first line suffix
let mut cursor_row_y = None;
for (line, &buffer_line) in prepaint
for (line, _buffer_line) in prepaint
.last_layout
.lines
.iter()
.zip(prepaint.last_layout.visible_buffer_lines.iter())
{
let row = buffer_line;
let line_y = origin.y + offset_y;
let p = point(
origin.x + prepaint.last_layout.line_number_width + (scroll_offset),
@@ -1853,40 +1297,6 @@ impl Element for TextElement {
cx,
);
offset_y += line.size(line_height).height;
if Some(row) == prepaint.current_row {
cursor_row_y = Some(line_y);
}
// After the cursor row, paint ghost lines (which shifts subsequent content down)
if has_ghost_lines && Some(row) == prepaint.current_row {
let ghost_x = origin.x + prepaint.last_layout.line_number_width;
for ghost_line in ghost_lines {
let ghost_p = point(ghost_x, origin.y + offset_y);
// Paint semi-transparent background for ghost line
let ghost_bounds = Bounds::new(
ghost_p,
size(
bounds.size.width - prepaint.last_layout.line_number_width,
line_height,
),
);
window.paint_quad(fill(ghost_bounds, cx.theme().surface_background));
// Paint ghost line text
_ = ghost_line.paint(
ghost_p,
line_height,
text_align,
Some(prepaint.last_layout.content_width),
window,
cx,
);
offset_y += line_height;
}
}
}
// Paint blinking cursor
@@ -1897,49 +1307,6 @@ impl Element for TextElement {
window.paint_quad(fill(cursor_bounds, cx.theme().cursor));
}
// Paint line numbers
let mut offset_y = px(0.);
if let Some(line_numbers) = prepaint.line_numbers.as_ref() {
offset_y += invisible_top_padding;
window.paint_quad(fill(
Bounds {
origin: input_bounds.origin,
size: size(
prepaint.last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN,
input_bounds.size.height + prepaint.ghost_lines_height,
),
},
cx.theme().surface_background,
));
// Each item is the normal lines.
for (lines, &buffer_line) in line_numbers
.iter()
.zip(prepaint.last_layout.visible_buffer_lines.iter())
{
let p = point(input_bounds.origin.x, origin.y + offset_y);
for line in lines {
_ = line.paint(p, line_height, TextAlign::Left, None, window, cx);
offset_y += line_height;
}
// Add ghost line height after cursor row for line numbers alignment
if !prepaint.ghost_lines.is_empty() && prepaint.current_row == Some(buffer_line) {
offset_y += prepaint.ghost_lines_height;
}
}
}
// Paint fold icons (only visible on hover or for current line)
self.paint_fold_icons(
&mut prepaint.fold_icon_layout,
prepaint.current_row,
window,
cx,
);
self.state.update(cx, |state, cx| {
state.last_layout = Some(prepaint.last_layout.clone());
state.last_bounds = Some(bounds);
@@ -1953,27 +1320,6 @@ impl Element for TextElement {
cx.notify();
});
if let Some(hitbox) = prepaint.hover_definition_hitbox.as_ref() {
window.set_cursor_style(gpui::CursorStyle::PointingHand, hitbox);
}
// Paint inline completion first line suffix (after cursor on same line)
if focused
&& let Some(first_line) = &prepaint.ghost_first_line
&& let (Some(cursor_bounds), Some(cursor_row_y)) =
(prepaint.cursor_bounds_with_scroll(), cursor_row_y)
{
let first_line_x = cursor_bounds.origin.x + cursor_bounds.size.width;
let p = point(first_line_x, cursor_row_y);
// Paint background to cover any existing text
let bg_bounds = Bounds::new(p, size(first_line.width + px(4.), line_height));
window.paint_quad(fill(bg_bounds, cx.theme().surface_background));
// Paint first line completion text
_ = first_line.paint(p, line_height, text_align, None, window, cx);
}
self.paint_mouse_listeners(window, cx);
}
}
+6 -161
View File
@@ -1,12 +1,8 @@
use gpui::{
Bounds, Context, EntityInputHandler as _, Hsla, Path, PathBuilder, Pixels, SharedString,
TextRun, TextStyle, Window, point, px,
};
use gpui::{Context, EntityInputHandler, SharedString, Window};
use ropey::RopeSlice;
use crate::input::element::TextElement;
use crate::input::mode::InputMode;
use crate::input::{Indent, IndentInline, InputState, LastLayout, Outdent, OutdentInline, RopeExt};
use crate::input::{Indent, IndentInline, InputState, Outdent, OutdentInline};
#[derive(Debug, Copy, Clone)]
pub struct TabSize {
@@ -49,165 +45,14 @@ impl TabSize {
}
}
impl InputMode {
#[inline]
pub(super) fn is_indentable(&self) -> bool {
match self {
InputMode::PlainText { multi_line, .. } | InputMode::CodeEditor { multi_line, .. } => {
*multi_line
}
_ => false,
}
}
#[inline]
pub(super) fn has_indent_guides(&self) -> bool {
match self {
InputMode::CodeEditor {
indent_guides,
multi_line,
..
} => *indent_guides && *multi_line,
_ => false,
}
}
#[inline]
pub(super) fn tab_size(&self) -> TabSize {
match self {
InputMode::PlainText { tab, .. } => *tab,
InputMode::CodeEditor { tab, .. } => *tab,
_ => TabSize::default(),
}
}
}
impl TextElement {
/// Measure the indent width in pixels for given column count.
fn measure_indent_width(&self, style: &TextStyle, column: usize, window: &Window) -> Pixels {
let font_size = style.font_size.to_pixels(window.rem_size());
let layout = window.text_system().shape_line(
SharedString::from(" ".repeat(column)),
font_size,
&[TextRun {
len: column,
font: style.font(),
color: Hsla::default(),
background_color: None,
strikethrough: None,
underline: None,
}],
None,
);
layout.width
}
pub(super) fn layout_indent_guides(
&self,
state: &InputState,
bounds: &Bounds<Pixels>,
last_layout: &LastLayout,
text_style: &TextStyle,
window: &mut Window,
) -> Option<Path<Pixels>> {
if !state.mode.has_indent_guides() {
return None;
}
let indent_width =
self.measure_indent_width(text_style, state.mode.tab_size().tab_size, window);
let tab_size = state.mode.tab_size();
let line_height = last_layout.line_height;
let mut builder = PathBuilder::stroke(px(1.));
let mut offset_y = last_layout.visible_top;
let mut last_indents = vec![];
for (&buffer_line, line_layout) in last_layout
.visible_buffer_lines
.iter()
.zip(last_layout.lines.iter())
{
let line = state.text.slice_line(buffer_line);
let mut current_indents = vec![];
if line.len() > 0 {
let indent_count = tab_size.indent_count(&line);
for offset in (0..indent_count).step_by(tab_size.tab_size) {
let x = if indent_count > 0 {
indent_width * offset as f32 / tab_size.tab_size as f32
} else {
px(0.)
};
let pos = point(x + last_layout.line_number_width, offset_y);
builder.move_to(pos);
builder.line_to(point(pos.x, pos.y + line_height));
current_indents.push(pos.x);
}
} else if !last_indents.is_empty() {
for x in &last_indents {
let pos = point(*x, offset_y);
builder.move_to(pos);
builder.line_to(point(pos.x, pos.y + line_height));
}
current_indents = last_indents.clone();
}
offset_y += line_layout.wrapped_lines.len() * line_height;
last_indents = current_indents;
}
builder.translate(bounds.origin);
let path = builder.build().unwrap();
Some(path)
}
}
impl InputState {
/// Set whether to show indent guides in code editor mode, default is true.
///
/// Only for [`InputMode::CodeEditor`] mode.
pub fn indent_guides(mut self, indent_guides: bool) -> Self {
debug_assert!(self.mode.is_code_editor() && self.mode.is_multi_line());
if let InputMode::CodeEditor {
indent_guides: l, ..
} = &mut self.mode
{
*l = indent_guides;
}
self
}
/// Set indent guides in code editor mode.
///
/// Only for [`InputMode::CodeEditor`] mode.
pub fn set_indent_guides(
&mut self,
indent_guides: bool,
_: &mut Window,
cx: &mut Context<Self>,
) {
debug_assert!(self.mode.is_code_editor());
if let InputMode::CodeEditor {
indent_guides: l, ..
} = &mut self.mode
{
*l = indent_guides;
}
cx.notify();
}
/// Set the tab size for the input.
///
/// Only for [`InputMode::PlainText`] and [`InputMode::CodeEditor`] mode with multi_line.
/// Only for [`InputMode::PlainText`] mode with multi_line.
pub fn tab_size(mut self, tab: TabSize) -> Self {
debug_assert!(self.mode.is_multi_line() || self.mode.is_code_editor());
match &mut self.mode {
InputMode::PlainText { tab: t, .. } => *t = tab,
InputMode::CodeEditor { tab: t, .. } => *t = tab,
_ => {}
debug_assert!(self.mode.is_multi_line());
if let InputMode::PlainText { tab: t, .. } = &mut self.mode {
*t = tab;
}
self
}
-27
View File
@@ -35,8 +35,6 @@ pub struct Input {
cleanable: bool,
mask_toggle: bool,
disabled: bool,
bordered: bool,
focus_bordered: bool,
tab_index: isize,
selected: bool,
}
@@ -73,8 +71,6 @@ impl Input {
cleanable: false,
mask_toggle: false,
disabled: false,
bordered: true,
focus_bordered: true,
tab_index: 0,
selected: false,
}
@@ -108,18 +104,6 @@ impl Input {
self
}
/// Set the bordered for the input, default: true
pub fn bordered(mut self, bordered: bool) -> Self {
self.bordered = bordered;
self
}
/// Set focus border for the input, default is true.
pub fn focus_bordered(mut self, bordered: bool) -> Self {
self.focus_bordered = bordered;
self
}
/// Set whether to show the clear button when the input field is not empty, default is false.
pub fn cleanable(mut self, cleanable: bool) -> Self {
self.cleanable = cleanable;
@@ -234,12 +218,6 @@ impl RenderOnce for Input {
let (bg, _) = input_style(state.disabled, cx);
let bg = if state.mode.is_code_editor() {
cx.theme().surface_background
} else {
bg
};
let prefix = self.prefix;
let suffix = self.suffix;
let show_clear_button = self.cleanable
@@ -338,11 +316,6 @@ impl RenderOnce for Input {
this.bg(bg)
.when(self.disabled, |this| this.opacity(0.5))
.rounded(cx.theme().radius)
.when(self.bordered, |this| {
this.border_color(cx.theme().border)
.border_1()
.when(cx.theme().shadow, |this| this.shadow_xs())
})
})
.items_center()
.gap(gap_x)
+1 -3
View File
@@ -18,9 +18,7 @@ mod state;
pub(crate) use clear_button::*;
pub use cursor::*;
#[cfg(target_family = "wasm")]
pub use display_map::folding::Tree;
pub use display_map::{BufferPoint, DisplayMap, DisplayPoint, FoldRange};
pub use display_map::DisplayMap;
pub use indent::TabSize;
pub use input::*;
pub use mask_pattern::MaskPattern;
+12 -78
View File
@@ -1,26 +1,11 @@
use std::cell::RefCell;
use std::rc::Rc;
use gpui::{SharedString, Task};
use ropey::Rope;
use super::display_map::DisplayMap;
use crate::input::TabSize;
#[allow(dead_code)]
pub(super) struct PendingBackgroundParse {
pub parse_task: Rc<RefCell<Option<Task<()>>>>,
pub language: SharedString,
pub text: Rope,
pub is_folding: bool,
}
#[derive(Clone)]
pub(crate) enum InputMode {
/// A plain text input mode.
PlainText {
multi_line: bool,
tab: TabSize,
tab: crate::input::indent::TabSize,
rows: usize,
},
/// An auto grow input mode.
@@ -29,18 +14,6 @@ pub(crate) enum InputMode {
min_rows: usize,
max_rows: usize,
},
/// A code editor input mode.
CodeEditor {
multi_line: bool,
tab: TabSize,
rows: usize,
/// Show line number
line_number: bool,
language: SharedString,
indent_guides: bool,
folding: bool,
parse_task: Rc<RefCell<Option<Task<()>>>>,
},
}
impl Default for InputMode {
@@ -55,25 +28,11 @@ impl InputMode {
pub(super) fn plain_text() -> Self {
InputMode::PlainText {
multi_line: false,
tab: TabSize::default(),
tab: crate::input::indent::TabSize::default(),
rows: 1,
}
}
/// Create a code editor input mode with default settings.
pub(super) fn code_editor(language: impl Into<SharedString>) -> Self {
InputMode::CodeEditor {
rows: 2,
multi_line: true,
tab: TabSize::default(),
language: language.into(),
line_number: true,
indent_guides: true,
folding: true,
parse_task: Rc::new(RefCell::new(None)),
}
}
/// Create an auto grow input mode with given min and max rows.
pub(super) fn auto_grow(min_rows: usize, max_rows: usize) -> Self {
InputMode::AutoGrow {
@@ -86,7 +45,6 @@ impl InputMode {
pub(super) fn multi_line(mut self, multi_line: bool) -> Self {
match &mut self {
InputMode::PlainText { multi_line: ml, .. } => *ml = multi_line,
InputMode::CodeEditor { multi_line: ml, .. } => *ml = multi_line,
InputMode::AutoGrow { .. } => {}
}
self
@@ -97,28 +55,6 @@ impl InputMode {
!self.is_multi_line()
}
#[inline]
pub(super) fn is_code_editor(&self) -> bool {
matches!(self, InputMode::CodeEditor { .. })
}
/// Return true if the mode is code editor and `folding: true`, `multi_line: true`.
#[inline]
pub(crate) fn is_folding(&self) -> bool {
if cfg!(target_family = "wasm") {
return false;
}
matches!(
self,
InputMode::CodeEditor {
folding: true,
multi_line: true,
..
}
)
}
#[inline]
pub(super) fn is_auto_grow(&self) -> bool {
matches!(self, InputMode::AutoGrow { .. })
@@ -128,7 +64,6 @@ impl InputMode {
pub(super) fn is_multi_line(&self) -> bool {
match self {
InputMode::PlainText { multi_line, .. } => *multi_line,
InputMode::CodeEditor { multi_line, .. } => *multi_line,
InputMode::AutoGrow { max_rows, .. } => *max_rows > 1,
}
}
@@ -138,9 +73,6 @@ impl InputMode {
InputMode::PlainText { rows, .. } => {
*rows = new_rows;
}
InputMode::CodeEditor { rows, .. } => {
*rows = new_rows;
}
InputMode::AutoGrow {
rows,
min_rows,
@@ -168,7 +100,6 @@ impl InputMode {
match self {
InputMode::PlainText { rows, .. } => *rows,
InputMode::CodeEditor { rows, .. } => *rows,
InputMode::AutoGrow { rows, .. } => *rows,
}
.max(1)
@@ -196,16 +127,19 @@ impl InputMode {
}
}
/// Return false if the mode is not [`InputMode::CodeEditor`].
#[inline]
pub(super) fn line_number(&self) -> bool {
pub(super) fn is_indentable(&self) -> bool {
match self {
InputMode::CodeEditor {
line_number,
multi_line,
..
} => *line_number && *multi_line,
InputMode::PlainText { multi_line, .. } => *multi_line,
_ => false,
}
}
#[inline]
pub(super) fn tab_size(&self) -> crate::input::indent::TabSize {
match self {
InputMode::PlainText { tab, .. } => *tab,
_ => crate::input::indent::TabSize::default(),
}
}
}
@@ -1,337 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
Action, AnyElement, App, AppContext, Context, DismissEvent, Empty, Entity, EventEmitter,
InteractiveElement as _, IntoElement, ParentElement, Pixels, Point, Render, RenderOnce,
SharedString, Styled, StyledText, Subscription, Window, deferred, div, px, relative,
};
use lsp_types::CodeAction;
use theme::ActiveTheme;
const MAX_MENU_WIDTH: Pixels = px(320.);
const MAX_MENU_HEIGHT: Pixels = px(480.);
use crate::input::popovers::editor_popover;
use crate::input::{self, InputState};
use crate::list::{List, ListDelegate, ListEvent, ListState};
use crate::{IndexPath, Selectable, actions, h_flex};
#[derive(Debug, Clone)]
pub(crate) struct CodeActionItem {
/// The `id` of the `CodeActionProvider` that provided this item.
pub(crate) provider_id: SharedString,
pub(crate) action: CodeAction,
}
struct MenuDelegate {
menu: Entity<CodeActionMenu>,
items: Vec<Rc<CodeActionItem>>,
selected_ix: usize,
}
impl MenuDelegate {
fn set_items(&mut self, items: Vec<CodeActionItem>) {
self.items = items.into_iter().map(Rc::new).collect();
self.selected_ix = 0;
}
fn selected_item(&self) -> Option<&Rc<CodeActionItem>> {
self.items.get(self.selected_ix)
}
}
#[derive(IntoElement)]
struct MenuItem {
ix: usize,
item: Rc<CodeActionItem>,
children: Vec<AnyElement>,
selected: bool,
}
impl MenuItem {
fn new(ix: usize, item: Rc<CodeActionItem>) -> Self {
Self {
ix,
item,
children: vec![],
selected: false,
}
}
}
impl Selectable for MenuItem {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl ParentElement for MenuItem {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for MenuItem {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let item = self.item;
let highlights = vec![];
h_flex()
.id(self.ix)
.gap_2()
.p_1()
.text_xs()
.line_height(relative(1.))
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().secondary_hover))
.when(self.selected, |this| {
this.bg(cx.theme().secondary_background)
.text_color(cx.theme().secondary_foreground)
})
.child(
div().child(StyledText::new(item.action.title.clone()).with_highlights(highlights)),
)
.children(self.children)
}
}
impl EventEmitter<DismissEvent> for MenuDelegate {}
impl ListDelegate for MenuDelegate {
type Item = MenuItem;
fn items_count(&self, _: usize, _: &gpui::App) -> usize {
self.items.len()
}
fn render_item(
&mut self,
ix: crate::IndexPath,
_: &mut Window,
_: &mut Context<ListState<Self>>,
) -> Option<Self::Item> {
let item = self.items.get(ix.row)?;
Some(MenuItem::new(ix.row, item.clone()))
}
fn set_selected_index(
&mut self,
ix: Option<crate::IndexPath>,
_: &mut Window,
cx: &mut Context<ListState<Self>>,
) {
self.selected_ix = ix.map(|i| i.row).unwrap_or(0);
cx.notify();
}
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) {
let Some(item) = self.selected_item() else {
return;
};
self.menu.update(cx, |this, cx| {
this.select_item(&item, window, cx);
});
}
}
/// A context menu for code completions and code actions.
pub struct CodeActionMenu {
offset: usize,
state: Entity<InputState>,
list: Entity<ListState<MenuDelegate>>,
open: bool,
_subscriptions: Vec<Subscription>,
}
impl CodeActionMenu {
/// Creates a new `CompletionMenu` with the given offset and completion items.
///
/// NOTE: This element should not call from InputState::new, unless that will stack overflow.
pub(crate) fn new(
state: Entity<InputState>,
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
cx.new(|cx| {
let view = cx.entity();
let menu = MenuDelegate {
menu: view,
items: vec![],
selected_ix: 0,
};
let list = cx.new(|cx| ListState::new(menu, window, cx));
let _subscriptions =
vec![
cx.subscribe(&list, |this: &mut Self, _, ev: &ListEvent, cx| {
match ev {
ListEvent::Confirm(_) => {
this.hide(cx);
}
_ => {}
}
cx.notify();
}),
];
Self {
offset: 0,
state,
list,
open: false,
_subscriptions,
}
})
}
fn select_item(&mut self, item: &CodeActionItem, window: &mut Window, cx: &mut Context<Self>) {
let state = self.state.clone();
let item = item.clone();
cx.spawn_in(window, {
async move |_, cx| {
state.update_in(cx, |state, window, cx| {
state.perform_code_action(&item, window, cx);
})
}
})
.detach();
self.hide(cx);
}
pub(crate) fn handle_action(
&mut self,
action: Box<dyn Action>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
if !self.open {
return false;
}
cx.propagate();
if input::Enter::is_primary(&*action) {
self.on_action_enter(window, cx);
} else if action.partial_eq(&input::Escape) {
self.on_action_escape(window, cx);
} else if action.partial_eq(&input::MoveUp) {
self.on_action_up(window, cx);
} else if action.partial_eq(&input::MoveDown) {
self.on_action_down(window, cx);
} else {
return false;
}
true
}
fn on_action_enter(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(item) = self.list.read(cx).delegate().selected_item().cloned() else {
return;
};
self.select_item(&item, window, cx);
}
fn on_action_escape(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.hide(cx);
}
fn on_action_up(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.list.update(cx, |this, cx| {
this.on_action_select_prev(&actions::SelectUp, window, cx)
});
}
fn on_action_down(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.list.update(cx, |this, cx| {
this.on_action_select_next(&actions::SelectDown, window, cx)
});
}
pub(crate) fn is_open(&self) -> bool {
self.open
}
/// Hide the completion menu and reset the trigger start offset.
pub(crate) fn hide(&mut self, cx: &mut Context<Self>) {
self.open = false;
cx.notify();
}
pub(crate) fn show(
&mut self,
offset: usize,
items: impl Into<Vec<CodeActionItem>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let items = items.into();
self.offset = offset;
self.open = true;
self.list.update(cx, |this, cx| {
this.delegate_mut().set_items(items);
this.set_selected_index(Some(IndexPath::new(0)), window, cx);
});
cx.notify();
}
fn origin(&self, cx: &App) -> Option<Point<Pixels>> {
let state = self.state.read(cx);
let Some(last_layout) = state.last_layout.as_ref() else {
return None;
};
let Some(cursor_origin) = last_layout.cursor_bounds.map(|b| b.origin) else {
return None;
};
let scroll_origin = self.state.read(cx).scroll_handle.offset();
Some(
scroll_origin + cursor_origin - state.input_bounds.origin
+ Point::new(-px(4.), last_layout.line_height + px(4.)),
)
}
}
impl Render for CodeActionMenu {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if !self.open {
return Empty.into_any_element();
}
if self.list.read(cx).delegate().items.is_empty() {
self.open = false;
return Empty.into_any_element();
}
let Some(pos) = self.origin(cx) else {
return Empty.into_any_element();
};
let max_width = MAX_MENU_WIDTH.min(window.bounds().size.width - pos.x);
deferred(
editor_popover("code-action-menu", cx)
.absolute()
.left(pos.x)
.top(pos.y)
.max_w(max_width)
.min_w(px(120.))
.child(List::new(&self.list).max_h(MAX_MENU_HEIGHT))
.on_mouse_down_out(cx.listener(|this, _, _, cx| {
this.hide(cx);
})),
)
.into_any_element()
}
}
@@ -1,446 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
Action, AnyElement, App, AppContext, Context, DismissEvent, Empty, Entity, EventEmitter,
Half as _, HighlightStyle, InteractiveElement as _, IntoElement, ParentElement, Pixels, Point,
Render, RenderOnce, SharedString, Styled, StyledText, Subscription, Window, deferred, div, px,
relative,
};
use lsp_types::{CompletionItem, CompletionTextEdit};
use theme::ActiveTheme;
const MAX_MENU_WIDTH: Pixels = px(320.);
const MAX_MENU_HEIGHT: Pixels = px(240.);
const POPOVER_GAP: Pixels = px(4.);
use crate::input::popovers::{editor_popover, render_markdown};
use crate::input::{self, InputState, RopeExt};
use crate::list::{List, ListDelegate, ListEvent, ListState};
use crate::{IndexPath, Selectable, actions, h_flex};
struct ContextMenuDelegate {
query: SharedString,
menu: Entity<CompletionMenu>,
items: Vec<Rc<CompletionItem>>,
selected_ix: usize,
}
impl ContextMenuDelegate {
fn set_items(&mut self, items: Vec<CompletionItem>) {
self.items = items.into_iter().map(Rc::new).collect();
self.selected_ix = 0;
}
fn selected_item(&self) -> Option<&Rc<CompletionItem>> {
self.items.get(self.selected_ix)
}
}
#[derive(IntoElement)]
struct CompletionMenuItem {
ix: usize,
item: Rc<CompletionItem>,
children: Vec<AnyElement>,
selected: bool,
highlight_prefix: SharedString,
}
impl CompletionMenuItem {
fn new(ix: usize, item: Rc<CompletionItem>) -> Self {
Self {
ix,
item,
children: vec![],
selected: false,
highlight_prefix: "".into(),
}
}
fn highlight_prefix(mut self, s: impl Into<SharedString>) -> Self {
self.highlight_prefix = s.into();
self
}
}
impl Selectable for CompletionMenuItem {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl ParentElement for CompletionMenuItem {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for CompletionMenuItem {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let item = self.item;
let matched_len = item
.filter_text
.as_ref()
.map(|s| s.len())
.unwrap_or(self.highlight_prefix.len())
.min(item.label.len());
let highlights = vec![(
0..matched_len,
HighlightStyle {
color: Some(cx.theme().selection),
..Default::default()
},
)];
h_flex()
.id(self.ix)
.gap_2()
.p_1()
.text_xs()
.line_height(relative(1.))
.rounded(cx.theme().radius.half())
.when(item.deprecated.unwrap_or(false), |this| this.line_through())
.hover(|this| this.bg(cx.theme().secondary_hover))
.when(self.selected, |this| {
this.bg(cx.theme().secondary_background)
.text_color(cx.theme().secondary_foreground)
})
.child(div().child(StyledText::new(item.label.clone()).with_highlights(highlights)))
.children(self.children)
}
}
impl EventEmitter<DismissEvent> for ContextMenuDelegate {}
impl ListDelegate for ContextMenuDelegate {
type Item = CompletionMenuItem;
fn items_count(&self, _: usize, _: &gpui::App) -> usize {
self.items.len()
}
fn render_item(
&mut self,
ix: crate::IndexPath,
_: &mut Window,
_: &mut Context<ListState<Self>>,
) -> Option<Self::Item> {
let item = self.items.get(ix.row)?;
Some(CompletionMenuItem::new(ix.row, item.clone()).highlight_prefix(self.query.clone()))
}
fn set_selected_index(
&mut self,
ix: Option<crate::IndexPath>,
_: &mut Window,
cx: &mut Context<ListState<Self>>,
) {
self.selected_ix = ix.map(|i| i.row).unwrap_or(0);
cx.notify();
}
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) {
let Some(item) = self.selected_item() else {
return;
};
self.menu.update(cx, |this, cx| {
this.select_item(&item, window, cx);
});
}
}
/// A context menu for code completions and code actions.
pub struct CompletionMenu {
offset: usize,
editor: Entity<InputState>,
list: Entity<ListState<ContextMenuDelegate>>,
open: bool,
/// The offset of the first character that triggered the completion.
pub(crate) trigger_start_offset: Option<usize>,
query: SharedString,
_subscriptions: Vec<Subscription>,
}
impl CompletionMenu {
/// Creates a new `CompletionMenu` with the given offset and completion items.
///
/// NOTE: This element should not call from InputState::new, unless that will stack overflow.
pub(crate) fn new(
editor: Entity<InputState>,
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
cx.new(|cx| {
let view = cx.entity();
let menu = ContextMenuDelegate {
query: SharedString::default(),
menu: view,
items: vec![],
selected_ix: 0,
};
let list = cx.new(|cx| ListState::new(menu, window, cx));
let _subscriptions =
vec![
cx.subscribe(&list, |this: &mut Self, _, ev: &ListEvent, cx| {
match ev {
ListEvent::Confirm(_) => {
this.hide(cx);
}
_ => {}
}
cx.notify();
}),
];
Self {
offset: 0,
editor,
list,
open: false,
trigger_start_offset: None,
query: SharedString::default(),
_subscriptions,
}
})
}
fn select_item(&mut self, item: &CompletionItem, window: &mut Window, cx: &mut Context<Self>) {
let offset = self.offset;
let item = item.clone();
let mut range = self.trigger_start_offset.unwrap_or(self.offset)..self.offset;
let editor = self.editor.clone();
cx.spawn_in(window, async move |_, cx| {
editor.update_in(cx, |editor, window, cx| {
editor.completion_inserting = true;
let mut new_text = item.label.clone();
if let Some(text_edit) = item.text_edit.as_ref() {
match text_edit {
CompletionTextEdit::Edit(edit) => {
new_text = edit.new_text.clone();
range.start = editor.text.position_to_offset(&edit.range.start);
range.end = editor.text.position_to_offset(&edit.range.end);
}
CompletionTextEdit::InsertAndReplace(edit) => {
new_text = edit.new_text.clone();
range.start = editor.text.position_to_offset(&edit.replace.start);
range.end = editor.text.position_to_offset(&edit.replace.end);
}
}
} else if let Some(insert_text) = item.insert_text.clone() {
new_text = insert_text;
range = offset..offset;
}
editor.replace_text_in_range_silent(
Some(editor.range_to_utf16(&range)),
&new_text,
window,
cx,
);
editor.completion_inserting = false;
// FIXME: Input not get the focus
editor.focus(window, cx);
})
})
.detach();
self.hide(cx);
}
pub(crate) fn handle_action(
&mut self,
action: Box<dyn Action>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
if !self.open {
return false;
}
cx.propagate();
if input::Enter::is_primary(&*action) {
self.on_action_enter(window, cx);
} else if action.partial_eq(&input::Escape) {
self.on_action_escape(window, cx);
} else if action.partial_eq(&input::MoveUp) {
self.on_action_up(window, cx);
} else if action.partial_eq(&input::MoveDown) {
self.on_action_down(window, cx);
} else {
return false;
}
true
}
fn on_action_enter(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(item) = self.list.read(cx).delegate().selected_item().cloned() else {
return;
};
self.select_item(&item, window, cx);
}
fn on_action_escape(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.hide(cx);
}
fn on_action_up(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.list.update(cx, |this, cx| {
this.on_action_select_prev(&actions::SelectUp, window, cx)
});
}
fn on_action_down(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.list.update(cx, |this, cx| {
this.on_action_select_next(&actions::SelectDown, window, cx)
});
}
pub(crate) fn is_open(&self) -> bool {
self.open
}
/// Hide the completion menu and reset the trigger start offset.
pub(crate) fn hide(&mut self, cx: &mut Context<Self>) {
self.open = false;
self.trigger_start_offset = None;
cx.notify();
}
/// Sets the trigger start offset if it is not already set.
pub(crate) fn update_query(&mut self, start_offset: usize, query: impl Into<SharedString>) {
if self.trigger_start_offset.is_none() {
self.trigger_start_offset = Some(start_offset);
}
self.query = query.into();
}
pub(crate) fn show(
&mut self,
offset: usize,
items: impl Into<Vec<CompletionItem>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let items = items.into();
self.offset = offset;
self.open = true;
self.list.update(cx, |this, cx| {
let longest_ix = items
.iter()
.enumerate()
.max_by_key(|(_, item)| {
item.label.len() + item.detail.as_ref().map(|d| d.len()).unwrap_or(0)
})
.map(|(ix, _)| ix)
.unwrap_or(0);
this.delegate_mut().query = self.query.clone();
this.delegate_mut().set_items(items);
this.set_selected_index(Some(IndexPath::new(0)), window, cx);
this.set_item_to_measure_index(IndexPath::new(longest_ix), window, cx);
});
cx.notify();
}
fn origin(&self, cx: &App) -> Option<Point<Pixels>> {
let editor = self.editor.read(cx);
let Some(last_layout) = editor.last_layout.as_ref() else {
return None;
};
let Some(cursor_origin) = last_layout.cursor_bounds.map(|b| b.origin) else {
return None;
};
let scroll_origin = self.editor.read(cx).scroll_handle.offset();
Some(
scroll_origin + cursor_origin - editor.input_bounds.origin
+ Point::new(-px(4.), last_layout.line_height + px(4.)),
)
}
}
impl Render for CompletionMenu {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if !self.open {
return Empty.into_any_element();
}
if self.list.read(cx).delegate().items.is_empty() {
self.open = false;
return Empty.into_any_element();
}
let Some(pos) = self.origin(cx) else {
return Empty.into_any_element();
};
let selected_documentation = self
.list
.read(cx)
.delegate()
.selected_item()
.and_then(|item| item.documentation.clone());
let max_width = MAX_MENU_WIDTH.min(window.bounds().size.width - pos.x);
let abs_pos = self.editor.read(cx).input_bounds.origin + pos;
let vertical_layout =
abs_pos.x + MAX_MENU_WIDTH + POPOVER_GAP + MAX_MENU_WIDTH + POPOVER_GAP
> window.bounds().size.width;
deferred(
div()
.absolute()
.left(pos.x)
.top(pos.y)
.flex()
.flex_row()
.gap(POPOVER_GAP)
.items_start()
.when(vertical_layout, |this| this.flex_col())
.child(
editor_popover("completion-menu", cx)
.max_w(max_width)
.min_w(px(120.))
.child(List::new(&self.list).max_h(MAX_MENU_HEIGHT)),
)
.when_some(selected_documentation, |this, documentation| {
let mut doc = match documentation {
lsp_types::Documentation::String(s) => s.clone(),
lsp_types::Documentation::MarkupContent(mc) => mc.value.clone(),
};
if vertical_layout {
doc = doc.split("\n").next().unwrap_or_default().to_string();
}
this.child(
div().child(
editor_popover("completion-menu", cx)
.w(MAX_MENU_WIDTH)
.px_2()
.child(render_markdown("doc", doc, window, cx)),
),
)
})
.on_mouse_down_out(cx.listener(|this, _, _, cx| {
this.hide(cx);
})),
)
.into_any_element()
}
}
@@ -1,142 +0,0 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
Anchor, App, AppContext as _, Context, DismissEvent, Entity, IntoElement, MouseDownEvent,
ParentElement as _, Pixels, Point, Render, Styled, Subscription, Window, anchored, deferred,
div, px,
};
use crate::input::popovers::ContextMenu;
use crate::input::{self, InputState};
use crate::menu::PopupMenu;
/// Context menu for mouse right clicks.
pub(crate) struct InputContextMenu {
editor: Entity<InputState>,
menu: Entity<PopupMenu>,
mouse_position: Point<Pixels>,
open: bool,
_subscriptions: Vec<Subscription>,
}
impl InputState {
pub(crate) fn handle_right_click_menu(
&mut self,
event: &MouseDownEvent,
offset: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Show Mouse context menu
if !self.selected_range.contains(offset) {
self.move_to(offset, None, cx);
}
self.context_menu_content = Some(ContextMenu::RightClick(self.context_menu.clone()));
let is_code_editor = self.mode.is_code_editor();
if is_code_editor {
self.handle_hover_definition(offset, window, cx);
}
let is_enable = !self.disabled;
let has_goto_definition = is_enable && self.lsp.definition_provider.is_some();
let has_code_action = is_enable && !self.lsp.code_action_providers.is_empty();
let is_selected = !self.selected_range.is_empty();
let has_paste = is_enable && cx.read_from_clipboard().is_some();
let action_context = self.focus_handle.clone();
self.context_menu.update(cx, |this, cx| {
this.mouse_position = event.position;
this.menu.update(cx, |menu, cx| {
let new_menu = if let Some(builder) = &self.context_menu_builder {
builder(PopupMenu::new(cx), window, cx)
} else {
PopupMenu::new(cx)
.when(is_code_editor, |m| {
m.menu_with_enable(
"Go to Definition",
Box::new(input::GoToDefinition),
has_goto_definition,
)
.menu_with_enable(
"Show Code Actions",
Box::new(input::ToggleCodeActions),
has_code_action,
)
.separator()
})
.menu_with_enable("Cut", Box::new(input::Cut), is_enable && is_selected)
.menu_with_enable("Copy", Box::new(input::Copy), is_selected)
.menu_with_enable("Paste", Box::new(input::Paste), has_paste)
.separator()
.menu("Select All", Box::new(input::SelectAll))
};
menu.menu_items = new_menu.menu_items;
menu.action_context = Some(action_context);
cx.notify();
});
cx.defer_in(window, |this, _, cx| {
this.open = true;
cx.notify();
});
});
}
}
impl InputContextMenu {
pub(crate) fn new(
editor: Entity<InputState>,
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
cx.new(|cx| {
let menu = cx.new(|cx| PopupMenu::new(cx).small());
let _subscriptions = vec![cx.subscribe_in(&menu, window, {
move |this: &mut Self, _, _: &DismissEvent, window, cx| {
this.close(window, cx);
}
})];
Self {
editor,
menu,
mouse_position: Point::default(),
open: false,
_subscriptions,
}
})
}
#[inline]
pub(crate) fn is_open(&self) -> bool {
self.open
}
#[inline]
pub(crate) fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.open = false;
self.editor.update(cx, |this, cx| {
this.focus(window, cx);
});
}
}
impl Render for InputContextMenu {
fn render(&mut self, _: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
if !self.open {
return div().into_any_element();
}
deferred(
anchored()
.snap_to_window_with_margin(px(8.))
.anchor(Anchor::TopLeft)
.position(self.mouse_position)
.child(div().cursor_default().child(self.menu.clone())),
)
.into_any_element()
}
}
@@ -1,95 +0,0 @@
use std::rc::Rc;
use gpui::{
prelude::FluentBuilder as _, px, App, AppContext as _, Bounds, Context, Empty, Entity,
IntoElement, Pixels, Point, Render, Styled, Window,
};
use crate::{
highlighter::DiagnosticEntry,
input::{
popovers::{render_markdown, Popover},
InputState,
},
};
pub struct DiagnosticPopover {
state: Entity<InputState>,
pub(crate) diagnostic: Rc<DiagnosticEntry>,
bounds: Bounds<Pixels>,
open: bool,
}
impl DiagnosticPopover {
pub fn new(
diagnostic: &DiagnosticEntry,
state: Entity<InputState>,
cx: &mut App,
) -> Entity<Self> {
let diagnostic = Rc::new(diagnostic.clone());
cx.new(|_| Self {
diagnostic,
state,
bounds: Bounds::default(),
open: true,
})
}
pub(crate) fn show(&mut self, cx: &mut Context<Self>) {
self.open = true;
cx.notify();
}
pub(crate) fn hide(&mut self, cx: &mut Context<Self>) {
self.open = false;
cx.notify();
}
pub(crate) fn check_to_hide(&mut self, mouse_position: Point<Pixels>, cx: &mut Context<Self>) {
if !self.open {
return;
}
let padding = px(5.);
let bounds = Bounds {
origin: self.bounds.origin.map(|v| v - padding),
size: self.bounds.size.map(|v| v + padding * 2.),
};
if !bounds.contains(&mouse_position) {
self.hide(cx);
}
}
}
impl Render for DiagnosticPopover {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if !self.open {
return Empty.into_any_element();
}
let message = self.diagnostic.message.clone();
let (border, bg, fg) = (
self.diagnostic.severity.border(cx),
self.diagnostic.severity.bg(cx),
self.diagnostic.severity.fg(cx),
);
Popover::new(
"diagnostic-popover",
self.state.clone(),
self.diagnostic.range.clone(),
move |window, cx| render_markdown("message", message.clone(), window, cx),
)
.when(!self.open, |this| this.invisible())
.px_1()
.py_0p5()
.bg(bg)
.text_color(fg)
.border_1()
.border_color(border)
.into_any_element()
}
}
@@ -1,292 +0,0 @@
use std::{ops::Range, rc::Rc};
use gpui::{
AnyElement, App, AppContext as _, AvailableSpace, Bounds, Element, ElementId, Entity,
InteractiveElement, IntoElement, MouseDownEvent, MouseMoveEvent, ParentElement as _, Pixels,
Render, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, deferred, div, point,
px,
};
use crate::{
StyledExt,
input::{InputState, popovers::render_markdown},
};
pub struct HoverPopover {
editor: Entity<InputState>,
/// The symbol range byte of the hover trigger.
pub(crate) symbol_range: Range<usize>,
pub(crate) hover: Rc<lsp_types::Hover>,
}
impl HoverPopover {
pub fn new(
editor: Entity<InputState>,
symbol_range: Range<usize>,
hover: &lsp_types::Hover,
cx: &mut App,
) -> Entity<Self> {
let hover = Rc::new(hover.clone());
cx.new(|_| Self {
editor,
symbol_range,
hover,
})
}
pub(crate) fn is_same(&self, offset: usize) -> bool {
self.symbol_range.contains(&offset)
}
}
impl Render for HoverPopover {
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
let contents = match self.hover.contents.clone() {
lsp_types::HoverContents::Scalar(scalar) => match scalar {
lsp_types::MarkedString::String(s) => s,
lsp_types::MarkedString::LanguageString(ls) => ls.value,
},
lsp_types::HoverContents::Array(arr) => arr
.into_iter()
.map(|item| match item {
lsp_types::MarkedString::String(s) => s,
lsp_types::MarkedString::LanguageString(ls) => ls.value,
})
.collect::<Vec<_>>()
.join("\n\n"),
lsp_types::HoverContents::Markup(markup) => markup.value,
};
Popover::new(
"hover-popover",
self.editor.clone(),
self.symbol_range.clone(),
move |window, cx| render_markdown("message", contents.clone(), window, cx),
)
.into_any_element()
}
}
pub(crate) struct Popover {
id: ElementId,
style: StyleRefinement,
editor: Entity<InputState>,
range: Range<usize>,
width_limit: Range<Pixels>,
content_builder: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
}
impl Styled for Popover {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl Popover {
pub fn new<F, E>(
id: impl Into<ElementId>,
editor: Entity<InputState>,
range: Range<usize>,
f: F,
) -> Self
where
F: Fn(&mut Window, &mut App) -> E + 'static,
E: IntoElement,
{
Self {
id: id.into(),
editor,
range,
style: StyleRefinement::default(),
width_limit: px(200.)..px(500.),
content_builder: Box::new(move |window, cx| (f)(window, cx).into_any_element()),
}
}
/// Get the bounds of the range in the editor, if it is visible.
fn trigger_bounds(&self, cx: &App) -> Option<Bounds<Pixels>> {
let editor = self.editor.read(cx);
let Some(last_layout) = editor.last_layout.as_ref() else {
return None;
};
let Some(last_bounds) = editor.last_bounds else {
return None;
};
let (_, _, start_pos) = editor.line_and_position_for_offset(self.range.start);
let (_, _, end_pos) = editor.line_and_position_for_offset(self.range.end);
let Some(start_pos) = start_pos else {
return None;
};
let Some(end_pos) = end_pos else {
return None;
};
Some(Bounds::from_corners(
last_bounds.origin + start_pos,
last_bounds.origin + end_pos + point(px(0.), last_layout.line_height),
))
}
}
impl IntoElement for Popover {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
pub(crate) struct PopoverLayoutState {
bounds: Bounds<Pixels>,
element: Option<AnyElement>,
}
impl Element for Popover {
type RequestLayoutState = PopoverLayoutState;
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let trigger_bounds = match self.trigger_bounds(cx) {
Some(bounds) => bounds,
None => {
return (
div().into_any_element().request_layout(window, cx),
PopoverLayoutState {
bounds: Bounds::default(),
element: None,
},
);
}
};
let max_width = self
.width_limit
.end
.min(window.bounds().size.width - SNAP_TO_EDGE * 2)
.max(px(200.));
let max_height = (window.bounds().size.height - SNAP_TO_EDGE * 2).min(px(320.));
let mut popover = deferred(
div()
.id("hover-popover-content")
.flex_none()
.occlude()
.p_1()
.text_xs()
.popover_style(cx)
.shadow_md()
.max_w(max_width)
.max_h(max_height)
.overflow_y_scroll()
.refine_style(&self.style)
.child((self.content_builder)(window, cx)),
)
.into_any_element();
let popover_size = popover.layout_as_root(AvailableSpace::min_size(), window, cx);
const SNAP_TO_EDGE: Pixels = px(8.);
let top_space = trigger_bounds.top() - SNAP_TO_EDGE;
let right_space = window.bounds().size.width - trigger_bounds.left() - SNAP_TO_EDGE;
let mut pos = point(
trigger_bounds.left(),
trigger_bounds.top() - popover_size.height,
);
if popover_size.height > top_space {
pos.y = trigger_bounds.bottom();
}
if popover_size.width > right_space {
pos.x = trigger_bounds.right() - popover_size.width;
}
let mut empty = div().into_any_element();
let layout_id = empty.request_layout(window, cx);
(
layout_id,
PopoverLayoutState {
bounds: Bounds {
origin: pos,
size: popover_size,
},
element: Some(popover),
},
)
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let bounds = request_layout.bounds;
let Some(popover) = request_layout.element.as_mut() else {
return;
};
window.with_absolute_element_offset(bounds.origin, |window| {
popover.prepaint(window, cx);
})
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
let bounds = request_layout.bounds;
let Some(popover) = request_layout.element.as_mut() else {
return;
};
popover.paint(window, cx);
let editor = self.editor.clone();
// Mouse down out to hide.
window.on_mouse_event(move |event: &MouseDownEvent, _, _, cx| {
if !bounds.contains(&event.position) {
let _ = editor.update(cx, |editor, cx| {
editor.clear_hover_state(cx);
});
}
});
// Mouse out of trigger + popover bounds
let editor = self.editor.clone();
let trigger_bounds = self.trigger_bounds(cx).unwrap_or(bounds);
let keep_open_region = trigger_bounds.union(&bounds);
window.on_mouse_event(move |event: &MouseMoveEvent, _, _, cx| {
if !keep_open_region.contains(&event.position) {
let _ = editor.update(cx, |editor, cx| {
editor.clear_hover_state(cx);
});
}
})
}
}
-41
View File
@@ -1,41 +0,0 @@
mod code_action_menu;
mod completion_menu;
mod context_menu;
mod diagnostic_popover;
mod hover_popover;
pub(crate) use code_action_menu::*;
pub(crate) use completion_menu::*;
pub(crate) use context_menu::*;
pub(crate) use diagnostic_popover::*;
use gpui::{
App, Div, ElementId, Entity, InteractiveElement as _, IntoElement, SharedString, Stateful,
StyleRefinement, Styled as _, Window, div, px, rems,
};
pub(crate) use hover_popover::*;
use crate::StyledExt as _;
pub(crate) enum ContextMenu {
Completion(Entity<CompletionMenu>),
CodeAction(Entity<CodeActionMenu>),
RightClick(Entity<InputContextMenu>),
}
impl ContextMenu {
pub(crate) fn is_open(&self, cx: &App) -> bool {
match self {
ContextMenu::Completion(menu) => menu.read(cx).is_open(),
ContextMenu::CodeAction(menu) => menu.read(cx).is_open(),
ContextMenu::RightClick(menu) => menu.read(cx).is_open(),
}
}
pub(crate) fn render(&self) -> impl IntoElement {
match self {
ContextMenu::Completion(menu) => menu.clone().into_any_element(),
ContextMenu::CodeAction(menu) => menu.clone().into_any_element(),
ContextMenu::RightClick(menu) => menu.clone().into_any_element(),
}
}
}
+8 -163
View File
@@ -99,9 +99,6 @@ actions!(
MoveToPreviousWord,
MoveToNextWord,
Escape,
ToggleCodeActions,
Search,
GoToDefinition,
]
);
@@ -250,14 +247,6 @@ pub(crate) fn init(cx: &mut App) {
KeyBinding::new("ctrl-z", Undo, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-y", Redo, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-.", ToggleCodeActions, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-.", ToggleCodeActions, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-f", Search, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-f", Search, Some(CONTEXT)),
]);
}
@@ -355,7 +344,6 @@ pub struct InputState {
pub(super) clean_on_escape: bool,
pub(super) submit_on_enter: bool,
pub(super) soft_wrap: bool,
pub(super) show_whitespaces: bool,
/// This flag tells the renderer to prefer the end of the current visual line.
pub(crate) cursor_line_end_affinity: bool,
pub(super) pattern: Option<regex::Regex>,
@@ -395,7 +383,7 @@ impl InputState {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle().tab_stop(true);
let blink_cursor = cx.new(|_| BlinkCursor::new());
let history = History::new().group_interval(std::time::Duration::from_secs(1));
let history = History::new().group_interval(instant::Duration::from_secs(1));
let _subscriptions = vec![
// Observe the blink cursor to repaint the view when it changes.
@@ -435,7 +423,6 @@ impl InputState {
clean_on_escape: false,
submit_on_enter: false,
soft_wrap: true,
show_whitespaces: false,
loading: false,
pattern: None,
validate: None,
@@ -480,33 +467,6 @@ impl InputState {
self
}
/// Set Input to use [`InputMode::CodeEditor`] mode.
///
/// Default options:
///
/// - line_number: true
/// - tab_size: 2
/// - hard_tabs: false
/// - height: 100%
/// - multi_line: true
/// - indent_guides: true
///
/// If `highlighter` is None, will use the default highlighter.
///
/// Code Editor aim for help used to simple code editing or display, not a full-featured code editor.
///
/// ## Features
///
/// - Syntax Highlighting
/// - Auto Indent
/// - Line Number
/// - Large Text support, up to 50K lines.
pub fn code_editor(mut self, language: impl Into<SharedString>) -> Self {
let language: SharedString = language.into();
self.mode = InputMode::code_editor(language);
self
}
/// Set whether search UI allows replacement, default is true.
pub fn replaceable(mut self, allow: bool) -> Self {
self.replaceable = allow;
@@ -519,49 +479,6 @@ impl InputState {
self
}
/// Set enable/disable code folding, only for [`InputMode::CodeEditor`] mode.
///
/// Default: true
pub fn folding(mut self, folding: bool) -> Self {
debug_assert!(self.mode.is_code_editor());
if let InputMode::CodeEditor { folding: f, .. } = &mut self.mode {
*f = folding;
}
self
}
/// Set code folding at runtime, only for [`InputMode::CodeEditor`] mode.
///
/// When disabling, all existing folds are cleared.
pub fn set_folding(&mut self, folding: bool, _: &mut Window, cx: &mut Context<Self>) {
debug_assert!(self.mode.is_code_editor());
if let InputMode::CodeEditor { folding: f, .. } = &mut self.mode {
*f = folding;
}
if !folding {
self.display_map.clear_folds();
}
cx.notify();
}
/// Set enable/disable line number, only for [`InputMode::CodeEditor`] mode.
pub fn line_number(mut self, line_number: bool) -> Self {
debug_assert!(self.mode.is_code_editor() && self.mode.is_multi_line());
if let InputMode::CodeEditor { line_number: l, .. } = &mut self.mode {
*l = line_number;
}
self
}
/// Set line number, only for [`InputMode::CodeEditor`] mode.
pub fn set_line_number(&mut self, line_number: bool, _: &mut Window, cx: &mut Context<Self>) {
debug_assert!(self.mode.is_code_editor() && self.mode.is_multi_line());
if let InputMode::CodeEditor { line_number: l, .. } = &mut self.mode {
*l = line_number;
}
cx.notify();
}
/// Set the number of rows for the multi-line Textarea.
///
/// This is only used when `multi_line` is set to true.
@@ -569,9 +486,7 @@ impl InputState {
/// default: 2
pub fn rows(mut self, rows: usize) -> Self {
match &mut self.mode {
InputMode::PlainText { rows: r, .. } | InputMode::CodeEditor { rows: r, .. } => {
*r = rows
}
InputMode::PlainText { rows: r, .. } => *r = rows,
InputMode::AutoGrow {
max_rows: max_r,
rows: r,
@@ -584,31 +499,6 @@ impl InputState {
self
}
/// Set highlighter language for for [`InputMode::CodeEditor`] mode.
pub fn set_highlighter(
&mut self,
new_language: impl Into<SharedString>,
cx: &mut Context<Self>,
) {
if let InputMode::CodeEditor {
language,
parse_task,
..
} = &mut self.mode
{
*language = new_language.into();
parse_task.borrow_mut().take();
}
cx.notify();
}
fn reset_highlighter(&mut self, cx: &mut Context<Self>) {
if let InputMode::CodeEditor { parse_task, .. } = &mut self.mode {
parse_task.borrow_mut().take();
}
cx.notify();
}
/// Set placeholder
pub fn set_placeholder(
&mut self,
@@ -726,7 +616,6 @@ impl InputState {
let text: SharedString = text.into();
let range = 0..self.text.chars().map(|c| c.len_utf16()).sum();
self.replace_text_in_range_silent(Some(range), &text, window, cx);
self.reset_highlighter(cx);
self.disabled = was_disabled;
}
@@ -779,12 +668,6 @@ impl InputState {
self
}
/// Set whether to show whitespace characters.
pub fn show_whitespaces(mut self, show: bool) -> Self {
self.show_whitespaces = show;
self
}
/// Update the soft wrap mode for multi-line input, default is true.
pub fn set_soft_wrap(&mut self, wrap: bool, _: &mut Window, cx: &mut Context<Self>) {
debug_assert!(self.mode.is_multi_line());
@@ -808,12 +691,6 @@ impl InputState {
cx.notify();
}
/// Update whether to show whitespace characters.
pub fn set_show_whitespaces(&mut self, show: bool, _: &mut Window, cx: &mut Context<Self>) {
self.show_whitespaces = show;
cx.notify();
}
/// Set the regular expression pattern of the input field.
///
/// Only for [`InputMode::SingleLine`] mode.
@@ -1038,7 +915,7 @@ impl InputState {
let row = self.text.offset_to_point(self.cursor()).row;
let logical_start = self.text.line_start_offset(row);
if self.soft_wrap && self.mode.is_code_editor() {
if self.soft_wrap {
let wrap_point = self.display_map.offset_to_wrap_display_point(self.cursor());
if let Some(line) = self.display_map.lines().get(row)
&& let Some(range) = line.wrapped_lines.get(wrap_point.local_row)
@@ -1066,7 +943,7 @@ impl InputState {
let logical_start = self.text.line_start_offset(row);
let logical_end = self.text.line_end_offset(row);
if self.soft_wrap && self.mode.is_code_editor() {
if self.soft_wrap {
let wrap_point = self.display_map.offset_to_wrap_display_point(self.cursor());
if let Some(line) = self.display_map.lines().get(row)
&& let Some(range) = line.wrapped_lines.get(wrap_point.local_row)
@@ -1264,7 +1141,7 @@ impl InputState {
if insert_newline {
// Get current line indent
let indent = if self.mode.is_code_editor() {
let indent = if self.mode.is_indentable() {
self.indent_of_next_line()
} else {
"".to_string()
@@ -1465,11 +1342,7 @@ impl InputState {
// Check if row_offset_y is out of the viewport
// If row offset is not in the viewport, scroll to make it visible
let edge_height = if direction.is_some() && self.mode.is_code_editor() {
3 * line_height
} else {
line_height
};
let edge_height = line_height;
if row_offset_y - edge_height + line_height < -scroll_offset.y {
// Scroll up
scroll_offset.y = -row_offset_y + edge_height - line_height;
@@ -1749,34 +1622,6 @@ impl InputState {
self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end)
}
/// If offset falls on a hidden (folded) line, clamp backward to the end of
/// the fold header line (last visible position before the fold).
fn clamp_offset_to_visible_backward(&self, offset: usize) -> usize {
let line = self.text.offset_to_point(offset).row;
if self.display_map.is_buffer_line_hidden(line) {
for fold in self.display_map.folded_ranges() {
if line > fold.start_line && line <= fold.end_line {
return self.text.line_end_offset(fold.start_line);
}
}
}
offset
}
/// If offset falls on a hidden (folded) line, clamp forward to the start of
/// the fold end line (first visible position after the fold).
fn clamp_offset_to_visible_forward(&self, offset: usize) -> usize {
let line = self.text.offset_to_point(offset).row;
if self.display_map.is_buffer_line_hidden(line) {
for fold in self.display_map.folded_ranges() {
if line > fold.start_line && line <= fold.end_line {
return self.text.line_start_offset(fold.end_line);
}
}
}
offset
}
pub(super) fn previous_boundary(&self, offset: usize) -> usize {
let mut offset = self.text.clip_offset(offset.saturating_sub(1), Bias::Left);
if let Some(ch) = self.text.char_at(offset)
@@ -1785,7 +1630,7 @@ impl InputState {
offset -= 1;
}
self.clamp_offset_to_visible_backward(offset)
offset
}
pub(super) fn next_boundary(&self, offset: usize) -> usize {
@@ -1796,7 +1641,7 @@ impl InputState {
offset += 1;
}
self.clamp_offset_to_visible_forward(offset)
offset
}
/// Returns the true to let InputElement to render cursor, when Input is focused and current BlinkCursor is visible.
+1 -1
View File
@@ -1,5 +1,4 @@
use std::ops::Range;
use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -9,6 +8,7 @@ use gpui::{
SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task,
UniformListScrollHandle, Window, div, px, size, uniform_list,
};
use instant::Duration;
use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
+5 -5
View File
@@ -1,5 +1,4 @@
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -7,6 +6,7 @@ use gpui::{
InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, point, px,
};
use instant::Duration;
use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm};
@@ -359,8 +359,8 @@ impl RenderOnce for Modal {
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
let x = bounds.center().x - self.width / 2.;
let mut padding_right = px(8.);
let mut padding_left = px(8.);
let mut padding_right = px(16.);
let mut padding_left = px(16.);
if let Some(pl) = self.style.padding.left {
padding_left = pl.to_pixels(self.width.into(), window.rem_size());
@@ -452,8 +452,8 @@ impl RenderOnce for Modal {
.when_some(self.max_width, |this, w| this.max_w(w))
.child(
div()
.px_2()
.h_4()
.px_4()
.h_8()
.w_full()
.flex()
.items_center()
+1 -1
View File
@@ -1,7 +1,7 @@
use std::any::TypeId;
use std::collections::{HashMap, VecDeque};
use std::rc::Rc;
use std::time::Duration;
use instant::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
+3 -4
View File
@@ -117,7 +117,7 @@ impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
cx.theme().border
};
let mut el = div()
let mut ele = div()
.id(self.id.clone())
.occlude()
.absolute()
@@ -160,16 +160,15 @@ impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
})
.child(
div()
.bg(bg_color)
.group_hover("handle", |this| this.bg(bg_color))
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)),
)
.into_any_element();
let layout_id = el.request_layout(window, cx);
let layout_id = ele.request_layout(window, cx);
((layout_id, el), state)
((layout_id, ele), state)
})
}
+3 -3
View File
@@ -2,7 +2,6 @@ use std::cell::Cell;
use std::ops::Deref;
use std::panic::Location;
use std::rc::Rc;
use std::time::{Duration, Instant};
use gpui::{
Anchor, App, Axis, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element, ElementId,
@@ -11,6 +10,7 @@ use gpui::{
Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill,
point, px, relative, size,
};
use instant::{Duration, Instant};
use theme::{ActiveTheme, AxisExt, ScrollbarMode};
/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH)
@@ -157,7 +157,7 @@ impl ScrollbarStateInner {
let mut state = *self;
state.hovered_axis = axis;
if axis.is_some() {
state.last_scroll_time = Some(std::time::Instant::now());
state.last_scroll_time = Some(instant::Instant::now());
}
state
}
@@ -166,7 +166,7 @@ impl ScrollbarStateInner {
let mut state = *self;
state.hovered_on_thumb = axis;
if self.is_scrollbar_visible() && axis.is_some() {
state.last_scroll_time = Some(std::time::Instant::now());
state.last_scroll_time = Some(instant::Instant::now());
}
state
}
+1 -1
View File
@@ -1,4 +1,4 @@
use std::time::Duration;
use instant::Duration;
use gpui::{
bounce, div, ease_in_out, Animation, AnimationExt, IntoElement, RenderOnce, StyleRefinement,
+2 -2
View File
@@ -14,7 +14,7 @@ pub fn v_flex() -> Div {
/// Returns a `Div` as divider.
pub fn divider(cx: &App) -> Div {
div().my_2().w_full().h_px().bg(cx.theme().border_variant)
div().my_1().w_full().h_px().bg(cx.theme().border_variant)
}
macro_rules! font_weight {
@@ -87,7 +87,7 @@ pub trait StyledExt: Styled + Sized {
.border_1()
.border_color(cx.theme().border)
.shadow_md()
.rounded(cx.theme().radius)
.rounded(cx.theme().radius_lg)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
use instant::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
+34 -496
View File
@@ -2,396 +2,15 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, Div, Edges, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
div, px, relative,
AnyElement, App, ClickEvent, Div, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px, relative,
};
use theme::ActiveTheme;
use theme::{ActiveTheme, TABBAR_HEIGHT};
use crate::{Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex};
use crate::{Icon, IconName, Selectable, h_flex};
pub mod tab_bar;
/// Tab variants.
#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Hash)]
pub enum TabVariant {
#[default]
Tab,
Outline,
Pill,
Segmented,
Underline,
}
impl TabVariant {
fn height(&self, size: Size) -> Pixels {
match size {
Size::XSmall => match self {
TabVariant::Underline => px(26.),
_ => px(20.),
},
Size::Small => match self {
TabVariant::Underline => px(30.),
_ => px(24.),
},
Size::Large => match self {
TabVariant::Underline => px(44.),
_ => px(36.),
},
_ => match self {
TabVariant::Underline => px(36.),
_ => px(32.),
},
}
}
fn inner_height(&self, size: Size) -> Pixels {
match size {
Size::XSmall => match self {
TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(18.),
TabVariant::Segmented => px(16.),
TabVariant::Underline => px(20.),
},
Size::Small => match self {
TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(22.),
TabVariant::Segmented => px(18.),
TabVariant::Underline => px(22.),
},
Size::Large => match self {
TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(36.),
TabVariant::Segmented => px(28.),
TabVariant::Underline => px(32.),
},
_ => match self {
TabVariant::Tab => px(30.),
TabVariant::Outline | TabVariant::Pill => px(26.),
TabVariant::Segmented => px(24.),
TabVariant::Underline => px(26.),
},
}
}
/// Default px(12) to match panel px_3, See [`crate::dock::TabPanel`]
fn inner_paddings(&self, size: Size) -> Edges<Pixels> {
let mut padding_x = match size {
Size::XSmall => px(8.),
Size::Small => px(10.),
Size::Large => px(16.),
_ => px(12.),
};
if matches!(self, TabVariant::Underline) {
padding_x = px(0.);
}
Edges {
left: padding_x,
right: padding_x,
..Default::default()
}
}
fn inner_margins(&self, size: Size) -> Edges<Pixels> {
match size {
Size::XSmall => match self {
TabVariant::Underline => Edges {
top: px(1.),
bottom: px(2.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
Size::Small => match self {
TabVariant::Underline => Edges {
top: px(2.),
bottom: px(3.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
Size::Large => match self {
TabVariant::Underline => Edges {
top: px(5.),
bottom: px(6.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
_ => match self {
TabVariant::Underline => Edges {
top: px(3.),
bottom: px(4.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
}
}
fn normal(&self, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
borders: Edges::all(px(1.)),
border_color: cx.theme().border,
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: cx.theme().text,
bg: gpui::transparent_black(),
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
inner_bg: gpui::transparent_black(),
borders: Edges {
bottom: px(2.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
}
}
fn hovered(&self, selected: bool, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().secondary_foreground,
bg: cx.theme().secondary_hover,
borders: Edges::all(px(1.)),
border_color: cx.theme().border,
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: cx.theme().secondary_foreground,
bg: cx.theme().secondary_background,
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
inner_bg: if selected {
cx.theme().background
} else {
gpui::transparent_black()
},
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
inner_bg: gpui::transparent_black(),
borders: Edges {
bottom: px(2.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
}
}
fn selected(&self, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().tab_active_foreground,
bg: cx.theme().tab_active_background,
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
border_color: cx.theme().border,
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().text_accent,
bg: gpui::transparent_black(),
borders: Edges::all(px(1.)),
border_color: cx.theme().element_active,
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: cx.theme().element_foreground,
bg: cx.theme().element_background,
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().tab_active_foreground,
bg: gpui::transparent_black(),
inner_bg: cx.theme().background,
shadow: true,
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().tab_active_foreground,
bg: gpui::transparent_black(),
borders: Edges {
bottom: px(2.),
..Default::default()
},
border_color: cx.theme().element_active,
..Default::default()
},
}
}
fn disabled(&self, selected: bool, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().text_muted,
bg: gpui::transparent_black(),
border_color: if selected {
cx.theme().border
} else {
gpui::transparent_black()
},
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().text_muted,
bg: gpui::transparent_black(),
borders: Edges::all(px(1.)),
border_color: if selected {
cx.theme().element_active
} else {
cx.theme().border
},
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: if selected {
cx.theme().element_foreground.opacity(0.5)
} else {
cx.theme().text_muted
},
bg: if selected {
cx.theme().element_background.opacity(0.5)
} else {
gpui::transparent_black()
},
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().text_muted,
bg: cx.theme().tab_background,
inner_bg: if selected {
cx.theme().background
} else {
gpui::transparent_black()
},
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().text_muted,
bg: gpui::transparent_black(),
border_color: if selected {
cx.theme().border
} else {
gpui::transparent_black()
},
borders: Edges {
bottom: px(2.),
..Default::default()
},
..Default::default()
},
}
}
pub(super) fn tab_bar_radius(&self, size: Size, cx: &App) -> Pixels {
if *self != TabVariant::Segmented {
return px(0.);
}
match size {
Size::XSmall | Size::Small => cx.theme().radius,
Size::Large => cx.theme().radius_lg,
_ => cx.theme().radius_lg,
}
}
fn radius(&self, size: Size, cx: &App) -> Pixels {
match self {
TabVariant::Outline | TabVariant::Pill => px(99.),
TabVariant::Segmented => match size {
Size::XSmall | Size::Small => cx.theme().radius,
Size::Large => cx.theme().radius_lg,
_ => cx.theme().radius_lg,
},
_ => px(0.),
}
}
fn inner_radius(&self, size: Size, cx: &App) -> Pixels {
match self {
TabVariant::Segmented => match size {
Size::Large => self.tab_bar_radius(size, cx) - px(3.),
_ => self.tab_bar_radius(size, cx) - px(2.),
},
_ => px(0.),
}
}
}
#[allow(dead_code)]
struct TabStyle {
borders: Edges<Pixels>,
border_color: Hsla,
bg: Hsla,
fg: Hsla,
shadow: bool,
inner_bg: Hsla,
}
impl Default for TabStyle {
fn default() -> Self {
TabStyle {
borders: Edges::all(px(0.)),
border_color: gpui::transparent_white(),
bg: gpui::transparent_white(),
fg: gpui::transparent_white(),
shadow: false,
inner_bg: gpui::transparent_white(),
}
}
}
#[allow(clippy::type_complexity)]
/// A Tab element for the [`super::TabBar`].
#[derive(IntoElement)]
@@ -404,8 +23,6 @@ pub struct Tab {
pub(super) tab_bar_prefix: Option<bool>,
suffix: Option<AnyElement>,
children: Vec<AnyElement>,
variant: TabVariant,
size: Size,
pub(super) disabled: bool,
pub(super) selected: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
@@ -454,8 +71,6 @@ impl Default for Tab {
selected: false,
prefix: None,
suffix: None,
variant: TabVariant::default(),
size: Size::default(),
on_click: None,
}
}
@@ -479,36 +94,6 @@ impl Tab {
self
}
/// Set Tab Variant.
pub fn with_variant(mut self, variant: TabVariant) -> Self {
self.variant = variant;
self
}
/// Use Pill variant.
pub fn pill(mut self) -> Self {
self.variant = TabVariant::Pill;
self
}
/// Use outline variant.
pub fn outline(mut self) -> Self {
self.variant = TabVariant::Outline;
self
}
/// Use Segmented variant.
pub fn segmented(mut self) -> Self {
self.variant = TabVariant::Segmented;
self
}
/// Use Underline variant.
pub fn underline(mut self) -> Self {
self.variant = TabVariant::Underline;
self
}
/// Set the left side of the tab
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
@@ -580,42 +165,16 @@ impl Styled for Tab {
}
}
impl Sizable for Tab {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for Tab {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let mut tab_style = if self.selected {
self.variant.selected(cx)
let fg = if self.disabled {
cx.theme().text_muted
} else if self.selected {
cx.theme().tab_active_foreground
} else {
self.variant.normal(cx)
cx.theme().tab_foreground
};
let mut hover_style = self.variant.hovered(self.selected, cx);
if self.disabled {
tab_style = self.variant.disabled(self.selected, cx);
hover_style = self.variant.disabled(self.selected, cx);
}
let tab_bar_prefix = self.tab_bar_prefix.unwrap_or_default();
if !tab_bar_prefix && self.ix == 0 && self.variant == TabVariant::Tab {
tab_style.borders.left = px(0.);
hover_style.borders.left = px(0.);
}
let radius = self.variant.radius(self.size, cx);
let inner_radius = self.variant.inner_radius(self.size, cx);
let inner_paddings = self.variant.inner_paddings(self.size);
let inner_margins = self.variant.inner_margins(self.size);
let inner_height = self.variant.inner_height(self.size);
let height = self.variant.height(self.size);
self.base
.id(self.ix)
.flex()
@@ -623,74 +182,37 @@ impl RenderOnce for Tab {
.gap_1()
.items_center()
.flex_shrink_0()
.h(height)
.h(TABBAR_HEIGHT)
.relative()
.overflow_hidden()
.text_color(tab_style.fg)
.map(|this| match self.size {
Size::XSmall => this.text_xs(),
Size::Large => this.text_base(),
_ => this.text_sm(),
})
.bg(tab_style.bg)
.border_l(tab_style.borders.left)
.border_r(tab_style.borders.right)
.border_t(tab_style.borders.top)
.border_b(tab_style.borders.bottom)
.border_color(tab_style.border_color)
.rounded(radius)
.when(!self.selected && !self.disabled, |this| {
this.hover(|this| {
this.text_color(hover_style.fg)
.bg(hover_style.bg)
.border_l(hover_style.borders.left)
.border_r(hover_style.borders.right)
.border_t(hover_style.borders.top)
.border_b(hover_style.borders.bottom)
.border_color(hover_style.border_color)
.rounded(radius)
})
})
.text_color(fg)
.text_sm()
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
.flex_1()
.h(inner_height)
.h(px(30.))
.line_height(relative(1.))
.whitespace_nowrap()
.items_center()
.justify_center()
.overflow_hidden()
.margins(inner_margins)
.flex_shrink_0()
.px_3()
.map(|this| match self.icon {
Some(icon) => {
this.w(inner_height * 1.25)
.child(icon.map(|this| match self.size {
Size::XSmall => this.size_2p5(),
Size::Small => this.size_3p5(),
Size::Large => this.size_4(),
_ => this.size_4(),
}))
}
Some(icon) => this.w(px(38.)).child(icon.size_4()),
None => this
.paddings(inner_paddings)
.map(|this| match self.label {
Some(label) => this.child(label),
None => this,
})
.children(self.children),
})
.bg(tab_style.inner_bg)
.rounded(inner_radius)
.when(tab_style.shadow, |this| this.shadow_xs())
.hover(|this| this.bg(hover_style.inner_bg).rounded(inner_radius)),
}),
)
.when_some(self.suffix, |this, suffix| {
this.child(div().pr_2().child(suffix))
})
.on_mouse_down(MouseButton::Left, |_, _, cx| {
// Stop propagation behavior, for works on TitleBar.
// https://github.com/longbridge/gpui-component/issues/1836
.on_mouse_down(MouseButton::Left, |_ev, _window, cx| {
cx.stop_propagation();
})
.when(!self.disabled, |this| {
@@ -698,5 +220,21 @@ impl RenderOnce for Tab {
this.on_click(move |event, window, cx| on_click(event, window, cx))
})
})
.child(
div()
.absolute()
.bottom_0()
.left_0()
.right_0()
.h_0p5()
.when(self.selected && !self.disabled, |this| {
this.bg(cx.theme().element_active)
})
.when(!self.selected && !self.disabled, |this| {
this.invisible().group_hover("", |this| {
this.visible().bg(cx.theme().secondary_background)
})
}),
)
}
}
+8 -116
View File
@@ -2,19 +2,17 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Anchor, AnyElement, App, Div, Edges, ElementId, InteractiveElement, IntoElement, ParentElement,
Anchor, AnyElement, App, Div, ElementId, InteractiveElement, IntoElement, ParentElement,
RenderOnce, ScrollHandle, Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled,
Window, div, px,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use super::{Tab, TabVariant};
use super::Tab;
use crate::button::{Button, ButtonVariants as _};
use crate::menu::{DropdownMenu as _, PopupMenuItem};
use crate::{IconName, Selectable, Sizable, Size, StyledExt, h_flex};
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex};
#[allow(clippy::type_complexity)]
/// A TabBar element that contains multiple [`Tab`] items.
#[derive(IntoElement)]
pub struct TabBar {
@@ -26,9 +24,8 @@ pub struct TabBar {
children: SmallVec<[Tab; 2]>,
last_empty_space: AnyElement,
selected_index: Option<usize>,
variant: TabVariant,
size: Size,
menu: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
}
@@ -42,8 +39,6 @@ impl TabBar {
scroll_handle: None,
prefix: None,
suffix: None,
variant: TabVariant::default(),
size: Size::default(),
last_empty_space: div().w_3().into_any_element(),
selected_index: None,
on_click: None,
@@ -51,36 +46,6 @@ impl TabBar {
}
}
/// Set the Tab variant, all children will inherit the variant.
pub fn with_variant(mut self, variant: TabVariant) -> Self {
self.variant = variant;
self
}
/// Set the Tab variant to Pill, all children will inherit the variant.
pub fn pill(mut self) -> Self {
self.variant = TabVariant::Pill;
self
}
/// Set the Tab variant to Outline, all children will inherit the variant.
pub fn outline(mut self) -> Self {
self.variant = TabVariant::Outline;
self
}
/// Set the Tab variant to Segmented, all children will inherit the variant.
pub fn segmented(mut self) -> Self {
self.variant = TabVariant::Segmented;
self
}
/// Set the Tab variant to Underline, all children will inherit the variant.
pub fn underline(mut self) -> Self {
self.variant = TabVariant::Underline;
self
}
/// Set whether to show the menu button when tabs overflow, default is false.
pub fn menu(mut self, menu: bool) -> Self {
self.menu = menu;
@@ -105,13 +70,13 @@ impl TabBar {
self
}
/// Add children of the TabBar, all children will inherit the variant.
/// Add children of the TabBar.
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
self.children.extend(children.into_iter().map(Into::into));
self
}
/// Add child of the TabBar, tab will inherit the variant.
/// Add child of the TabBar.
pub fn child(mut self, child: impl Into<Tab>) -> Self {
self.children.push(child.into());
self
@@ -147,60 +112,8 @@ impl Styled for TabBar {
}
}
impl Sizable for TabBar {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for TabBar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let default_gap = match self.size {
Size::Small | Size::XSmall => px(8.),
Size::Large => px(16.),
_ => px(12.),
};
let (bg, paddings, gap) = match self.variant {
TabVariant::Tab => {
let padding = Edges::all(px(0.));
(cx.theme().tab_background, padding, px(0.))
}
TabVariant::Outline => {
let padding = Edges::all(px(0.));
(gpui::transparent_black(), padding, default_gap)
}
TabVariant::Pill => {
let padding = Edges::all(px(0.));
(gpui::transparent_black(), padding, px(4.))
}
TabVariant::Segmented => {
let padding_x = match self.size {
Size::XSmall => px(2.),
Size::Small => px(3.),
_ => px(4.),
};
let padding = Edges {
left: padding_x,
right: padding_x,
..Default::default()
};
(cx.theme().tab_background, padding, px(2.))
}
TabVariant::Underline => {
// This gap is same as the tab inner_paddings
let gap = match self.size {
Size::XSmall => px(10.),
Size::Small => px(12.),
Size::Large => px(20.),
_ => px(16.),
};
(gpui::transparent_black(), Edges::all(px(0.)), gap)
}
};
fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement {
let mut item_labels = Vec::new();
let selected_index = self.selected_index;
let on_click = self.on_click.clone();
@@ -210,25 +123,6 @@ impl RenderOnce for TabBar {
.relative()
.flex()
.items_center()
.bg(bg)
.text_color(cx.theme().tab_foreground)
.when(
self.variant == TabVariant::Underline || self.variant == TabVariant::Tab,
|this| {
this.child(
div()
.id("border-b")
.absolute()
.left_0()
.bottom_0()
.size_full()
.border_b_1()
.border_color(cx.theme().border),
)
},
)
.rounded(self.variant.tab_bar_radius(self.size, cx))
.paddings(paddings)
.refine_style(&self.style)
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
@@ -239,15 +133,13 @@ impl RenderOnce for TabBar {
.when_some(self.scroll_handle, |this, scroll_handle| {
this.track_scroll(&scroll_handle)
})
.gap(gap)
.gap(px(0.))
.children(self.children.into_iter().enumerate().map(|(ix, child)| {
item_labels.push((child.label.clone(), child.disabled));
let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
child
.ix(ix)
.tab_bar_prefix(tab_bar_prefix)
.with_variant(self.variant)
.with_size(self.size)
.when_some(self.selected_index, |this, selected_ix| {
this.selected(selected_ix == ix)
})
-3
View File
@@ -12,7 +12,6 @@ use theme::ActiveTheme;
use crate::{Icon, IconName, InteractiveElementExt as _, Sizable as _, StyledExt, h_flex};
pub const TITLE_BAR_HEIGHT: Pixels = px(34.);
#[cfg(target_os = "macos")]
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
/// TitleBar used to customize the appearance of the title bar.
@@ -284,8 +283,6 @@ impl RenderOnce for TitleBar {
this.px_2()
}
})
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().title_bar)
.refine_style(&self.style)
.when(is_linux, |this| {
+3
View File
@@ -14,10 +14,13 @@ chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
settings = { path = "../settings" }
person = { path = "../person" }
auto_update = { path = "../auto_update" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
nostr-connect.workspace = true
browser-signer-proxy = { path = "../browser-signer-proxy" }
anyhow.workspace = true
serde.workspace = true
+68 -35
View File
@@ -1,17 +1,16 @@
use std::time::Duration;
use anyhow::{Error, anyhow};
use gpui::prelude::FluentBuilder;
use gpui::{
AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window, div,
};
use instant::Duration;
use nostr_connect::prelude::*;
use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{Input, InputEvent, InputState};
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
use ui::{Disableable, StyledExt, WindowExtension, divider, v_flex};
#[derive(Debug)]
pub struct ImportIdentity {
@@ -36,7 +35,7 @@ pub struct ImportIdentity {
impl ImportIdentity {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let key_input = cx.new(|cx| InputState::new(window, cx));
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("nsec or bunker://"));
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
@@ -135,7 +134,6 @@ impl ImportIdentity {
})?;
}
}
Ok(())
}));
}
@@ -146,7 +144,7 @@ impl ImportIdentity {
let password = uri.to_string();
let save = cx.write_credentials(USER_KEYRING, "bunker", password.as_bytes());
cx.spawn_in(window, async move |_this, cx| {
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
let keys = master_keys.await;
let timeout = Duration::from_secs(30);
@@ -162,9 +160,16 @@ impl ImportIdentity {
cx.notify();
});
Ok::<(), anyhow::Error>(())
})
.detach();
Ok(())
}));
}
#[cfg(not(target_arch = "wasm32"))]
fn proxy(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.connect_proxy(cx);
});
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
@@ -202,50 +207,78 @@ impl ImportIdentity {
impl Render for ImportIdentity {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
const MSG: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
const KEY_WARN: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
let is_wasm = cfg!(target_arch = "wasm32");
let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1");
let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password;
let bunker_warning = self.key_input.read(cx).value().starts_with("bunker://");
v_flex()
.size_full()
.gap_2()
.gap_4()
.text_sm()
.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child("Continue with existing key or bunker connection")
.child(Input::new(&self.key_input)),
.gap_2()
.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child("Continue with existing key or bunker connection")
.child(Input::new(&self.key_input)),
)
.when(require_password, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child("Decrypt Password:")
.child(Input::new(&self.pass_input)),
)
})
.when(bunker_warning, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_warning)
.child(div().child(BUNKER_WARN)),
)
})
.when(key_warning, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_warning)
.child(div().child(KEY_WARN)),
)
}),
)
.when(require_password, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child("Decrypt Password:")
.child(Input::new(&self.pass_input)),
)
})
.when(key_warning, |this| {
this.child(
div()
.v_flex()
.text_xs()
.text_color(cx.theme().text_warning)
.child(div().font_semibold().child("Warning"))
.child(div().child(MSG)),
)
})
.child(
Button::new("login")
.label("Continue")
.primary()
.font_semibold()
.loading(self.loading)
.disabled(self.loading)
.on_click(cx.listener(move |this, _, window, cx| {
.on_click(cx.listener(move |this, _ev, window, cx| {
this.login(window, cx);
})),
)
.child(divider(cx))
.when(!is_wasm, |this| {
this.child(
Button::new("proxy")
.label("Connect via Web Extension (Experimental)")
.ghost_alt()
.loading(self.loading)
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.proxy(cx);
})),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
+1 -1
View File
@@ -1,4 +1,4 @@
use std::time::Duration;
use instant::Duration;
use anyhow::Error;
use device::DeviceRegistry;
+50 -46
View File
@@ -1,5 +1,4 @@
use std::collections::HashMap;
use std::time::Duration;
use anyhow::Error;
use common::TimestampExt;
@@ -8,6 +7,7 @@ use gpui::{
App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render,
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use smallvec::{SmallVec, smallvec};
@@ -16,7 +16,7 @@ use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
cx.new(|cx| Screening::new(public_key, window, cx))
@@ -55,7 +55,7 @@ impl Screening {
window.close_all_modals(cx);
}));
cx.defer_in(window, move |this, _window, cx| {
cx.defer_in(window, |this, _window, cx| {
this.check_contact(cx);
this.check_wot(cx);
this.check_last_activity(cx);
@@ -84,8 +84,20 @@ impl Screening {
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
// Check if user is in contact list
let contacts = client.database().contacts_public_keys(current_user).await;
let followed = contacts.unwrap_or_default().contains(&public_key);
let filter = Filter::new()
.author(current_user)
.kind(Kind::ContactList)
.limit(1);
let followed = client
.database()
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.next()
.map(|event| event.tags.public_keys().any(|k| k == public_key))
.unwrap_or(false);
Ok(followed)
});
@@ -228,13 +240,13 @@ impl Screening {
let public_key = self.public_key;
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let tag = Nip56Tag::PublicKey {
let tag = Tag::from(Nip56Tag::PublicKey {
public_key,
report: Report::Impersonation,
}
.to_tag();
});
let event = EventBuilder::report(vec![tag], "")
let event = EventBuilder::new(Kind::Reporting, "")
.tag(tag)
.finalize_async(&signer)
.await?;
@@ -262,7 +274,7 @@ impl Screening {
let contacts = contacts.clone();
let total = contacts.len();
this.title(SharedString::from("Mutual contacts")).child(
this.title("Mutual contacts").child(
v_flex().gap_1().pb_2().child(
uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx);
@@ -341,7 +353,7 @@ impl Render for Screening {
.h_7()
.justify_center()
.rounded_full()
.bg(cx.theme().surface_background)
.bg(cx.theme().elevated_surface_background)
.text_sm()
.truncate()
.text_ellipsis()
@@ -354,7 +366,8 @@ impl Render for Screening {
.gap_1()
.child(
Button::new("njump")
.label("View on njump.me")
.icon(IconName::Link)
.label("njump.me")
.secondary()
.small()
.rounded()
@@ -385,21 +398,18 @@ impl Render for Screening {
.text_sm()
.child(status_badge(Some(self.followed), cx))
.child(
v_flex()
.text_sm()
.child(SharedString::from("Contact"))
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
v_flex().text_sm().child("Contact").child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
),
)
.child(
@@ -414,7 +424,7 @@ impl Render for Screening {
.child(
h_flex()
.gap_0p5()
.child(SharedString::from("Activity on Public Relays"))
.child("Activity on Public Relays")
.child(
Button::new("active")
.icon(IconName::Info)
@@ -483,25 +493,8 @@ impl Render for Screening {
.gap_2()
.child(status_badge(Some(mutuals > 0), cx))
.child(
v_flex()
h_flex()
.text_sm()
.child(
h_flex()
.gap_0p5()
.child(SharedString::from("Mutual contacts"))
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.on_click(cx.listener(
move |this, _, window, cx| {
this.mutual_contacts(window, cx);
},
)),
),
)
.child(
div()
.line_clamp(1)
@@ -513,6 +506,17 @@ impl Render for Screening {
SharedString::from(NO_MUTUAL)
}
}),
)
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.disabled(mutuals == 0)
.on_click(cx.listener(move |this, _, window, cx| {
this.mutual_contacts(window, cx);
})),
),
),
),
+1 -49
View File
@@ -3,7 +3,7 @@ use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Window, div, px,
};
use settings::{AppSettings, AuthMode};
use settings::AppSettings;
use theme::{ActiveTheme, Theme, ThemeMode};
use ui::button::{Button, ButtonVariants};
use ui::group_box::{GroupBox, GroupBoxVariants};
@@ -60,13 +60,11 @@ impl Render for Preferences {
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
const MODE: &str = "Use the selected light or dark theme, or to follow the OS.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
const AUTH: &str = "Choose the authentication behavior for relays.";
const RESET: &str = "Reset the theme to the default one.";
let screening = AppSettings::get_screening(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let nip4e = AppSettings::get_nip4e(cx);
let auth_mode = AppSettings::get_auth_mode(cx);
let theme_mode = AppSettings::get_theme_mode(cx);
v_flex()
@@ -93,52 +91,6 @@ impl Render for Preferences {
.on_click(move |_, _window, cx| {
AppSettings::update_hide_avatar(!hide_avatar, cx);
}),
)
.child(
h_flex()
.gap_3()
.justify_between()
.child(
v_flex()
.child(
div()
.text_sm()
.child(SharedString::from("Relay authentication")),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(AUTH)),
),
)
.child(
Button::new("auth")
.label(auth_mode.to_string())
.ghost_alt()
.small()
.dropdown_menu(|this, _window, _cx| {
this.min_w(px(256.))
.item(
PopupMenuItem::new("Auto authentication").on_click(
|_ev, _window, cx| {
AppSettings::update_auth_mode(
AuthMode::Auto,
cx,
);
},
),
)
.item(PopupMenuItem::new("Ask every time").on_click(
|_ev, _window, cx| {
AppSettings::update_auth_mode(
AuthMode::Manual,
cx,
);
},
))
}),
),
),
)
.child(
+98 -144
View File
@@ -1,20 +1,21 @@
use std::sync::Arc;
use ::settings::AppSettings;
use anyhow::Error;
use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry};
use common::{CoopImageCache, download_dir};
use common::download_dir;
use device::{DeviceEvent, DeviceRegistry};
use gpui::prelude::FluentBuilder;
use gpui::{
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div,
image_cache, px, relative,
Render, SharedString, Styled, Subscription, Task, Window, div, px,
};
use nostr_sdk::prelude::*;
use person::{PersonRegistry, shorten_pubkey};
use serde::Deserialize;
use smallvec::{SmallVec, smallvec};
use state::{IMAGE_CACHE_SIZE, NostrRegistry, StateEvent};
use state::{NostrRegistry, StateEvent};
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
@@ -26,7 +27,8 @@ use ui::{Icon, IconName, Root, Sizable, TitleBar, WindowExtension, h_flex, v_fle
use crate::dialogs::import::ImportIdentity;
use crate::dialogs::restore::RestoreEncryption;
use crate::dialogs::settings;
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list, trash};
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list};
use crate::sidebar::Sidebar;
mod dialogs;
mod panels;
@@ -43,13 +45,12 @@ struct MsgRelayNotification;
#[action(namespace = workspace, no_json)]
enum Command {
ToggleTheme,
Update,
RefreshMessagingRelays,
BackupEncryption,
ImportEncryption,
RefreshEncryption,
ResetEncryption,
ShowRelayList,
ShowMessaging,
ShowProfile,
@@ -59,11 +60,12 @@ enum Command {
}
pub struct Workspace {
sidebar: Entity<Sidebar>,
/// App's Dock Area
dock: Entity<DockArea>,
/// App's Image Cache
image_cache: Entity<CoopImageCache>,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 6]>,
@@ -75,20 +77,11 @@ impl Workspace {
let device = DeviceRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
let dock = cx.new(|cx| DockArea::new(window, cx));
let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx);
let mut subscriptions = smallvec![];
subscriptions.push(
// Observe sign in state changes
cx.observe_in(&nostr, window, move |this, nostr, window, cx| {
if nostr.read(cx).current_user().is_some() {
this.set_center_layout(window, cx);
}
}),
);
subscriptions.push(
// Observe system appearance and update theme
cx.observe_window_appearance(window, |_this, window, cx| {
@@ -101,11 +94,7 @@ impl Workspace {
cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| {
match event {
StateEvent::SignerChanged => {
this.set_center_layout(window, cx);
cx.defer_in(window, |_this, window, cx| {
window.close_all_modals(cx);
});
window.close_all_modals(cx);
}
StateEvent::NoSigner => {
this.import_identity(window, cx);
@@ -226,25 +215,21 @@ impl Workspace {
}),
);
subscriptions.push(
// Observe the chat registry
cx.observe(&chat, move |this, chat, cx| {
let ids = this.panel_ids(cx);
chat.update(cx, |this, cx| {
this.refresh_rooms(&ids, cx);
});
}),
);
// Set the layout at the end of cycle
cx.defer_in(window, |this, window, cx| {
this.set_layout(window, cx);
let dock = this.dock.downgrade();
let greeter = Arc::new(greeter::init(window, cx));
let tabs = DockItem::tabs(vec![greeter], None, &dock, window, cx);
let center = DockItem::split(Axis::Vertical, vec![tabs], &dock, window, cx);
this.dock.update(cx, |this, cx| {
this.set_center(center, window, cx);
});
});
Self {
sidebar,
dock,
image_cache,
tasks: vec![],
_subscriptions: subscriptions,
}
}
@@ -265,40 +250,6 @@ impl Workspace {
}
}
/// Get all panel ids
fn panel_ids(&self, cx: &App) -> Vec<u64> {
self.dock
.read(cx)
.items
.panel_ids(cx)
.into_iter()
.filter_map(|panel| panel.parse::<u64>().ok())
.collect()
}
/// Set the dock layout
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
// Update the dock layout with sidebar on the left
self.dock.update(cx, |this, cx| {
this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx);
});
}
/// Set the center dock layout
fn set_center_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let dock = self.dock.downgrade();
let greeter = Arc::new(greeter::init(window, cx));
let tabs = DockItem::tabs(vec![greeter], None, &dock, window, cx);
let center = DockItem::split(Axis::Vertical, vec![tabs], &dock, window, cx);
// Update the layout with center dock
self.dock.update(cx, |this, cx| {
this.set_center(center, window, cx);
});
}
/// Handle command events
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
@@ -320,7 +271,7 @@ impl Workspace {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(profile::init(public_key, window, cx)),
DockPlacement::Right,
DockPlacement::Left,
window,
cx,
);
@@ -331,7 +282,7 @@ impl Workspace {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(contact_list::init(window, cx)),
DockPlacement::Right,
DockPlacement::Left,
window,
cx,
);
@@ -341,7 +292,7 @@ impl Workspace {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(backup::init(window, cx)),
DockPlacement::Right,
DockPlacement::Left,
window,
cx,
);
@@ -351,7 +302,7 @@ impl Workspace {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(messaging_relays::init(window, cx)),
DockPlacement::Right,
DockPlacement::Left,
window,
cx,
);
@@ -361,7 +312,7 @@ impl Workspace {
let chat = ChatRegistry::global(cx);
// Trigger a refresh of the chat registry
chat.update(cx, |this, cx| {
this.refresh(cx);
this.reload(cx);
});
}
Command::ShowRelayList => {
@@ -390,7 +341,7 @@ impl Workspace {
let device = DeviceRegistry::global(cx).downgrade();
let save_dialog = cx.prompt_for_new_path(download_dir(), Some("encryption.txt"));
cx.spawn_in(window, async move |_this, cx| {
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
// Get the output path from the save dialog
let output_path = match save_dialog.await {
Ok(Ok(Some(path))) => path,
@@ -417,13 +368,17 @@ impl Workspace {
cx.open_with_system(output_path.as_path());
})?;
Ok::<_, anyhow::Error>(())
})
.detach();
Ok(())
}));
}
Command::ImportEncryption => {
self.import_encryption(window, cx);
}
Command::Update => {
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
auto_updater.update(cx, |this, cx| this.check(cx));
}
}
}
}
@@ -479,10 +434,11 @@ impl Workspace {
let import = cx.new(|cx| ImportIdentity::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(420.))
this.width(px(450.))
.show_close(false)
.overlay_closable(false)
.title("Import Identity")
.keyboard(false)
.title("Onboarding")
.child(import.clone())
});
}
@@ -595,7 +551,7 @@ impl Workspace {
.caret()
.compact()
.transparent()
.dropdown_menu(move |this, _window, _cx| {
.dropdown_menu(move |this, _window, cx| {
let avatar = avatar.clone();
let name = name.clone();
@@ -629,7 +585,15 @@ impl Workspace {
IconName::Sun,
Box::new(Command::ToggleTheme),
)
.separator()
// Only offer in-app updates when auto-update is
// enabled (managed channels update themselves).
.when(AutoUpdater::is_available(cx), |this| {
this.separator().menu_with_icon(
"Check for Updates",
IconName::Device,
Box::new(Command::Update),
)
})
.menu_with_icon(
"Settings",
IconName::Settings,
@@ -641,10 +605,9 @@ impl Workspace {
}
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let auto_updater = AutoUpdater::try_global(cx);
let chat = ChatRegistry::global(cx);
let trash_messages = chat.read(cx).count_trash_messages(cx);
let is_nip4e_enabled = AppSettings::get_nip4e(cx);
let nip4e_enabled = AppSettings::get_nip4e(cx);
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
@@ -655,42 +618,36 @@ impl Workspace {
let profile = persons.read(cx).get(&public_key, cx);
let announcement = profile.announcement();
let updater_status = auto_updater.as_ref().and_then(|updater| {
let updater = updater.read(cx);
(!updater.idle()).then(|| updater.status())
});
let staged_update = auto_updater
.as_ref()
.is_some_and(|updater| updater.read(cx).staged());
h_flex()
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
.gap_2()
.when(trash_messages > 0, |this| {
.when_some(updater_status, |this, status| {
this.child(div().text_xs().italic().child(status))
})
.when(staged_update, |this| {
this.child(
h_flex()
.id("trash-messages")
.h_6()
.px_1()
.gap_1()
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.child(
Icon::new(IconName::Warning)
.small()
.text_color(cx.theme().text_danger),
)
.child(
div()
.text_xs()
.line_height(relative(1.))
.child(format!("{trash_messages}")),
)
.on_click(move |_ev, window, cx| {
cx.stop_propagation();
// Add the trash panel to the center workspace
Self::add_panel(
trash::init(window, cx),
DockPlacement::Center,
window,
cx,
);
}),
Button::new("restart-to-update")
.label("Restart to Update")
.tooltip("Quit and relaunch into the installed update")
.small()
.ghost()
.on_click(cx.listener(|_this, _event, _window, cx| {
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
auto_updater.update(cx, |this, cx| this.restart(cx));
}
})),
)
})
.when(is_nip4e_enabled, |this| {
.when(nip4e_enabled, |this| {
this.child(
Button::new("key")
.icon(IconName::UserKey)
@@ -782,17 +739,7 @@ impl Workspace {
.w_full()
.text_sm()
.justify_between()
.child(
h_flex()
.gap_2()
.child(
div()
.size_1p5()
.rounded_full()
.bg(cx.theme().icon_accent),
)
.child(url.clone()),
)
.child(url.clone())
.child(
div()
.text_xs()
@@ -804,11 +751,6 @@ impl Workspace {
// Footer
menu.separator()
.menu_with_icon(
"Reload",
IconName::Refresh,
Box::new(Command::RefreshMessagingRelays),
)
.menu_with_icon(
"Manage gossip relays",
IconName::Relay,
@@ -816,9 +758,15 @@ impl Workspace {
)
.menu_with_icon(
"Manage messaging relays",
IconName::Settings,
IconName::Relay,
Box::new(Command::ShowMessaging),
)
.separator()
.menu_with_icon(
"Reload",
IconName::Refresh,
Box::new(Command::RefreshMessagingRelays),
)
}),
)
}
@@ -830,24 +778,30 @@ impl Render for Workspace {
let notification_layer = Root::render_notification_layer(window, cx);
div()
.id(SharedString::from("workspace"))
.id("workspace")
.on_action(cx.listener(Self::on_command))
.relative()
.size_full()
.child(
image_cache(self.image_cache.clone())
.relative()
v_flex()
.size_full()
// Title Bar
.child(
v_flex()
TitleBar::new()
.child(self.titlebar_left(cx))
.child(self.titlebar_right(cx)),
)
// Main
.child(
h_flex()
.size_full()
// Title Bar
.child(
TitleBar::new()
.child(self.titlebar_left(cx))
.child(self.titlebar_right(cx)),
div()
.flex_shrink_0()
.h_full()
.w(SIDEBAR_WIDTH)
.child(self.sidebar.clone()),
)
// Dock
.child(self.dock.clone()),
),
)
+3 -14
View File
@@ -1,10 +1,9 @@
use std::time::Duration;
use anyhow::Error;
use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use state::USER_KEYRING;
use theme::ActiveTheme;
@@ -155,12 +154,7 @@ impl Render for BackupPanel {
.text_color(cx.theme().text_muted)
.child(SharedString::from("Public Key:")),
)
.child(
Input::new(&self.npub_input)
.small()
.bordered(false)
.disabled(true),
),
.child(Input::new(&self.npub_input).small().disabled(true)),
)
.child(
v_flex()
@@ -173,12 +167,7 @@ impl Render for BackupPanel {
.text_color(cx.theme().text_muted)
.child(SharedString::from("Secret Key:")),
)
.child(
Input::new(&self.nsec_input)
.small()
.bordered(false)
.disabled(true),
),
.child(Input::new(&self.nsec_input).small().disabled(true)),
)
.child(
Button::new("copy")
+91 -78
View File
@@ -1,13 +1,13 @@
use std::collections::HashSet;
use std::time::Duration;
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Task, TextAlign, Window, div, rems,
Task, TextAlign, Window, div, rems, retain_all,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
@@ -17,6 +17,7 @@ use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::input::{Input, InputEvent, InputState};
use ui::scroll::ScrollableElement;
use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
@@ -87,7 +88,20 @@ impl ContactListPanel {
};
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let contact_list = client.database().contacts_public_keys(public_key).await?;
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contact_list: HashSet<PublicKey> = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
Ok(contact_list)
});
@@ -220,8 +234,7 @@ impl ContactListPanel {
.px_2()
.justify_between()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary_background)
.text_color(cx.theme().secondary_foreground)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.child(
h_flex()
.gap_2()
@@ -283,79 +296,79 @@ impl Focusable for ContactListPanel {
impl Render for ContactListPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex().p_3().gap_3().w_full().child(
v_flex()
.gap_2()
.flex_1()
.w_full()
.text_sm()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("New contact:")),
)
.child(
v_flex()
.gap_1()
.child(
h_flex()
.gap_1()
.w_full()
.child(
Input::new(&self.input)
.small()
.bordered(false)
.cleanable(true),
)
.child(
Button::new("add")
.icon(IconName::Plus)
.tooltip("Add contact")
.ghost()
.size(rems(2.))
.on_click(cx.listener(move |this, _, window, cx| {
this.add(window, cx);
})),
),
)
.when_some(self.error.as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().text_danger)
.child(error.clone()),
v_flex()
.image_cache(retain_all("contact-list-panel"))
.p_3()
.gap_3()
.w_full()
.overflow_y_scrollbar()
.child(
v_flex()
.gap_2()
.flex_1()
.w_full()
.text_sm()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child("New contact:"),
)
.child(
v_flex()
.gap_1()
.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&self.input).small().cleanable(true))
.child(
Button::new("add")
.icon(IconName::Plus)
.tooltip("Add contact")
.ghost()
.size(rems(2.))
.on_click(cx.listener(move |this, _, window, cx| {
this.add(window, cx);
})),
),
)
}),
)
.map(|this| {
if self.contacts.is_empty() {
this.child(self.render_empty(window, cx))
} else {
this.child(
v_flex()
.gap_1()
.flex_1()
.w_full()
.children(self.render_list_items(cx)),
)
}
})
.child(
Button::new("submit")
.icon(IconName::CheckCircle)
.label("Update")
.primary()
.small()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.update(window, cx);
})),
),
)
.when_some(self.error.as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_color(cx.theme().text_danger)
.child(error.clone()),
)
}),
)
.map(|this| {
if self.contacts.is_empty() {
this.child(self.render_empty(window, cx))
} else {
this.child(
v_flex()
.gap_1()
.flex_1()
.w_full()
.children(self.render_list_items(cx)),
)
}
})
.child(
Button::new("submit")
.icon(IconName::CheckCircle)
.label("Update")
.primary()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.update(window, cx);
})),
),
)
}
}
+17 -10
View File
@@ -1,6 +1,7 @@
use anyhow::Error;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window, div, svg,
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div, svg,
};
use state::NostrRegistry;
use theme::ActiveTheme;
@@ -8,8 +9,8 @@ use ui::button::{Button, ButtonVariants};
use ui::dock::{DockPlacement, Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use crate::Workspace;
use crate::panels::profile;
use crate::{Command, Workspace};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
cx.new(|cx| GreeterPanel::new(window, cx))
@@ -18,6 +19,7 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
pub struct GreeterPanel {
name: SharedString,
focus_handle: FocusHandle,
tasks: Vec<Task<Result<(), Error>>>,
}
impl GreeterPanel {
@@ -25,6 +27,7 @@ impl GreeterPanel {
Self {
name: "Onboarding".into(),
focus_handle: cx.focus_handle(),
tasks: vec![],
}
}
@@ -32,7 +35,7 @@ impl GreeterPanel {
let nostr = NostrRegistry::global(cx);
if let Some(public_key) = nostr.read(cx).current_user() {
cx.spawn_in(window, async move |_this, cx| {
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
cx.update(|window, cx| {
Workspace::add_panel(
profile::init(public_key, window, cx),
@@ -42,8 +45,9 @@ impl GreeterPanel {
);
})
.ok();
})
.detach();
Ok(())
}));
}
}
}
@@ -142,17 +146,20 @@ impl Render for GreeterPanel {
.ghost()
.small()
.justify_start()
.on_click(cx.listener(move |this, _ev, window, cx| {
.on_click(cx.listener(move |this, _, window, cx| {
this.add_profile_panel(window, cx)
})),
)
.child(
Button::new("invite")
.icon(Icon::new(IconName::Invite))
.label("Invite friends")
Button::new("theme")
.icon(Icon::new(IconName::Moon))
.label("Change theme")
.ghost()
.small()
.justify_start(),
.justify_start()
.on_click(cx.listener(move |_, _, _, cx| {
cx.dispatch_action(&Command::ToggleTheme);
})),
),
),
),
@@ -1,5 +1,4 @@
use std::collections::HashSet;
use std::time::Duration;
use anyhow::{Error, anyhow};
use gpui::prelude::FluentBuilder;
@@ -8,6 +7,7 @@ use gpui::{
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Task, TextAlign, Window, div, rems,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
@@ -93,7 +93,7 @@ impl MessagingRelayPanel {
.author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
Ok(nip17::extract_relay_list(&event).collect())
} else {
Err(anyhow!("Not found."))
@@ -177,7 +177,7 @@ impl MessagingRelayPanel {
let tags: Vec<Tag> = self
.relays
.iter()
.map(|relay| Nip17Tag::Relay(relay.to_owned()).to_tag())
.map(|relay| Tag::from(Nip17Tag::Relay(relay.to_owned())))
.collect();
// Set updating state
@@ -320,12 +320,7 @@ impl Render for MessagingRelayPanel {
h_flex()
.gap_1()
.w_full()
.child(
Input::new(&self.input)
.small()
.bordered(false)
.cleanable(true),
)
.child(Input::new(&self.input).small().cleanable(true))
.child(
Button::new("add")
.icon(IconName::Plus)
@@ -365,7 +360,6 @@ impl Render for MessagingRelayPanel {
.icon(IconName::CheckCircle)
.label("Update")
.primary()
.small()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)
-1
View File
@@ -4,4 +4,3 @@ pub mod greeter;
pub mod messaging_relays;
pub mod profile;
pub mod relay_list;
pub mod trash;
+16 -13
View File
@@ -1,12 +1,12 @@
use std::str::FromStr;
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error};
use anyhow::Error;
use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
Window, div,
Window, div, retain_all,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use settings::AppSettings;
@@ -132,7 +132,7 @@ impl ProfilePanel {
cx.notify();
if status {
cx.spawn_in(window, async move |this, cx| {
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
// Reset the copied state after a delay
@@ -143,8 +143,9 @@ impl ProfilePanel {
.ok();
})
.ok();
})
.detach();
Ok(())
}));
}
}
@@ -166,13 +167,15 @@ impl ProfilePanel {
});
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 {
return Ok(());
};
this.update(cx, |this, cx| {
this.set_uploading(true, cx);
})?;
let mut paths = path.await??.context("Not found")?;
let path = paths.pop().context("No path")?;
// Upload via blossom client
match upload(server, path, cx).await {
Ok(url) => {
@@ -318,6 +321,7 @@ impl Render for ProfilePanel {
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
v_flex()
.image_cache(retain_all("profile-panel"))
.p_3()
.gap_3()
.w_full()
@@ -352,7 +356,7 @@ impl Render for ProfilePanel {
.text_color(cx.theme().text_muted)
.child(SharedString::from("What should people call you?")),
)
.child(Input::new(&self.name_input).bordered(false).small()),
.child(Input::new(&self.name_input).small()),
)
.child(
v_flex()
@@ -363,7 +367,7 @@ impl Render for ProfilePanel {
.text_color(cx.theme().text_muted)
.child(SharedString::from("A short introduction about you:")),
)
.child(Input::new(&self.bio_input).bordered(false).small()),
.child(Input::new(&self.bio_input).small()),
)
.child(
v_flex()
@@ -374,7 +378,7 @@ impl Render for ProfilePanel {
.text_color(cx.theme().text_muted)
.child(SharedString::from("Website:")),
)
.child(Input::new(&self.website_input).bordered(false).small()),
.child(Input::new(&self.website_input).small()),
)
.child(
v_flex()
@@ -418,7 +422,6 @@ impl Render for ProfilePanel {
.icon(IconName::CheckCircle)
.label("Update")
.primary()
.small()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)

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