20 Commits

Author SHA1 Message Date
0faa8694f8 use instant 2026-07-28 16:39:09 +07:00
5ddae637f6 . 2026-07-28 16:28:05 +07:00
0ae38d38d4 . 2026-07-28 16:22:19 +07:00
4253a6df3e fix 2026-07-28 15:43:44 +07:00
3a95456917 update web 2026-07-28 14:03:56 +07:00
a8bdd49072 . 2026-07-27 17:43:31 +07:00
fd32b8c0ce . 2026-07-27 17:22:50 +07:00
8d4d8184e6 . 2026-07-27 16:47:32 +07:00
e85974497b fix 2026-07-27 16:29:25 +07:00
da7167013f . 2026-07-27 16:07:25 +07:00
2ff83079b8 . 2026-07-27 15:02:50 +07:00
b349656c56 . 2026-07-27 10:46:10 +07:00
8bbb472103 optimize 2026-07-27 10:14:19 +07:00
dfda7ff157 clean up 2026-07-27 09:25:38 +07:00
f46f15e10c update 2026-07-27 09:23:26 +07:00
2f15615d7b clean up input component 2026-07-27 08:38:25 +07:00
9addbd49f4 fix web build 2026-07-27 08:09:55 +07:00
b518c729f6 feat: add initial support web via wasm (#35)
Reviewed-on: #35
2026-07-25 03:43:51 +00:00
4b57a1d2a6 chore: update deps 2026-07-16 10:43:16 +07:00
5ea6cdb34f chore: prepare for rc version (#34)
**TODOs:**

- [x] Fix all clippy issues
- [x] Make NIP-4e optional (disabled by default)
- [x] Remove support for bunker (Nostr Connect)
- [x] Group messages in the same timeframe
- [ ] ...

Reviewed-on: #34
2026-06-19 09:16:37 +00:00
102 changed files with 4191 additions and 8767 deletions

4
.gitignore vendored
View File

@@ -19,8 +19,8 @@ dist/
# Useless stuffs
.DS_Store
# Added by goreleaser init:
.intentionally-empty-file.o
.cargo/
vendor/
wasm/
node_modules/

2766
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,12 +19,13 @@ 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", rev = "46d66396467e07c3dfb71e5102104ecb7e9c6b64" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr", rev = "46d66396467e07c3dfb71e5102104ecb7e9c6b64" }
nostr-blossom = { git = "https://github.com/rust-nostr/nostr", rev = "46d66396467e07c3dfb71e5102104ecb7e9c6b64" }
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr", rev = "46d66396467e07c3dfb71e5102104ecb7e9c6b64" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", rev = "46d66396467e07c3dfb71e5102104ecb7e9c6b64" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ], rev = "46d66396467e07c3dfb71e5102104ecb7e9c6b64" }
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"
@@ -34,15 +35,20 @@ itertools = "0.13.0"
log = "0.4"
oneshot = "0.1.10"
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
rust-embed = "8.5.0"
rust-embed = { version = "8.5", features = ["include-exclude"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
schemars = "1"
smallvec = "1.14.0"
smol = "2"
tracing = "0.1.40"
webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
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

View File

@@ -8,9 +8,8 @@ publish.workspace = true
common = { path = "../common" }
gpui.workspace = true
gpui_tokio.workspace = true
instant.workspace = true
anyhow.workspace = true
smol.workspace = true
log.workspace = true
smallvec.workspace = true
serde = { workspace = true, features = ["derive"] }
@@ -18,4 +17,6 @@ serde_json.workspace = true
semver = "1.0.27"
tempfile = "3.23.0"
futures.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true

View File

@@ -1,7 +1,8 @@
#![cfg(not(target_arch = "wasm32"))]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::http_client::{AsyncBody, HttpClient};
@@ -9,6 +10,7 @@ use gpui::{
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
Window,
};
use instant::Duration;
use semver::Version;
use serde::Deserialize;
use smallvec::{SmallVec, smallvec};

View File

@@ -13,15 +13,16 @@ settings = { path = "../settings" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
smol.workspace = true
log.workspace = true
futures.workspace = true
flume.workspace = true
serde.workspace = true
serde_json.workspace = true
futures.workspace = true
fuzzy-matcher = "0.3.7"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,106 @@ use common::{EventExt, NostrParser, extract_and_remove_media_urls};
use gpui::{SharedString, SharedUri};
use nostr_sdk::prelude::*;
/// Rendered message.
#[derive(Debug, Clone)]
pub struct Message {
pub id: EventId,
/// Author's public key
pub author: PublicKey,
/// The content/text of the message
pub content: String,
/// List of media URLs in the message
pub media: Vec<SharedUri>,
/// Message created time as unix timestamp
pub created_at: Timestamp,
/// List of mentioned public keys in the message
pub mentions: Vec<Mention>,
/// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>,
}
impl From<&Event> for Message {
fn from(val: &Event) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self {
id: val.id,
author: val.pubkey,
content: string,
media,
created_at: val.created_at,
mentions,
replies_to,
}
}
}
impl From<&UnsignedEvent> for Message {
fn from(val: &UnsignedEvent) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self {
// Event ID must be known
id: val.id.unwrap(),
author: val.pubkey,
content: string,
media,
created_at: val.created_at,
mentions,
replies_to,
}
}
}
impl From<&NewMessage> for Message {
fn from(val: &NewMessage) -> Self {
let mentions = extract_mentions(&val.rumor.content);
let replies_to = extract_reply_ids(&val.rumor.tags);
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
Self {
// Event ID must be known
id: val.rumor.id.unwrap(),
author: val.rumor.pubkey,
content: string,
media,
created_at: val.rumor.created_at,
mentions,
replies_to,
}
}
}
impl Eq for Message {}
impl PartialEq for Message {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Ord for Message {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.created_at.cmp(&other.created_at)
}
}
impl PartialOrd for Message {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Hash for Message {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
/// New message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NewMessage {
@@ -44,74 +144,6 @@ impl FailedMessage {
}
}
/// Message.
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum Message {
User(RenderedMessage),
Warning(String, Timestamp),
System(Timestamp),
}
impl Message {
pub fn user<I>(user: I) -> Self
where
I: Into<RenderedMessage>,
{
Self::User(user.into())
}
pub fn warning<I>(content: I) -> Self
where
I: Into<String>,
{
Self::Warning(content.into(), Timestamp::now())
}
pub fn system() -> Self {
Self::System(Timestamp::default())
}
fn timestamp(&self) -> &Timestamp {
match self {
Message::User(msg) => &msg.created_at,
Message::Warning(_, ts) => ts,
Message::System(ts) => ts,
}
}
}
impl From<&NewMessage> for Message {
fn from(val: &NewMessage) -> Self {
Self::User(val.into())
}
}
impl From<&UnsignedEvent> for Message {
fn from(val: &UnsignedEvent) -> Self {
Self::User(val.into())
}
}
impl Ord for Message {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match (self, other) {
// System always comes first
(Message::System(_), Message::System(_)) => self.timestamp().cmp(other.timestamp()),
(Message::System(_), _) => std::cmp::Ordering::Less,
(_, Message::System(_)) => std::cmp::Ordering::Greater,
// For non-system messages, compare by timestamp
_ => self.timestamp().cmp(other.timestamp()),
}
}
}
impl PartialOrd for Message {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug, Clone)]
pub struct Mention {
pub public_key: PublicKey,
@@ -124,106 +156,6 @@ impl Mention {
}
}
/// Rendered message.
#[derive(Debug, Clone)]
pub struct RenderedMessage {
pub id: EventId,
/// Author's public key
pub author: PublicKey,
/// The content/text of the message
pub content: String,
/// List of media URLs in the message
pub media: Vec<SharedUri>,
/// Message created time as unix timestamp
pub created_at: Timestamp,
/// List of mentioned public keys in the message
pub mentions: Vec<Mention>,
/// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>,
}
impl From<&Event> for RenderedMessage {
fn from(val: &Event) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self {
id: val.id,
author: val.pubkey,
content: string,
media,
created_at: val.created_at,
mentions,
replies_to,
}
}
}
impl From<&UnsignedEvent> for RenderedMessage {
fn from(val: &UnsignedEvent) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self {
// Event ID must be known
id: val.id.unwrap(),
author: val.pubkey,
content: string,
media,
created_at: val.created_at,
mentions,
replies_to,
}
}
}
impl From<&NewMessage> for RenderedMessage {
fn from(val: &NewMessage) -> Self {
let mentions = extract_mentions(&val.rumor.content);
let replies_to = extract_reply_ids(&val.rumor.tags);
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
Self {
// Event ID must be known
id: val.rumor.id.unwrap(),
author: val.rumor.pubkey,
content: string,
media,
created_at: val.rumor.created_at,
mentions,
replies_to,
}
}
}
impl Eq for RenderedMessage {}
impl PartialEq for RenderedMessage {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Ord for RenderedMessage {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.created_at.cmp(&other.created_at)
}
}
impl PartialOrd for RenderedMessage {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Hash for RenderedMessage {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
/// Extracts all mentions (public keys) from a content string.
fn extract_mentions(content: &str) -> Vec<Mention> {
let parser = NostrParser::new();
@@ -242,13 +174,13 @@ fn extract_mentions(content: &str) -> Vec<Mention> {
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
let mut replies_to = vec![];
for tag in inner.filter(TagKind::e()) {
for tag in inner.iter().filter(|tag| tag.kind() == "e") {
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
replies_to.push(id);
}
}
for tag in inner.filter(TagKind::q()) {
for tag in inner.iter().filter(|tag| tag.kind() == "q") {
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
replies_to.push(id);
}

View File

@@ -1,15 +1,16 @@
use std::cmp::Ordering;
use std::hash::{Hash, Hasher};
use std::time::Duration;
use instant::Duration;
use anyhow::{Error, anyhow};
use common::EventExt;
use device::DeviceRegistry;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT};
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
use crate::NewMessage;
@@ -21,7 +22,7 @@ pub struct SendReport {
pub receiver: PublicKey,
pub gift_wrap_id: Option<EventId>,
pub error: Option<SharedString>,
pub output: Option<Output<EventId>>,
pub output: Option<Output<EventId, EventSendStatus>>,
}
impl SendReport {
@@ -41,7 +42,7 @@ impl SendReport {
}
/// Set the output.
pub fn output(mut self, output: Output<EventId>) -> Self {
pub fn output(mut self, output: Output<EventId, EventSendStatus>) -> Self {
self.output = Some(output);
self
}
@@ -171,7 +172,8 @@ impl From<&UnsignedEvent> for Room {
let members = val.extract_public_keys();
let subject = val
.tags
.find(TagKind::Subject)
.iter()
.find(|tag| tag.kind() == "subject")
.and_then(|tag| tag.content().map(|s| s.to_owned().into()));
Room {
@@ -205,7 +207,7 @@ impl Room {
// WARNING: never sign this event
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
.tags(tags)
.build(author);
.finalize_unsigned(author);
// Ensure that the ID is set
event.ensure_id();
@@ -361,22 +363,31 @@ impl Room {
.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(())
@@ -387,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()
@@ -400,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();
@@ -425,24 +432,14 @@ impl Room {
let nostr = NostrRegistry::global(cx);
// Get current user's public key
let sender = nostr.read(cx).signer().public_key()?;
// 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();
let sender = nostr.read(cx).current_user()?;
// Construct event's tags
let mut tags = vec![];
// Add subject tag if present
if let Some(value) = self.subject.as_ref() {
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
value.to_string(),
)));
tags.push(Tag::custom("subject", vec![value.to_string()]));
}
// Add all reply tags
@@ -450,21 +447,23 @@ impl Room {
tags.push(Tag::event(id))
}
// Add all receiver tags
for member in members.into_iter() {
tags.push(Tag::from_standardized_without_cell(
TagStandard::PublicKey {
// 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(),
relay_url: member.messaging_relay_hint(),
alias: None,
uppercase: false,
},
));
relay_hint: member.messaging_relay_hint(),
}
.to_tag(),
);
}
// Construct a direct message rumor event
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(kind, content).tags(tags).build(sender);
let mut event = EventBuilder::new(kind, content)
.tags(tags)
.finalize_unsigned(sender);
// Ensure that the ID is set
event.ensure_id();
@@ -472,17 +471,45 @@ 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();
let persons = PersonRegistry::global(cx);
let device = DeviceRegistry::global(cx);
let encryption_signer = device.read(cx).signer(cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let user_signer = nostr.read(cx).signer();
let current_user = nostr.read(cx).current_user()?;
// Get current user's public key
let public_key = nostr.read(cx).signer().public_key()?;
let sender = persons.read(cx).get(&public_key, cx);
// Get sender's profile
let persons = PersonRegistry::global(cx);
let sender = persons.read(cx).get(&current_user, cx);
// Get all members (excluding sender)
let members: Vec<Person> = self
@@ -496,9 +523,6 @@ impl Room {
let signer_kind = config.signer_kind();
let backup = config.backup();
let user_signer = signer.get().await;
let encryption_signer = signer.get_encryption_signer().await;
let mut sents = 0;
let mut reports = Vec::new();
@@ -523,23 +547,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 {
@@ -559,23 +572,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),
@@ -592,17 +594,14 @@ impl Room {
}
// Helper function to send a gift-wrapped event
async fn send_gift_wrap<T>(
async fn send_gift_wrap(
client: &Client,
signer: &T,
signer: &UniversalSigner,
receiver: &Person,
rumor: &UnsignedEvent,
config: &SignerKind,
) -> Result<SendReport, Error>
where
T: NostrSigner + 'static,
{
let k_tag = Tag::custom(TagKind::k(), vec!["14"]);
) -> Result<SendReport, Error> {
let k_tag = Tag::custom("k", vec!["14"]);
let mut extra_tags = vec![k_tag];
// Determine the receiver public key based on the config
@@ -627,7 +626,10 @@ where
};
// Construct the gift wrap event
let event = EventBuilder::gift_wrap(signer, &receiver, rumor.clone(), extra_tags).await?;
let event = nip59::GiftWrapBuilder::new(receiver, rumor.clone())
.extra_tags(extra_tags)
.finalize_async(signer)
.await?;
// Send the gift wrap event and collect the report
let report = client

View File

@@ -14,19 +14,15 @@ chat = { path = "../chat" }
settings = { path = "../settings" }
gpui.workspace = true
gpui_tokio.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
smol.workspace = true
flume.workspace = true
futures.workspace = true
log.workspace = true
serde.workspace = true
serde_json.workspace = true
once_cell = "1.19.0"
regex = "1"
linkify = "0.10.0"
pulldown-cmark = "0.13.1"

View File

@@ -1,24 +1,24 @@
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::sync::Arc;
use std::sync::{Arc, RwLock};
pub use actions::*;
use anyhow::{Context as AnyhowContext, Error};
use chat::{ChatRegistry, Message, RenderedMessage, Room, RoomEvent, SendReport, SendStatus};
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,
deferred, div, img, list, px, red, relative, svg, white,
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window, div,
img, list, px, red, relative, svg, white,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use settings::{AppSettings, SignerKind};
use smallvec::{SmallVec, smallvec};
use smol::lock::RwLock;
use state::{NostrRegistry, upload};
use theme::ActiveTheme;
use ui::avatar::Avatar;
@@ -38,9 +38,6 @@ use crate::text::RenderedText;
mod actions;
mod text;
const ANNOUNCEMENT: &str =
"This conversation is private. Only members can see each other's messages.";
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
cx.new(|cx| ChatPanel::new(room, window, cx))
}
@@ -63,7 +60,7 @@ pub struct ChatPanel {
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 +72,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 +95,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::from([Message::system()]);
let messages = BTreeSet::default();
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
// Get room id and name
@@ -172,7 +169,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 +189,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 +204,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,10 +220,11 @@ 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() {
{
let mut map = reports.write().unwrap();
for reports in map.values_mut() {
for report in reports.iter_mut() {
let Some(output) = report.output.as_mut() else {
continue;
@@ -234,7 +232,7 @@ impl ChatPanel {
match &*status {
SendStatus::Ok { id, relay } => {
if output.id() == id {
output.success.insert(relay.clone());
output.success.insert(relay.clone(), EventSendStatus::Sent);
}
}
SendStatus::Failed { id, relay, message } => {
@@ -243,10 +241,10 @@ impl ChatPanel {
}
}
}
cx.notify();
}
}
})?;
}
this.update(cx, |_, cx| cx.notify()).ok();
}
Ok(())
}));
@@ -345,69 +343,47 @@ impl ChatPanel {
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;
};
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| {
let rumor = room.rumor(content.clone(), replies, 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;
}
};
// Add empty reports
let id = rumor.id.expect("rumor must have an id");
// Insert optimistic message and clear input
self.insert_message(&rumor, true, cx);
self.insert_reports(id, vec![], cx);
self.clear(window, 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 +405,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
@@ -466,41 +445,22 @@ impl ChatPanel {
}
/// 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, _cx: &App) -> bool {
self.reports_by_id.read().unwrap().get(id).is_some()
}
/// Check if a message was encrypted by the dekey
fn encrypted_by_dekey(&self, id: &EventId, cx: &App) -> bool {
let chat = ChatRegistry::global(cx);
chat.read(cx).encrypted_by_dekey(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()
fn sent_reports(&self, id: &EventId, _cx: &App) -> Option<Vec<SendReport>> {
self.reports_by_id.read().unwrap().get(id).cloned()
}
/// Get a message by its ID
fn message(&self, id: &EventId) -> Option<&RenderedMessage> {
self.messages.iter().find_map(|msg| {
if let Message::User(rendered) = msg
&& &rendered.id == id
{
return Some(rendered);
}
None
})
fn message(&self, id: &EventId) -> Option<&Message> {
self.messages.iter().find(|msg| &msg.id == id)
}
fn scroll_to(&self, id: EventId) {
if let Some(ix) = self.messages.iter().position(|m| {
if let Message::User(msg) = m {
msg.id == id
} else {
false
}
}) {
/// Scroll to a message by its ID
fn scroll_to(&self, id: &EventId) {
if let Some(ix) = self.messages.iter().position(|msg| &msg.id == id) {
self.list_state.scroll_to_reveal_item(ix);
}
}
@@ -620,13 +580,19 @@ impl ChatPanel {
})
.is_err()
{
window.push_notification(
Notification::error("Failed to change subject").autohide(false),
cx,
);
window.push_notification(Notification::error("Failed to change subject"), cx);
}
}
Command::ChangeSigner(kind) => {
let settings = AppSettings::global(cx);
let is_nip4e_enabled = settings.read(cx).is_nip4e_enabled(cx);
let is_force_nip4e = *kind == SignerKind::Encryption || *kind == SignerKind::Auto;
if !is_nip4e_enabled && is_force_nip4e {
window.push_notification("Decoupling Encryption Key is not enabled", cx);
return;
}
if self
.room
.update(cx, |this, cx| {
@@ -634,10 +600,7 @@ impl ChatPanel {
})
.is_err()
{
window.push_notification(
Notification::error("Failed to change signer").autohide(false),
cx,
);
window.push_notification(Notification::error("Failed to change signer"), cx);
}
}
Command::ToggleBackup => {
@@ -648,10 +611,7 @@ impl ChatPanel {
})
.is_err()
{
window.push_notification(
Notification::error("Failed to toggle backup").autohide(false),
cx,
);
window.push_notification(Notification::error("Failed to toggle backup"), cx);
}
}
Command::Copy(public_key) => {
@@ -748,9 +708,11 @@ impl ChatPanel {
cx.open_url(&content);
}
fn render_announcement(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
fn render_announcement(&self, cx: &Context<Self>) -> AnyElement {
const MSG: &str =
"This conversation is private. Only members can see each other's messages.";
v_flex()
.id(ix)
.h_40()
.w_full()
.gap_3()
@@ -767,7 +729,7 @@ impl ChatPanel {
.size_12()
.text_color(cx.theme().ghost_element_active),
)
.child(SharedString::from(ANNOUNCEMENT))
.child(MSG)
.into_any_element()
}
@@ -804,6 +766,34 @@ impl ChatPanel {
.into_any_element()
}
fn is_group_start(&self, ix: usize) -> bool {
// 5 minutes
const GROUP_WINDOW: u64 = 300;
if ix == 0 {
return true;
}
let mut iter = self.messages.iter();
if let Some(previous) = iter.nth(ix - 1)
&& let Some(current) = iter.next()
{
if current.author != previous.author {
return true;
}
let gap = current
.created_at
.as_secs()
.saturating_sub(previous.created_at.as_secs());
return gap > GROUP_WINDOW;
}
false
}
fn render_message(
&mut self,
ix: usize,
@@ -811,24 +801,17 @@ impl ChatPanel {
cx: &mut Context<Self>,
) -> AnyElement {
if let Some(message) = self.messages.iter().nth(ix) {
match message {
Message::User(rendered) => {
let persons = PersonRegistry::global(cx);
let text = self
.rendered_texts_by_id
.entry(rendered.id)
.or_insert_with(|| {
RenderedText::new(&rendered.content, &rendered.mentions, &persons, cx)
})
.element(ix.into(), window, cx);
let persons = PersonRegistry::global(cx);
let show_author = self.is_group_start(ix);
let text = self
.rendered_texts_by_id
.entry(message.id)
.or_insert_with(|| {
RenderedText::new(&message.content, &message.mentions, &persons, cx)
})
.element(ix.into(), window, cx);
self.render_text_message(ix, rendered, text, cx)
}
Message::Warning(content, _timestamp) => {
self.render_warning(ix, SharedString::from(content), cx)
}
Message::System(_timestamp) => self.render_announcement(ix, cx),
}
self.render_text_message(ix, message, text, show_author, cx)
} else {
self.render_warning(ix, SharedString::from("Message not found"), cx)
}
@@ -837,8 +820,9 @@ impl ChatPanel {
fn render_text_message(
&self,
ix: usize,
message: &RenderedMessage,
message: &Message,
rendered_text: AnyElement,
show_author: bool,
cx: &Context<Self>,
) -> AnyElement {
let id = message.id;
@@ -848,7 +832,6 @@ impl ChatPanel {
let replies = message.replies_to.as_slice();
let has_replies = !replies.is_empty();
let has_reports = self.has_reports(&id, cx);
let encrypted_by_dekey = self.encrypted_by_dekey(&id, cx);
// Hide avatar setting
let hide_avatar = AppSettings::get_hide_avatar(cx);
@@ -865,17 +848,21 @@ impl ChatPanel {
.flex()
.gap_3()
.when(!hide_avatar, |this| {
this.child(
Avatar::new(author.avatar())
.flex_shrink_0()
.relative()
.dropdown_menu(move |this, _window, _cx| {
this.menu("Public Key", Box::new(Command::Copy(pk)))
.menu("View Relays", Box::new(Command::Relays(pk)))
.separator()
.menu("View on njump.me", Box::new(Command::Njump(pk)))
}),
)
if show_author {
this.child(
Avatar::new(author.avatar())
.flex_shrink_0()
.relative()
.dropdown_menu(move |this, _window, _cx| {
this.menu("Public Key", Box::new(Command::Copy(pk)))
.menu("View Relays", Box::new(Command::Relays(pk)))
.separator()
.menu("View on njump.me", Box::new(Command::Njump(pk)))
}),
)
} else {
this.child(div().flex_shrink_0().w(px(32.)))
}
})
.child(
v_flex()
@@ -883,33 +870,24 @@ impl ChatPanel {
.w_full()
.flex_initial()
.overflow_hidden()
.child(
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().text_placeholder)
.child(
div()
.font_semibold()
.text_color(cx.theme().text)
.child(author.name()),
)
.when(encrypted_by_dekey, |this| {
this.child(
Button::new(format!("dekey-{ix}"))
.icon(IconName::Shield)
.ghost()
.xsmall()
.px_4()
.tooltip("Encrypted by Dekey")
.disabled(true),
.when(show_author, |this| {
this.child(
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().text_placeholder)
.child(
div()
.font_semibold()
.text_color(cx.theme().text)
.child(author.name()),
)
})
.child(message.created_at.to_human_time())
.when(has_reports, |this| {
this.child(deferred(self.render_sent_reports(&id, cx)))
}),
)
.child(message.created_at.to_human_time())
.when(has_reports, |this| {
this.child(self.render_sent_reports(&id, cx))
}),
)
})
.when(has_replies, |this| {
this.children(self.render_message_replies(replies, cx))
})
@@ -1027,7 +1005,7 @@ impl ChatPanel {
.on_click({
let id = *id;
cx.listener(move |this, _event, _window, _cx| {
this.scroll_to(id);
this.scroll_to(&id);
})
}),
);
@@ -1176,7 +1154,7 @@ impl ChatPanel {
.text_xs()
.font_semibold()
.line_height(relative(1.25))
.child(SharedString::from(url.to_string())),
.child(SharedString::from(url.0.to_string())),
)
.child(
div()
@@ -1518,15 +1496,28 @@ impl Render for ChatPanel {
v_flex()
.flex_1()
.relative()
.child(
list(
self.list_state.clone(),
cx.processor(move |this, ix, window, cx| {
this.render_message(ix, window, cx)
}),
)
.size_full(),
)
.map(|this| {
if self.messages.is_empty() {
this.child(
div()
.size_full()
.flex()
.items_center()
.justify_end()
.child(self.render_announcement(cx)),
)
} else {
this.child(
list(
self.list_state.clone(),
cx.processor(move |this, ix, window, cx| {
this.render_message(ix, window, cx)
}),
)
.size_full(),
)
}
})
.child(Scrollbar::vertical(&self.list_state)),
)
.child(
@@ -1552,7 +1543,7 @@ impl Render for ChatPanel {
this.upload(window, cx);
})),
)
.child(Input::new(&self.input).appearance(false).text_sm().flex_1())
.child(Input::new(&self.input).appearance(false).flex_1())
.child(
h_flex()
.pl_1()

View File

@@ -7,17 +7,14 @@ publish.workspace = true
[dependencies]
gpui.workspace = true
nostr.workspace = true
instant.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
chrono.workspace = true
smallvec.workspace = true
smol.workspace = true
futures.workspace = true
log.workspace = true
dirs = "5.0"
qrcode = "0.14.1"
bech32 = "0.11.1"
regex = "1.10"

View File

@@ -1,5 +1,5 @@
use std::marker::PhantomData;
use std::time::Duration;
use instant::Duration;
use futures::channel::oneshot;
use futures::FutureExt;

View File

@@ -18,7 +18,7 @@ impl EventExt for Event {
}
fn extract_public_keys(&self) -> Vec<PublicKey> {
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
public_keys.push(self.pubkey);
public_keys.into_iter().unique().collect()
@@ -46,7 +46,7 @@ impl EventExt for UnsignedEvent {
}
fn extract_public_keys(&self) -> Vec<PublicKey> {
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
public_keys.push(self.pubkey);
public_keys.into_iter().unique().sorted().collect()
}

View File

@@ -10,15 +10,15 @@ state = { path = "../state" }
person = { path = "../person" }
ui = { path = "../ui" }
theme = { path = "../theme" }
settings = { path = "../settings" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
smol.workspace = true
log.workspace = true
flume.workspace = true
serde.workspace = true
serde_json.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true

View File

@@ -2,7 +2,9 @@ use std::cell::Cell;
use std::collections::HashSet;
use std::path::PathBuf;
use std::rc::Rc;
use std::time::Duration;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use instant::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::{
@@ -11,7 +13,9 @@ use gpui::{
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use state::{Announcement, CLIENT_NAME, NostrRegistry, StateEvent, TIMEOUT};
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::Button;
@@ -19,8 +23,6 @@ use ui::notification::{Notification, NotificationKind};
use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
const IDENTIFIER: &str = "coop:device";
const MSG: &str = "You've requested an encryption key from another device. \
Approve to allow Coop to share with it.";
pub fn init(window: &mut Window, cx: &mut App) {
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
@@ -35,10 +37,10 @@ impl Global for GlobalDeviceRegistry {}
pub enum DeviceEvent {
/// A new encryption signer has been set
Set,
/// User have not setup encryption key
NotSet,
/// The device is requesting an encryption key
Requesting,
/// The device is creating a new encryption key
Creating,
/// An error occurred
Error(SharedString),
}
@@ -57,17 +59,20 @@ impl DeviceEvent {
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
#[derive(Debug)]
pub struct DeviceRegistry {
/// Whether the registry is currently initializing
pub initializing: bool,
/// Whether there is a pending request for encryption key approval
pub pending_request: bool,
/// Whether an announcement has been made for this device
pub announcement_existed: Arc<AtomicBool>,
/// Signer
signer: Entity<Option<UniversalSigner>>,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscription
_subscription: Option<Subscription>,
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl EventEmitter<DeviceEvent> for DeviceRegistry {}
@@ -86,35 +91,54 @@ impl DeviceRegistry {
/// Create a new device registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let settings = AppSettings::global(cx);
// Subscribe to nostr state events
let subscription = cx.subscribe_in(&nostr, window, |this, _e, event, _window, cx| {
if event == &StateEvent::SignerSet {
this.set_initializing(true, cx);
this.get_announcement(cx);
};
});
let signer = cx.new(|_| None);
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to nostr state events
cx.observe(&settings, move |this, settings, cx| {
if settings.read(cx).is_nip4e_enabled(cx) {
this.get_announcement(cx);
};
}),
);
subscriptions.push(
// Observe the user signer
cx.subscribe(&nostr, move |this, _nostr, event, cx| {
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
this.get_announcement(cx);
}
}),
);
cx.defer_in(window, |this, window, cx| {
this.handle_notifications(window, cx);
});
Self {
initializing: true,
signer,
pending_request: false,
announcement_existed: Arc::new(AtomicBool::new(false)),
tasks: vec![],
_subscription: Some(subscription),
_subscriptions: subscriptions,
}
}
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let announcement_existed = self.announcement_existed.clone();
let (tx, rx) = flume::bounded::<Event>(100);
self.tasks.push(cx.background_spawn(async move {
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
let current_user = signer.get_public_key_async().await.ok();
while let Some(notification) = notifications.next().await {
if let ClientNotification::Message { message, .. } = notification
@@ -126,15 +150,15 @@ impl DeviceRegistry {
}
match event.kind {
Kind::Custom(4454) => {
if verify_author(&client, event.as_ref()).await {
tx.send_async(event.into_owned()).await?;
}
Kind::Custom(10044) if current_user == Some(event.pubkey) => {
announcement_existed.store(true, Ordering::Relaxed);
tx.send_async(event.into_owned()).await?;
}
Kind::Custom(4455) => {
if verify_author(&client, event.as_ref()).await {
tx.send_async(event.into_owned()).await?;
}
Kind::Custom(4454) if current_user == Some(event.pubkey) => {
tx.send_async(event.into_owned()).await?;
}
Kind::Custom(4455) if current_user == Some(event.pubkey) => {
tx.send_async(event.into_owned()).await?;
}
_ => {}
}
@@ -147,6 +171,11 @@ impl DeviceRegistry {
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
while let Ok(event) = rx.recv_async().await {
match event.kind {
Kind::Custom(10044) => {
this.update_in(cx, |this, _window, cx| {
this.set_encryption(&event, cx);
})?;
}
// New request event from other device
Kind::Custom(4454) => {
this.update_in(cx, |this, window, cx| {
@@ -166,48 +195,41 @@ impl DeviceRegistry {
}));
}
/// Set whether the registry is currently initializing
fn set_initializing(&mut self, initializing: bool, cx: &mut Context<Self>) {
self.initializing = initializing;
cx.notify();
}
/// Set whether there is a pending request for encryption key approval
fn set_pending_request(&mut self, pending: bool, cx: &mut Context<Self>) {
self.pending_request = pending;
cx.notify();
}
/// Get the signer
pub fn signer(&self, cx: &App) -> Option<UniversalSigner> {
self.signer.read(cx).clone()
}
/// Set the decoupled encryption key for the current user
fn set_signer<S>(&mut self, new: S, cx: &mut Context<Self>)
where
S: NostrSigner + 'static,
{
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
self.tasks.push(cx.spawn(async move |this, cx| {
signer.set_encryption_signer(new).await;
// Update state
this.update(cx, |this, cx| {
this.set_initializing(false, cx);
cx.emit(DeviceEvent::Set);
})?;
Ok(())
}));
fn set_signer(&mut self, new_signer: Keys, cx: &mut Context<Self>) {
self.signer.update(cx, |this, cx| {
*this = Some(UniversalSigner::new(new_signer));
cx.notify();
});
cx.emit(DeviceEvent::Set);
}
/// Backup the encryption's secret key to a file
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
cx.background_spawn(async move {
let keys = get_keys(&client).await?;
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(())
@@ -218,48 +240,42 @@ impl DeviceRegistry {
pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let task: Task<Result<Event, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
self.tasks.push(cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let current_user = signer.get_public_key_async().await?;
// Construct the filter for the device announcement event
let filter = Filter::new()
.kind(Kind::Custom(10044))
.author(public_key)
.author(current_user)
.limit(1);
// Stream events from user's write relays
let mut stream = client
.stream_events(filter)
.timeout(Duration::from_secs(TIMEOUT))
client
.subscribe(filter)
.close_on(opts)
.with_id(SubscriptionId::new("nip4e"))
.await?;
while let Some((_url, res)) = stream.next().await {
if let Ok(event) = res {
return Ok(event);
}
}
Ok(())
}));
Err(anyhow!("Announcement not found"))
});
let announcement_existed = self.announcement_existed.clone();
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(event) => {
// Set encryption key from the announcement event
this.update(cx, |this, cx| {
this.set_encryption(&event, cx);
})?;
}
Err(_) => {
// User has no announcement, create a new one
this.update(cx, |this, cx| {
this.set_announcement(Keys::generate(), cx);
})?;
}
// 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 Ok(());
}
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::NotSet);
})?;
Ok(())
}));
}
@@ -268,9 +284,6 @@ impl DeviceRegistry {
pub fn set_announcement(&mut self, keys: Keys, cx: &mut Context<Self>) {
let task = self.create_encryption(keys, cx);
// Notify that we're creating a new encryption key
cx.emit(DeviceEvent::Creating);
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(keys) => {
@@ -293,19 +306,20 @@ impl DeviceRegistry {
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let secret = keys.secret_key().to_secret_hex();
let n = keys.public_key();
cx.background_spawn(async move {
// Construct an announcement event
let builder = EventBuilder::new(Kind::Custom(10044), "").tags(vec![
Tag::custom(TagKind::custom("n"), vec![n]),
Tag::client(CLIENT_NAME),
]);
// Sign the event with user's signer
let event = client.sign_event_builder(builder).await?;
let event = EventBuilder::new(Kind::Custom(10044), "")
.tags(vec![
Tag::custom("n", vec![n]),
Tag::custom("client", vec![CLIENT_NAME]),
])
.finalize_async(&signer)
.await?;
// Publish announcement
client
@@ -315,7 +329,7 @@ impl DeviceRegistry {
.await?;
// Save device keys to the database
set_keys(&client, &secret).await?;
set_keys(&client, &signer, &secret).await?;
Ok(keys)
})
@@ -325,13 +339,14 @@ impl DeviceRegistry {
fn set_encryption(&mut self, event: &Event, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let announcement = Announcement::from(event);
let device_pubkey = announcement.public_key();
// Get encryption key from the database and compare with the announcement
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
let keys = get_keys(&client).await?;
let keys = get_keys(&client, &signer).await?;
// Compare the public key from the announcement with the one from the database
if keys.public_key() != device_pubkey {
@@ -363,7 +378,7 @@ impl DeviceRegistry {
let signer = nostr.read(cx).signer();
self.tasks.push(cx.background_spawn(async move {
let public_key = signer.get_public_key().await?;
let public_key = signer.get_public_key_async().await?;
let id = SubscriptionId::new("dekey-requests");
// Construct a filter for encryption key requests
@@ -385,11 +400,12 @@ impl DeviceRegistry {
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let app_keys = nostr.read(cx).keys();
let app_pubkey = app_keys.public_key();
let app_keys_task = get_or_init_app_keys(cx);
let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
let public_key = signer.get_public_key().await?;
let app_keys = app_keys_task.await?;
let app_pubkey = app_keys.public_key();
let public_key = signer.get_public_key_async().await?;
// Construct a filter to get the latest approval event
let filter = Filter::new()
@@ -404,13 +420,13 @@ impl DeviceRegistry {
// No approval event found, construct a request event
None => {
// Construct an event for device key request
let builder = EventBuilder::new(Kind::Custom(4454), "").tags(vec![
Tag::custom(TagKind::custom("P"), vec![app_pubkey]),
Tag::client(CLIENT_NAME),
]);
// Sign the event with user's signer
let event = client.sign_event_builder(builder).await?;
let event = EventBuilder::new(Kind::Custom(4454), "")
.tags(vec![
Tag::custom("P", vec![app_pubkey]),
Tag::custom("client", vec![CLIENT_NAME]),
])
.finalize_async(&signer)
.await?;
// Send the event to write relays
client.send_event(&event).to_nip65().await?;
@@ -429,10 +445,7 @@ impl DeviceRegistry {
}
Ok(None) => {
this.update(cx, |this, cx| {
this.set_initializing(false, cx);
this.wait_for_approval(cx);
cx.emit(DeviceEvent::Requesting);
})?;
}
Err(e) => {
@@ -451,8 +464,10 @@ impl DeviceRegistry {
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
cx.emit(DeviceEvent::Requesting);
self.tasks.push(cx.background_spawn(async move {
let public_key = signer.get_public_key().await?;
let public_key = signer.get_public_key_async().await?;
// Construct a filter for device key requests
let filter = Filter::new()
@@ -469,19 +484,20 @@ 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 nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).keys();
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
.find(TagKind::custom("P"))
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Invalid event's tags")?;
let payload = event.content.as_str();
let decrypted = app_keys.nip44_decrypt(&master, payload).await?;
let decrypted = app_keys.nip44_decrypt_async(&master, payload).await?;
let secret = SecretKey::from_hex(&decrypted)?;
let keys = Keys::new(secret);
@@ -510,6 +526,7 @@ impl DeviceRegistry {
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
// Get user's write relays
let event = event.clone();
@@ -517,31 +534,32 @@ impl DeviceRegistry {
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Get device keys
let keys = get_keys(&client).await?;
let keys = get_keys(&client, &signer).await?;
let secret = keys.secret_key().to_secret_hex();
// Extract the target public key from the event tags
let target = event
.tags
.find(TagKind::custom("P"))
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Target is not a valid public key")?;
// Encrypt the device keys with the user's signer
let payload = keys.nip44_encrypt(&target, &secret).await?;
let payload = keys.nip44_encrypt_async(&target, &secret).await?;
// Construct the response event
//
// P tag: the current device's public key
// p tag: the requester's public key
let builder = EventBuilder::new(Kind::Custom(4455), payload).tags(vec![
Tag::custom(TagKind::custom("P"), vec![keys.public_key().to_hex()]),
Tag::public_key(target),
]);
// Sign the builder
let event = client.sign_event_builder(builder).await?;
let event = EventBuilder::new(Kind::Custom(4455), payload)
.tags(vec![
Tag::custom("P", vec![keys.public_key().to_hex()]),
Tag::public_key(target),
])
.finalize_async(&signer)
.await?;
// Send the response event to the user's relay list
client.send_event(&event).to_nip65().await?;
@@ -549,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| {
@@ -567,8 +585,9 @@ impl DeviceRegistry {
.ok();
}
};
})
.detach();
Ok(())
}));
}
/// Handle encryption request
@@ -586,6 +605,9 @@ impl DeviceRegistry {
/// Build a notification for the encryption request.
fn notification(&self, event: Event, cx: &Context<Self>) -> Notification {
const MSG: &str = "You've requested an encryption key from another device. \
Approve to allow Coop to share with it.";
let request = Announcement::from(&event);
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&request.public_key(), cx);
@@ -688,29 +710,45 @@ impl DeviceRegistry {
struct DeviceNotification;
/// Verify the author of an event
async fn verify_author(client: &Client, event: &Event) -> bool {
if let Some(signer) = client.signer()
&& let Ok(public_key) = signer.get_public_key().await
{
return public_key == event.pubkey;
}
false
/// 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);
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();
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.
async fn set_keys(client: &Client, secret: &str) -> Result<(), Error> {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
// Encrypt the value
let content = signer.nip44_encrypt(&public_key, secret).await?;
async fn set_keys(client: &Client, signer: &UniversalSigner, secret: &str) -> Result<(), Error> {
let public_key = signer.get_public_key_async().await?;
let content = signer.nip44_encrypt_async(&public_key, secret).await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(IDENTIFIER))
.build(public_key)
.sign(&Keys::generate())
.finalize_async(signer)
.await?;
// Save the event to the database
@@ -720,9 +758,8 @@ async fn set_keys(client: &Client, secret: &str) -> Result<(), Error> {
}
/// Get device keys from the local database.
async fn get_keys(client: &Client) -> Result<Keys, Error> {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
async fn get_keys(client: &Client, signer: &UniversalSigner) -> Result<Keys, Error> {
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
@@ -730,7 +767,10 @@ async fn get_keys(client: &Client) -> Result<Keys, Error> {
.author(public_key);
if let Some(event) = client.database().query(filter).await?.first() {
let content = signer.nip44_decrypt(&public_key, &event.content).await?;
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
let secret = SecretKey::parse(&content)?;
let keys = Keys::new(secret);

View File

@@ -10,9 +10,9 @@ state = { path = "../state" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
anyhow.workspace = true
smallvec.workspace = true
smol.workspace = true
flume.workspace = true
log.workspace = true
urlencoding = "2.1.3"

View File

@@ -1,7 +1,6 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::time::Duration;
use std::sync::RwLock;
use instant::Duration;
use anyhow::{Error, anyhow};
use common::EventExt;
@@ -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();
}
@@ -242,7 +231,7 @@ impl PersonRegistry {
/// Set messaging relays for a person
fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) {
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).cloned().collect();
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).collect();
if let Some(person) = self.persons.get(&event.pubkey) {
person.update(cx, |person, cx| {
@@ -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();

View File

@@ -126,13 +126,13 @@ impl Person {
if let Some(display_name) = self.metadata().display_name.as_ref()
&& !display_name.is_empty()
{
return SharedString::from(display_name);
return SharedString::from(display_name.trim());
}
if let Some(name) = self.metadata().name.as_ref()
&& !name.is_empty()
{
return SharedString::from(name);
return SharedString::from(name.trim());
}
SharedString::from(shorten_pubkey(self.public_key(), 4))

View File

@@ -1,377 +0,0 @@
use std::borrow::Cow;
use std::cell::Cell;
use std::collections::HashSet;
use std::hash::Hash;
use std::rc::Rc;
use std::sync::Arc;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::{
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
Task, Window, div, relative,
};
use nostr_sdk::prelude::*;
use settings::{AppSettings, AuthMode};
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::button::Button;
use ui::notification::{Notification, NotificationKind};
use ui::{Disableable, WindowExtension, v_flex};
const AUTH_MESSAGE: &str =
"Approve the authentication request to allow Coop to continue sending or receiving events.";
pub fn init(window: &mut Window, cx: &mut App) {
RelayAuth::set_global(cx.new(|cx| RelayAuth::new(window, cx)), cx);
}
/// Authentication request
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
struct AuthRequest {
url: RelayUrl,
challenge: String,
}
impl AuthRequest {
pub fn new<S>(challenge: S, url: RelayUrl) -> Self
where
S: Into<String>,
{
Self {
challenge: challenge.into(),
url,
}
}
pub fn url(&self) -> &RelayUrl {
&self.url
}
pub fn challenge(&self) -> &str {
&self.challenge
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Signal {
Auth(Arc<AuthRequest>),
Pending((EventId, RelayUrl)),
}
struct GlobalRelayAuth(Entity<RelayAuth>);
impl Global for GlobalRelayAuth {}
// Relay authentication
#[derive(Debug)]
pub struct RelayAuth {
/// Pending events waiting for resend after authentication
pending_events: HashSet<(EventId, RelayUrl)>,
/// Tasks for asynchronous operations
_tasks: SmallVec<[Task<()>; 2]>,
}
impl RelayAuth {
/// Retrieve the global relay auth state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalRelayAuth>().0.clone()
}
/// Set the global relay auth instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalRelayAuth(state));
}
/// Create a new relay auth instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let mut tasks = smallvec![];
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Signal>(256);
tasks.push(cx.background_spawn(async move {
let mut notifications = client.notifications();
let mut challenges: HashSet<Cow<'_, str>> = HashSet::default();
while let Some(notification) = notifications.next().await {
if let ClientNotification::Message { relay_url, message } = notification {
match *message {
RelayMessage::Auth { challenge } => {
if challenges.insert(challenge.clone()) {
let request = Arc::new(AuthRequest::new(challenge, relay_url));
let signal = Signal::Auth(request);
tx.send_async(signal).await.ok();
}
}
RelayMessage::Ok {
event_id, message, ..
} => {
let msg = MachineReadablePrefix::parse(&message);
// Handle authentication messages
if let Some(MachineReadablePrefix::AuthRequired) = msg {
let signal = Signal::Pending((event_id, relay_url));
tx.send_async(signal).await.ok();
}
}
_ => {}
}
}
}
}));
tasks.push(cx.spawn_in(window, async move |this, cx| {
while let Ok(signal) = rx.recv_async().await {
match signal {
Signal::Auth(req) => {
this.update_in(cx, |this, window, cx| {
this.handle_auth(&req, window, cx);
})
.ok();
}
Signal::Pending((event_id, relay_url)) => {
this.update_in(cx, |this, _window, cx| {
this.insert_pending_event(event_id, relay_url, cx);
})
.ok();
}
}
}
}));
Self {
pending_events: HashSet::default(),
_tasks: tasks,
}
}
/// Insert a pending event waiting for resend after authentication
fn insert_pending_event(&mut self, id: EventId, relay: RelayUrl, cx: &mut Context<Self>) {
self.pending_events.insert((id, relay));
cx.notify();
}
/// Get all pending events for a specific relay,
fn get_pending_events(&self, relay: &RelayUrl, _cx: &App) -> Vec<EventId> {
self.pending_events
.iter()
.filter(|(_, pending_relay)| pending_relay == relay)
.map(|(id, _relay)| id)
.cloned()
.collect()
}
/// Clear all pending events for a specific relay,
fn clear_pending_events(&mut self, relay: &RelayUrl, cx: &mut Context<Self>) {
self.pending_events
.retain(|(_, pending_relay)| pending_relay != relay);
cx.notify();
}
/// Handle authentication request
fn handle_auth(&mut self, req: &Arc<AuthRequest>, window: &mut Window, cx: &mut Context<Self>) {
let settings = AppSettings::global(cx);
let trusted_relay = settings.read(cx).trusted_relay(req.url(), cx);
let mode = AppSettings::get_auth_mode(cx);
if trusted_relay && mode == AuthMode::Auto {
// Automatically authenticate if the relay is authenticated before
self.response(req, window, cx);
} else {
// Otherwise open the auth request popup
self.ask_for_approval(req, window, cx);
}
}
/// Send auth response and wait for confirmation
fn auth(&self, req: &Arc<AuthRequest>, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let req = req.clone();
// Get all pending events for the relay
let pending_events = self.get_pending_events(req.url(), cx);
cx.background_spawn(async move {
// Construct event
let builder = EventBuilder::auth(req.challenge(), req.url().clone());
let event = client.sign_event_builder(builder).await?;
// Get the event ID
let id = event.id;
// Get the relay
let relay = client.relay(req.url()).await?.context("Relay not found")?;
// Subscribe to notifications
let mut notifications = relay.notifications();
// Send the AUTH message
relay
.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))
.await?;
log::info!("Sending AUTH event");
while let Some(notification) = notifications.next().await {
match notification {
RelayNotification::Message { message } => {
if let RelayMessage::Ok { event_id, .. } = *message {
if id != event_id {
continue;
}
// Get all subscriptions
let subscriptions = relay.subscriptions().await;
// Re-subscribe to previous subscriptions
for (id, filters) in subscriptions.into_iter() {
if !filters.is_empty() {
relay.send_msg(ClientMessage::req(id, filters)).await?;
}
}
// Re-send pending events
for id in pending_events {
if let Some(event) = client.database().event_by_id(&id).await? {
relay.send_event(&event).await?;
}
}
return Ok(());
}
}
RelayNotification::AuthenticationFailed => break,
_ => {}
}
}
Err(anyhow!("Authentication failed"))
})
}
/// Respond to an authentication request.
fn response(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
let settings = AppSettings::global(cx);
let req = req.clone();
let challenge = SharedString::from(req.challenge().to_string());
// Create a task for authentication
let task = self.auth(&req, cx);
cx.spawn_in(window, async move |this, cx| {
let result = task.await;
let url = req.url();
this.update_in(cx, |this, window, cx| {
window.clear_notification_by_id::<AuthNotification>(challenge, cx);
match result {
Ok(_) => {
// Clear pending events for the authenticated relay
this.clear_pending_events(url, cx);
// Save the authenticated relay to automatically authenticate future requests
settings.update(cx, |this, cx| {
this.add_trusted_relay(url, cx);
});
window.push_notification(
Notification::success(format!(
"Relay {} has been authenticated",
url.domain().unwrap_or_default()
)),
cx,
);
}
Err(e) => {
window.push_notification(
Notification::error(e.to_string()).autohide(false),
cx,
);
}
}
})
.ok();
})
.detach();
}
/// Push a popup to approve the authentication request.
fn ask_for_approval(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
let notification = self.notification(req, cx);
cx.spawn_in(window, async move |_this, cx| {
cx.update(|window, cx| {
window.push_notification(notification, cx);
})
.ok();
})
.detach();
}
/// Build a notification for the authentication request.
fn notification(&self, req: &Arc<AuthRequest>, cx: &Context<Self>) -> Notification {
let req = req.clone();
let challenge = SharedString::from(req.challenge.clone());
let url = SharedString::from(req.url().to_string());
let entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false));
Notification::new()
.type_id::<AuthNotification>(challenge)
.autohide(false)
.with_kind(NotificationKind::Info)
.title("Authentication Required")
.content(move |_this, _window, cx| {
v_flex()
.gap_2()
.child(
div()
.text_sm()
.line_height(relative(1.25))
.child(SharedString::from(AUTH_MESSAGE)),
)
.child(
v_flex()
.py_1()
.px_1p5()
.rounded_sm()
.text_xs()
.bg(cx.theme().elevated_surface_background)
.text_color(cx.theme().text)
.child(url.clone()),
)
.into_any_element()
})
.action(move |_this, _window, _cx| {
let view = entity.clone();
let req = req.clone();
Button::new("approve")
.label("Approve")
.loading(loading.get())
.disabled(loading.get())
.on_click({
let loading = Rc::clone(&loading);
move |_ev, window, cx| {
// Set loading state to true
loading.set(true);
// Process to approve the request
view.update(cx, |this, cx| {
this.response(&req, window, cx);
})
.ok();
}
})
})
}
}
struct AuthNotification;

View File

@@ -10,7 +10,6 @@ common = { path = "../common" }
nostr-sdk.workspace = true
gpui.workspace = true
smol.workspace = true
anyhow.workspace = true
log.workspace = true
smallvec.workspace = true
@@ -18,3 +17,6 @@ serde.workspace = true
serde_json.workspace = true
paste = "1.0.15"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true

View File

@@ -1,4 +1,3 @@
use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::rc::Rc;
@@ -20,13 +19,15 @@ macro_rules! setting_accessors {
$(
paste::paste! {
pub fn [<get_ $field>](cx: &App) -> $type {
Self::global(cx).read(cx).values.$field.clone()
Self::global(cx).read(cx).inner.read(cx).$field.clone()
}
pub fn [<update_ $field>](value: $type, cx: &mut App) {
Self::global(cx).update(cx, |this, cx| {
this.values.$field = value;
cx.notify();
this.inner.update(cx, |inner, cx| {
inner.$field = value;
cx.notify();
});
});
}
}
@@ -40,9 +41,9 @@ setting_accessors! {
pub theme_mode: ThemeMode,
pub hide_avatar: bool,
pub screening: bool,
pub nip4e: bool,
pub auth_mode: AuthMode,
pub trusted_relays: HashSet<RelayUrl>,
pub room_configs: HashMap<u64, RoomConfig>,
pub trusted_relays: Vec<String>,
pub file_server: Url,
}
@@ -66,10 +67,10 @@ impl Display for AuthMode {
/// Signer kind
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum SignerKind {
#[default]
Auto,
User,
Encryption,
#[default]
User,
}
impl SignerKind {
@@ -97,7 +98,7 @@ impl RoomConfig {
pub fn new() -> Self {
Self {
backup: true,
signer_kind: SignerKind::Auto,
signer_kind: SignerKind::default(),
}
}
@@ -137,14 +138,14 @@ pub struct Settings {
/// Enable screening for unknown chat requests
pub screening: bool,
/// 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: HashSet<RelayUrl>,
/// Configuration for each chat room
pub room_configs: HashMap<u64, RoomConfig>,
pub trusted_relays: Vec<String>,
/// Server for blossom media attachments
pub file_server: Url,
@@ -157,9 +158,9 @@ impl Default for Settings {
theme_mode: ThemeMode::default(),
hide_avatar: false,
screening: true,
nip4e: false,
auth_mode: AuthMode::default(),
trusted_relays: HashSet::default(),
room_configs: HashMap::default(),
trusted_relays: vec![],
file_server: Url::parse("https://blossom.band/").unwrap(),
}
}
@@ -178,7 +179,7 @@ impl Global for GlobalAppSettings {}
/// Application settings
pub struct AppSettings {
/// Settings
values: Settings,
inner: Entity<Settings>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
@@ -196,11 +197,12 @@ impl AppSettings {
}
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let inner = cx.new(|_| Settings::default());
let mut subscriptions = smallvec![];
subscriptions.push(
// Observe and automatically save settings on changes
cx.observe_self(|this, cx| {
cx.observe(&inner, |this, _inner, cx| {
this.save(cx);
}),
);
@@ -211,27 +213,30 @@ impl AppSettings {
});
Self {
values: Settings::default(),
inner,
_subscriptions: subscriptions,
}
}
/// Update settings
fn set_settings(&mut self, settings: Settings, cx: &mut Context<Self>) {
self.values = settings;
cx.notify();
self.inner.update(cx, |this, cx| {
*this = settings;
cx.notify();
});
}
/// Load settings
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
let path = config_dir().join(".settings");
if let Ok(content) = smol::fs::read_to_string(&path).await {
Ok(serde_json::from_str(&content)?)
} else {
Err(anyhow!("Not found"))
#[cfg(not(target_arch = "wasm32"))]
{
let path = config_dir().join(".settings");
if let Ok(content) = smol::fs::read_to_string(&path).await {
return Ok(serde_json::from_str(&content)?);
}
}
Err(anyhow!("Not found"))
});
cx.spawn_in(window, async move |this, cx| {
@@ -249,19 +254,15 @@ impl AppSettings {
/// Save settings
pub fn save(&mut self, cx: &mut Context<Self>) {
let settings = self.values.clone();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let path = config_dir().join(".settings");
let content = serde_json::to_string(&settings)?;
// Write settings to file
smol::fs::write(&path, content).await?;
Ok(())
});
task.detach();
let settings = self.inner.read(cx);
if let Ok(content) = serde_json::to_string(&settings) {
#[cfg(not(target_arch = "wasm32"))]
cx.background_spawn(async move {
let path = config_dir().join(".settings");
smol::fs::write(&path, content).await.ok();
})
.detach();
}
}
/// Set theme
@@ -270,8 +271,10 @@ impl AppSettings {
T: Into<String>,
{
// Update settings
self.values.theme = Some(theme.into());
cx.notify();
self.inner.update(cx, |this, cx| {
this.theme = Some(theme.into());
cx.notify();
});
// Apply the new theme
self.apply_theme(window, cx);
@@ -279,16 +282,17 @@ impl AppSettings {
/// Reset theme
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.values.theme = None;
cx.notify();
self.inner.update(cx, |this, cx| {
this.theme = None;
cx.notify();
});
self.apply_theme(window, cx);
}
/// Apply theme
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(name) = self.values.theme.as_ref() {
let mode = self.values.theme_mode;
if let Some(name) = self.inner.read(cx).theme.as_ref() {
let mode = self.inner.read(cx).theme_mode;
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
@@ -301,26 +305,32 @@ impl AppSettings {
}
}
/// Check if decoupling encryption key is enabled
pub fn is_nip4e_enabled(&self, cx: &App) -> bool {
self.inner.read(cx).nip4e
}
/// Check if the given relay is already authenticated
pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
self.values.trusted_relays.iter().any(|relay| {
relay.as_str_without_trailing_slash() == url.as_str_without_trailing_slash()
})
pub fn trusted_relay(&self, url: &RelayUrl, cx: &App) -> bool {
self.inner
.read(cx)
.trusted_relays
.iter()
.any(|relay| relay == url.as_str_without_trailing_slash())
}
/// Add a relay to the trusted list
pub fn add_trusted_relay(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
self.values.trusted_relays.insert(url.clone());
cx.notify();
}
/// Add a room configuration
pub fn add_room_config(&mut self, id: u64, config: RoomConfig, cx: &mut Context<Self>) {
self.values
.room_configs
.entry(id)
.and_modify(|this| *this = config)
.or_default();
cx.notify();
self.inner.update(cx, |this, cx| {
if !this
.trusted_relays
.iter()
.any(|relay| relay == url.as_str_without_trailing_slash())
{
this.trusted_relays
.push(url.as_str_without_trailing_slash().to_string());
cx.notify();
}
});
}
}

View File

@@ -9,22 +9,26 @@ common = { path = "../common" }
nostr.workspace = true
nostr-sdk.workspace = true
nostr-lmdb.workspace = true
nostr-gossip-memory.workspace = true
nostr-connect.workspace = true
nostr-blossom.workspace = true
nostr-connect.workspace = true
gpui.workspace = true
gpui_tokio.workspace = true
smol.workspace = true
instant.workspace = true
flume.workspace = true
futures.workspace = true
log.workspace = true
anyhow.workspace = true
webbrowser.workspace = true
serde.workspace = true
serde_json.workspace = true
rustls = "0.23"
petname = "2.0.2"
whoami = "1.6.1"
mime_guess = "2.0.4"
[target.'cfg(target_arch = "wasm32")'.dependencies]
nostr-memory.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
nostr-lmdb.workspace = true
smol.workspace = true
gpui_tokio.workspace = true
rustls = "0.23"

View File

@@ -1,12 +1,14 @@
use std::path::PathBuf;
use anyhow::{anyhow, Error};
use anyhow::{Error, anyhow};
use gpui::AsyncApp;
#[cfg(not(target_arch = "wasm32"))]
use gpui_tokio::Tokio;
use mime_guess::from_path;
use nostr_blossom::prelude::*;
use nostr_sdk::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
let content_type = from_path(&path).first_or_octet_stream().to_string();
let data = smol::fs::read(path).await?;
@@ -25,3 +27,8 @@ pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Er
.await
.map_err(|e| anyhow!("Upload error: {e}"))?
}
#[cfg(target_arch = "wasm32")]
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
Err(anyhow!("File upload not supported on web"))
}

View File

@@ -8,7 +8,8 @@ pub const COOP_PUBKEY: &str = "npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5w
pub const APP_ID: &str = "su.reya.coop";
/// Keyring name
pub const KEYRING: &str = "Coop Safe Storage";
pub const MASTER_KEYRING: &str = "Coop Master Key";
pub const USER_KEYRING: &str = "Coop User Credential";
/// Default timeout for subscription
pub const TIMEOUT: u64 = 2;
@@ -45,7 +46,7 @@ pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.
/// Default bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
"wss://relay.damus.io",
"wss://relay.ditto.pub",
"wss://relay.primal.net",
"wss://user.kindpag.es",
];

View File

@@ -1,37 +1,40 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use instant::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use anyhow::{Error, anyhow};
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Task, Window};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
use nostr_connect::prelude::*;
use nostr_gossip_memory::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use nostr_lmdb::prelude::*;
#[cfg(target_arch = "wasm32")]
use nostr_memory::prelude::*;
use nostr_sdk::prelude::*;
mod blossom;
mod constants;
mod device;
mod nip05;
mod nip4e;
mod signer;
pub use blossom::*;
pub use constants::*;
pub use device::*;
pub use nip4e::*;
pub use nip05::*;
pub use signer::*;
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
pub fn init(window: &mut Window, cx: &mut App) {
// 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`.
#[cfg(not(target_arch = "wasm32"))]
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.ok();
// Initialize the tokio runtime
#[cfg(not(target_arch = "wasm32"))]
gpui_tokio::init(cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx);
@@ -44,27 +47,27 @@ impl Global for GlobalNostrRegistry {}
/// Signer event.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum StateEvent {
/// Connecting to the bootstrapping relay
Connecting,
/// Connected to the bootstrapping relay
Connected,
/// Creating the signer
Creating,
/// Show the identity dialog
Show,
/// A new signer has been set
SignerSet,
/// The state is busy
Busy,
/// User has no signer
NoSigner,
/// The signer has changed
SignerChanged,
/// An error occurred
Error(SharedString),
Error(String),
}
impl StateEvent {
pub fn error<T>(error: T) -> Self
where
T: Into<SharedString>,
T: Into<String>,
{
Self::Error(error.into())
}
pub fn signer_changed(&self) -> bool {
matches!(self, StateEvent::SignerChanged)
}
}
/// Nostr Registry
@@ -73,19 +76,11 @@ pub struct NostrRegistry {
/// Nostr client
client: Client,
/// Nostr signer
signer: Arc<CoopSigner>,
/// Universal signer
signer: UniversalSigner,
/// All local stored identities
npubs: Entity<Vec<PublicKey>>,
/// Keys directory
key_dir: PathBuf,
/// Master app keys used for various operations.
///
/// Example: Nostr Connect and NIP-4e operations
app_keys: Keys,
/// Current user's public key
current_user: Option<PublicKey>,
/// Tasks for asynchronous operations
tasks: Vec<Task<Result<(), Error>>>,
@@ -106,64 +101,43 @@ impl NostrRegistry {
/// Create a new nostr instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let key_dir = config_dir().join("keys");
let app_keys = get_or_init_app_keys(cx).unwrap_or(Keys::generate());
// Construct the nostr signer
let signer = Arc::new(CoopSigner::new(app_keys.clone()));
// Get all local stored npubs
let npubs = cx.new(|_| match Self::discover(&key_dir) {
Ok(npubs) => npubs,
Err(e) => {
log::error!("Failed to discover npubs: {e}");
vec![]
}
});
let signer = UniversalSigner::new(Keys::generate());
let authenticator = SignerAuthenticator::new(signer.clone());
// Construct the nostr lmdb instance
let lmdb = cx.foreground_executor().block_on(async move {
#[cfg(not(target_arch = "wasm32"))]
let database = cx.foreground_executor().block_on(async move {
NostrLmdb::open(config_dir().join("nostr"))
.await
.expect("Failed to initialize database")
});
#[cfg(target_arch = "wasm32")]
let database = MemoryDatabase::unbounded();
// Construct the nostr client
let client = ClientBuilder::default()
.signer(signer.clone())
.database(lmdb)
.database(database)
.authenticator(authenticator)
.gossip(NostrGossipMemory::unbounded())
.gossip_config(
GossipConfig::default()
.sync_initial_timeout(Duration::from_millis(100))
.sync_idle_timeout(Duration::from_millis(100))
.no_background_refresh(),
)
.automatic_authentication(false)
.gossip_config(GossipConfig::default().no_background_refresh())
.connect_timeout(Duration::from_secs(10))
.sleep_when_idle(SleepWhenIdle::Enabled {
timeout: Duration::from_secs(600),
})
.build();
// Run at the end of current cycle
// Connect to bootstrap relays after the window is ready
cx.defer_in(window, |this, _window, cx| {
this.connect(cx);
// Create an identity if none exists
if this.npubs.read(cx).is_empty() {
this.create_identity(cx);
} else {
// Show the identity dialog
cx.emit(StateEvent::Show);
}
this.connect_bootstrap_relays(cx);
#[cfg(not(target_arch = "wasm32"))]
this.get_user_credential(cx);
});
Self {
client,
signer,
npubs,
key_dir,
app_keys,
current_user: None,
tasks: vec![],
}
}
@@ -173,50 +147,48 @@ impl NostrRegistry {
self.client.clone()
}
/// Get the nostr signer
pub fn signer(&self) -> Arc<CoopSigner> {
/// Get the current signer
pub fn signer(&self) -> UniversalSigner {
self.signer.clone()
}
/// Get the npubs entity
pub fn npubs(&self) -> Entity<Vec<PublicKey>> {
self.npubs.clone()
/// Get the current user's public key
pub fn current_user(&self) -> Option<PublicKey> {
self.current_user
}
/// Get the app keys
pub fn keys(&self) -> Keys {
self.app_keys.clone()
}
/// Update the signer
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
where
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
<T as AsyncSignEvent>::Error: std::error::Error + Send + Sync + 'static,
<T as AsyncNip44>::Error: std::error::Error + Send + Sync + 'static,
{
let task = cx.spawn(async move |this, cx| {
match new_signer.get_public_key_async().await {
Ok(public_key) => {
this.update(cx, |this, cx| {
this.signer.swap_inner(new_signer);
this.current_user = Some(public_key);
cx.emit(StateEvent::SignerChanged);
cx.notify();
})?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(StateEvent::error(e.to_string()));
})?;
}
};
/// Discover all npubs in the keys directory
fn discover(dir: &PathBuf) -> Result<Vec<PublicKey>, Error> {
// Ensure keys directory exists
std::fs::create_dir_all(dir)?;
let files = std::fs::read_dir(dir)?;
let mut entries = Vec::new();
let mut npubs: Vec<PublicKey> = Vec::new();
for file in files.flatten() {
let metadata = file.metadata()?;
let modified_time = metadata.modified()?;
let name = file.file_name().into_string().unwrap().replace(".npub", "");
entries.push((modified_time, name));
}
// Sort by modification time (most recent first)
entries.sort_by(|a, b| b.0.cmp(&a.0));
for (_, name) in entries {
let public_key = PublicKey::parse(&name)?;
npubs.push(public_key);
}
Ok(npubs)
Ok(())
});
self.tasks.push(task);
}
/// Connect to the bootstrapping relays
fn connect(&mut self, cx: &mut Context<Self>) {
fn connect_bootstrap_relays(&mut self, cx: &mut Context<Self>) {
let client = self.client();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
@@ -234,345 +206,102 @@ impl NostrRegistry {
}
// Connect to all added relays
client
.connect()
.and_wait(Duration::from_secs(TIMEOUT))
.await;
client.connect().await;
Ok(())
});
// Emit connecting event
cx.emit(StateEvent::Connecting);
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = task.await {
this.update(cx, |_this, cx| {
cx.emit(StateEvent::error(e.to_string()));
})?;
} else {
this.update(cx, |_this, cx| {
cx.emit(StateEvent::Connected);
})?;
}
Ok(())
}));
}
/// Get the secret for a given npub.
pub fn get_secret(
&self,
public_key: PublicKey,
cx: &App,
) -> Task<Result<Arc<dyn NostrSigner>, Error>> {
let npub = public_key.to_bech32().unwrap();
let key_path = self.key_dir.join(format!("{}.npub", npub));
let app_keys = self.app_keys.clone();
if let Ok(payload) = std::fs::read_to_string(key_path) {
if !payload.is_empty() {
cx.background_spawn(async move {
let decrypted = app_keys.nip44_decrypt(&public_key, &payload).await?;
let secret = SecretKey::parse(&decrypted)?;
let keys = Keys::new(secret);
Ok(keys.into_nostr_signer())
})
} else {
self.get_secret_keyring(&npub, cx)
}
} else {
self.get_secret_keyring(&npub, cx)
}
}
/// Get the secret for a given npub in the OS credentials store.
#[deprecated = "Use get_secret instead"]
fn get_secret_keyring(
&self,
user: &str,
cx: &App,
) -> Task<Result<Arc<dyn NostrSigner>, Error>> {
let read = cx.read_credentials(user);
let app_keys = self.app_keys.clone();
cx.background_spawn(async move {
let (_, secret) = read
.await
.map_err(|_| anyhow!("Failed to get signer. Please re-import the secret key"))?
.ok_or_else(|| anyhow!("Failed to get signer. Please re-import the secret key"))?;
// Try to parse as a direct secret key first
if let Ok(secret_key) = SecretKey::from_slice(&secret) {
return Ok(Keys::new(secret_key).into_nostr_signer());
}
// Convert the secret into string
let sec = String::from_utf8(secret)
.map_err(|_| anyhow!("Failed to parse secret as UTF-8"))?;
// Try to parse as a NIP-46 URI
let uri =
NostrConnectUri::parse(&sec).map_err(|_| anyhow!("Failed to parse NIP-46 URI"))?;
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let mut nip46 = NostrConnect::new(uri, app_keys, timeout, None)?;
// Set the auth URL handler
nip46.auth_url_handler(CoopAuthUrlHandler);
Ok(nip46.into_nostr_signer())
})
}
/// Add a new npub to the keys directory
fn write_secret(
&self,
public_key: PublicKey,
secret: String,
cx: &App,
) -> Task<Result<(), Error>> {
let npub = public_key.to_bech32().unwrap();
let key_path = self.key_dir.join(format!("{}.npub", npub));
let app_keys = self.app_keys.clone();
cx.background_spawn(async move {
// If the secret starts with "bunker://" (nostr connect), use it directly; otherwise, encrypt it
let content = if secret.starts_with("bunker://") {
secret
} else {
app_keys.nip44_encrypt(&public_key, &secret).await?
};
// Write the encrypted secret to the keys directory
smol::fs::write(key_path, &content).await?;
Ok(())
})
}
/// Remove a secret
pub fn remove_secret(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
let public_key = public_key.to_owned();
let npub = public_key.to_bech32().unwrap();
let keys_dir = config_dir().join("keys");
let key_path = keys_dir.join(format!("{}.npub", npub));
// Remove the secret file from the keys directory
std::fs::remove_file(key_path).ok();
self.npubs.update(cx, |this, cx| {
this.retain(|k| k != &public_key);
cx.notify();
});
}
/// Create a new identity
pub fn create_identity(&mut self, cx: &mut Context<Self>) {
let client = self.client();
let keys = Keys::generate();
let async_keys = keys.clone();
// Emit creating event
cx.emit(StateEvent::Creating);
// Create the write secret task
let write_secret =
self.write_secret(keys.public_key(), keys.secret_key().to_secret_hex(), cx);
// Run async tasks in background
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let signer = async_keys.into_nostr_signer();
// Construct relay list event
let relay_list = default_relay_list();
let event = EventBuilder::relay_list(relay_list).sign(&signer).await?;
// Publish relay list
client
.send_event(&event)
.to(BOOTSTRAP_RELAYS)
.ack_policy(AckPolicy::none())
.await?;
// Construct the default metadata
let name = petname::petname(2, "-").unwrap_or("Cooper".to_string());
let avatar = Url::parse(&format!("https://avatar.vercel.sh/{name}")).unwrap();
let metadata = Metadata::new().display_name(&name).picture(avatar);
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
// Publish metadata event
client
.send_event(&event)
.to_nip65()
.ack_policy(AckPolicy::none())
.await?;
// Construct the default contact list
let contacts = vec![Contact::new(PublicKey::parse(COOP_PUBKEY).unwrap())];
let event = EventBuilder::contact_list(contacts).sign(&signer).await?;
// Publish contact list event
client
.send_event(&event)
.to_nip65()
.ack_policy(AckPolicy::none())
.await?;
// Construct the default messaging relay list
let relays = default_messaging_relays();
let event = EventBuilder::nip17_relay_list(relays).sign(&signer).await?;
// Publish messaging relay list event
client.send_event(&event).to_nip65().await?;
// Write user's credentials to the system keyring
write_secret.await?;
Ok(())
});
/// Check the user's credential and set the signer if valid
#[cfg(not(target_arch = "wasm32"))]
fn get_user_credential(&mut self, cx: &mut Context<Self>) {
let user_keyring = cx.read_credentials(USER_KEYRING);
let master_keyring = self.get_master_key(cx);
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(_) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
})?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(StateEvent::error(e.to_string()));
})?;
}
};
match user_keyring.await {
Ok(Some((_username, secret))) => {
let content = String::from_utf8(secret)?;
Ok(())
}));
}
if content.starts_with("nsec1") {
let secret_key = SecretKey::parse(&content)?;
let keys = Keys::new(secret_key);
/// Set the signer for the nostr client and verify the public key
pub fn set_signer<T>(&mut self, new: T, cx: &mut Context<Self>)
where
T: NostrSigner + 'static,
{
let client = self.client();
let signer = self.signer();
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
cx.notify();
})?;
} else if content.starts_with("bunker://") {
let keys = master_keyring.await;
let timeout = Duration::from_secs(30);
let uri = NostrConnectUri::parse(content)?;
// Create a task to update the signer and verify the public key
let task: Task<Result<PublicKey, Error>> = cx.background_spawn(async move {
// Update signer and unsubscribe
signer.switch(new).await;
client.unsubscribe_all().await?;
// Construct the nostr connect signer
let mut signer = NostrConnect::new(uri, keys, timeout, None)?;
// Verify and get public key
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
log::info!("Signer's public key: {}", public_key);
Ok(public_key)
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(public_key) => {
this.update(cx, |this, cx| {
// Add public key to npubs if not already present
this.npubs.update(cx, |this, cx| {
if !this.contains(&public_key) {
this.push(public_key);
cx.notify();
}
});
// Emit signer changed event
cx.emit(StateEvent::SignerSet);
})?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(StateEvent::error(e.to_string()));
})?;
}
};
Ok(())
}));
}
/// Add a key signer to keyring
pub fn add_key_signer(&mut self, keys: &Keys, cx: &mut Context<Self>) {
let keys = keys.clone();
let write_secret =
self.write_secret(keys.public_key(), keys.secret_key().to_secret_hex(), cx);
self.tasks.push(cx.spawn(async move |this, cx| {
match write_secret.await {
Ok(_) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
})?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(StateEvent::error(e.to_string()));
})?;
}
};
Ok(())
}));
}
/// Add a nostr connect signer to keyring
pub fn add_nip46_signer(&mut self, nip46: &NostrConnect, cx: &mut Context<Self>) {
let nip46 = nip46.clone();
let async_nip46 = nip46.clone();
// Connect and verify the remote signer
let task: Task<Result<(PublicKey, NostrConnectUri), Error>> =
cx.background_spawn(async move {
let uri = async_nip46.bunker_uri().await?;
let public_key = async_nip46.get_public_key().await?;
Ok((public_key, uri))
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok((public_key, uri)) => {
// Create the write secret task
let write_secret = this.read_with(cx, |this, cx| {
this.write_secret(public_key, uri.to_string(), cx)
})?;
match write_secret.await {
Ok(_) => {
this.update(cx, |this, cx| {
this.set_signer(nip46, cx);
})?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(StateEvent::error(e.to_string()));
})?;
}
this.update(cx, |this, cx| {
this.set_signer(signer, cx);
cx.notify();
})?;
}
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(StateEvent::error(e.to_string()));
_ => {
this.update(cx, |_, cx| {
cx.emit(StateEvent::NoSigner);
})?;
}
};
}
Ok(())
}));
}
/// 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| {
let (keys, new_key) = match task.await {
Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) {
Ok(secret_key) => (Keys::new(secret_key), false),
_ => (Keys::generate(), true),
},
_ => (Keys::generate(), true),
};
if new_key {
let keys_clone = keys.clone();
let username = keys_clone.public_key().to_hex();
let password = keys_clone.secret_key().to_secret_bytes();
cx.update(|cx| {
let task = cx.write_credentials(MASTER_KEYRING, &username, &password);
cx.background_spawn(async move { task.await.ok() }).detach();
});
}
keys
})
}
/// Get the public key of a NIP-05 address
pub fn get_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
let client = self.client();
let http_client = cx.http_client();
@@ -609,7 +338,7 @@ impl NostrRegistry {
// Get the address task if the query is a valid NIP-05 address
let address_task = if let Ok(addr) = Nip05Address::parse(&query) {
Some(self.get_address(addr, cx))
Some(self.query_address(addr, cx))
} else {
None
};
@@ -680,14 +409,17 @@ impl NostrRegistry {
pub fn wot_search(&self, query: &str, cx: &App) -> Task<Result<Vec<PublicKey>, Error>> {
let client = self.client();
let query = query.to_string();
let signer = self.signer.clone();
cx.background_spawn(async move {
// Construct a vertex request event
let builder = EventBuilder::new(Kind::Custom(5315), "").tags(vec![
Tag::custom(TagKind::custom("param"), vec!["search", &query]),
Tag::custom(TagKind::custom("param"), vec!["limit", "10"]),
]);
let event = client.sign_event_builder(builder).await?;
let event = EventBuilder::new(Kind::Custom(5315), "")
.tags(vec![
Tag::custom("param", vec!["search", &query]),
Tag::custom("param", vec!["limit", "10"]),
])
.finalize_async(&signer)
.await?;
// Send the event to vertex relays
let output = client.send_event(&event).to(WOT_RELAYS).await?;
@@ -737,78 +469,3 @@ impl NostrRegistry {
})
}
}
/// Get or create new app keys
fn get_or_init_app_keys(cx: &App) -> 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 {
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}")
}
});
Ok(keys)
}
}
fn default_relay_list() -> Vec<(RelayUrl, Option<RelayMetadata>)> {
vec![
(
RelayUrl::parse("wss://relay.nostr.net").unwrap(),
Some(RelayMetadata::Write),
),
(
RelayUrl::parse("wss://relay.primal.net").unwrap(),
Some(RelayMetadata::Write),
),
(
RelayUrl::parse("wss://relay.damus.io").unwrap(),
Some(RelayMetadata::Read),
),
(
RelayUrl::parse("wss://nos.lol").unwrap(),
Some(RelayMetadata::Read),
),
(
RelayUrl::parse("wss://nostr.superfriends.online").unwrap(),
None,
),
]
}
fn default_messaging_relays() -> Vec<RelayUrl> {
vec![
RelayUrl::parse("wss://nos.lol").unwrap(),
RelayUrl::parse("wss://nip17.com").unwrap(),
RelayUrl::parse("wss://auth.nostr1.com").unwrap(),
]
}
#[derive(Debug, Clone)]
pub struct CoopAuthUrlHandler;
impl AuthUrlHandler for CoopAuthUrlHandler {
#[allow(mismatched_lifetime_syntaxes)]
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
Box::pin(async move {
webbrowser::open(auth_url.as_str())?;
Ok(())
})
}
}

View File

@@ -1,9 +1,9 @@
use std::sync::Arc;
use anyhow::Error;
use futures::io::AsyncReadExt;
use gpui::http_client::{AsyncBody, HttpClient};
use nostr_sdk::prelude::*;
use smol::io::AsyncReadExt;
#[allow(async_fn_in_trait)]
pub trait NostrAddress {

View File

@@ -16,14 +16,15 @@ impl From<&Event> for Announcement {
let public_key = val
.tags
.iter()
.find(|tag| tag.kind().as_str() == "n")
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|c| PublicKey::parse(c).ok())
.unwrap_or(val.pubkey);
let client_name = val
.tags
.find(TagKind::Client)
.iter()
.find(|tag| tag.kind() == "client")
.and_then(|tag| tag.content())
.map(|c| c.to_string());

View File

@@ -1,134 +1,206 @@
use std::borrow::Cow;
use std::result::Result;
use std::sync::Arc;
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;
use nostr_sdk::prelude::*;
use smol::lock::RwLock;
#[derive(Debug)]
pub struct CoopSigner {
/// User's signer
signer: RwLock<Arc<dyn NostrSigner>>,
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
/// User's signer public key
signer_pkey: RwLock<Option<PublicKey>>,
/// Specific signer for encryption purposes
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>,
impl fmt::Display for UniversalSignerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl CoopSigner {
impl Error for UniversalSignerError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&*self.0)
}
}
impl UniversalSignerError {
pub fn new<E>(err: E) -> Self
where
E: Error + Send + Sync + 'static,
{
UniversalSignerError(Box::new(err))
}
}
#[derive(Clone, Debug)]
pub struct UniversalSigner {
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
}
impl UniversalSigner {
pub fn new<T>(signer: T) -> Self
where
T: IntoNostrSigner,
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
{
Self {
signer: RwLock::new(signer.into_nostr_signer()),
signer_pkey: RwLock::new(None),
encryption_signer: RwLock::new(None),
inner: Arc::new(RwLock::new(Arc::new(InnerSignerImpl(signer)))),
}
}
/// Get the current signer.
pub async fn get(&self) -> Arc<dyn NostrSigner> {
self.signer.read().await.clone()
}
/// Get the encryption signer.
pub async fn get_encryption_signer(&self) -> Option<Arc<dyn NostrSigner>> {
self.encryption_signer.read().await.clone()
}
/// Get public key
///
/// Ensure to call this method after the signer has been initialized.
/// Otherwise, it will panic.
pub fn public_key(&self) -> Option<PublicKey> {
*self.signer_pkey.read_blocking()
}
/// Switch the current signer to a new signer.
pub async fn switch<T>(&self, new: T)
/// Swap the inner signer in-place. All clones see the new signer.
pub fn swap_inner<T>(&self, new_signer: T)
where
T: IntoNostrSigner,
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
{
let new_signer = new.into_nostr_signer();
let public_key = new_signer.get_public_key().await.ok();
let mut signer = self.signer.write().await;
let mut signer_pkey = self.signer_pkey.write().await;
let mut encryption_signer = self.encryption_signer.write().await;
// Switch to the new signer
*signer = new_signer;
// Update the public key
*signer_pkey = public_key;
// Reset the encryption signer
*encryption_signer = None;
}
/// Set the encryption signer.
pub async fn set_encryption_signer<T>(&self, new: T)
where
T: IntoNostrSigner,
{
let mut encryption_signer = self.encryption_signer.write().await;
*encryption_signer = Some(new.into_nostr_signer());
*self.inner.write().expect("RwLock poisoned") = Arc::new(InnerSignerImpl(new_signer));
}
}
impl NostrSigner for CoopSigner {
#[allow(mismatched_lifetime_syntaxes)]
fn backend(&self) -> SignerBackend {
SignerBackend::Custom(Cow::Borrowed("custom"))
}
fn get_public_key<'a>(&'a self) -> BoxedFuture<'a, Result<PublicKey, SignerError>> {
Box::pin(async move { self.get().await.get_public_key().await })
}
fn sign_event<'a>(
&'a self,
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> BoxedFuture<'a, Result<Event, SignerError>> {
Box::pin(async move { self.get().await.sign_event(unsigned).await })
}
fn nip04_encrypt<'a>(
) -> 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, SignerError>> {
Box::pin(async move { self.get().await.nip04_encrypt(public_key, content).await })
}
fn nip04_decrypt<'a>(
&'a self,
public_key: &'a PublicKey,
encrypted_content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move {
self.get()
.await
.nip04_decrypt(public_key, encrypted_content)
.await
})
}
fn nip44_encrypt<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, SignerError>> {
Box::pin(async move { self.get().await.nip44_encrypt(public_key, content).await })
}
fn nip44_decrypt<'a>(
) -> 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, SignerError>> {
Box::pin(async move { self.get().await.nip44_decrypt(public_key, payload).await })
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
}
#[derive(Debug)]
struct InnerSignerImpl<T>(T);
impl<T> InnerSigner for InnerSignerImpl<T>
where
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static,
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
<T as AsyncSignEvent>::Error: Error + Send + Sync + 'static,
<T as AsyncNip44>::Error: Error + Send + Sync + 'static,
{
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
Box::pin(async move {
AsyncGetPublicKey::get_public_key_async(&self.0)
.await
.map_err(UniversalSignerError::new)
})
}
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
Box::pin(async move {
AsyncSignEvent::sign_event_async(&self.0, unsigned)
.await
.map_err(UniversalSignerError::new)
})
}
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
Box::pin(async move {
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
.await
.map_err(UniversalSignerError::new)
})
}
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
Box::pin(async move {
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
.await
.map_err(UniversalSignerError::new)
})
}
}
impl UniversalSigner {
#[allow(dead_code)]
fn with_inner<R>(&self, f: impl FnOnce(&dyn InnerSigner) -> R) -> R {
let guard = self.inner.read().expect("RwLock poisoned");
f(&**guard)
}
}
impl AsyncGetPublicKey for UniversalSigner {
type Error = UniversalSignerError;
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
let inner = self.inner.read().expect("RwLock poisoned").clone();
Box::pin(async move { inner.get_public_key_async().await })
}
}
impl AsyncSignEvent for UniversalSigner {
type Error = UniversalSignerError;
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
let inner = self.inner.read().expect("RwLock poisoned").clone();
Box::pin(async move { inner.sign_event_async(unsigned).await })
}
}
impl AsyncNip44 for UniversalSigner {
type Error = UniversalSignerError;
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
let inner = self.inner.read().expect("RwLock poisoned").clone();
Box::pin(async move { inner.nip44_encrypt_async(public_key, content).await })
}
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
let inner = self.inner.read().expect("RwLock poisoned").clone();
Box::pin(async move { inner.nip44_decrypt_async(public_key, payload).await })
}
}
#[derive(Debug, Clone)]
pub struct CoopAuthUrlHandler;
impl AuthUrlHandler for CoopAuthUrlHandler {
fn on_auth_url(
&self,
auth_url: Url,
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
Box::pin(async move {
webbrowser::open(auth_url.as_str()).unwrap();
Ok(())
})
}
}

View File

@@ -192,7 +192,6 @@ impl From<ThemeFamily> for Theme {
let mode = ThemeMode::default();
// Define the font family based on the platform.
// TODO: Use native fonts on Linux too.
let font_family = match platform {
PlatformKind::Linux => "Inter",
_ => ".SystemUIFont",

View File

@@ -3,6 +3,7 @@ pub enum PlatformKind {
Mac,
Linux,
Windows,
Web,
}
impl PlatformKind {
@@ -11,22 +12,21 @@ impl PlatformKind {
Self::Linux
} else if cfg!(target_os = "windows") {
Self::Windows
} else {
} else if cfg!(target_os = "macos") {
Self::Mac
} else {
Self::Web
}
}
#[allow(dead_code)]
pub fn is_linux(&self) -> bool {
matches!(self, Self::Linux)
}
#[allow(dead_code)]
pub fn is_windows(&self) -> bool {
matches!(self, Self::Windows)
}
#[allow(dead_code)]
pub fn is_mac(&self) -> bool {
matches!(self, Self::Mac)
}

View File

@@ -1,23 +0,0 @@
[package]
name = "title_bar"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
theme = { path = "../theme" }
ui = { path = "../ui" }
nostr-sdk.workspace = true
gpui.workspace = true
smol.workspace = true
smallvec.workspace = true
anyhow.workspace = true
log.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.61", features = ["Wdk_System_SystemServices"] }
[target.'cfg(target_os = "linux")'.dependencies]
linicon = "2.3.0"

View File

@@ -1,172 +0,0 @@
#[cfg(target_os = "linux")]
use gpui::MouseButton;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, ParentElement,
Pixels, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, px,
};
use smallvec::{SmallVec, smallvec};
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, PlatformKind};
use ui::h_flex;
#[cfg(target_os = "linux")]
use crate::platforms::linux::LinuxWindowControls;
use crate::platforms::mac::TRAFFIC_LIGHT_PADDING;
use crate::platforms::windows::WindowsWindowControls;
mod platforms;
/// Titlebar
pub struct TitleBar {
/// Children elements of the title bar.
children: SmallVec<[AnyElement; 2]>,
/// Whether the title bar is currently being moved.
should_move: bool,
}
impl TitleBar {
pub fn new() -> Self {
Self {
children: smallvec![],
should_move: false,
}
}
#[cfg(not(target_os = "windows"))]
pub fn height(&self, window: &mut Window) -> Pixels {
(1.75 * window.rem_size()).max(px(34.))
}
#[cfg(target_os = "windows")]
pub fn height(&self, _window: &mut Window) -> Pixels {
px(32.)
}
pub fn titlebar_color(&self, window: &mut Window, cx: &mut Context<Self>) -> Hsla {
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
if window.is_window_active() && !self.should_move {
cx.theme().title_bar
} else {
cx.theme().title_bar_inactive
}
} else {
cx.theme().title_bar
}
}
pub fn set_children<T>(&mut self, children: T)
where
T: IntoIterator<Item = AnyElement>,
{
self.children = children.into_iter().collect();
}
}
impl Default for TitleBar {
fn default() -> Self {
Self::new()
}
}
impl Render for TitleBar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let height = self.height(window);
let color = self.titlebar_color(window, cx);
let children = std::mem::take(&mut self.children);
#[cfg(target_os = "linux")]
let supported_controls = window.window_controls();
let decorations = window.window_decorations();
h_flex()
.window_control_area(WindowControlArea::Drag)
.h(height)
.w_full()
.map(|this| {
if window.is_fullscreen() {
this.px_2()
} else if cx.theme().platform.is_mac() {
this.pr_2().pl(px(TRAFFIC_LIGHT_PADDING))
} else {
this.px_2()
}
})
.map(|this| match decorations {
Decorations::Server => this,
Decorations::Client { tiling } => this
.when(!(tiling.top || tiling.right), |div| {
div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
}),
})
.bg(color)
.border_b_1()
.border_color(cx.theme().border)
.content_stretch()
.child(
h_flex()
.id("title-bar")
.justify_between()
.w_full()
.when(cx.theme().platform.is_mac(), |this| {
this.on_click(|event, window, _| {
if event.click_count() == 2 {
window.titlebar_double_click();
}
})
})
.when(cx.theme().platform.is_linux(), |this| {
this.on_click(|event, window, _| {
if event.click_count() == 2 {
window.zoom_window();
}
})
})
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
.children(children),
)
.when(!window.is_fullscreen(), |this| match cx.theme().platform {
PlatformKind::Linux => {
#[cfg(target_os = "linux")]
if matches!(decorations, Decorations::Client { .. }) {
this.child(LinuxWindowControls::new(None))
.when(supported_controls.window_menu, |this| {
this.on_mouse_down(MouseButton::Right, move |ev, window, _| {
window.show_window_menu(ev.position)
})
})
.on_mouse_move(cx.listener(move |this, _ev, window, _| {
if this.should_move {
this.should_move = false;
window.start_window_move();
}
}))
.on_mouse_down_out(cx.listener(move |this, _ev, _window, _cx| {
this.should_move = false;
}))
.on_mouse_up(
MouseButton::Left,
cx.listener(move |this, _ev, _window, _cx| {
this.should_move = false;
}),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _ev, _window, _cx| {
this.should_move = true;
}),
)
} else {
this
}
#[cfg(not(target_os = "linux"))]
this
}
PlatformKind::Windows => this.child(WindowsWindowControls::new(height)),
PlatformKind::Mac => this,
})
}
}

View File

@@ -1,227 +0,0 @@
use std::collections::HashMap;
use std::sync::OnceLock;
use gpui::prelude::FluentBuilder;
use gpui::{
svg, Action, App, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window,
};
use linicon::{lookup_icon, IconType};
use theme::ActiveTheme;
use ui::{h_flex, Icon, IconName, Sizable};
#[derive(IntoElement)]
pub struct LinuxWindowControls {
close_window_action: Option<Box<dyn Action>>,
}
impl LinuxWindowControls {
pub fn new(close_window_action: Option<Box<dyn Action>>) -> Self {
Self {
close_window_action,
}
}
}
impl RenderOnce for LinuxWindowControls {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let supported_controls = window.window_controls();
h_flex()
.id("linux-window-controls")
.gap_2()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.when(supported_controls.minimize, |this| {
this.child(WindowControl::new(
LinuxControl::Minimize,
IconName::WindowMinimize,
))
})
.when(supported_controls.maximize, |this| {
this.child({
if window.is_maximized() {
WindowControl::new(LinuxControl::Restore, IconName::WindowRestore)
} else {
WindowControl::new(LinuxControl::Maximize, IconName::WindowMaximize)
}
})
})
.child(
WindowControl::new(LinuxControl::Close, IconName::WindowClose)
.when_some(self.close_window_action, |this, close_action| {
this.close_action(close_action)
}),
)
}
}
#[derive(IntoElement)]
pub struct WindowControl {
kind: LinuxControl,
fallback: IconName,
close_action: Option<Box<dyn Action>>,
}
impl WindowControl {
pub fn new(kind: LinuxControl, fallback: IconName) -> Self {
Self {
kind,
fallback,
close_action: None,
}
}
pub fn close_action(mut self, action: Box<dyn Action>) -> Self {
self.close_action = Some(action);
self
}
pub fn is_gnome(&self) -> bool {
matches!(detect_desktop_environment(), DesktopEnvironment::Gnome)
}
}
impl RenderOnce for WindowControl {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let is_gnome = self.is_gnome();
h_flex()
.id(self.kind.as_icon_name())
.group("")
.justify_center()
.items_center()
.rounded_full()
.size_6()
.when(is_gnome, |this| {
this.bg(cx.theme().ghost_element_background_alt)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.active(|this| this.bg(cx.theme().ghost_element_active))
})
.map(|this| {
if let Some(Some(path)) = linux_controls().get(&self.kind).cloned() {
this.child(
svg()
.external_path(SharedString::from(path))
.size_4()
.text_color(cx.theme().text),
)
} else {
this.child(Icon::new(self.fallback).small().text_color(cx.theme().text))
}
})
.on_mouse_move(|_, _window, cx| cx.stop_propagation())
.on_click(move |_, window, cx| {
cx.stop_propagation();
match self.kind {
LinuxControl::Minimize => window.minimize_window(),
LinuxControl::Restore => window.zoom_window(),
LinuxControl::Maximize => window.zoom_window(),
LinuxControl::Close => cx.quit(),
}
})
}
}
static DE: OnceLock<DesktopEnvironment> = OnceLock::new();
static LINUX_CONTROLS: OnceLock<HashMap<LinuxControl, Option<String>>> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DesktopEnvironment {
Gnome,
Kde,
Unknown,
}
/// Detect the current desktop environment
pub fn detect_desktop_environment() -> &'static DesktopEnvironment {
DE.get_or_init(|| {
// Try to use environment variables first
if let Ok(output) = std::env::var("XDG_CURRENT_DESKTOP") {
let desktop = output.to_lowercase();
if desktop.contains("gnome") {
return DesktopEnvironment::Gnome;
} else if desktop.contains("kde") {
return DesktopEnvironment::Kde;
}
}
// Fallback detection methods
if let Ok(output) = std::env::var("DESKTOP_SESSION") {
let session = output.to_lowercase();
if session.contains("gnome") {
return DesktopEnvironment::Gnome;
} else if session.contains("kde") || session.contains("plasma") {
return DesktopEnvironment::Kde;
}
}
DesktopEnvironment::Unknown
})
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub enum LinuxControl {
Minimize,
Restore,
Maximize,
Close,
}
impl LinuxControl {
pub fn as_icon_name(&self) -> &'static str {
match self {
LinuxControl::Close => "window-close",
LinuxControl::Minimize => "window-minimize",
LinuxControl::Maximize => "window-maximize",
LinuxControl::Restore => "window-restore",
}
}
}
fn linux_controls() -> &'static HashMap<LinuxControl, Option<String>> {
LINUX_CONTROLS.get_or_init(|| {
let mut icons = HashMap::new();
icons.insert(LinuxControl::Close, None);
icons.insert(LinuxControl::Minimize, None);
icons.insert(LinuxControl::Maximize, None);
icons.insert(LinuxControl::Restore, None);
let icon_names = [
(LinuxControl::Close, vec!["window-close", "dialog-close"]),
(
LinuxControl::Minimize,
vec!["window-minimize", "window-lower"],
),
(
LinuxControl::Maximize,
vec!["window-maximize", "window-expand"],
),
(
LinuxControl::Restore,
vec!["window-restore", "window-return"],
),
];
for (control, icon_names) in icon_names {
for icon_name in icon_names {
// Try GNOME-style naming first
let mut control_icon = lookup_icon(format!("{icon_name}-symbolic"))
.find(|icon| matches!(icon, Ok(icon) if icon.icon_type == IconType::SVG));
// If not found, try KDE-style naming
if control_icon.is_none() {
control_icon = lookup_icon(icon_name)
.find(|icon| matches!(icon, Ok(icon) if icon.icon_type == IconType::SVG));
}
if let Some(Ok(icon)) = control_icon {
icons
.entry(control)
.and_modify(|v| *v = Some(icon.path.to_string_lossy().to_string()));
}
}
}
icons
})
}

