Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d082f4fad9 | ||
|
|
8c59eabba3 | ||
|
|
bc6bdb3c35 | ||
|
|
dbfee32d55 | ||
|
|
4f52fc52df | ||
|
|
fbf06f2c81 | ||
|
|
b0d1521c49 | ||
|
|
9dbac5308d | ||
|
|
c8cabb7cd2 | ||
|
|
6d9284b37a |
@@ -19,8 +19,8 @@ dist/
|
||||
|
||||
# Useless stuffs
|
||||
.DS_Store
|
||||
# Added by goreleaser init:
|
||||
.intentionally-empty-file.o
|
||||
|
||||
.cargo/
|
||||
vendor/
|
||||
wasm/
|
||||
node_modules/
|
||||
|
||||
@@ -24,13 +24,12 @@ nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
chrono = "0.4.38"
|
||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
futures = "0.3"
|
||||
itertools = "0.13.0"
|
||||
log = "0.4"
|
||||
@@ -43,7 +42,9 @@ 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"
|
||||
|
||||
[patch.crates-io]
|
||||
# Use stacker's psm version which may have better WASM support
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/*", "desktop", "web"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.0-beta5"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_windows = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_macos = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
futures = "0.3"
|
||||
itertools = "0.13.0"
|
||||
log = "0.4"
|
||||
oneshot = "0.1.10"
|
||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
schemars = "1"
|
||||
smallvec = "1.14.0"
|
||||
smol = "2"
|
||||
webbrowser = "1.0.4"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
errno = { version = "0.3.14", default-features = false }
|
||||
instant = "0.1"
|
||||
|
||||
[patch.crates-io]
|
||||
# Use stacker's psm version which may have better WASM support
|
||||
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[profile.profiling]
|
||||
inherits = "release"
|
||||
debug = true
|
||||
@@ -1,125 +1,5 @@
|
||||

|
||||
|
||||
<p>
|
||||
<a href="https://github.com/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
|
||||
|
||||
@@ -5,17 +5,8 @@ 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_json.workspace = true
|
||||
|
||||
semver = "1.0.27"
|
||||
tempfile = "3.23.0"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] }
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Window};
|
||||
use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version};
|
||||
use instant::{Duration, Instant};
|
||||
|
||||
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;
|
||||
|
||||
const GITHUB_API_URL: &str = "https://api.github.com";
|
||||
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||
|
||||
fn get_github_repo_owner() -> String {
|
||||
@@ -30,12 +15,15 @@ fn get_github_repo_name() -> 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()
|
||||
}
|
||||
|
||||
/// Initialize the auto-update system.
|
||||
///
|
||||
/// Skips initialization when running as a Flatpak (updates are handled by the
|
||||
/// Flatpak distribution channel). Otherwise creates the global [`AutoUpdater`]
|
||||
/// entity and schedules a check for updates after a 2-minute delay.
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
// Skip auto-update initialization if installed via Flatpak
|
||||
if is_flatpak_installation() {
|
||||
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
|
||||
return;
|
||||
@@ -48,516 +36,145 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||
|
||||
impl Global for GlobalAutoUpdater {}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
struct InstallerDir(tempfile::TempDir);
|
||||
/// Observable auto-update status — re-exported from [`gpui_updater::UpdateStatus`].
|
||||
pub use gpui_updater::UpdateStatus as AutoUpdateStatus;
|
||||
|
||||
#[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)]
|
||||
/// The global auto-updater entity.
|
||||
///
|
||||
/// Wraps [`gpui_updater::Updater`] with Coop-specific configuration
|
||||
/// (GitHub repo, Flatpak detection, delayed auto-check).
|
||||
///
|
||||
/// Retrieve the global instance via [`AutoUpdater::global`].
|
||||
pub struct AutoUpdater {
|
||||
/// Current status of the auto updater
|
||||
pub status: AutoUpdateStatus,
|
||||
|
||||
/// Current version of the application
|
||||
/// The underlying gpui-updater entity that does the heavy lifting.
|
||||
pub updater: Entity<Updater>,
|
||||
/// Currently running app version.
|
||||
pub version: Version,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
/// Background tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
/// Keeps the observer subscription alive.
|
||||
_subscription: Subscription,
|
||||
/// When the last error was recorded, so we can reset to idle after 5s.
|
||||
error_time: Option<Instant>,
|
||||
}
|
||||
|
||||
impl AutoUpdater {
|
||||
/// Retrieve the global auto updater instance
|
||||
/// 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![];
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the status
|
||||
cx.observe_self(|this, cx| {
|
||||
if let AutoUpdateStatus::Checked { download_url } = this.status.clone() {
|
||||
this.download_and_install(&download_url, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Run at the end of current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.check(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
status: AutoUpdateStatus::Idle,
|
||||
version,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
|
||||
self.status = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn check(&mut self, cx: &mut Context<Self>) {
|
||||
let version = self.version.clone();
|
||||
let duration = Duration::from_secs(120);
|
||||
let task = self.check_for_updates(version, cx);
|
||||
|
||||
// Check for updates after 2 minutes
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(duration).await;
|
||||
|
||||
// Update the status to checking
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Checking, cx);
|
||||
})?;
|
||||
|
||||
match task.await {
|
||||
Ok(download_url) => {
|
||||
// Update the status to checked with download URL
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::checked(download_url), cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to check for updates: {e}");
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Idle, cx);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn check_for_updates(&self, version: Version, cx: &App) -> Task<Result<String, Error>> {
|
||||
let http_client = cx.http_client();
|
||||
let repo_owner = get_github_repo_owner();
|
||||
let repo_name = get_github_repo_name();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let url = format!(
|
||||
"{}/repos/{}/{}/releases/latest",
|
||||
GITHUB_API_URL, repo_owner, repo_name
|
||||
);
|
||||
let source =
|
||||
GitHubSource::new(&repo_owner, &repo_name).asset_contains(match std::env::consts::OS {
|
||||
"macos" => "macos",
|
||||
"linux" => "linux",
|
||||
_ => "",
|
||||
});
|
||||
|
||||
let async_body = AsyncBody::default();
|
||||
let mut body = Vec::new();
|
||||
let mut response = http_client.get(&url, async_body, false).await?;
|
||||
let updater: Entity<Updater> =
|
||||
cx.new(|cx| Updater::new(source, EngineConfig::new(version.clone()), cx));
|
||||
|
||||
// Read the response body into a vector
|
||||
response.body_mut().read_to_end(&mut body).await?;
|
||||
// When an update becomes available, automatically download and install it.
|
||||
let subscription = cx.observe(&updater, |this: &mut AutoUpdater, _updater, cx| {
|
||||
let status = this.updater.read(cx).status().clone();
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("GitHub API returned error: {}", response.status()));
|
||||
if matches!(status, UpdateStatus::Available(_)) {
|
||||
this.updater.update(cx, |updater, cx| {
|
||||
updater.download_and_install(cx);
|
||||
});
|
||||
}
|
||||
|
||||
// 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)
|
||||
if matches!(status, UpdateStatus::Errored(_)) {
|
||||
this.error_time = Some(Instant::now());
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
||||
this.update(cx, |_this, cx| cx.notify()).ok();
|
||||
})
|
||||
.detach();
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"No update available. Current: {}, Latest: {}",
|
||||
version,
|
||||
new_version
|
||||
))
|
||||
this.error_time = None;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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))
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.tasks.push(
|
||||
// Install the new release
|
||||
// Schedule an auto-check after a 2-minute delay (deferred to run at the
|
||||
// end of the current frame so the window is fully set up).
|
||||
cx.defer_in(window, |_this, _window, cx| {
|
||||
let duration = Duration::from_secs(120);
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(duration).await;
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Installing, cx);
|
||||
})?;
|
||||
this.updater.update(cx, |updater, cx| {
|
||||
updater.check(cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
match task.await {
|
||||
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);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
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);
|
||||
})?;
|
||||
}
|
||||
Self {
|
||||
updater,
|
||||
version,
|
||||
_subscription: subscription,
|
||||
error_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn idle(&self, cx: &App) -> bool {
|
||||
let status = self.updater.read(cx).status();
|
||||
if status == &UpdateStatus::Idle {
|
||||
return true;
|
||||
}
|
||||
if matches!(status, UpdateStatus::Errored(_))
|
||||
&& self
|
||||
.error_time
|
||||
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn status(&self, cx: &App) -> SharedString {
|
||||
let status = self.updater.read(cx).status();
|
||||
|
||||
match status {
|
||||
UpdateStatus::Idle => "Up to date".into(),
|
||||
UpdateStatus::Checking => "Checking for updates…".into(),
|
||||
UpdateStatus::UpToDate => "Up to date".into(),
|
||||
UpdateStatus::Available(version) => format!("Version {version} available").into(),
|
||||
UpdateStatus::Downloading { downloaded, total } => {
|
||||
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
|
||||
let downloaded_mb = *downloaded as f64 / 1_048_576.0;
|
||||
match total_mb {
|
||||
Some(t) => format!("Downloading {downloaded_mb:.1} / {t:.1} MB").into(),
|
||||
None => format!("Downloading {downloaded_mb:.1} MB").into(),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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}"),
|
||||
}
|
||||
UpdateStatus::Installing => "Installing update…".into(),
|
||||
UpdateStatus::Staged(version) => {
|
||||
format!("Version {version} ready — restart to apply").into()
|
||||
}
|
||||
UpdateStatus::Errored(msg) => {
|
||||
if self
|
||||
.error_time
|
||||
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
||||
{
|
||||
"Up to date".into()
|
||||
} else {
|
||||
format!("Update failed: {msg}").into()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn download(
|
||||
url: &str,
|
||||
target_path: &std::path::Path,
|
||||
client: Arc<dyn HttpClient>,
|
||||
) -> Result<(), Error> {
|
||||
let body = AsyncBody::default();
|
||||
let mut target_file = File::create(&target_path).await?;
|
||||
let mut response = client.get(url, body, true).await?;
|
||||
|
||||
// Copy the response body to the target file
|
||||
smol::io::copy(response.body_mut(), &mut target_file).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_release_macos(
|
||||
temp_dir: &InstallerDir,
|
||||
downloaded_dmg: PathBuf,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<(), Error> {
|
||||
let running_app_path = cx.update(|cx| cx.app_path())?;
|
||||
let running_app_filename = running_app_path
|
||||
.file_name()
|
||||
.with_context(|| format!("invalid running app path {running_app_path:?}"))?;
|
||||
|
||||
let mount_path = temp_dir.path().join("Coop");
|
||||
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
|
||||
|
||||
mounted_app_path.push("/");
|
||||
|
||||
let output = Command::new("hdiutil")
|
||||
.args(["attach", "-nobrowse"])
|
||||
.arg(&downloaded_dmg)
|
||||
.arg("-mountroot")
|
||||
.arg(temp_dir.path())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to mount: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
// Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
|
||||
let _unmounter = MacOsUnmounter {
|
||||
mount_path: mount_path.clone(),
|
||||
background_executor: cx.background_executor(),
|
||||
};
|
||||
|
||||
let output = Command::new("rsync")
|
||||
.args(["-av", "--delete"])
|
||||
.arg(&mounted_app_path)
|
||||
.arg(&running_app_path)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to copy app: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_release_linux(
|
||||
temp_dir: &InstallerDir,
|
||||
downloaded_tar_gz: PathBuf,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<(), Error> {
|
||||
let running_app_path = cx.update(|cx| cx.app_path())?;
|
||||
|
||||
// Extract the tar.gz file
|
||||
let extracted = temp_dir.path().join("coop");
|
||||
smol::fs::create_dir_all(&extracted)
|
||||
.await
|
||||
.context("failed to create directory to extract update")?;
|
||||
|
||||
let output = Command::new("tar")
|
||||
.arg("-xzf")
|
||||
.arg(&downloaded_tar_gz)
|
||||
.arg("-C")
|
||||
.arg(&extracted)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to extract {:?} to {:?}: {:?}",
|
||||
downloaded_tar_gz,
|
||||
extracted,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
// Find the extracted app directory
|
||||
let mut entries = smol::fs::read_dir(&extracted).await?;
|
||||
let mut app_dir = None;
|
||||
|
||||
use smol::stream::StreamExt;
|
||||
|
||||
while let Some(entry) = entries.next().await {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
app_dir = Some(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let from = app_dir.context("No app directory found in archive")?;
|
||||
|
||||
// Copy to the current installation directory
|
||||
let output = Command::new("rsync")
|
||||
.args(["-av", "--delete"])
|
||||
.arg(&from)
|
||||
.arg(
|
||||
running_app_path
|
||||
.parent()
|
||||
.context("No parent directory for app")?,
|
||||
)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to copy app from {:?} to {:?}: {:?}",
|
||||
from,
|
||||
running_app_path.parent(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
|
||||
//const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
let system_root = std::env::var("SYSTEMROOT");
|
||||
let powershell_path = system_root.as_ref().map_or_else(
|
||||
|_| "powershell.exe".to_string(),
|
||||
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
|
||||
);
|
||||
|
||||
let mut installer_path = std::ffi::OsString::new();
|
||||
installer_path.push("\"");
|
||||
installer_path.push(&downloaded_installer);
|
||||
installer_path.push("\"");
|
||||
|
||||
let output = Command::new(powershell_path)
|
||||
//.creation_flags(CREATE_NO_WINDOW)
|
||||
.args(["-NoProfile", "-WindowStyle", "Hidden"])
|
||||
.args(["Start-Process"])
|
||||
.arg(installer_path)
|
||||
.arg("-ArgumentList")
|
||||
.args(["/P", "/R"])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to start installer: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "browser-signer-proxy"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
description = "Nostr browser signer (NIP-07) proxy using smol async runtime"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/nostrdevkit/nostr"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
atomic-destructor = "0.2"
|
||||
event-listener = "5"
|
||||
nostr.workspace = true
|
||||
opaquerr = "0.1"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
smol.workspace = true
|
||||
tracing = { version = "0.1", features = ["std"] }
|
||||
uuid = { version = "1.23", features = ["serde", "v4"] }
|
||||
@@ -0,0 +1,55 @@
|
||||
# browser-signer-proxy
|
||||
|
||||
Proxy to use Nostr Browser signer ([NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)) in native applications.
|
||||
|
||||
This is a re-implementation of [`nostr-browser-signer-proxy`](https://github.com/nostrdevkit/nostr/tree/master/signer/nostr-browser-signer-proxy)
|
||||
using the [`smol`](https://github.com/smol-rs/smol) async runtime instead of tokio.
|
||||
|
||||
## Description
|
||||
|
||||
This crate provides a local HTTP proxy that communicates with a NIP-07 browser extension
|
||||
(e.g., Alby, nos2x) running in a browser tab. Native applications can use this proxy to
|
||||
request public keys, sign events, and perform NIP-04/NIP-44 encryption/decryption through
|
||||
the browser extension.
|
||||
|
||||
The HTTP server is implemented with a minimal, dependency-free approach using `smol::net::TcpListener`
|
||||
and manual HTTP/1.1 parsing — avoiding heavy HTTP framework dependencies entirely.
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use browser_signer_proxy::prelude::*;
|
||||
|
||||
async fn example() -> Result<(), Error> {
|
||||
// Create the proxy with default options (localhost:7400)
|
||||
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
|
||||
|
||||
// Open the proxy URL in a browser
|
||||
webbrowser::open(&proxy.url())?;
|
||||
|
||||
// Start the proxy server
|
||||
proxy.start().await?;
|
||||
|
||||
// Use it as an async Nostr signer
|
||||
let public_key = proxy.get_public_key_async().await?;
|
||||
println!("Connected with public key: {public_key}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Differences from the tokio-based version
|
||||
|
||||
| Feature | tokio (original) | smol (this crate) |
|
||||
|---|---|---|
|
||||
| Async runtime | `tokio` | `smol` |
|
||||
| HTTP server | `hyper` | `smol::net::TcpListener` + manual HTTP/1.1 |
|
||||
| Mutex | `tokio::sync::Mutex` | `smol::lock::Mutex` |
|
||||
| Shutdown signal | `tokio::sync::Notify` | `event_listener::Event` |
|
||||
| Request-response channel | `tokio::sync::oneshot` | `smol::channel::bounded(1)` |
|
||||
| Timeout | `tokio::time::timeout` | `smol::future::or` + `smol::Timer` |
|
||||
| Task spawning | `tokio::spawn` | `smol::spawn` |
|
||||
|
||||
## License
|
||||
|
||||
This project is distributed under the MIT software license.
|
||||
@@ -0,0 +1,185 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Coop — Web Signer Proxy</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--brand: #F8FF37;
|
||||
--ink: #111111;
|
||||
--ink-soft: #333333;
|
||||
--muted: #666666;
|
||||
--paper: #FFFFFF;
|
||||
--edge: rgba(17, 17, 17, 0.14);
|
||||
--radius-sm: 1rem;
|
||||
--radius-md: 1.5rem;
|
||||
--radius-lg: 2.5rem;
|
||||
--green: #2E8B57;
|
||||
--red: #D32F2F;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--brand);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--paper);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2.5rem;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
box-shadow: 0 8px 0 rgba(17, 17, 17, 0.12), 0 2px 20px rgba(17, 17, 17, 0.06);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.logo__mark {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background: var(--ink);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: "Nunito", system-ui, sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 1.2rem;
|
||||
color: var(--brand);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.logo__text {
|
||||
font-family: "Nunito", system-ui, sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 1.3rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: "Nunito", system-ui, sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.15;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink-soft);
|
||||
margin: 0 0 1.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
transition: background 300ms ease, color 300ms ease;
|
||||
}
|
||||
|
||||
.status--checking {
|
||||
background: rgba(17, 17, 17, 0.05);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status--connected {
|
||||
background: rgba(46, 139, 87, 0.1);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.status--error {
|
||||
background: rgba(211, 47, 47, 0.08);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.status__dot {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status--checking .status__dot {
|
||||
background: var(--muted);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status--connected .status__dot {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.status--error .status__dot {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--edge);
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hint strong {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1 class="heading">Web Signer</h1>
|
||||
|
||||
<p class="subtitle">
|
||||
This page connects the app to your Nostr Web Signer extension so you can sign in and use Coop securely.
|
||||
</p>
|
||||
|
||||
<div id="nip07-status" class="status status--checking">
|
||||
<div class="status__dot"></div>
|
||||
<span id="nip07-status-text">Checking extension…</span>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
<strong>Keep this tab open</strong> while using the app — it automatically handles sign-in requests in the background.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="proxy.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
let isPolling = false;
|
||||
|
||||
async function pollForRequests() {
|
||||
if (isPolling) return;
|
||||
isPolling = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/pending');
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Polled for requests, got:', data);
|
||||
|
||||
// Process any new requests
|
||||
if (data.requests && data.requests.length > 0) {
|
||||
console.log(`Processing ${data.requests.length} requests`);
|
||||
for (const request of data.requests) {
|
||||
await handleNip07Request(request);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Polling error:', error);
|
||||
updateStatus('Error: ' + error.message, 'error');
|
||||
}
|
||||
|
||||
isPolling = false;
|
||||
}
|
||||
|
||||
async function handleNip07Request(request) {
|
||||
console.log('Handling request:', request);
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
if (!window.nostr) {
|
||||
throw new Error('NIP-07 extension not available');
|
||||
}
|
||||
|
||||
switch (request.method) {
|
||||
case 'get_public_key':
|
||||
console.log('Calling nostr.getPublicKey()');
|
||||
result = await window.nostr.getPublicKey();
|
||||
console.log('Got public key:', result);
|
||||
break;
|
||||
|
||||
case 'sign_event':
|
||||
console.log('Calling nostr.signEvent() with:', request.params);
|
||||
result = await window.nostr.signEvent(request.params);
|
||||
console.log('Got signed event:', result);
|
||||
break;
|
||||
|
||||
case 'nip04_encrypt':
|
||||
console.log('Calling nostr.nip04.encrypt()');
|
||||
result = await window.nostr.nip04.encrypt(
|
||||
request.params.public_key,
|
||||
request.params.content
|
||||
);
|
||||
break;
|
||||
|
||||
case 'nip04_decrypt':
|
||||
console.log('Calling nostr.nip04.decrypt()');
|
||||
result = await window.nostr.nip04.decrypt(
|
||||
request.params.public_key,
|
||||
request.params.content
|
||||
);
|
||||
break;
|
||||
|
||||
case 'nip44_encrypt':
|
||||
console.log('Calling nostr.nip44.encrypt()');
|
||||
result = await window.nostr.nip44.encrypt(
|
||||
request.params.public_key,
|
||||
request.params.content
|
||||
);
|
||||
break;
|
||||
|
||||
case 'nip44_decrypt':
|
||||
console.log('Calling nostr.nip44.decrypt()');
|
||||
result = await window.nostr.nip44.decrypt(
|
||||
request.params.public_key,
|
||||
request.params.content
|
||||
);
|
||||
break;
|
||||
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown method: ${request.method}`);
|
||||
}
|
||||
|
||||
// Send response back to server
|
||||
const responsePayload = {
|
||||
id: request.id,
|
||||
result: result,
|
||||
error: null
|
||||
};
|
||||
|
||||
console.log('Sending response:', responsePayload);
|
||||
|
||||
await fetch('/api/response', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(responsePayload)
|
||||
});
|
||||
|
||||
console.log('Response sent successfully');
|
||||
updateStatus('Request processed successfully', 'connected');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error handling request:', error);
|
||||
|
||||
// Send error response back to server
|
||||
const errorPayload = {
|
||||
id: request.id,
|
||||
result: null,
|
||||
error: error.message
|
||||
};
|
||||
|
||||
console.log('Sending error response:', errorPayload);
|
||||
|
||||
await fetch('/api/response', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(errorPayload)
|
||||
});
|
||||
|
||||
updateStatus('Error: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(message, state) {
|
||||
const container = document.getElementById('nip07-status');
|
||||
const textEl = document.getElementById('nip07-status-text');
|
||||
if (container && textEl) {
|
||||
container.className = 'status status--' + state;
|
||||
textEl.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling when page loads
|
||||
window.addEventListener('load', () => {
|
||||
console.log('NIP-07 Proxy loaded');
|
||||
|
||||
// Check if NIP-07 extension is available
|
||||
if (window.nostr) {
|
||||
console.log('NIP-07 extension detected');
|
||||
updateStatus('Connected — ready', 'connected');
|
||||
} else {
|
||||
console.log('NIP-07 extension not found');
|
||||
updateStatus('No NIP-07 extension found', 'error');
|
||||
}
|
||||
|
||||
// Start polling every 500 ms
|
||||
setInterval(pollForRequests, 500);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2022-2023 Yuki Kishimoto
|
||||
// Copyright (c) 2023-2025 Rust Nostr Developers
|
||||
// Distributed under the MIT software license
|
||||
|
||||
//! Error types for the browser signer proxy.
|
||||
|
||||
opaquerr::define_kind! {
|
||||
/// Nostr browser signer proxy error kind.
|
||||
pub ErrorKind {
|
||||
/// Nostr protocol error.
|
||||
Protocol => "nostr protocol error",
|
||||
/// I/O error.
|
||||
IO => "I/O error",
|
||||
/// JSON error.
|
||||
Json => "JSON error",
|
||||
/// The operation timed out.
|
||||
Timeout => "timeout",
|
||||
/// The operation cannot be completed in the current state.
|
||||
State => "invalid state",
|
||||
/// Anything not covered by the stable categories above.
|
||||
Other => "other error",
|
||||
}
|
||||
}
|
||||
|
||||
opaquerr::define_error! {
|
||||
/// Nostr browser signer proxy error.
|
||||
pub Error(ErrorKind)
|
||||
|
||||
from {
|
||||
nostr::error::Error => ErrorKind::Protocol,
|
||||
std::io::Error => ErrorKind::IO,
|
||||
serde_json::Error => ErrorKind::Json,
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub(crate) fn generic<S>(message: S) -> Self
|
||||
where
|
||||
S: Into<String>,
|
||||
{
|
||||
Self::new(ErrorKind::Other, message.into())
|
||||
}
|
||||
|
||||
pub(crate) fn timeout() -> Self {
|
||||
Self::simple(ErrorKind::Timeout)
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown() -> Self {
|
||||
Self::with_static_message(ErrorKind::State, "server is shutdown")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use atomic_destructor::{AtomicDestroyer, AtomicDestructor};
|
||||
use event_listener::Event as ShutdownEvent;
|
||||
use nostr::prelude::*;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
use serde_json::{Value, json};
|
||||
use smol::channel;
|
||||
use smol::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use smol::lock::Mutex;
|
||||
use smol::net::{TcpListener, TcpStream};
|
||||
use uuid::Uuid;
|
||||
|
||||
mod error;
|
||||
pub mod prelude;
|
||||
|
||||
pub use self::error::Error;
|
||||
|
||||
const DEFAULT_HTML: &str = include_str!("../index.html");
|
||||
const JS: &str = include_str!("../proxy.js");
|
||||
|
||||
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Message {
|
||||
id: Uuid,
|
||||
error: Option<String>,
|
||||
result: Option<Value>,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
fn into_result(self) -> Result<Value, String> {
|
||||
if let Some(error) = self.error {
|
||||
Err(error)
|
||||
} else {
|
||||
Ok(self.result.unwrap_or(Value::Null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum RequestMethod {
|
||||
GetPublicKey,
|
||||
SignEvent,
|
||||
Nip04Encrypt,
|
||||
Nip04Decrypt,
|
||||
Nip44Encrypt,
|
||||
Nip44Decrypt,
|
||||
}
|
||||
|
||||
impl RequestMethod {
|
||||
fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::GetPublicKey => "get_public_key",
|
||||
Self::SignEvent => "sign_event",
|
||||
Self::Nip04Encrypt => "nip04_encrypt",
|
||||
Self::Nip04Decrypt => "nip04_decrypt",
|
||||
Self::Nip44Encrypt => "nip44_encrypt",
|
||||
Self::Nip44Decrypt => "nip44_decrypt",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for RequestMethod {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct RequestData {
|
||||
id: Uuid,
|
||||
method: RequestMethod,
|
||||
params: Value,
|
||||
}
|
||||
|
||||
impl RequestData {
|
||||
#[inline]
|
||||
fn new(method: RequestMethod, params: Value) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
method,
|
||||
params,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Requests<'a> {
|
||||
requests: &'a [RequestData],
|
||||
}
|
||||
|
||||
impl<'a> Requests<'a> {
|
||||
#[inline]
|
||||
fn new(requests: &'a [RequestData]) -> Self {
|
||||
Self { requests }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn len(&self) -> usize {
|
||||
self.requests.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Params for NIP-04 and NIP-44 encryption/decryption
|
||||
#[derive(Serialize)]
|
||||
struct CryptoParams<'a> {
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> CryptoParams<'a> {
|
||||
#[inline]
|
||||
fn new(public_key: &'a PublicKey, content: &'a str) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ProxyState {
|
||||
/// Requests waiting to be picked up by browser
|
||||
pub outgoing_requests: Mutex<Vec<RequestData>>,
|
||||
/// Map of request ID to response sender
|
||||
pub pending_responses: Mutex<PendingResponseMap>,
|
||||
/// Last time the client asked for the pending requests
|
||||
pub last_pending_request: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
/// Configuration options for [`BrowserSignerProxy`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BrowserSignerProxyOptions {
|
||||
/// Request timeout for the signer extension. Default is 30 seconds.
|
||||
pub timeout: Duration,
|
||||
/// Proxy server IP address and port. Default is `127.0.0.1:7400`.
|
||||
pub addr: SocketAddr,
|
||||
/// Custom HTML page.
|
||||
// NOTE: not `Option` to move it between threads without reference counter
|
||||
pub custom_html: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct InnerBrowserSignerProxy {
|
||||
/// Configuration options for the proxy
|
||||
options: BrowserSignerProxyOptions,
|
||||
/// Internal state of the proxy including request queues
|
||||
state: Arc<ProxyState>,
|
||||
/// Notification trigger for graceful shutdown
|
||||
shutdown: Arc<ShutdownEvent>,
|
||||
/// Flag to indicate if the server is shutdown
|
||||
is_shutdown: Arc<AtomicBool>,
|
||||
/// Flag indicating if the server is started
|
||||
is_started: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl AtomicDestroyer for InnerBrowserSignerProxy {
|
||||
fn on_destroy(&self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
impl InnerBrowserSignerProxy {
|
||||
#[inline]
|
||||
fn is_shutdown(&self) -> bool {
|
||||
self.is_shutdown.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn shutdown(&self) {
|
||||
// Mark the server as shutdown
|
||||
self.is_shutdown.store(true, Ordering::SeqCst);
|
||||
|
||||
// Notify all waiters that the proxy is shutting down
|
||||
self.shutdown.notify(usize::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
/// Nostr Browser Signer Proxy
|
||||
///
|
||||
/// Proxy to use Nostr Browser signer (NIP-07) in native applications.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BrowserSignerProxy {
|
||||
inner: AtomicDestructor<InnerBrowserSignerProxy>,
|
||||
}
|
||||
|
||||
impl Default for BrowserSignerProxyOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timeout: Duration::from_secs(30),
|
||||
// 7 for NIP-07 and 400 because the NIP title is 40 bytes :)
|
||||
addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 7400)),
|
||||
custom_html: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserSignerProxyOptions {
|
||||
/// Sets the timeout duration.
|
||||
pub const fn timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the IP address.
|
||||
pub const fn ip_addr(mut self, new_ip: IpAddr) -> Self {
|
||||
self.addr = SocketAddr::new(new_ip, self.addr.port());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the port number.
|
||||
pub const fn port(mut self, new_port: u16) -> Self {
|
||||
self.addr = SocketAddr::new(self.addr.ip(), new_port);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets a custom html page.
|
||||
///
|
||||
/// The page must include `/proxy.js` script (`<script src="/proxy.js"></script>`)
|
||||
/// which will handle communication with the server and update the element
|
||||
/// with id `nip07-proxy-status` with the status.
|
||||
pub const fn custom_html_page(mut self, custom_html: &'static str) -> Self {
|
||||
self.custom_html = custom_html;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserSignerProxy {
|
||||
/// Construct a new browser signer proxy
|
||||
pub fn new(options: BrowserSignerProxyOptions) -> Self {
|
||||
let state = ProxyState {
|
||||
outgoing_requests: Mutex::new(Vec::new()),
|
||||
pending_responses: Mutex::new(HashMap::new()),
|
||||
last_pending_request: Arc::new(AtomicU64::new(0)),
|
||||
};
|
||||
|
||||
Self {
|
||||
inner: AtomicDestructor::new(InnerBrowserSignerProxy {
|
||||
options,
|
||||
state: Arc::new(state),
|
||||
shutdown: Arc::new(ShutdownEvent::new()),
|
||||
is_shutdown: Arc::new(AtomicBool::new(false)),
|
||||
is_started: Arc::new(AtomicBool::new(false)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicates whether the server is currently running.
|
||||
#[inline]
|
||||
pub fn is_started(&self) -> bool {
|
||||
self.inner.is_started.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Checks if there is an open browser tab ready to respond to requests by
|
||||
/// verifying the time since the last pending request.
|
||||
#[inline]
|
||||
pub fn is_session_active(&self) -> bool {
|
||||
current_time() - self.inner.state.last_pending_request.load(Ordering::SeqCst) < 2
|
||||
}
|
||||
|
||||
/// Get the signer proxy webpage URL
|
||||
#[inline]
|
||||
pub fn url(&self) -> String {
|
||||
format!("http://{}", self.inner.options.addr)
|
||||
}
|
||||
|
||||
/// Start the proxy server.
|
||||
///
|
||||
/// If this is not called explicitly, the server will be automatically
|
||||
/// started on the first interaction with the signer.
|
||||
pub async fn start(&self) -> Result<(), Error> {
|
||||
// Ensure is not shutdown
|
||||
if self.inner.is_shutdown() {
|
||||
return Err(Error::shutdown());
|
||||
}
|
||||
|
||||
// Mark the proxy as started and check if was already started
|
||||
let is_started: bool = self.inner.is_started.swap(true, Ordering::SeqCst);
|
||||
|
||||
// Immediately return if already started
|
||||
if is_started {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let listener: TcpListener = match TcpListener::bind(self.inner.options.addr).await {
|
||||
Ok(listener) => listener,
|
||||
Err(e) => {
|
||||
// Undo the started flag if binding fails
|
||||
self.inner.is_started.store(false, Ordering::SeqCst);
|
||||
return Err(Error::from(e));
|
||||
}
|
||||
};
|
||||
|
||||
let addr: SocketAddr = self.inner.options.addr;
|
||||
let state: Arc<ProxyState> = self.inner.state.clone();
|
||||
let custom_html: &'static str = self.inner.options.custom_html;
|
||||
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
|
||||
|
||||
smol::spawn(async move {
|
||||
tracing::info!("Starting proxy server on {addr}");
|
||||
|
||||
loop {
|
||||
// Race between accepting a new connection and shutdown signal
|
||||
let shutdown_listener = shutdown.listen();
|
||||
|
||||
enum AcceptEvent {
|
||||
Connection(Result<(TcpStream, SocketAddr), std::io::Error>),
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
let event = smol::future::or(
|
||||
async { AcceptEvent::Connection(listener.accept().await) },
|
||||
async {
|
||||
shutdown_listener.await;
|
||||
AcceptEvent::Shutdown
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
match event {
|
||||
AcceptEvent::Connection(Ok((stream, _))) => {
|
||||
let state: Arc<ProxyState> = state.clone();
|
||||
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
|
||||
|
||||
smol::spawn(async move {
|
||||
let shutdown_listener = shutdown.listen();
|
||||
|
||||
smol::future::or(
|
||||
async {
|
||||
handle_connection(stream, state, custom_html).await;
|
||||
},
|
||||
async {
|
||||
shutdown_listener.await;
|
||||
tracing::debug!(
|
||||
"Closing connection, proxy server is shutting down."
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
AcceptEvent::Connection(Err(e)) => {
|
||||
tracing::error!("Failed to accept connection: {e}");
|
||||
}
|
||||
AcceptEvent::Shutdown => break,
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Proxy server shut down.");
|
||||
})
|
||||
.detach();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn store_pending_response(&self, id: Uuid, tx: channel::Sender<Result<Value, String>>) {
|
||||
let mut pending_responses = self.inner.state.pending_responses.lock().await;
|
||||
pending_responses.insert(id, tx);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn store_outgoing_request(&self, request: RequestData) {
|
||||
let mut outgoing_requests = self.inner.state.outgoing_requests.lock().await;
|
||||
outgoing_requests.push(request);
|
||||
}
|
||||
|
||||
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
// Start the proxy if not already started
|
||||
self.start().await?;
|
||||
|
||||
// Construct the request
|
||||
let request: RequestData = RequestData::new(method, params);
|
||||
|
||||
// Create a bounded channel of size 1 as a oneshot replacement
|
||||
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
|
||||
|
||||
// Store the response sender
|
||||
self.store_pending_response(request.id, tx).await;
|
||||
|
||||
// Add to outgoing requests queue
|
||||
self.store_outgoing_request(request).await;
|
||||
|
||||
// Wait for response with timeout
|
||||
let response = race_timeout(self.inner.options.timeout, rx.recv()).await;
|
||||
|
||||
match response {
|
||||
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
|
||||
Ok(Err(error)) => Err(Error::generic(error)),
|
||||
Err(TimeoutError) => Err(Error::timeout()),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn _get_public_key(&self) -> Result<PublicKey, Error> {
|
||||
self.request(RequestMethod::GetPublicKey, json!({})).await
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
|
||||
let event: Event = self
|
||||
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
|
||||
.await?;
|
||||
event.verify()?;
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn _nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||
let params = CryptoParams::new(public_key, content);
|
||||
self.request(RequestMethod::Nip04Encrypt, serde_json::to_value(params)?)
|
||||
.await
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn _nip04_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||
let params = CryptoParams::new(public_key, content);
|
||||
self.request(RequestMethod::Nip04Decrypt, serde_json::to_value(params)?)
|
||||
.await
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn _nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||
let params = CryptoParams::new(public_key, content);
|
||||
self.request(RequestMethod::Nip44Encrypt, serde_json::to_value(params)?)
|
||||
.await
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn _nip44_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
||||
let params = CryptoParams::new(public_key, content);
|
||||
self.request(RequestMethod::Nip44Decrypt, serde_json::to_value(params)?)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncGetPublicKey for BrowserSignerProxy {
|
||||
type Error = Error;
|
||||
|
||||
#[inline]
|
||||
fn get_public_key_async(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
|
||||
Box::pin(async move { self._get_public_key().await })
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncSignEvent for BrowserSignerProxy {
|
||||
type Error = Error;
|
||||
|
||||
#[inline]
|
||||
fn sign_event_async(
|
||||
&self,
|
||||
unsigned: UnsignedEvent,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
|
||||
Box::pin(async move { self._sign_event(unsigned).await })
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncNip04 for BrowserSignerProxy {
|
||||
type Error = Error;
|
||||
|
||||
fn nip04_encrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||
Box::pin(async move { self._nip04_encrypt(public_key, content).await })
|
||||
}
|
||||
|
||||
fn nip04_decrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
encrypted_content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||
Box::pin(async move { self._nip04_decrypt(public_key, encrypted_content).await })
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncNip44 for BrowserSignerProxy {
|
||||
type Error = Error;
|
||||
|
||||
fn nip44_encrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||
Box::pin(async move { self._nip44_encrypt(public_key, content).await })
|
||||
}
|
||||
|
||||
fn nip44_decrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
||||
Box::pin(async move { self._nip44_decrypt(public_key, payload).await })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Minimal HTTP server ──────────────────────────────────────────────────
|
||||
|
||||
/// Handle a single HTTP connection.
|
||||
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
|
||||
let mut reader = BufReader::new(stream);
|
||||
|
||||
// Read the request line
|
||||
let mut request_line = String::new();
|
||||
if reader.read_line(&mut request_line).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let request_line = request_line.trim_end().to_string();
|
||||
|
||||
// Parse method, path, and HTTP version from request line
|
||||
let parts: Vec<&str> = request_line.split_whitespace().collect();
|
||||
if parts.len() < 2 {
|
||||
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||
return;
|
||||
}
|
||||
let method = parts[0].to_uppercase();
|
||||
let path = parts[1].to_string();
|
||||
|
||||
// Read headers until empty line
|
||||
let mut headers = Vec::new();
|
||||
let mut content_length: usize = 0;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let line = line.trim_end().to_string();
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("content-length:") {
|
||||
content_length = value.trim().parse().unwrap_or(0);
|
||||
} else if let Some(value) = line.strip_prefix("Content-Length:") {
|
||||
content_length = value.trim().parse().unwrap_or(0);
|
||||
}
|
||||
headers.push(line);
|
||||
}
|
||||
|
||||
match (method.as_str(), path.as_str()) {
|
||||
// Serve the HTML proxy page
|
||||
("GET", "/") => {
|
||||
let html = if custom_html.is_empty() {
|
||||
DEFAULT_HTML
|
||||
} else {
|
||||
custom_html
|
||||
};
|
||||
send_response(&mut reader, 200, "OK", "text/html", html).await;
|
||||
}
|
||||
// Serve the JS proxy script
|
||||
("GET", "/proxy.js") => {
|
||||
send_response(&mut reader, 200, "OK", "application/javascript", JS).await;
|
||||
}
|
||||
// Browser polls this endpoint to get pending requests
|
||||
("GET", "/api/pending") => {
|
||||
state
|
||||
.last_pending_request
|
||||
.store(current_time(), Ordering::SeqCst);
|
||||
|
||||
let mut outgoing = state.outgoing_requests.lock().await;
|
||||
|
||||
let requests = Requests::new(&outgoing);
|
||||
let json = match serde_json::to_string(&requests) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to serialize pending requests: {e}");
|
||||
send_response(&mut reader, 500, "Internal Server Error", "", "").await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!("Sending {} pending requests to browser", requests.len());
|
||||
|
||||
// Clear the outgoing requests after sending them
|
||||
outgoing.clear();
|
||||
|
||||
send_response_cors_json(&mut reader, 200, "OK", &json).await;
|
||||
}
|
||||
// Receive response from browser extension
|
||||
("POST", "/api/response") => {
|
||||
let mut body_bytes = vec![0u8; content_length];
|
||||
if content_length > 0 && reader.read_exact(&mut body_bytes).await.is_err() {
|
||||
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||
return;
|
||||
}
|
||||
|
||||
let message: Message = match serde_json::from_slice(&body_bytes) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to parse response body: {e}");
|
||||
send_response(&mut reader, 400, "Invalid JSON", "", "").await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!("Received response from browser: {message:?}");
|
||||
|
||||
let id: Uuid = message.id;
|
||||
let mut pending = state.pending_responses.lock().await;
|
||||
|
||||
match pending.remove(&id) {
|
||||
Some(sender) => {
|
||||
// Use try_send since we already hold the lock
|
||||
let _ = sender.try_send(message.into_result());
|
||||
tracing::info!("Forwarded response for request {id}");
|
||||
}
|
||||
None => tracing::warn!("No pending request found for {id}"),
|
||||
}
|
||||
|
||||
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
|
||||
}
|
||||
// CORS preflight
|
||||
("OPTIONS", _) => {
|
||||
let response = "HTTP/1.1 200 OK\r\n\
|
||||
Access-Control-Allow-Origin: *\r\n\
|
||||
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
|
||||
Access-Control-Allow-Headers: Content-Type\r\n\
|
||||
Content-Length: 0\r\n\
|
||||
Connection: close\r\n\
|
||||
\r\n";
|
||||
let _ = reader.get_mut().write_all(response.as_bytes()).await;
|
||||
let _ = reader.get_mut().flush().await;
|
||||
}
|
||||
// 404 - not found
|
||||
_ => {
|
||||
send_response(&mut reader, 404, "Not Found", "", "").await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an HTTP response to the stream.
|
||||
async fn send_response(
|
||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
||||
status: u16,
|
||||
status_text: &str,
|
||||
content_type: &str,
|
||||
body: &str,
|
||||
) {
|
||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
||||
|
||||
if !content_type.is_empty() {
|
||||
response.push_str(&format!("Content-Type: {content_type}\r\n"));
|
||||
}
|
||||
|
||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
||||
response.push_str("Connection: close\r\n");
|
||||
response.push_str("\r\n");
|
||||
response.push_str(body);
|
||||
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
}
|
||||
|
||||
/// Write a response with CORS headers and JSON content type.
|
||||
async fn send_response_cors_json(
|
||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
||||
status: u16,
|
||||
status_text: &str,
|
||||
body: &str,
|
||||
) {
|
||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
||||
response.push_str("Content-Type: application/json\r\n");
|
||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
||||
response.push_str("Connection: close\r\n");
|
||||
response.push_str("\r\n");
|
||||
response.push_str(body);
|
||||
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
}
|
||||
|
||||
/// Write a response with CORS headers.
|
||||
async fn send_response_cors(
|
||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
||||
status: u16,
|
||||
status_text: &str,
|
||||
content_type: &str,
|
||||
body: &str,
|
||||
) {
|
||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
||||
|
||||
if !content_type.is_empty() {
|
||||
response.push_str(&format!("Content-Type: {content_type}\r\n"));
|
||||
}
|
||||
|
||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
||||
response.push_str("Connection: close\r\n");
|
||||
response.push_str("\r\n");
|
||||
response.push_str(body);
|
||||
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
}
|
||||
|
||||
// ── Timeout helper ───────────────────────────────────────────────────────
|
||||
|
||||
/// An error indicating that an operation timed out.
|
||||
#[derive(Debug)]
|
||||
struct TimeoutError;
|
||||
|
||||
/// Races a channel receive against a duration.
|
||||
///
|
||||
/// Returns the channel value on success, or [`TimeoutError`] if the duration
|
||||
/// elapses first or the channel is closed.
|
||||
async fn race_timeout<T>(
|
||||
duration: Duration,
|
||||
recv: impl Future<Output = Result<T, channel::RecvError>>,
|
||||
) -> Result<T, TimeoutError> {
|
||||
enum Event<T> {
|
||||
Value(T),
|
||||
ChannelClosed,
|
||||
Timeout,
|
||||
}
|
||||
|
||||
let event = smol::future::or(
|
||||
async {
|
||||
match recv.await {
|
||||
Ok(value) => Event::Value(value),
|
||||
Err(_) => Event::ChannelClosed,
|
||||
}
|
||||
},
|
||||
async {
|
||||
smol::Timer::after(duration).await;
|
||||
Event::Timeout
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
match event {
|
||||
Event::Value(value) => Ok(value),
|
||||
Event::ChannelClosed | Event::Timeout => Err(TimeoutError),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utility ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
|
||||
/// time is before the epoch, returns 0.
|
||||
#[inline]
|
||||
fn current_time() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2022-2023 Yuki Kishimoto
|
||||
// Copyright (c) 2023-2025 Rust Nostr Developers
|
||||
// Distributed under the MIT software license
|
||||
|
||||
//! Prelude
|
||||
|
||||
#![allow(unknown_lints)]
|
||||
#![allow(ambiguous_glob_reexports)]
|
||||
#![doc(hidden)]
|
||||
|
||||
pub use nostr::prelude::*;
|
||||
|
||||
pub use crate::error::{Error, ErrorKind};
|
||||
pub use crate::*;
|
||||
@@ -13,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]
|
||||
|
||||
@@ -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;
|
||||
@@ -27,6 +22,9 @@ mod room;
|
||||
pub use message::*;
|
||||
pub use room::*;
|
||||
|
||||
/// 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 +80,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 +104,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 +168,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 +209,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 +232,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 +244,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 +288,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 +302,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 +323,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 +332,43 @@ 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()
|
||||
.first_owned()
|
||||
.is_some();
|
||||
|
||||
if !found {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(ChatEvent::InboxRelayNotFound);
|
||||
})?;
|
||||
@@ -379,58 +384,52 @@ 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?
|
||||
.first_owned()
|
||||
.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 +440,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 +464,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 +504,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 +575,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 +587,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 +615,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 +627,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.get_rooms_task(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
@@ -635,7 +638,9 @@ impl ChatRegistry {
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to load rooms: {}", e);
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(ChatEvent::Error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -644,77 +649,53 @@ 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>> {
|
||||
fn get_rooms_task(&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 public_key = signer.get_public_key_async().await?;
|
||||
let contacts = client
|
||||
.database()
|
||||
.contacts_public_keys(public_key)
|
||||
.await
|
||||
.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_tag(SingleLetterTag::lowercase(Alphabet::K), "14");
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -734,7 +715,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 +756,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 +775,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 +787,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 +804,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", ["14"]),
|
||||
];
|
||||
|
||||
// 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,10 +825,7 @@ 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() {
|
||||
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
|
||||
@@ -888,15 +833,3 @@ async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent,
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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};
|
||||
@@ -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(Alphabet::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,12 +419,23 @@ 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();
|
||||
|
||||
@@ -429,14 +445,6 @@ impl Room {
|
||||
// Get current user's public key
|
||||
let sender = nostr.read(cx).current_user()?;
|
||||
|
||||
// Get all members, excluding the sender
|
||||
let members: Vec<Person> = self
|
||||
.members
|
||||
.iter()
|
||||
.filter(|public_key| public_key != &&sender)
|
||||
.map(|member| persons.read(cx).get(member, cx))
|
||||
.collect();
|
||||
|
||||
// Construct event's tags
|
||||
let mut tags = vec![];
|
||||
|
||||
@@ -450,8 +458,9 @@ impl Room {
|
||||
tags.push(Tag::event(id))
|
||||
}
|
||||
|
||||
// Add all receiver tags
|
||||
for member in members.into_iter() {
|
||||
// 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(
|
||||
Nip01Tag::PublicKey {
|
||||
public_key: member.public_key(),
|
||||
@@ -473,6 +482,30 @@ impl Room {
|
||||
Some(event)
|
||||
}
|
||||
|
||||
/// Select the appropriate signer based on signer kind and available keys.
|
||||
fn select_signer(
|
||||
signer_kind: &SignerKind,
|
||||
has_announcement: bool,
|
||||
encryption_signer: &Option<UniversalSigner>,
|
||||
user_signer: &UniversalSigner,
|
||||
) -> UniversalSigner {
|
||||
match signer_kind {
|
||||
SignerKind::Auto => {
|
||||
if has_announcement {
|
||||
encryption_signer
|
||||
.clone()
|
||||
.unwrap_or_else(|| user_signer.clone())
|
||||
} else {
|
||||
user_signer.clone()
|
||||
}
|
||||
}
|
||||
SignerKind::Encryption => encryption_signer
|
||||
.clone()
|
||||
.expect("encryption signer must be set"),
|
||||
SignerKind::User => user_signer.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send rumor event to all members's messaging relays
|
||||
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
|
||||
let config = self.config.clone();
|
||||
@@ -525,23 +558,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 +583,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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::sync::Arc;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::sync::RwLock;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::{Arc, LazyLock, RwLock};
|
||||
|
||||
pub use actions::*;
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
||||
use common::{TimestampExt, coop_cache};
|
||||
use futures::lock::Mutex;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window, div,
|
||||
img, list, px, red, relative, svg, white,
|
||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
||||
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, svg,
|
||||
white,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry};
|
||||
use regex::Regex;
|
||||
use settings::{AppSettings, SignerKind};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use smol::lock::RwLock;
|
||||
use state::{NostrRegistry, upload};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
@@ -38,6 +37,14 @@ use ui::{
|
||||
|
||||
use crate::text::RenderedText;
|
||||
|
||||
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
||||
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
|
||||
|
||||
/// Regex matching strings that consist entirely of emoji characters,
|
||||
/// zero-width joiners, variation selectors, and keycap combiners.
|
||||
static EMOJI_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
||||
|
||||
mod actions;
|
||||
mod text;
|
||||
|
||||
@@ -56,14 +63,20 @@ pub struct ChatPanel {
|
||||
/// Message list state
|
||||
list_state: ListState,
|
||||
|
||||
/// All messages
|
||||
messages: BTreeSet<Message>,
|
||||
/// All messages (sorted by created_at)
|
||||
messages: Vec<Message>,
|
||||
|
||||
/// O(1) message lookup by EventId
|
||||
message_index: HashMap<EventId, usize>,
|
||||
|
||||
/// All reactions
|
||||
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
|
||||
|
||||
/// Mapping message ids to their rendered texts
|
||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||
|
||||
/// Mapping message (rumor event) ids to their reports
|
||||
reports_by_id: Entity<BTreeMap<EventId, Vec<SendReport>>>,
|
||||
reports_by_id: Arc<RwLock<BTreeMap<EventId, Vec<SendReport>>>>,
|
||||
|
||||
/// Chat input state
|
||||
input: Entity<InputState>,
|
||||
@@ -75,7 +88,7 @@ pub struct ChatPanel {
|
||||
subject_bar: Entity<bool>,
|
||||
|
||||
/// Sent message ids
|
||||
sent_ids: Arc<RwLock<Vec<EventId>>>,
|
||||
sent_ids: Arc<Mutex<Vec<EventId>>>,
|
||||
|
||||
/// Replies to
|
||||
replies_to: Entity<HashSet<EventId>>,
|
||||
@@ -98,10 +111,10 @@ impl ChatPanel {
|
||||
// Define attachments and replies_to entities
|
||||
let attachments = cx.new(|_| vec![]);
|
||||
let replies_to = cx.new(|_| HashSet::new());
|
||||
let reports_by_id = cx.new(|_| BTreeMap::new());
|
||||
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
||||
|
||||
// Define list of messages
|
||||
let messages = BTreeSet::default();
|
||||
let messages = Vec::new();
|
||||
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
|
||||
|
||||
// Get room id and name
|
||||
@@ -163,6 +176,8 @@ impl ChatPanel {
|
||||
focus_handle: cx.focus_handle(),
|
||||
id,
|
||||
messages,
|
||||
message_index: HashMap::new(),
|
||||
reactions: BTreeMap::new(),
|
||||
room,
|
||||
list_state,
|
||||
input,
|
||||
@@ -172,7 +187,7 @@ impl ChatPanel {
|
||||
attachments,
|
||||
rendered_texts_by_id: BTreeMap::new(),
|
||||
reports_by_id,
|
||||
sent_ids: Arc::new(RwLock::new(Vec::new())),
|
||||
sent_ids: Arc::new(Mutex::new(Vec::new())),
|
||||
uploading: false,
|
||||
subscriptions,
|
||||
tasks: vec![],
|
||||
@@ -192,7 +207,7 @@ impl ChatPanel {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let sent_ids = self.sent_ids.clone();
|
||||
let reports = self.reports_by_id.downgrade();
|
||||
let reports = self.reports_by_id.clone();
|
||||
|
||||
let (tx, rx) = flume::bounded::<Arc<SendStatus>>(256);
|
||||
|
||||
@@ -207,7 +222,7 @@ impl ChatPanel {
|
||||
message,
|
||||
} = *message
|
||||
{
|
||||
let sent_ids = sent_ids.read().await;
|
||||
let sent_ids = sent_ids.lock().await;
|
||||
|
||||
if sent_ids.contains(&event_id) {
|
||||
let status = if status {
|
||||
@@ -223,30 +238,37 @@ impl ChatPanel {
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.tasks.push(cx.spawn(async move |_this, cx| {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
while let Ok(status) = rx.recv_async().await {
|
||||
reports.update(cx, |this, cx| {
|
||||
for reports in this.values_mut() {
|
||||
for report in reports.iter_mut() {
|
||||
{
|
||||
let mut map = reports.write().unwrap();
|
||||
let status_id = match &*status {
|
||||
SendStatus::Ok { id, .. } => *id,
|
||||
SendStatus::Failed { id, .. } => *id,
|
||||
};
|
||||
|
||||
// Find the matching report and update it (exit early on first match)
|
||||
'outer: for reports_list in map.values_mut() {
|
||||
for report in reports_list.iter_mut() {
|
||||
let Some(output) = report.output.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if *output.id() != status_id {
|
||||
continue;
|
||||
}
|
||||
match &*status {
|
||||
SendStatus::Ok { id, relay } => {
|
||||
if output.id() == id {
|
||||
output.success.insert(relay.clone(), EventSendStatus::Sent);
|
||||
}
|
||||
SendStatus::Ok { relay, .. } => {
|
||||
output.success.insert(relay.clone(), EventSendStatus::Sent);
|
||||
}
|
||||
SendStatus::Failed { id, relay, message } => {
|
||||
if output.id() == id {
|
||||
output.failed.insert(relay.clone(), message.clone());
|
||||
}
|
||||
SendStatus::Failed { relay, message, .. } => {
|
||||
output.failed.insert(relay.clone(), message.clone());
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
this.update(cx, |_, cx| cx.notify()).ok();
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
@@ -254,22 +276,44 @@ impl ChatPanel {
|
||||
|
||||
/// Subscribe to room events
|
||||
fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(room) = self.room.upgrade() {
|
||||
self.subscriptions.push(cx.subscribe_in(
|
||||
&room,
|
||||
window,
|
||||
move |this, _room, event, window, cx| {
|
||||
match event {
|
||||
RoomEvent::Incoming(message) => {
|
||||
let Some(room) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.subscriptions.push(cx.subscribe_in(
|
||||
&room,
|
||||
window,
|
||||
move |this, _room, event, window, cx| {
|
||||
match event {
|
||||
RoomEvent::Incoming(message) => {
|
||||
if message.rumor.kind == Kind::Reaction {
|
||||
this.insert_reaction(&message.rumor, cx);
|
||||
} else {
|
||||
this.insert_message(message, false, cx);
|
||||
|
||||
if !window.is_window_active() {
|
||||
cx.show_system_notification(SystemNotification {
|
||||
tag: "message".into(),
|
||||
title: "New Message".into(),
|
||||
body: "You have a new message.".into(),
|
||||
actions: vec![SystemNotificationAction {
|
||||
id: "open".into(),
|
||||
label: "Open".into(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
RoomEvent::Reload => {
|
||||
}
|
||||
RoomEvent::Reload => {
|
||||
// Defer to avoid re-entrant read on Room while
|
||||
// emit_refresh holds a write lock (via refresh_rooms).
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.get_messages(window, cx);
|
||||
}
|
||||
};
|
||||
},
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
/// Load all messages belonging to this room
|
||||
@@ -329,85 +373,107 @@ impl ChatPanel {
|
||||
// Get the message which includes all attachments
|
||||
let content = self.get_input_value(cx);
|
||||
|
||||
// Get the replies to this message
|
||||
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
||||
|
||||
// Return if message is empty
|
||||
if content.trim().is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
self.send_message(&content, window, cx);
|
||||
// If replying to exactly one message with only a valid emoji,
|
||||
// send as a reaction instead of a text message
|
||||
if replies.len() == 1 && EMOJI_RE.is_match(&content) && self.attachments.read(cx).is_empty()
|
||||
{
|
||||
for reply in &replies {
|
||||
self.send_reaction(&content, reply, window, cx);
|
||||
}
|
||||
self.clear(window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
self.send_message(&content, replies, false, window, cx);
|
||||
}
|
||||
|
||||
fn send_reaction(
|
||||
&mut self,
|
||||
emoji: &str,
|
||||
target: &EventId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Return if emoji is empty
|
||||
if emoji.trim().is_empty() {
|
||||
window.push_notification("Cannot send an empty reaction", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
self.send_message(emoji, vec![*target], true, window, cx);
|
||||
}
|
||||
|
||||
/// Send a message to all members of the chat
|
||||
fn send_message(&mut self, value: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn send_message(
|
||||
&mut self,
|
||||
value: &str,
|
||||
replies: Vec<EventId>,
|
||||
reaction: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if value.trim().is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get room entity
|
||||
let room = self.room.clone();
|
||||
|
||||
// Get content and replies
|
||||
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
||||
let content = value.to_string();
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let room = room.upgrade().context("Room is not available")?;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match room.read(cx).rumor(content, replies, cx) {
|
||||
Some(rumor) => {
|
||||
this.insert_message(&rumor, true, cx);
|
||||
this.send_and_wait(rumor, window, cx);
|
||||
this.clear(window, cx);
|
||||
}
|
||||
None => {
|
||||
window.push_notification("Failed to create message", cx);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Send message in the background and wait for the response
|
||||
fn send_and_wait(&mut self, rumor: UnsignedEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let sent_ids = self.sent_ids.clone();
|
||||
|
||||
// This can't fail, because we already ensured that the ID is set
|
||||
let id = rumor.id.unwrap();
|
||||
// Upgrade room and create rumor + send task in a single read lock
|
||||
let Some(room_entity) = room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Add empty reports
|
||||
// Create rumor and send task
|
||||
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| {
|
||||
let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?;
|
||||
let send_task = room.send(rumor.clone(), cx)?;
|
||||
Some((rumor, send_task))
|
||||
}) {
|
||||
Some(pair) => pair,
|
||||
None => {
|
||||
window.push_notification("Failed to create message", cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let id = rumor.id.expect("rumor must have an id");
|
||||
|
||||
// Insert optimistic message and clear input
|
||||
if rumor.kind != Kind::Reaction {
|
||||
self.insert_message(&rumor, true, cx);
|
||||
self.clear(window, cx);
|
||||
} else {
|
||||
self.insert_reaction(&rumor, cx);
|
||||
}
|
||||
|
||||
// Update reports
|
||||
self.insert_reports(id, vec![], cx);
|
||||
|
||||
// Upgrade room reference
|
||||
let Some(room) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Get the send message task
|
||||
let Some(task) = room.read(cx).send(rumor, cx) else {
|
||||
window.push_notification("Failed to send message", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
// Spawn a single task to await the send and update reports
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
// Send and get reports
|
||||
let outputs = task.await;
|
||||
let outputs = send_task.await;
|
||||
|
||||
// Add sent IDs to the list
|
||||
let mut sent_ids = sent_ids.write().await;
|
||||
let mut sent_ids = sent_ids.lock().await;
|
||||
sent_ids.extend(outputs.iter().filter_map(|output| output.gift_wrap_id));
|
||||
|
||||
// Update the state
|
||||
this.update(cx, |this, cx| {
|
||||
this.insert_reports(id, outputs, cx);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
/// Clear the input field, attachments, and replies
|
||||
@@ -429,10 +495,13 @@ impl ChatPanel {
|
||||
|
||||
/// Insert reports
|
||||
fn insert_reports(&mut self, id: EventId, reports: Vec<SendReport>, cx: &mut Context<Self>) {
|
||||
self.reports_by_id.update(cx, |this, cx| {
|
||||
this.entry(id).or_default().extend(reports);
|
||||
cx.notify();
|
||||
});
|
||||
self.reports_by_id
|
||||
.write()
|
||||
.unwrap()
|
||||
.entry(id)
|
||||
.or_default()
|
||||
.extend(reports);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Insert a message into the chat panel
|
||||
@@ -441,9 +510,14 @@ impl ChatPanel {
|
||||
E: Into<Message>,
|
||||
{
|
||||
let old_len = self.messages.len();
|
||||
let msg: Message = m.into();
|
||||
|
||||
// Extend the messages list with the new events
|
||||
if self.messages.insert(m.into()) {
|
||||
if let Err(pos) = self.messages.binary_search(&msg) {
|
||||
self.messages.insert(pos, msg);
|
||||
// Rebuild message index after insertion (indices from pos to end shift)
|
||||
for (i, message) in self.messages.iter().enumerate().skip(pos) {
|
||||
self.message_index.insert(message.id, i);
|
||||
}
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
|
||||
if scroll {
|
||||
@@ -460,24 +534,56 @@ impl ChatPanel {
|
||||
/// Convert and insert a vector of nostr events into the chat panel
|
||||
fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context<Self>) {
|
||||
for event in events.iter() {
|
||||
if event.kind == Kind::Reaction {
|
||||
self.insert_reaction(event, cx);
|
||||
continue;
|
||||
}
|
||||
// Bulk inserting messages, so no need to scroll to the latest message
|
||||
self.insert_message(event, false, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a reaction into the chat panel
|
||||
fn insert_reaction(&mut self, event: &UnsignedEvent, cx: &mut Context<Self>) {
|
||||
if event.kind != Kind::Reaction {
|
||||
return;
|
||||
}
|
||||
|
||||
for id in event.tags.event_ids() {
|
||||
self.reactions
|
||||
.entry(id)
|
||||
.or_default()
|
||||
.push((SharedString::from(&event.content), event.pubkey));
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Check if a message has any reports
|
||||
fn has_reports(&self, id: &EventId, cx: &App) -> bool {
|
||||
self.reports_by_id.read(cx).get(id).is_some()
|
||||
fn has_reports(&self, id: &EventId) -> bool {
|
||||
self.reports_by_id.read().unwrap().contains_key(id)
|
||||
}
|
||||
|
||||
/// Get all sent reports for a message by its ID
|
||||
fn sent_reports(&self, id: &EventId, cx: &App) -> Option<Vec<SendReport>> {
|
||||
self.reports_by_id.read(cx).get(id).cloned()
|
||||
/// Clone reports for a message (used for modal display, not called during render)
|
||||
fn sent_reports(&self, id: &EventId) -> Option<Vec<SendReport>> {
|
||||
self.reports_by_id.read().unwrap().get(id).cloned()
|
||||
}
|
||||
|
||||
/// Get a message by its ID
|
||||
/// Get a message by its ID (O(1) lookup)
|
||||
fn message(&self, id: &EventId) -> Option<&Message> {
|
||||
self.messages.iter().find(|msg| &msg.id == id)
|
||||
self.message_index
|
||||
.get(id)
|
||||
.and_then(|&ix| self.messages.get(ix))
|
||||
}
|
||||
|
||||
/// Get a reaction by its target ID (returns reference, no allocation)
|
||||
fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] {
|
||||
self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Check if a message has any reactions
|
||||
fn has_reaction(&self, id: &EventId) -> bool {
|
||||
self.reactions.contains_key(id)
|
||||
}
|
||||
|
||||
/// Scroll to a message by its ID
|
||||
@@ -592,7 +698,10 @@ impl ChatPanel {
|
||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match command {
|
||||
Command::Insert(content) => {
|
||||
self.send_message(content, window, cx);
|
||||
self.input.update(cx, |this, cx| {
|
||||
let new_value = format!("{} {}", this.value(), content);
|
||||
this.set_value(new_value, window, cx);
|
||||
});
|
||||
}
|
||||
Command::ChangeSubject(subject) => {
|
||||
if self
|
||||
@@ -796,10 +905,8 @@ impl ChatPanel {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut iter = self.messages.iter();
|
||||
|
||||
if let Some(previous) = iter.nth(ix - 1)
|
||||
&& let Some(current) = iter.next()
|
||||
if let Some(previous) = self.messages.get(ix - 1)
|
||||
&& let Some(current) = self.messages.get(ix)
|
||||
{
|
||||
if current.author != previous.author {
|
||||
return true;
|
||||
@@ -822,7 +929,7 @@ impl ChatPanel {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
if let Some(message) = self.messages.iter().nth(ix) {
|
||||
if let Some(message) = self.messages.get(ix) {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let show_author = self.is_group_start(ix);
|
||||
let text = self
|
||||
@@ -853,7 +960,8 @@ impl ChatPanel {
|
||||
|
||||
let replies = message.replies_to.as_slice();
|
||||
let has_replies = !replies.is_empty();
|
||||
let has_reports = self.has_reports(&id, cx);
|
||||
let has_reactions = self.has_reaction(&id);
|
||||
let has_reports = self.has_reports(&id);
|
||||
|
||||
// Hide avatar setting
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
@@ -898,12 +1006,7 @@ impl ChatPanel {
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text)
|
||||
.child(author.name()),
|
||||
)
|
||||
.child(div().font_semibold().child(author.name()))
|
||||
.child(message.created_at.to_human_time())
|
||||
.when(has_reports, |this| {
|
||||
this.child(self.render_sent_reports(&id, cx))
|
||||
@@ -914,7 +1017,10 @@ impl ChatPanel {
|
||||
this.children(self.render_message_replies(replies, cx))
|
||||
})
|
||||
.child(rendered_text)
|
||||
.child(self.render_media(&message.media, cx)),
|
||||
.child(self.render_media(&message.media, cx))
|
||||
.when(has_reactions, |this| {
|
||||
this.child(self.render_reactions(&id, cx))
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -1009,13 +1115,9 @@ impl ChatPanel {
|
||||
.w_full()
|
||||
.px_2()
|
||||
.border_l_2()
|
||||
.border_color(cx.theme().element_selected)
|
||||
.border_color(cx.theme().element_active)
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_accent)
|
||||
.child(author.name()),
|
||||
)
|
||||
.child(div().font_semibold().child(author.name()))
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
@@ -1036,8 +1138,45 @@ impl ChatPanel {
|
||||
items
|
||||
}
|
||||
|
||||
fn render_reactions(&self, id: &EventId, cx: &App) -> impl IntoElement {
|
||||
let current_user = NostrRegistry::global(cx).read(cx).current_user();
|
||||
let reactions = self.reaction(id);
|
||||
|
||||
// Group reactions by emoji and collect authors for each
|
||||
let mut grouped: BTreeMap<SharedString, Vec<PublicKey>> = BTreeMap::new();
|
||||
for (emoji, author) in reactions {
|
||||
grouped.entry(emoji.clone()).or_default().push(*author);
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.mt_2()
|
||||
.gap_1()
|
||||
.children(grouped.into_iter().map(|(emoji, authors)| {
|
||||
let count = authors.len();
|
||||
let has_reacted = current_user
|
||||
.map(|pk| authors.contains(&pk))
|
||||
.unwrap_or(false);
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.py_0p5()
|
||||
.px_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_xs()
|
||||
.border_1()
|
||||
.when(has_reacted, |this| {
|
||||
this.text_color(cx.theme().secondary_foreground)
|
||||
.bg(cx.theme().secondary_background)
|
||||
.border_color(cx.theme().secondary_active)
|
||||
})
|
||||
.when(!has_reacted, |this| this.border_color(cx.theme().border))
|
||||
.child(emoji)
|
||||
.child(SharedString::from(count.to_string()))
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
|
||||
let reports = self.sent_reports(id, cx);
|
||||
let reports = self.sent_reports(id);
|
||||
|
||||
let pending = reports
|
||||
.as_ref()
|
||||
@@ -1211,6 +1350,29 @@ impl ChatPanel {
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().background)
|
||||
.children({
|
||||
let mut items = vec![];
|
||||
|
||||
for emoji in COMPACT_REACTION_EMOJIS {
|
||||
items.push(
|
||||
Button::new(*emoji)
|
||||
.label(*emoji)
|
||||
.tooltip(*emoji)
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click({
|
||||
let emoji = *emoji;
|
||||
let id = *id;
|
||||
cx.listener(move |this, _event, window, cx| {
|
||||
this.send_reaction(emoji, &id, window, cx);
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
|
||||
.child(
|
||||
Button::new("reply")
|
||||
.icon(IconName::Reply)
|
||||
@@ -1324,7 +1486,7 @@ impl ChatPanel {
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Replying to:"))
|
||||
.child("Replying to:")
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_accent)
|
||||
@@ -1421,15 +1583,10 @@ impl ChatPanel {
|
||||
.ghost()
|
||||
.large()
|
||||
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
||||
this.horizontal()
|
||||
.menu("👍", Box::new(Command::Insert("👍")))
|
||||
.menu("👎", Box::new(Command::Insert("👎")))
|
||||
.menu("😄", Box::new(Command::Insert("😄")))
|
||||
.menu("🎉", Box::new(Command::Insert("🎉")))
|
||||
.menu("😕", Box::new(Command::Insert("😕")))
|
||||
.menu("❤️", Box::new(Command::Insert("❤️")))
|
||||
.menu("🚀", Box::new(Command::Insert("🚀")))
|
||||
.menu("👀", Box::new(Command::Insert("👀")))
|
||||
let menu = this.horizontal();
|
||||
REACTION_EMOJIS.iter().fold(menu, |this, emoji| {
|
||||
this.menu(*emoji, Box::new(Command::Insert(emoji)))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1496,12 +1653,7 @@ impl Render for ChatPanel {
|
||||
.gap_2()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
Input::new(&self.subject_input)
|
||||
.text_sm()
|
||||
.small()
|
||||
.bordered(false),
|
||||
)
|
||||
.child(Input::new(&self.subject_input).text_sm().small())
|
||||
.child(
|
||||
Button::new("change")
|
||||
.icon(IconName::CheckCircle)
|
||||
|
||||
@@ -7,6 +7,7 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
nostr.workspace = true
|
||||
instant.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
itertools.workspace = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::time::Duration;
|
||||
use instant::Duration;
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use futures::FutureExt;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
use instant::Duration;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use gpui::{
|
||||
@@ -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?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::fmt::Display;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
@@ -42,28 +41,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 +122,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,7 +137,6 @@ 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(),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -27,6 +28,7 @@ mime_guess = "2.0.4"
|
||||
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
|
||||
|
||||
@@ -39,14 +39,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",
|
||||
];
|
||||
|
||||
@@ -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"))]
|
||||
@@ -24,7 +27,7 @@ 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 +40,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 +61,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 +103,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 +133,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 +176,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 +193,9 @@ impl NostrRegistry {
|
||||
}
|
||||
};
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
Ok(())
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Connect to the bootstrapping relays
|
||||
@@ -253,6 +265,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 +285,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 +315,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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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.);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
use instant::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::time::Duration;
|
||||
use instant::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::time::Duration;
|
||||
use instant::Duration;
|
||||
|
||||
use gpui::{Context, Pixels, Task, px};
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
¤t_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 positive,Start 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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};
|
||||
|
||||
@@ -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,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::{
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
use std::time::Duration;
|
||||
use instant::Duration;
|
||||
|
||||
use gpui::{
|
||||
bounce, div, ease_in_out, Animation, AnimationExt, IntoElement, RenderOnce, StyleRefinement,
|
||||
|
||||
@@ -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,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::{
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
use std::time::Duration;
|
||||
use instant::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use device::DeviceRegistry;
|
||||
|
||||
@@ -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);
|
||||
@@ -234,7 +234,8 @@ impl Screening {
|
||||
}
|
||||
.to_tag();
|
||||
|
||||
let event = EventBuilder::report(vec![tag], "")
|
||||
let event = EventBuilder::new(Kind::Reporting, "")
|
||||
.tag(tag)
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
@@ -262,7 +263,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 +342,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 +355,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 +387,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 +413,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 +482,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 +495,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);
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ::settings::AppSettings;
|
||||
use anyhow::Error;
|
||||
use auto_update::AutoUpdater;
|
||||
use chat::{ChatEvent, ChatRegistry};
|
||||
use common::{CoopImageCache, 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, image_cache, px,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{PersonRegistry, shorten_pubkey};
|
||||
@@ -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,12 +60,16 @@ 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 +80,12 @@ 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 +98,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 +219,22 @@ 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 +255,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 +276,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 +287,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 +297,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 +307,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 +317,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 +346,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 +373,20 @@ impl Workspace {
|
||||
cx.open_with_system(output_path.as_path());
|
||||
})?;
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
Command::ImportEncryption => {
|
||||
self.import_encryption(window, cx);
|
||||
}
|
||||
Command::Update => {
|
||||
let auto_updater = AutoUpdater::global(cx);
|
||||
auto_updater.update(cx, |this, cx| {
|
||||
this.updater.update(cx, |updater, cx| {
|
||||
updater.check(cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,10 +442,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())
|
||||
});
|
||||
}
|
||||
@@ -630,6 +594,11 @@ impl Workspace {
|
||||
Box::new(Command::ToggleTheme),
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"Check for Updates",
|
||||
IconName::Device,
|
||||
Box::new(Command::Update),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Settings",
|
||||
IconName::Settings,
|
||||
@@ -641,10 +610,9 @@ impl Workspace {
|
||||
}
|
||||
|
||||
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let updater = AutoUpdater::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 {
|
||||
@@ -654,43 +622,16 @@ impl Workspace {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
let announcement = profile.announcement();
|
||||
let updater_idle = updater.read(cx).idle(cx);
|
||||
|
||||
h_flex()
|
||||
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
||||
.gap_2()
|
||||
.when(trash_messages > 0, |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,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.when(!updater_idle, |this| {
|
||||
let status = updater.read(cx).status(cx);
|
||||
this.child(div().text_xs().italic().child(status))
|
||||
})
|
||||
.when(is_nip4e_enabled, |this| {
|
||||
.when(nip4e_enabled, |this| {
|
||||
this.child(
|
||||
Button::new("key")
|
||||
.icon(IconName::UserKey)
|
||||
@@ -782,17 +723,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 +735,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 +742,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,7 +762,7 @@ 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()
|
||||
@@ -847,8 +779,19 @@ impl Render for Workspace {
|
||||
.child(self.titlebar_left(cx))
|
||||
.child(self.titlebar_right(cx)),
|
||||
)
|
||||
// Dock
|
||||
.child(self.dock.clone()),
|
||||
// Main
|
||||
.child(
|
||||
h_flex()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.h_full()
|
||||
.w(SIDEBAR_WIDTH)
|
||||
.child(self.sidebar.clone()),
|
||||
)
|
||||
.child(self.dock.clone()),
|
||||
),
|
||||
),
|
||||
)
|
||||
// Notifications
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
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 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> {
|
||||
@@ -220,8 +221,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 +283,78 @@ 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()
|
||||
.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);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -4,4 +4,3 @@ pub mod greeter;
|
||||
pub mod messaging_relays;
|
||||
pub mod profile;
|
||||
pub mod relay_list;
|
||||
pub mod trash;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use gpui::{
|
||||
@@ -7,6 +6,7 @@ use gpui::{
|
||||
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
||||
Window, div,
|
||||
};
|
||||
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(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +353,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 +364,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 +375,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 +419,6 @@ impl Render for ProfilePanel {
|
||||
.icon(IconName::CheckCircle)
|
||||
.label("Update")
|
||||
.primary()
|
||||
.small()
|
||||
.font_semibold()
|
||||
.loading(self.updating)
|
||||
.disabled(self.updating)
|
||||
|
||||
@@ -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, px, rems,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
@@ -216,7 +216,7 @@ impl RelayListPanel {
|
||||
self.set_updating(true, cx);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let event = EventBuilder::relay_list(relays)
|
||||
let event = nip65::RelayList::new(relays)
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
@@ -371,12 +371,7 @@ impl Render for RelayListPanel {
|
||||
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("metadata")
|
||||
.map(|this| {
|
||||
@@ -434,7 +429,6 @@ impl Render for RelayListPanel {
|
||||
.icon(IconName::CheckCircle)
|
||||
.label("Update")
|
||||
.primary()
|
||||
.small()
|
||||
.font_semibold()
|
||||
.loading(self.updating)
|
||||
.disabled(self.updating)
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
use chat::ChatRegistry;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListState, ParentElement, Render,
|
||||
SharedString, Styled, Window, div, list, px, relative,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::scroll::Scrollbar;
|
||||
use ui::{Icon, IconName, Sizable, h_flex, v_flex};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<TrashPanel> {
|
||||
cx.new(|cx| TrashPanel::new(window, cx))
|
||||
}
|
||||
|
||||
pub struct TrashPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// List state for messages
|
||||
list_state: ListState,
|
||||
}
|
||||
|
||||
impl TrashPanel {
|
||||
fn new(_window: &mut Window, cx: &mut App) -> Self {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let count = chat.read(cx).count_trash_messages(cx);
|
||||
let list_state = ListState::new(count, ListAlignment::Bottom, px(1024.));
|
||||
|
||||
Self {
|
||||
name: "Trash".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
list_state,
|
||||
}
|
||||
}
|
||||
|
||||
fn copy(&self, ix: usize, cx: &App) {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let trashes = chat.read(cx).trashes();
|
||||
|
||||
if let Some(message) = trashes.read(cx).iter().nth(ix) {
|
||||
let item = ClipboardItem::new_string(message.raw_event.to_string());
|
||||
cx.write_to_clipboard(item);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_list_item(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let trashes = chat.read(cx).trashes();
|
||||
|
||||
if let Some(message) = trashes.read(cx).iter().nth(ix) {
|
||||
v_flex()
|
||||
.id(ix)
|
||||
.p_2()
|
||||
.w_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.p_2()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.bg(cx.theme().surface_background)
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.child(message.reason.clone()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_10()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.justify_between()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
div()
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.text_xs()
|
||||
.line_height(relative(1.))
|
||||
.child(message.raw_event.clone()),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("copy-{ix}"))
|
||||
.icon(IconName::Copy)
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||
this.copy(ix, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
} else {
|
||||
div().id(ix).into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for TrashPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(Icon::new(IconName::Warning).small())
|
||||
.child("Errors")
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for TrashPanel {}
|
||||
|
||||
impl Focusable for TrashPanel {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TrashPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex().size_full().relative().child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.relative()
|
||||
.child(
|
||||
list(
|
||||
self.list_state.clone(),
|
||||
cx.processor(move |this, ix, window, cx| {
|
||||
this.render_list_item(ix, window, cx)
|
||||
}),
|
||||
)
|
||||
.size_full(),
|
||||
)
|
||||
.child(Scrollbar::vertical(&self.list_state)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -164,9 +164,7 @@ impl RenderOnce for RoomEntry {
|
||||
)
|
||||
.on_cancel(move |_event, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
// Prevent closing the modal on click
|
||||
// modal will be automatically closed after closing panel
|
||||
false
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
use std::ops::Range;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||
@@ -12,11 +11,12 @@ use gpui::{
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
|
||||
Window, div, uniform_list,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{FIND_DELAY, IMAGE_CACHE_SIZE, NostrRegistry};
|
||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT};
|
||||
use theme::{ActiveTheme, SIDEBAR_WIDTH};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::indicator::Indicator;
|
||||
@@ -29,13 +29,8 @@ mod entry;
|
||||
|
||||
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
|
||||
cx.new(|cx| Sidebar::new(window, cx))
|
||||
}
|
||||
|
||||
/// Sidebar.
|
||||
pub struct Sidebar {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
scroll_handle: UniformListScrollHandle,
|
||||
|
||||
@@ -80,7 +75,7 @@ pub struct Sidebar {
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let filter = cx.new(|_| RoomKind::Ongoing);
|
||||
let contact_list = cx.new(|_| None);
|
||||
@@ -135,7 +130,6 @@ impl Sidebar {
|
||||
);
|
||||
|
||||
Self {
|
||||
name: "Sidebar".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
scroll_handle: UniformListScrollHandle::new(),
|
||||
find_input,
|
||||
@@ -335,7 +329,7 @@ impl Sidebar {
|
||||
.organize(&public_key)
|
||||
.kind(RoomKind::Ongoing)
|
||||
});
|
||||
this.emit_room(&room, cx);
|
||||
this.emit_room(&room, _window, cx);
|
||||
})?;
|
||||
|
||||
// Reset the find panel
|
||||
@@ -360,6 +354,14 @@ impl Sidebar {
|
||||
cx.notify();
|
||||
});
|
||||
self.new_requests = false;
|
||||
|
||||
// Reset search state when switching to inbox/requests
|
||||
self.reset(window, cx);
|
||||
|
||||
// Clear the find input value
|
||||
self.find_input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_list_items(
|
||||
@@ -379,9 +381,9 @@ impl Sidebar {
|
||||
let room = item.read(cx);
|
||||
let room_clone = item.clone();
|
||||
let public_key = room.display_member(cx).public_key();
|
||||
let handler = cx.listener(move |_this, _ev, _window, cx| {
|
||||
let handler = cx.listener(move |_this, _ev, window, cx| {
|
||||
ChatRegistry::global(cx).update(cx, |s, cx| {
|
||||
s.emit_room(&room_clone, cx);
|
||||
s.emit_room(&room_clone, window, cx);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -474,7 +476,7 @@ impl Sidebar {
|
||||
|
||||
impl Panel for Sidebar {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
"Sidebar".into()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,8 +490,11 @@ impl Focusable for Sidebar {
|
||||
|
||||
impl Render for Sidebar {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let loading = chat.read(cx).loading();
|
||||
let logged_in = nostr.read(cx).current_user().is_some();
|
||||
let loading = chat.read(cx).loading() && logged_in;
|
||||
|
||||
let total_rooms = chat.read(cx).count(self.filter.read(cx), cx);
|
||||
|
||||
// Whether the find panel should be shown
|
||||
@@ -507,27 +512,21 @@ impl Render for Sidebar {
|
||||
.size_full()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.h(TABBAR_HEIGHT)
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().tab_background)
|
||||
.child(
|
||||
Input::new(&self.find_input)
|
||||
.appearance(false)
|
||||
.bordered(false)
|
||||
.small()
|
||||
.text_xs()
|
||||
.when(!self.find_input.read(cx).loading, |this| {
|
||||
this.suffix(
|
||||
Button::new("find-icon")
|
||||
.icon(IconName::Search)
|
||||
.tooltip("Press Enter to search")
|
||||
.transparent()
|
||||
.small(),
|
||||
)
|
||||
}),
|
||||
),
|
||||
h_flex().px_2().py_1().child(
|
||||
Input::new(&self.find_input)
|
||||
.small()
|
||||
.text_xs()
|
||||
.disabled(loading)
|
||||
.when(!self.find_input.read(cx).loading, |this| {
|
||||
this.suffix(
|
||||
Button::new("find-icon")
|
||||
.icon(IconName::Search)
|
||||
.tooltip("Press Enter to search")
|
||||
.transparent()
|
||||
.small(),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -539,7 +538,6 @@ impl Render for Sidebar {
|
||||
Button::new("search-results")
|
||||
.icon(IconName::Search)
|
||||
.tooltip("All search results")
|
||||
.small()
|
||||
.ghost_alt()
|
||||
.font_semibold()
|
||||
.flex_1()
|
||||
@@ -555,9 +553,8 @@ impl Render for Sidebar {
|
||||
this.icon(IconName::Inbox)
|
||||
}
|
||||
})
|
||||
.when(!show_find_panel, |this| this.label("Inbox"))
|
||||
.when(!show_find_panel, |this| this.label("Inbox").small())
|
||||
.tooltip("All ongoing conversations")
|
||||
.small()
|
||||
.ghost_alt()
|
||||
.font_semibold()
|
||||
.flex_1()
|
||||
@@ -577,9 +574,8 @@ impl Render for Sidebar {
|
||||
this.icon(IconName::Fistbump)
|
||||
}
|
||||
})
|
||||
.when(!show_find_panel, |this| this.label("Requests"))
|
||||
.when(!show_find_panel, |this| this.label("Requests").small())
|
||||
.tooltip("Incoming new conversations")
|
||||
.small()
|
||||
.ghost_alt()
|
||||
.font_semibold()
|
||||
.flex_1()
|
||||
@@ -608,17 +604,13 @@ impl Render for Sidebar {
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(div().text_sm().font_semibold().child("No conversations"))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("No conversations")),
|
||||
)
|
||||
.child(div().text_xs().text_color(cx.theme().text_muted).child(
|
||||
SharedString::from(
|
||||
"Start a conversation with someone to get started.",
|
||||
),
|
||||
)),
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Start a conversation with someone to get started."),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -643,7 +635,7 @@ impl Render for Sidebar {
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(Icon::new(IconName::ChevronDown))
|
||||
.child(SharedString::from("Results")),
|
||||
.child("Results"),
|
||||
)
|
||||
.child(
|
||||
uniform_list(
|
||||
@@ -670,7 +662,7 @@ impl Render for Sidebar {
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(Icon::new(IconName::ChevronDown).small())
|
||||
.child(SharedString::from("Suggestions")),
|
||||
.child("Contacts"),
|
||||
)
|
||||
.child(
|
||||
uniform_list(
|
||||
@@ -707,17 +699,17 @@ impl Render for Sidebar {
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
.bottom_0()
|
||||
.bottom_2()
|
||||
.left_0()
|
||||
.h_9()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.px_4()
|
||||
.child(
|
||||
Button::new("create")
|
||||
.label(button_label)
|
||||
.primary()
|
||||
.small()
|
||||
.shadow_lg()
|
||||
.rounded()
|
||||
.shadow_md()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.create_room(window, cx);
|
||||
})),
|
||||
@@ -740,15 +732,13 @@ impl Render for Sidebar {
|
||||
.h_9()
|
||||
.justify_center()
|
||||
.bg(cx.theme().background.opacity(0.85))
|
||||
.border_color(cx.theme().border_disabled)
|
||||
.border_1()
|
||||
.when(cx.theme().shadow, |this| this.shadow_xs())
|
||||
.when(cx.theme().shadow, |this| this.shadow_md())
|
||||
.rounded_full()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(Indicator::new().small().color(cx.theme().icon_accent))
|
||||
.child(SharedString::from("Getting messages...")),
|
||||
.child("Getting messages..."),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -48,3 +48,4 @@ reqwest_client.workspace = true
|
||||
|
||||
log.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
[package]
|
||||
name = "coop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "coop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[package.metadata.packager]
|
||||
name = "Coop"
|
||||
product-name = "Coop"
|
||||
description = "Chat Freely, Stay Private on Nostr"
|
||||
identifier = "su.reya.coop"
|
||||
category = "SocialNetworking"
|
||||
version = "1.0.0-beta5"
|
||||
out-dir = "../dist"
|
||||
before-packaging-command = "cargo build --release"
|
||||
resources = ["Cargo.toml", "src"]
|
||||
icons = [
|
||||
"resources/32x32.png",
|
||||
"resources/128x128.png",
|
||||
"resources/128x128@2x.png",
|
||||
"resources/icon.icns",
|
||||
"resources/icon.ico",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
assets = { path = "../crates/assets" }
|
||||
workspace = { path = "../crates/workspace" }
|
||||
ui = { path = "../crates/ui" }
|
||||
theme = { path = "../crates/theme" }
|
||||
common = { path = "../crates/common" }
|
||||
state = { path = "../crates/state" }
|
||||
device = { path = "../crates/device" }
|
||||
chat = { path = "../crates/chat" }
|
||||
settings = { path = "../crates/settings" }
|
||||
auto_update = { path = "../crates/auto_update" }
|
||||
person = { path = "../crates/person" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
gpui_linux.workspace = true
|
||||
gpui_windows.workspace = true
|
||||
gpui_macos.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
|
||||
log.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
@@ -7,6 +7,7 @@ use gpui::{
|
||||
actions, point, px, size,
|
||||
};
|
||||
use gpui_platform::application;
|
||||
use nostr_sdk::prelude::SecretKey;
|
||||
use state::{APP_ID, CLIENT_NAME};
|
||||
use ui::Root;
|
||||
|
||||
@@ -16,6 +17,14 @@ fn main() {
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// Parse CLI arguments for --sec <nsec1>
|
||||
let cli_key = parse_cli_key();
|
||||
if let Err(ref e) = cli_key {
|
||||
eprintln!("Failed to parse --sec argument: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let cli_key = cli_key.unwrap();
|
||||
|
||||
// Run application
|
||||
application()
|
||||
.with_assets(Assets)
|
||||
@@ -24,6 +33,9 @@ fn main() {
|
||||
// Load embedded fonts in assets/fonts
|
||||
load_embedded_fonts(cx);
|
||||
|
||||
// Set app identity
|
||||
cx.set_app_identity(APP_ID, CLIENT_NAME);
|
||||
|
||||
// Register the `quit` function
|
||||
cx.on_action(quit);
|
||||
|
||||
@@ -43,7 +55,7 @@ fn main() {
|
||||
}]);
|
||||
|
||||
// Set up the window bounds
|
||||
let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx);
|
||||
let bounds = Bounds::centered(None, size(px(960.0), px(720.0)), cx);
|
||||
|
||||
// Set up the window options
|
||||
let opts = WindowOptions {
|
||||
@@ -62,41 +74,39 @@ fn main() {
|
||||
|
||||
// Open a window with default options
|
||||
cx.open_window(opts, |window, cx| {
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
|
||||
cx.new(|cx| {
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
// Initialize theme registry
|
||||
theme::init(cx);
|
||||
|
||||
// Initialize theme registry
|
||||
theme::init(cx);
|
||||
// Initialize settings
|
||||
settings::init(window, cx);
|
||||
|
||||
// Initialize settings
|
||||
settings::init(window, cx);
|
||||
// Initialize the nostr client
|
||||
state::init(window, cx, cli_key);
|
||||
|
||||
// Initialize the nostr client
|
||||
state::init(window, cx);
|
||||
// Initialize person registry
|
||||
person::init(window, cx);
|
||||
|
||||
// Initialize person registry
|
||||
person::init(window, cx);
|
||||
// Initialize device signer
|
||||
//
|
||||
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
device::init(window, cx);
|
||||
|
||||
// Initialize device signer
|
||||
//
|
||||
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
device::init(window, cx);
|
||||
// Initialize app registry
|
||||
chat::init(window, cx);
|
||||
|
||||
// Initialize app registry
|
||||
chat::init(window, cx);
|
||||
// Initialize auto update
|
||||
auto_update::init(window, cx);
|
||||
|
||||
// Initialize auto update
|
||||
auto_update::init(window, cx);
|
||||
|
||||
// Root Entity
|
||||
Root::new(workspace::init(window, cx).into(), window, cx)
|
||||
})
|
||||
// Root view
|
||||
cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx))
|
||||
})
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,6 +134,25 @@ fn load_embedded_fonts(cx: &App) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn parse_cli_key() -> Result<Option<SecretKey>, String> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
if args[i] == "--sec" {
|
||||
if i + 1 < args.len() {
|
||||
let nsec = &args[i + 1];
|
||||
return SecretKey::parse(nsec)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Invalid nsec key '{nsec}': {e}"));
|
||||
} else {
|
||||
return Err("--sec requires a value (nsec1...)".to_string());
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn quit(_ev: &Quit, cx: &mut App) {
|
||||
log::info!("Gracefully quitting the application . . .");
|
||||
cx.quit();
|
||||
|
||||
|
Before Width: | Height: | Size: 769 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 535 KiB |
|
Before Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 446 KiB |
|
Before Width: | Height: | Size: 862 KiB |