Compare commits
7 Commits
cb70e49264
...
fix-web
| Author | SHA1 | Date | |
|---|---|---|---|
| b349656c56 | |||
| 8bbb472103 | |||
| dfda7ff157 | |||
| f46f15e10c | |||
| 2f15615d7b | |||
| 9addbd49f4 | |||
| b518c729f6 |
1312
Cargo.lock
generated
1312
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
19
Cargo.toml
19
Cargo.toml
@@ -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", }
|
||||
nostr-connect = { 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-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
|
||||
nostr-memory = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
|
||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
|
||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", branch = "send-bound" }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ], branch = "send-bound" }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
@@ -40,9 +41,13 @@ 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 }
|
||||
|
||||
[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
|
||||
|
||||
@@ -8,9 +8,7 @@ publish.workspace = true
|
||||
common = { path = "../common" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
anyhow.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
@@ -18,4 +16,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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -17,11 +17,10 @@ nostr-sdk.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
|
||||
|
||||
fuzzy-matcher = "0.3.7"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, LazyLock, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
@@ -15,8 +15,7 @@ use gpui::{
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use smol::lock::RwLock;
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
|
||||
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP, UniversalSigner};
|
||||
|
||||
mod message;
|
||||
mod room;
|
||||
@@ -24,6 +23,9 @@ mod room;
|
||||
pub use message::*;
|
||||
pub use room::*;
|
||||
|
||||
/// A static keypair used only for signing locally-cached rumor events.
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx);
|
||||
}
|
||||
@@ -44,16 +46,14 @@ pub enum ChatEvent {
|
||||
/// No Inbox Relays found, the app is not ready to subscribe messages
|
||||
InboxRelayNotFound,
|
||||
/// An error occurred
|
||||
Error(SharedString),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Channel signal.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Signal {
|
||||
/// Inbox Relays found, the app is ready to subscribe messages
|
||||
InboxReady(Box<Event>),
|
||||
/// No Inbox Relays found, the app is not ready to subscribe messages
|
||||
InboxRelayNotFound,
|
||||
InboxReady,
|
||||
/// Message received from relay pool
|
||||
Message(NewMessage),
|
||||
/// Eose received from relay pool
|
||||
@@ -67,18 +67,6 @@ impl Signal {
|
||||
Self::Message(NewMessage::new(gift_wrap, rumor))
|
||||
}
|
||||
|
||||
pub fn inbox_ready(event: &Event) -> Self {
|
||||
Self::InboxReady(Box::new(event.to_owned()))
|
||||
}
|
||||
|
||||
pub fn inbox_relay_not_found() -> Self {
|
||||
Self::InboxRelayNotFound
|
||||
}
|
||||
|
||||
pub fn eose() -> Self {
|
||||
Self::Eose
|
||||
}
|
||||
|
||||
pub fn error<T>(event: &Event, reason: T) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
@@ -105,9 +93,6 @@ pub struct ChatRegistry {
|
||||
/// Tracking the status of unwrapping gift wrap events.
|
||||
tracking: Arc<AtomicBool>,
|
||||
|
||||
/// Whether the messaging relays have been found.
|
||||
msg_relays_existed: Arc<AtomicBool>,
|
||||
|
||||
/// Channel for sending signals to the UI.
|
||||
signal_tx: flume::Sender<Signal>,
|
||||
|
||||
@@ -117,6 +102,12 @@ pub struct ChatRegistry {
|
||||
/// Async tasks
|
||||
tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
|
||||
|
||||
/// Notification listener task (cancelled on signer change)
|
||||
notification_listener: Option<Task<Result<(), Error>>>,
|
||||
|
||||
/// Signal consumer task (cancelled on signer change)
|
||||
signal_consumer: Option<Task<Result<(), Error>>>,
|
||||
|
||||
/// Subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
@@ -137,17 +128,15 @@ impl ChatRegistry {
|
||||
/// Create a new chat registry instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let user_signer = nostr.read(cx).signer.clone();
|
||||
|
||||
let (tx, rx) = flume::unbounded::<Signal>();
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to the signer event
|
||||
cx.observe(&user_signer, |this, signer, cx| {
|
||||
if let Some(keys) = signer.read(cx).clone() {
|
||||
cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||
if event.signer_changed() {
|
||||
this.reset(cx);
|
||||
this.handle_notifications(keys, cx);
|
||||
this.handle_notifications(cx);
|
||||
this.get_metadata(cx);
|
||||
this.get_rooms(cx);
|
||||
};
|
||||
@@ -166,22 +155,26 @@ impl ChatRegistry {
|
||||
seens: Arc::new(RwLock::new(HashMap::default())),
|
||||
event_map: Arc::new(RwLock::new(HashMap::default())),
|
||||
tracking: Arc::new(AtomicBool::new(false)),
|
||||
msg_relays_existed: Arc::new(AtomicBool::new(false)),
|
||||
signal_rx: rx,
|
||||
signal_tx: tx,
|
||||
tasks: smallvec![],
|
||||
notification_listener: None,
|
||||
signal_consumer: None,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle nostr notifications
|
||||
fn handle_notifications(&mut self, signer: Keys, cx: &mut Context<Self>) {
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
// Cancel previous notification tasks before spawning new ones
|
||||
self.notification_listener = None;
|
||||
self.signal_consumer = None;
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let tracking = self.tracking.clone();
|
||||
let msg_relays_existed = self.msg_relays_existed.clone();
|
||||
|
||||
let seens = self.seens.clone();
|
||||
let event_map = self.event_map.clone();
|
||||
let trashes = self.trashes.downgrade();
|
||||
@@ -194,7 +187,7 @@ impl ChatRegistry {
|
||||
let tx = self.signal_tx.clone();
|
||||
let rx = self.signal_rx.clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
self.notification_listener = Some(cx.background_spawn(async move {
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed_events = HashSet::new();
|
||||
|
||||
@@ -206,12 +199,6 @@ impl ChatRegistry {
|
||||
|
||||
match *message {
|
||||
RelayMessage::Event { event, .. } => {
|
||||
// Keep track of which relays have seen this event
|
||||
{
|
||||
let mut seens = seens.write().await;
|
||||
seens.entry(event.id).or_default().insert(relay_url);
|
||||
}
|
||||
|
||||
// De-duplicate events by their ID
|
||||
if !processed_events.insert(event.id) {
|
||||
continue;
|
||||
@@ -220,12 +207,9 @@ impl ChatRegistry {
|
||||
// Handle msg relays event to determine when the app is ready to subscribe
|
||||
if event.kind == Kind::InboxRelays {
|
||||
let current_user = signer.get_public_key_async().await?;
|
||||
|
||||
// Emit the inbox ready signal
|
||||
if event.pubkey == current_user {
|
||||
// Mark that the msg relays have been found
|
||||
msg_relays_existed.store(true, Ordering::Release);
|
||||
// Emit the inbox ready signal
|
||||
tx.send_async(Signal::inbox_ready(&event)).await?;
|
||||
tx.send_async(Signal::InboxReady).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,13 +218,23 @@ impl ChatRegistry {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep track of which relays have seen this event
|
||||
{
|
||||
let mut seens = seens.write().unwrap();
|
||||
seens.entry(event.id).or_default().insert(relay_url);
|
||||
}
|
||||
|
||||
// Extract the rumor from the gift wrap event
|
||||
match extract_rumor(&client, &signer, event.as_ref()).await {
|
||||
Ok(rumor) => {
|
||||
// Map the rumor id to the gift wrap event id for later lookup
|
||||
let Some(rumor_id) = rumor.id else {
|
||||
log::error!("Rumor missing id after ensure_id");
|
||||
continue;
|
||||
};
|
||||
{
|
||||
let mut event_map = event_map.write().await;
|
||||
event_map.insert(rumor.id.unwrap(), event.id);
|
||||
let mut event_map = event_map.write().unwrap();
|
||||
event_map.insert(rumor_id, event.id);
|
||||
}
|
||||
|
||||
// Check if the rumor has a recipient
|
||||
@@ -268,7 +262,7 @@ impl ChatRegistry {
|
||||
RelayMessage::EndOfStoredEvents(id)
|
||||
if (id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2) =>
|
||||
{
|
||||
tx.send_async(Signal::eose()).await?;
|
||||
tx.send_async(Signal::Eose).await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -277,7 +271,7 @@ impl ChatRegistry {
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
|
||||
while let Ok(message) = rx.recv_async().await {
|
||||
match message {
|
||||
Signal::Message(message) => {
|
||||
@@ -285,14 +279,9 @@ impl ChatRegistry {
|
||||
this.new_message(message, cx);
|
||||
})?;
|
||||
}
|
||||
Signal::InboxReady(event) => {
|
||||
Signal::InboxReady => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_messages(&event, cx);
|
||||
})?;
|
||||
}
|
||||
Signal::InboxRelayNotFound => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(ChatEvent::InboxRelayNotFound);
|
||||
this.get_messages(cx);
|
||||
})?;
|
||||
}
|
||||
Signal::Eose => {
|
||||
@@ -313,22 +302,23 @@ impl ChatRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Tracking the status of unwrapping gift wrap events.
|
||||
/// Check periodically whether old gift-wrap events have finished processing,
|
||||
/// and refresh rooms once the backlog is caught up.
|
||||
fn tracking(&mut self, cx: &mut Context<Self>) {
|
||||
let status = self.tracking.clone();
|
||||
let tx = self.signal_tx.clone();
|
||||
let check_interval = Duration::from_secs(15);
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let loop_duration = Duration::from_secs(15);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |_, cx| {
|
||||
loop {
|
||||
if status.load(Ordering::Acquire) {
|
||||
_ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed);
|
||||
_ = tx.send_async(Signal::Eose).await;
|
||||
} else {
|
||||
cx.background_executor().timer(check_interval).await;
|
||||
|
||||
// Only trigger a room refresh if old events were being tracked
|
||||
// (i.e. the notification handler set the flag while catching up).
|
||||
// `swap` atomically reads and clears the flag.
|
||||
if status.swap(false, Ordering::AcqRel) {
|
||||
_ = tx.send_async(Signal::Eose).await;
|
||||
}
|
||||
smol::Timer::after(loop_duration).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -338,48 +328,58 @@ impl ChatRegistry {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let subscribe = cx.background_spawn({
|
||||
let client = client.clone();
|
||||
|
||||
// Construct filter for msg relays
|
||||
let msg_relays = Filter::new()
|
||||
async move {
|
||||
let opts =
|
||||
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
let msg_relays = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
let contact_list = Filter::new()
|
||||
.kind(Kind::ContactList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
client
|
||||
.subscribe(vec![msg_relays, contact_list])
|
||||
.close_on(opts)
|
||||
.await
|
||||
}
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
// Wait for subscription to complete (or fail silently)
|
||||
_ = subscribe.await;
|
||||
|
||||
// Give relays time to respond
|
||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Construct filter for contact list
|
||||
let contact_list = Filter::new()
|
||||
.kind(Kind::ContactList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
let found = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.first_owned()
|
||||
.is_some();
|
||||
|
||||
// Subscribe
|
||||
client
|
||||
.subscribe(vec![msg_relays, contact_list])
|
||||
.close_on(opts)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
let tx = self.signal_tx.clone();
|
||||
let msg_relays_existed = self.msg_relays_existed.clone();
|
||||
|
||||
// Reset the status flag
|
||||
msg_relays_existed.store(false, Ordering::Release);
|
||||
|
||||
// Wait for the msg relays to be found or timeout
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
// Wait for 5 seconds
|
||||
smol::Timer::after(Duration::from_secs(5)).await;
|
||||
|
||||
// Then check if the msg relays have been found
|
||||
if !msg_relays_existed.load(Ordering::Acquire) {
|
||||
tx.send_async(Signal::inbox_relay_not_found()).await?;
|
||||
if !found {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(ChatEvent::InboxRelayNotFound);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -387,47 +387,51 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// Get all messages for the provided signer
|
||||
fn get_messages(&mut self, msg_relays: &Event, cx: &mut Context<Self>) {
|
||||
fn get_messages(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let urls: Vec<RelayUrl> = nip17::extract_relay_list(msg_relays).collect();
|
||||
|
||||
let Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
let id = SubscriptionId::new(format!("{}-msg", public_key.to_hex()));
|
||||
|
||||
// Ensure relay connections
|
||||
for url in urls.iter() {
|
||||
client.add_relay(url).and_connect().await?;
|
||||
}
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<RelayUrl, Filter> = urls
|
||||
.into_iter()
|
||||
.map(|relay| (relay, filter.clone()))
|
||||
.collect();
|
||||
|
||||
let output = client.subscribe(target).with_id(id).await?;
|
||||
|
||||
log::info!(
|
||||
"Successfully subscribed to gift-wrap messages on: {:?}",
|
||||
output.success
|
||||
);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
let event = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.first_owned()
|
||||
.ok_or(anyhow::anyhow!("No inbox relays found"))?;
|
||||
|
||||
let relays: Vec<RelayUrl> = nip17::extract_relay_list(&event).collect();
|
||||
for url in relays.iter() {
|
||||
client.add_relay(url).and_connect().await?;
|
||||
}
|
||||
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
let id = SubscriptionId::new(format!("{}-msg", public_key.to_hex()));
|
||||
|
||||
let target: HashMap<RelayUrl, Filter> = relays
|
||||
.into_iter()
|
||||
.map(|relay| (relay, filter.clone()))
|
||||
.collect();
|
||||
|
||||
client.subscribe(target).with_id(id).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
if let Err(e) = task.await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(ChatEvent::Error(SharedString::from(e.to_string())));
|
||||
cx.emit(ChatEvent::Error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
@@ -472,7 +476,8 @@ impl ChatRegistry {
|
||||
/// Count the number of messages seen by a given relay.
|
||||
pub fn count_messages(&self, relay_url: &RelayUrl) -> usize {
|
||||
self.seens
|
||||
.read_blocking()
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|seen| seen.contains(relay_url))
|
||||
.count()
|
||||
@@ -491,7 +496,8 @@ impl ChatRegistry {
|
||||
/// Get the relays that have seen a given rumor id.
|
||||
pub fn rumor_seen_on(&self, id: &EventId) -> Option<HashSet<RelayUrl>> {
|
||||
self.event_map
|
||||
.read_blocking()
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.map(|id| self.seen_on(id))
|
||||
}
|
||||
@@ -499,7 +505,8 @@ impl ChatRegistry {
|
||||
/// Get the relays that have seen a given gift wrap id.
|
||||
pub fn seen_on(&self, id: &EventId) -> HashSet<RelayUrl> {
|
||||
self.seens
|
||||
.read_blocking()
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
@@ -508,25 +515,18 @@ impl ChatRegistry {
|
||||
/// Add a new room to the start of list.
|
||||
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: Into<Room> + 'static,
|
||||
I: Into<Room>,
|
||||
{
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let room: Room = room.into().organize(&public_key);
|
||||
let room: Room = room.into().organize(&public_key);
|
||||
self.rooms.insert(0, cx.new(|_| room));
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.rooms.insert(0, cx.new(|_| room));
|
||||
cx.emit(ChatEvent::Ping);
|
||||
cx.notify();
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.detach();
|
||||
cx.emit(ChatEvent::Ping);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Emit an open room event.
|
||||
@@ -623,7 +623,7 @@ impl ChatRegistry {
|
||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -638,7 +638,9 @@ impl ChatRegistry {
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to load rooms: {}", e);
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(ChatEvent::Error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -733,7 +735,7 @@ impl ChatRegistry {
|
||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -769,7 +771,7 @@ impl ChatRegistry {
|
||||
/// Unwraps a gift-wrapped event and processes its contents.
|
||||
async fn extract_rumor(
|
||||
client: &Client,
|
||||
signer: &Keys,
|
||||
signer: &UniversalSigner,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnsignedEvent, Error> {
|
||||
// Try to get cached rumor first
|
||||
@@ -778,7 +780,7 @@ async fn extract_rumor(
|
||||
}
|
||||
|
||||
// Try to unwrap with the available signer
|
||||
let unwrapped = try_unwrap(signer, gift_wrap).await?;
|
||||
let unwrapped = try_unwrap_with(signer, gift_wrap).await?;
|
||||
let mut rumor = unwrapped.rumor;
|
||||
|
||||
// Generate event id for the rumor if it doesn't have one
|
||||
@@ -792,27 +794,11 @@ async fn extract_rumor(
|
||||
Ok(rumor)
|
||||
}
|
||||
|
||||
/// Helper method to try unwrapping with different signers
|
||||
async fn try_unwrap(signer: &Keys, gift_wrap: &Event) -> Result<UnwrappedGift, Error> {
|
||||
/*
|
||||
* // Try with the device signer first
|
||||
if let Some(signer) = signer.get_encryption_signer().await {
|
||||
log::info!("trying with encryption key");
|
||||
if let Ok(unwrapped) = try_unwrap_with(gift_wrap, &signer).await {
|
||||
return Ok(unwrapped);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the user's signer
|
||||
let user_signer = signer.get().await;
|
||||
*/
|
||||
let unwrapped = try_unwrap_with(gift_wrap, signer).await?;
|
||||
|
||||
Ok(unwrapped)
|
||||
}
|
||||
|
||||
/// Attempts to unwrap a gift wrap event with a given signer.
|
||||
async fn try_unwrap_with(gift_wrap: &Event, signer: &Keys) -> Result<UnwrappedGift, Error> {
|
||||
async fn try_unwrap_with(
|
||||
signer: &UniversalSigner,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnwrappedGift, Error> {
|
||||
// Get the sealed event
|
||||
let seal = signer
|
||||
.nip44_decrypt_async(&gift_wrap.pubkey, &gift_wrap.content)
|
||||
@@ -866,7 +852,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
|
||||
// Construct the event
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||
.tags(tags)
|
||||
.finalize_async(&Keys::generate())
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
// Save the event to the database
|
||||
|
||||
@@ -10,7 +10,7 @@ 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;
|
||||
|
||||
@@ -427,7 +427,7 @@ impl Room {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
// Get current user's public key
|
||||
let sender = nostr.read(cx).signer_pubkey(cx)?;
|
||||
let sender = nostr.read(cx).current_user()?;
|
||||
|
||||
// Get all members, excluding the sender
|
||||
let members: Vec<Person> = self
|
||||
@@ -482,13 +482,12 @@ impl Room {
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let user_signer = nostr.read(cx).signer();
|
||||
let current_user = nostr.read(cx).current_user()?;
|
||||
|
||||
// Get current user's public key
|
||||
let user_signer = nostr.read(cx).signer(cx)?;
|
||||
let public_key = nostr.read(cx).signer_pubkey(cx)?;
|
||||
|
||||
// Get sender's profile
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let sender = persons.read(cx).get(&public_key, cx);
|
||||
let sender = persons.read(cx).get(¤t_user, cx);
|
||||
|
||||
// Get all members (excluding sender)
|
||||
let members: Vec<Person> = self
|
||||
@@ -597,7 +596,7 @@ impl Room {
|
||||
// Helper function to send a gift-wrapped event
|
||||
async fn send_gift_wrap(
|
||||
client: &Client,
|
||||
signer: &Keys,
|
||||
signer: &UniversalSigner,
|
||||
receiver: &Person,
|
||||
rumor: &UnsignedEvent,
|
||||
config: &SignerKind,
|
||||
|
||||
@@ -14,19 +14,16 @@ 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
|
||||
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"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::sync::Arc;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::sync::RwLock as AsyncRwLock;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
pub use actions::*;
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
@@ -18,7 +20,8 @@ use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry};
|
||||
use settings::{AppSettings, SignerKind};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use smol::lock::RwLock;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use smol::lock::RwLock as AsyncRwLock;
|
||||
use state::{NostrRegistry, upload};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
@@ -60,7 +63,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>,
|
||||
@@ -72,7 +75,7 @@ pub struct ChatPanel {
|
||||
subject_bar: Entity<bool>,
|
||||
|
||||
/// Sent message ids
|
||||
sent_ids: Arc<RwLock<Vec<EventId>>>,
|
||||
sent_ids: Arc<AsyncRwLock<Vec<EventId>>>,
|
||||
|
||||
/// Replies to
|
||||
replies_to: Entity<HashSet<EventId>>,
|
||||
@@ -95,7 +98,7 @@ impl ChatPanel {
|
||||
// Define attachments and replies_to entities
|
||||
let attachments = cx.new(|_| vec![]);
|
||||
let replies_to = cx.new(|_| HashSet::new());
|
||||
let reports_by_id = cx.new(|_| BTreeMap::new());
|
||||
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
||||
|
||||
// Define list of messages
|
||||
let messages = BTreeSet::default();
|
||||
@@ -169,7 +172,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(AsyncRwLock::new(Vec::new())),
|
||||
uploading: false,
|
||||
subscriptions,
|
||||
tasks: vec![],
|
||||
@@ -189,7 +192,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);
|
||||
|
||||
@@ -220,10 +223,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;
|
||||
@@ -240,10 +244,10 @@ impl ChatPanel {
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
this.update(cx, |_, cx| cx.notify()).ok();
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
@@ -342,69 +346,46 @@ 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();
|
||||
|
||||
// Add empty reports
|
||||
self.insert_reports(id, vec![], cx);
|
||||
|
||||
// Upgrade room reference
|
||||
let Some(room) = self.room.upgrade() else {
|
||||
// Upgrade room and create the rumor synchronously
|
||||
let Some(room_entity) = room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(rumor) = room_entity.read(cx).rumor(content.clone(), replies, cx) else {
|
||||
window.push_notification("Failed to create message", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
// Get the send message task
|
||||
let Some(task) = room.read(cx).send(rumor, cx) else {
|
||||
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);
|
||||
|
||||
// Get the send task
|
||||
let Some(send_task) = room_entity.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;
|
||||
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
|
||||
@@ -426,10 +407,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
|
||||
@@ -463,13 +447,12 @@ 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()
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -9,15 +9,11 @@ gpui.workspace = true
|
||||
nostr.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"
|
||||
|
||||
@@ -14,12 +14,10 @@ settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.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
|
||||
|
||||
@@ -15,7 +15,7 @@ use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{Announcement, CLIENT_NAME, NostrRegistry};
|
||||
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::Button;
|
||||
@@ -66,7 +66,7 @@ pub struct DeviceRegistry {
|
||||
pub announcement_existed: Arc<AtomicBool>,
|
||||
|
||||
/// Signer
|
||||
signer: Entity<Option<Keys>>,
|
||||
signer: Entity<Option<UniversalSigner>>,
|
||||
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
@@ -90,14 +90,11 @@ impl DeviceRegistry {
|
||||
|
||||
/// Create a new device registry instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let signer = cx.new(|_| None);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let user_signer = nostr.read(cx).signer.clone();
|
||||
|
||||
let settings = AppSettings::global(cx);
|
||||
let is_nip4e_enabled = settings.read(cx).is_nip4e_enabled(cx);
|
||||
let nip4e_enabled = settings.read(cx).is_nip4e_enabled(cx);
|
||||
|
||||
let signer = cx.new(|_| None);
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
@@ -111,10 +108,10 @@ impl DeviceRegistry {
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the user signer
|
||||
cx.observe(&user_signer, move |this, signer, cx| {
|
||||
if signer.read(cx).is_some() && is_nip4e_enabled {
|
||||
cx.subscribe(&nostr, move |this, _nostr, event, cx| {
|
||||
if event.signer_changed() && nip4e_enabled {
|
||||
this.get_announcement(cx);
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -134,7 +131,7 @@ impl DeviceRegistry {
|
||||
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let current_user = nostr.read(cx).signer_pubkey(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let announcement_existed = self.announcement_existed.clone();
|
||||
let (tx, rx) = flume::bounded::<Event>(100);
|
||||
@@ -142,6 +139,7 @@ impl DeviceRegistry {
|
||||
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
|
||||
@@ -205,14 +203,14 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// Get the signer
|
||||
pub fn signer(&self, cx: &App) -> Option<Keys> {
|
||||
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(&mut self, new: Keys, cx: &mut Context<Self>) {
|
||||
fn set_signer(&mut self, new_signer: Keys, cx: &mut Context<Self>) {
|
||||
self.signer.update(cx, |this, cx| {
|
||||
*this = Some(new);
|
||||
*this = Some(UniversalSigner::new(new_signer));
|
||||
cx.notify();
|
||||
});
|
||||
cx.emit(DeviceEvent::Set);
|
||||
@@ -222,10 +220,7 @@ impl DeviceRegistry {
|
||||
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return Task::ready(Err(anyhow!("Signer is required")));
|
||||
};
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let keys = get_keys(&client, &signer).await?;
|
||||
@@ -241,13 +236,11 @@ impl DeviceRegistry {
|
||||
pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(current_user) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
return;
|
||||
};
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
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()
|
||||
@@ -267,25 +260,18 @@ impl DeviceRegistry {
|
||||
let announcement_existed = self.announcement_existed.clone();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if !cx
|
||||
.background_spawn(async move {
|
||||
// Wait for 5 seconds
|
||||
smol::Timer::after(Duration::from_secs(5)).await;
|
||||
// Wait for 5 seconds
|
||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
||||
|
||||
// Then check if the msg relays have been found
|
||||
if !announcement_existed.load(Ordering::Acquire) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
})
|
||||
.await
|
||||
{
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::NotSet);
|
||||
})?;
|
||||
// Then check if the msg relays have been found
|
||||
if announcement_existed.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(DeviceEvent::NotSet);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
@@ -316,14 +302,11 @@ 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();
|
||||
|
||||
let Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return Task::ready(Err(anyhow!("Signer is required")));
|
||||
};
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Construct an announcement event
|
||||
let event = EventBuilder::new(Kind::Custom(10044), "")
|
||||
@@ -352,10 +335,7 @@ 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 Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return;
|
||||
};
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let announcement = Announcement::from(event);
|
||||
let device_pubkey = announcement.public_key();
|
||||
@@ -391,10 +371,7 @@ impl DeviceRegistry {
|
||||
fn wait_for_request(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return;
|
||||
};
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
@@ -417,16 +394,12 @@ impl DeviceRegistry {
|
||||
pub fn request(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(app_keys) = get_or_init_app_keys(cx) else {
|
||||
return;
|
||||
};
|
||||
let app_keys_task = get_or_init_app_keys(cx);
|
||||
|
||||
let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
|
||||
let app_keys = app_keys_task.await?;
|
||||
let app_pubkey = app_keys.public_key();
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
@@ -485,10 +458,7 @@ impl DeviceRegistry {
|
||||
fn wait_for_approval(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return;
|
||||
};
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
cx.emit(DeviceEvent::Requesting);
|
||||
|
||||
@@ -510,11 +480,10 @@ impl DeviceRegistry {
|
||||
|
||||
/// Parse the approval event to get encryption key then set it
|
||||
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
let Ok(app_keys) = get_or_init_app_keys(cx) else {
|
||||
return;
|
||||
};
|
||||
let app_keys_task = get_or_init_app_keys(cx);
|
||||
|
||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
||||
let app_keys = app_keys_task.await?;
|
||||
let master = event
|
||||
.tags
|
||||
.iter()
|
||||
@@ -553,10 +522,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 Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return;
|
||||
};
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
// Get user's write relays
|
||||
let event = event.clone();
|
||||
@@ -597,7 +563,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| {
|
||||
@@ -615,8 +581,9 @@ impl DeviceRegistry {
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Handle encryption request
|
||||
@@ -739,37 +706,38 @@ impl DeviceRegistry {
|
||||
|
||||
struct DeviceNotification;
|
||||
|
||||
/// Get or create new app keys
|
||||
fn get_or_init_app_keys(cx: &App) -> Result<Keys, Error> {
|
||||
/// Get or create new app keys (async, returns a task)
|
||||
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
|
||||
let read = cx.read_credentials(CLIENT_NAME);
|
||||
let stored_keys: Option<Keys> = cx.foreground_executor().block_on(async move {
|
||||
if let Ok(Some((_, secret))) = read.await {
|
||||
SecretKey::from_slice(&secret).map(Keys::new).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(keys) = stored_keys {
|
||||
Ok(keys)
|
||||
} else {
|
||||
cx.spawn(async move |cx| {
|
||||
if let Ok(Some((_, secret))) = read.await
|
||||
&& let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
|
||||
{
|
||||
return Ok(keys);
|
||||
}
|
||||
|
||||
// No stored keys found or invalid — generate new ones
|
||||
let keys = Keys::generate();
|
||||
let user = keys.public_key().to_hex();
|
||||
let secret = keys.secret_key().to_secret_bytes();
|
||||
let write = cx.write_credentials(CLIENT_NAME, &user, &secret);
|
||||
|
||||
cx.foreground_executor().block_on(async move {
|
||||
if let Err(e) = write.await {
|
||||
log::error!("Keyring not available or panic: {e}")
|
||||
}
|
||||
cx.update(|cx| {
|
||||
let write = cx.write_credentials(CLIENT_NAME, &user, &secret);
|
||||
cx.background_spawn(async move {
|
||||
if let Err(e) = write.await {
|
||||
log::error!("Keyring not available or panic: {e}")
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Encrypt and store device keys in the local database.
|
||||
async fn set_keys(client: &Client, signer: &Keys, secret: &str) -> Result<(), Error> {
|
||||
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?;
|
||||
|
||||
@@ -786,7 +754,7 @@ async fn set_keys(client: &Client, signer: &Keys, secret: &str) -> Result<(), Er
|
||||
}
|
||||
|
||||
/// Get device keys from the local database.
|
||||
async fn get_keys(client: &Client, signer: &Keys) -> Result<Keys, Error> {
|
||||
async fn get_keys(client: &Client, signer: &UniversalSigner) -> Result<Keys, Error> {
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
|
||||
@@ -12,7 +12,6 @@ gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
flume.workspace = true
|
||||
log.workspace = true
|
||||
urlencoding = "2.1.3"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
@@ -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>>>,
|
||||
seens: RefCell<HashSet<PublicKey>>,
|
||||
|
||||
/// Sender for requesting metadata
|
||||
sender: flume::Sender<PublicKey>,
|
||||
@@ -67,49 +66,38 @@ impl PersonRegistry {
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Handle nostr notifications
|
||||
cx.background_spawn({
|
||||
let client = client.clone();
|
||||
tasks.push(cx.background_spawn({
|
||||
let client = client.clone();
|
||||
async move {
|
||||
Self::handle_notifications(&client, &tx).await;
|
||||
}
|
||||
}));
|
||||
|
||||
async move {
|
||||
Self::handle_notifications(&client, &tx).await;
|
||||
}
|
||||
}),
|
||||
);
|
||||
tasks.push(cx.background_spawn({
|
||||
let client = client.clone();
|
||||
async move {
|
||||
Self::handle_requests(&client, &mta_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,7 +106,7 @@ impl PersonRegistry {
|
||||
|
||||
Self {
|
||||
persons: HashMap::new(),
|
||||
seens: Rc::new(RefCell::new(HashSet::new())),
|
||||
seens: RefCell::new(HashSet::new()),
|
||||
sender: mta_tx,
|
||||
tasks,
|
||||
}
|
||||
@@ -145,24 +133,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 +171,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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,15 +283,14 @@ impl PersonRegistry {
|
||||
}
|
||||
|
||||
let public_key = *public_key;
|
||||
let mut seen = self.seens.borrow_mut();
|
||||
|
||||
if seen.insert(public_key) {
|
||||
if self.seens.borrow_mut().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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,372 +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 Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return Task::ready(Err(anyhow!("Signer is required")));
|
||||
};
|
||||
|
||||
// Get all pending events for the relay
|
||||
let req = req.clone();
|
||||
let pending_events = self.get_pending_events(req.url(), cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Construct event
|
||||
let event = EventBuilder::auth(req.challenge(), req.url().clone())
|
||||
.finalize_async(&signer)
|
||||
.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?;
|
||||
|
||||
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);
|
||||
|
||||
if let Err(e) = result {
|
||||
window
|
||||
.push_notification(Notification::error(e.to_string()).autohide(false), cx);
|
||||
} else {
|
||||
// Clear pending events for the authenticated relay
|
||||
this.clear_pending_events(url, cx);
|
||||
|
||||
let domain = url.domain().unwrap_or_default();
|
||||
let msg = format!("Relay {} has been authenticated", domain);
|
||||
|
||||
window.push_notification(Notification::success(msg), cx);
|
||||
|
||||
// Save the authenticated relay to automatically authenticate future requests
|
||||
settings.update(cx, |this, cx| {
|
||||
this.add_trusted_relay(url, 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;
|
||||
@@ -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
|
||||
|
||||
@@ -229,13 +229,14 @@ impl AppSettings {
|
||||
/// 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| {
|
||||
@@ -254,11 +255,10 @@ impl AppSettings {
|
||||
/// Save settings
|
||||
pub fn save(&mut self, cx: &mut Context<Self>) {
|
||||
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");
|
||||
// Write settings to file
|
||||
smol::fs::write(&path, content).await.ok();
|
||||
})
|
||||
.detach();
|
||||
|
||||
@@ -9,22 +9,25 @@ 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
|
||||
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"
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -3,31 +3,38 @@ use std::time::Duration;
|
||||
|
||||
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 nip05;
|
||||
mod nip4e;
|
||||
mod signer;
|
||||
|
||||
pub use blossom::*;
|
||||
pub use constants::*;
|
||||
pub use nip4e::*;
|
||||
pub use nip05::*;
|
||||
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);
|
||||
@@ -40,21 +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,
|
||||
/// 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
|
||||
@@ -63,8 +76,11 @@ pub struct NostrRegistry {
|
||||
/// Nostr client
|
||||
client: Client,
|
||||
|
||||
/// Currently active signer
|
||||
pub signer: Entity<Option<Keys>>,
|
||||
/// Universal signer
|
||||
signer: UniversalSigner,
|
||||
|
||||
/// Current user's public key
|
||||
current_user: Option<PublicKey>,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
@@ -85,18 +101,24 @@ impl NostrRegistry {
|
||||
|
||||
/// Create a new nostr instance
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let signer = cx.new(|_| None);
|
||||
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()
|
||||
.database(lmdb)
|
||||
.database(database)
|
||||
.authenticator(authenticator)
|
||||
.gossip(NostrGossipMemory::unbounded())
|
||||
.gossip_config(GossipConfig::default().no_background_refresh())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
@@ -105,14 +127,16 @@ impl NostrRegistry {
|
||||
})
|
||||
.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);
|
||||
this.connect_bootstrap_relays(cx);
|
||||
this.get_user_credential(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
client,
|
||||
signer,
|
||||
current_user: None,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
@@ -122,26 +146,48 @@ impl NostrRegistry {
|
||||
self.client.clone()
|
||||
}
|
||||
|
||||
/// Get the signer
|
||||
pub fn signer(&self, cx: &App) -> Option<Keys> {
|
||||
self.signer.read(cx).clone()
|
||||
/// Get the current signer
|
||||
pub fn signer(&self) -> UniversalSigner {
|
||||
self.signer.clone()
|
||||
}
|
||||
|
||||
/// Get the public key of the signer
|
||||
pub fn signer_pubkey(&self, cx: &App) -> Option<PublicKey> {
|
||||
self.signer.read(cx).as_ref().map(|s| s.public_key())
|
||||
/// Get the current user's public key
|
||||
pub fn current_user(&self) -> Option<PublicKey> {
|
||||
self.current_user
|
||||
}
|
||||
|
||||
/// Set the signer to the given keys
|
||||
pub fn set_signer(&mut self, new_keys: Keys, cx: &mut Context<Self>) {
|
||||
self.signer.update(cx, |this, cx| {
|
||||
*this = Some(new_keys);
|
||||
cx.notify();
|
||||
/// 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()));
|
||||
})?;
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
@@ -164,24 +210,90 @@ impl NostrRegistry {
|
||||
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(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Check the user's credential and set the signer if valid
|
||||
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 user_keyring.await {
|
||||
Ok(Some((_username, secret))) => {
|
||||
let content = String::from_utf8(secret)?;
|
||||
|
||||
if content.starts_with("nsec1") {
|
||||
let secret_key = SecretKey::parse(&content)?;
|
||||
let keys = Keys::new(secret_key);
|
||||
|
||||
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)?;
|
||||
|
||||
// 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);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(signer, cx);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
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> {
|
||||
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 query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
|
||||
let client = self.client();
|
||||
@@ -291,10 +403,7 @@ 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 Some(signer) = self.signer.read(cx).clone() else {
|
||||
return Task::ready(Err(anyhow!("Signer is required")));
|
||||
};
|
||||
let signer = self.signer.clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Construct a vertex request event
|
||||
|
||||
@@ -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 {
|
||||
|
||||
206
crates/state/src/signer.rs
Normal file
206
crates/state/src/signer.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
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::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
|
||||
|
||||
impl fmt::Display for UniversalSignerError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
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: 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 {
|
||||
inner: Arc::new(RwLock::new(Arc::new(InnerSignerImpl(signer)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Swap the inner signer in-place. All clones see the new signer.
|
||||
pub fn swap_inner<T>(&self, new_signer: T)
|
||||
where
|
||||
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.inner.write().expect("RwLock poisoned") = Arc::new(InnerSignerImpl(new_signer));
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
|
||||
fn nip44_encrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
content: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||
fn nip44_decrypt_async<'a>(
|
||||
&'a self,
|
||||
public_key: &'a PublicKey,
|
||||
payload: &'a str,
|
||||
) -> 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(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -3,6 +3,7 @@ pub enum PlatformKind {
|
||||
Mac,
|
||||
Linux,
|
||||
Windows,
|
||||
Web,
|
||||
}
|
||||
|
||||
impl PlatformKind {
|
||||
@@ -11,22 +12,21 @@ impl PlatformKind {
|
||||
Self::Linux
|
||||
} else if cfg!(target_os = "windows") {
|
||||
Self::Windows
|
||||
} else {
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Self::Mac
|
||||
} else {
|
||||
Self::Web
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_linux(&self) -> bool {
|
||||
matches!(self, Self::Linux)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_windows(&self) -> bool {
|
||||
matches!(self, Self::Windows)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_mac(&self) -> bool {
|
||||
matches!(self, Self::Mac)
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ common = { path = "../common" }
|
||||
theme = { path = "../theme" }
|
||||
|
||||
gpui.workspace = true
|
||||
smol.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
smallvec.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
@@ -20,8 +18,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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
/// FoldMap: Folding projection layer (Wrap rows → Display rows).
|
||||
///
|
||||
/// This module manages code folding by:
|
||||
/// - Filtering out wrap rows that belong to folded regions
|
||||
/// - Maintaining bidirectional mapping: wrap_row ↔ display_row
|
||||
/// - Handling fold state changes and rebuilding the projection
|
||||
use super::folding::FoldRange;
|
||||
use super::wrap_map::WrapMap;
|
||||
|
||||
/// FoldMap projects wrap rows to display rows by hiding folded regions.
|
||||
pub struct FoldMap {
|
||||
/// Mapping: display_row → wrap_row
|
||||
/// index = display_row, value = actual wrap_row
|
||||
visible_wrap_rows: Vec<usize>,
|
||||
|
||||
/// Reverse mapping: wrap_row → display_row
|
||||
/// index = wrap_row, value = Some(display_row) if visible, None if folded
|
||||
wrap_row_to_display_row: Vec<Option<usize>>,
|
||||
|
||||
/// Candidate fold ranges (from tree-sitter/LSP)
|
||||
/// Sorted by start_line, unique start_line
|
||||
candidates: Vec<FoldRange>,
|
||||
|
||||
/// Currently folded ranges
|
||||
/// Subset of candidates, sorted by start_line
|
||||
folded: Vec<FoldRange>,
|
||||
|
||||
/// Flag indicating if the fold projection needs rebuilding
|
||||
/// Used for lazy evaluation to avoid expensive rebuilds on every text change
|
||||
needs_rebuild: bool,
|
||||
|
||||
/// Cached wrap_row_count from last rebuild
|
||||
/// Used to detect if WrapMap changed and rebuild is needed
|
||||
cached_wrap_row_count: usize,
|
||||
}
|
||||
|
||||
impl FoldMap {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
visible_wrap_rows: Vec::new(),
|
||||
wrap_row_to_display_row: Vec::new(),
|
||||
candidates: Vec::new(),
|
||||
folded: Vec::new(),
|
||||
needs_rebuild: true,
|
||||
cached_wrap_row_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update cached wrap_row_count without full rebuild.
|
||||
/// Used when no folds are active (identity mapping assumed).
|
||||
pub(super) fn mark_dirty_with_wrap_count(&mut self, wrap_row_count: usize) {
|
||||
self.needs_rebuild = true;
|
||||
self.cached_wrap_row_count = wrap_row_count;
|
||||
}
|
||||
|
||||
/// Get total number of visible display rows
|
||||
pub fn display_row_count(&self) -> usize {
|
||||
if self.folded.is_empty() {
|
||||
return self.cached_wrap_row_count;
|
||||
}
|
||||
self.visible_wrap_rows.len()
|
||||
}
|
||||
|
||||
/// Convert wrap_row to display_row
|
||||
/// Returns None if the wrap_row is hidden by folding
|
||||
pub fn wrap_row_to_display_row(&self, wrap_row: usize) -> Option<usize> {
|
||||
if self.folded.is_empty() {
|
||||
return if wrap_row < self.cached_wrap_row_count {
|
||||
Some(wrap_row)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
self.wrap_row_to_display_row
|
||||
.get(wrap_row)
|
||||
.copied()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Convert display_row to wrap_row
|
||||
pub fn display_row_to_wrap_row(&self, display_row: usize) -> Option<usize> {
|
||||
if self.folded.is_empty() {
|
||||
return if display_row < self.cached_wrap_row_count {
|
||||
Some(display_row)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
self.visible_wrap_rows.get(display_row).copied()
|
||||
}
|
||||
|
||||
/// Find the nearest visible display_row for a given wrap_row
|
||||
pub fn nearest_visible_display_row(&self, wrap_row: usize) -> usize {
|
||||
if self.folded.is_empty() {
|
||||
return wrap_row.min(self.cached_wrap_row_count.saturating_sub(1));
|
||||
}
|
||||
|
||||
if let Some(dr) = self.wrap_row_to_display_row(wrap_row) {
|
||||
return dr;
|
||||
}
|
||||
|
||||
match self.visible_wrap_rows.binary_search(&wrap_row) {
|
||||
Ok(idx) => idx,
|
||||
Err(insert_pos) => insert_pos.saturating_sub(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set fold candidates (from tree-sitter/LSP), full replacement.
|
||||
pub fn set_candidates(&mut self, mut candidates: Vec<FoldRange>) {
|
||||
// Sort and deduplicate by start_line
|
||||
candidates.sort_by_key(|r| r.start_line);
|
||||
candidates.dedup_by_key(|r| r.start_line);
|
||||
self.candidates = candidates;
|
||||
|
||||
// Remove any folded ranges that are no longer in candidates
|
||||
self.folded.retain(|fold| {
|
||||
self.candidates
|
||||
.iter()
|
||||
.any(|c| c.start_line == fold.start_line)
|
||||
});
|
||||
}
|
||||
|
||||
/// Merge new candidates extracted from an edited region into existing candidates.
|
||||
///
|
||||
/// Replaces candidates within [edit_start_line, edit_end_line] with `new_candidates`,
|
||||
/// keeping candidates outside the edit range intact.
|
||||
pub fn merge_candidates_for_edit(
|
||||
&mut self,
|
||||
edit_start_line: usize,
|
||||
edit_end_line: usize,
|
||||
new_candidates: Vec<FoldRange>,
|
||||
) {
|
||||
// Remove old candidates within the edit range (already done by adjust_folds_for_edit)
|
||||
// But do it again in case adjust wasn't called or range differs
|
||||
self.candidates
|
||||
.retain(|c| c.start_line < edit_start_line || c.start_line > edit_end_line);
|
||||
|
||||
// Add new candidates
|
||||
self.candidates.extend(new_candidates);
|
||||
self.candidates.sort_by_key(|r| r.start_line);
|
||||
self.candidates.dedup_by_key(|r| r.start_line);
|
||||
}
|
||||
|
||||
/// Set a fold at the given start_line (must be in candidates)
|
||||
pub fn set_folded(&mut self, start_line: usize, folded: bool) {
|
||||
if folded {
|
||||
// Find the candidate range for this start_line
|
||||
if let Some(candidate) = self.candidates.iter().find(|c| c.start_line == start_line) {
|
||||
// Add to folded if not already present
|
||||
if !self.folded.iter().any(|f| f.start_line == start_line) {
|
||||
self.folded.push(*candidate);
|
||||
self.folded.sort_by_key(|r| r.start_line);
|
||||
self.needs_rebuild = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove from folded
|
||||
self.folded.retain(|f| f.start_line != start_line);
|
||||
self.needs_rebuild = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle fold at the given start_line
|
||||
pub fn toggle_fold(&mut self, start_line: usize) {
|
||||
let is_folded = self.is_folded_at(start_line);
|
||||
self.set_folded(start_line, !is_folded);
|
||||
}
|
||||
|
||||
/// Check if a line is currently folded
|
||||
pub fn is_folded_at(&self, start_line: usize) -> bool {
|
||||
self.folded.iter().any(|f| f.start_line == start_line)
|
||||
}
|
||||
|
||||
/// Check if a line is a fold candidate
|
||||
pub fn is_fold_candidate(&self, start_line: usize) -> bool {
|
||||
self.candidates.iter().any(|c| c.start_line == start_line)
|
||||
}
|
||||
|
||||
/// Get all fold candidates
|
||||
#[inline]
|
||||
pub fn fold_candidates(&self) -> &[FoldRange] {
|
||||
&self.candidates
|
||||
}
|
||||
|
||||
/// Get all currently folded ranges
|
||||
#[inline]
|
||||
pub fn folded_ranges(&self) -> &[FoldRange] {
|
||||
&self.folded
|
||||
}
|
||||
|
||||
/// Clear all folds
|
||||
#[inline]
|
||||
pub fn clear_folds(&mut self) {
|
||||
self.folded.clear();
|
||||
}
|
||||
|
||||
/// Adjust folds and candidates after a text edit.
|
||||
///
|
||||
/// - Folds/candidates overlapping the edited line range are removed
|
||||
/// - Folds/candidates after the edit are shifted by line_delta
|
||||
///
|
||||
/// This avoids expensive full tree traversal on every keystroke.
|
||||
pub fn adjust_folds_for_edit(
|
||||
&mut self,
|
||||
edit_start_line: usize,
|
||||
edit_end_line: usize,
|
||||
line_delta: isize,
|
||||
) {
|
||||
// Adjust folded ranges
|
||||
if !self.folded.is_empty() {
|
||||
self.folded.retain(|fold| {
|
||||
!(fold.start_line <= edit_end_line && fold.end_line >= edit_start_line)
|
||||
});
|
||||
|
||||
if line_delta != 0 {
|
||||
for fold in &mut self.folded {
|
||||
if fold.start_line > edit_end_line {
|
||||
fold.start_line = (fold.start_line as isize + line_delta).max(0) as usize;
|
||||
fold.end_line = (fold.end_line as isize + line_delta).max(0) as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust candidates the same way
|
||||
if !self.candidates.is_empty() {
|
||||
self.candidates
|
||||
.retain(|c| !(c.start_line <= edit_end_line && c.end_line >= edit_start_line));
|
||||
|
||||
if line_delta != 0 {
|
||||
for c in &mut self.candidates {
|
||||
if c.start_line > edit_end_line {
|
||||
c.start_line = (c.start_line as isize + line_delta).max(0) as usize;
|
||||
c.end_line = (c.end_line as isize + line_delta).max(0) as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.needs_rebuild = true;
|
||||
}
|
||||
|
||||
/// Rebuild the fold mapping after wrap_map or fold state changes
|
||||
///
|
||||
/// This is the core algorithm that projects wrap rows to display rows.
|
||||
pub fn rebuild(&mut self, wrap_map: &WrapMap) {
|
||||
let wrap_row_count = wrap_map.wrap_row_count();
|
||||
|
||||
// Performance optimization: skip rebuild if nothing changed
|
||||
if !self.needs_rebuild && wrap_row_count == self.cached_wrap_row_count {
|
||||
return;
|
||||
}
|
||||
|
||||
self.cached_wrap_row_count = wrap_row_count;
|
||||
|
||||
self.visible_wrap_rows.clear();
|
||||
self.wrap_row_to_display_row = vec![None; wrap_row_count];
|
||||
|
||||
if self.folded.is_empty() {
|
||||
// Fast path: no folds, all wrap rows are visible
|
||||
self.visible_wrap_rows = (0..wrap_row_count).collect();
|
||||
for (display_row, &wrap_row) in self.visible_wrap_rows.iter().enumerate() {
|
||||
self.wrap_row_to_display_row[wrap_row] = Some(display_row);
|
||||
}
|
||||
self.needs_rebuild = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build set of hidden wrap_row ranges from folded buffer lines
|
||||
let mut hidden_ranges = Vec::new();
|
||||
for fold in &self.folded {
|
||||
// Hide wrap rows from (start_line + 1) to (end_line - 1) (inclusive)
|
||||
// Both the first line and last line of the fold remain visible
|
||||
let hide_start_line = fold.start_line + 1;
|
||||
let hide_end_line = fold.end_line.saturating_sub(1);
|
||||
|
||||
if hide_start_line > hide_end_line {
|
||||
continue; // No middle lines to hide (0 or 1 lines between start and end)
|
||||
}
|
||||
|
||||
// Get wrap_row ranges for the hidden buffer lines
|
||||
let start_wrap_row = wrap_map.buffer_line_to_first_wrap_row(hide_start_line);
|
||||
let end_wrap_row = if hide_end_line + 1 < wrap_map.buffer_line_count() {
|
||||
wrap_map.buffer_line_to_first_wrap_row(hide_end_line + 1)
|
||||
} else {
|
||||
wrap_row_count
|
||||
};
|
||||
|
||||
if start_wrap_row < end_wrap_row {
|
||||
hidden_ranges.push(start_wrap_row..end_wrap_row);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge overlapping hidden ranges
|
||||
hidden_ranges.sort_by_key(|r| r.start);
|
||||
let mut merged_hidden = Vec::new();
|
||||
for range in hidden_ranges {
|
||||
if let Some(last) = merged_hidden.last_mut() {
|
||||
if range.start <= *last {
|
||||
// Overlapping or adjacent, merge
|
||||
*last = (*last).max(range.end);
|
||||
} else {
|
||||
merged_hidden.push(range.start);
|
||||
merged_hidden.push(range.end);
|
||||
}
|
||||
} else {
|
||||
merged_hidden.push(range.start);
|
||||
merged_hidden.push(range.end);
|
||||
}
|
||||
}
|
||||
|
||||
// Scan all wrap rows and filter out hidden ones
|
||||
let mut display_row = 0;
|
||||
let mut hidden_iter = merged_hidden.chunks_exact(2);
|
||||
let mut current_hidden = hidden_iter.next();
|
||||
|
||||
for wrap_row in 0..wrap_row_count {
|
||||
// Check if wrap_row is in current hidden range
|
||||
let is_hidden = if let Some(&[start, end]) = current_hidden {
|
||||
if wrap_row >= end {
|
||||
current_hidden = hidden_iter.next();
|
||||
if let Some(&[new_start, new_end]) = current_hidden {
|
||||
wrap_row >= new_start && wrap_row < new_end
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
wrap_row >= start && wrap_row < end
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !is_hidden {
|
||||
self.visible_wrap_rows.push(wrap_row);
|
||||
self.wrap_row_to_display_row[wrap_row] = Some(display_row);
|
||||
display_row += 1;
|
||||
}
|
||||
}
|
||||
|
||||
self.needs_rebuild = false;
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
use std::ops::Range;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use tree_sitter::Node;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use tree_sitter::Tree;
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
/// Stub type for tree-sitter Tree on WASM (tree-sitter not available).
|
||||
pub struct Tree;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
/// Minimum line span for a node to be considered foldable.
|
||||
const MIN_FOLD_LINES: usize = 2;
|
||||
|
||||
/// A fold range representing a foldable code region.
|
||||
///
|
||||
/// The fold range spans from start_line to end_line (inclusive).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct FoldRange {
|
||||
/// Start line (inclusive)
|
||||
pub start_line: usize,
|
||||
/// End line (inclusive)
|
||||
pub end_line: usize,
|
||||
}
|
||||
|
||||
impl FoldRange {
|
||||
pub fn new(start_line: usize, end_line: usize) -> Self {
|
||||
assert!(
|
||||
start_line <= end_line,
|
||||
"fold start_line must be <= end_line"
|
||||
);
|
||||
Self {
|
||||
start_line,
|
||||
end_line,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
/// Check if a named node qualifies as a fold candidate.
|
||||
///
|
||||
/// Uses a structural heuristic: any **named** node spanning ≥ MIN_FOLD_LINES
|
||||
/// is foldable. tree-sitter already parses code into semantic units (functions,
|
||||
/// classes, blocks, etc.), so named nodes naturally correspond to meaningful
|
||||
/// foldable regions across all languages without a per-language node-type list.
|
||||
fn is_foldable_node(node: &Node) -> bool {
|
||||
let start = node.start_position().row;
|
||||
let end = node.end_position().row;
|
||||
end.saturating_sub(start) >= MIN_FOLD_LINES
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
/// Extract fold ranges only within a byte range (for incremental updates after edits).
|
||||
///
|
||||
/// Skips subtrees entirely outside the range, making it O(nodes in range)
|
||||
/// instead of O(all nodes in tree).
|
||||
pub fn extract_fold_ranges_in_range(tree: &Tree, byte_range: Range<usize>) -> Vec<FoldRange> {
|
||||
let mut ranges = Vec::new();
|
||||
let root = tree.root_node();
|
||||
let mut cursor = root.walk();
|
||||
// Skip the root, it's not foldable. Use named_children to skip literal tokens.
|
||||
for child in root.named_children(&mut cursor) {
|
||||
collect_foldable_nodes_in_range(child, &byte_range, &mut ranges);
|
||||
}
|
||||
|
||||
ranges.sort_by_key(|r| r.start_line);
|
||||
ranges.dedup_by_key(|r| r.start_line);
|
||||
ranges
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
/// Recursively collect foldable nodes, skipping subtrees outside byte_range.
|
||||
fn collect_foldable_nodes_in_range(
|
||||
node: Node,
|
||||
byte_range: &Range<usize>,
|
||||
ranges: &mut Vec<FoldRange>,
|
||||
) {
|
||||
if node.end_byte() <= byte_range.start || node.start_byte() >= byte_range.end {
|
||||
return;
|
||||
}
|
||||
|
||||
if !is_foldable_node(&node) {
|
||||
return;
|
||||
}
|
||||
|
||||
ranges.push(FoldRange {
|
||||
start_line: node.start_position().row,
|
||||
end_line: node.end_position().row,
|
||||
});
|
||||
|
||||
let mut cursor = node.walk();
|
||||
for child in node.named_children(&mut cursor) {
|
||||
collect_foldable_nodes_in_range(child, byte_range, ranges);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,7 @@
|
||||
#[allow(clippy::module_inception)]
|
||||
mod display_map;
|
||||
mod fold_map;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod folding;
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub mod folding;
|
||||
mod text_wrapper;
|
||||
mod wrap_map;
|
||||
|
||||
// Re-export public API
|
||||
// Re-export FoldRange and extract_fold_ranges
|
||||
pub use folding::FoldRange;
|
||||
|
||||
pub use self::display_map::DisplayMap;
|
||||
pub(crate) use self::text_wrapper::LineLayout;
|
||||
|
||||
/// Position in the buffer (logical text).
|
||||
///
|
||||
/// - `line`: 0-based logical line number (split by `\n`)
|
||||
/// - `col`: 0-based column offset (byte offset)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct BufferPoint {
|
||||
pub line: usize,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
impl BufferPoint {
|
||||
pub fn new(line: usize, col: usize) -> Self {
|
||||
Self { line, col }
|
||||
}
|
||||
}
|
||||
|
||||
/// Position after soft-wrapping but before folding (internal).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(super) struct WrapPoint {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
impl WrapPoint {
|
||||
pub fn new(row: usize, col: usize) -> Self {
|
||||
Self { row, col }
|
||||
}
|
||||
}
|
||||
|
||||
/// Final display position (after soft-wrapping and folding).
|
||||
///
|
||||
/// - `row`: 0-based display row (final visible row)
|
||||
/// - `col`: 0-based display column
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct DisplayPoint {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
impl DisplayPoint {
|
||||
pub fn new(row: usize, col: usize) -> Self {
|
||||
Self { row, col }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,7 @@ use std::ops::Range;
|
||||
use gpui::{App, Font, Pixels};
|
||||
use ropey::Rope;
|
||||
|
||||
use super::fold_map::FoldMap;
|
||||
use super::text_wrapper::{LineItem, TextWrapper, WrapDisplayPoint};
|
||||
use super::{BufferPoint, WrapPoint};
|
||||
use crate::input::rope_ext::RopeExt;
|
||||
use super::text_wrapper::{LineItem, TextWrapper};
|
||||
|
||||
/// WrapMap manages soft-wrapping and provides buffer ↔ wrap coordinate mapping.
|
||||
pub struct WrapMap {
|
||||
@@ -55,49 +52,6 @@ impl WrapMap {
|
||||
self.wrapper.lines.len()
|
||||
}
|
||||
|
||||
/// Convert buffer position to wrap position
|
||||
pub(super) fn buffer_pos_to_wrap_pos(&self, pos: BufferPoint) -> WrapPoint {
|
||||
let BufferPoint { line, col } = pos;
|
||||
|
||||
// Clamp to valid range
|
||||
let line = line.min(self.buffer_line_count().saturating_sub(1));
|
||||
let line_item = self.wrapper.lines.get(line);
|
||||
|
||||
let col = if let Some(line_item) = line_item {
|
||||
col.min(line_item.len())
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Calculate offset in rope
|
||||
let line_start_offset = self.wrapper.text().line_start_offset(line);
|
||||
let offset = line_start_offset + col;
|
||||
|
||||
// Use TextWrapper's existing conversion
|
||||
let display_point = self.wrapper.offset_to_display_point(offset);
|
||||
|
||||
WrapPoint::new(display_point.row, display_point.column)
|
||||
}
|
||||
|
||||
/// Convert wrap position to buffer position
|
||||
pub(super) fn wrap_pos_to_buffer_pos(&self, pos: WrapPoint) -> BufferPoint {
|
||||
let WrapPoint { row, col } = pos;
|
||||
|
||||
// Clamp wrap_row to valid range
|
||||
let row = row.min(self.wrap_row_count().saturating_sub(1));
|
||||
|
||||
// Use TextWrapper's existing conversion
|
||||
let display_point = WrapDisplayPoint::new(row, 0, col);
|
||||
let offset = self.wrapper.display_point_to_offset(display_point);
|
||||
|
||||
// Convert offset to buffer position
|
||||
let point = self.wrapper.text().offset_to_point(offset);
|
||||
let line_start = self.wrapper.text().line_start_offset(point.row);
|
||||
let col = offset.saturating_sub(line_start);
|
||||
|
||||
BufferPoint::new(point.row, col)
|
||||
}
|
||||
|
||||
/// Get the buffer line for a given wrap row
|
||||
pub fn wrap_row_to_buffer_line(&self, wrap_row: usize) -> usize {
|
||||
if wrap_row >= self.wrap_row_count() {
|
||||
@@ -176,8 +130,6 @@ impl WrapMap {
|
||||
let wrap_row_count = self.wrapper.len();
|
||||
|
||||
// Skip if nothing changed: both buffer line count and total wrap row count must match.
|
||||
// Checking wrap_row_count is essential because soft-wrap can change the number of
|
||||
// wrap rows per line without changing the buffer line count.
|
||||
if line_count == self.cached_line_count
|
||||
&& wrap_row_count == self.cached_wrap_row_count
|
||||
&& !self.buffer_line_starts.is_empty()
|
||||
@@ -212,11 +164,9 @@ impl WrapMap {
|
||||
self.wrapper.text()
|
||||
}
|
||||
|
||||
/// Calculate how many wrap rows of a buffer line are visible (not folded)
|
||||
pub fn visible_wrap_row_count_for_line(&self, line: usize, fold_map: &FoldMap) -> usize {
|
||||
let wrap_range = self.buffer_line_to_wrap_row_range(line);
|
||||
wrap_range
|
||||
.filter(|&wr| fold_map.wrap_row_to_display_row(wr).is_some())
|
||||
.count()
|
||||
/// Calculate how many wrap rows of a buffer line are visible.
|
||||
/// Without folding, all wrap rows are visible.
|
||||
pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize {
|
||||
self.buffer_line_to_wrap_row_range(line).len()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, Bounds, Corners, Edges, Element, ElementId, ElementInputHandler, Entity,
|
||||
GlobalElementId, Half, HighlightStyle, Hitbox, HitboxBehavior, Hsla, InteractiveElement,
|
||||
IntoElement, LayoutId, MouseButton, MouseMoveEvent, MouseUpEvent, Path, Pixels, Point,
|
||||
Position, ShapedLine, SharedString, Size, Style, Styled as _, TextAlign, TextRun, TextStyle,
|
||||
GlobalElementId, Half, Hsla, IntoElement, LayoutId, MouseButton, MouseMoveEvent, MouseUpEvent,
|
||||
Path, Pixels, Point, Position, SharedString, Size, Style, TextAlign, TextRun, TextStyle,
|
||||
UnderlineStyle, Window, fill, point, px, relative, size,
|
||||
};
|
||||
use ropey::Rope;
|
||||
@@ -14,19 +13,14 @@ use theme::ActiveTheme;
|
||||
|
||||
use super::mode::InputMode;
|
||||
use super::{InputState, LastLayout, WhitespaceIndicators};
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::Root;
|
||||
use crate::input::RopeExt as _;
|
||||
use crate::input::blink_cursor::CURSOR_WIDTH;
|
||||
use crate::input::display_map::LineLayout;
|
||||
use crate::scroll::Scrollbar;
|
||||
use crate::{IconName, Root, Selectable, Sizable as _};
|
||||
|
||||
const BOTTOM_MARGIN_ROWS: usize = 3;
|
||||
pub(super) const RIGHT_MARGIN: Pixels = px(10.);
|
||||
pub(super) const LINE_NUMBER_RIGHT_MARGIN: Pixels = px(10.);
|
||||
const FOLD_ICON_WIDTH: Pixels = px(14.);
|
||||
const FOLD_ICON_HITBOX_WIDTH: Pixels = px(18.);
|
||||
const MAX_HIGHLIGHT_LINE_LENGTH: usize = 10_000;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct EditorScrollbarLayout {
|
||||
@@ -72,7 +66,7 @@ impl EditorScrollbarLayout {
|
||||
let left = if line_number_width == px(0.) {
|
||||
px(0.)
|
||||
} else {
|
||||
paddings.left + line_number_width - LINE_NUMBER_RIGHT_MARGIN
|
||||
paddings.left + line_number_width
|
||||
};
|
||||
|
||||
Self {
|
||||
@@ -216,14 +210,6 @@ fn masked_display_offset(text: &Rope, original_offset: usize) -> usize {
|
||||
text.offset_to_char_index(original_offset) * MASK_CHAR.len_utf8()
|
||||
}
|
||||
|
||||
/// Layout information for fold icons.
|
||||
struct FoldIconLayout {
|
||||
/// Hitbox for the line number area (used for hover detection)
|
||||
line_number_hitbox: Hitbox,
|
||||
/// List of (display_row, is_folded, icon_element) pairs for each fold candidate
|
||||
icons: Vec<(usize, bool, gpui::AnyElement)>,
|
||||
}
|
||||
|
||||
pub(super) struct TextElement {
|
||||
pub(crate) state: Entity<InputState>,
|
||||
placeholder: SharedString,
|
||||
@@ -285,7 +271,7 @@ impl TextElement {
|
||||
scroll_size: Size<Pixels>,
|
||||
_: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (Option<Bounds<Pixels>>, Point<Pixels>, Option<usize>) {
|
||||
) -> (Option<Bounds<Pixels>>, Point<Pixels>) {
|
||||
let state = self.state.read(cx);
|
||||
|
||||
let line_height = last_layout.line_height;
|
||||
@@ -307,7 +293,6 @@ impl TextElement {
|
||||
cursor = masked_display_offset(&state.text, cursor);
|
||||
}
|
||||
|
||||
let mut current_row = None;
|
||||
let mut scroll_offset = state.scroll_handle.offset();
|
||||
let mut cursor_bounds = None;
|
||||
|
||||
@@ -330,7 +315,6 @@ impl TextElement {
|
||||
let visible_buffer_lines = &last_layout.visible_buffer_lines;
|
||||
let mut vi = 0; // index into visible_buffer_lines / lines
|
||||
for (ix, wrap_line) in buffer_lines.iter().enumerate() {
|
||||
let row = ix;
|
||||
let line_origin = point(px(0.), offset_y);
|
||||
|
||||
// break loop if all cursor positions are found
|
||||
@@ -353,7 +337,6 @@ impl TextElement {
|
||||
if let Some(pos) =
|
||||
line.position_for_index(offset, last_layout, state.cursor_line_end_affinity)
|
||||
{
|
||||
current_row = Some(row);
|
||||
cursor_pos = Some(line_origin + pos);
|
||||
}
|
||||
}
|
||||
@@ -377,7 +360,6 @@ impl TextElement {
|
||||
// Not visible (before visible range or hidden/folded).
|
||||
// Just increase the offset_y and prev_lines_offset for scroll tracking.
|
||||
if prev_lines_offset >= cursor && cursor_pos.is_none() {
|
||||
current_row = Some(row);
|
||||
cursor_pos = Some(line_origin);
|
||||
}
|
||||
if prev_lines_offset >= selected_range.start && cursor_start.is_none() {
|
||||
@@ -494,7 +476,7 @@ impl TextElement {
|
||||
|
||||
bounds.origin += scroll_offset;
|
||||
|
||||
(cursor_bounds, scroll_offset, current_row)
|
||||
(cursor_bounds, scroll_offset)
|
||||
}
|
||||
|
||||
/// Layout the match range to a Path.
|
||||
@@ -642,41 +624,6 @@ impl TextElement {
|
||||
builder.build().ok()
|
||||
}
|
||||
|
||||
fn layout_search_matches(
|
||||
&self,
|
||||
_last_layout: &LastLayout,
|
||||
_bounds: &Bounds<Pixels>,
|
||||
_cx: &mut App,
|
||||
) -> Vec<(Path<Pixels>, bool)> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn layout_hover_highlight(
|
||||
&self,
|
||||
_last_layout: &LastLayout,
|
||||
_bounds: &Bounds<Pixels>,
|
||||
_cx: &mut App,
|
||||
) -> Option<Path<Pixels>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn layout_document_colors(
|
||||
&self,
|
||||
document_colors: &[(Range<usize>, Hsla)],
|
||||
last_layout: &LastLayout,
|
||||
bounds: &Bounds<Pixels>,
|
||||
_cx: &mut App,
|
||||
) -> Vec<(Path<Pixels>, Hsla)> {
|
||||
let mut paths = vec![];
|
||||
for (range, color) in document_colors.iter() {
|
||||
if let Some(path) = Self::layout_match_range(range.clone(), last_layout, bounds) {
|
||||
paths.push((path, *color));
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn layout_selections(
|
||||
&self,
|
||||
last_layout: &LastLayout,
|
||||
@@ -784,267 +731,20 @@ impl TextElement {
|
||||
(visible_range, visible_buffer_lines, visible_top)
|
||||
}
|
||||
|
||||
/// Return (line_number_width, line_number_len)
|
||||
fn layout_line_numbers(
|
||||
state: &InputState,
|
||||
text: &Rope,
|
||||
font_size: Pixels,
|
||||
style: &TextStyle,
|
||||
window: &mut Window,
|
||||
) -> (Pixels, usize) {
|
||||
let total_lines = text.lines_len();
|
||||
let line_number_len = match total_lines {
|
||||
0..=9999 => 5,
|
||||
10000..=99999 => 6,
|
||||
100000..=999999 => 7,
|
||||
_ => 8,
|
||||
};
|
||||
|
||||
let mut line_number_width = if state.mode.line_number() {
|
||||
let empty_line_number = window.text_system().shape_line(
|
||||
"+".repeat(line_number_len).into(),
|
||||
font_size,
|
||||
&[TextRun {
|
||||
len: line_number_len,
|
||||
font: style.font(),
|
||||
color: gpui::black(),
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
}],
|
||||
None,
|
||||
);
|
||||
|
||||
empty_line_number.width + LINE_NUMBER_RIGHT_MARGIN
|
||||
} else if state.mode.is_code_editor() && state.mode.is_multi_line() {
|
||||
LINE_NUMBER_RIGHT_MARGIN
|
||||
} else {
|
||||
px(0.)
|
||||
};
|
||||
|
||||
if state.mode.is_folding() {
|
||||
// Add extra space for fold icons
|
||||
line_number_width += FOLD_ICON_HITBOX_WIDTH
|
||||
}
|
||||
|
||||
(line_number_width, line_number_len)
|
||||
}
|
||||
|
||||
/// Layout shaped lines for whitespace indicators (space and tab).
|
||||
///
|
||||
/// Returns `WhitespaceIndicators` with shaped lines for space and tab characters.
|
||||
fn layout_whitespace_indicators(
|
||||
state: &InputState,
|
||||
_state: &InputState,
|
||||
text_size: Pixels,
|
||||
style: &TextStyle,
|
||||
window: &mut Window,
|
||||
cx: &App,
|
||||
) -> Option<WhitespaceIndicators> {
|
||||
if !state.show_whitespaces {
|
||||
return None;
|
||||
}
|
||||
|
||||
let invisible_color = cx.theme().text_muted;
|
||||
let space_font_size = text_size.half();
|
||||
let tab_font_size = text_size;
|
||||
|
||||
let space_text = SharedString::new_static("•");
|
||||
let space = window.text_system().shape_line(
|
||||
space_text.clone(),
|
||||
space_font_size,
|
||||
&[TextRun {
|
||||
len: space_text.len(),
|
||||
font: style.font(),
|
||||
color: invisible_color,
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
}],
|
||||
None,
|
||||
);
|
||||
|
||||
let tab_text = SharedString::new_static("→");
|
||||
let tab = window.text_system().shape_line(
|
||||
tab_text.clone(),
|
||||
tab_font_size,
|
||||
&[TextRun {
|
||||
len: tab_text.len(),
|
||||
font: style.font(),
|
||||
color: invisible_color,
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
}],
|
||||
None,
|
||||
);
|
||||
|
||||
Some(WhitespaceIndicators { space, tab })
|
||||
}
|
||||
|
||||
/// Compute inline completion ghost lines for rendering.
|
||||
///
|
||||
/// Returns (first_line, ghost_lines) where:
|
||||
/// - first_line: Shaped text for the first line (goes after cursor on same line)
|
||||
/// - ghost_lines: Shaped lines for subsequent lines (shift content down)
|
||||
fn layout_inline_completion(
|
||||
_state: &InputState,
|
||||
_visible_range: &Range<usize>,
|
||||
_font_size: Pixels,
|
||||
_window: &mut Window,
|
||||
_cx: &App,
|
||||
) -> (Option<ShapedLine>, Vec<ShapedLine>) {
|
||||
(None, vec![])
|
||||
}
|
||||
|
||||
/// Return (line_number_width, line_number_len)
|
||||
/// Layout fold icon hitboxes during prepaint phase.
|
||||
///
|
||||
/// This creates hitboxes for the fold icon area, positioned to the right of line numbers.
|
||||
/// Icons are created and prepainted here to avoid panics.
|
||||
fn layout_fold_icons(
|
||||
&self,
|
||||
origin_x: Pixels,
|
||||
bounds: &Bounds<Pixels>,
|
||||
last_layout: &LastLayout,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> FoldIconLayout {
|
||||
// First pass: collect fold information from state
|
||||
struct FoldInfo {
|
||||
buffer_line: usize,
|
||||
is_folded: bool,
|
||||
display_row: usize,
|
||||
offset_y: Pixels,
|
||||
}
|
||||
|
||||
let line_number_hitbox = window.insert_hitbox(
|
||||
Bounds::new(
|
||||
point(origin_x, bounds.origin.y + last_layout.visible_top),
|
||||
size(last_layout.line_number_width, bounds.size.height),
|
||||
),
|
||||
HitboxBehavior::Normal,
|
||||
);
|
||||
|
||||
let mut icon_layout = FoldIconLayout {
|
||||
line_number_hitbox,
|
||||
icons: vec![],
|
||||
};
|
||||
|
||||
let fold_infos: Vec<FoldInfo> = {
|
||||
let state = self.state.read(cx);
|
||||
if !state.mode.is_folding() {
|
||||
return icon_layout;
|
||||
}
|
||||
|
||||
let mut infos = Vec::with_capacity(last_layout.visible_buffer_lines.len());
|
||||
let mut offset_y = last_layout.visible_top;
|
||||
|
||||
for (line, &buffer_line) in last_layout
|
||||
.lines
|
||||
.iter()
|
||||
.zip(last_layout.visible_buffer_lines.iter())
|
||||
{
|
||||
if state.display_map.is_fold_candidate(buffer_line) {
|
||||
let is_folded = state.display_map.is_folded_at(buffer_line);
|
||||
infos.push(FoldInfo {
|
||||
buffer_line,
|
||||
is_folded,
|
||||
display_row: buffer_line,
|
||||
offset_y,
|
||||
});
|
||||
}
|
||||
|
||||
offset_y += line.wrapped_lines.len() * last_layout.line_height;
|
||||
}
|
||||
|
||||
infos
|
||||
}; // state is dropped here
|
||||
|
||||
// Second pass: create and prepaint icons
|
||||
let line_height = last_layout.line_height;
|
||||
let line_number_width =
|
||||
last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN - FOLD_ICON_HITBOX_WIDTH;
|
||||
let icon_relative_pos = point(
|
||||
(FOLD_ICON_HITBOX_WIDTH - FOLD_ICON_WIDTH).half(),
|
||||
(line_height - FOLD_ICON_WIDTH).half(),
|
||||
);
|
||||
|
||||
for (ix, info) in fold_infos.iter().enumerate() {
|
||||
// Position fold icon to the right of line numbers.
|
||||
// Use origin_x (unscrolled) so icons stay fixed in the gutter during horizontal scroll.
|
||||
let fold_icon_bounds = Bounds::new(
|
||||
point(
|
||||
origin_x + icon_relative_pos.x + line_number_width,
|
||||
bounds.origin.y + icon_relative_pos.y + info.offset_y,
|
||||
),
|
||||
size(FOLD_ICON_HITBOX_WIDTH, line_height),
|
||||
);
|
||||
|
||||
// Create and prepaint icon
|
||||
let mut icon = Button::new(("fold", ix))
|
||||
.ghost()
|
||||
.icon(if info.is_folded {
|
||||
IconName::CaretRight
|
||||
} else {
|
||||
IconName::CaretDown
|
||||
})
|
||||
.xsmall()
|
||||
.rounded_xs()
|
||||
.size(FOLD_ICON_WIDTH)
|
||||
.selected(info.is_folded)
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let state = self.state.clone();
|
||||
let buffer_line = info.buffer_line;
|
||||
move |_, _: &mut Window, cx: &mut App| {
|
||||
cx.stop_propagation();
|
||||
|
||||
state.update(cx, |state, cx| {
|
||||
state.display_map.toggle_fold(buffer_line);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
icon.prepaint_as_root(
|
||||
fold_icon_bounds.origin,
|
||||
fold_icon_bounds.size.into(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
icon_layout
|
||||
.icons
|
||||
.push((info.display_row, info.is_folded, icon));
|
||||
}
|
||||
|
||||
icon_layout
|
||||
}
|
||||
|
||||
/// Paint fold icons using prepaint hitboxes.
|
||||
///
|
||||
/// This handles:
|
||||
/// - Rendering fold icons (chevron-right for folded, chevron-down for expanded)
|
||||
/// - Mouse click handling to toggle fold state
|
||||
/// - Cursor style changes on hover
|
||||
/// - Only show icon on hover or for current line
|
||||
fn paint_fold_icons(
|
||||
&mut self,
|
||||
fold_icon_layout: &mut FoldIconLayout,
|
||||
current_row: Option<usize>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let is_hovered = fold_icon_layout.line_number_hitbox.is_hovered(window);
|
||||
for (display_row, is_folded, icon) in fold_icon_layout.icons.iter_mut() {
|
||||
let is_current_line = current_row == Some(*display_row);
|
||||
|
||||
if !is_hovered && !is_current_line && !*is_folded {
|
||||
continue;
|
||||
}
|
||||
|
||||
icon.paint(window, cx);
|
||||
}
|
||||
// Whitespace indicators are not currently enabled.
|
||||
// When re-enabled, check `state.show_whitespaces` to conditionally enable.
|
||||
let _ = (text_size, style, window, cx);
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1098,10 +798,6 @@ impl TextElement {
|
||||
}
|
||||
|
||||
let mut lines = Vec::with_capacity(last_layout.visible_buffer_lines.len());
|
||||
// run_offset tracks position in the runs vec coordinate space (only visible line bytes).
|
||||
// This is separate from the visible_text offset because runs from highlight_lines
|
||||
// only cover visible (non-folded) lines.
|
||||
let mut run_offset = 0;
|
||||
|
||||
for (vi, &buffer_line) in last_layout.visible_buffer_lines.iter().enumerate() {
|
||||
let line_text: String = display_text.slice_line(buffer_line).into();
|
||||
@@ -1112,9 +808,10 @@ impl TextElement {
|
||||
debug_assert_eq!(line_item.len(), line_text.len());
|
||||
|
||||
let mut wrapped_lines = SmallVec::with_capacity(1);
|
||||
let line_offset = display_text.line_start_offset(buffer_line);
|
||||
|
||||
for range in &line_item.wrapped_lines {
|
||||
let line_runs = runs_for_range(runs, run_offset, range);
|
||||
let line_runs = runs_for_range(runs, line_offset, range);
|
||||
let line_runs = if bg_segments.is_empty() {
|
||||
line_runs
|
||||
} else {
|
||||
@@ -1137,107 +834,21 @@ impl TextElement {
|
||||
.lines(wrapped_lines)
|
||||
.with_whitespaces(whitespace_indicators.clone());
|
||||
lines.push(line_layout);
|
||||
|
||||
// +1 for the `\n`
|
||||
run_offset += line_text.len() + 1;
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// First usize is the offset of skipped.
|
||||
fn highlight_lines(
|
||||
&mut self,
|
||||
visible_buffer_lines: &[usize],
|
||||
_visible_top: Pixels,
|
||||
_visible_byte_range: Range<usize>,
|
||||
cx: &mut App,
|
||||
) -> Option<Vec<(Range<usize>, HighlightStyle)>> {
|
||||
let state = self.state.read(cx);
|
||||
let text = &state.text;
|
||||
let is_multi_line = state.mode.is_multi_line();
|
||||
|
||||
let mut styles = Vec::with_capacity(visible_buffer_lines.len());
|
||||
|
||||
// Helper to flush a contiguous range of lines. These ranges are disjoint,
|
||||
// so appending avoids repeatedly cloning and recombining prior styles.
|
||||
let flush_range = |start_line: usize, end_line: usize, _skip: bool, styles: &mut Vec<_>| {
|
||||
let byte_start = text.line_start_offset(start_line);
|
||||
let byte_end = if is_multi_line {
|
||||
// +1 for `\n`
|
||||
text.line_start_offset(end_line + 1)
|
||||
} else {
|
||||
text.line_end_offset(end_line)
|
||||
};
|
||||
let range_styles = vec![(byte_start..byte_end, HighlightStyle::default())];
|
||||
styles.extend(range_styles);
|
||||
};
|
||||
|
||||
// Group contiguous visible lines into ranges and call styles() once per range
|
||||
let mut visible_iter = visible_buffer_lines.iter().peekable();
|
||||
let mut range_start: Option<usize> = None;
|
||||
|
||||
while let Some(&line) = visible_iter.next() {
|
||||
// Check if this line is too long for highlighting
|
||||
let line_len = text.slice_line(line).len();
|
||||
if line_len > MAX_HIGHLIGHT_LINE_LENGTH {
|
||||
// Flush any accumulated range first
|
||||
if let Some(start) = range_start.take() {
|
||||
flush_range(start, line - 1, false, &mut styles);
|
||||
}
|
||||
|
||||
flush_range(line, line, true, &mut styles);
|
||||
continue;
|
||||
}
|
||||
|
||||
range_start.get_or_insert(line);
|
||||
|
||||
// Check if next line is contiguous, if so keep accumulating
|
||||
if visible_iter
|
||||
.peek()
|
||||
.map(|&&next| next == line + 1)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Flush the contiguous range
|
||||
let start_line = range_start.take().unwrap();
|
||||
flush_range(start_line, line, false, &mut styles);
|
||||
}
|
||||
|
||||
Some(styles)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PrepaintState {
|
||||
/// The lines of entire lines.
|
||||
last_layout: LastLayout,
|
||||
/// The lines only contains the visible lines in the viewport, based on `visible_range`.
|
||||
///
|
||||
/// The child is the soft lines.
|
||||
line_numbers: Option<Vec<SmallVec<[ShapedLine; 1]>>>,
|
||||
/// Size of the scrollable area by entire lines.
|
||||
scroll_size: Size<Pixels>,
|
||||
cursor_bounds: Option<Bounds<Pixels>>,
|
||||
cursor_scroll_offset: Point<Pixels>,
|
||||
/// row index (zero based), no wrap, same line as the cursor.
|
||||
current_row: Option<usize>,
|
||||
selection_path: Option<Path<Pixels>>,
|
||||
hover_highlight_path: Option<Path<Pixels>>,
|
||||
search_match_paths: Vec<(Path<Pixels>, bool)>,
|
||||
document_color_paths: Vec<(Path<Pixels>, Hsla)>,
|
||||
hover_definition_hitbox: Option<Hitbox>,
|
||||
indent_guides_path: Option<Path<Pixels>>,
|
||||
bounds: Bounds<Pixels>,
|
||||
/// Fold icon layout data
|
||||
fold_icon_layout: FoldIconLayout,
|
||||
// Inline completion rendering data
|
||||
/// Shaped ghost lines to paint after cursor row (completion lines 2+)
|
||||
ghost_lines: Vec<ShapedLine>,
|
||||
/// First line of inline completion (painted after cursor on same line)
|
||||
ghost_first_line: Option<ShapedLine>,
|
||||
ghost_lines_height: Pixels,
|
||||
}
|
||||
|
||||
impl PrepaintState {
|
||||
@@ -1356,13 +967,6 @@ impl Element for TextElement {
|
||||
.text
|
||||
.line_end_offset(visible_range.end.saturating_sub(1));
|
||||
|
||||
let highlight_styles = self.highlight_lines(
|
||||
&visible_buffer_lines,
|
||||
visible_top,
|
||||
visible_start_offset..visible_end_offset,
|
||||
cx,
|
||||
);
|
||||
|
||||
let state = self.state.read(cx);
|
||||
let multi_line = state.mode.is_multi_line();
|
||||
let text = state.text.clone();
|
||||
@@ -1382,9 +986,8 @@ impl Element for TextElement {
|
||||
(&text, fg)
|
||||
};
|
||||
|
||||
// Calculate the width of the line numbers
|
||||
let (line_number_width, line_number_len) =
|
||||
Self::layout_line_numbers(state, &text, text_size, &text_style, window);
|
||||
// Line numbers are not used (code editor mode removed)
|
||||
let line_number_width = px(0.);
|
||||
|
||||
let mut bounds = bounds;
|
||||
let wrap_width = if multi_line && state.soft_wrap {
|
||||
@@ -1452,28 +1055,7 @@ impl Element for TextElement {
|
||||
};
|
||||
|
||||
let runs = if !is_empty {
|
||||
if let Some(highlight_styles) = highlight_styles {
|
||||
let mut runs = Vec::with_capacity(highlight_styles.len());
|
||||
|
||||
runs.extend(highlight_styles.iter().map(|(range, style)| {
|
||||
let mut run = text_style.clone().highlight(*style).to_run(range.len());
|
||||
|
||||
if let Some(ime_marked_range) = &state.ime_marked_range
|
||||
&& range.start >= ime_marked_range.start
|
||||
&& range.end <= ime_marked_range.end
|
||||
{
|
||||
run.color = marked_run.color;
|
||||
run.strikethrough = marked_run.strikethrough;
|
||||
run.underline = marked_run.underline;
|
||||
}
|
||||
|
||||
run
|
||||
}));
|
||||
|
||||
runs.into_iter().filter(|run| run.len > 0).collect()
|
||||
} else {
|
||||
vec![run]
|
||||
}
|
||||
vec![run]
|
||||
} else if let Some(ime_marked_range) = &state.ime_marked_range {
|
||||
// IME marked text
|
||||
vec![
|
||||
@@ -1498,8 +1080,6 @@ impl Element for TextElement {
|
||||
vec![run]
|
||||
};
|
||||
|
||||
let document_colors = [];
|
||||
|
||||
// Create shaped lines for whitespace indicators before layout
|
||||
let whitespace_indicators =
|
||||
Self::layout_whitespace_indicators(state, text_size, &text_style, window, cx);
|
||||
@@ -1510,7 +1090,7 @@ impl Element for TextElement {
|
||||
&last_layout,
|
||||
text_size,
|
||||
&runs,
|
||||
&document_colors,
|
||||
&[],
|
||||
whitespace_indicators,
|
||||
window,
|
||||
);
|
||||
@@ -1540,26 +1120,8 @@ impl Element for TextElement {
|
||||
}
|
||||
last_layout.lines = Rc::new(lines);
|
||||
|
||||
let (ghost_first_line, ghost_lines) = Self::layout_inline_completion(
|
||||
state,
|
||||
&last_layout.visible_range,
|
||||
text_size,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let ghost_line_count = ghost_lines.len();
|
||||
let ghost_lines_height = ghost_line_count as f32 * line_height;
|
||||
|
||||
let total_wrapped_lines = state.display_map.wrap_row_count();
|
||||
let empty_bottom_height = if state.mode.is_code_editor() {
|
||||
bounds
|
||||
.size
|
||||
.height
|
||||
.half()
|
||||
.max(BOTTOM_MARGIN_ROWS * line_height)
|
||||
} else {
|
||||
px(0.)
|
||||
};
|
||||
let empty_bottom_height = px(0.);
|
||||
|
||||
let mut scroll_size = size(
|
||||
if longest_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width {
|
||||
@@ -1567,7 +1129,7 @@ impl Element for TextElement {
|
||||
} else {
|
||||
longest_line_width
|
||||
},
|
||||
(total_wrapped_lines as f32 * line_height + empty_bottom_height + ghost_lines_height)
|
||||
(total_wrapped_lines as f32 * line_height + empty_bottom_height)
|
||||
.max(bounds.size.height),
|
||||
);
|
||||
|
||||
@@ -1608,75 +1170,16 @@ impl Element for TextElement {
|
||||
|
||||
// Calculate the scroll offset to keep the cursor in view
|
||||
|
||||
// Save the unscrolled x before layout_cursor modifies bounds.origin with scroll_offset.
|
||||
// Fold icons and their hitboxes must use this value so they stay fixed in the gutter
|
||||
// regardless of horizontal scroll position.
|
||||
// Save the bounds before layout_cursor modifies bounds.origin with scroll_offset.
|
||||
let input_bounds = bounds;
|
||||
let original_x = bounds.origin.x;
|
||||
|
||||
let (cursor_bounds, cursor_scroll_offset, current_row) =
|
||||
let (cursor_bounds, cursor_scroll_offset) =
|
||||
self.layout_cursor(&last_layout, &mut bounds, scroll_size, window, cx);
|
||||
last_layout.cursor_bounds = cursor_bounds;
|
||||
|
||||
let search_match_paths = self.layout_search_matches(&last_layout, &bounds, cx);
|
||||
let selection_path = self.layout_selections(&last_layout, &mut bounds, window, cx);
|
||||
let hover_highlight_path = self.layout_hover_highlight(&last_layout, &bounds, cx);
|
||||
let document_color_paths =
|
||||
self.layout_document_colors(&document_colors, &last_layout, &bounds, cx);
|
||||
|
||||
let state = self.state.read(cx);
|
||||
let line_numbers = if state.mode.line_number() {
|
||||
let mut line_numbers = Vec::with_capacity(last_layout.visible_buffer_lines.len());
|
||||
let other_line_runs = vec![TextRun {
|
||||
len: line_number_len,
|
||||
font: style.font(),
|
||||
color: cx.theme().text_muted,
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
}];
|
||||
let current_line_runs = vec![TextRun {
|
||||
len: line_number_len,
|
||||
font: style.font(),
|
||||
color: cx.theme().text,
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
}];
|
||||
|
||||
// build line numbers
|
||||
for (line, &buffer_line) in last_layout
|
||||
.lines
|
||||
.iter()
|
||||
.zip(last_layout.visible_buffer_lines.iter())
|
||||
{
|
||||
let line_no: SharedString =
|
||||
format!("{:>width$}", buffer_line + 1, width = line_number_len).into();
|
||||
|
||||
let runs = if current_row == Some(buffer_line) {
|
||||
¤t_line_runs
|
||||
} else {
|
||||
&other_line_runs
|
||||
};
|
||||
|
||||
let mut sub_lines: SmallVec<[ShapedLine; 1]> = SmallVec::new();
|
||||
sub_lines.push(
|
||||
window
|
||||
.text_system()
|
||||
.shape_line(line_no, text_size, runs, None),
|
||||
);
|
||||
for _ in 0..line.wrapped_lines.len().saturating_sub(1) {
|
||||
sub_lines.push(ShapedLine::default());
|
||||
}
|
||||
line_numbers.push(sub_lines);
|
||||
}
|
||||
Some(line_numbers)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let indent_guides_path =
|
||||
self.layout_indent_guides(state, &bounds, &last_layout, &text_style, window);
|
||||
|
||||
state
|
||||
.editor_scrollbar_snapshot
|
||||
@@ -1688,27 +1191,13 @@ impl Element for TextElement {
|
||||
state,
|
||||
)));
|
||||
|
||||
let fold_icon_layout =
|
||||
self.layout_fold_icons(original_x, &bounds, &last_layout, window, cx);
|
||||
|
||||
PrepaintState {
|
||||
bounds,
|
||||
last_layout,
|
||||
scroll_size,
|
||||
line_numbers,
|
||||
cursor_bounds,
|
||||
cursor_scroll_offset,
|
||||
current_row,
|
||||
selection_path,
|
||||
search_match_paths,
|
||||
hover_highlight_path,
|
||||
hover_definition_hitbox: None,
|
||||
document_color_paths,
|
||||
indent_guides_path,
|
||||
fold_icon_layout,
|
||||
ghost_first_line,
|
||||
ghost_lines,
|
||||
ghost_lines_height,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1765,56 +1254,15 @@ impl Element for TextElement {
|
||||
|
||||
let invisible_top_padding = prepaint.last_layout.visible_top;
|
||||
|
||||
// Paint active line
|
||||
let mut offset_y = px(0.);
|
||||
if let Some(line_numbers) = prepaint.line_numbers.as_ref() {
|
||||
offset_y += invisible_top_padding;
|
||||
|
||||
// Each item is the normal lines.
|
||||
for (lines, _) in line_numbers
|
||||
.iter()
|
||||
.zip(prepaint.last_layout.visible_buffer_lines.iter())
|
||||
{
|
||||
let height = line_height * lines.len() as f32;
|
||||
offset_y += height;
|
||||
}
|
||||
}
|
||||
|
||||
// Paint indent guides
|
||||
if let Some(path) = prepaint.indent_guides_path.take() {
|
||||
window.paint_path(path, cx.theme().border.opacity(0.85));
|
||||
}
|
||||
|
||||
// Paint selections
|
||||
if window.is_window_active() {
|
||||
let secondary_selection = cx.theme().selection;
|
||||
for (path, is_active) in prepaint.search_match_paths.iter() {
|
||||
window.paint_path(path.clone(), secondary_selection);
|
||||
|
||||
if *is_active {
|
||||
window.paint_path(path.clone(), cx.theme().selection);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = prepaint.selection_path.take() {
|
||||
window.paint_path(path, cx.theme().selection);
|
||||
}
|
||||
|
||||
// Paint hover highlight
|
||||
if let Some(path) = prepaint.hover_highlight_path.take() {
|
||||
window.paint_path(path, secondary_selection);
|
||||
}
|
||||
if window.is_window_active()
|
||||
&& let Some(path) = prepaint.selection_path.take()
|
||||
{
|
||||
window.paint_path(path, cx.theme().selection);
|
||||
}
|
||||
|
||||
// Paint document colors
|
||||
for (path, color) in prepaint.document_color_paths.iter() {
|
||||
window.paint_path(path.clone(), *color);
|
||||
}
|
||||
|
||||
// Paint text with inline completion ghost line support
|
||||
// Paint text
|
||||
let mut offset_y = invisible_top_padding;
|
||||
let ghost_lines = &prepaint.ghost_lines;
|
||||
let has_ghost_lines = !ghost_lines.is_empty();
|
||||
|
||||
// Keep scrollbar offset always be positive,Start from the left position
|
||||
let scroll_offset = if text_align == TextAlign::Right {
|
||||
@@ -1827,16 +1275,12 @@ impl Element for TextElement {
|
||||
px(0.)
|
||||
};
|
||||
|
||||
// Track the y-position of the cursor row for positioning the first line suffix
|
||||
let mut cursor_row_y = None;
|
||||
|
||||
for (line, &buffer_line) in prepaint
|
||||
for (line, _buffer_line) in prepaint
|
||||
.last_layout
|
||||
.lines
|
||||
.iter()
|
||||
.zip(prepaint.last_layout.visible_buffer_lines.iter())
|
||||
{
|
||||
let row = buffer_line;
|
||||
let line_y = origin.y + offset_y;
|
||||
let p = point(
|
||||
origin.x + prepaint.last_layout.line_number_width + (scroll_offset),
|
||||
@@ -1853,40 +1297,6 @@ impl Element for TextElement {
|
||||
cx,
|
||||
);
|
||||
offset_y += line.size(line_height).height;
|
||||
|
||||
if Some(row) == prepaint.current_row {
|
||||
cursor_row_y = Some(line_y);
|
||||
}
|
||||
|
||||
// After the cursor row, paint ghost lines (which shifts subsequent content down)
|
||||
if has_ghost_lines && Some(row) == prepaint.current_row {
|
||||
let ghost_x = origin.x + prepaint.last_layout.line_number_width;
|
||||
|
||||
for ghost_line in ghost_lines {
|
||||
let ghost_p = point(ghost_x, origin.y + offset_y);
|
||||
|
||||
// Paint semi-transparent background for ghost line
|
||||
let ghost_bounds = Bounds::new(
|
||||
ghost_p,
|
||||
size(
|
||||
bounds.size.width - prepaint.last_layout.line_number_width,
|
||||
line_height,
|
||||
),
|
||||
);
|
||||
window.paint_quad(fill(ghost_bounds, cx.theme().surface_background));
|
||||
|
||||
// Paint ghost line text
|
||||
_ = ghost_line.paint(
|
||||
ghost_p,
|
||||
line_height,
|
||||
text_align,
|
||||
Some(prepaint.last_layout.content_width),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
offset_y += line_height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Paint blinking cursor
|
||||
@@ -1897,49 +1307,6 @@ impl Element for TextElement {
|
||||
window.paint_quad(fill(cursor_bounds, cx.theme().cursor));
|
||||
}
|
||||
|
||||
// Paint line numbers
|
||||
let mut offset_y = px(0.);
|
||||
if let Some(line_numbers) = prepaint.line_numbers.as_ref() {
|
||||
offset_y += invisible_top_padding;
|
||||
|
||||
window.paint_quad(fill(
|
||||
Bounds {
|
||||
origin: input_bounds.origin,
|
||||
size: size(
|
||||
prepaint.last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN,
|
||||
input_bounds.size.height + prepaint.ghost_lines_height,
|
||||
),
|
||||
},
|
||||
cx.theme().surface_background,
|
||||
));
|
||||
|
||||
// Each item is the normal lines.
|
||||
for (lines, &buffer_line) in line_numbers
|
||||
.iter()
|
||||
.zip(prepaint.last_layout.visible_buffer_lines.iter())
|
||||
{
|
||||
let p = point(input_bounds.origin.x, origin.y + offset_y);
|
||||
|
||||
for line in lines {
|
||||
_ = line.paint(p, line_height, TextAlign::Left, None, window, cx);
|
||||
offset_y += line_height;
|
||||
}
|
||||
|
||||
// Add ghost line height after cursor row for line numbers alignment
|
||||
if !prepaint.ghost_lines.is_empty() && prepaint.current_row == Some(buffer_line) {
|
||||
offset_y += prepaint.ghost_lines_height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Paint fold icons (only visible on hover or for current line)
|
||||
self.paint_fold_icons(
|
||||
&mut prepaint.fold_icon_layout,
|
||||
prepaint.current_row,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.last_layout = Some(prepaint.last_layout.clone());
|
||||
state.last_bounds = Some(bounds);
|
||||
@@ -1953,27 +1320,6 @@ impl Element for TextElement {
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
if let Some(hitbox) = prepaint.hover_definition_hitbox.as_ref() {
|
||||
window.set_cursor_style(gpui::CursorStyle::PointingHand, hitbox);
|
||||
}
|
||||
|
||||
// Paint inline completion first line suffix (after cursor on same line)
|
||||
if focused
|
||||
&& let Some(first_line) = &prepaint.ghost_first_line
|
||||
&& let (Some(cursor_bounds), Some(cursor_row_y)) =
|
||||
(prepaint.cursor_bounds_with_scroll(), cursor_row_y)
|
||||
{
|
||||
let first_line_x = cursor_bounds.origin.x + cursor_bounds.size.width;
|
||||
let p = point(first_line_x, cursor_row_y);
|
||||
|
||||
// Paint background to cover any existing text
|
||||
let bg_bounds = Bounds::new(p, size(first_line.width + px(4.), line_height));
|
||||
window.paint_quad(fill(bg_bounds, cx.theme().surface_background));
|
||||
|
||||
// Paint first line completion text
|
||||
_ = first_line.paint(p, line_height, text_align, None, window, cx);
|
||||
}
|
||||
|
||||
self.paint_mouse_listeners(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
use gpui::{
|
||||
Bounds, Context, EntityInputHandler as _, Hsla, Path, PathBuilder, Pixels, SharedString,
|
||||
TextRun, TextStyle, Window, point, px,
|
||||
};
|
||||
use gpui::{Context, EntityInputHandler, SharedString, Window};
|
||||
use ropey::RopeSlice;
|
||||
|
||||
use crate::input::element::TextElement;
|
||||
use crate::input::mode::InputMode;
|
||||
use crate::input::{Indent, IndentInline, InputState, LastLayout, Outdent, OutdentInline, RopeExt};
|
||||
use crate::input::{Indent, IndentInline, InputState, Outdent, OutdentInline};
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct TabSize {
|
||||
@@ -49,165 +45,14 @@ impl TabSize {
|
||||
}
|
||||
}
|
||||
|
||||
impl InputMode {
|
||||
#[inline]
|
||||
pub(super) fn is_indentable(&self) -> bool {
|
||||
match self {
|
||||
InputMode::PlainText { multi_line, .. } | InputMode::CodeEditor { multi_line, .. } => {
|
||||
*multi_line
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(super) fn has_indent_guides(&self) -> bool {
|
||||
match self {
|
||||
InputMode::CodeEditor {
|
||||
indent_guides,
|
||||
multi_line,
|
||||
..
|
||||
} => *indent_guides && *multi_line,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(super) fn tab_size(&self) -> TabSize {
|
||||
match self {
|
||||
InputMode::PlainText { tab, .. } => *tab,
|
||||
InputMode::CodeEditor { tab, .. } => *tab,
|
||||
_ => TabSize::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextElement {
|
||||
/// Measure the indent width in pixels for given column count.
|
||||
fn measure_indent_width(&self, style: &TextStyle, column: usize, window: &Window) -> Pixels {
|
||||
let font_size = style.font_size.to_pixels(window.rem_size());
|
||||
let layout = window.text_system().shape_line(
|
||||
SharedString::from(" ".repeat(column)),
|
||||
font_size,
|
||||
&[TextRun {
|
||||
len: column,
|
||||
font: style.font(),
|
||||
color: Hsla::default(),
|
||||
background_color: None,
|
||||
strikethrough: None,
|
||||
underline: None,
|
||||
}],
|
||||
None,
|
||||
);
|
||||
|
||||
layout.width
|
||||
}
|
||||
|
||||
pub(super) fn layout_indent_guides(
|
||||
&self,
|
||||
state: &InputState,
|
||||
bounds: &Bounds<Pixels>,
|
||||
last_layout: &LastLayout,
|
||||
text_style: &TextStyle,
|
||||
window: &mut Window,
|
||||
) -> Option<Path<Pixels>> {
|
||||
if !state.mode.has_indent_guides() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let indent_width =
|
||||
self.measure_indent_width(text_style, state.mode.tab_size().tab_size, window);
|
||||
|
||||
let tab_size = state.mode.tab_size();
|
||||
let line_height = last_layout.line_height;
|
||||
let mut builder = PathBuilder::stroke(px(1.));
|
||||
let mut offset_y = last_layout.visible_top;
|
||||
let mut last_indents = vec![];
|
||||
|
||||
for (&buffer_line, line_layout) in last_layout
|
||||
.visible_buffer_lines
|
||||
.iter()
|
||||
.zip(last_layout.lines.iter())
|
||||
{
|
||||
let line = state.text.slice_line(buffer_line);
|
||||
let mut current_indents = vec![];
|
||||
if line.len() > 0 {
|
||||
let indent_count = tab_size.indent_count(&line);
|
||||
for offset in (0..indent_count).step_by(tab_size.tab_size) {
|
||||
let x = if indent_count > 0 {
|
||||
indent_width * offset as f32 / tab_size.tab_size as f32
|
||||
} else {
|
||||
px(0.)
|
||||
};
|
||||
|
||||
let pos = point(x + last_layout.line_number_width, offset_y);
|
||||
|
||||
builder.move_to(pos);
|
||||
builder.line_to(point(pos.x, pos.y + line_height));
|
||||
current_indents.push(pos.x);
|
||||
}
|
||||
} else if !last_indents.is_empty() {
|
||||
for x in &last_indents {
|
||||
let pos = point(*x, offset_y);
|
||||
builder.move_to(pos);
|
||||
builder.line_to(point(pos.x, pos.y + line_height));
|
||||
}
|
||||
current_indents = last_indents.clone();
|
||||
}
|
||||
|
||||
offset_y += line_layout.wrapped_lines.len() * line_height;
|
||||
last_indents = current_indents;
|
||||
}
|
||||
|
||||
builder.translate(bounds.origin);
|
||||
let path = builder.build().unwrap();
|
||||
Some(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
/// Set whether to show indent guides in code editor mode, default is true.
|
||||
///
|
||||
/// Only for [`InputMode::CodeEditor`] mode.
|
||||
pub fn indent_guides(mut self, indent_guides: bool) -> Self {
|
||||
debug_assert!(self.mode.is_code_editor() && self.mode.is_multi_line());
|
||||
if let InputMode::CodeEditor {
|
||||
indent_guides: l, ..
|
||||
} = &mut self.mode
|
||||
{
|
||||
*l = indent_guides;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set indent guides in code editor mode.
|
||||
///
|
||||
/// Only for [`InputMode::CodeEditor`] mode.
|
||||
pub fn set_indent_guides(
|
||||
&mut self,
|
||||
indent_guides: bool,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
debug_assert!(self.mode.is_code_editor());
|
||||
if let InputMode::CodeEditor {
|
||||
indent_guides: l, ..
|
||||
} = &mut self.mode
|
||||
{
|
||||
*l = indent_guides;
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Set the tab size for the input.
|
||||
///
|
||||
/// Only for [`InputMode::PlainText`] and [`InputMode::CodeEditor`] mode with multi_line.
|
||||
/// Only for [`InputMode::PlainText`] mode with multi_line.
|
||||
pub fn tab_size(mut self, tab: TabSize) -> Self {
|
||||
debug_assert!(self.mode.is_multi_line() || self.mode.is_code_editor());
|
||||
match &mut self.mode {
|
||||
InputMode::PlainText { tab: t, .. } => *t = tab,
|
||||
InputMode::CodeEditor { tab: t, .. } => *t = tab,
|
||||
_ => {}
|
||||
debug_assert!(self.mode.is_multi_line());
|
||||
if let InputMode::PlainText { tab: t, .. } = &mut self.mode {
|
||||
*t = tab;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,9 +18,7 @@ mod state;
|
||||
|
||||
pub(crate) use clear_button::*;
|
||||
pub use cursor::*;
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub use display_map::folding::Tree;
|
||||
pub use display_map::{BufferPoint, DisplayMap, DisplayPoint, FoldRange};
|
||||
pub use display_map::DisplayMap;
|
||||
pub use indent::TabSize;
|
||||
pub use input::*;
|
||||
pub use mask_pattern::MaskPattern;
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{SharedString, Task};
|
||||
use ropey::Rope;
|
||||
|
||||
use super::display_map::DisplayMap;
|
||||
use crate::input::TabSize;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(super) struct PendingBackgroundParse {
|
||||
pub parse_task: Rc<RefCell<Option<Task<()>>>>,
|
||||
pub language: SharedString,
|
||||
pub text: Rope,
|
||||
pub is_folding: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum InputMode {
|
||||
/// A plain text input mode.
|
||||
PlainText {
|
||||
multi_line: bool,
|
||||
tab: TabSize,
|
||||
tab: crate::input::indent::TabSize,
|
||||
rows: usize,
|
||||
},
|
||||
/// An auto grow input mode.
|
||||
@@ -29,18 +14,6 @@ pub(crate) enum InputMode {
|
||||
min_rows: usize,
|
||||
max_rows: usize,
|
||||
},
|
||||
/// A code editor input mode.
|
||||
CodeEditor {
|
||||
multi_line: bool,
|
||||
tab: TabSize,
|
||||
rows: usize,
|
||||
/// Show line number
|
||||
line_number: bool,
|
||||
language: SharedString,
|
||||
indent_guides: bool,
|
||||
folding: bool,
|
||||
parse_task: Rc<RefCell<Option<Task<()>>>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for InputMode {
|
||||
@@ -55,25 +28,11 @@ impl InputMode {
|
||||
pub(super) fn plain_text() -> Self {
|
||||
InputMode::PlainText {
|
||||
multi_line: false,
|
||||
tab: TabSize::default(),
|
||||
tab: crate::input::indent::TabSize::default(),
|
||||
rows: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a code editor input mode with default settings.
|
||||
pub(super) fn code_editor(language: impl Into<SharedString>) -> Self {
|
||||
InputMode::CodeEditor {
|
||||
rows: 2,
|
||||
multi_line: true,
|
||||
tab: TabSize::default(),
|
||||
language: language.into(),
|
||||
line_number: true,
|
||||
indent_guides: true,
|
||||
folding: true,
|
||||
parse_task: Rc::new(RefCell::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an auto grow input mode with given min and max rows.
|
||||
pub(super) fn auto_grow(min_rows: usize, max_rows: usize) -> Self {
|
||||
InputMode::AutoGrow {
|
||||
@@ -86,7 +45,6 @@ impl InputMode {
|
||||
pub(super) fn multi_line(mut self, multi_line: bool) -> Self {
|
||||
match &mut self {
|
||||
InputMode::PlainText { multi_line: ml, .. } => *ml = multi_line,
|
||||
InputMode::CodeEditor { multi_line: ml, .. } => *ml = multi_line,
|
||||
InputMode::AutoGrow { .. } => {}
|
||||
}
|
||||
self
|
||||
@@ -97,28 +55,6 @@ impl InputMode {
|
||||
!self.is_multi_line()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(super) fn is_code_editor(&self) -> bool {
|
||||
matches!(self, InputMode::CodeEditor { .. })
|
||||
}
|
||||
|
||||
/// Return true if the mode is code editor and `folding: true`, `multi_line: true`.
|
||||
#[inline]
|
||||
pub(crate) fn is_folding(&self) -> bool {
|
||||
if cfg!(target_family = "wasm") {
|
||||
return false;
|
||||
}
|
||||
|
||||
matches!(
|
||||
self,
|
||||
InputMode::CodeEditor {
|
||||
folding: true,
|
||||
multi_line: true,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(super) fn is_auto_grow(&self) -> bool {
|
||||
matches!(self, InputMode::AutoGrow { .. })
|
||||
@@ -128,7 +64,6 @@ impl InputMode {
|
||||
pub(super) fn is_multi_line(&self) -> bool {
|
||||
match self {
|
||||
InputMode::PlainText { multi_line, .. } => *multi_line,
|
||||
InputMode::CodeEditor { multi_line, .. } => *multi_line,
|
||||
InputMode::AutoGrow { max_rows, .. } => *max_rows > 1,
|
||||
}
|
||||
}
|
||||
@@ -138,9 +73,6 @@ impl InputMode {
|
||||
InputMode::PlainText { rows, .. } => {
|
||||
*rows = new_rows;
|
||||
}
|
||||
InputMode::CodeEditor { rows, .. } => {
|
||||
*rows = new_rows;
|
||||
}
|
||||
InputMode::AutoGrow {
|
||||
rows,
|
||||
min_rows,
|
||||
@@ -168,7 +100,6 @@ impl InputMode {
|
||||
|
||||
match self {
|
||||
InputMode::PlainText { rows, .. } => *rows,
|
||||
InputMode::CodeEditor { rows, .. } => *rows,
|
||||
InputMode::AutoGrow { rows, .. } => *rows,
|
||||
}
|
||||
.max(1)
|
||||
@@ -196,16 +127,19 @@ impl InputMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return false if the mode is not [`InputMode::CodeEditor`].
|
||||
#[inline]
|
||||
pub(super) fn line_number(&self) -> bool {
|
||||
pub(super) fn is_indentable(&self) -> bool {
|
||||
match self {
|
||||
InputMode::CodeEditor {
|
||||
line_number,
|
||||
multi_line,
|
||||
..
|
||||
} => *line_number && *multi_line,
|
||||
InputMode::PlainText { multi_line, .. } => *multi_line,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(super) fn tab_size(&self) -> crate::input::indent::TabSize {
|
||||
match self {
|
||||
InputMode::PlainText { tab, .. } => *tab,
|
||||
_ => crate::input::indent::TabSize::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Action, AnyElement, App, AppContext, Context, DismissEvent, Empty, Entity, EventEmitter,
|
||||
InteractiveElement as _, IntoElement, ParentElement, Pixels, Point, Render, RenderOnce,
|
||||
SharedString, Styled, StyledText, Subscription, Window, deferred, div, px, relative,
|
||||
};
|
||||
use lsp_types::CodeAction;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
const MAX_MENU_WIDTH: Pixels = px(320.);
|
||||
const MAX_MENU_HEIGHT: Pixels = px(480.);
|
||||
|
||||
use crate::input::popovers::editor_popover;
|
||||
use crate::input::{self, InputState};
|
||||
use crate::list::{List, ListDelegate, ListEvent, ListState};
|
||||
use crate::{IndexPath, Selectable, actions, h_flex};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CodeActionItem {
|
||||
/// The `id` of the `CodeActionProvider` that provided this item.
|
||||
pub(crate) provider_id: SharedString,
|
||||
pub(crate) action: CodeAction,
|
||||
}
|
||||
|
||||
struct MenuDelegate {
|
||||
menu: Entity<CodeActionMenu>,
|
||||
items: Vec<Rc<CodeActionItem>>,
|
||||
selected_ix: usize,
|
||||
}
|
||||
|
||||
impl MenuDelegate {
|
||||
fn set_items(&mut self, items: Vec<CodeActionItem>) {
|
||||
self.items = items.into_iter().map(Rc::new).collect();
|
||||
self.selected_ix = 0;
|
||||
}
|
||||
|
||||
fn selected_item(&self) -> Option<&Rc<CodeActionItem>> {
|
||||
self.items.get(self.selected_ix)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
struct MenuItem {
|
||||
ix: usize,
|
||||
item: Rc<CodeActionItem>,
|
||||
children: Vec<AnyElement>,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
impl MenuItem {
|
||||
fn new(ix: usize, item: Rc<CodeActionItem>) -> Self {
|
||||
Self {
|
||||
ix,
|
||||
item,
|
||||
children: vec![],
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Selectable for MenuItem {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for MenuItem {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
impl RenderOnce for MenuItem {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let item = self.item;
|
||||
|
||||
let highlights = vec![];
|
||||
|
||||
h_flex()
|
||||
.id(self.ix)
|
||||
.gap_2()
|
||||
.p_1()
|
||||
.text_xs()
|
||||
.line_height(relative(1.))
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover))
|
||||
.when(self.selected, |this| {
|
||||
this.bg(cx.theme().secondary_background)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
})
|
||||
.child(
|
||||
div().child(StyledText::new(item.action.title.clone()).with_highlights(highlights)),
|
||||
)
|
||||
.children(self.children)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for MenuDelegate {}
|
||||
|
||||
impl ListDelegate for MenuDelegate {
|
||||
type Item = MenuItem;
|
||||
|
||||
fn items_count(&self, _: usize, _: &gpui::App) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&mut self,
|
||||
ix: crate::IndexPath,
|
||||
_: &mut Window,
|
||||
_: &mut Context<ListState<Self>>,
|
||||
) -> Option<Self::Item> {
|
||||
let item = self.items.get(ix.row)?;
|
||||
Some(MenuItem::new(ix.row, item.clone()))
|
||||
}
|
||||
|
||||
fn set_selected_index(
|
||||
&mut self,
|
||||
ix: Option<crate::IndexPath>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) {
|
||||
self.selected_ix = ix.map(|i| i.row).unwrap_or(0);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) {
|
||||
let Some(item) = self.selected_item() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.menu.update(cx, |this, cx| {
|
||||
this.select_item(&item, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A context menu for code completions and code actions.
|
||||
pub struct CodeActionMenu {
|
||||
offset: usize,
|
||||
state: Entity<InputState>,
|
||||
list: Entity<ListState<MenuDelegate>>,
|
||||
open: bool,
|
||||
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl CodeActionMenu {
|
||||
/// Creates a new `CompletionMenu` with the given offset and completion items.
|
||||
///
|
||||
/// NOTE: This element should not call from InputState::new, unless that will stack overflow.
|
||||
pub(crate) fn new(
|
||||
state: Entity<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
let view = cx.entity();
|
||||
let menu = MenuDelegate {
|
||||
menu: view,
|
||||
items: vec![],
|
||||
selected_ix: 0,
|
||||
};
|
||||
|
||||
let list = cx.new(|cx| ListState::new(menu, window, cx));
|
||||
|
||||
let _subscriptions =
|
||||
vec![
|
||||
cx.subscribe(&list, |this: &mut Self, _, ev: &ListEvent, cx| {
|
||||
match ev {
|
||||
ListEvent::Confirm(_) => {
|
||||
this.hide(cx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cx.notify();
|
||||
}),
|
||||
];
|
||||
|
||||
Self {
|
||||
offset: 0,
|
||||
state,
|
||||
list,
|
||||
open: false,
|
||||
_subscriptions,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn select_item(&mut self, item: &CodeActionItem, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let state = self.state.clone();
|
||||
let item = item.clone();
|
||||
|
||||
cx.spawn_in(window, {
|
||||
async move |_, cx| {
|
||||
state.update_in(cx, |state, window, cx| {
|
||||
state.perform_code_action(&item, window, cx);
|
||||
})
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
self.hide(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn handle_action(
|
||||
&mut self,
|
||||
action: Box<dyn Action>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
if !self.open {
|
||||
return false;
|
||||
}
|
||||
|
||||
cx.propagate();
|
||||
if input::Enter::is_primary(&*action) {
|
||||
self.on_action_enter(window, cx);
|
||||
} else if action.partial_eq(&input::Escape) {
|
||||
self.on_action_escape(window, cx);
|
||||
} else if action.partial_eq(&input::MoveUp) {
|
||||
self.on_action_up(window, cx);
|
||||
} else if action.partial_eq(&input::MoveDown) {
|
||||
self.on_action_down(window, cx);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn on_action_enter(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(item) = self.list.read(cx).delegate().selected_item().cloned() else {
|
||||
return;
|
||||
};
|
||||
self.select_item(&item, window, cx);
|
||||
}
|
||||
|
||||
fn on_action_escape(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.hide(cx);
|
||||
}
|
||||
|
||||
fn on_action_up(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.list.update(cx, |this, cx| {
|
||||
this.on_action_select_prev(&actions::SelectUp, window, cx)
|
||||
});
|
||||
}
|
||||
|
||||
fn on_action_down(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.list.update(cx, |this, cx| {
|
||||
this.on_action_select_next(&actions::SelectDown, window, cx)
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn is_open(&self) -> bool {
|
||||
self.open
|
||||
}
|
||||
|
||||
/// Hide the completion menu and reset the trigger start offset.
|
||||
pub(crate) fn hide(&mut self, cx: &mut Context<Self>) {
|
||||
self.open = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn show(
|
||||
&mut self,
|
||||
offset: usize,
|
||||
items: impl Into<Vec<CodeActionItem>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let items = items.into();
|
||||
self.offset = offset;
|
||||
self.open = true;
|
||||
self.list.update(cx, |this, cx| {
|
||||
this.delegate_mut().set_items(items);
|
||||
this.set_selected_index(Some(IndexPath::new(0)), window, cx);
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn origin(&self, cx: &App) -> Option<Point<Pixels>> {
|
||||
let state = self.state.read(cx);
|
||||
let Some(last_layout) = state.last_layout.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
let Some(cursor_origin) = last_layout.cursor_bounds.map(|b| b.origin) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let scroll_origin = self.state.read(cx).scroll_handle.offset();
|
||||
|
||||
Some(
|
||||
scroll_origin + cursor_origin - state.input_bounds.origin
|
||||
+ Point::new(-px(4.), last_layout.line_height + px(4.)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CodeActionMenu {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if !self.open {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
if self.list.read(cx).delegate().items.is_empty() {
|
||||
self.open = false;
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let Some(pos) = self.origin(cx) else {
|
||||
return Empty.into_any_element();
|
||||
};
|
||||
|
||||
let max_width = MAX_MENU_WIDTH.min(window.bounds().size.width - pos.x);
|
||||
|
||||
deferred(
|
||||
editor_popover("code-action-menu", cx)
|
||||
.absolute()
|
||||
.left(pos.x)
|
||||
.top(pos.y)
|
||||
.max_w(max_width)
|
||||
.min_w(px(120.))
|
||||
.child(List::new(&self.list).max_h(MAX_MENU_HEIGHT))
|
||||
.on_mouse_down_out(cx.listener(|this, _, _, cx| {
|
||||
this.hide(cx);
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1,446 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Action, AnyElement, App, AppContext, Context, DismissEvent, Empty, Entity, EventEmitter,
|
||||
Half as _, HighlightStyle, InteractiveElement as _, IntoElement, ParentElement, Pixels, Point,
|
||||
Render, RenderOnce, SharedString, Styled, StyledText, Subscription, Window, deferred, div, px,
|
||||
relative,
|
||||
};
|
||||
use lsp_types::{CompletionItem, CompletionTextEdit};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
const MAX_MENU_WIDTH: Pixels = px(320.);
|
||||
const MAX_MENU_HEIGHT: Pixels = px(240.);
|
||||
const POPOVER_GAP: Pixels = px(4.);
|
||||
|
||||
use crate::input::popovers::{editor_popover, render_markdown};
|
||||
use crate::input::{self, InputState, RopeExt};
|
||||
use crate::list::{List, ListDelegate, ListEvent, ListState};
|
||||
use crate::{IndexPath, Selectable, actions, h_flex};
|
||||
|
||||
struct ContextMenuDelegate {
|
||||
query: SharedString,
|
||||
menu: Entity<CompletionMenu>,
|
||||
items: Vec<Rc<CompletionItem>>,
|
||||
selected_ix: usize,
|
||||
}
|
||||
|
||||
impl ContextMenuDelegate {
|
||||
fn set_items(&mut self, items: Vec<CompletionItem>) {
|
||||
self.items = items.into_iter().map(Rc::new).collect();
|
||||
self.selected_ix = 0;
|
||||
}
|
||||
|
||||
fn selected_item(&self) -> Option<&Rc<CompletionItem>> {
|
||||
self.items.get(self.selected_ix)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
struct CompletionMenuItem {
|
||||
ix: usize,
|
||||
item: Rc<CompletionItem>,
|
||||
children: Vec<AnyElement>,
|
||||
selected: bool,
|
||||
highlight_prefix: SharedString,
|
||||
}
|
||||
|
||||
impl CompletionMenuItem {
|
||||
fn new(ix: usize, item: Rc<CompletionItem>) -> Self {
|
||||
Self {
|
||||
ix,
|
||||
item,
|
||||
children: vec![],
|
||||
selected: false,
|
||||
highlight_prefix: "".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_prefix(mut self, s: impl Into<SharedString>) -> Self {
|
||||
self.highlight_prefix = s.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
impl Selectable for CompletionMenuItem {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for CompletionMenuItem {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for CompletionMenuItem {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let item = self.item;
|
||||
|
||||
let matched_len = item
|
||||
.filter_text
|
||||
.as_ref()
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(self.highlight_prefix.len())
|
||||
.min(item.label.len());
|
||||
|
||||
let highlights = vec![(
|
||||
0..matched_len,
|
||||
HighlightStyle {
|
||||
color: Some(cx.theme().selection),
|
||||
..Default::default()
|
||||
},
|
||||
)];
|
||||
|
||||
h_flex()
|
||||
.id(self.ix)
|
||||
.gap_2()
|
||||
.p_1()
|
||||
.text_xs()
|
||||
.line_height(relative(1.))
|
||||
.rounded(cx.theme().radius.half())
|
||||
.when(item.deprecated.unwrap_or(false), |this| this.line_through())
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover))
|
||||
.when(self.selected, |this| {
|
||||
this.bg(cx.theme().secondary_background)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
})
|
||||
.child(div().child(StyledText::new(item.label.clone()).with_highlights(highlights)))
|
||||
.children(self.children)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for ContextMenuDelegate {}
|
||||
|
||||
impl ListDelegate for ContextMenuDelegate {
|
||||
type Item = CompletionMenuItem;
|
||||
|
||||
fn items_count(&self, _: usize, _: &gpui::App) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&mut self,
|
||||
ix: crate::IndexPath,
|
||||
_: &mut Window,
|
||||
_: &mut Context<ListState<Self>>,
|
||||
) -> Option<Self::Item> {
|
||||
let item = self.items.get(ix.row)?;
|
||||
Some(CompletionMenuItem::new(ix.row, item.clone()).highlight_prefix(self.query.clone()))
|
||||
}
|
||||
|
||||
fn set_selected_index(
|
||||
&mut self,
|
||||
ix: Option<crate::IndexPath>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) {
|
||||
self.selected_ix = ix.map(|i| i.row).unwrap_or(0);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) {
|
||||
let Some(item) = self.selected_item() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.menu.update(cx, |this, cx| {
|
||||
this.select_item(&item, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A context menu for code completions and code actions.
|
||||
pub struct CompletionMenu {
|
||||
offset: usize,
|
||||
editor: Entity<InputState>,
|
||||
list: Entity<ListState<ContextMenuDelegate>>,
|
||||
open: bool,
|
||||
|
||||
/// The offset of the first character that triggered the completion.
|
||||
pub(crate) trigger_start_offset: Option<usize>,
|
||||
query: SharedString,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl CompletionMenu {
|
||||
/// Creates a new `CompletionMenu` with the given offset and completion items.
|
||||
///
|
||||
/// NOTE: This element should not call from InputState::new, unless that will stack overflow.
|
||||
pub(crate) fn new(
|
||||
editor: Entity<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
let view = cx.entity();
|
||||
let menu = ContextMenuDelegate {
|
||||
query: SharedString::default(),
|
||||
menu: view,
|
||||
items: vec![],
|
||||
selected_ix: 0,
|
||||
};
|
||||
|
||||
let list = cx.new(|cx| ListState::new(menu, window, cx));
|
||||
|
||||
let _subscriptions =
|
||||
vec![
|
||||
cx.subscribe(&list, |this: &mut Self, _, ev: &ListEvent, cx| {
|
||||
match ev {
|
||||
ListEvent::Confirm(_) => {
|
||||
this.hide(cx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cx.notify();
|
||||
}),
|
||||
];
|
||||
|
||||
Self {
|
||||
offset: 0,
|
||||
editor,
|
||||
list,
|
||||
open: false,
|
||||
trigger_start_offset: None,
|
||||
query: SharedString::default(),
|
||||
_subscriptions,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn select_item(&mut self, item: &CompletionItem, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let offset = self.offset;
|
||||
let item = item.clone();
|
||||
let mut range = self.trigger_start_offset.unwrap_or(self.offset)..self.offset;
|
||||
|
||||
let editor = self.editor.clone();
|
||||
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.completion_inserting = true;
|
||||
|
||||
let mut new_text = item.label.clone();
|
||||
if let Some(text_edit) = item.text_edit.as_ref() {
|
||||
match text_edit {
|
||||
CompletionTextEdit::Edit(edit) => {
|
||||
new_text = edit.new_text.clone();
|
||||
range.start = editor.text.position_to_offset(&edit.range.start);
|
||||
range.end = editor.text.position_to_offset(&edit.range.end);
|
||||
}
|
||||
CompletionTextEdit::InsertAndReplace(edit) => {
|
||||
new_text = edit.new_text.clone();
|
||||
range.start = editor.text.position_to_offset(&edit.replace.start);
|
||||
range.end = editor.text.position_to_offset(&edit.replace.end);
|
||||
}
|
||||
}
|
||||
} else if let Some(insert_text) = item.insert_text.clone() {
|
||||
new_text = insert_text;
|
||||
range = offset..offset;
|
||||
}
|
||||
|
||||
editor.replace_text_in_range_silent(
|
||||
Some(editor.range_to_utf16(&range)),
|
||||
&new_text,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
editor.completion_inserting = false;
|
||||
// FIXME: Input not get the focus
|
||||
editor.focus(window, cx);
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
|
||||
self.hide(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn handle_action(
|
||||
&mut self,
|
||||
action: Box<dyn Action>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
if !self.open {
|
||||
return false;
|
||||
}
|
||||
|
||||
cx.propagate();
|
||||
if input::Enter::is_primary(&*action) {
|
||||
self.on_action_enter(window, cx);
|
||||
} else if action.partial_eq(&input::Escape) {
|
||||
self.on_action_escape(window, cx);
|
||||
} else if action.partial_eq(&input::MoveUp) {
|
||||
self.on_action_up(window, cx);
|
||||
} else if action.partial_eq(&input::MoveDown) {
|
||||
self.on_action_down(window, cx);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn on_action_enter(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(item) = self.list.read(cx).delegate().selected_item().cloned() else {
|
||||
return;
|
||||
};
|
||||
self.select_item(&item, window, cx);
|
||||
}
|
||||
|
||||
fn on_action_escape(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.hide(cx);
|
||||
}
|
||||
|
||||
fn on_action_up(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.list.update(cx, |this, cx| {
|
||||
this.on_action_select_prev(&actions::SelectUp, window, cx)
|
||||
});
|
||||
}
|
||||
|
||||
fn on_action_down(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.list.update(cx, |this, cx| {
|
||||
this.on_action_select_next(&actions::SelectDown, window, cx)
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn is_open(&self) -> bool {
|
||||
self.open
|
||||
}
|
||||
|
||||
/// Hide the completion menu and reset the trigger start offset.
|
||||
pub(crate) fn hide(&mut self, cx: &mut Context<Self>) {
|
||||
self.open = false;
|
||||
self.trigger_start_offset = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Sets the trigger start offset if it is not already set.
|
||||
pub(crate) fn update_query(&mut self, start_offset: usize, query: impl Into<SharedString>) {
|
||||
if self.trigger_start_offset.is_none() {
|
||||
self.trigger_start_offset = Some(start_offset);
|
||||
}
|
||||
self.query = query.into();
|
||||
}
|
||||
|
||||
pub(crate) fn show(
|
||||
&mut self,
|
||||
offset: usize,
|
||||
items: impl Into<Vec<CompletionItem>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let items = items.into();
|
||||
self.offset = offset;
|
||||
self.open = true;
|
||||
self.list.update(cx, |this, cx| {
|
||||
let longest_ix = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(_, item)| {
|
||||
item.label.len() + item.detail.as_ref().map(|d| d.len()).unwrap_or(0)
|
||||
})
|
||||
.map(|(ix, _)| ix)
|
||||
.unwrap_or(0);
|
||||
|
||||
this.delegate_mut().query = self.query.clone();
|
||||
this.delegate_mut().set_items(items);
|
||||
this.set_selected_index(Some(IndexPath::new(0)), window, cx);
|
||||
this.set_item_to_measure_index(IndexPath::new(longest_ix), window, cx);
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn origin(&self, cx: &App) -> Option<Point<Pixels>> {
|
||||
let editor = self.editor.read(cx);
|
||||
let Some(last_layout) = editor.last_layout.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
let Some(cursor_origin) = last_layout.cursor_bounds.map(|b| b.origin) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let scroll_origin = self.editor.read(cx).scroll_handle.offset();
|
||||
|
||||
Some(
|
||||
scroll_origin + cursor_origin - editor.input_bounds.origin
|
||||
+ Point::new(-px(4.), last_layout.line_height + px(4.)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CompletionMenu {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if !self.open {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
if self.list.read(cx).delegate().items.is_empty() {
|
||||
self.open = false;
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let Some(pos) = self.origin(cx) else {
|
||||
return Empty.into_any_element();
|
||||
};
|
||||
|
||||
let selected_documentation = self
|
||||
.list
|
||||
.read(cx)
|
||||
.delegate()
|
||||
.selected_item()
|
||||
.and_then(|item| item.documentation.clone());
|
||||
|
||||
let max_width = MAX_MENU_WIDTH.min(window.bounds().size.width - pos.x);
|
||||
let abs_pos = self.editor.read(cx).input_bounds.origin + pos;
|
||||
let vertical_layout =
|
||||
abs_pos.x + MAX_MENU_WIDTH + POPOVER_GAP + MAX_MENU_WIDTH + POPOVER_GAP
|
||||
> window.bounds().size.width;
|
||||
|
||||
deferred(
|
||||
div()
|
||||
.absolute()
|
||||
.left(pos.x)
|
||||
.top(pos.y)
|
||||
.flex()
|
||||
.flex_row()
|
||||
.gap(POPOVER_GAP)
|
||||
.items_start()
|
||||
.when(vertical_layout, |this| this.flex_col())
|
||||
.child(
|
||||
editor_popover("completion-menu", cx)
|
||||
.max_w(max_width)
|
||||
.min_w(px(120.))
|
||||
.child(List::new(&self.list).max_h(MAX_MENU_HEIGHT)),
|
||||
)
|
||||
.when_some(selected_documentation, |this, documentation| {
|
||||
let mut doc = match documentation {
|
||||
lsp_types::Documentation::String(s) => s.clone(),
|
||||
lsp_types::Documentation::MarkupContent(mc) => mc.value.clone(),
|
||||
};
|
||||
if vertical_layout {
|
||||
doc = doc.split("\n").next().unwrap_or_default().to_string();
|
||||
}
|
||||
|
||||
this.child(
|
||||
div().child(
|
||||
editor_popover("completion-menu", cx)
|
||||
.w(MAX_MENU_WIDTH)
|
||||
.px_2()
|
||||
.child(render_markdown("doc", doc, window, cx)),
|
||||
),
|
||||
)
|
||||
})
|
||||
.on_mouse_down_out(cx.listener(|this, _, _, cx| {
|
||||
this.hide(cx);
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
Anchor, App, AppContext as _, Context, DismissEvent, Entity, IntoElement, MouseDownEvent,
|
||||
ParentElement as _, Pixels, Point, Render, Styled, Subscription, Window, anchored, deferred,
|
||||
div, px,
|
||||
};
|
||||
|
||||
use crate::input::popovers::ContextMenu;
|
||||
use crate::input::{self, InputState};
|
||||
use crate::menu::PopupMenu;
|
||||
|
||||
/// Context menu for mouse right clicks.
|
||||
pub(crate) struct InputContextMenu {
|
||||
editor: Entity<InputState>,
|
||||
menu: Entity<PopupMenu>,
|
||||
mouse_position: Point<Pixels>,
|
||||
open: bool,
|
||||
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
pub(crate) fn handle_right_click_menu(
|
||||
&mut self,
|
||||
event: &MouseDownEvent,
|
||||
offset: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Show Mouse context menu
|
||||
if !self.selected_range.contains(offset) {
|
||||
self.move_to(offset, None, cx);
|
||||
}
|
||||
|
||||
self.context_menu_content = Some(ContextMenu::RightClick(self.context_menu.clone()));
|
||||
|
||||
let is_code_editor = self.mode.is_code_editor();
|
||||
if is_code_editor {
|
||||
self.handle_hover_definition(offset, window, cx);
|
||||
}
|
||||
|
||||
let is_enable = !self.disabled;
|
||||
let has_goto_definition = is_enable && self.lsp.definition_provider.is_some();
|
||||
let has_code_action = is_enable && !self.lsp.code_action_providers.is_empty();
|
||||
let is_selected = !self.selected_range.is_empty();
|
||||
let has_paste = is_enable && cx.read_from_clipboard().is_some();
|
||||
|
||||
let action_context = self.focus_handle.clone();
|
||||
self.context_menu.update(cx, |this, cx| {
|
||||
this.mouse_position = event.position;
|
||||
this.menu.update(cx, |menu, cx| {
|
||||
let new_menu = if let Some(builder) = &self.context_menu_builder {
|
||||
builder(PopupMenu::new(cx), window, cx)
|
||||
} else {
|
||||
PopupMenu::new(cx)
|
||||
.when(is_code_editor, |m| {
|
||||
m.menu_with_enable(
|
||||
"Go to Definition",
|
||||
Box::new(input::GoToDefinition),
|
||||
has_goto_definition,
|
||||
)
|
||||
.menu_with_enable(
|
||||
"Show Code Actions",
|
||||
Box::new(input::ToggleCodeActions),
|
||||
has_code_action,
|
||||
)
|
||||
.separator()
|
||||
})
|
||||
.menu_with_enable("Cut", Box::new(input::Cut), is_enable && is_selected)
|
||||
.menu_with_enable("Copy", Box::new(input::Copy), is_selected)
|
||||
.menu_with_enable("Paste", Box::new(input::Paste), has_paste)
|
||||
.separator()
|
||||
.menu("Select All", Box::new(input::SelectAll))
|
||||
};
|
||||
|
||||
menu.menu_items = new_menu.menu_items;
|
||||
menu.action_context = Some(action_context);
|
||||
cx.notify();
|
||||
});
|
||||
cx.defer_in(window, |this, _, cx| {
|
||||
this.open = true;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl InputContextMenu {
|
||||
pub(crate) fn new(
|
||||
editor: Entity<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
let menu = cx.new(|cx| PopupMenu::new(cx).small());
|
||||
|
||||
let _subscriptions = vec![cx.subscribe_in(&menu, window, {
|
||||
move |this: &mut Self, _, _: &DismissEvent, window, cx| {
|
||||
this.close(window, cx);
|
||||
}
|
||||
})];
|
||||
|
||||
Self {
|
||||
editor,
|
||||
menu,
|
||||
mouse_position: Point::default(),
|
||||
open: false,
|
||||
_subscriptions,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_open(&self) -> bool {
|
||||
self.open
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.open = false;
|
||||
self.editor.update(cx, |this, cx| {
|
||||
this.focus(window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for InputContextMenu {
|
||||
fn render(&mut self, _: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if !self.open {
|
||||
return div().into_any_element();
|
||||
}
|
||||
|
||||
deferred(
|
||||
anchored()
|
||||
.snap_to_window_with_margin(px(8.))
|
||||
.anchor(Anchor::TopLeft)
|
||||
.position(self.mouse_position)
|
||||
.child(div().cursor_default().child(self.menu.clone())),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
prelude::FluentBuilder as _, px, App, AppContext as _, Bounds, Context, Empty, Entity,
|
||||
IntoElement, Pixels, Point, Render, Styled, Window,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
highlighter::DiagnosticEntry,
|
||||
input::{
|
||||
popovers::{render_markdown, Popover},
|
||||
InputState,
|
||||
},
|
||||
};
|
||||
|
||||
pub struct DiagnosticPopover {
|
||||
state: Entity<InputState>,
|
||||
pub(crate) diagnostic: Rc<DiagnosticEntry>,
|
||||
bounds: Bounds<Pixels>,
|
||||
open: bool,
|
||||
}
|
||||
|
||||
impl DiagnosticPopover {
|
||||
pub fn new(
|
||||
diagnostic: &DiagnosticEntry,
|
||||
state: Entity<InputState>,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let diagnostic = Rc::new(diagnostic.clone());
|
||||
|
||||
cx.new(|_| Self {
|
||||
diagnostic,
|
||||
state,
|
||||
bounds: Bounds::default(),
|
||||
open: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn show(&mut self, cx: &mut Context<Self>) {
|
||||
self.open = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn hide(&mut self, cx: &mut Context<Self>) {
|
||||
self.open = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn check_to_hide(&mut self, mouse_position: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
if !self.open {
|
||||
return;
|
||||
}
|
||||
|
||||
let padding = px(5.);
|
||||
let bounds = Bounds {
|
||||
origin: self.bounds.origin.map(|v| v - padding),
|
||||
size: self.bounds.size.map(|v| v + padding * 2.),
|
||||
};
|
||||
|
||||
if !bounds.contains(&mouse_position) {
|
||||
self.hide(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for DiagnosticPopover {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if !self.open {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let message = self.diagnostic.message.clone();
|
||||
|
||||
let (border, bg, fg) = (
|
||||
self.diagnostic.severity.border(cx),
|
||||
self.diagnostic.severity.bg(cx),
|
||||
self.diagnostic.severity.fg(cx),
|
||||
);
|
||||
|
||||
Popover::new(
|
||||
"diagnostic-popover",
|
||||
self.state.clone(),
|
||||
self.diagnostic.range.clone(),
|
||||
move |window, cx| render_markdown("message", message.clone(), window, cx),
|
||||
)
|
||||
.when(!self.open, |this| this.invisible())
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.bg(bg)
|
||||
.text_color(fg)
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, AvailableSpace, Bounds, Element, ElementId, Entity,
|
||||
InteractiveElement, IntoElement, MouseDownEvent, MouseMoveEvent, ParentElement as _, Pixels,
|
||||
Render, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, deferred, div, point,
|
||||
px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
StyledExt,
|
||||
input::{InputState, popovers::render_markdown},
|
||||
};
|
||||
|
||||
pub struct HoverPopover {
|
||||
editor: Entity<InputState>,
|
||||
/// The symbol range byte of the hover trigger.
|
||||
pub(crate) symbol_range: Range<usize>,
|
||||
pub(crate) hover: Rc<lsp_types::Hover>,
|
||||
}
|
||||
|
||||
impl HoverPopover {
|
||||
pub fn new(
|
||||
editor: Entity<InputState>,
|
||||
symbol_range: Range<usize>,
|
||||
hover: &lsp_types::Hover,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let hover = Rc::new(hover.clone());
|
||||
|
||||
cx.new(|_| Self {
|
||||
editor,
|
||||
symbol_range,
|
||||
hover,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_same(&self, offset: usize) -> bool {
|
||||
self.symbol_range.contains(&offset)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for HoverPopover {
|
||||
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
|
||||
let contents = match self.hover.contents.clone() {
|
||||
lsp_types::HoverContents::Scalar(scalar) => match scalar {
|
||||
lsp_types::MarkedString::String(s) => s,
|
||||
lsp_types::MarkedString::LanguageString(ls) => ls.value,
|
||||
},
|
||||
lsp_types::HoverContents::Array(arr) => arr
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
lsp_types::MarkedString::String(s) => s,
|
||||
lsp_types::MarkedString::LanguageString(ls) => ls.value,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n"),
|
||||
lsp_types::HoverContents::Markup(markup) => markup.value,
|
||||
};
|
||||
|
||||
Popover::new(
|
||||
"hover-popover",
|
||||
self.editor.clone(),
|
||||
self.symbol_range.clone(),
|
||||
move |window, cx| render_markdown("message", contents.clone(), window, cx),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Popover {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
editor: Entity<InputState>,
|
||||
range: Range<usize>,
|
||||
width_limit: Range<Pixels>,
|
||||
content_builder: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
|
||||
}
|
||||
|
||||
impl Styled for Popover {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl Popover {
|
||||
pub fn new<F, E>(
|
||||
id: impl Into<ElementId>,
|
||||
editor: Entity<InputState>,
|
||||
range: Range<usize>,
|
||||
f: F,
|
||||
) -> Self
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) -> E + 'static,
|
||||
E: IntoElement,
|
||||
{
|
||||
Self {
|
||||
id: id.into(),
|
||||
editor,
|
||||
range,
|
||||
style: StyleRefinement::default(),
|
||||
width_limit: px(200.)..px(500.),
|
||||
content_builder: Box::new(move |window, cx| (f)(window, cx).into_any_element()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the bounds of the range in the editor, if it is visible.
|
||||
fn trigger_bounds(&self, cx: &App) -> Option<Bounds<Pixels>> {
|
||||
let editor = self.editor.read(cx);
|
||||
let Some(last_layout) = editor.last_layout.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let Some(last_bounds) = editor.last_bounds else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let (_, _, start_pos) = editor.line_and_position_for_offset(self.range.start);
|
||||
let (_, _, end_pos) = editor.line_and_position_for_offset(self.range.end);
|
||||
|
||||
let Some(start_pos) = start_pos else {
|
||||
return None;
|
||||
};
|
||||
let Some(end_pos) = end_pos else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(Bounds::from_corners(
|
||||
last_bounds.origin + start_pos,
|
||||
last_bounds.origin + end_pos + point(px(0.), last_layout.line_height),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for Popover {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PopoverLayoutState {
|
||||
bounds: Bounds<Pixels>,
|
||||
element: Option<AnyElement>,
|
||||
}
|
||||
|
||||
impl Element for Popover {
|
||||
type RequestLayoutState = PopoverLayoutState;
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
Some(self.id.clone())
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
let trigger_bounds = match self.trigger_bounds(cx) {
|
||||
Some(bounds) => bounds,
|
||||
None => {
|
||||
return (
|
||||
div().into_any_element().request_layout(window, cx),
|
||||
PopoverLayoutState {
|
||||
bounds: Bounds::default(),
|
||||
element: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let max_width = self
|
||||
.width_limit
|
||||
.end
|
||||
.min(window.bounds().size.width - SNAP_TO_EDGE * 2)
|
||||
.max(px(200.));
|
||||
let max_height = (window.bounds().size.height - SNAP_TO_EDGE * 2).min(px(320.));
|
||||
|
||||
let mut popover = deferred(
|
||||
div()
|
||||
.id("hover-popover-content")
|
||||
.flex_none()
|
||||
.occlude()
|
||||
.p_1()
|
||||
.text_xs()
|
||||
.popover_style(cx)
|
||||
.shadow_md()
|
||||
.max_w(max_width)
|
||||
.max_h(max_height)
|
||||
.overflow_y_scroll()
|
||||
.refine_style(&self.style)
|
||||
.child((self.content_builder)(window, cx)),
|
||||
)
|
||||
.into_any_element();
|
||||
|
||||
let popover_size = popover.layout_as_root(AvailableSpace::min_size(), window, cx);
|
||||
const SNAP_TO_EDGE: Pixels = px(8.);
|
||||
let top_space = trigger_bounds.top() - SNAP_TO_EDGE;
|
||||
let right_space = window.bounds().size.width - trigger_bounds.left() - SNAP_TO_EDGE;
|
||||
|
||||
let mut pos = point(
|
||||
trigger_bounds.left(),
|
||||
trigger_bounds.top() - popover_size.height,
|
||||
);
|
||||
if popover_size.height > top_space {
|
||||
pos.y = trigger_bounds.bottom();
|
||||
}
|
||||
if popover_size.width > right_space {
|
||||
pos.x = trigger_bounds.right() - popover_size.width;
|
||||
}
|
||||
|
||||
let mut empty = div().into_any_element();
|
||||
let layout_id = empty.request_layout(window, cx);
|
||||
(
|
||||
layout_id,
|
||||
PopoverLayoutState {
|
||||
bounds: Bounds {
|
||||
origin: pos,
|
||||
size: popover_size,
|
||||
},
|
||||
element: Some(popover),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
let bounds = request_layout.bounds;
|
||||
let Some(popover) = request_layout.element.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
window.with_absolute_element_offset(bounds.origin, |window| {
|
||||
popover.prepaint(window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let bounds = request_layout.bounds;
|
||||
let Some(popover) = request_layout.element.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
popover.paint(window, cx);
|
||||
|
||||
let editor = self.editor.clone();
|
||||
// Mouse down out to hide.
|
||||
window.on_mouse_event(move |event: &MouseDownEvent, _, _, cx| {
|
||||
if !bounds.contains(&event.position) {
|
||||
let _ = editor.update(cx, |editor, cx| {
|
||||
editor.clear_hover_state(cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Mouse out of trigger + popover bounds
|
||||
let editor = self.editor.clone();
|
||||
let trigger_bounds = self.trigger_bounds(cx).unwrap_or(bounds);
|
||||
let keep_open_region = trigger_bounds.union(&bounds);
|
||||
window.on_mouse_event(move |event: &MouseMoveEvent, _, _, cx| {
|
||||
if !keep_open_region.contains(&event.position) {
|
||||
let _ = editor.update(cx, |editor, cx| {
|
||||
editor.clear_hover_state(cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
mod code_action_menu;
|
||||
mod completion_menu;
|
||||
mod context_menu;
|
||||
mod diagnostic_popover;
|
||||
mod hover_popover;
|
||||
|
||||
pub(crate) use code_action_menu::*;
|
||||
pub(crate) use completion_menu::*;
|
||||
pub(crate) use context_menu::*;
|
||||
pub(crate) use diagnostic_popover::*;
|
||||
use gpui::{
|
||||
App, Div, ElementId, Entity, InteractiveElement as _, IntoElement, SharedString, Stateful,
|
||||
StyleRefinement, Styled as _, Window, div, px, rems,
|
||||
};
|
||||
pub(crate) use hover_popover::*;
|
||||
|
||||
use crate::StyledExt as _;
|
||||
|
||||
pub(crate) enum ContextMenu {
|
||||
Completion(Entity<CompletionMenu>),
|
||||
CodeAction(Entity<CodeActionMenu>),
|
||||
RightClick(Entity<InputContextMenu>),
|
||||
}
|
||||
|
||||
impl ContextMenu {
|
||||
pub(crate) fn is_open(&self, cx: &App) -> bool {
|
||||
match self {
|
||||
ContextMenu::Completion(menu) => menu.read(cx).is_open(),
|
||||
ContextMenu::CodeAction(menu) => menu.read(cx).is_open(),
|
||||
ContextMenu::RightClick(menu) => menu.read(cx).is_open(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render(&self) -> impl IntoElement {
|
||||
match self {
|
||||
ContextMenu::Completion(menu) => menu.clone().into_any_element(),
|
||||
ContextMenu::CodeAction(menu) => menu.clone().into_any_element(),
|
||||
ContextMenu::RightClick(menu) => menu.clone().into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,9 +99,6 @@ actions!(
|
||||
MoveToPreviousWord,
|
||||
MoveToNextWord,
|
||||
Escape,
|
||||
ToggleCodeActions,
|
||||
Search,
|
||||
GoToDefinition,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -250,14 +247,6 @@ pub(crate) fn init(cx: &mut App) {
|
||||
KeyBinding::new("ctrl-z", Undo, Some(CONTEXT)),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
KeyBinding::new("ctrl-y", Redo, Some(CONTEXT)),
|
||||
#[cfg(target_os = "macos")]
|
||||
KeyBinding::new("cmd-.", ToggleCodeActions, Some(CONTEXT)),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
KeyBinding::new("ctrl-.", ToggleCodeActions, Some(CONTEXT)),
|
||||
#[cfg(target_os = "macos")]
|
||||
KeyBinding::new("cmd-f", Search, Some(CONTEXT)),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
KeyBinding::new("ctrl-f", Search, Some(CONTEXT)),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -355,7 +344,6 @@ pub struct InputState {
|
||||
pub(super) clean_on_escape: bool,
|
||||
pub(super) submit_on_enter: bool,
|
||||
pub(super) soft_wrap: bool,
|
||||
pub(super) show_whitespaces: bool,
|
||||
/// This flag tells the renderer to prefer the end of the current visual line.
|
||||
pub(crate) cursor_line_end_affinity: bool,
|
||||
pub(super) pattern: Option<regex::Regex>,
|
||||
@@ -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.
|
||||
|
||||
@@ -9,7 +9,6 @@ use gpui::{
|
||||
SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task,
|
||||
UniformListScrollHandle, Window, div, px, size, uniform_list,
|
||||
};
|
||||
use smol::Timer;
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ use theme::ActiveTheme;
|
||||
use crate::{Icon, IconName, InteractiveElementExt as _, Sizable as _, StyledExt, h_flex};
|
||||
|
||||
pub const TITLE_BAR_HEIGHT: Pixels = px(34.);
|
||||
#[cfg(target_os = "macos")]
|
||||
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
|
||||
|
||||
/// TitleBar used to customize the appearance of the title bar.
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
[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
|
||||
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
|
||||
@@ -7,11 +7,11 @@ use gpui::{
|
||||
Subscription, Task, Window, div,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use state::NostrRegistry;
|
||||
use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::{Disableable, WindowExtension, v_flex};
|
||||
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportIdentity {
|
||||
@@ -36,7 +36,7 @@ pub struct ImportIdentity {
|
||||
|
||||
impl ImportIdentity {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let key_input = cx.new(|cx| InputState::new(window, cx).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);
|
||||
|
||||
@@ -61,11 +61,26 @@ impl ImportIdentity {
|
||||
let value = self.key_input.read(cx).value();
|
||||
let password = self.pass_input.read(cx).value();
|
||||
|
||||
// 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);
|
||||
@@ -74,7 +89,6 @@ impl ImportIdentity {
|
||||
nostr.update(cx, |this, cx| {
|
||||
this.set_signer(keys, cx);
|
||||
});
|
||||
window.close_modal(cx);
|
||||
} else {
|
||||
self.set_error("Invalid key", cx);
|
||||
}
|
||||
@@ -126,10 +140,43 @@ impl ImportIdentity {
|
||||
}));
|
||||
}
|
||||
|
||||
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>,
|
||||
{
|
||||
self.set_loading(false, cx);
|
||||
|
||||
// Update error message
|
||||
self.error.update(cx, |this, cx| {
|
||||
*this = Some(message.into());
|
||||
@@ -154,34 +201,40 @@ impl ImportIdentity {
|
||||
|
||||
impl Render for ImportIdentity {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const MSG: &str = "Coop isn't stored your identity secret in local device. Everything will be reset on the next login.";
|
||||
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 ncryptsec://")
|
||||
.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)),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(div().text_xs().text_color(cx.theme().text_muted).child(MSG))
|
||||
.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")
|
||||
@@ -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};
|
||||
@@ -78,7 +78,7 @@ impl Screening {
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let Some(current_user) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
let Some(current_user) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -106,7 +106,7 @@ impl Screening {
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let Some(current_user) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
let Some(current_user) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -224,12 +224,9 @@ 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 Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let tag = Nip56Tag::PublicKey {
|
||||
public_key,
|
||||
@@ -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::*;
|
||||
@@ -27,14 +28,16 @@ use crate::dialogs::import::ImportIdentity;
|
||||
use crate::dialogs::restore::RestoreEncryption;
|
||||
use crate::dialogs::settings;
|
||||
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list, trash};
|
||||
use crate::sidebar;
|
||||
|
||||
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 RelayNotifcation;
|
||||
struct MsgRelayNotification;
|
||||
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
@@ -63,6 +66,9 @@ pub struct Workspace {
|
||||
/// App's Image Cache
|
||||
image_cache: Entity<CoopImageCache>,
|
||||
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 6]>,
|
||||
}
|
||||
@@ -72,13 +78,21 @@ impl Workspace {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer.clone();
|
||||
|
||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||
let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Observe sign in state changes
|
||||
cx.observe_in(&nostr, window, move |this, nostr, window, cx| {
|
||||
if nostr.read(cx).current_user().is_some() {
|
||||
this.set_center_layout(window, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe system appearance and update theme
|
||||
cx.observe_window_appearance(window, |_this, window, cx| {
|
||||
@@ -86,40 +100,19 @@ impl Workspace {
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the signer
|
||||
cx.observe_in(&signer, window, |this, signer, window, cx| {
|
||||
if signer.read(cx).is_some() {
|
||||
this.set_center_layout(window, cx);
|
||||
} else {
|
||||
this.import_identity(window, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to the nostr events
|
||||
cx.subscribe_in(&nostr, window, move |this, state, event, window, cx| {
|
||||
cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| {
|
||||
match event {
|
||||
StateEvent::Connecting => {
|
||||
let note = Notification::new()
|
||||
.id::<RelayNotifcation>()
|
||||
.message("Connecting to the bootstrap relays...")
|
||||
.with_kind(NotificationKind::Info);
|
||||
StateEvent::SignerChanged => {
|
||||
this.set_center_layout(window, cx);
|
||||
|
||||
window.push_notification(note, cx);
|
||||
cx.defer_in(window, |_this, window, cx| {
|
||||
window.close_all_modals(cx);
|
||||
});
|
||||
}
|
||||
StateEvent::Connected => {
|
||||
let note = Notification::new()
|
||||
.id::<RelayNotifcation>()
|
||||
.message("Connected to the bootstrap relays")
|
||||
.with_kind(NotificationKind::Success);
|
||||
|
||||
window.push_notification(note, cx);
|
||||
|
||||
if state.read(cx).signer.read(cx).is_none() {
|
||||
this.import_identity(window, cx);
|
||||
}
|
||||
StateEvent::NoSigner => {
|
||||
this.import_identity(window, cx);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
@@ -256,6 +249,7 @@ impl Workspace {
|
||||
Self {
|
||||
dock,
|
||||
image_cache,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
@@ -327,7 +321,7 @@ impl Workspace {
|
||||
Command::ShowProfile => {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
if let Some(public_key) = nostr.read(cx).signer_pubkey(cx) {
|
||||
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)),
|
||||
@@ -401,7 +395,7 @@ impl Workspace {
|
||||
let device = DeviceRegistry::global(cx).downgrade();
|
||||
let save_dialog = cx.prompt_for_new_path(download_dir(), Some("encryption.txt"));
|
||||
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
|
||||
// Get the output path from the save dialog
|
||||
let output_path = match save_dialog.await {
|
||||
Ok(Ok(Some(path))) => path,
|
||||
@@ -428,9 +422,8 @@ impl Workspace {
|
||||
cx.open_with_system(output_path.as_path());
|
||||
})?;
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
Command::ImportEncryption => {
|
||||
self.import_encryption(window, cx);
|
||||
@@ -580,7 +573,7 @@ impl Workspace {
|
||||
|
||||
fn titlebar_left(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let current_user = nostr.read(cx).signer_pubkey(cx);
|
||||
let current_user = nostr.read(cx).current_user();
|
||||
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
@@ -658,7 +651,7 @@ impl Workspace {
|
||||
let is_nip4e_enabled = AppSettings::get_nip4e(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return div();
|
||||
};
|
||||
|
||||
@@ -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? {
|
||||
@@ -82,7 +82,7 @@ impl ContactListPanel {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -157,10 +157,7 @@ impl ContactListPanel {
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return;
|
||||
};
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
// Get contacts
|
||||
let contacts: Vec<Contact> = self
|
||||
@@ -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,14 +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);
|
||||
|
||||
if let Some(public_key) = nostr.read(cx).signer_pubkey(cx) {
|
||||
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),
|
||||
@@ -42,8 +45,9 @@ impl GreeterPanel {
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ impl MessagingRelayPanel {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -171,10 +171,7 @@ impl MessagingRelayPanel {
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return;
|
||||
};
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
// Construct event tags
|
||||
let tags: Vec<Tag> = self
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
||||
@@ -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,9 @@ 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();
|
||||
|
||||
let Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return Task::ready(Err(anyhow!("Signer is required")));
|
||||
};
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Build and sign the metadata event
|
||||
let event = metadata.finalize_async(&signer).await?;
|
||||
@@ -100,7 +100,7 @@ impl RelayListPanel {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -207,10 +207,7 @@ impl RelayListPanel {
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(signer) = nostr.read(cx).signer(cx) else {
|
||||
return;
|
||||
};
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
// Get all relays
|
||||
let relays = self.relays.clone();
|
||||
@@ -159,7 +159,7 @@ impl Sidebar {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -320,7 +320,7 @@ impl Sidebar {
|
||||
let async_chat = chat.downgrade();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let Some(public_key) = nostr.read(cx).signer_pubkey(cx) else {
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -462,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)
|
||||
@@ -28,43 +28,23 @@ icons = [
|
||||
|
||||
[dependencies]
|
||||
assets = { path = "../crates/assets" }
|
||||
workspace = { path = "../crates/workspace" }
|
||||
ui = { path = "../crates/ui" }
|
||||
theme = { path = "../crates/theme" }
|
||||
common = { path = "../crates/common" }
|
||||
state = { path = "../crates/state" }
|
||||
device = { path = "../crates/device" }
|
||||
chat = { path = "../crates/chat" }
|
||||
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"
|
||||
|
||||
@@ -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() {
|
||||
@@ -71,9 +66,6 @@ fn main() {
|
||||
cx.activate(true);
|
||||
|
||||
cx.new(|cx| {
|
||||
// Initialize the tokio runtime
|
||||
gpui_tokio::init(cx);
|
||||
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
|
||||
@@ -89,9 +81,6 @@ fn main() {
|
||||
// Initialize person registry
|
||||
person::init(window, cx);
|
||||
|
||||
// Initialize relay auth registry
|
||||
relay_auth::init(window, cx);
|
||||
|
||||
// Initialize device signer
|
||||
//
|
||||
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||
|
||||
@@ -10,5 +10,6 @@ targets = [
|
||||
"aarch64-pc-windows-msvc",
|
||||
"aarch64-apple-ios",
|
||||
"aarch64-linux-android",
|
||||
"wasm32-unknown-unknown"
|
||||
"wasm32-unknown-unknown",
|
||||
"wasm32-wasip2"
|
||||
]
|
||||
|
||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
workspace = { path = "../crates/workspace" }
|
||||
assets = { path = "../crates/assets" }
|
||||
ui = { path = "../crates/ui" }
|
||||
theme = { path = "../crates/theme" }
|
||||
@@ -14,29 +15,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"
|
||||
|
||||
[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"] }
|
||||
tokio = { version = "1", default-features = false, features = ["sync", "macros", "io-util", "rt", "time"] }
|
||||
|
||||
46
web/Makefile
Normal file
46
web/Makefile
Normal 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/src/wasm/*.js www/src/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
4
web/rust-toolchain.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
components = ["rustfmt", "clippy"]
|
||||
targets = ["wasm32-unknown-unknown"]
|
||||
66
web/script/build-wasm.sh
Executable file
66
web/script/build-wasm.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/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}"
|
||||
brew install llvm
|
||||
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"
|
||||
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
|
||||
WORKSPACE_ROOT="$PROJECT_ROOT/../.."
|
||||
WASM_PATH="$WORKSPACE_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/src/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"
|
||||
@@ -1,4 +1,5 @@
|
||||
use gpui::*;
|
||||
use ui::Root;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
@@ -14,13 +15,39 @@ pub fn run() -> Result<(), JsValue> {
|
||||
#[cfg(target_family = "wasm")]
|
||||
gpui_platform::web_init();
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let app = gpui_platform::application();
|
||||
gpui_platform::application().run(|cx| {
|
||||
cx.open_window(WindowOptions::default(), |window, cx| {
|
||||
cx.new(|cx| {
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
let app = gpui_platform::single_threaded_web();
|
||||
// Initialize theme registry
|
||||
theme::init(cx);
|
||||
|
||||
app.run(|_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 Entity
|
||||
Root::new(workspace::init(window, cx).into(), window, cx)
|
||||
})
|
||||
})
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
cx.activate(true);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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/src/www/main.js
Normal file
35
web/src/www/main.js
Normal 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();
|
||||
17
web/src/www/package.json
Normal file
17
web/src/www/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "coop-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"wasm": "cargo build --target wasm32-unknown-unknown --release && wasm-bindgen ../../../target/wasm32-unknown-unknown/release/gpui_component_story_web.wasm --out-dir ./src/wasm --target web --no-typescript"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^8",
|
||||
"vite-plugin-static-copy": "^3.2.0",
|
||||
"vite-plugin-wasm": "^3.3.0"
|
||||
}
|
||||
}
|
||||
75
web/src/www/vite.config.js
Normal file
75
web/src/www/vite.config.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { defineConfig } from "vite";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
wasm(),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: path.resolve(__dirname, "../../../assets/icons"),
|
||||
dest: "assets",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
name: "serve-assets",
|
||||
configureServer(server) {
|
||||
server.middlewares.use(
|
||||
"/coop/assets",
|
||||
(req, res, next) => {
|
||||
const assetsPath = path.resolve(__dirname, "../../../assets");
|
||||
const filePath = path.join(
|
||||
assetsPath,
|
||||
req.url.replace("/assets", ""),
|
||||
);
|
||||
|
||||
// Try to serve the file
|
||||
import("fs").then(({ default: fs }) => {
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
if (filePath.endsWith(".svg")) {
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
}
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
build: {
|
||||
target: "esnext",
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true,
|
||||
fs: {
|
||||
strict: false,
|
||||
allow: [".."],
|
||||
},
|
||||
headers: {
|
||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["./src/wasm"],
|
||||
},
|
||||
base: "/",
|
||||
});
|
||||
Reference in New Issue
Block a user