View File

@@ -1,6 +0,0 @@
/// Use pixels here instead of a rem-based size because the macOS traffic
/// lights are a static size, and don't scale with the rest of the UI.
///
/// Magic number: There is one extra pixel of padding on the left side due to
/// the 1px border around the window on macOS apps.
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;

View File

@@ -1,4 +0,0 @@
#[cfg(target_os = "linux")]
pub mod linux;
pub mod mac;
pub mod windows;

View File

@@ -1,147 +0,0 @@
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels,
RenderOnce, Rgba, StatefulInteractiveElement, Styled, Window, WindowControlArea,
};
use theme::ActiveTheme;
use ui::h_flex;
#[derive(IntoElement)]
pub struct WindowsWindowControls {
button_height: Pixels,
}
impl WindowsWindowControls {
pub fn new(button_height: Pixels) -> Self {
Self { button_height }
}
#[cfg(not(target_os = "windows"))]
fn get_font() -> &'static str {
"Segoe Fluent Icons"
}
#[cfg(target_os = "windows")]
fn get_font() -> &'static str {
use windows::Wdk::System::SystemServices::RtlGetVersion;
let mut version = unsafe { std::mem::zeroed() };
let status = unsafe { RtlGetVersion(&mut version) };
if status.is_ok() && version.dwBuildNumber >= 22000 {
"Segoe Fluent Icons"
} else {
"Segoe MDL2 Assets"
}
}
}
impl RenderOnce for WindowsWindowControls {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let close_button_hover_color = Rgba {
r: 232.0 / 255.0,
g: 17.0 / 255.0,
b: 32.0 / 255.0,
a: 1.0,
};
let button_hover_color = cx.theme().ghost_element_hover;
let button_active_color = cx.theme().ghost_element_active;
div()
.id("windows-window-controls")
.font_family(Self::get_font())
.flex()
.flex_row()
.justify_center()
.content_stretch()
.max_h(self.button_height)
.min_h(self.button_height)
.child(WindowsCaptionButton::new(
"minimize",
WindowsCaptionButtonIcon::Minimize,
button_hover_color,
button_active_color,
))
.child(WindowsCaptionButton::new(
"maximize-or-restore",
if window.is_maximized() {
WindowsCaptionButtonIcon::Restore
} else {
WindowsCaptionButtonIcon::Maximize
},
button_hover_color,
button_active_color,
))
.child(WindowsCaptionButton::new(
"close",
WindowsCaptionButtonIcon::Close,
close_button_hover_color,
button_active_color,
))
}
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
enum WindowsCaptionButtonIcon {
Minimize,
Restore,
Maximize,
Close,
}
#[derive(IntoElement)]
struct WindowsCaptionButton {
id: ElementId,
icon: WindowsCaptionButtonIcon,
hover_background_color: Hsla,
active_background_color: Hsla,
}
impl WindowsCaptionButton {
pub fn new(
id: impl Into<ElementId>,
icon: WindowsCaptionButtonIcon,
hover_background_color: impl Into<Hsla>,
active_background_color: impl Into<Hsla>,
) -> Self {
Self {
id: id.into(),
icon,
hover_background_color: hover_background_color.into(),
active_background_color: active_background_color.into(),
}
}
}
impl RenderOnce for WindowsCaptionButton {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
h_flex()
.id(self.id)
.justify_center()
.content_center()
.occlude()
.w(px(36.))
.h_full()
.text_size(px(10.0))
.hover(|style| style.bg(self.hover_background_color))
.active(|style| style.bg(self.active_background_color))
.map(|this| match self.icon {
WindowsCaptionButtonIcon::Close => {
this.window_control_area(WindowControlArea::Close)
}
WindowsCaptionButtonIcon::Maximize | WindowsCaptionButtonIcon::Restore => {
this.window_control_area(WindowControlArea::Max)
}
WindowsCaptionButtonIcon::Minimize => {
this.window_control_area(WindowControlArea::Min)
}
})
.child(match self.icon {
WindowsCaptionButtonIcon::Minimize => "\u{e921}",
WindowsCaptionButtonIcon::Restore => "\u{e923}",
WindowsCaptionButtonIcon::Maximize => "\u{e922}",
WindowsCaptionButtonIcon::Close => "\u{e8bb}",
})
}
}

View File

@@ -9,9 +9,8 @@ common = { path = "../common" }
theme = { path = "../theme" }
gpui.workspace = true
smol.workspace = true
instant.workspace = true
serde.workspace = true
serde_json.workspace = true
smallvec.workspace = true
anyhow.workspace = true
itertools.workspace = true
@@ -20,8 +19,10 @@ log.workspace = true
unicode-segmentation = "1.12.0"
uuid = "1.10"
regex = "1"
image = "0.25.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" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
tree-sitter = "0.26"

View File

@@ -2,15 +2,15 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, relative, AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement,
IntoElement, ParentElement, RenderOnce, SharedString, Stateful,
StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _,
StyleRefinement, Styled, Window, div, relative,
};
use theme::ActiveTheme;
use crate::indicator::Indicator;
use crate::tooltip::Tooltip;
use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt};
use crate::{Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex};
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct ButtonCustomVariant {
@@ -617,7 +617,7 @@ impl ButtonVariant {
};
let fg = match self {
ButtonVariant::Primary => cx.theme().text_muted, // TODO: use a different color?
ButtonVariant::Primary => cx.theme().text_muted,
_ => cx.theme().text_muted,
};

View File

@@ -1,5 +1,5 @@
use std::rc::Rc;
use std::time::Duration;
use instant::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{

View File

@@ -214,6 +214,8 @@ impl Dock {
pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
self.open = open;
let item = self.panel.clone();
// Use defer_in (not window.defer) so the callback is cancelled
// if this Dock entity is dropped before the deferred frame runs.
cx.defer_in(window, move |_, window, cx| {
item.set_collapsed(!open, window, cx);
});

View File

@@ -1,5 +1,5 @@
use std::fmt::Debug;
use std::time::{Duration, Instant};
use instant::{Duration, Instant};
/// A HistoryItem represents a single change in the history.
/// It must implement Clone and PartialEq to be used in the History.

View File

@@ -1,4 +1,4 @@
use std::time::Duration;
use instant::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{

View File

@@ -1,4 +1,4 @@
use std::time::Duration;
use instant::Duration;
use gpui::{Context, Pixels, Task, px};

View File

@@ -1,202 +1,61 @@
/// DisplayMap: Public facade for Editor/Input display mapping.
///
/// This combines WrapMap and FoldMap to provide a unified API:
/// - BufferPoint ↔ DisplayPoint conversion
/// - Fold management (candidates, toggle, query)
/// - Automatic projection updates on text/layout changes
use std::ops::Range;
use gpui::{App, Font, Pixels};
use ropey::Rope;
use super::fold_map::FoldMap;
use super::folding::FoldRange;
use super::text_wrapper::{LineItem, WrapDisplayPoint};
use super::wrap_map::WrapMap;
use super::{BufferPoint, DisplayPoint};
use crate::input::display_map::WrapPoint;
use crate::input::rope_ext::RopeExt as _;
use crate::input::Point as TreeSitterPoint;
/// 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 {
@@ -269,30 +104,34 @@ impl DisplayMap {
/// Convert wrap display point to TreeSitterPoint (buffer line/col).
#[inline]
pub(crate) fn wrap_display_point_to_point(
&self,
point: WrapDisplayPoint,
) -> TreeSitterPoint {
pub(crate) fn wrap_display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint {
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).
@@ -301,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] {
@@ -315,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()

View File

@@ -1,343 +0,0 @@
/// FoldMap: Folding projection layer (Wrap rows → Display rows).
///
/// This module manages code folding by:
/// - Filtering out wrap rows that belong to folded regions
/// - Maintaining bidirectional mapping: wrap_row ↔ display_row
/// - Handling fold state changes and rebuilding the projection
use super::folding::FoldRange;
use super::wrap_map::WrapMap;
/// FoldMap projects wrap rows to display rows by hiding folded regions.
pub struct FoldMap {
/// Mapping: display_row → wrap_row
/// index = display_row, value = actual wrap_row
visible_wrap_rows: Vec<usize>,
/// Reverse mapping: wrap_row → display_row
/// index = wrap_row, value = Some(display_row) if visible, None if folded
wrap_row_to_display_row: Vec<Option<usize>>,
/// Candidate fold ranges (from tree-sitter/LSP)
/// Sorted by start_line, unique start_line
candidates: Vec<FoldRange>,
/// Currently folded ranges
/// Subset of candidates, sorted by start_line
folded: Vec<FoldRange>,
/// Flag indicating if the fold projection needs rebuilding
/// Used for lazy evaluation to avoid expensive rebuilds on every text change
needs_rebuild: bool,
/// Cached wrap_row_count from last rebuild
/// Used to detect if WrapMap changed and rebuild is needed
cached_wrap_row_count: usize,
}
impl FoldMap {
pub fn new() -> Self {
Self {
visible_wrap_rows: Vec::new(),
wrap_row_to_display_row: Vec::new(),
candidates: Vec::new(),
folded: Vec::new(),
needs_rebuild: true,
cached_wrap_row_count: 0,
}
}
/// Update cached wrap_row_count without full rebuild.
/// Used when no folds are active (identity mapping assumed).
pub(super) fn mark_dirty_with_wrap_count(&mut self, wrap_row_count: usize) {
self.needs_rebuild = true;
self.cached_wrap_row_count = wrap_row_count;
}
/// Get total number of visible display rows
pub fn display_row_count(&self) -> usize {
if self.folded.is_empty() {
return self.cached_wrap_row_count;
}
self.visible_wrap_rows.len()
}
/// Convert wrap_row to display_row
/// Returns None if the wrap_row is hidden by folding
pub fn wrap_row_to_display_row(&self, wrap_row: usize) -> Option<usize> {
if self.folded.is_empty() {
return if wrap_row < self.cached_wrap_row_count {
Some(wrap_row)
} else {
None
};
}
self.wrap_row_to_display_row
.get(wrap_row)
.copied()
.flatten()
}
/// Convert display_row to wrap_row
pub fn display_row_to_wrap_row(&self, display_row: usize) -> Option<usize> {
if self.folded.is_empty() {
return if display_row < self.cached_wrap_row_count {
Some(display_row)
} else {
None
};
}
self.visible_wrap_rows.get(display_row).copied()
}
/// Find the nearest visible display_row for a given wrap_row
pub fn nearest_visible_display_row(&self, wrap_row: usize) -> usize {
if self.folded.is_empty() {
return wrap_row.min(self.cached_wrap_row_count.saturating_sub(1));
}
if let Some(dr) = self.wrap_row_to_display_row(wrap_row) {
return dr;
}
match self.visible_wrap_rows.binary_search(&wrap_row) {
Ok(idx) => idx,
Err(insert_pos) => insert_pos.saturating_sub(1),
}
}
/// Set fold candidates (from tree-sitter/LSP), full replacement.
pub fn set_candidates(&mut self, mut candidates: Vec<FoldRange>) {
// Sort and deduplicate by start_line
candidates.sort_by_key(|r| r.start_line);
candidates.dedup_by_key(|r| r.start_line);
self.candidates = candidates;
// Remove any folded ranges that are no longer in candidates
self.folded.retain(|fold| {
self.candidates
.iter()
.any(|c| c.start_line == fold.start_line)
});
}
/// Merge new candidates extracted from an edited region into existing candidates.
///
/// Replaces candidates within [edit_start_line, edit_end_line] with `new_candidates`,
/// keeping candidates outside the edit range intact.
pub fn merge_candidates_for_edit(
&mut self,
edit_start_line: usize,
edit_end_line: usize,
new_candidates: Vec<FoldRange>,
) {
// Remove old candidates within the edit range (already done by adjust_folds_for_edit)
// But do it again in case adjust wasn't called or range differs
self.candidates
.retain(|c| c.start_line < edit_start_line || c.start_line > edit_end_line);
// Add new candidates
self.candidates.extend(new_candidates);
self.candidates.sort_by_key(|r| r.start_line);
self.candidates.dedup_by_key(|r| r.start_line);
}
/// Set a fold at the given start_line (must be in candidates)
pub fn set_folded(&mut self, start_line: usize, folded: bool) {
if folded {
// Find the candidate range for this start_line
if let Some(candidate) = self.candidates.iter().find(|c| c.start_line == start_line) {
// Add to folded if not already present
if !self.folded.iter().any(|f| f.start_line == start_line) {
self.folded.push(*candidate);
self.folded.sort_by_key(|r| r.start_line);
self.needs_rebuild = true;
}
}
} else {
// Remove from folded
self.folded.retain(|f| f.start_line != start_line);
self.needs_rebuild = true;
}
}
/// Toggle fold at the given start_line
pub fn toggle_fold(&mut self, start_line: usize) {
let is_folded = self.is_folded_at(start_line);
self.set_folded(start_line, !is_folded);
}
/// Check if a line is currently folded
pub fn is_folded_at(&self, start_line: usize) -> bool {
self.folded.iter().any(|f| f.start_line == start_line)
}
/// Check if a line is a fold candidate
pub fn is_fold_candidate(&self, start_line: usize) -> bool {
self.candidates.iter().any(|c| c.start_line == start_line)
}
/// Get all fold candidates
#[inline]
pub fn fold_candidates(&self) -> &[FoldRange] {
&self.candidates
}
/// Get all currently folded ranges
#[inline]
pub fn folded_ranges(&self) -> &[FoldRange] {
&self.folded
}
/// Clear all folds
#[inline]
pub fn clear_folds(&mut self) {
self.folded.clear();
}
/// Adjust folds and candidates after a text edit.
///
/// - Folds/candidates overlapping the edited line range are removed
/// - Folds/candidates after the edit are shifted by line_delta
///
/// This avoids expensive full tree traversal on every keystroke.
pub fn adjust_folds_for_edit(
&mut self,
edit_start_line: usize,
edit_end_line: usize,
line_delta: isize,
) {
// Adjust folded ranges
if !self.folded.is_empty() {
self.folded.retain(|fold| {
!(fold.start_line <= edit_end_line && fold.end_line >= edit_start_line)
});
if line_delta != 0 {
for fold in &mut self.folded {
if fold.start_line > edit_end_line {
fold.start_line = (fold.start_line as isize + line_delta).max(0) as usize;
fold.end_line = (fold.end_line as isize + line_delta).max(0) as usize;
}
}
}
}
// Adjust candidates the same way
if !self.candidates.is_empty() {
self.candidates
.retain(|c| !(c.start_line <= edit_end_line && c.end_line >= edit_start_line));
if line_delta != 0 {
for c in &mut self.candidates {
if c.start_line > edit_end_line {
c.start_line = (c.start_line as isize + line_delta).max(0) as usize;
c.end_line = (c.end_line as isize + line_delta).max(0) as usize;
}
}
}
}
self.needs_rebuild = true;
}
/// Rebuild the fold mapping after wrap_map or fold state changes
///
/// This is the core algorithm that projects wrap rows to display rows.
pub fn rebuild(&mut self, wrap_map: &WrapMap) {
let wrap_row_count = wrap_map.wrap_row_count();
// Performance optimization: skip rebuild if nothing changed
if !self.needs_rebuild && wrap_row_count == self.cached_wrap_row_count {
return;
}
self.cached_wrap_row_count = wrap_row_count;
self.visible_wrap_rows.clear();
self.wrap_row_to_display_row = vec![None; wrap_row_count];
if self.folded.is_empty() {
// Fast path: no folds, all wrap rows are visible
self.visible_wrap_rows = (0..wrap_row_count).collect();
for (display_row, &wrap_row) in self.visible_wrap_rows.iter().enumerate() {
self.wrap_row_to_display_row[wrap_row] = Some(display_row);
}
self.needs_rebuild = false;
return;
}
// Build set of hidden wrap_row ranges from folded buffer lines
let mut hidden_ranges = Vec::new();
for fold in &self.folded {
// Hide wrap rows from (start_line + 1) to (end_line - 1) (inclusive)
// Both the first line and last line of the fold remain visible
let hide_start_line = fold.start_line + 1;
let hide_end_line = fold.end_line.saturating_sub(1);
if hide_start_line > hide_end_line {
continue; // No middle lines to hide (0 or 1 lines between start and end)
}
// Get wrap_row ranges for the hidden buffer lines
let start_wrap_row = wrap_map.buffer_line_to_first_wrap_row(hide_start_line);
let end_wrap_row = if hide_end_line + 1 < wrap_map.buffer_line_count() {
wrap_map.buffer_line_to_first_wrap_row(hide_end_line + 1)
} else {
wrap_row_count
};
if start_wrap_row < end_wrap_row {
hidden_ranges.push(start_wrap_row..end_wrap_row);
}
}
// Merge overlapping hidden ranges
hidden_ranges.sort_by_key(|r| r.start);
let mut merged_hidden = Vec::new();
for range in hidden_ranges {
if let Some(last) = merged_hidden.last_mut() {
if range.start <= *last {
// Overlapping or adjacent, merge
*last = (*last).max(range.end);
} else {
merged_hidden.push(range.start);
merged_hidden.push(range.end);
}
} else {
merged_hidden.push(range.start);
merged_hidden.push(range.end);
}
}
// Scan all wrap rows and filter out hidden ones
let mut display_row = 0;
let mut hidden_iter = merged_hidden.chunks_exact(2);
let mut current_hidden = hidden_iter.next();
for wrap_row in 0..wrap_row_count {
// Check if wrap_row is in current hidden range
let is_hidden = if let Some(&[start, end]) = current_hidden {
if wrap_row >= end {
current_hidden = hidden_iter.next();
if let Some(&[new_start, new_end]) = current_hidden {
wrap_row >= new_start && wrap_row < new_end
} else {
false
}
} else {
wrap_row >= start && wrap_row < end
}
} else {
false
};
if !is_hidden {
self.visible_wrap_rows.push(wrap_row);
self.wrap_row_to_display_row[wrap_row] = Some(display_row);
display_row += 1;
}
}
self.needs_rebuild = false;
}
}

View File

@@ -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);
}
}

View File

@@ -1,69 +1,7 @@
/// Display mapping system for Editor/Input.
///
/// This module implements a layered display mapping architecture:
/// - **WrapMap**: Handles soft-wrapping (buffer → wrap rows)
/// - **FoldMap**: Handles folding (wrap rows → display rows)
/// - **DisplayMap**: Public facade for Editor/Input
///
/// The goal is to provide a clean, unified API where Editor only needs to know
/// about `BufferPoint ↔ DisplayPoint` mapping, without worrying about internal wrap/fold complexity.
#[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 }
}
}

View File

@@ -1,8 +1,7 @@
use std::ops::Range;
use gpui::Half;
use gpui::{
App, Font, LineFragment, Pixels, Point, ShapedLine, Size, TextAlign, Window, point, px,
App, Font, Half, LineFragment, Pixels, Point, ShapedLine, Size, TextAlign, Window, point, px,
size,
};
use ropey::Rope;
@@ -97,7 +96,7 @@ impl TextWrapper {
/// Get the line item by row index.
#[inline]
pub(crate) fn line(&self, row: usize) -> Option<&LineItem> {
self.lines.iter().skip(row).next()
self.lines.get(row)
}
pub(crate) fn set_wrap_width(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
@@ -228,7 +227,7 @@ impl TextWrapper {
});
}
if self.lines.len() == 0 {
if self.lines.is_empty() {
self.lines = new_lines;
} else {
self.lines.splice(rows_range, new_lines);
@@ -246,7 +245,7 @@ impl TextWrapper {
///
/// If the `text` is the same as the current text, do nothing.
fn update_all(&mut self, text: &Rope, cx: &mut App) {
self.update(text, &(0..text.len()), &text, cx);
self.update(text, &(0..text.len()), text, cx);
}
/// Return display point (with soft wrap) from the given byte offset in the text.
@@ -278,7 +277,8 @@ impl TextWrapper {
// Otherwise return the eof of the line.
let last_range = line.wrapped_lines.last().unwrap_or(&(0..0));
let ix = line.lines_len().saturating_sub(1);
return WrapDisplayPoint::new(wrapped_row + ix, ix, last_range.len());
WrapDisplayPoint::new(wrapped_row + ix, ix, last_range.len())
}
/// Return byte offset in the text from the given display point (with soft wrap).
@@ -301,7 +301,7 @@ impl TextWrapper {
wrapped_row += line.lines_len();
}
return self.text.len();
self.text.len()
}
pub(crate) fn display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint {
@@ -580,351 +580,3 @@ impl LineLayout {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::rc::Rc;
use gpui::{Boundary, FontFeatures, FontStyle, FontWeight, px};
#[test]
fn test_update() {
let font = gpui::Font {
family: "Arial".into(),
weight: FontWeight::default(),
style: FontStyle::Normal,
features: FontFeatures::default(),
fallbacks: None,
};
let mut wrapper = TextWrapper::new(font, px(14.), None);
let mut text = Rope::from(
"Hello, 世界!\r\nThis is second line.\nThis is third line.\n这里是第 4 行。",
);
fn fake_wrap_line(_line: &str, _wrap_width: Pixels) -> Vec<Boundary> {
vec![]
}
#[track_caller]
fn assert_wrapper_lines(text: &Rope, wrapper: &TextWrapper, expected_lines: &[&[&str]]) {
let mut actual_lines = vec![];
let mut offset = 0;
for line in wrapper.lines.iter() {
actual_lines.push(
line.wrapped_lines
.iter()
.map(|range| text.slice(offset + range.start..offset + range.end))
.collect::<Vec<_>>(),
);
// +1 \n
offset += line.len() + 1;
}
assert_eq!(actual_lines, expected_lines);
}
wrapper._update(&text, &(0..text.len()), &text, &mut fake_wrap_line);
assert_eq!(wrapper.lines.len(), 4);
assert_wrapper_lines(
&text,
&wrapper,
&[
&["Hello, 世界!\r"],
&["This is second line."],
&["This is third line."],
&["这里是第 4 行。"],
],
);
// Add a new text to end
let range = text.len()..text.len();
let new_text = "New text";
text.replace(range.clone(), new_text);
wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line);
assert_eq!(
text.to_string(),
"Hello, 世界!\r\nThis is second line.\nThis is third line.\n这里是第 4 行。New text"
);
assert_eq!(wrapper.lines.len(), 4);
assert_eq!(wrapper.lines.len(), 4);
assert_wrapper_lines(
&text,
&wrapper,
&[
&["Hello, 世界!\r"],
&["This is second line."],
&["This is third line."],
&["这里是第 4 行。New text"],
],
);
// Replace first line `Hello` to `AAA`
let range = 0..5;
let new_text = "AAA";
text.replace(range.clone(), new_text);
wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line);
assert_eq!(
text.to_string(),
"AAA, 世界!\r\nThis is second line.\nThis is third line.\n这里是第 4 行。New text"
);
assert_eq!(wrapper.lines.len(), 4);
assert_wrapper_lines(
&text,
&wrapper,
&[
&["AAA, 世界!\r"],
&["This is second line."],
&["This is third line."],
&["这里是第 4 行。New text"],
],
);
// Remove the second line
let start_offset = text.line_start_offset(1);
let end_offset = text.line_end_offset(1);
let range = start_offset..end_offset + 1;
text.replace(range.clone(), "");
wrapper._update(&text, &range, &Rope::from(""), &mut fake_wrap_line);
assert_eq!(
text.to_string(),
"AAA, 世界!\r\nThis is third line.\n这里是第 4 行。New text"
);
assert_eq!(wrapper.lines.len(), 3);
assert_wrapper_lines(
&text,
&wrapper,
&[
&["AAA, 世界!\r"],
&["This is third line."],
&["这里是第 4 行。New text"],
],
);
// Replace the first 2 lines to "This is a new line."
let range = text.line_start_offset(0)..text.line_end_offset(1) + 1;
let new_text = "This is a new line.\nThis is new line 2.\n";
text.replace(range.clone(), new_text);
wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line);
assert_eq!(
text.to_string(),
"This is a new line.\nThis is new line 2.\n这里是第 4 行。New text"
);
assert_eq!(wrapper.lines.len(), 3);
assert_wrapper_lines(
&text,
&wrapper,
&[
&["This is a new line."],
&["This is new line 2."],
&["这里是第 4 行。New text"],
],
);
// Add a new line at the end
let range = text.len()..text.len();
let new_text = "\nThis is a new line at the end.";
text.replace(range.clone(), new_text);
wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line);
assert_eq!(
text.to_string(),
"This is a new line.\nThis is new line 2.\n这里是第 4 行。New text\nThis is a new line at the end."
);
assert_eq!(wrapper.lines.len(), 4);
assert_wrapper_lines(
&text,
&wrapper,
&[
&["This is a new line."],
&["This is new line 2."],
&["这里是第 4 行。New text"],
&["This is a new line at the end."],
],
);
// Add a new line at the beginning
let range = 0..0;
let new_text = "This is a new line at the beginning.\n";
text.replace(range.clone(), new_text);
wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line);
assert_eq!(
text.to_string(),
"This is a new line at the beginning.\nThis is a new line.\nThis is new line 2.\n这里是第 4 行。New text\nThis is a new line at the end."
);
assert_eq!(wrapper.lines.len(), 5);
assert_wrapper_lines(
&text,
&wrapper,
&[
&["This is a new line at the beginning."],
&["This is a new line."],
&["This is new line 2."],
&["这里是第 4 行。New text"],
&["This is a new line at the end."],
],
);
// Remove all to at least one line in `lines`.
let range = 0..text.len();
let new_text = "";
text.replace(range.clone(), new_text);
wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line);
assert_eq!(text.to_string(), "");
assert_eq!(wrapper.lines.len(), 1);
assert_eq!(wrapper.lines[0].wrapped_lines, vec![0..0]);
// Test update_all
let range = 0..text.len();
let new_text = "This is a full text.\nThis is a second line.";
text.replace(range.clone(), new_text);
wrapper._update(&text, &range, &text, &mut fake_wrap_line);
assert_eq!(
text.to_string(),
"This is a full text.\nThis is a second line."
);
assert_eq!(wrapper.lines.len(), 2);
}
#[test]
fn test_line_layout() {
let mut line_layout = LineLayout::new();
let line1 = ShapedLine::default().with_len(100);
let line2 = ShapedLine::default().with_len(50);
let wrapped_lines = smallvec::smallvec![line1, line2];
line_layout.set_wrapped_lines(wrapped_lines);
assert_eq!(line_layout.len(), 150);
assert_eq!(line_layout.wrapped_lines.len(), 2);
}
#[test]
fn test_position_for_index_prefers_first_leading_empty_visual_line() {
let mut line_layout = LineLayout::new();
line_layout.set_wrapped_lines(smallvec::smallvec![
ShapedLine::default(),
ShapedLine::default(),
ShapedLine::default().with_len(3),
]);
let last_layout = LastLayout {
visible_range: 0..1,
visible_buffer_lines: vec![0],
visible_line_byte_offsets: vec![0],
visible_top: px(0.),
visible_range_offset: 0..0,
lines: Rc::new(vec![]),
line_height: px(20.),
wrap_width: None,
line_number_width: px(0.),
cursor_bounds: None,
text_align: TextAlign::Left,
content_width: px(0.),
};
assert_eq!(
line_layout.position_for_index(0, &last_layout, false),
Some(point(px(0.), px(0.)))
);
}
#[test]
fn test_offset_to_display_point() {
let font = gpui::Font {
family: "Arial".into(),
weight: FontWeight::default(),
style: FontStyle::Normal,
features: FontFeatures::default(),
fallbacks: None,
};
let mut wrapper = TextWrapper::new(font, px(14.), None);
wrapper.text = Rope::from(
"Hello, 世界!\r\nThis is second line.\nThis is third line.\n这里是第 4 行。",
);
wrapper.lines = vec![
// range: 0..15
LineItem {
line: Rope::from("Hello, 世界!\r"),
wrapped_lines: vec![0..15],
},
// range: 16..36
LineItem {
line: Rope::from("This is second line."),
wrapped_lines: vec![0..10, 10..20],
},
// range: 37..56
LineItem {
line: Rope::from("This is third line."),
wrapped_lines: vec![0..9, 9..15, 15..20],
},
// range: 57..79
LineItem {
line: Rope::from("这里是第 4 行。"),
wrapped_lines: vec![0..22],
},
];
assert_eq!(
wrapper.offset_to_display_point(12),
WrapDisplayPoint::new(0, 0, 12)
);
assert_eq!(
wrapper.offset_to_display_point(15),
WrapDisplayPoint::new(0, 0, 15)
);
assert_eq!(
wrapper.offset_to_display_point(16),
WrapDisplayPoint::new(1, 0, 0)
);
assert_eq!(
wrapper.offset_to_display_point(21),
WrapDisplayPoint::new(1, 0, 5)
);
assert_eq!(
wrapper.offset_to_display_point(27),
WrapDisplayPoint::new(2, 1, 1)
);
assert_eq!(
wrapper.offset_to_display_point(37),
WrapDisplayPoint::new(3, 0, 0)
);
assert_eq!(
wrapper.offset_to_display_point(54),
WrapDisplayPoint::new(5, 2, 2)
);
assert_eq!(
wrapper.offset_to_display_point(59),
WrapDisplayPoint::new(6, 0, 2)
);
assert_eq!(
wrapper.display_point_to_offset(WrapDisplayPoint::new(6, 0, 2)),
59
);
assert_eq!(
wrapper.display_point_to_offset(WrapDisplayPoint::new(5, 2, 2)),
54
);
assert_eq!(
wrapper.display_point_to_offset(WrapDisplayPoint::new(3, 0, 0)),
37
);
assert_eq!(
wrapper.display_point_to_offset(WrapDisplayPoint::new(2, 1, 1)),
27
);
assert_eq!(
wrapper.display_point_to_offset(WrapDisplayPoint::new(1, 0, 5)),
21
);
assert_eq!(
wrapper.display_point_to_offset(WrapDisplayPoint::new(1, 0, 0)),
16
);
assert_eq!(
wrapper.display_point_to_offset(WrapDisplayPoint::new(0, 0, 15)),
15
);
}
}

View File

@@ -9,10 +9,7 @@ use std::ops::Range;
use gpui::{App, Font, Pixels};
use ropey::Rope;
use super::fold_map::FoldMap;
use super::text_wrapper::{LineItem, TextWrapper, WrapDisplayPoint};
use super::{BufferPoint, WrapPoint};
use crate::input::rope_ext::RopeExt;
use super::text_wrapper::{LineItem, TextWrapper};
/// WrapMap manages soft-wrapping and provides buffer ↔ wrap coordinate mapping.
pub struct WrapMap {
@@ -55,49 +52,6 @@ impl WrapMap {
self.wrapper.lines.len()
}
/// Convert buffer position to wrap position
pub(super) fn buffer_pos_to_wrap_pos(&self, pos: BufferPoint) -> WrapPoint {
let BufferPoint { line, col } = pos;
// Clamp to valid range
let line = line.min(self.buffer_line_count().saturating_sub(1));
let line_item = self.wrapper.lines.get(line);
let col = if let Some(line_item) = line_item {
col.min(line_item.len())
} else {
0
};
// Calculate offset in rope
let line_start_offset = self.wrapper.text().line_start_offset(line);
let offset = line_start_offset + col;
// Use TextWrapper's existing conversion
let display_point = self.wrapper.offset_to_display_point(offset);
WrapPoint::new(display_point.row, display_point.column)
}
/// Convert wrap position to buffer position
pub(super) fn wrap_pos_to_buffer_pos(&self, pos: WrapPoint) -> BufferPoint {
let WrapPoint { row, col } = pos;
// Clamp wrap_row to valid range
let row = row.min(self.wrap_row_count().saturating_sub(1));
// Use TextWrapper's existing conversion
let display_point = WrapDisplayPoint::new(row, 0, col);
let offset = self.wrapper.display_point_to_offset(display_point);
// Convert offset to buffer position
let point = self.wrapper.text().offset_to_point(offset);
let line_start = self.wrapper.text().line_start_offset(point.row);
let col = offset.saturating_sub(line_start);
BufferPoint::new(point.row, col)
}
/// Get the buffer line for a given wrap row
pub fn wrap_row_to_buffer_line(&self, wrap_row: usize) -> usize {
if wrap_row >= self.wrap_row_count() {
@@ -176,8 +130,6 @@ impl WrapMap {
let wrap_row_count = self.wrapper.len();
// Skip if nothing changed: both buffer line count and total wrap row count must match.
// Checking wrap_row_count is essential because soft-wrap can change the number of
// wrap rows per line without changing the buffer line count.
if line_count == self.cached_line_count
&& wrap_row_count == self.cached_wrap_row_count
&& !self.buffer_line_starts.is_empty()
@@ -212,11 +164,9 @@ impl WrapMap {
self.wrapper.text()
}
/// Calculate how many wrap rows of a buffer line are visible (not folded)
pub fn visible_wrap_row_count_for_line(&self, line: usize, fold_map: &FoldMap) -> usize {
let wrap_range = self.buffer_line_to_wrap_row_range(line);
wrap_range
.filter(|&wr| fold_map.wrap_row_to_display_row(wr).is_some())
.count()
/// Calculate how many wrap rows of a buffer line are visible.
/// Without folding, all wrap rows are visible.
pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize {
self.buffer_line_to_wrap_row_range(line).len()
}
}

View File

@@ -3,9 +3,8 @@ use std::rc::Rc;
use gpui::{
AnyElement, App, Bounds, Corners, Edges, Element, ElementId, ElementInputHandler, Entity,
GlobalElementId, Half, HighlightStyle, Hitbox, HitboxBehavior, Hsla, InteractiveElement,
IntoElement, LayoutId, MouseButton, MouseMoveEvent, MouseUpEvent, Path, Pixels, Point,
Position, ShapedLine, SharedString, Size, Style, Styled as _, TextAlign, TextRun, TextStyle,
GlobalElementId, Half, Hsla, IntoElement, LayoutId, MouseButton, MouseMoveEvent, MouseUpEvent,
Path, Pixels, Point, Position, SharedString, Size, Style, TextAlign, TextRun, TextStyle,
UnderlineStyle, Window, fill, point, px, relative, size,
};
use ropey::Rope;
@@ -14,19 +13,14 @@ use theme::ActiveTheme;
use super::mode::InputMode;
use super::{InputState, LastLayout, WhitespaceIndicators};
use crate::button::{Button, ButtonVariants as _};
use crate::Root;
use crate::input::RopeExt as _;
use crate::input::blink_cursor::CURSOR_WIDTH;
use crate::input::display_map::LineLayout;
use crate::scroll::Scrollbar;
use crate::{IconName, Root, Selectable, Sizable as _};
const BOTTOM_MARGIN_ROWS: usize = 3;
pub(super) const RIGHT_MARGIN: Pixels = px(10.);
pub(super) const LINE_NUMBER_RIGHT_MARGIN: Pixels = px(10.);
const FOLD_ICON_WIDTH: Pixels = px(14.);
const FOLD_ICON_HITBOX_WIDTH: Pixels = px(18.);
const MAX_HIGHLIGHT_LINE_LENGTH: usize = 10_000;
#[derive(Clone, Copy, Debug, PartialEq)]
struct EditorScrollbarLayout {
@@ -72,7 +66,7 @@ impl EditorScrollbarLayout {
let left = if line_number_width == px(0.) {
px(0.)
} else {
paddings.left + line_number_width - LINE_NUMBER_RIGHT_MARGIN
paddings.left + line_number_width
};
Self {
@@ -131,11 +125,14 @@ impl Element for EditorScrollbar {
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
style.position = Position::Absolute;
style.size.width = relative(1.).into();
style.size.height = relative(1.).into();
let style = Style {
position: Position::Absolute,
size: Size {
width: relative(1.).into(),
height: relative(1.).into(),
},
..Default::default()
};
(window.request_layout(style, [], cx), ())
}
@@ -213,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,
@@ -282,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;
@@ -304,18 +293,16 @@ 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;
// If the input has a fixed height (Otherwise is auto-grow), we need to add a bottom margin to the input.
let top_bottom_margin = if state.mode.is_auto_grow() {
line_height
} else if visible_range.len() < BOTTOM_MARGIN_ROWS * 8 {
line_height
} else {
BOTTOM_MARGIN_ROWS * line_height
};
let top_bottom_margin =
if state.mode.is_auto_grow() || visible_range.len() < BOTTOM_MARGIN_ROWS * 8 {
line_height
} else {
BOTTOM_MARGIN_ROWS * line_height
};
// The cursor corresponds to the current cursor position in the text no only the line.
let mut cursor_pos = None;
@@ -328,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
@@ -351,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);
}
}
@@ -375,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() {
@@ -492,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.
@@ -640,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,
@@ -782,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)]
@@ -1096,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();
@@ -1110,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 {
@@ -1135,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 {
@@ -1354,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();
@@ -1380,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 {
@@ -1450,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![
@@ -1496,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);
@@ -1508,7 +1090,7 @@ impl Element for TextElement {
&last_layout,
text_size,
&runs,
&document_colors,
&[],
whitespace_indicators,
window,
);
@@ -1538,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 {
@@ -1565,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),
);
@@ -1606,75 +1170,16 @@ impl Element for TextElement {
// Calculate the scroll offset to keep the cursor in view
// Save the unscrolled x before layout_cursor modifies bounds.origin with scroll_offset.
// Fold icons and their hitboxes must use this value so they stay fixed in the gutter
// regardless of horizontal scroll position.
// Save the bounds before layout_cursor modifies bounds.origin with scroll_offset.
let input_bounds = bounds;
let original_x = bounds.origin.x;
let (cursor_bounds, cursor_scroll_offset, current_row) =
let (cursor_bounds, cursor_scroll_offset) =
self.layout_cursor(&last_layout, &mut bounds, scroll_size, window, cx);
last_layout.cursor_bounds = cursor_bounds;
let search_match_paths = self.layout_search_matches(&last_layout, &bounds, cx);
let selection_path = self.layout_selections(&last_layout, &mut bounds, window, cx);
let hover_highlight_path = self.layout_hover_highlight(&last_layout, &bounds, cx);
let document_color_paths =
self.layout_document_colors(&document_colors, &last_layout, &bounds, cx);
let state = self.state.read(cx);
let line_numbers = if state.mode.line_number() {
let mut line_numbers = Vec::with_capacity(last_layout.visible_buffer_lines.len());
let other_line_runs = vec![TextRun {
len: line_number_len,
font: style.font(),
color: cx.theme().text_muted,
background_color: None,
underline: None,
strikethrough: None,
}];
let current_line_runs = vec![TextRun {
len: line_number_len,
font: style.font(),
color: cx.theme().text,
background_color: None,
underline: None,
strikethrough: None,
}];
// build line numbers
for (line, &buffer_line) in last_layout
.lines
.iter()
.zip(last_layout.visible_buffer_lines.iter())
{
let line_no: SharedString =
format!("{:>width$}", buffer_line + 1, width = line_number_len).into();
let runs = if current_row == Some(buffer_line) {
&current_line_runs
} else {
&other_line_runs
};
let mut sub_lines: SmallVec<[ShapedLine; 1]> = SmallVec::new();
sub_lines.push(
window
.text_system()
.shape_line(line_no, text_size, runs, None),
);
for _ in 0..line.wrapped_lines.len().saturating_sub(1) {
sub_lines.push(ShapedLine::default());
}
line_numbers.push(sub_lines);
}
Some(line_numbers)
} else {
None
};
let indent_guides_path =
self.layout_indent_guides(state, &bounds, &last_layout, &text_style, window);
state
.editor_scrollbar_snapshot
@@ -1686,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,
}
}
@@ -1763,56 +1254,15 @@ impl Element for TextElement {
let invisible_top_padding = prepaint.last_layout.visible_top;
// Paint active line
let mut offset_y = px(0.);
if let Some(line_numbers) = prepaint.line_numbers.as_ref() {
offset_y += invisible_top_padding;
// Each item is the normal lines.
for (lines, _) in line_numbers
.iter()
.zip(prepaint.last_layout.visible_buffer_lines.iter())
{
let height = line_height * lines.len() as f32;
offset_y += height;
}
}
// Paint indent guides
if let Some(path) = prepaint.indent_guides_path.take() {
window.paint_path(path, cx.theme().border.opacity(0.85));
}
// Paint selections
if window.is_window_active() {
let secondary_selection = cx.theme().selection;
for (path, is_active) in prepaint.search_match_paths.iter() {
window.paint_path(path.clone(), secondary_selection);
if *is_active {
window.paint_path(path.clone(), cx.theme().selection);
}
}
if let Some(path) = prepaint.selection_path.take() {
window.paint_path(path, cx.theme().selection);
}
// Paint hover highlight
if let Some(path) = prepaint.hover_highlight_path.take() {
window.paint_path(path, secondary_selection);
}
if window.is_window_active()
&& let Some(path) = prepaint.selection_path.take()
{
window.paint_path(path, cx.theme().selection);
}
// Paint document colors
for (path, color) in prepaint.document_color_paths.iter() {
window.paint_path(path.clone(), *color);
}
// Paint text with inline completion ghost line support
// Paint text
let mut offset_y = invisible_top_padding;
let ghost_lines = &prepaint.ghost_lines;
let has_ghost_lines = !ghost_lines.is_empty();
// Keep scrollbar offset always be positiveStart from the left position
let scroll_offset = if text_align == TextAlign::Right {
@@ -1825,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),
@@ -1851,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
@@ -1895,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);
@@ -1951,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);
}
}

View File

@@ -1,12 +1,8 @@
use gpui::{
Bounds, Context, EntityInputHandler as _, Hsla, Path, PathBuilder, Pixels, SharedString,
TextRun, TextStyle, Window, point, px,
};
use gpui::{Context, EntityInputHandler, SharedString, Window};
use ropey::RopeSlice;
use crate::input::element::TextElement;
use crate::input::mode::InputMode;
use crate::input::{Indent, IndentInline, InputState, LastLayout, Outdent, OutdentInline, RopeExt};
use crate::input::{Indent, IndentInline, InputState, Outdent, OutdentInline};
#[derive(Debug, Copy, Clone)]
pub struct TabSize {
@@ -49,165 +45,14 @@ impl TabSize {
}
}
impl InputMode {
#[inline]
pub(super) fn is_indentable(&self) -> bool {
match self {
InputMode::PlainText { multi_line, .. } | InputMode::CodeEditor { multi_line, .. } => {
*multi_line
}
_ => false,
}
}
#[inline]
pub(super) fn has_indent_guides(&self) -> bool {
match self {
InputMode::CodeEditor {
indent_guides,
multi_line,
..
} => *indent_guides && *multi_line,
_ => false,
}
}
#[inline]
pub(super) fn tab_size(&self) -> TabSize {
match self {
InputMode::PlainText { tab, .. } => *tab,
InputMode::CodeEditor { tab, .. } => *tab,
_ => TabSize::default(),
}
}
}
impl TextElement {
/// Measure the indent width in pixels for given column count.
fn measure_indent_width(&self, style: &TextStyle, column: usize, window: &Window) -> Pixels {
let font_size = style.font_size.to_pixels(window.rem_size());
let layout = window.text_system().shape_line(
SharedString::from(" ".repeat(column)),
font_size,
&[TextRun {
len: column,
font: style.font(),
color: Hsla::default(),
background_color: None,
strikethrough: None,
underline: None,
}],
None,
);
layout.width
}
pub(super) fn layout_indent_guides(
&self,
state: &InputState,
bounds: &Bounds<Pixels>,
last_layout: &LastLayout,
text_style: &TextStyle,
window: &mut Window,
) -> Option<Path<Pixels>> {
if !state.mode.has_indent_guides() {
return None;
}
let indent_width =
self.measure_indent_width(text_style, state.mode.tab_size().tab_size, window);
let tab_size = state.mode.tab_size();
let line_height = last_layout.line_height;
let mut builder = PathBuilder::stroke(px(1.));
let mut offset_y = last_layout.visible_top;
let mut last_indents = vec![];
for (&buffer_line, line_layout) in last_layout
.visible_buffer_lines
.iter()
.zip(last_layout.lines.iter())
{
let line = state.text.slice_line(buffer_line);
let mut current_indents = vec![];
if line.len() > 0 {
let indent_count = tab_size.indent_count(&line);
for offset in (0..indent_count).step_by(tab_size.tab_size) {
let x = if indent_count > 0 {
indent_width * offset as f32 / tab_size.tab_size as f32
} else {
px(0.)
};
let pos = point(x + last_layout.line_number_width, offset_y);
builder.move_to(pos);
builder.line_to(point(pos.x, pos.y + line_height));
current_indents.push(pos.x);
}
} else if !last_indents.is_empty() {
for x in &last_indents {
let pos = point(*x, offset_y);
builder.move_to(pos);
builder.line_to(point(pos.x, pos.y + line_height));
}
current_indents = last_indents.clone();
}
offset_y += line_layout.wrapped_lines.len() * line_height;
last_indents = current_indents;
}
builder.translate(bounds.origin);
let path = builder.build().unwrap();
Some(path)
}
}
impl InputState {
/// Set whether to show indent guides in code editor mode, default is true.
///
/// Only for [`InputMode::CodeEditor`] mode.
pub fn indent_guides(mut self, indent_guides: bool) -> Self {
debug_assert!(self.mode.is_code_editor() && self.mode.is_multi_line());
if let InputMode::CodeEditor {
indent_guides: l, ..
} = &mut self.mode
{
*l = indent_guides;
}
self
}
/// Set indent guides in code editor mode.
///
/// Only for [`InputMode::CodeEditor`] mode.
pub fn set_indent_guides(
&mut self,
indent_guides: bool,
_: &mut Window,
cx: &mut Context<Self>,
) {
debug_assert!(self.mode.is_code_editor());
if let InputMode::CodeEditor {
indent_guides: l, ..
} = &mut self.mode
{
*l = indent_guides;
}
cx.notify();
}
/// Set the tab size for the input.
///
/// Only for [`InputMode::PlainText`] and [`InputMode::CodeEditor`] mode with multi_line.
/// Only for [`InputMode::PlainText`] mode with multi_line.
pub fn tab_size(mut self, tab: TabSize) -> Self {
debug_assert!(self.mode.is_multi_line() || self.mode.is_code_editor());
match &mut self.mode {
InputMode::PlainText { tab: t, .. } => *t = tab,
InputMode::CodeEditor { tab: t, .. } => *t = tab,
_ => {}
debug_assert!(self.mode.is_multi_line());
if let InputMode::PlainText { tab: t, .. } = &mut self.mode {
*t = tab;
}
self
}

View File

@@ -18,7 +18,7 @@ pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) {
if disabled {
(cx.theme().surface_background, cx.theme().text_muted)
} else {
(cx.theme().surface_background, cx.theme().text)
(cx.theme().elevated_surface_background, cx.theme().text)
}
}
@@ -234,12 +234,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

View File

@@ -18,9 +18,7 @@ mod state;
pub(crate) use clear_button::*;
pub use cursor::*;
#[cfg(target_family = "wasm")]
pub use display_map::folding::Tree;
pub use display_map::{BufferPoint, DisplayMap, DisplayPoint, FoldRange};
pub use display_map::DisplayMap;
pub use indent::TabSize;
pub use input::*;
pub use mask_pattern::MaskPattern;

View File

@@ -1,26 +1,11 @@
use std::cell::RefCell;
use std::rc::Rc;
use gpui::{SharedString, Task};
use ropey::Rope;
use super::display_map::DisplayMap;
use crate::input::TabSize;
#[allow(dead_code)]
pub(super) struct PendingBackgroundParse {
pub parse_task: Rc<RefCell<Option<Task<()>>>>,
pub language: SharedString,
pub text: Rope,
pub is_folding: bool,
}
#[derive(Clone)]
pub(crate) enum InputMode {
/// A plain text input mode.
PlainText {
multi_line: bool,
tab: TabSize,
tab: crate::input::indent::TabSize,
rows: usize,
},
/// An auto grow input mode.
@@ -29,18 +14,6 @@ pub(crate) enum InputMode {
min_rows: usize,
max_rows: usize,
},
/// A code editor input mode.
CodeEditor {
multi_line: bool,
tab: TabSize,
rows: usize,
/// Show line number
line_number: bool,
language: SharedString,
indent_guides: bool,
folding: bool,
parse_task: Rc<RefCell<Option<Task<()>>>>,
},
}
impl Default for InputMode {
@@ -55,25 +28,11 @@ impl InputMode {
pub(super) fn plain_text() -> Self {
InputMode::PlainText {
multi_line: false,
tab: TabSize::default(),
tab: crate::input::indent::TabSize::default(),
rows: 1,
}
}
/// Create a code editor input mode with default settings.
pub(super) fn code_editor(language: impl Into<SharedString>) -> Self {
InputMode::CodeEditor {
rows: 2,
multi_line: true,
tab: TabSize::default(),
language: language.into(),
line_number: true,
indent_guides: true,
folding: true,
parse_task: Rc::new(RefCell::new(None)),
}
}
/// Create an auto grow input mode with given min and max rows.
pub(super) fn auto_grow(min_rows: usize, max_rows: usize) -> Self {
InputMode::AutoGrow {
@@ -86,7 +45,6 @@ impl InputMode {
pub(super) fn multi_line(mut self, multi_line: bool) -> Self {
match &mut self {
InputMode::PlainText { multi_line: ml, .. } => *ml = multi_line,
InputMode::CodeEditor { multi_line: ml, .. } => *ml = multi_line,
InputMode::AutoGrow { .. } => {}
}
self
@@ -97,28 +55,6 @@ impl InputMode {
!self.is_multi_line()
}
#[inline]
pub(super) fn is_code_editor(&self) -> bool {
matches!(self, InputMode::CodeEditor { .. })
}
/// Return true if the mode is code editor and `folding: true`, `multi_line: true`.
#[inline]
pub(crate) fn is_folding(&self) -> bool {
if cfg!(target_family = "wasm") {
return false;
}
matches!(
self,
InputMode::CodeEditor {
folding: true,
multi_line: true,
..
}
)
}
#[inline]
pub(super) fn is_auto_grow(&self) -> bool {
matches!(self, InputMode::AutoGrow { .. })
@@ -128,7 +64,6 @@ impl InputMode {
pub(super) fn is_multi_line(&self) -> bool {
match self {
InputMode::PlainText { multi_line, .. } => *multi_line,
InputMode::CodeEditor { multi_line, .. } => *multi_line,
InputMode::AutoGrow { max_rows, .. } => *max_rows > 1,
}
}
@@ -138,9 +73,6 @@ impl InputMode {
InputMode::PlainText { rows, .. } => {
*rows = new_rows;
}
InputMode::CodeEditor { rows, .. } => {
*rows = new_rows;
}
InputMode::AutoGrow {
rows,
min_rows,
@@ -168,7 +100,6 @@ impl InputMode {
match self {
InputMode::PlainText { rows, .. } => *rows,
InputMode::CodeEditor { rows, .. } => *rows,
InputMode::AutoGrow { rows, .. } => *rows,
}
.max(1)
@@ -196,16 +127,19 @@ impl InputMode {
}
}
/// Return false if the mode is not [`InputMode::CodeEditor`].
#[inline]
pub(super) fn line_number(&self) -> bool {
pub(super) fn is_indentable(&self) -> bool {
match self {
InputMode::CodeEditor {
line_number,
multi_line,
..
} => *line_number && *multi_line,
InputMode::PlainText { multi_line, .. } => *multi_line,
_ => false,
}
}
#[inline]
pub(super) fn tab_size(&self) -> crate::input::indent::TabSize {
match self {
InputMode::PlainText { tab, .. } => *tab,
_ => crate::input::indent::TabSize::default(),
}
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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);
});
}
})
}
}

View File

@@ -1,41 +0,0 @@
mod code_action_menu;
mod completion_menu;
mod context_menu;
mod diagnostic_popover;
mod hover_popover;
pub(crate) use code_action_menu::*;
pub(crate) use completion_menu::*;
pub(crate) use context_menu::*;
pub(crate) use diagnostic_popover::*;
use gpui::{
App, Div, ElementId, Entity, InteractiveElement as _, IntoElement, SharedString, Stateful,
StyleRefinement, Styled as _, Window, div, px, rems,
};
pub(crate) use hover_popover::*;
use crate::StyledExt as _;
pub(crate) enum ContextMenu {
Completion(Entity<CompletionMenu>),
CodeAction(Entity<CodeActionMenu>),
RightClick(Entity<InputContextMenu>),
}
impl ContextMenu {
pub(crate) fn is_open(&self, cx: &App) -> bool {
match self {
ContextMenu::Completion(menu) => menu.read(cx).is_open(),
ContextMenu::CodeAction(menu) => menu.read(cx).is_open(),
ContextMenu::RightClick(menu) => menu.read(cx).is_open(),
}
}
pub(crate) fn render(&self) -> impl IntoElement {
match self {
ContextMenu::Completion(menu) => menu.clone().into_any_element(),
ContextMenu::CodeAction(menu) => menu.clone().into_any_element(),
ContextMenu::RightClick(menu) => menu.clone().into_any_element(),
}
}
}

View File

@@ -99,9 +99,6 @@ actions!(
MoveToPreviousWord,
MoveToNextWord,
Escape,
ToggleCodeActions,
Search,
GoToDefinition,
]
);
@@ -250,14 +247,6 @@ pub(crate) fn init(cx: &mut App) {
KeyBinding::new("ctrl-z", Undo, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-y", Redo, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-.", ToggleCodeActions, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-.", ToggleCodeActions, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-f", Search, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-f", Search, Some(CONTEXT)),
]);
}
@@ -355,7 +344,6 @@ pub struct InputState {
pub(super) clean_on_escape: bool,
pub(super) submit_on_enter: bool,
pub(super) soft_wrap: bool,
pub(super) show_whitespaces: bool,
/// This flag tells the renderer to prefer the end of the current visual line.
pub(crate) cursor_line_end_affinity: bool,
pub(super) pattern: Option<regex::Regex>,
@@ -395,7 +383,7 @@ impl InputState {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle().tab_stop(true);
let blink_cursor = cx.new(|_| BlinkCursor::new());
let history = History::new().group_interval(std::time::Duration::from_secs(1));
let history = History::new().group_interval(instant::Duration::from_secs(1));
let _subscriptions = vec![
// Observe the blink cursor to repaint the view when it changes.
@@ -435,7 +423,6 @@ impl InputState {
clean_on_escape: false,
submit_on_enter: false,
soft_wrap: true,
show_whitespaces: false,
loading: false,
pattern: None,
validate: None,
@@ -480,33 +467,6 @@ impl InputState {
self
}
/// Set Input to use [`InputMode::CodeEditor`] mode.
///
/// Default options:
///
/// - line_number: true
/// - tab_size: 2
/// - hard_tabs: false
/// - height: 100%
/// - multi_line: true
/// - indent_guides: true
///
/// If `highlighter` is None, will use the default highlighter.
///
/// Code Editor aim for help used to simple code editing or display, not a full-featured code editor.
///
/// ## Features
///
/// - Syntax Highlighting
/// - Auto Indent
/// - Line Number
/// - Large Text support, up to 50K lines.
pub fn code_editor(mut self, language: impl Into<SharedString>) -> Self {
let language: SharedString = language.into();
self.mode = InputMode::code_editor(language);
self
}
/// Set whether search UI allows replacement, default is true.
pub fn replaceable(mut self, allow: bool) -> Self {
self.replaceable = allow;
@@ -519,49 +479,6 @@ impl InputState {
self
}
/// Set enable/disable code folding, only for [`InputMode::CodeEditor`] mode.
///
/// Default: true
pub fn folding(mut self, folding: bool) -> Self {
debug_assert!(self.mode.is_code_editor());
if let InputMode::CodeEditor { folding: f, .. } = &mut self.mode {
*f = folding;
}
self
}
/// Set code folding at runtime, only for [`InputMode::CodeEditor`] mode.
///
/// When disabling, all existing folds are cleared.
pub fn set_folding(&mut self, folding: bool, _: &mut Window, cx: &mut Context<Self>) {
debug_assert!(self.mode.is_code_editor());
if let InputMode::CodeEditor { folding: f, .. } = &mut self.mode {
*f = folding;
}
if !folding {
self.display_map.clear_folds();
}
cx.notify();
}
/// Set enable/disable line number, only for [`InputMode::CodeEditor`] mode.
pub fn line_number(mut self, line_number: bool) -> Self {
debug_assert!(self.mode.is_code_editor() && self.mode.is_multi_line());
if let InputMode::CodeEditor { line_number: l, .. } = &mut self.mode {
*l = line_number;
}
self
}
/// Set line number, only for [`InputMode::CodeEditor`] mode.
pub fn set_line_number(&mut self, line_number: bool, _: &mut Window, cx: &mut Context<Self>) {
debug_assert!(self.mode.is_code_editor() && self.mode.is_multi_line());
if let InputMode::CodeEditor { line_number: l, .. } = &mut self.mode {
*l = line_number;
}
cx.notify();
}
/// Set the number of rows for the multi-line Textarea.
///
/// This is only used when `multi_line` is set to true.
@@ -569,9 +486,7 @@ impl InputState {
/// default: 2
pub fn rows(mut self, rows: usize) -> Self {
match &mut self.mode {
InputMode::PlainText { rows: r, .. } | InputMode::CodeEditor { rows: r, .. } => {
*r = rows
}
InputMode::PlainText { rows: r, .. } => *r = rows,
InputMode::AutoGrow {
max_rows: max_r,
rows: r,
@@ -584,31 +499,6 @@ impl InputState {
self
}
/// Set highlighter language for for [`InputMode::CodeEditor`] mode.
pub fn set_highlighter(
&mut self,
new_language: impl Into<SharedString>,
cx: &mut Context<Self>,
) {
if let InputMode::CodeEditor {
language,
parse_task,
..
} = &mut self.mode
{
*language = new_language.into();
parse_task.borrow_mut().take();
}
cx.notify();
}
fn reset_highlighter(&mut self, cx: &mut Context<Self>) {
if let InputMode::CodeEditor { parse_task, .. } = &mut self.mode {
parse_task.borrow_mut().take();
}
cx.notify();
}
/// Set placeholder
pub fn set_placeholder(
&mut self,
@@ -726,7 +616,6 @@ impl InputState {
let text: SharedString = text.into();
let range = 0..self.text.chars().map(|c| c.len_utf16()).sum();
self.replace_text_in_range_silent(Some(range), &text, window, cx);
self.reset_highlighter(cx);
self.disabled = was_disabled;
}
@@ -779,12 +668,6 @@ impl InputState {
self
}
/// Set whether to show whitespace characters.
pub fn show_whitespaces(mut self, show: bool) -> Self {
self.show_whitespaces = show;
self
}
/// Update the soft wrap mode for multi-line input, default is true.
pub fn set_soft_wrap(&mut self, wrap: bool, _: &mut Window, cx: &mut Context<Self>) {
debug_assert!(self.mode.is_multi_line());
@@ -808,12 +691,6 @@ impl InputState {
cx.notify();
}
/// Update whether to show whitespace characters.
pub fn set_show_whitespaces(&mut self, show: bool, _: &mut Window, cx: &mut Context<Self>) {
self.show_whitespaces = show;
cx.notify();
}
/// Set the regular expression pattern of the input field.
///
/// Only for [`InputMode::SingleLine`] mode.
@@ -1038,7 +915,7 @@ impl InputState {
let row = self.text.offset_to_point(self.cursor()).row;
let logical_start = self.text.line_start_offset(row);
if self.soft_wrap && self.mode.is_code_editor() {
if self.soft_wrap {
let wrap_point = self.display_map.offset_to_wrap_display_point(self.cursor());
if let Some(line) = self.display_map.lines().get(row)
&& let Some(range) = line.wrapped_lines.get(wrap_point.local_row)
@@ -1066,7 +943,7 @@ impl InputState {
let logical_start = self.text.line_start_offset(row);
let logical_end = self.text.line_end_offset(row);
if self.soft_wrap && self.mode.is_code_editor() {
if self.soft_wrap {
let wrap_point = self.display_map.offset_to_wrap_display_point(self.cursor());
if let Some(line) = self.display_map.lines().get(row)
&& let Some(range) = line.wrapped_lines.get(wrap_point.local_row)
@@ -1264,7 +1141,7 @@ impl InputState {
if insert_newline {
// Get current line indent
let indent = if self.mode.is_code_editor() {
let indent = if self.mode.is_indentable() {
self.indent_of_next_line()
} else {
"".to_string()
@@ -1465,11 +1342,7 @@ impl InputState {
// Check if row_offset_y is out of the viewport
// If row offset is not in the viewport, scroll to make it visible
let edge_height = if direction.is_some() && self.mode.is_code_editor() {
3 * line_height
} else {
line_height
};
let edge_height = line_height;
if row_offset_y - edge_height + line_height < -scroll_offset.y {
// Scroll up
scroll_offset.y = -row_offset_y + edge_height - line_height;
@@ -1749,34 +1622,6 @@ impl InputState {
self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end)
}
/// If offset falls on a hidden (folded) line, clamp backward to the end of
/// the fold header line (last visible position before the fold).
fn clamp_offset_to_visible_backward(&self, offset: usize) -> usize {
let line = self.text.offset_to_point(offset).row;
if self.display_map.is_buffer_line_hidden(line) {
for fold in self.display_map.folded_ranges() {
if line > fold.start_line && line <= fold.end_line {
return self.text.line_end_offset(fold.start_line);
}
}
}
offset
}
/// If offset falls on a hidden (folded) line, clamp forward to the start of
/// the fold end line (first visible position after the fold).
fn clamp_offset_to_visible_forward(&self, offset: usize) -> usize {
let line = self.text.offset_to_point(offset).row;
if self.display_map.is_buffer_line_hidden(line) {
for fold in self.display_map.folded_ranges() {
if line > fold.start_line && line <= fold.end_line {
return self.text.line_start_offset(fold.end_line);
}
}
}
offset
}
pub(super) fn previous_boundary(&self, offset: usize) -> usize {
let mut offset = self.text.clip_offset(offset.saturating_sub(1), Bias::Left);
if let Some(ch) = self.text.char_at(offset)
@@ -1785,7 +1630,7 @@ impl InputState {
offset -= 1;
}
self.clamp_offset_to_visible_backward(offset)
offset
}
pub(super) fn next_boundary(&self, offset: usize) -> usize {
@@ -1796,7 +1641,7 @@ impl InputState {
offset += 1;
}
self.clamp_offset_to_visible_forward(offset)
offset
}
/// Returns the true to let InputElement to render cursor, when Input is focused and current BlinkCursor is visible.

View File

@@ -6,6 +6,7 @@ pub use index_path::IndexPath;
pub use kbd::*;
pub use root::{Root, window_paddings};
pub use styled::*;
pub use title_bar::*;
pub use window_ext::*;
pub use crate::Disableable;
@@ -41,6 +42,7 @@ mod index_path;
mod kbd;
mod root;
mod styled;
mod title_bar;
mod window_ext;
/// Initialize the UI module.

View File

@@ -1,5 +1,4 @@
use std::ops::Range;
use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -9,7 +8,7 @@ use gpui::{
SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task,
UniformListScrollHandle, Window, div, px, size, uniform_list,
};
use smol::Timer;
use instant::Duration;
use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
@@ -265,6 +264,7 @@ where
}
self.set_searching(true, window, cx);
let search = self.delegate.perform_search(&text, window, cx);
if self.rows_cache.len() > 0 {
@@ -273,6 +273,7 @@ where
self._set_selected_index(None, window, cx);
}
let executor = cx.background_executor().clone();
self._search_task = cx.spawn_in(window, async move |this, window| {
search.await;
@@ -282,7 +283,8 @@ where
});
// Always wait 100ms to avoid flicker
Timer::after(Duration::from_millis(100)).await;
executor.timer(Duration::from_millis(100)).await;
_ = this.update_in(window, |this, window, cx| {
this.set_searching(false, window, cx);
});

View File

@@ -1,5 +1,5 @@
use std::rc::Rc;
use std::time::Duration;
use instant::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{

View File

@@ -1,7 +1,7 @@
use std::any::TypeId;
use std::collections::{HashMap, VecDeque};
use std::rc::Rc;
use std::time::Duration;
use instant::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -298,10 +298,10 @@ impl Render for Notification {
let action = self.action_builder.clone().map(|builder| {
builder(self, window, cx)
.xsmall()
.small()
.primary()
.px_3()
.font_semibold()
.px_4()
.font_medium()
});
let icon = match self.kind {
@@ -364,8 +364,14 @@ impl Render for Notification {
})
.when_some(content, |this, content| this.child(content))
.when_some(action, |this, action| {
this.gap_2()
.child(h_flex().w_full().flex_1().justify_end().child(action))
this.gap_2().child(
h_flex()
.mt_2()
.w_full()
.flex_1()
.justify_end()
.child(action),
)
}),
)
.child(

View File

@@ -2,7 +2,6 @@ use std::cell::Cell;
use std::ops::Deref;
use std::panic::Location;
use std::rc::Rc;
use std::time::{Duration, Instant};
use gpui::{
Anchor, App, Axis, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element, ElementId,
@@ -11,6 +10,7 @@ use gpui::{
Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill,
point, px, relative, size,
};
use instant::{Duration, Instant};
use theme::{ActiveTheme, AxisExt, ScrollbarMode};
/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH)
@@ -157,7 +157,7 @@ impl ScrollbarStateInner {
let mut state = *self;
state.hovered_axis = axis;
if axis.is_some() {
state.last_scroll_time = Some(std::time::Instant::now());
state.last_scroll_time = Some(instant::Instant::now());
}
state
}
@@ -166,7 +166,7 @@ impl ScrollbarStateInner {
let mut state = *self;
state.hovered_on_thumb = axis;
if self.is_scrollbar_visible() && axis.is_some() {
state.last_scroll_time = Some(std::time::Instant::now());
state.last_scroll_time = Some(instant::Instant::now());
}
state
}

View File

@@ -1,4 +1,4 @@
use std::time::Duration;
use instant::Duration;
use gpui::{
bounce, div, ease_in_out, Animation, AnimationExt, IntoElement, RenderOnce, StyleRefinement,

View File

@@ -1,6 +1,6 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
use instant::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{

351
crates/ui/src/title_bar.rs Normal file
View File

@@ -0,0 +1,351 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, Context, Decorations, Hsla, InteractiveElement, IntoElement,
MouseButton, ParentElement, Pixels, Render, RenderOnce, StatefulInteractiveElement as _,
StyleRefinement, Styled, TitlebarOptions, Window, WindowControlArea, div, px,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use crate::{Icon, IconName, InteractiveElementExt as _, Sizable as _, StyledExt, h_flex};
pub const TITLE_BAR_HEIGHT: Pixels = px(34.);
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
/// TitleBar used to customize the appearance of the title bar.
///
/// We can put some elements inside the title bar.
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
pub struct TitleBar {
style: StyleRefinement,
children: SmallVec<[AnyElement; 1]>,
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
}
impl TitleBar {
/// Create a new TitleBar.
pub fn new() -> Self {
Self {
style: StyleRefinement::default(),
children: SmallVec::new(),
on_close_window: None,
}
}
/// Returns the default title bar options for compatible with the [`crate::TitleBar`].
pub fn title_bar_options() -> TitlebarOptions {
TitlebarOptions {
title: None,
appears_transparent: true,
traffic_light_position: Some(gpui::point(px(9.0), px(9.0))),
}
}
/// Add custom for close window event, default is None, then click X button will call `window.remove_window()`.
/// Linux only, this will do nothing on other platforms.
pub fn on_close_window(
mut self,
f: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
if cfg!(target_os = "linux") {
self.on_close_window = Some(Rc::new(Box::new(f)));
}
self
}
}
impl Default for TitleBar {
fn default() -> Self {
Self::new()
}
}
// The Windows control buttons have a fixed width of 35px.
//
// We don't need implementation the click event for the control buttons.
// If user clicked in the bounds, the window event will be triggered.
#[derive(IntoElement, Clone)]
#[allow(clippy::type_complexity)]
enum ControlIcon {
Minimize,
Restore,
Maximize,
Close {
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
},
}
impl ControlIcon {
fn minimize() -> Self {
Self::Minimize
}
fn restore() -> Self {
Self::Restore
}
fn maximize() -> Self {
Self::Maximize
}
#[allow(clippy::type_complexity)]
fn close(on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>) -> Self {
Self::Close { on_close_window }
}
fn id(&self) -> &'static str {
match self {
Self::Minimize => "minimize",
Self::Restore => "restore",
Self::Maximize => "maximize",
Self::Close { .. } => "close",
}
}
fn icon(&self) -> IconName {
match self {
Self::Minimize => IconName::WindowMinimize,
Self::Restore => IconName::WindowRestore,
Self::Maximize => IconName::WindowMaximize,
Self::Close { .. } => IconName::WindowClose,
}
}
fn window_control_area(&self) -> WindowControlArea {
match self {
Self::Minimize => WindowControlArea::Min,
Self::Restore | Self::Maximize => WindowControlArea::Max,
Self::Close { .. } => WindowControlArea::Close,
}
}
fn is_close(&self) -> bool {
matches!(self, Self::Close { .. })
}
#[inline]
fn hover_fg(&self, cx: &App) -> Hsla {
if self.is_close() {
cx.theme().danger_foreground
} else {
cx.theme().text
}
}
#[inline]
fn hover_bg(&self, cx: &App) -> Hsla {
if self.is_close() {
cx.theme().danger_background
} else {
cx.theme().ghost_element_hover
}
}
#[inline]
fn active_bg(&self, cx: &mut App) -> Hsla {
if self.is_close() {
cx.theme().danger_active
} else {
cx.theme().ghost_element_active
}
}
}
impl RenderOnce for ControlIcon {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let is_linux = cfg!(target_os = "linux");
let is_windows = cfg!(target_os = "windows");
let icon = self.clone();
let hover_fg = self.hover_fg(cx);
let hover_bg = self.hover_bg(cx);
let active_bg = self.active_bg(cx);
let on_close_window = match &self {
ControlIcon::Close { on_close_window } => on_close_window.clone(),
_ => None,
};
div()
.id(self.id())
.flex()
.w(TITLE_BAR_HEIGHT)
.h_full()
.flex_shrink_0()
.justify_center()
.content_center()
.items_center()
.text_color(cx.theme().text)
.hover(|style| style.bg(hover_bg).text_color(hover_fg))
.active(|style| style.bg(active_bg).text_color(hover_fg))
.when(is_windows, |this| {
this.window_control_area(self.window_control_area())
})
.when(is_linux, |this| {
this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.on_click(move |_, window, cx| {
cx.stop_propagation();
match icon {
Self::Minimize => window.minimize_window(),
Self::Restore | Self::Maximize => window.zoom_window(),
Self::Close { .. } => {
if let Some(f) = on_close_window.clone() {
f(&ClickEvent::default(), window, cx);
} else {
window.remove_window();
}
}
}
})
})
.child(Icon::new(self.icon()).small())
}
}
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
struct WindowControls {
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
}
impl RenderOnce for WindowControls {
fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
if cfg!(target_os = "macos") || cfg!(target_family = "wasm") {
return div().id("window-controls");
}
h_flex()
.id("window-controls")
.items_center()
.flex_shrink_0()
.h_full()
.child(ControlIcon::minimize())
.child(if window.is_maximized() {
ControlIcon::restore()
} else {
ControlIcon::maximize()
})
.child(ControlIcon::close(self.on_close_window))
}
}
impl Styled for TitleBar {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl ParentElement for TitleBar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
struct TitleBarState {
should_move: bool,
}
impl Render for TitleBarState {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
}
}
impl RenderOnce for TitleBar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let is_client_decorated = matches!(window.window_decorations(), Decorations::Client { .. });
let is_web = cfg!(target_family = "wasm");
let is_linux = cfg!(target_os = "linux");
let is_macos = cfg!(target_os = "macos");
let state = window.use_state(cx, |_, _| TitleBarState { should_move: false });
div().flex_shrink_0().child(
div()
.id("title-bar")
.flex()
.flex_row()
.items_center()
.justify_between()
.h(TITLE_BAR_HEIGHT)
.map(|this| {
if window.is_fullscreen() {
this.px_2()
} else if cx.theme().platform.is_mac() {
this.pr_2().pl(px(TRAFFIC_LIGHT_PADDING))
} else {
this.px_2()
}
})
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().title_bar)
.refine_style(&self.style)
.when(is_linux, |this| {
this.on_double_click(|_, window, _| window.zoom_window())
})
.when(is_macos, |this| {
this.on_double_click(|_, window, _| window.titlebar_double_click())
})
.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}))
.on_mouse_down(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = true;
}),
)
.on_mouse_up(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}),
)
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
if state.should_move {
state.should_move = false;
window.start_window_move();
}
}))
.child(
h_flex()
.id("bar")
.h_full()
.justify_between()
.flex_shrink_0()
.flex_1()
.when(!is_web, |this| {
this.window_control_area(WindowControlArea::Drag)
.when(window.is_fullscreen(), |this| this.pl_3())
.when(is_linux && is_client_decorated, |this| {
this.child(
div()
.top_0()
.left_0()
.absolute()
.size_full()
.h_full()
.on_mouse_down(
MouseButton::Right,
move |ev, window, _| {
window.show_window_menu(ev.position)
},
),
)
})
})
.children(self.children),
)
.child(WindowControls {
on_close_window: self.on_close_window,
}),
)
}
}

View File

@@ -1,21 +1,26 @@
[package]
name = "relay_auth"
name = "workspace"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
state = { path = "../state" }
settings = { path = "../settings" }
common = { path = "../common" }
theme = { path = "../theme" }
ui = { path = "../ui" }
theme = { path = "../theme" }
common = { path = "../common" }
state = { path = "../state" }
device = { path = "../device" }
chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
settings = { path = "../settings" }
person = { path = "../person" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
nostr-connect.workspace = true
anyhow.workspace = true
smallvec.workspace = true
smol.workspace = true
flume.workspace = true
serde.workspace = true
log.workspace = true
smallvec.workspace = true

View File

@@ -1,4 +1,4 @@
use std::time::Duration;
use instant::Duration;
use anyhow::{Error, anyhow};
use gpui::prelude::FluentBuilder;
@@ -7,15 +7,14 @@ use gpui::{
Subscription, Task, Window, div,
};
use nostr_connect::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::{CoopAuthUrlHandler, NostrRegistry, StateEvent};
use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{Input, InputEvent, InputState};
use ui::{Disableable, v_flex};
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
#[derive(Debug)]
pub struct ImportKey {
pub struct ImportIdentity {
/// Secret key input
key_input: Entity<InputState>,
@@ -25,130 +24,76 @@ pub struct ImportKey {
/// Error message
error: Entity<Option<SharedString>>,
/// Countdown timer for nostr connect
countdown: Entity<Option<u64>>,
/// Whether the user is currently loading
loading: bool,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
/// Input subscription
_subscription: Option<Subscription>,
}
impl ImportKey {
impl ImportIdentity {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let key_input = cx.new(|cx| InputState::new(window, cx));
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
let countdown = cx.new(|_| None);
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to key input events and process login when the user presses enter
let input_subscription =
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.login(window, cx);
};
}),
);
subscriptions.push(
// Subscribe to the nostr signer event
cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
if let StateEvent::Error(e) = event {
this.set_error(e, cx);
}
}),
);
});
Self {
key_input,
pass_input,
error,
countdown,
loading: false,
tasks: vec![],
_subscriptions: subscriptions,
_subscription: Some(input_subscription),
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.loading {
return;
};
// Prevent duplicate login requests
self.set_loading(true, cx);
let value = self.key_input.read(cx).value();
let password = self.pass_input.read(cx).value();
if value.starts_with("bunker://") {
self.bunker(&value, window, cx);
return;
}
// Set loading state
self.set_loading(true, cx);
if value.starts_with("ncryptsec1") {
self.ncryptsec(value, password, window, cx);
return;
}
if value.starts_with("bunker://") {
match NostrConnectUri::parse(value) {
Ok(uri) => {
self.bunker(uri, window, cx);
}
Err(e) => {
self.set_error(e.to_string(), cx);
}
}
return;
}
if let Ok(secret) = SecretKey::parse(&value) {
let keys = Keys::new(secret);
let nostr = NostrRegistry::global(cx);
// Update the signer
nostr.update(cx, |this, cx| {
this.add_key_signer(&keys, cx);
this.set_signer(keys, cx);
});
} else {
self.set_error("Invalid key", cx);
}
}
fn bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
let Ok(uri) = NostrConnectUri::parse(content) else {
self.set_error("Bunker is not valid", cx);
return;
};
let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).keys();
let timeout = Duration::from_secs(30);
// Construct the nostr connect signer
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// Set signer in the background
nostr.update(cx, |this, cx| {
this.add_nip46_signer(&signer, cx);
});
// Start countdown
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
for i in (0..=30).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);
})?;
} else {
this.update(cx, |this, cx| {
this.set_countdown(Some(i), cx);
})?;
}
cx.background_executor().timer(Duration::from_secs(1)).await;
}
Ok(())
}));
}
fn ncryptsec<S>(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context<Self>)
where
S: Into<String>,
@@ -179,9 +124,10 @@ impl ImportKey {
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(keys) => {
nostr.update(cx, |this, cx| {
this.add_key_signer(&keys, cx);
});
nostr.update_in(cx, |this, window, cx| {
this.set_signer(keys, cx);
window.close_modal(cx);
})?;
}
Err(e) => {
this.update(cx, |this, cx| {
@@ -194,16 +140,43 @@ impl ImportKey {
}));
}
fn bunker(&mut self, uri: NostrConnectUri, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let master_keys = nostr.read(cx).get_master_key(cx);
let password = uri.to_string();
let save = cx.write_credentials(USER_KEYRING, "bunker", password.as_bytes());
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
let keys = master_keys.await;
let timeout = Duration::from_secs(30);
// Construct the nostr connect signer
let mut signer = NostrConnect::new(uri, keys, timeout, None)?;
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
nostr.update(cx, |this, cx| {
this.set_signer(signer, cx);
cx.background_spawn(async move { save.await.ok() }).detach();
cx.notify();
});
Ok(())
}));
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
// Reset the log in state
self.set_loading(false, cx);
// Reset the countdown
self.set_countdown(None, cx);
// Update error message
self.error.update(cx, |this, cx| {
*this = Some(message.into());
@@ -224,47 +197,44 @@ impl ImportKey {
Ok(())
}));
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
}
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
self.countdown.update(cx, |this, cx| {
*this = i;
cx.notify();
});
}
}
impl Render for ImportKey {
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.";
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;
v_flex()
.size_full()
.gap_2()
.text_sm()
.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("nsec or bunker://")
.child("Continue with existing key or bunker connection")
.child(Input::new(&self.key_input)),
)
.when(
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|this| {
this.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child("Password:")
.child(Input::new(&self.pass_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(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")
@@ -275,18 +245,6 @@ impl Render for ImportKey {
this.login(window, cx);
})),
)
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(SharedString::from(format!(
"Approve connection request from your signer in {} seconds",
i
))),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()

View File

@@ -1,5 +1,3 @@
pub mod accounts;
pub mod connect;
pub mod import;
pub mod restore;
pub mod screening;

View File

@@ -1,4 +1,4 @@
use std::time::Duration;
use instant::Duration;
use anyhow::Error;
use device::DeviceRegistry;
@@ -7,7 +7,7 @@ use gpui::{
AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window, div,
};
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{Input, InputEvent, InputState};

View File

@@ -1,13 +1,13 @@
use std::collections::HashMap;
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error};
use anyhow::Error;
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
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};
@@ -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);
@@ -78,12 +78,13 @@ impl Screening {
let client = nostr.read(cx).client();
let public_key = self.public_key;
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let signer_pubkey = signer.get_public_key().await?;
let Some(current_user) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
// Check if user is in contact list
let contacts = client.database().contacts_public_keys(signer_pubkey).await;
let contacts = client.database().contacts_public_keys(current_user).await;
let followed = contacts.unwrap_or_default().contains(&public_key);
Ok(followed)
@@ -105,16 +106,17 @@ impl Screening {
let client = nostr.read(cx).client();
let public_key = self.public_key;
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let signer_pubkey = signer.get_public_key().await?;
let Some(current_user) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
// Check mutual contacts
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
let mut mutual_contacts = vec![];
if let Ok(events) = client.database().query(filter).await {
for event in events.into_iter().filter(|ev| ev.pubkey != signer_pubkey) {
for event in events.into_iter().filter(|ev| ev.pubkey != current_user) {
mutual_contacts.push(event.pubkey);
}
}
@@ -222,12 +224,19 @@ impl Screening {
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let public_key = self.public_key;
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let tag = Tag::public_key_report(public_key, Report::Impersonation);
let builder = EventBuilder::report(vec![tag], "");
let event = client.sign_event_builder(builder).await?;
let tag = Nip56Tag::PublicKey {
public_key,
report: Report::Impersonation,
}
.to_tag();
let event = EventBuilder::report(vec![tag], "")
.finalize_async(&signer)
.await?;
// Send the report to the public relays
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;

View File

@@ -56,17 +56,16 @@ impl Preferences {
impl Render for Preferences {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const SCREENING: &str =
"When opening a request, a popup will appear to help you identify the sender.";
const AVATAR: &str =
"Hide all avatar pictures to improve performance and protect your privacy.";
const MODE: &str =
"Choose whether to use the selected light or dark theme, or to follow the OS.";
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
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);
@@ -207,6 +206,21 @@ impl Render for Preferences {
),
),
)
.child(
GroupBox::new()
.id("experiments")
.title("Experiments")
.fill()
.child(
Switch::new("nip4e")
.label("Decoupling Encryption Key")
.description(NIP4E)
.checked(nip4e)
.on_click(move |_, _window, cx| {
AppSettings::update_nip4e(!nip4e, cx);
}),
),
)
.child(
GroupBox::new()
.id("media")

View File

@@ -1,13 +1,14 @@
use std::sync::Arc;
use ::settings::AppSettings;
use anyhow::Error;
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,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, div,
image_cache, px, relative,
};
use nostr_sdk::prelude::*;
@@ -16,38 +17,33 @@ use serde::Deserialize;
use smallvec::{SmallVec, smallvec};
use state::{IMAGE_CACHE_SIZE, NostrRegistry, StateEvent};
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
use title_bar::TitleBar;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{ClosePanel, DockArea, DockItem, DockPlacement, PanelView};
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::notification::{Notification, NotificationKind};
use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
use ui::{Icon, IconName, Root, Sizable, TitleBar, WindowExtension, h_flex, v_flex};
use crate::dialogs::import::ImportIdentity;
use crate::dialogs::restore::RestoreEncryption;
use crate::dialogs::{accounts, settings};
use crate::dialogs::settings;
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list, trash};
use crate::sidebar;
const PREPARE_MSG: &str = "Coop is preparing a new identity for you. This may take a moment...";
const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \
Your identity is completely decoupled from all encryption processes to protect your privacy.";
const ENC_WARN: &str = "By resetting your encryption key, you will lose access to \
all your encrypted messages before. This action cannot be undone.";
mod dialogs;
mod panels;
mod sidebar;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
cx.new(|cx| Workspace::new(window, cx))
}
struct DeviceNotifcation;
struct SignerNotifcation;
struct RelayNotifcation;
struct MsgRelayNotification;
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = workspace, no_json)]
enum Command {
ToggleTheme,
ToggleAccount,
RefreshMessagingRelays,
BackupEncryption,
@@ -64,17 +60,17 @@ enum Command {
}
pub struct Workspace {
/// App's Title Bar
titlebar: Entity<TitleBar>,
/// 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; 5]>,
_subscriptions: SmallVec<[Subscription; 6]>,
}
impl Workspace {
@@ -83,10 +79,14 @@ impl Workspace {
let device = DeviceRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let titlebar = cx.new(|_| TitleBar::new());
let dock = cx.new(|cx| DockArea::new(window, cx));
let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx);
let dock = cx.new(|cx| {
let mut this = DockArea::new(window, cx);
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx);
this
});
let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx);
let mut subscriptions = smallvec![];
subscriptions.push(
@@ -97,42 +97,14 @@ impl Workspace {
);
subscriptions.push(
// Subscribe to the signer events
// Subscribe to the nostr events
cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| {
match event {
StateEvent::Creating => {
let note = Notification::new()
.id::<SignerNotifcation>()
.title("Preparing a new identity")
.message(PREPARE_MSG)
.autohide(false)
.with_kind(NotificationKind::Info);
window.push_notification(note, cx);
StateEvent::SignerChanged => {
window.close_all_modals(cx);
}
StateEvent::Connecting => {
let note = Notification::new()
.id::<RelayNotifcation>()
.message("Connecting to the bootstrap relays...")
.with_kind(NotificationKind::Info);
window.push_notification(note, cx);
}
StateEvent::Connected => {
let note = Notification::new()
.id::<RelayNotifcation>()
.message("Connected to the bootstrap relays")
.with_kind(NotificationKind::Success);
window.push_notification(note, cx);
}
StateEvent::SignerSet => {
this.set_center_layout(window, cx);
// Clear the signer notification
window.clear_notification::<SignerNotifcation>(cx);
}
StateEvent::Show => {
this.account_selector(window, cx);
StateEvent::NoSigner => {
this.import_identity(window, cx);
}
_ => {}
};
@@ -145,7 +117,7 @@ impl Workspace {
match event {
DeviceEvent::Requesting => {
const MSG: &str =
"Coop has sent a request for an encryption key. Please open the other client then approve the request.";
"Please open other client and approve the request for encryption key.";
let note = Notification::new()
.id::<DeviceNotifcation>()
@@ -156,12 +128,25 @@ impl Workspace {
window.push_notification(note, cx);
}
DeviceEvent::Creating => {
DeviceEvent::NotSet => {
const MSG: &str =
"User're not setup encryption key yet. Do you want to create one?";
let note = Notification::new()
.id::<DeviceNotifcation>()
.autohide(false)
.message("Creating encryption key")
.with_kind(NotificationKind::Info);
.message(MSG)
.with_kind(NotificationKind::Info)
.action(|_this, _window, _cx| {
Button::new("retry").label("Retry").on_click(
move |_this, window, cx| {
let device = DeviceRegistry::global(cx);
device.update(cx, |this, cx| {
this.set_announcement(Keys::generate(), cx);
});
window.clear_notification::<DeviceNotifcation>(cx);
},
)
});
window.push_notification(note, cx);
}
@@ -184,6 +169,27 @@ impl Workspace {
// Observe all events emitted by the chat registry
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
match ev {
ChatEvent::InboxRelayNotFound => {
const MSG: &str = "Messaging Relays not found. Cannot receive messages.";
window.push_notification(
Notification::warning(MSG)
.id::<MsgRelayNotification>()
.autohide(false)
.action(|_this, _window, _cx| {
Button::new("retry").label("Retry").on_click(
move |_this, window, cx| {
let chat = ChatRegistry::global(cx);
chat.update(cx, |this, cx| {
this.get_metadata(cx);
});
window.clear_notification::<MsgRelayNotification>(cx);
},
)
}),
cx,
);
}
ChatEvent::OpenRoom(id) => {
if let Some(room) = chat.read(cx).room(id, cx) {
this.dock.update(cx, |this, cx| {
@@ -227,15 +233,21 @@ impl Workspace {
}),
);
// 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 {
titlebar,
dock,
image_cache,
tasks: vec![],
_subscriptions: subscriptions,
}
}
@@ -267,29 +279,6 @@ impl Workspace {
.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 {
@@ -306,9 +295,8 @@ impl Workspace {
}
Command::ShowProfile => {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
if let Some(public_key) = signer.public_key() {
if let Some(public_key) = nostr.read(cx).current_user() {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(profile::init(public_key, window, cx)),
@@ -353,7 +341,7 @@ impl Workspace {
let chat = ChatRegistry::global(cx);
// Trigger a refresh of the chat registry
chat.update(cx, |this, cx| {
this.refresh(window, cx);
this.reload(cx);
});
}
Command::ShowRelayList => {
@@ -378,14 +366,11 @@ impl Workspace {
Command::ToggleTheme => {
self.theme_selector(window, cx);
}
Command::ToggleAccount => {
self.account_selector(window, cx);
}
Command::BackupEncryption => {
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,
@@ -412,9 +397,8 @@ impl Workspace {
cx.open_with_system(output_path.as_path());
})?;
Ok::<_, anyhow::Error>(())
})
.detach();
Ok(())
}));
}
Command::ImportEncryption => {
self.import_encryption(window, cx);
@@ -423,6 +407,12 @@ impl Workspace {
}
fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \
Your identity is completely decoupled from all encryption processes to protect your privacy.";
const ENC_WARN: &str = "By resetting your encryption key, you will lose access to \
all your encrypted messages before. This action cannot be undone.";
let device = DeviceRegistry::global(cx);
let ent = device.downgrade();
@@ -457,24 +447,22 @@ impl Workspace {
fn import_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let restore = cx.new(|cx| RestoreEncryption::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(520.))
this.width(px(420.))
.title("Restore Encryption")
.child(restore.clone())
});
}
fn account_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let accounts = accounts::init(window, cx);
fn import_identity(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let import = cx.new(|cx| ImportIdentity::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(520.))
.title("Continue with")
this.width(px(420.))
.show_close(false)
.keyboard(false)
.overlay_closable(false)
.child(accounts.clone())
.title("Import Identity")
.child(import.clone())
});
}
@@ -560,8 +548,7 @@ impl Workspace {
fn titlebar_left(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let current_user = signer.public_key();
let current_user = nostr.read(cx).current_user();
h_flex()
.flex_shrink_0()
@@ -571,7 +558,7 @@ impl Workspace {
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Choose an account to continue...")),
.child(SharedString::from("Import your identity to continue")),
)
})
.when_some(current_user.as_ref(), |this, public_key| {
@@ -622,11 +609,6 @@ impl Workspace {
Box::new(Command::ToggleTheme),
)
.separator()
.menu_with_icon(
"Accounts",
IconName::Group,
Box::new(Command::ToggleAccount),
)
.menu_with_icon(
"Settings",
IconName::Settings,
@@ -639,16 +621,12 @@ impl Workspace {
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let chat = ChatRegistry::global(cx);
let initializing = chat.read(cx).initializing;
let trash_messages = chat.read(cx).count_trash_messages(cx);
let device = DeviceRegistry::global(cx);
let device_initializing = device.read(cx).initializing;
let is_nip4e_enabled = AppSettings::get_nip4e(cx);
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let Some(public_key) = signer.public_key() else {
let Some(public_key) = nostr.read(cx).current_user() else {
return div();
};
@@ -691,83 +669,75 @@ impl Workspace {
}),
)
})
.child(
Button::new("key")
.icon(IconName::UserKey)
.tooltip("Decoupled encryption key")
.small()
.ghost()
.loading(device_initializing)
.when(device_initializing, |this| {
this.label("Dekey")
.xsmall()
.tooltip("Loading decoupled encryption key...")
})
.dropdown_menu(move |this, _window, _cx| {
this.min_w(px(260.))
.label("Encryption Key")
.when_some(announcement.as_ref(), |this, announcement| {
let name = announcement.client_name();
let pkey = shorten_pubkey(announcement.public_key(), 8);
.when(is_nip4e_enabled, |this| {
this.child(
Button::new("key")
.icon(IconName::UserKey)
.tooltip("Decoupled encryption key")
.small()
.ghost()
.dropdown_menu(move |this, _window, _cx| {
this.min_w(px(260.))
.label("Encryption Key")
.when_some(announcement.as_ref(), |this, announcement| {
let name = announcement.client_name();
let pkey = shorten_pubkey(announcement.public_key(), 8);
this.item(PopupMenuItem::element(move |_window, cx| {
h_flex()
.gap_1()
.text_sm()
.child(
Icon::new(IconName::Device)
.small()
.text_color(cx.theme().icon_muted),
)
.child(name.clone())
}))
.item(PopupMenuItem::element(move |_window, cx| {
h_flex()
.gap_1()
.text_sm()
.child(
Icon::new(IconName::UserKey)
.small()
.text_color(cx.theme().icon_muted),
)
.child(SharedString::from(pkey.clone()))
}))
})
.separator()
.menu_with_icon(
"Backup",
IconName::Shield,
Box::new(Command::BackupEncryption),
)
.menu_with_icon(
"Restore from secret key",
IconName::Usb,
Box::new(Command::ImportEncryption),
)
.separator()
.menu_with_icon(
"Reload",
IconName::Refresh,
Box::new(Command::RefreshEncryption),
)
.menu_with_icon(
"Reset",
IconName::Warning,
Box::new(Command::ResetEncryption),
)
}),
)
this.item(PopupMenuItem::element(move |_window, cx| {
h_flex()
.gap_1()
.text_sm()
.child(
Icon::new(IconName::Device)
.small()
.text_color(cx.theme().icon_muted),
)
.child(name.clone())
}))
.item(
PopupMenuItem::element(move |_window, cx| {
h_flex()
.gap_1()
.text_sm()
.child(
Icon::new(IconName::UserKey)
.small()
.text_color(cx.theme().icon_muted),
)
.child(SharedString::from(pkey.clone()))
}),
)
})
.separator()
.menu_with_icon(
"Backup",
IconName::Shield,
Box::new(Command::BackupEncryption),
)
.menu_with_icon(
"Restore from secret key",
IconName::Usb,
Box::new(Command::ImportEncryption),
)
.separator()
.menu_with_icon(
"Reload",
IconName::Refresh,
Box::new(Command::RefreshEncryption),
)
.menu_with_icon(
"Reset",
IconName::Warning,
Box::new(Command::ResetEncryption),
)
}),
)
})
.child(
Button::new("inbox")
.icon(IconName::Inbox)
.small()
.ghost()
.loading(initializing)
.when(initializing, |this| {
this.label("Inbox")
.xsmall()
.tooltip("Getting inbox messages...")
})
.dropdown_menu(move |this, _window, cx| {
let urls: Vec<(SharedString, SharedString)> = profile
.messaging_relays()
@@ -838,17 +808,8 @@ impl Render for Workspace {
let modal_layer = Root::render_modal_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
// Titlebar elements
let left = self.titlebar_left(cx).into_any_element();
let right = self.titlebar_right(cx).into_any_element();
// Update title bar children
self.titlebar.update(cx, |this, _cx| {
this.set_children(vec![left, right]);
});
div()
.id(SharedString::from("workspace"))
.id("workspace")
.on_action(cx.listener(Self::on_command))
.relative()
.size_full()
@@ -860,7 +821,11 @@ impl Render for Workspace {
v_flex()
.size_full()
// Title Bar
.child(self.titlebar.clone())
.child(
TitleBar::new()
.child(self.titlebar_left(cx))
.child(self.titlebar_right(cx)),
)
// Dock
.child(self.dock.clone()),
),

View File

@@ -1,4 +1,4 @@
use std::time::Duration;
use instant::Duration;
use anyhow::Error;
use gpui::{
@@ -6,7 +6,7 @@ use gpui::{
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div,
};
use nostr_sdk::prelude::*;
use state::KEYRING;
use state::USER_KEYRING;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
@@ -59,7 +59,7 @@ impl BackupPanel {
}
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let keyring = cx.read_credentials(KEYRING);
let keyring = cx.read_credentials(USER_KEYRING);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
if let Some((_, secret)) = keyring.await? {

View File

@@ -1,7 +1,7 @@
use std::collections::HashSet;
use std::time::Duration;
use instant::Duration;
use anyhow::{Context as AnyhowContext, Error};
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
@@ -82,11 +82,12 @@ impl ContactListPanel {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
let contact_list = client.database().contacts_public_keys(public_key).await?;
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let contact_list = client.database().contacts_public_keys(public_key).await?;
Ok(contact_list)
});
@@ -156,6 +157,7 @@ impl ContactListPanel {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
// Get contacts
let contacts: Vec<Contact> = self
@@ -169,8 +171,9 @@ impl ContactListPanel {
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Construct contact list event builder
let builder = EventBuilder::contact_list(contacts);
let event = client.sign_event_builder(builder).await?;
let event = ContactListBuilder::new(contacts)
.finalize_async(&signer)
.await?;
// Set contact list
client.send_event(&event).to_nip65().await?;

View File

@@ -1,6 +1,7 @@
use anyhow::Error;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window, div, svg,
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div, svg,
};
use state::NostrRegistry;
use theme::ActiveTheme;
@@ -8,8 +9,8 @@ use ui::button::{Button, ButtonVariants};
use ui::dock::{DockPlacement, Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use crate::Workspace;
use crate::panels::profile;
use crate::workspace::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,15 +27,15 @@ impl GreeterPanel {
Self {
name: "Onboarding".into(),
focus_handle: cx.focus_handle(),
tasks: vec![],
}
}
fn add_profile_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
if let Some(public_key) = signer.public_key() {
cx.spawn_in(window, async move |_this, cx| {
if let Some(public_key) = nostr.read(cx).current_user() {
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
cx.update(|window, cx| {
Workspace::add_panel(
profile::init(public_key, window, cx),
@@ -43,8 +45,9 @@ impl GreeterPanel {
);
})
.ok();
})
.detach();
Ok(())
}));
}
}
}

View File

@@ -1,7 +1,7 @@
use std::collections::HashSet;
use std::time::Duration;
use instant::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use anyhow::{Error, anyhow};
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
@@ -83,17 +83,18 @@ impl MessagingRelayPanel {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
Ok(nip17::extract_owned_relay_list(event).collect())
Ok(nip17::extract_relay_list(&event).collect())
} else {
Err(anyhow!("Not found."))
}
@@ -170,12 +171,13 @@ impl MessagingRelayPanel {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
// Construct event tags
let tags: Vec<Tag> = self
.relays
.iter()
.map(|relay| Tag::relay(relay.clone()))
.map(|relay| Nip17Tag::Relay(relay.to_owned()).to_tag())
.collect();
// Set updating state
@@ -183,8 +185,10 @@ impl MessagingRelayPanel {
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Construct nip17 event builder
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
let event = client.sign_event_builder(builder).await?;
let event = EventBuilder::new(Kind::InboxRelays, "")
.tags(tags)
.finalize_async(&signer)
.await?;
// Set messaging relays
client.send_event(&event).to_nip65().await?;

View File

@@ -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(())
}));
}
}
@@ -207,12 +208,12 @@ impl ProfilePanel {
fn publish(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let metadata = metadata.clone();
cx.background_spawn(async move {
// Build and sign the metadata event
let builder = EventBuilder::metadata(&metadata);
let event = client.sign_event_builder(builder).await?;
let event = metadata.finalize_async(&signer).await?;
// Send event to user's relays
client.send_event(&event).await?;

View File

@@ -1,7 +1,7 @@
use std::collections::HashSet;
use std::time::Duration;
use instant::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use anyhow::{Error, anyhow};
use gpui::prelude::FluentBuilder;
use gpui::{
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
@@ -100,18 +100,19 @@ impl RelayListPanel {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<Vec<(RelayUrl, Option<RelayMetadata>)>, Error>> = cx
.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::RelayList)
.author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
Ok(nip65::extract_owned_relay_list(event).collect())
Ok(nip65::extract_relay_list(&event).collect())
} else {
Err(anyhow!("Not found."))
}
@@ -206,6 +207,7 @@ impl RelayListPanel {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
// Get all relays
let relays = self.relays.clone();
@@ -214,8 +216,9 @@ impl RelayListPanel {
self.set_updating(true, cx);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let builder = EventBuilder::relay_list(relays);
let event = client.sign_event_builder(builder).await?;
let event = EventBuilder::relay_list(relays)
.finalize_async(&signer)
.await?;
// Set relay list for current user
client.send_event(&event).await?;

View File

@@ -37,9 +37,9 @@ impl TrashPanel {
fn copy(&self, ix: usize, cx: &App) {
let chat = ChatRegistry::global(cx);
let trashes = chat.read(cx).trashes();
let trash_events = chat.read(cx).trash();
if let Some(message) = trashes.read(cx).iter().nth(ix) {
if let Some(message) = trash_events.read(cx).iter().nth(ix) {
let item = ClipboardItem::new_string(message.raw_event.to_string());
cx.write_to_clipboard(item);
}
@@ -52,9 +52,9 @@ impl TrashPanel {
cx: &mut Context<Self>,
) -> AnyElement {
let chat = ChatRegistry::global(cx);
let trashes = chat.read(cx).trashes();
let trash_events = chat.read(cx).trash();
if let Some(message) = trashes.read(cx).iter().nth(ix) {
if let Some(message) = trash_events.read(cx).iter().nth(ix) {
v_flex()
.id(ix)
.p_2()

View File

@@ -1,8 +1,8 @@
use std::collections::HashSet;
use std::ops::Range;
use std::time::Duration;
use instant::Duration;
use anyhow::{Context as AnyhowContext, Error};
use anyhow::Error;
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::{DebouncedDelay, TimestampExt, coop_cache};
use entry::RoomEntry;
@@ -159,11 +159,12 @@ impl Sidebar {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
let contacts = client.database().contacts_public_keys(public_key).await?;
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let contacts = client.database().contacts_public_keys(public_key).await?;
Ok(contacts)
});
@@ -319,14 +320,14 @@ impl Sidebar {
let async_chat = chat.downgrade();
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
// Get all selected public keys
let receivers = self.get_selected(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let public_key = signer.get_public_key().await?;
// Create a new room and emit it
async_chat.update_in(cx, |this, _window, cx| {
let room = cx.new(|_| {
@@ -461,7 +462,7 @@ impl Sidebar {
});
RoomEntry::new(range.start + ix)
.name(profile.name())
.name(profile.name().trim())
.avatar(profile.avatar())
.on_click(handler)
.selected(selected)

View File

@@ -28,44 +28,23 @@ icons = [
[dependencies]
assets = { path = "../crates/assets" }
workspace = { path = "../crates/workspace" }
ui = { path = "../crates/ui" }
title_bar = { path = "../crates/title_bar" }
theme = { path = "../crates/theme" }
common = { path = "../crates/common" }
state = { path = "../crates/state" }
device = { path = "../crates/device" }
chat = { path = "../crates/chat" }
chat_ui = { path = "../crates/chat_ui" }
settings = { path = "../crates/settings" }
auto_update = { path = "../crates/auto_update" }
person = { path = "../crates/person" }
relay_auth = { path = "../crates/relay_auth" }
gpui.workspace = true
gpui_platform.workspace = true
gpui_linux.workspace = true
gpui_windows.workspace = true
gpui_macos.workspace = true
gpui_tokio.workspace = true
reqwest_client.workspace = true
nostr-connect.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
itertools.workspace = true
log.workspace = true
smallvec.workspace = true
smol.workspace = true
futures.workspace = true
oneshot.workspace = true
webbrowser.workspace = true
tracing-subscriber.workspace = true
indexset = "0.12.3"
[target.'cfg(target_os = "macos")'.dependencies]
# Temporary workaround https://github.com/zed-industries/zed/issues/47168
core-text = "=21.0.0"

View File

@@ -1,257 +0,0 @@
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, Render,
SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, div, px,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use state::{NostrRegistry, StateEvent};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{Disableable, Icon, IconName, Sizable, WindowExtension, h_flex, v_flex};
use crate::dialogs::connect::ConnectSigner;
use crate::dialogs::import::ImportKey;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<AccountSelector> {
cx.new(|cx| AccountSelector::new(window, cx))
}
/// Account selector
pub struct AccountSelector {
/// Public key currently being chosen for login
logging_in: Entity<Option<PublicKey>>,
/// The error message displayed when an error occurs.
error: Entity<Option<SharedString>>,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Subscription to the signer events
_subscription: Option<Subscription>,
}
impl AccountSelector {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let logging_in = cx.new(|_| None);
let error = cx.new(|_| None);
// Subscribe to the signer events
let nostr = NostrRegistry::global(cx);
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, window, cx| {
match event {
StateEvent::SignerSet => {
window.close_all_modals(cx);
window.refresh();
}
StateEvent::Error(e) => {
this.set_error(e.to_string(), cx);
}
_ => {}
};
});
Self {
logging_in,
error,
tasks: vec![],
_subscription: Some(subscription),
}
}
fn logging_in(&self, public_key: &PublicKey, cx: &App) -> bool {
self.logging_in.read(cx) == &Some(*public_key)
}
fn set_logging_in(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
self.logging_in.update(cx, |this, cx| {
*this = Some(public_key);
cx.notify();
});
}
fn set_error<T>(&mut self, error: T, cx: &mut Context<Self>)
where
T: Into<SharedString>,
{
self.error.update(cx, |this, cx| {
*this = Some(error.into());
cx.notify();
});
self.logging_in.update(cx, |this, cx| {
*this = None;
cx.notify();
})
}
fn login(&mut self, public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let task = nostr.read(cx).get_secret(public_key, cx);
// Mark the public key as being logged in
self.set_logging_in(public_key, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(signer) => {
nostr.update(cx, |this, cx| {
this.set_signer(signer, cx);
});
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), cx);
})?;
}
};
Ok(())
}));
}
fn remove(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.remove_secret(&public_key, cx);
});
}
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let import = cx.new(|cx| ImportKey::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(460.))
.title("Import a Secret Key or Bunker Connection")
.show_close(true)
.pb_2()
.child(import.clone())
});
}
fn open_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let connect = cx.new(|cx| ConnectSigner::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(460.))
.title("Scan QR Code to Connect")
.show_close(true)
.pb_2()
.child(connect.clone())
});
}
}
impl Render for AccountSelector {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let npubs = nostr.read(cx).npubs();
let loading = self.logging_in.read(cx).is_some();
v_flex()
.size_full()
.gap_2()
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_center()
.text_color(cx.theme().text_danger)
.child(error.clone()),
)
})
.children({
let mut items = vec![];
for (ix, public_key) in npubs.read(cx).iter().enumerate() {
let profile = persons.read(cx).get(public_key, cx);
let logging_in = self.logging_in(public_key, cx);
items.push(
h_flex()
.id(ix)
.group("")
.px_2()
.h_10()
.justify_between()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().ghost_element_background)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.child(
h_flex()
.gap_2()
.child(Avatar::new(profile.avatar()).small())
.child(div().text_sm().child(profile.name())),
)
.when(logging_in, |this| this.child(Indicator::new().small()))
.when(!logging_in, |this| {
this.child(
h_flex()
.gap_1()
.invisible()
.group_hover("", |this| this.visible())
.child(
Button::new(format!("del-{ix}"))
.icon(IconName::Close)
.ghost()
.small()
.disabled(logging_in)
.on_click(cx.listener({
let public_key = *public_key;
move |this, _ev, _window, cx| {
cx.stop_propagation();
this.remove(public_key, cx);
}
})),
),
)
})
.when(!logging_in, |this| {
let public_key = *public_key;
this.on_click(cx.listener(move |this, _ev, window, cx| {
this.login(public_key, window, cx);
}))
}),
);
}
items
})
.child(div().w_full().h_px().bg(cx.theme().border_variant))
.child(
h_flex()
.gap_1()
.justify_end()
.w_full()
.child(
Button::new("input")
.icon(Icon::new(IconName::Usb))
.label("Import")
.ghost()
.small()
.disabled(loading)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_import(window, cx);
})),
)
.child(
Button::new("qr")
.icon(Icon::new(IconName::Scan))
.label("Scan QR to connect")
.ghost()
.small()
.disabled(loading)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_connect(window, cx);
})),
),
)
}
}

View File

@@ -1,115 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use common::StringExt;
use gpui::prelude::FluentBuilder;
use gpui::{
AppContext, Context, Entity, Image, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Window, div, img, px,
};
use nostr_connect::prelude::*;
use state::{
CLIENT_NAME, CoopAuthUrlHandler, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT, NostrRegistry,
StateEvent,
};
use theme::ActiveTheme;
use ui::v_flex;
pub struct ConnectSigner {
/// QR Code
qr_code: Option<Arc<Image>>,
/// Error message
error: Entity<Option<SharedString>>,
/// Subscription to the signer event
_subscription: Option<Subscription>,
}
impl ConnectSigner {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let error = cx.new(|_| None);
let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).keys();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
// Generate the nostr connect uri
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
// Generate the nostr connect
let mut signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None).unwrap();
// Handle the auth request
signer.auth_url_handler(CoopAuthUrlHandler);
// Generate a QR code for quick connection
let qr_code = uri.to_string().to_qr();
// Set signer in the background
nostr.update(cx, |this, cx| {
this.add_nip46_signer(&signer, cx);
});
// Subscribe to the signer event
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
if let StateEvent::Error(e) = event {
this.set_error(e, cx);
}
});
Self {
qr_code,
error,
_subscription: Some(subscription),
}
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
}
}
impl Render for ConnectSigner {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const MSG: &str = "Scan with any Nostr Connect-compatible app to connect";
v_flex()
.size_full()
.items_center()
.justify_center()
.p_4()
.when_some(self.qr_code.as_ref(), |this, qr| {
this.child(
img(qr.clone())
.size(px(256.))
.rounded(cx.theme().radius_lg)
.border_1()
.border_color(cx.theme().border),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_danger)
.child(error.clone()),
)
})
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(MSG)),
)
}
}

View File

@@ -10,11 +10,6 @@ use gpui_platform::application;
use state::{APP_ID, CLIENT_NAME};
use ui::Root;
mod dialogs;
mod panels;
mod sidebar;
mod workspace;
actions!(coop, [Quit]);
fn main() {
@@ -48,7 +43,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 {
@@ -67,47 +62,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 the tokio runtime
gpui_tokio::init(cx);
// Initialize theme registry
theme::init(cx);
// Initialize components
ui::init(cx);
// Initialize settings
settings::init(window, cx);
// Initialize theme registry
theme::init(cx);
// Initialize the nostr client
state::init(window, cx);
// Initialize settings
settings::init(window, cx);
// Initialize person registry
person::init(window, cx);
// Initialize the nostr client
state::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 person registry
person::init(window, cx);
// Initialize app registry
chat::init(window, cx);
// Initialize relay auth registry
relay_auth::init(window, cx);
// Initialize auto update
auto_update::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 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);
});
}

View File

@@ -1,5 +1,5 @@
[toolchain]
channel = "1.92"
channel = "1.96"
profile = "minimal"
components = ["rustfmt", "clippy"]
targets = [
@@ -10,5 +10,6 @@ targets = [
"aarch64-pc-windows-msvc",
"aarch64-apple-ios",
"aarch64-linux-android",
"wasm32-unknown-unknown"
"wasm32-unknown-unknown",
"wasm32-wasip2"
]

View File

@@ -4,7 +4,11 @@ version.workspace = true
edition.workspace = true
publish.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
workspace = { path = "../crates/workspace" }
assets = { path = "../crates/assets" }
ui = { path = "../crates/ui" }
theme = { path = "../crates/theme" }
@@ -14,29 +18,20 @@ device = { path = "../crates/device" }
chat = { path = "../crates/chat" }
settings = { path = "../crates/settings" }
person = { path = "../crates/person" }
relay_auth = { path = "../crates/relay_auth" }
gpui.workspace = true
gpui_platform.workspace = true
gpui_tokio.workspace = true
gpui_web = { git = "https://github.com/zed-industries/zed" }
nostr-connect.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
itertools.workspace = true
log.workspace = true
smallvec.workspace = true
smol.workspace = true
futures.workspace = true
oneshot.workspace = true
webbrowser.workspace = true
tracing-subscriber.workspace = true
console_error_panic_hook = "0.1"
tracing-wasm = "0.2"
console_log = "1.0"
wasm-bindgen = "0.2"
universal-time = { git = "https://github.com/shadowylab/universal-time" }
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom_0_2 = { package = "getrandom", version = "0.2", features = ["js"] }
getrandom_0_3 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }
getrandom_0_4 = { package = "getrandom", version = "0.4", features = ["wasm_js"] }

46
web/Makefile Normal file
View File

@@ -0,0 +1,46 @@
.PHONY: help dev build-wasm build-web build clean install
help: ## Show help information
@echo "Coop - Available commands:"
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
install: ## Install all dependencies
@echo "Checking Rust WASM target..."
@rustup target add wasm32-unknown-unknown || true
@echo "Checking wasm-bindgen-cli..."
@cargo install wasm-bindgen-cli || true
@echo "Installing frontend dependencies..."
@cd www && deno install
build-wasm: ## Build WASM (release mode)
@./script/build-wasm.sh --release
build-wasm-dev: ## Build WASM (debug mode)
@./script/build-wasm.sh
build-web: ## Build frontend
@cd www && deno task build
build-web-prod: ## Build frontend for production (GitHub Pages)
@cd www && deno install && NODE_ENV=production deno task build
build: build-wasm build-web ## Build complete project (WASM + frontend)
build-prod: build-wasm build-web-prod ## Build complete project for production
dev: build-wasm-dev ## Start development server
@cd www && deno install && deno task dev
preview: ## Preview production build
@cd www && deno task preview
clean: ## Clean build artifacts
@echo "Cleaning build artifacts..."
@rm -rf www/dist
@rm -rf www/wasm/*.js www/wasm/*.wasm
@cargo clean
watch-wasm: ## Watch Rust code changes and auto-rebuild WASM
@echo "Watching WASM changes..."
@cargo watch -x 'build --target wasm32-unknown-unknown' -s './script/build-wasm.sh'

4
web/rust-toolchain.toml Normal file
View File

@@ -0,0 +1,4 @@
[toolchain]
channel = "nightly"
components = ["rustfmt", "clippy"]
targets = ["wasm32-unknown-unknown"]

72
web/script/build-wasm.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}Building Coop Web...${NC}"
# Get the script directory
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$SCRIPT_DIR/.."
# Parse arguments
RELEASE_FLAG=""
if [[ "$1" == "--release" ]]; then
RELEASE_FLAG="--release"
echo -e "${YELLOW}Building in release mode${NC}"
fi
# macOS: ensure LLVM from Homebrew is available
if [[ "$(uname)" == "Darwin" ]]; then
echo -e "${GREEN}Detected macOS, setting up LLVM...${NC}"
if brew list llvm &>/dev/null; then
echo -e "${GREEN}LLVM already installed, skipping install${NC}"
else
echo -e "${YELLOW}LLVM not found, installing via Homebrew...${NC}"
brew install llvm
fi
LLVM_PATH=$(brew --prefix llvm)
export AR="${LLVM_PATH}/bin/llvm-ar"
export CC="${LLVM_PATH}/bin/clang"
echo -e "${GREEN}LLVM path: ${LLVM_PATH}${NC}"
fi
# Step 1: Build WASM
echo -e "${GREEN}Step 1: Building WASM...${NC}"
cd "$PROJECT_ROOT"
export CARGO_TARGET_DIR="$PROJECT_ROOT/target"
RUSTFLAGS='-C target-feature=+bulk-memory -C link-arg=--max-memory=4294967296' \
cargo build --target wasm32-unknown-unknown $RELEASE_FLAG
# Determine the build directory
if [[ "$RELEASE_FLAG" == "--release" ]]; then
BUILD_MODE="release"
else
BUILD_MODE="debug"
fi
# WASM file is in workspace target directory
WASM_PATH="$PROJECT_ROOT/target/wasm32-unknown-unknown/$BUILD_MODE/coop_web.wasm"
# Check if WASM file exists
if [[ ! -f "$WASM_PATH" ]]; then
echo -e "${RED}Error: WASM file not found at: $WASM_PATH${NC}"
exit 1
fi
# Step 2: Generate JavaScript bindings
echo -e "${GREEN}Step 2: Generating JavaScript bindings...${NC}"
wasm-bindgen "$WASM_PATH" \
--out-dir "$PROJECT_ROOT/www/wasm" \
--target web \
--no-typescript
echo -e "${GREEN}✓ Build completed successfully!${NC}"
echo -e "${YELLOW}Next steps:${NC}"
echo -e " cd www"
echo -e " deno install"
echo -e " deno run dev"

View File

@@ -1,6 +1,26 @@
use gpui::*;
use ui::Root;
use universal_time::{
Duration, Instant, MonotonicClock, SystemTime, WallClock, define_time_provider,
};
use wasm_bindgen::prelude::*;
struct CustomTimeProvider;
impl WallClock for CustomTimeProvider {
fn system_time(&self) -> SystemTime {
SystemTime::from_unix_duration(Duration::from_secs(0))
}
}
impl MonotonicClock for CustomTimeProvider {
fn instant(&self) -> Instant {
Instant::from_ticks(Duration::from_secs(0))
}
}
define_time_provider!(CustomTimeProvider);
#[wasm_bindgen]
pub fn run() -> Result<(), JsValue> {
console_error_panic_hook::set_once();
@@ -18,9 +38,49 @@ pub fn run() -> Result<(), JsValue> {
let app = gpui_platform::application();
#[cfg(target_family = "wasm")]
let app = gpui_platform::single_threaded_web();
let app = {
let app = gpui_platform::single_threaded_web();
app.run(|_cx| {});
// Workaround: intentionally leak the `Rc<AppCell>` to keep the application alive
struct WasmApplication(std::rc::Rc<AppCell>);
let wasm_app = unsafe { std::mem::transmute::<Application, WasmApplication>(app) };
std::mem::forget(wasm_app.0.clone());
unsafe { std::mem::transmute::<WasmApplication, Application>(wasm_app) }
};
app.run(|cx| {
// Open the root window
cx.open_window(WindowOptions::default(), |window, cx| {
// Initialize components
ui::init(cx);
// Initialize theme registry
theme::init(cx);
// Initialize settings
settings::init(window, cx);
// Initialize the nostr client
state::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 app registry
chat::init(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.");
cx.activate(true);
});
Ok(())
}

396
web/www/deno.lock generated Normal file
View File

@@ -0,0 +1,396 @@
{
"version": "5",
"specifiers": {
"npm:vite-plugin-static-copy@^3.2.0": "3.4.0_vite@8.1.5",
"npm:vite-plugin-wasm@^3.3.0": "3.6.0_vite@8.1.5",
"npm:vite@8": "8.1.5"
},
"npm": {
"@emnapi/core@1.11.1": {
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dependencies": [
"@emnapi/wasi-threads",
"tslib"
]
},
"@emnapi/runtime@1.11.1": {
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dependencies": [
"tslib"
]
},
"@emnapi/wasi-threads@1.2.2": {
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dependencies": [
"tslib"
]
},
"@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1": {
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dependencies": [
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util"
]
},
"@oxc-project/types@0.139.0": {
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="
},
"@rolldown/binding-android-arm64@1.1.5": {
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"os": ["android"],
"cpu": ["arm64"]
},
"@rolldown/binding-darwin-arm64@1.1.5": {
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@rolldown/binding-darwin-x64@1.1.5": {
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@rolldown/binding-freebsd-x64@1.1.5": {
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"@rolldown/binding-linux-arm-gnueabihf@1.1.5": {
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"os": ["linux"],
"cpu": ["arm"]
},
"@rolldown/binding-linux-arm64-gnu@1.1.5": {
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@rolldown/binding-linux-arm64-musl@1.1.5": {
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@rolldown/binding-linux-ppc64-gnu@1.1.5": {
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"os": ["linux"],
"cpu": ["ppc64"]
},
"@rolldown/binding-linux-s390x-gnu@1.1.5": {
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@rolldown/binding-linux-x64-gnu@1.1.5": {
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
"os": ["linux"],
"cpu": ["x64"]
},
"@rolldown/binding-linux-x64-musl@1.1.5": {
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
"os": ["linux"],
"cpu": ["x64"]
},
"@rolldown/binding-openharmony-arm64@1.1.5": {
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
"@rolldown/binding-wasm32-wasi@1.1.5": {
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"dependencies": [
"@emnapi/core",
"@emnapi/runtime",
"@napi-rs/wasm-runtime"
],
"cpu": ["wasm32"]
},
"@rolldown/binding-win32-arm64-msvc@1.1.5": {
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@rolldown/binding-win32-x64-msvc@1.1.5": {
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"os": ["win32"],
"cpu": ["x64"]
},
"@rolldown/pluginutils@1.0.1": {
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="
},
"@tybys/wasm-util@0.10.3": {
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dependencies": [
"tslib"
]
},
"anymatch@3.1.3": {
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dependencies": [
"normalize-path",
"picomatch@2.3.2"
]
},
"binary-extensions@2.3.0": {
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="
},
"braces@3.0.3": {
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dependencies": [
"fill-range"
]
},
"chokidar@3.6.0": {
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dependencies": [
"anymatch",
"braces",
"glob-parent",
"is-binary-path",
"is-glob",
"normalize-path",
"readdirp"
],
"optionalDependencies": [
"fsevents"
]
},
"detect-libc@2.1.2": {
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
},
"fdir@6.5.0_picomatch@4.0.5": {
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dependencies": [
"picomatch@4.0.5"
],
"optionalPeers": [
"picomatch@4.0.5"
]
},
"fill-range@7.1.1": {
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dependencies": [
"to-regex-range"
]
},
"fsevents@2.3.3": {
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"os": ["darwin"],
"scripts": true
},
"glob-parent@5.1.2": {
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dependencies": [
"is-glob"
]
},
"is-binary-path@2.1.0": {
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dependencies": [
"binary-extensions"
]
},
"is-extglob@2.1.1": {
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="
},
"is-glob@4.0.3": {
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dependencies": [
"is-extglob"
]
},
"is-number@7.0.0": {
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
},
"lightningcss-android-arm64@1.33.0": {
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"os": ["android"],
"cpu": ["arm64"]
},
"lightningcss-darwin-arm64@1.33.0": {
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"lightningcss-darwin-x64@1.33.0": {
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"os": ["darwin"],
"cpu": ["x64"]
},
"lightningcss-freebsd-x64@1.33.0": {
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"lightningcss-linux-arm-gnueabihf@1.33.0": {
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"os": ["linux"],
"cpu": ["arm"]
},
"lightningcss-linux-arm64-gnu@1.33.0": {
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"os": ["linux"],
"cpu": ["arm64"]
},
"lightningcss-linux-arm64-musl@1.33.0": {
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"os": ["linux"],
"cpu": ["arm64"]
},
"lightningcss-linux-x64-gnu@1.33.0": {
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"os": ["linux"],
"cpu": ["x64"]
},
"lightningcss-linux-x64-musl@1.33.0": {
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"os": ["linux"],
"cpu": ["x64"]
},
"lightningcss-win32-arm64-msvc@1.33.0": {
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"os": ["win32"],
"cpu": ["arm64"]
},
"lightningcss-win32-x64-msvc@1.33.0": {
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"os": ["win32"],
"cpu": ["x64"]
},
"lightningcss@1.33.0": {
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dependencies": [
"detect-libc"
],
"optionalDependencies": [
"lightningcss-android-arm64",
"lightningcss-darwin-arm64",
"lightningcss-darwin-x64",
"lightningcss-freebsd-x64",
"lightningcss-linux-arm-gnueabihf",
"lightningcss-linux-arm64-gnu",
"lightningcss-linux-arm64-musl",
"lightningcss-linux-x64-gnu",
"lightningcss-linux-x64-musl",
"lightningcss-win32-arm64-msvc",
"lightningcss-win32-x64-msvc"
]
},
"nanoid@3.3.16": {
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"bin": true
},
"normalize-path@3.0.0": {
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="
},
"p-map@7.0.6": {
"integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg=="
},
"picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"picomatch@2.3.2": {
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="
},
"picomatch@4.0.5": {
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="
},
"postcss@8.5.23": {
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dependencies": [
"nanoid",
"picocolors",
"source-map-js"
]
},
"readdirp@3.6.0": {
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dependencies": [
"picomatch@2.3.2"
]
},
"rolldown@1.1.5": {
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dependencies": [
"@oxc-project/types",
"@rolldown/pluginutils"
],
"optionalDependencies": [
"@rolldown/binding-android-arm64",
"@rolldown/binding-darwin-arm64",
"@rolldown/binding-darwin-x64",
"@rolldown/binding-freebsd-x64",
"@rolldown/binding-linux-arm-gnueabihf",
"@rolldown/binding-linux-arm64-gnu",
"@rolldown/binding-linux-arm64-musl",
"@rolldown/binding-linux-ppc64-gnu",
"@rolldown/binding-linux-s390x-gnu",
"@rolldown/binding-linux-x64-gnu",
"@rolldown/binding-linux-x64-musl",
"@rolldown/binding-openharmony-arm64",
"@rolldown/binding-wasm32-wasi",
"@rolldown/binding-win32-arm64-msvc",
"@rolldown/binding-win32-x64-msvc"
],
"bin": true
},
"source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
},
"tinyglobby@0.2.17": {
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dependencies": [
"fdir",
"picomatch@4.0.5"
]
},
"to-regex-range@5.0.1": {
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dependencies": [
"is-number"
]
},
"tslib@2.8.1": {
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"vite-plugin-static-copy@3.4.0_vite@8.1.5": {
"integrity": "sha512-ekryzCw0ouAOE8tw4RvVL/dfqguXzumsV3FBKoKso4MQ1MUUrUXtl5RI4KpJQUNGqFEsg9kxl4EvDl02YtA9VQ==",
"dependencies": [
"chokidar",
"p-map",
"picocolors",
"tinyglobby",
"vite"
]
},
"vite-plugin-wasm@3.6.0_vite@8.1.5": {
"integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==",
"dependencies": [
"vite"
]
},
"vite@8.1.5": {
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"dependencies": [
"lightningcss",
"picomatch@4.0.5",
"postcss",
"rolldown",
"tinyglobby"
],
"optionalDependencies": [
"fsevents"
],
"bin": true
}
},
"workspace": {
"packageJson": {
"dependencies": [
"npm:vite-plugin-static-copy@^3.2.0",
"npm:vite-plugin-wasm@^3.3.0",
"npm:vite@8"
]
}
}
}

View File

@@ -72,6 +72,6 @@
<p>Loading Coop...</p>
</div>
</div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="main.js"></script>
</body>
</html>

35
web/www/main.js Normal file
View File

@@ -0,0 +1,35 @@
async function init() {
const loadingEl = document.getElementById('loading');
const appEl = document.getElementById('app');
try {
// Import the WASM module
const wasm = await import('./wasm/coop_web.js');
await wasm.default();
// Initialize the story gallery
await wasm.run();
// Hide loading indicator
if (appEl) {
appEl.remove();
}
} catch (error) {
console.error('Failed to initialize:', error);
// Show error message
if (loadingEl) {
loadingEl.innerHTML = `
<div class="error">
<h2>Failed to load the application</h2>
<p>${error.message || error}</p>
<p style="margin-top: 10px; font-size: 14px;">
Please check the console for more details.
</p>
</div>
`;
}
}
}
init();

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