Merge branch 'main' into nate/gpui2-ui-components

This commit is contained in:
Nate Butler 2023-09-19 00:26:41 -04:00
commit 26f442a675
77 changed files with 3152 additions and 406 deletions

View File

@ -1,2 +1,6 @@
[alias]
xtask = "run --package xtask --"
[build]
# v0 mangling scheme provides more detailed backtraces around closures
rustflags = ["-C", "symbol-mangling-version=v0"]

26
Cargo.lock generated
View File

@ -79,18 +79,9 @@ dependencies = [
[[package]]
name = "aho-corasick"
version = "0.7.20"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac"
dependencies = [
"memchr",
]
[[package]]
name = "aho-corasick"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6748e8def348ed4d14996fa801f4122cd763fff530258cdc03f64b25f89d3a5a"
checksum = "0f2135563fb5c609d2b2b87c1e8ce7bc41b0b45430fa9661f457981503dd5bf0"
dependencies = [
"memchr",
]
@ -101,6 +92,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"client",
"collections",
"ctor",
"editor",
@ -127,6 +119,7 @@ dependencies = [
"theme",
"tiktoken-rs 0.4.5",
"util",
"uuid 1.4.1",
"workspace",
]
@ -1554,6 +1547,7 @@ dependencies = [
"settings",
"theme",
"theme_selector",
"time 0.3.27",
"util",
"vcs_menu",
"workspace",
@ -2329,7 +2323,7 @@ checksum = "bbfc4744c1b8f2a09adc0e55242f60b1af195d88596bd8700be74418c056c555"
name = "editor"
version = "0.1.0"
dependencies = [
"aho-corasick 0.7.20",
"aho-corasick",
"anyhow",
"client",
"clock",
@ -3077,7 +3071,7 @@ version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "759c97c1e17c55525b57192c06a267cda0ac5210b222d6b82189a2338fa1c13d"
dependencies = [
"aho-corasick 1.0.4",
"aho-corasick",
"bstr",
"fnv",
"log",
@ -5459,7 +5453,7 @@ dependencies = [
name = "project"
version = "0.1.0"
dependencies = [
"aho-corasick 0.7.20",
"aho-corasick",
"anyhow",
"async-trait",
"backtrace",
@ -5969,7 +5963,7 @@ version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81bc1d4caf89fac26a70747fe603c130093b53c773888797a6329091246d651a"
dependencies = [
"aho-corasick 1.0.4",
"aho-corasick",
"memchr",
"regex-automata 0.3.6",
"regex-syntax 0.7.4",
@ -5990,7 +5984,7 @@ version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed1ceff11a1dddaee50c9dc8e4938bd106e9d89ae372f192311e7da498e3b69"
dependencies = [
"aho-corasick 1.0.4",
"aho-corasick",
"memchr",
"regex-syntax 0.7.4",
]

3
assets/icons/logo_96.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 6C7.34315 6 6 7.34315 6 9V75H0V9C0 4.02944 4.02944 0 9 0H89.3787C93.3878 0 95.3955 4.84715 92.5607 7.68198L43.0551 57.1875H57V51H63V58.6875C63 61.1728 60.9853 63.1875 58.5 63.1875H37.0551L26.7426 73.5H73.5V36H79.5V73.5C79.5 76.8137 76.8137 79.5 73.5 79.5H20.7426L10.2426 90H87C88.6569 90 90 88.6569 90 87V21H96V87C96 91.9706 91.9706 96 87 96H6.62132C2.61224 96 0.604504 91.1529 3.43934 88.318L52.7574 39H39V45H33V37.5C33 35.0147 35.0147 33 37.5 33H58.7574L69.2574 22.5H22.5V60H16.5V22.5C16.5 19.1863 19.1863 16.5 22.5 16.5H75.2574L85.7574 6H9Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 715 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.9 8.00002C7.44656 8.00002 8.7 6.74637 8.7 5.20002C8.7 3.65368 7.44656 2.40002 5.9 2.40002C4.35344 2.40002 3.1 3.65368 3.1 5.20002C3.1 6.74637 4.35344 8.00002 5.9 8.00002ZM7.00906 9.05002H4.79094C2.69684 9.05002 1 10.7475 1 12.841C1 13.261 1.3395 13.6 1.75819 13.6H10.0409C10.4609 13.6 10.8 13.261 10.8 12.841C10.8 10.7475 9.1025 9.05002 7.00906 9.05002ZM11.4803 9.40002H9.86484C10.87 10.2247 11.5 11.4585 11.5 12.841C11.5 13.121 11.4169 13.3791 11.2812 13.6H14.3C14.6872 13.6 15 13.285 15 12.8803C15 10.9663 13.4338 9.40002 11.4803 9.40002ZM10.45 8.00002C11.8041 8.00002 12.9 6.90409 12.9 5.55002C12.9 4.19596 11.8041 3.10002 10.45 3.10002C9.90072 3.10002 9.39913 3.28716 8.9905 3.59243C9.2425 4.07631 9.4 4.61815 9.4 5.20002C9.4 5.97702 9.13903 6.69059 8.70897 7.27181C9.15281 7.72002 9.7675 8.00002 10.45 8.00002Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 947 B

View File

@ -455,6 +455,7 @@
"shift-d": "vim::VisualDelete",
"shift-x": "vim::VisualDelete",
"y": "vim::VisualYank",
"shift-y": "vim::VisualYank",
"p": "vim::Paste",
"shift-p": [
"vim::Paste",

View File

@ -131,6 +131,14 @@
// Default width of the channels panel.
"default_width": 240
},
"chat_panel": {
// Whether to show the collaboration panel button in the status bar.
"button": true,
// Where to dock channels panel. Can be 'left' or 'right'.
"dock": "right",
// Default width of the channels panel.
"default_width": 240
},
"assistant": {
// Whether to show the assistant panel button in the status bar.
"button": true,

View File

@ -9,6 +9,7 @@ path = "src/ai.rs"
doctest = false
[dependencies]
client = { path = "../client" }
collections = { path = "../collections"}
editor = { path = "../editor" }
fs = { path = "../fs" }
@ -19,6 +20,7 @@ search = { path = "../search" }
settings = { path = "../settings" }
theme = { path = "../theme" }
util = { path = "../util" }
uuid = { version = "1.1.2", features = ["v4"] }
workspace = { path = "../workspace" }
anyhow.workspace = true

View File

@ -61,6 +61,7 @@ struct SavedMessage {
#[derive(Serialize, Deserialize)]
struct SavedConversation {
id: Option<String>,
zed: String,
version: String,
text: String,

View File

@ -6,6 +6,7 @@ use crate::{
};
use anyhow::{anyhow, Result};
use chrono::{DateTime, Local};
use client::{telemetry::AssistantKind, ClickhouseEvent, TelemetrySettings};
use collections::{hash_map, HashMap, HashSet, VecDeque};
use editor::{
display_map::{
@ -48,6 +49,7 @@ use theme::{
AssistantStyle,
};
use util::{paths::CONVERSATIONS_DIR, post_inc, ResultExt, TryFutureExt};
use uuid::Uuid;
use workspace::{
dock::{DockPosition, Panel},
searchable::Direction,
@ -296,6 +298,7 @@ impl AssistantPanel {
self.include_conversation_in_next_inline_assist,
self.inline_prompt_history.clone(),
codegen.clone(),
self.workspace.clone(),
cx,
);
cx.focus_self();
@ -724,6 +727,7 @@ impl AssistantPanel {
self.api_key.clone(),
self.languages.clone(),
self.fs.clone(),
self.workspace.clone(),
cx,
)
});
@ -1059,6 +1063,7 @@ impl AssistantPanel {
}
let fs = self.fs.clone();
let workspace = self.workspace.clone();
let api_key = self.api_key.clone();
let languages = self.languages.clone();
cx.spawn(|this, mut cx| async move {
@ -1073,8 +1078,9 @@ impl AssistantPanel {
if let Some(ix) = this.editor_index_for_path(&path, cx) {
this.set_active_editor_index(Some(ix), cx);
} else {
let editor = cx
.add_view(|cx| ConversationEditor::for_conversation(conversation, fs, cx));
let editor = cx.add_view(|cx| {
ConversationEditor::for_conversation(conversation, fs, workspace, cx)
});
this.add_conversation(editor, cx);
}
})?;
@ -1348,6 +1354,7 @@ struct Summary {
}
struct Conversation {
id: Option<String>,
buffer: ModelHandle<Buffer>,
message_anchors: Vec<MessageAnchor>,
messages_metadata: HashMap<MessageId, MessageMetadata>,
@ -1398,6 +1405,7 @@ impl Conversation {
let model = settings.default_open_ai_model.clone();
let mut this = Self {
id: Some(Uuid::new_v4().to_string()),
message_anchors: Default::default(),
messages_metadata: Default::default(),
next_message_id: Default::default(),
@ -1435,6 +1443,7 @@ impl Conversation {
fn serialize(&self, cx: &AppContext) -> SavedConversation {
SavedConversation {
id: self.id.clone(),
zed: "conversation".into(),
version: SavedConversation::VERSION.into(),
text: self.buffer.read(cx).text(),
@ -1462,6 +1471,10 @@ impl Conversation {
language_registry: Arc<LanguageRegistry>,
cx: &mut ModelContext<Self>,
) -> Self {
let id = match saved_conversation.id {
Some(id) => Some(id),
None => Some(Uuid::new_v4().to_string()),
};
let model = saved_conversation.model;
let markdown = language_registry.language_for_name("Markdown");
let mut message_anchors = Vec::new();
@ -1491,6 +1504,7 @@ impl Conversation {
});
let mut this = Self {
id,
message_anchors,
messages_metadata: saved_conversation.message_metadata,
next_message_id,
@ -2108,6 +2122,7 @@ struct ScrollPosition {
struct ConversationEditor {
conversation: ModelHandle<Conversation>,
fs: Arc<dyn Fs>,
workspace: WeakViewHandle<Workspace>,
editor: ViewHandle<Editor>,
blocks: HashSet<BlockId>,
scroll_position: Option<ScrollPosition>,
@ -2119,15 +2134,17 @@ impl ConversationEditor {
api_key: Rc<RefCell<Option<String>>>,
language_registry: Arc<LanguageRegistry>,
fs: Arc<dyn Fs>,
workspace: WeakViewHandle<Workspace>,
cx: &mut ViewContext<Self>,
) -> Self {
let conversation = cx.add_model(|cx| Conversation::new(api_key, language_registry, cx));
Self::for_conversation(conversation, fs, cx)
Self::for_conversation(conversation, fs, workspace, cx)
}
fn for_conversation(
conversation: ModelHandle<Conversation>,
fs: Arc<dyn Fs>,
workspace: WeakViewHandle<Workspace>,
cx: &mut ViewContext<Self>,
) -> Self {
let editor = cx.add_view(|cx| {
@ -2150,6 +2167,7 @@ impl ConversationEditor {
blocks: Default::default(),
scroll_position: None,
fs,
workspace,
_subscriptions,
};
this.update_message_headers(cx);
@ -2157,6 +2175,13 @@ impl ConversationEditor {
}
fn assist(&mut self, _: &Assist, cx: &mut ViewContext<Self>) {
report_assistant_event(
self.workspace.clone(),
self.conversation.read(cx).id.clone(),
AssistantKind::Panel,
cx,
);
let cursors = self.cursors(cx);
let user_messages = self.conversation.update(cx, |conversation, cx| {
@ -2665,6 +2690,7 @@ enum InlineAssistantEvent {
struct InlineAssistant {
id: usize,
prompt_editor: ViewHandle<Editor>,
workspace: WeakViewHandle<Workspace>,
confirmed: bool,
has_focus: bool,
include_conversation: bool,
@ -2780,6 +2806,7 @@ impl InlineAssistant {
include_conversation: bool,
prompt_history: VecDeque<String>,
codegen: ModelHandle<Codegen>,
workspace: WeakViewHandle<Workspace>,
cx: &mut ViewContext<Self>,
) -> Self {
let prompt_editor = cx.add_view(|cx| {
@ -2801,6 +2828,7 @@ impl InlineAssistant {
Self {
id,
prompt_editor,
workspace,
confirmed: false,
has_focus: false,
include_conversation,
@ -2859,6 +2887,8 @@ impl InlineAssistant {
if self.confirmed {
cx.emit(InlineAssistantEvent::Dismissed);
} else {
report_assistant_event(self.workspace.clone(), None, AssistantKind::Inline, cx);
let prompt = self.prompt_editor.read(cx).text(cx);
self.prompt_editor.update(cx, |editor, cx| {
editor.set_read_only(true);
@ -3347,3 +3377,30 @@ mod tests {
.collect()
}
}
fn report_assistant_event(
workspace: WeakViewHandle<Workspace>,
conversation_id: Option<String>,
assistant_kind: AssistantKind,
cx: &AppContext,
) {
let Some(workspace) = workspace.upgrade(cx) else {
return;
};
let client = workspace.read(cx).project().read(cx).client();
let telemetry = client.telemetry();
let model = settings::get::<AssistantSettings>(cx)
.default_open_ai_model
.clone();
let event = ClickhouseEvent::Assistant {
conversation_id,
kind: assistant_kind,
model: model.full_name(),
};
let telemetry_settings = *settings::get::<TelemetrySettings>(cx);
telemetry.report_clickhouse_event(event, telemetry_settings)
}

View File

@ -172,7 +172,7 @@ impl Room {
cx.spawn(|this, mut cx| async move {
connect.await?;
if !cx.read(|cx| settings::get::<CallSettings>(cx).mute_on_join) {
if !cx.read(Self::mute_on_join) {
this.update(&mut cx, |this, cx| this.share_microphone(cx))
.await?;
}
@ -301,6 +301,10 @@ impl Room {
})
}
pub fn mute_on_join(cx: &AppContext) -> bool {
settings::get::<CallSettings>(cx).mute_on_join || client::IMPERSONATE_LOGIN.is_some()
}
fn from_join_response(
response: proto::JoinRoomResponse,
client: Arc<Client>,
@ -1124,7 +1128,7 @@ impl Room {
self.live_kit
.as_ref()
.and_then(|live_kit| match &live_kit.microphone_track {
LocalTrack::None => Some(settings::get::<CallSettings>(cx).mute_on_join),
LocalTrack::None => Some(Self::mute_on_join(cx)),
LocalTrack::Pending { muted, .. } => Some(*muted),
LocalTrack::Published { muted, .. } => Some(*muted),
})

View File

@ -47,5 +47,6 @@ tempfile = "3"
collections = { path = "../collections", features = ["test-support"] }
gpui = { path = "../gpui", features = ["test-support"] }
rpc = { path = "../rpc", features = ["test-support"] }
client = { path = "../client", features = ["test-support"] }
settings = { path = "../settings", features = ["test-support"] }
util = { path = "../util", features = ["test-support"] }

View File

@ -1,14 +1,18 @@
mod channel_buffer;
mod channel_chat;
mod channel_store;
pub mod channel_buffer;
use std::sync::Arc;
pub use channel_buffer::{ChannelBuffer, ChannelBufferEvent};
pub use channel_chat::{ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId};
pub use channel_store::{Channel, ChannelEvent, ChannelId, ChannelMembership, ChannelStore};
pub use channel_store::*;
use client::Client;
use std::sync::Arc;
#[cfg(test)]
mod channel_store_tests;
pub fn init(client: &Arc<Client>) {
channel_buffer::init(client);
channel_chat::init(client);
}

View File

@ -23,13 +23,13 @@ pub struct ChannelBuffer {
subscription: Option<client::Subscription>,
}
pub enum Event {
pub enum ChannelBufferEvent {
CollaboratorsChanged,
Disconnected,
}
impl Entity for ChannelBuffer {
type Event = Event;
type Event = ChannelBufferEvent;
fn release(&mut self, _: &mut AppContext) {
if self.connected {
@ -101,7 +101,7 @@ impl ChannelBuffer {
}
}
self.collaborators = collaborators;
cx.emit(Event::CollaboratorsChanged);
cx.emit(ChannelBufferEvent::CollaboratorsChanged);
cx.notify();
}
@ -141,7 +141,7 @@ impl ChannelBuffer {
this.update(&mut cx, |this, cx| {
this.collaborators.push(collaborator);
cx.emit(Event::CollaboratorsChanged);
cx.emit(ChannelBufferEvent::CollaboratorsChanged);
cx.notify();
});
@ -165,7 +165,7 @@ impl ChannelBuffer {
true
}
});
cx.emit(Event::CollaboratorsChanged);
cx.emit(ChannelBufferEvent::CollaboratorsChanged);
cx.notify();
});
@ -185,7 +185,7 @@ impl ChannelBuffer {
break;
}
}
cx.emit(Event::CollaboratorsChanged);
cx.emit(ChannelBufferEvent::CollaboratorsChanged);
cx.notify();
});
@ -230,7 +230,7 @@ impl ChannelBuffer {
if self.connected {
self.connected = false;
self.subscription.take();
cx.emit(Event::Disconnected);
cx.emit(ChannelBufferEvent::Disconnected);
cx.notify()
}
}

View File

@ -0,0 +1,505 @@
use crate::Channel;
use anyhow::{anyhow, Result};
use client::{
proto,
user::{User, UserStore},
Client, Subscription, TypedEnvelope,
};
use futures::lock::Mutex;
use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task};
use rand::prelude::*;
use std::{collections::HashSet, mem, ops::Range, sync::Arc};
use sum_tree::{Bias, SumTree};
use time::OffsetDateTime;
use util::{post_inc, ResultExt as _, TryFutureExt};
pub struct ChannelChat {
channel: Arc<Channel>,
messages: SumTree<ChannelMessage>,
loaded_all_messages: bool,
next_pending_message_id: usize,
user_store: ModelHandle<UserStore>,
rpc: Arc<Client>,
outgoing_messages_lock: Arc<Mutex<()>>,
rng: StdRng,
_subscription: Subscription,
}
#[derive(Clone, Debug)]
pub struct ChannelMessage {
pub id: ChannelMessageId,
pub body: String,
pub timestamp: OffsetDateTime,
pub sender: Arc<User>,
pub nonce: u128,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum ChannelMessageId {
Saved(u64),
Pending(usize),
}
#[derive(Clone, Debug, Default)]
pub struct ChannelMessageSummary {
max_id: ChannelMessageId,
count: usize,
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
struct Count(usize);
#[derive(Clone, Debug, PartialEq)]
pub enum ChannelChatEvent {
MessagesUpdated {
old_range: Range<usize>,
new_count: usize,
},
}
pub fn init(client: &Arc<Client>) {
client.add_model_message_handler(ChannelChat::handle_message_sent);
client.add_model_message_handler(ChannelChat::handle_message_removed);
}
impl Entity for ChannelChat {
type Event = ChannelChatEvent;
fn release(&mut self, _: &mut AppContext) {
self.rpc
.send(proto::LeaveChannelChat {
channel_id: self.channel.id,
})
.log_err();
}
}
impl ChannelChat {
pub async fn new(
channel: Arc<Channel>,
user_store: ModelHandle<UserStore>,
client: Arc<Client>,
mut cx: AsyncAppContext,
) -> Result<ModelHandle<Self>> {
let channel_id = channel.id;
let subscription = client.subscribe_to_entity(channel_id).unwrap();
let response = client
.request(proto::JoinChannelChat { channel_id })
.await?;
let messages = messages_from_proto(response.messages, &user_store, &mut cx).await?;
let loaded_all_messages = response.done;
Ok(cx.add_model(|cx| {
let mut this = Self {
channel,
user_store,
rpc: client,
outgoing_messages_lock: Default::default(),
messages: Default::default(),
loaded_all_messages,
next_pending_message_id: 0,
rng: StdRng::from_entropy(),
_subscription: subscription.set_model(&cx.handle(), &mut cx.to_async()),
};
this.insert_messages(messages, cx);
this
}))
}
pub fn channel(&self) -> &Arc<Channel> {
&self.channel
}
pub fn send_message(
&mut self,
body: String,
cx: &mut ModelContext<Self>,
) -> Result<Task<Result<()>>> {
if body.is_empty() {
Err(anyhow!("message body can't be empty"))?;
}
let current_user = self
.user_store
.read(cx)
.current_user()
.ok_or_else(|| anyhow!("current_user is not present"))?;
let channel_id = self.channel.id;
let pending_id = ChannelMessageId::Pending(post_inc(&mut self.next_pending_message_id));
let nonce = self.rng.gen();
self.insert_messages(
SumTree::from_item(
ChannelMessage {
id: pending_id,
body: body.clone(),
sender: current_user,
timestamp: OffsetDateTime::now_utc(),
nonce,
},
&(),
),
cx,
);
let user_store = self.user_store.clone();
let rpc = self.rpc.clone();
let outgoing_messages_lock = self.outgoing_messages_lock.clone();
Ok(cx.spawn(|this, mut cx| async move {
let outgoing_message_guard = outgoing_messages_lock.lock().await;
let request = rpc.request(proto::SendChannelMessage {
channel_id,
body,
nonce: Some(nonce.into()),
});
let response = request.await?;
drop(outgoing_message_guard);
let message = ChannelMessage::from_proto(
response.message.ok_or_else(|| anyhow!("invalid message"))?,
&user_store,
&mut cx,
)
.await?;
this.update(&mut cx, |this, cx| {
this.insert_messages(SumTree::from_item(message, &()), cx);
Ok(())
})
}))
}
pub fn remove_message(&mut self, id: u64, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
let response = self.rpc.request(proto::RemoveChannelMessage {
channel_id: self.channel.id,
message_id: id,
});
cx.spawn(|this, mut cx| async move {
response.await?;
this.update(&mut cx, |this, cx| {
this.message_removed(id, cx);
Ok(())
})
})
}
pub fn load_more_messages(&mut self, cx: &mut ModelContext<Self>) -> bool {
if !self.loaded_all_messages {
let rpc = self.rpc.clone();
let user_store = self.user_store.clone();
let channel_id = self.channel.id;
if let Some(before_message_id) =
self.messages.first().and_then(|message| match message.id {
ChannelMessageId::Saved(id) => Some(id),
ChannelMessageId::Pending(_) => None,
})
{
cx.spawn(|this, mut cx| {
async move {
let response = rpc
.request(proto::GetChannelMessages {
channel_id,
before_message_id,
})
.await?;
let loaded_all_messages = response.done;
let messages =
messages_from_proto(response.messages, &user_store, &mut cx).await?;
this.update(&mut cx, |this, cx| {
this.loaded_all_messages = loaded_all_messages;
this.insert_messages(messages, cx);
});
anyhow::Ok(())
}
.log_err()
})
.detach();
return true;
}
}
false
}
pub fn rejoin(&mut self, cx: &mut ModelContext<Self>) {
let user_store = self.user_store.clone();
let rpc = self.rpc.clone();
let channel_id = self.channel.id;
cx.spawn(|this, mut cx| {
async move {
let response = rpc.request(proto::JoinChannelChat { channel_id }).await?;
let messages = messages_from_proto(response.messages, &user_store, &mut cx).await?;
let loaded_all_messages = response.done;
let pending_messages = this.update(&mut cx, |this, cx| {
if let Some((first_new_message, last_old_message)) =
messages.first().zip(this.messages.last())
{
if first_new_message.id > last_old_message.id {
let old_messages = mem::take(&mut this.messages);
cx.emit(ChannelChatEvent::MessagesUpdated {
old_range: 0..old_messages.summary().count,
new_count: 0,
});
this.loaded_all_messages = loaded_all_messages;
}
}
this.insert_messages(messages, cx);
if loaded_all_messages {
this.loaded_all_messages = loaded_all_messages;
}
this.pending_messages().cloned().collect::<Vec<_>>()
});
for pending_message in pending_messages {
let request = rpc.request(proto::SendChannelMessage {
channel_id,
body: pending_message.body,
nonce: Some(pending_message.nonce.into()),
});
let response = request.await?;
let message = ChannelMessage::from_proto(
response.message.ok_or_else(|| anyhow!("invalid message"))?,
&user_store,
&mut cx,
)
.await?;
this.update(&mut cx, |this, cx| {
this.insert_messages(SumTree::from_item(message, &()), cx);
});
}
anyhow::Ok(())
}
.log_err()
})
.detach();
}
pub fn message_count(&self) -> usize {
self.messages.summary().count
}
pub fn messages(&self) -> &SumTree<ChannelMessage> {
&self.messages
}
pub fn message(&self, ix: usize) -> &ChannelMessage {
let mut cursor = self.messages.cursor::<Count>();
cursor.seek(&Count(ix), Bias::Right, &());
cursor.item().unwrap()
}
pub fn messages_in_range(&self, range: Range<usize>) -> impl Iterator<Item = &ChannelMessage> {
let mut cursor = self.messages.cursor::<Count>();
cursor.seek(&Count(range.start), Bias::Right, &());
cursor.take(range.len())
}
pub fn pending_messages(&self) -> impl Iterator<Item = &ChannelMessage> {
let mut cursor = self.messages.cursor::<ChannelMessageId>();
cursor.seek(&ChannelMessageId::Pending(0), Bias::Left, &());
cursor
}
async fn handle_message_sent(
this: ModelHandle<Self>,
message: TypedEnvelope<proto::ChannelMessageSent>,
_: Arc<Client>,
mut cx: AsyncAppContext,
) -> Result<()> {
let user_store = this.read_with(&cx, |this, _| this.user_store.clone());
let message = message
.payload
.message
.ok_or_else(|| anyhow!("empty message"))?;
let message = ChannelMessage::from_proto(message, &user_store, &mut cx).await?;
this.update(&mut cx, |this, cx| {
this.insert_messages(SumTree::from_item(message, &()), cx)
});
Ok(())
}
async fn handle_message_removed(
this: ModelHandle<Self>,
message: TypedEnvelope<proto::RemoveChannelMessage>,
_: Arc<Client>,
mut cx: AsyncAppContext,
) -> Result<()> {
this.update(&mut cx, |this, cx| {
this.message_removed(message.payload.message_id, cx)
});
Ok(())
}
fn insert_messages(&mut self, messages: SumTree<ChannelMessage>, cx: &mut ModelContext<Self>) {
if let Some((first_message, last_message)) = messages.first().zip(messages.last()) {
let nonces = messages
.cursor::<()>()
.map(|m| m.nonce)
.collect::<HashSet<_>>();
let mut old_cursor = self.messages.cursor::<(ChannelMessageId, Count)>();
let mut new_messages = old_cursor.slice(&first_message.id, Bias::Left, &());
let start_ix = old_cursor.start().1 .0;
let removed_messages = old_cursor.slice(&last_message.id, Bias::Right, &());
let removed_count = removed_messages.summary().count;
let new_count = messages.summary().count;
let end_ix = start_ix + removed_count;
new_messages.append(messages, &());
let mut ranges = Vec::<Range<usize>>::new();
if new_messages.last().unwrap().is_pending() {
new_messages.append(old_cursor.suffix(&()), &());
} else {
new_messages.append(
old_cursor.slice(&ChannelMessageId::Pending(0), Bias::Left, &()),
&(),
);
while let Some(message) = old_cursor.item() {
let message_ix = old_cursor.start().1 .0;
if nonces.contains(&message.nonce) {
if ranges.last().map_or(false, |r| r.end == message_ix) {
ranges.last_mut().unwrap().end += 1;
} else {
ranges.push(message_ix..message_ix + 1);
}
} else {
new_messages.push(message.clone(), &());
}
old_cursor.next(&());
}
}
drop(old_cursor);
self.messages = new_messages;
for range in ranges.into_iter().rev() {
cx.emit(ChannelChatEvent::MessagesUpdated {
old_range: range,
new_count: 0,
});
}
cx.emit(ChannelChatEvent::MessagesUpdated {
old_range: start_ix..end_ix,
new_count,
});
cx.notify();
}
}
fn message_removed(&mut self, id: u64, cx: &mut ModelContext<Self>) {
let mut cursor = self.messages.cursor::<ChannelMessageId>();
let mut messages = cursor.slice(&ChannelMessageId::Saved(id), Bias::Left, &());
if let Some(item) = cursor.item() {
if item.id == ChannelMessageId::Saved(id) {
let ix = messages.summary().count;
cursor.next(&());
messages.append(cursor.suffix(&()), &());
drop(cursor);
self.messages = messages;
cx.emit(ChannelChatEvent::MessagesUpdated {
old_range: ix..ix + 1,
new_count: 0,
});
}
}
}
}
async fn messages_from_proto(
proto_messages: Vec<proto::ChannelMessage>,
user_store: &ModelHandle<UserStore>,
cx: &mut AsyncAppContext,
) -> Result<SumTree<ChannelMessage>> {
let unique_user_ids = proto_messages
.iter()
.map(|m| m.sender_id)
.collect::<HashSet<_>>()
.into_iter()
.collect();
user_store
.update(cx, |user_store, cx| {
user_store.get_users(unique_user_ids, cx)
})
.await?;
let mut messages = Vec::with_capacity(proto_messages.len());
for message in proto_messages {
messages.push(ChannelMessage::from_proto(message, user_store, cx).await?);
}
let mut result = SumTree::new();
result.extend(messages, &());
Ok(result)
}
impl ChannelMessage {
pub async fn from_proto(
message: proto::ChannelMessage,
user_store: &ModelHandle<UserStore>,
cx: &mut AsyncAppContext,
) -> Result<Self> {
let sender = user_store
.update(cx, |user_store, cx| {
user_store.get_user(message.sender_id, cx)
})
.await?;
Ok(ChannelMessage {
id: ChannelMessageId::Saved(message.id),
body: message.body,
timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64)?,
sender,
nonce: message
.nonce
.ok_or_else(|| anyhow!("nonce is required"))?
.into(),
})
}
pub fn is_pending(&self) -> bool {
matches!(self.id, ChannelMessageId::Pending(_))
}
}
impl sum_tree::Item for ChannelMessage {
type Summary = ChannelMessageSummary;
fn summary(&self) -> Self::Summary {
ChannelMessageSummary {
max_id: self.id,
count: 1,
}
}
}
impl Default for ChannelMessageId {
fn default() -> Self {
Self::Saved(0)
}
}
impl sum_tree::Summary for ChannelMessageSummary {
type Context = ();
fn add_summary(&mut self, summary: &Self, _: &()) {
self.max_id = summary.max_id;
self.count += summary.count;
}
}
impl<'a> sum_tree::Dimension<'a, ChannelMessageSummary> for ChannelMessageId {
fn add_summary(&mut self, summary: &'a ChannelMessageSummary, _: &()) {
debug_assert!(summary.max_id > *self);
*self = summary.max_id;
}
}
impl<'a> sum_tree::Dimension<'a, ChannelMessageSummary> for Count {
fn add_summary(&mut self, summary: &'a ChannelMessageSummary, _: &()) {
self.0 += summary.count;
}
}

View File

@ -1,4 +1,4 @@
use crate::channel_buffer::ChannelBuffer;
use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat};
use anyhow::{anyhow, Result};
use client::{Client, Subscription, User, UserId, UserStore};
use collections::{hash_map, HashMap, HashSet};
@ -20,7 +20,8 @@ pub struct ChannelStore {
channels_with_admin_privileges: HashSet<ChannelId>,
outgoing_invites: HashSet<(ChannelId, UserId)>,
update_channels_tx: mpsc::UnboundedSender<proto::UpdateChannels>,
opened_buffers: HashMap<ChannelId, OpenedChannelBuffer>,
opened_buffers: HashMap<ChannelId, OpenedModelHandle<ChannelBuffer>>,
opened_chats: HashMap<ChannelId, OpenedModelHandle<ChannelChat>>,
client: Arc<Client>,
user_store: ModelHandle<UserStore>,
_rpc_subscription: Subscription,
@ -50,15 +51,9 @@ impl Entity for ChannelStore {
type Event = ChannelEvent;
}
pub enum ChannelMemberStatus {
Invited,
Member,
NotMember,
}
enum OpenedChannelBuffer {
Open(WeakModelHandle<ChannelBuffer>),
Loading(Shared<Task<Result<ModelHandle<ChannelBuffer>, Arc<anyhow::Error>>>>),
enum OpenedModelHandle<E: Entity> {
Open(WeakModelHandle<E>),
Loading(Shared<Task<Result<ModelHandle<E>, Arc<anyhow::Error>>>>),
}
impl ChannelStore {
@ -94,6 +89,7 @@ impl ChannelStore {
channels_with_admin_privileges: Default::default(),
outgoing_invites: Default::default(),
opened_buffers: Default::default(),
opened_chats: Default::default(),
update_channels_tx,
client,
user_store,
@ -115,6 +111,10 @@ impl ChannelStore {
}
}
pub fn client(&self) -> Arc<Client> {
self.client.clone()
}
pub fn has_children(&self, channel_id: ChannelId) -> bool {
self.channel_paths.iter().any(|path| {
if let Some(ix) = path.iter().position(|id| *id == channel_id) {
@ -129,6 +129,12 @@ impl ChannelStore {
self.channel_paths.len()
}
pub fn index_of_channel(&self, channel_id: ChannelId) -> Option<usize> {
self.channel_paths
.iter()
.position(|path| path.ends_with(&[channel_id]))
}
pub fn channels(&self) -> impl '_ + Iterator<Item = (usize, &Arc<Channel>)> {
self.channel_paths.iter().map(move |path| {
let id = path.last().unwrap();
@ -154,7 +160,7 @@ impl ChannelStore {
pub fn has_open_channel_buffer(&self, channel_id: ChannelId, cx: &AppContext) -> bool {
if let Some(buffer) = self.opened_buffers.get(&channel_id) {
if let OpenedChannelBuffer::Open(buffer) = buffer {
if let OpenedModelHandle::Open(buffer) = buffer {
return buffer.upgrade(cx).is_some();
}
}
@ -166,24 +172,62 @@ impl ChannelStore {
channel_id: ChannelId,
cx: &mut ModelContext<Self>,
) -> Task<Result<ModelHandle<ChannelBuffer>>> {
// Make sure that a given channel buffer is only opened once per
// app instance, even if this method is called multiple times
// with the same channel id while the first task is still running.
let client = self.client.clone();
self.open_channel_resource(
channel_id,
|this| &mut this.opened_buffers,
|channel, cx| ChannelBuffer::new(channel, client, cx),
cx,
)
}
pub fn open_channel_chat(
&mut self,
channel_id: ChannelId,
cx: &mut ModelContext<Self>,
) -> Task<Result<ModelHandle<ChannelChat>>> {
let client = self.client.clone();
let user_store = self.user_store.clone();
self.open_channel_resource(
channel_id,
|this| &mut this.opened_chats,
|channel, cx| ChannelChat::new(channel, user_store, client, cx),
cx,
)
}
/// Asynchronously open a given resource associated with a channel.
///
/// Make sure that the resource is only opened once, even if this method
/// is called multiple times with the same channel id while the first task
/// is still running.
fn open_channel_resource<T: Entity, F, Fut>(
&mut self,
channel_id: ChannelId,
get_map: fn(&mut Self) -> &mut HashMap<ChannelId, OpenedModelHandle<T>>,
load: F,
cx: &mut ModelContext<Self>,
) -> Task<Result<ModelHandle<T>>>
where
F: 'static + FnOnce(Arc<Channel>, AsyncAppContext) -> Fut,
Fut: Future<Output = Result<ModelHandle<T>>>,
{
let task = loop {
match self.opened_buffers.entry(channel_id) {
match get_map(self).entry(channel_id) {
hash_map::Entry::Occupied(e) => match e.get() {
OpenedChannelBuffer::Open(buffer) => {
if let Some(buffer) = buffer.upgrade(cx) {
break Task::ready(Ok(buffer)).shared();
OpenedModelHandle::Open(model) => {
if let Some(model) = model.upgrade(cx) {
break Task::ready(Ok(model)).shared();
} else {
self.opened_buffers.remove(&channel_id);
get_map(self).remove(&channel_id);
continue;
}
}
OpenedChannelBuffer::Loading(task) => break task.clone(),
OpenedModelHandle::Loading(task) => {
break task.clone();
}
},
hash_map::Entry::Vacant(e) => {
let client = self.client.clone();
let task = cx
.spawn(|this, cx| async move {
let channel = this.read_with(&cx, |this, _| {
@ -192,30 +236,24 @@ impl ChannelStore {
})
})?;
ChannelBuffer::new(channel, client, cx)
.await
.map_err(Arc::new)
load(channel, cx).await.map_err(Arc::new)
})
.shared();
e.insert(OpenedChannelBuffer::Loading(task.clone()));
e.insert(OpenedModelHandle::Loading(task.clone()));
cx.spawn({
let task = task.clone();
|this, mut cx| async move {
let result = task.await;
this.update(&mut cx, |this, cx| match result {
Ok(buffer) => {
cx.observe_release(&buffer, move |this, _, _| {
this.opened_buffers.remove(&channel_id);
})
.detach();
this.opened_buffers.insert(
this.update(&mut cx, |this, _| match result {
Ok(model) => {
get_map(this).insert(
channel_id,
OpenedChannelBuffer::Open(buffer.downgrade()),
OpenedModelHandle::Open(model.downgrade()),
);
}
Err(error) => {
log::error!("failed to open channel buffer {error:?}");
this.opened_buffers.remove(&channel_id);
Err(_) => {
get_map(this).remove(&channel_id);
}
});
}
@ -494,9 +532,19 @@ impl ChannelStore {
fn handle_connect(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
self.disconnect_channel_buffers_task.take();
for chat in self.opened_chats.values() {
if let OpenedModelHandle::Open(chat) = chat {
if let Some(chat) = chat.upgrade(cx) {
chat.update(cx, |chat, cx| {
chat.rejoin(cx);
});
}
}
}
let mut buffer_versions = Vec::new();
for buffer in self.opened_buffers.values() {
if let OpenedChannelBuffer::Open(buffer) = buffer {
if let OpenedModelHandle::Open(buffer) = buffer {
if let Some(buffer) = buffer.upgrade(cx) {
let channel_buffer = buffer.read(cx);
let buffer = channel_buffer.buffer().read(cx);
@ -522,7 +570,7 @@ impl ChannelStore {
this.update(&mut cx, |this, cx| {
this.opened_buffers.retain(|_, buffer| match buffer {
OpenedChannelBuffer::Open(channel_buffer) => {
OpenedModelHandle::Open(channel_buffer) => {
let Some(channel_buffer) = channel_buffer.upgrade(cx) else {
return false;
};
@ -583,7 +631,7 @@ impl ChannelStore {
false
})
}
OpenedChannelBuffer::Loading(_) => true,
OpenedModelHandle::Loading(_) => true,
});
});
anyhow::Ok(())
@ -605,7 +653,7 @@ impl ChannelStore {
if let Some(this) = this.upgrade(&cx) {
this.update(&mut cx, |this, cx| {
for (_, buffer) in this.opened_buffers.drain() {
if let OpenedChannelBuffer::Open(buffer) = buffer {
if let OpenedModelHandle::Open(buffer) = buffer {
if let Some(buffer) = buffer.upgrade(cx) {
buffer.update(cx, |buffer, cx| buffer.disconnect(cx));
}
@ -654,7 +702,7 @@ impl ChannelStore {
for channel_id in &payload.remove_channels {
let channel_id = *channel_id;
if let Some(OpenedChannelBuffer::Open(buffer)) =
if let Some(OpenedModelHandle::Open(buffer)) =
self.opened_buffers.remove(&channel_id)
{
if let Some(buffer) = buffer.upgrade(cx) {

View File

@ -1,16 +1,15 @@
use crate::channel_chat::ChannelChatEvent;
use super::*;
use client::{Client, UserStore};
use gpui::{AppContext, ModelHandle};
use client::{test::FakeServer, Client, UserStore};
use gpui::{AppContext, ModelHandle, TestAppContext};
use rpc::proto;
use settings::SettingsStore;
use util::http::FakeHttpClient;
#[gpui::test]
fn test_update_channels(cx: &mut AppContext) {
let http = FakeHttpClient::with_404_response();
let client = Client::new(http.clone(), cx);
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
let channel_store = cx.add_model(|cx| ChannelStore::new(client, user_store, cx));
let channel_store = init_test(cx);
update_channels(
&channel_store,
@ -78,11 +77,7 @@ fn test_update_channels(cx: &mut AppContext) {
#[gpui::test]
fn test_dangling_channel_paths(cx: &mut AppContext) {
let http = FakeHttpClient::with_404_response();
let client = Client::new(http.clone(), cx);
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
let channel_store = cx.add_model(|cx| ChannelStore::new(client, user_store, cx));
let channel_store = init_test(cx);
update_channels(
&channel_store,
@ -137,6 +132,208 @@ fn test_dangling_channel_paths(cx: &mut AppContext) {
assert_channels(&channel_store, &[(0, "a".to_string(), true)], cx);
}
#[gpui::test]
async fn test_channel_messages(cx: &mut TestAppContext) {
let user_id = 5;
let channel_id = 5;
let channel_store = cx.update(init_test);
let client = channel_store.read_with(cx, |s, _| s.client());
let server = FakeServer::for_client(user_id, &client, cx).await;
// Get the available channels.
server.send(proto::UpdateChannels {
channels: vec![proto::Channel {
id: channel_id,
name: "the-channel".to_string(),
parent_id: None,
}],
..Default::default()
});
cx.foreground().run_until_parked();
cx.read(|cx| {
assert_channels(&channel_store, &[(0, "the-channel".to_string(), false)], cx);
});
let get_users = server.receive::<proto::GetUsers>().await.unwrap();
assert_eq!(get_users.payload.user_ids, vec![5]);
server.respond(
get_users.receipt(),
proto::UsersResponse {
users: vec![proto::User {
id: 5,
github_login: "nathansobo".into(),
avatar_url: "http://avatar.com/nathansobo".into(),
}],
},
);
// Join a channel and populate its existing messages.
let channel = channel_store.update(cx, |store, cx| {
let channel_id = store.channels().next().unwrap().1.id;
store.open_channel_chat(channel_id, cx)
});
let join_channel = server.receive::<proto::JoinChannelChat>().await.unwrap();
server.respond(
join_channel.receipt(),
proto::JoinChannelChatResponse {
messages: vec![
proto::ChannelMessage {
id: 10,
body: "a".into(),
timestamp: 1000,
sender_id: 5,
nonce: Some(1.into()),
},
proto::ChannelMessage {
id: 11,
body: "b".into(),
timestamp: 1001,
sender_id: 6,
nonce: Some(2.into()),
},
],
done: false,
},
);
cx.foreground().start_waiting();
// Client requests all users for the received messages
let mut get_users = server.receive::<proto::GetUsers>().await.unwrap();
get_users.payload.user_ids.sort();
assert_eq!(get_users.payload.user_ids, vec![6]);
server.respond(
get_users.receipt(),
proto::UsersResponse {
users: vec![proto::User {
id: 6,
github_login: "maxbrunsfeld".into(),
avatar_url: "http://avatar.com/maxbrunsfeld".into(),
}],
},
);
let channel = channel.await.unwrap();
channel.read_with(cx, |channel, _| {
assert_eq!(
channel
.messages_in_range(0..2)
.map(|message| (message.sender.github_login.clone(), message.body.clone()))
.collect::<Vec<_>>(),
&[
("nathansobo".into(), "a".into()),
("maxbrunsfeld".into(), "b".into())
]
);
});
// Receive a new message.
server.send(proto::ChannelMessageSent {
channel_id,
message: Some(proto::ChannelMessage {
id: 12,
body: "c".into(),
timestamp: 1002,
sender_id: 7,
nonce: Some(3.into()),
}),
});
// Client requests user for message since they haven't seen them yet
let get_users = server.receive::<proto::GetUsers>().await.unwrap();
assert_eq!(get_users.payload.user_ids, vec![7]);
server.respond(
get_users.receipt(),
proto::UsersResponse {
users: vec![proto::User {
id: 7,
github_login: "as-cii".into(),
avatar_url: "http://avatar.com/as-cii".into(),
}],
},
);
assert_eq!(
channel.next_event(cx).await,
ChannelChatEvent::MessagesUpdated {
old_range: 2..2,
new_count: 1,
}
);
channel.read_with(cx, |channel, _| {
assert_eq!(
channel
.messages_in_range(2..3)
.map(|message| (message.sender.github_login.clone(), message.body.clone()))
.collect::<Vec<_>>(),
&[("as-cii".into(), "c".into())]
)
});
// Scroll up to view older messages.
channel.update(cx, |channel, cx| {
assert!(channel.load_more_messages(cx));
});
let get_messages = server.receive::<proto::GetChannelMessages>().await.unwrap();
assert_eq!(get_messages.payload.channel_id, 5);
assert_eq!(get_messages.payload.before_message_id, 10);
server.respond(
get_messages.receipt(),
proto::GetChannelMessagesResponse {
done: true,
messages: vec![
proto::ChannelMessage {
id: 8,
body: "y".into(),
timestamp: 998,
sender_id: 5,
nonce: Some(4.into()),
},
proto::ChannelMessage {
id: 9,
body: "z".into(),
timestamp: 999,
sender_id: 6,
nonce: Some(5.into()),
},
],
},
);
assert_eq!(
channel.next_event(cx).await,
ChannelChatEvent::MessagesUpdated {
old_range: 0..0,
new_count: 2,
}
);
channel.read_with(cx, |channel, _| {
assert_eq!(
channel
.messages_in_range(0..2)
.map(|message| (message.sender.github_login.clone(), message.body.clone()))
.collect::<Vec<_>>(),
&[
("nathansobo".into(), "y".into()),
("maxbrunsfeld".into(), "z".into())
]
);
});
}
fn init_test(cx: &mut AppContext) -> ModelHandle<ChannelStore> {
let http = FakeHttpClient::with_404_response();
let client = Client::new(http.clone(), cx);
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
cx.foreground().forbid_parking();
cx.set_global(SettingsStore::test(cx));
crate::init(&client);
client::init(&client, cx);
cx.add_model(|cx| ChannelStore::new(client, user_store, cx))
}
fn update_channels(
channel_store: &ModelHandle<ChannelStore>,
message: proto::UpdateChannels,

View File

@ -56,6 +56,13 @@ struct ClickhouseEventWrapper {
event: ClickhouseEvent,
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum AssistantKind {
Panel,
Inline,
}
#[derive(Serialize, Debug)]
#[serde(tag = "type")]
pub enum ClickhouseEvent {
@ -76,6 +83,11 @@ pub enum ClickhouseEvent {
room_id: Option<u64>,
channel_id: Option<u64>,
},
Assistant {
conversation_id: Option<String>,
kind: AssistantKind,
model: &'static str,
},
}
#[cfg(debug_assertions)]

View File

@ -170,8 +170,7 @@ impl FakeServer {
staff: false,
flags: Default::default(),
},
)
.await;
);
continue;
}
@ -182,11 +181,7 @@ impl FakeServer {
}
}
pub async fn respond<T: proto::RequestMessage>(
&self,
receipt: Receipt<T>,
response: T::Response,
) {
pub fn respond<T: proto::RequestMessage>(&self, receipt: Receipt<T>, response: T::Response) {
self.peer.respond(receipt, response).unwrap()
}

View File

@ -192,6 +192,26 @@ CREATE TABLE "channels" (
"created_at" TIMESTAMP NOT NULL DEFAULT now
);
CREATE TABLE IF NOT EXISTS "channel_chat_participants" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER NOT NULL REFERENCES users (id),
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"connection_id" INTEGER NOT NULL,
"connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE
);
CREATE INDEX "index_channel_chat_participants_on_channel_id" ON "channel_chat_participants" ("channel_id");
CREATE TABLE IF NOT EXISTS "channel_messages" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"sender_id" INTEGER NOT NULL REFERENCES users (id),
"body" TEXT NOT NULL,
"sent_at" TIMESTAMP,
"nonce" BLOB NOT NULL
);
CREATE INDEX "index_channel_messages_on_channel_id" ON "channel_messages" ("channel_id");
CREATE UNIQUE INDEX "index_channel_messages_on_nonce" ON "channel_messages" ("nonce");
CREATE TABLE "channel_paths" (
"id_path" TEXT NOT NULL PRIMARY KEY,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE

View File

@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS "channel_messages" (
"id" SERIAL PRIMARY KEY,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"sender_id" INTEGER NOT NULL REFERENCES users (id),
"body" TEXT NOT NULL,
"sent_at" TIMESTAMP,
"nonce" UUID NOT NULL
);
CREATE INDEX "index_channel_messages_on_channel_id" ON "channel_messages" ("channel_id");
CREATE UNIQUE INDEX "index_channel_messages_on_nonce" ON "channel_messages" ("nonce");
CREATE TABLE IF NOT EXISTS "channel_chat_participants" (
"id" SERIAL PRIMARY KEY,
"user_id" INTEGER NOT NULL REFERENCES users (id),
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"connection_id" INTEGER NOT NULL,
"connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE
);
CREATE INDEX "index_channel_chat_participants_on_channel_id" ON "channel_chat_participants" ("channel_id");

View File

@ -112,8 +112,10 @@ fn value_to_integer(v: Value) -> Result<i32, ValueTypeErr> {
id_type!(BufferId);
id_type!(AccessTokenId);
id_type!(ChannelChatParticipantId);
id_type!(ChannelId);
id_type!(ChannelMemberId);
id_type!(MessageId);
id_type!(ContactId);
id_type!(FollowerId);
id_type!(RoomId);

View File

@ -4,6 +4,7 @@ pub mod access_tokens;
pub mod buffers;
pub mod channels;
pub mod contacts;
pub mod messages;
pub mod projects;
pub mod rooms;
pub mod servers;

View File

@ -249,6 +249,29 @@ impl Database {
.await
}
pub async fn channel_buffer_connection_lost(
&self,
connection: ConnectionId,
tx: &DatabaseTransaction,
) -> Result<()> {
channel_buffer_collaborator::Entity::update_many()
.filter(
Condition::all()
.add(channel_buffer_collaborator::Column::ConnectionId.eq(connection.id as i32))
.add(
channel_buffer_collaborator::Column::ConnectionServerId
.eq(connection.owner_id as i32),
),
)
.set(channel_buffer_collaborator::ActiveModel {
connection_lost: ActiveValue::set(true),
..Default::default()
})
.exec(&*tx)
.await?;
Ok(())
}
pub async fn leave_channel_buffers(
&self,
connection: ConnectionId,

View File

@ -0,0 +1,214 @@
use super::*;
use time::OffsetDateTime;
impl Database {
pub async fn join_channel_chat(
&self,
channel_id: ChannelId,
connection_id: ConnectionId,
user_id: UserId,
) -> Result<()> {
self.transaction(|tx| async move {
self.check_user_is_channel_member(channel_id, user_id, &*tx)
.await?;
channel_chat_participant::ActiveModel {
id: ActiveValue::NotSet,
channel_id: ActiveValue::Set(channel_id),
user_id: ActiveValue::Set(user_id),
connection_id: ActiveValue::Set(connection_id.id as i32),
connection_server_id: ActiveValue::Set(ServerId(connection_id.owner_id as i32)),
}
.insert(&*tx)
.await?;
Ok(())
})
.await
}
pub async fn channel_chat_connection_lost(
&self,
connection_id: ConnectionId,
tx: &DatabaseTransaction,
) -> Result<()> {
channel_chat_participant::Entity::delete_many()
.filter(
Condition::all()
.add(
channel_chat_participant::Column::ConnectionServerId
.eq(connection_id.owner_id),
)
.add(channel_chat_participant::Column::ConnectionId.eq(connection_id.id)),
)
.exec(tx)
.await?;
Ok(())
}
pub async fn leave_channel_chat(
&self,
channel_id: ChannelId,
connection_id: ConnectionId,
_user_id: UserId,
) -> Result<()> {
self.transaction(|tx| async move {
channel_chat_participant::Entity::delete_many()
.filter(
Condition::all()
.add(
channel_chat_participant::Column::ConnectionServerId
.eq(connection_id.owner_id),
)
.add(channel_chat_participant::Column::ConnectionId.eq(connection_id.id))
.add(channel_chat_participant::Column::ChannelId.eq(channel_id)),
)
.exec(&*tx)
.await?;
Ok(())
})
.await
}
pub async fn get_channel_messages(
&self,
channel_id: ChannelId,
user_id: UserId,
count: usize,
before_message_id: Option<MessageId>,
) -> Result<Vec<proto::ChannelMessage>> {
self.transaction(|tx| async move {
self.check_user_is_channel_member(channel_id, user_id, &*tx)
.await?;
let mut condition =
Condition::all().add(channel_message::Column::ChannelId.eq(channel_id));
if let Some(before_message_id) = before_message_id {
condition = condition.add(channel_message::Column::Id.lt(before_message_id));
}
let mut rows = channel_message::Entity::find()
.filter(condition)
.limit(count as u64)
.stream(&*tx)
.await?;
let mut messages = Vec::new();
while let Some(row) = rows.next().await {
let row = row?;
let nonce = row.nonce.as_u64_pair();
messages.push(proto::ChannelMessage {
id: row.id.to_proto(),
sender_id: row.sender_id.to_proto(),
body: row.body,
timestamp: row.sent_at.assume_utc().unix_timestamp() as u64,
nonce: Some(proto::Nonce {
upper_half: nonce.0,
lower_half: nonce.1,
}),
});
}
Ok(messages)
})
.await
}
pub async fn create_channel_message(
&self,
channel_id: ChannelId,
user_id: UserId,
body: &str,
timestamp: OffsetDateTime,
nonce: u128,
) -> Result<(MessageId, Vec<ConnectionId>)> {
self.transaction(|tx| async move {
let mut rows = channel_chat_participant::Entity::find()
.filter(channel_chat_participant::Column::ChannelId.eq(channel_id))
.stream(&*tx)
.await?;
let mut is_participant = false;
let mut participant_connection_ids = Vec::new();
while let Some(row) = rows.next().await {
let row = row?;
if row.user_id == user_id {
is_participant = true;
}
participant_connection_ids.push(row.connection());
}
drop(rows);
if !is_participant {
Err(anyhow!("not a chat participant"))?;
}
let timestamp = timestamp.to_offset(time::UtcOffset::UTC);
let timestamp = time::PrimitiveDateTime::new(timestamp.date(), timestamp.time());
let message = channel_message::Entity::insert(channel_message::ActiveModel {
channel_id: ActiveValue::Set(channel_id),
sender_id: ActiveValue::Set(user_id),
body: ActiveValue::Set(body.to_string()),
sent_at: ActiveValue::Set(timestamp),
nonce: ActiveValue::Set(Uuid::from_u128(nonce)),
id: ActiveValue::NotSet,
})
.on_conflict(
OnConflict::column(channel_message::Column::Nonce)
.update_column(channel_message::Column::Nonce)
.to_owned(),
)
.exec(&*tx)
.await?;
#[derive(Debug, Clone, Copy, EnumIter, DeriveColumn)]
enum QueryConnectionId {
ConnectionId,
}
Ok((message.last_insert_id, participant_connection_ids))
})
.await
}
pub async fn remove_channel_message(
&self,
channel_id: ChannelId,
message_id: MessageId,
user_id: UserId,
) -> Result<Vec<ConnectionId>> {
self.transaction(|tx| async move {
let mut rows = channel_chat_participant::Entity::find()
.filter(channel_chat_participant::Column::ChannelId.eq(channel_id))
.stream(&*tx)
.await?;
let mut is_participant = false;
let mut participant_connection_ids = Vec::new();
while let Some(row) = rows.next().await {
let row = row?;
if row.user_id == user_id {
is_participant = true;
}
participant_connection_ids.push(row.connection());
}
drop(rows);
if !is_participant {
Err(anyhow!("not a chat participant"))?;
}
let result = channel_message::Entity::delete_by_id(message_id)
.filter(channel_message::Column::SenderId.eq(user_id))
.exec(&*tx)
.await?;
if result.rows_affected == 0 {
Err(anyhow!("no such message"))?;
}
Ok(participant_connection_ids)
})
.await
}
}

View File

@ -890,54 +890,43 @@ impl Database {
pub async fn connection_lost(&self, connection: ConnectionId) -> Result<()> {
self.transaction(|tx| async move {
let participant = room_participant::Entity::find()
.filter(
Condition::all()
.add(
room_participant::Column::AnsweringConnectionId
.eq(connection.id as i32),
)
.add(
room_participant::Column::AnsweringConnectionServerId
.eq(connection.owner_id as i32),
),
)
.one(&*tx)
self.room_connection_lost(connection, &*tx).await?;
self.channel_buffer_connection_lost(connection, &*tx)
.await?;
if let Some(participant) = participant {
room_participant::Entity::update(room_participant::ActiveModel {
answering_connection_lost: ActiveValue::set(true),
..participant.into_active_model()
})
.exec(&*tx)
.await?;
}
channel_buffer_collaborator::Entity::update_many()
.filter(
Condition::all()
.add(
channel_buffer_collaborator::Column::ConnectionId
.eq(connection.id as i32),
)
.add(
channel_buffer_collaborator::Column::ConnectionServerId
.eq(connection.owner_id as i32),
),
)
.set(channel_buffer_collaborator::ActiveModel {
connection_lost: ActiveValue::set(true),
..Default::default()
})
.exec(&*tx)
.await?;
self.channel_chat_connection_lost(connection, &*tx).await?;
Ok(())
})
.await
}
pub async fn room_connection_lost(
&self,
connection: ConnectionId,
tx: &DatabaseTransaction,
) -> Result<()> {
let participant = room_participant::Entity::find()
.filter(
Condition::all()
.add(room_participant::Column::AnsweringConnectionId.eq(connection.id as i32))
.add(
room_participant::Column::AnsweringConnectionServerId
.eq(connection.owner_id as i32),
),
)
.one(&*tx)
.await?;
if let Some(participant) = participant {
room_participant::Entity::update(room_participant::ActiveModel {
answering_connection_lost: ActiveValue::set(true),
..participant.into_active_model()
})
.exec(&*tx)
.await?;
}
Ok(())
}
fn build_incoming_call(
room: &proto::Room,
called_user_id: UserId,

View File

@ -4,7 +4,9 @@ pub mod buffer_operation;
pub mod buffer_snapshot;
pub mod channel;
pub mod channel_buffer_collaborator;
pub mod channel_chat_participant;
pub mod channel_member;
pub mod channel_message;
pub mod channel_path;
pub mod contact;
pub mod feature_flag;

View File

@ -21,6 +21,8 @@ pub enum Relation {
Member,
#[sea_orm(has_many = "super::channel_buffer_collaborator::Entity")]
BufferCollaborators,
#[sea_orm(has_many = "super::channel_chat_participant::Entity")]
ChatParticipants,
}
impl Related<super::channel_member::Entity> for Entity {
@ -46,3 +48,9 @@ impl Related<super::channel_buffer_collaborator::Entity> for Entity {
Relation::BufferCollaborators.def()
}
}
impl Related<super::channel_chat_participant::Entity> for Entity {
fn to() -> RelationDef {
Relation::ChatParticipants.def()
}
}

View File

@ -0,0 +1,41 @@
use crate::db::{ChannelChatParticipantId, ChannelId, ServerId, UserId};
use rpc::ConnectionId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "channel_chat_participants")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: ChannelChatParticipantId,
pub channel_id: ChannelId,
pub user_id: UserId,
pub connection_id: i32,
pub connection_server_id: ServerId,
}
impl Model {
pub fn connection(&self) -> ConnectionId {
ConnectionId {
owner_id: self.connection_server_id.0 as u32,
id: self.connection_id as u32,
}
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id"
)]
Channel,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,45 @@
use crate::db::{ChannelId, MessageId, UserId};
use sea_orm::entity::prelude::*;
use time::PrimitiveDateTime;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "channel_messages")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: MessageId,
pub channel_id: ChannelId,
pub sender_id: UserId,
pub body: String,
pub sent_at: PrimitiveDateTime,
pub nonce: Uuid,
}
impl ActiveModelBehavior for ActiveModel {}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id"
)]
Channel,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::SenderId",
to = "super::user::Column::Id"
)]
Sender,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::Sender.def()
}
}

View File

@ -1,6 +1,7 @@
mod buffer_tests;
mod db_tests;
mod feature_flag_tests;
mod message_tests;
use super::*;
use gpui::executor::Background;

View File

@ -0,0 +1,59 @@
use crate::{
db::{Database, NewUserParams},
test_both_dbs,
};
use std::sync::Arc;
use time::OffsetDateTime;
test_both_dbs!(
test_channel_message_nonces,
test_channel_message_nonces_postgres,
test_channel_message_nonces_sqlite
);
async fn test_channel_message_nonces(db: &Arc<Database>) {
let user = db
.create_user(
"user@example.com",
false,
NewUserParams {
github_login: "user".into(),
github_user_id: 1,
invite_count: 0,
},
)
.await
.unwrap()
.user_id;
let channel = db
.create_channel("channel", None, "room", user)
.await
.unwrap();
let owner_id = db.create_server("test").await.unwrap().0 as u32;
db.join_channel_chat(channel, rpc::ConnectionId { owner_id, id: 0 }, user)
.await
.unwrap();
let msg1_id = db
.create_channel_message(channel, user, "1", OffsetDateTime::now_utc(), 1)
.await
.unwrap();
let msg2_id = db
.create_channel_message(channel, user, "2", OffsetDateTime::now_utc(), 2)
.await
.unwrap();
let msg3_id = db
.create_channel_message(channel, user, "3", OffsetDateTime::now_utc(), 1)
.await
.unwrap();
let msg4_id = db
.create_channel_message(channel, user, "4", OffsetDateTime::now_utc(), 2)
.await
.unwrap();
assert_ne!(msg1_id, msg2_id);
assert_eq!(msg1_id, msg3_id);
assert_eq!(msg2_id, msg4_id);
}

View File

@ -2,7 +2,10 @@ mod connection_pool;
use crate::{
auth,
db::{self, ChannelId, ChannelsForUser, Database, ProjectId, RoomId, ServerId, User, UserId},
db::{
self, ChannelId, ChannelsForUser, Database, MessageId, ProjectId, RoomId, ServerId, User,
UserId,
},
executor::Executor,
AppState, Result,
};
@ -56,6 +59,7 @@ use std::{
},
time::{Duration, Instant},
};
use time::OffsetDateTime;
use tokio::sync::{watch, Semaphore};
use tower::ServiceBuilder;
use tracing::{info_span, instrument, Instrument};
@ -63,6 +67,9 @@ use tracing::{info_span, instrument, Instrument};
pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10);
const MESSAGE_COUNT_PER_PAGE: usize = 100;
const MAX_MESSAGE_LEN: usize = 1024;
lazy_static! {
static ref METRIC_CONNECTIONS: IntGauge =
register_int_gauge!("connections", "number of connections").unwrap();
@ -255,6 +262,11 @@ impl Server {
.add_request_handler(get_channel_members)
.add_request_handler(respond_to_channel_invite)
.add_request_handler(join_channel)
.add_request_handler(join_channel_chat)
.add_message_handler(leave_channel_chat)
.add_request_handler(send_channel_message)
.add_request_handler(remove_channel_message)
.add_request_handler(get_channel_messages)
.add_request_handler(follow)
.add_message_handler(unfollow)
.add_message_handler(update_followers)
@ -885,9 +897,8 @@ async fn connection_lost(
room_updated(&room, &session.peer);
}
}
update_user_contacts(session.user_id, &session).await?;
}
_ = teardown.changed().fuse() => {}
}
@ -2633,6 +2644,131 @@ fn channel_buffer_updated<T: EnvelopedMessage>(
});
}
async fn send_channel_message(
request: proto::SendChannelMessage,
response: Response<proto::SendChannelMessage>,
session: Session,
) -> Result<()> {
// Validate the message body.
let body = request.body.trim().to_string();
if body.len() > MAX_MESSAGE_LEN {
return Err(anyhow!("message is too long"))?;
}
if body.is_empty() {
return Err(anyhow!("message can't be blank"))?;
}
let timestamp = OffsetDateTime::now_utc();
let nonce = request
.nonce
.ok_or_else(|| anyhow!("nonce can't be blank"))?;
let channel_id = ChannelId::from_proto(request.channel_id);
let (message_id, connection_ids) = session
.db()
.await
.create_channel_message(
channel_id,
session.user_id,
&body,
timestamp,
nonce.clone().into(),
)
.await?;
let message = proto::ChannelMessage {
sender_id: session.user_id.to_proto(),
id: message_id.to_proto(),
body,
timestamp: timestamp.unix_timestamp() as u64,
nonce: Some(nonce),
};
broadcast(Some(session.connection_id), connection_ids, |connection| {
session.peer.send(
connection,
proto::ChannelMessageSent {
channel_id: channel_id.to_proto(),
message: Some(message.clone()),
},
)
});
response.send(proto::SendChannelMessageResponse {
message: Some(message),
})?;
Ok(())
}
async fn remove_channel_message(
request: proto::RemoveChannelMessage,
response: Response<proto::RemoveChannelMessage>,
session: Session,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
let message_id = MessageId::from_proto(request.message_id);
let connection_ids = session
.db()
.await
.remove_channel_message(channel_id, message_id, session.user_id)
.await?;
broadcast(Some(session.connection_id), connection_ids, |connection| {
session.peer.send(connection, request.clone())
});
response.send(proto::Ack {})?;
Ok(())
}
async fn join_channel_chat(
request: proto::JoinChannelChat,
response: Response<proto::JoinChannelChat>,
session: Session,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
let db = session.db().await;
db.join_channel_chat(channel_id, session.connection_id, session.user_id)
.await?;
let messages = db
.get_channel_messages(channel_id, session.user_id, MESSAGE_COUNT_PER_PAGE, None)
.await?;
response.send(proto::JoinChannelChatResponse {
done: messages.len() < MESSAGE_COUNT_PER_PAGE,
messages,
})?;
Ok(())
}
async fn leave_channel_chat(request: proto::LeaveChannelChat, session: Session) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
session
.db()
.await
.leave_channel_chat(channel_id, session.connection_id, session.user_id)
.await?;
Ok(())
}
async fn get_channel_messages(
request: proto::GetChannelMessages,
response: Response<proto::GetChannelMessages>,
session: Session,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
let messages = session
.db()
.await
.get_channel_messages(
channel_id,
session.user_id,
MESSAGE_COUNT_PER_PAGE,
Some(MessageId::from_proto(request.before_message_id)),
)
.await?;
response.send(proto::GetChannelMessagesResponse {
done: messages.len() < MESSAGE_COUNT_PER_PAGE,
messages,
})?;
Ok(())
}
async fn update_diff_base(request: proto::UpdateDiffBase, session: Session) -> Result<()> {
let project_id = ProjectId::from_proto(request.project_id);
let project_connection_ids = session

View File

@ -2,6 +2,7 @@ use call::Room;
use gpui::{ModelHandle, TestAppContext};
mod channel_buffer_tests;
mod channel_message_tests;
mod channel_tests;
mod integration_tests;
mod random_channel_buffer_tests;

View File

@ -0,0 +1,214 @@
use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer};
use channel::{ChannelChat, ChannelMessageId};
use gpui::{executor::Deterministic, ModelHandle, TestAppContext};
use std::sync::Arc;
#[gpui::test]
async fn test_basic_channel_messages(
deterministic: Arc<Deterministic>,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
deterministic.forbid_parking();
let mut server = TestServer::start(&deterministic).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let channel_id = server
.make_channel("the-channel", (&client_a, cx_a), &mut [(&client_b, cx_b)])
.await;
let channel_chat_a = client_a
.channel_store()
.update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
.await
.unwrap();
let channel_chat_b = client_b
.channel_store()
.update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx))
.await
.unwrap();
channel_chat_a
.update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap())
.await
.unwrap();
channel_chat_a
.update(cx_a, |c, cx| c.send_message("two".into(), cx).unwrap())
.await
.unwrap();
deterministic.run_until_parked();
channel_chat_b
.update(cx_b, |c, cx| c.send_message("three".into(), cx).unwrap())
.await
.unwrap();
deterministic.run_until_parked();
channel_chat_a.update(cx_a, |c, _| {
assert_eq!(
c.messages()
.iter()
.map(|m| m.body.as_str())
.collect::<Vec<_>>(),
vec!["one", "two", "three"]
);
})
}
#[gpui::test]
async fn test_rejoin_channel_chat(
deterministic: Arc<Deterministic>,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
deterministic.forbid_parking();
let mut server = TestServer::start(&deterministic).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let channel_id = server
.make_channel("the-channel", (&client_a, cx_a), &mut [(&client_b, cx_b)])
.await;
let channel_chat_a = client_a
.channel_store()
.update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
.await
.unwrap();
let channel_chat_b = client_b
.channel_store()
.update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx))
.await
.unwrap();
channel_chat_a
.update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap())
.await
.unwrap();
channel_chat_b
.update(cx_b, |c, cx| c.send_message("two".into(), cx).unwrap())
.await
.unwrap();
server.forbid_connections();
server.disconnect_client(client_a.peer_id().unwrap());
// While client A is disconnected, clients A and B both send new messages.
channel_chat_a
.update(cx_a, |c, cx| c.send_message("three".into(), cx).unwrap())
.await
.unwrap_err();
channel_chat_a
.update(cx_a, |c, cx| c.send_message("four".into(), cx).unwrap())
.await
.unwrap_err();
channel_chat_b
.update(cx_b, |c, cx| c.send_message("five".into(), cx).unwrap())
.await
.unwrap();
channel_chat_b
.update(cx_b, |c, cx| c.send_message("six".into(), cx).unwrap())
.await
.unwrap();
// Client A reconnects.
server.allow_connections();
deterministic.advance_clock(RECONNECT_TIMEOUT);
// Client A fetches the messages that were sent while they were disconnected
// and resends their own messages which failed to send.
let expected_messages = &["one", "two", "five", "six", "three", "four"];
assert_messages(&channel_chat_a, expected_messages, cx_a);
assert_messages(&channel_chat_b, expected_messages, cx_b);
}
#[gpui::test]
async fn test_remove_channel_message(
deterministic: Arc<Deterministic>,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
cx_c: &mut TestAppContext,
) {
deterministic.forbid_parking();
let mut server = TestServer::start(&deterministic).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let client_c = server.create_client(cx_c, "user_c").await;
let channel_id = server
.make_channel(
"the-channel",
(&client_a, cx_a),
&mut [(&client_b, cx_b), (&client_c, cx_c)],
)
.await;
let channel_chat_a = client_a
.channel_store()
.update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
.await
.unwrap();
let channel_chat_b = client_b
.channel_store()
.update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx))
.await
.unwrap();
// Client A sends some messages.
channel_chat_a
.update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap())
.await
.unwrap();
channel_chat_a
.update(cx_a, |c, cx| c.send_message("two".into(), cx).unwrap())
.await
.unwrap();
channel_chat_a
.update(cx_a, |c, cx| c.send_message("three".into(), cx).unwrap())
.await
.unwrap();
// Clients A and B see all of the messages.
deterministic.run_until_parked();
let expected_messages = &["one", "two", "three"];
assert_messages(&channel_chat_a, expected_messages, cx_a);
assert_messages(&channel_chat_b, expected_messages, cx_b);
// Client A deletes one of their messages.
channel_chat_a
.update(cx_a, |c, cx| {
let ChannelMessageId::Saved(id) = c.message(1).id else {
panic!("message not saved")
};
c.remove_message(id, cx)
})
.await
.unwrap();
// Client B sees that the message is gone.
deterministic.run_until_parked();
let expected_messages = &["one", "three"];
assert_messages(&channel_chat_a, expected_messages, cx_a);
assert_messages(&channel_chat_b, expected_messages, cx_b);
// Client C joins the channel chat, and does not see the deleted message.
let channel_chat_c = client_c
.channel_store()
.update(cx_c, |store, cx| store.open_channel_chat(channel_id, cx))
.await
.unwrap();
assert_messages(&channel_chat_c, expected_messages, cx_c);
}
#[track_caller]
fn assert_messages(chat: &ModelHandle<ChannelChat>, messages: &[&str], cx: &mut TestAppContext) {
assert_eq!(
chat.read_with(cx, |chat, _| chat
.messages()
.iter()
.map(|m| m.body.clone())
.collect::<Vec<_>>(),),
messages
);
}

View File

@ -4825,7 +4825,7 @@ async fn test_project_search(
let mut results = HashMap::default();
let mut search_rx = project_b.update(cx_b, |project, cx| {
project.search(
SearchQuery::text("world", false, false, Vec::new(), Vec::new()),
SearchQuery::text("world", false, false, Vec::new(), Vec::new()).unwrap(),
cx,
)
});

View File

@ -869,7 +869,7 @@ impl RandomizedTest for ProjectCollaborationTest {
let mut search = project.update(cx, |project, cx| {
project.search(
SearchQuery::text(query, false, false, Vec::new(), Vec::new()),
SearchQuery::text(query, false, false, Vec::new(), Vec::new()).unwrap(),
cx,
)
});

View File

@ -6,7 +6,7 @@ use crate::{
};
use anyhow::anyhow;
use call::ActiveCall;
use channel::{channel_buffer::ChannelBuffer, ChannelStore};
use channel::{ChannelBuffer, ChannelStore};
use client::{
self, proto::PeerId, Client, Connection, Credentials, EstablishConnectionError, UserStore,
};

View File

@ -55,6 +55,7 @@ schemars.workspace = true
postage.workspace = true
serde.workspace = true
serde_derive.workspace = true
time.workspace = true
[dev-dependencies]
call = { path = "../call", features = ["test-support"] }

View File

@ -1,8 +1,6 @@
use anyhow::{anyhow, Result};
use channel::{
channel_buffer::{self, ChannelBuffer},
ChannelId,
};
use call::ActiveCall;
use channel::{ChannelBuffer, ChannelBufferEvent, ChannelId};
use client::proto;
use clock::ReplicaId;
use collections::HashMap;
@ -38,6 +36,30 @@ pub struct ChannelView {
}
impl ChannelView {
pub fn deploy(channel_id: ChannelId, workspace: ViewHandle<Workspace>, cx: &mut AppContext) {
let pane = workspace.read(cx).active_pane().clone();
let channel_view = Self::open(channel_id, pane.clone(), workspace.clone(), cx);
cx.spawn(|mut cx| async move {
let channel_view = channel_view.await?;
pane.update(&mut cx, |pane, cx| {
let room_id = ActiveCall::global(cx)
.read(cx)
.room()
.map(|room| room.read(cx).id());
ActiveCall::report_call_event_for_room(
"open channel notes",
room_id,
Some(channel_id),
&workspace.read(cx).app_state().client,
cx,
);
pane.add_item(Box::new(channel_view), true, true, None, cx);
});
anyhow::Ok(())
})
.detach();
}
pub fn open(
channel_id: ChannelId,
pane: ViewHandle<Pane>,
@ -56,6 +78,7 @@ impl ChannelView {
cx.spawn(|mut cx| async move {
let channel_buffer = channel_buffer.await?;
let markdown = markdown.await?;
channel_buffer.update(&mut cx, |buffer, cx| {
buffer.buffer().update(cx, |buffer, cx| {
@ -78,7 +101,6 @@ impl ChannelView {
cx: &mut ViewContext<Self>,
) -> Self {
let buffer = channel_buffer.read(cx).buffer();
// buffer.update(cx, |buffer, cx| buffer.set_language(language, cx));
let editor = cx.add_view(|cx| Editor::for_buffer(buffer, None, cx));
let _editor_event_subscription = cx.subscribe(&editor, |_, _, e, cx| cx.emit(e.clone()));
@ -118,14 +140,14 @@ impl ChannelView {
fn handle_channel_buffer_event(
&mut self,
_: ModelHandle<ChannelBuffer>,
event: &channel_buffer::Event,
event: &ChannelBufferEvent,
cx: &mut ViewContext<Self>,
) {
match event {
channel_buffer::Event::CollaboratorsChanged => {
ChannelBufferEvent::CollaboratorsChanged => {
self.refresh_replica_id_map(cx);
}
channel_buffer::Event::Disconnected => self.editor.update(cx, |editor, cx| {
ChannelBufferEvent::Disconnected => self.editor.update(cx, |editor, cx| {
editor.set_read_only(true);
cx.notify();
}),

View File

@ -0,0 +1,701 @@
use crate::{channel_view::ChannelView, ChatPanelSettings};
use anyhow::Result;
use call::ActiveCall;
use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore};
use client::Client;
use db::kvp::KEY_VALUE_STORE;
use editor::Editor;
use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
use gpui::{
actions,
elements::*,
platform::{CursorStyle, MouseButton},
serde_json,
views::{ItemType, Select, SelectStyle},
AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View,
ViewContext, ViewHandle, WeakViewHandle,
};
use language::language_settings::SoftWrap;
use menu::Confirm;
use project::Fs;
use serde::{Deserialize, Serialize};
use settings::SettingsStore;
use std::sync::Arc;
use theme::{IconButton, Theme};
use time::{OffsetDateTime, UtcOffset};
use util::{ResultExt, TryFutureExt};
use workspace::{
dock::{DockPosition, Panel},
Workspace,
};
const MESSAGE_LOADING_THRESHOLD: usize = 50;
const CHAT_PANEL_KEY: &'static str = "ChatPanel";
pub struct ChatPanel {
client: Arc<Client>,
channel_store: ModelHandle<ChannelStore>,
active_chat: Option<(ModelHandle<ChannelChat>, Subscription)>,
message_list: ListState<ChatPanel>,
input_editor: ViewHandle<Editor>,
channel_select: ViewHandle<Select>,
local_timezone: UtcOffset,
fs: Arc<dyn Fs>,
width: Option<f32>,
pending_serialization: Task<Option<()>>,
subscriptions: Vec<gpui::Subscription>,
workspace: WeakViewHandle<Workspace>,
has_focus: bool,
}
#[derive(Serialize, Deserialize)]
struct SerializedChatPanel {
width: Option<f32>,
}
#[derive(Debug)]
pub enum Event {
DockPositionChanged,
Focus,
Dismissed,
}
actions!(
chat_panel,
[LoadMoreMessages, ToggleFocus, OpenChannelNotes, JoinCall]
);
pub fn init(cx: &mut AppContext) {
cx.add_action(ChatPanel::send);
cx.add_action(ChatPanel::load_more_messages);
cx.add_action(ChatPanel::open_notes);
cx.add_action(ChatPanel::join_call);
}
impl ChatPanel {
pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
let fs = workspace.app_state().fs.clone();
let client = workspace.app_state().client.clone();
let channel_store = workspace.app_state().channel_store.clone();
let input_editor = cx.add_view(|cx| {
let mut editor = Editor::auto_height(
4,
Some(Arc::new(|theme| theme.chat_panel.input_editor.clone())),
cx,
);
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
editor
});
let workspace_handle = workspace.weak_handle();
let channel_select = cx.add_view(|cx| {
let channel_store = channel_store.clone();
let workspace = workspace_handle.clone();
Select::new(0, cx, {
move |ix, item_type, is_hovered, cx| {
Self::render_channel_name(
&channel_store,
ix,
item_type,
is_hovered,
workspace,
cx,
)
}
})
.with_style(move |cx| {
let style = &theme::current(cx).chat_panel.channel_select;
SelectStyle {
header: Default::default(),
menu: style.menu,
}
})
});
let mut message_list =
ListState::<Self>::new(0, Orientation::Bottom, 1000., move |this, ix, cx| {
this.render_message(ix, cx)
});
message_list.set_scroll_handler(|visible_range, this, cx| {
if visible_range.start < MESSAGE_LOADING_THRESHOLD {
this.load_more_messages(&LoadMoreMessages, cx);
}
});
cx.add_view(|cx| {
let mut this = Self {
fs,
client,
channel_store,
active_chat: Default::default(),
pending_serialization: Task::ready(None),
message_list,
input_editor,
channel_select,
local_timezone: cx.platform().local_timezone(),
has_focus: false,
subscriptions: Vec::new(),
workspace: workspace_handle,
width: None,
};
let mut old_dock_position = this.position(cx);
this.subscriptions
.push(
cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
let new_dock_position = this.position(cx);
if new_dock_position != old_dock_position {
old_dock_position = new_dock_position;
cx.emit(Event::DockPositionChanged);
}
cx.notify();
}),
);
this.init_active_channel(cx);
cx.observe(&this.channel_store, |this, _, cx| {
this.init_active_channel(cx);
})
.detach();
cx.observe(&this.channel_select, |this, channel_select, cx| {
let selected_ix = channel_select.read(cx).selected_index();
let selected_channel_id = this
.channel_store
.read(cx)
.channel_at_index(selected_ix)
.map(|e| e.1.id);
if let Some(selected_channel_id) = selected_channel_id {
this.select_channel(selected_channel_id, cx)
.detach_and_log_err(cx);
}
})
.detach();
this
})
}
pub fn load(
workspace: WeakViewHandle<Workspace>,
cx: AsyncAppContext,
) -> Task<Result<ViewHandle<Self>>> {
cx.spawn(|mut cx| async move {
let serialized_panel = if let Some(panel) = cx
.background()
.spawn(async move { KEY_VALUE_STORE.read_kvp(CHAT_PANEL_KEY) })
.await
.log_err()
.flatten()
{
Some(serde_json::from_str::<SerializedChatPanel>(&panel)?)
} else {
None
};
workspace.update(&mut cx, |workspace, cx| {
let panel = Self::new(workspace, cx);
if let Some(serialized_panel) = serialized_panel {
panel.update(cx, |panel, cx| {
panel.width = serialized_panel.width;
cx.notify();
});
}
panel
})
})
}
fn serialize(&mut self, cx: &mut ViewContext<Self>) {
let width = self.width;
self.pending_serialization = cx.background().spawn(
async move {
KEY_VALUE_STORE
.write_kvp(
CHAT_PANEL_KEY.into(),
serde_json::to_string(&SerializedChatPanel { width })?,
)
.await?;
anyhow::Ok(())
}
.log_err(),
);
}
fn init_active_channel(&mut self, cx: &mut ViewContext<Self>) {
let channel_count = self.channel_store.read(cx).channel_count();
self.message_list.reset(0);
self.active_chat = None;
self.channel_select.update(cx, |select, cx| {
select.set_item_count(channel_count, cx);
});
}
fn set_active_chat(&mut self, chat: ModelHandle<ChannelChat>, cx: &mut ViewContext<Self>) {
if self.active_chat.as_ref().map(|e| &e.0) != Some(&chat) {
let id = chat.read(cx).channel().id;
{
let chat = chat.read(cx);
self.message_list.reset(chat.message_count());
let placeholder = format!("Message #{}", chat.channel().name);
self.input_editor.update(cx, move |editor, cx| {
editor.set_placeholder_text(placeholder, cx);
});
}
let subscription = cx.subscribe(&chat, Self::channel_did_change);
self.active_chat = Some((chat, subscription));
self.channel_select.update(cx, |select, cx| {
if let Some(ix) = self.channel_store.read(cx).index_of_channel(id) {
select.set_selected_index(ix, cx);
}
});
cx.notify();
}
}
fn channel_did_change(
&mut self,
_: ModelHandle<ChannelChat>,
event: &ChannelChatEvent,
cx: &mut ViewContext<Self>,
) {
match event {
ChannelChatEvent::MessagesUpdated {
old_range,
new_count,
} => {
self.message_list.splice(old_range.clone(), *new_count);
}
}
cx.notify();
}
fn render_channel(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
let theme = theme::current(cx);
Flex::column()
.with_child(
ChildView::new(&self.channel_select, cx)
.contained()
.with_style(theme.chat_panel.channel_select.container),
)
.with_child(self.render_active_channel_messages(&theme))
.with_child(self.render_input_box(&theme, cx))
.into_any()
}
fn render_active_channel_messages(&self, theme: &Arc<Theme>) -> AnyElement<Self> {
let messages = if self.active_chat.is_some() {
List::new(self.message_list.clone())
.contained()
.with_style(theme.chat_panel.list)
.into_any()
} else {
Empty::new().into_any()
};
messages.flex(1., true).into_any()
}
fn render_message(&self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
let message = self.active_chat.as_ref().unwrap().0.read(cx).message(ix);
let now = OffsetDateTime::now_utc();
let theme = theme::current(cx);
let style = if message.is_pending() {
&theme.chat_panel.pending_message
} else {
&theme.chat_panel.message
};
let belongs_to_user = Some(message.sender.id) == self.client.user_id();
let message_id_to_remove =
if let (ChannelMessageId::Saved(id), true) = (message.id, belongs_to_user) {
Some(id)
} else {
None
};
enum DeleteMessage {}
let body = message.body.clone();
Flex::column()
.with_child(
Flex::row()
.with_child(
Label::new(
message.sender.github_login.clone(),
style.sender.text.clone(),
)
.contained()
.with_style(style.sender.container),
)
.with_child(
Label::new(
format_timestamp(message.timestamp, now, self.local_timezone),
style.timestamp.text.clone(),
)
.contained()
.with_style(style.timestamp.container),
)
.with_children(message_id_to_remove.map(|id| {
MouseEventHandler::new::<DeleteMessage, _>(
id as usize,
cx,
|mouse_state, _| {
let button_style =
theme.chat_panel.icon_button.style_for(mouse_state);
render_icon_button(button_style, "icons/x.svg")
.aligned()
.into_any()
},
)
.with_padding(Padding::uniform(2.))
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, this, cx| {
this.remove_message(id, cx);
})
.flex_float()
})),
)
.with_child(Text::new(body, style.body.clone()))
.contained()
.with_style(style.container)
.into_any()
}
fn render_input_box(&self, theme: &Arc<Theme>, cx: &AppContext) -> AnyElement<Self> {
ChildView::new(&self.input_editor, cx)
.contained()
.with_style(theme.chat_panel.input_editor.container)
.into_any()
}
fn render_channel_name(
channel_store: &ModelHandle<ChannelStore>,
ix: usize,
item_type: ItemType,
is_hovered: bool,
workspace: WeakViewHandle<Workspace>,
cx: &mut ViewContext<Select>,
) -> AnyElement<Select> {
let theme = theme::current(cx);
let tooltip_style = &theme.tooltip;
let theme = &theme.chat_panel;
let style = match (&item_type, is_hovered) {
(ItemType::Header, _) => &theme.channel_select.header,
(ItemType::Selected, _) => &theme.channel_select.active_item,
(ItemType::Unselected, false) => &theme.channel_select.item,
(ItemType::Unselected, true) => &theme.channel_select.hovered_item,
};
let channel = &channel_store.read(cx).channel_at_index(ix).unwrap().1;
let channel_id = channel.id;
let mut row = Flex::row()
.with_child(
Label::new("#".to_string(), style.hash.text.clone())
.contained()
.with_style(style.hash.container),
)
.with_child(Label::new(channel.name.clone(), style.name.clone()));
if matches!(item_type, ItemType::Header) {
row.add_children([
MouseEventHandler::new::<OpenChannelNotes, _>(0, cx, |mouse_state, _| {
render_icon_button(theme.icon_button.style_for(mouse_state), "icons/file.svg")
})
.on_click(MouseButton::Left, move |_, _, cx| {
if let Some(workspace) = workspace.upgrade(cx) {
ChannelView::deploy(channel_id, workspace, cx);
}
})
.with_tooltip::<OpenChannelNotes>(
channel_id as usize,
"Open Notes",
Some(Box::new(OpenChannelNotes)),
tooltip_style.clone(),
cx,
)
.flex_float(),
MouseEventHandler::new::<ActiveCall, _>(0, cx, |mouse_state, _| {
render_icon_button(
theme.icon_button.style_for(mouse_state),
"icons/speaker-loud.svg",
)
})
.on_click(MouseButton::Left, move |_, _, cx| {
ActiveCall::global(cx)
.update(cx, |call, cx| call.join_channel(channel_id, cx))
.detach_and_log_err(cx);
})
.with_tooltip::<ActiveCall>(
channel_id as usize,
"Join Call",
Some(Box::new(JoinCall)),
tooltip_style.clone(),
cx,
)
.flex_float(),
]);
}
row.align_children_center()
.contained()
.with_style(style.container)
.into_any()
}
fn render_sign_in_prompt(
&self,
theme: &Arc<Theme>,
cx: &mut ViewContext<Self>,
) -> AnyElement<Self> {
enum SignInPromptLabel {}
MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
Label::new(
"Sign in to use chat".to_string(),
theme
.chat_panel
.sign_in_prompt
.style_for(mouse_state)
.clone(),
)
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, this, cx| {
let client = this.client.clone();
cx.spawn(|this, mut cx| async move {
if client
.authenticate_and_connect(true, &cx)
.log_err()
.await
.is_some()
{
this.update(&mut cx, |this, cx| {
if cx.handle().is_focused(cx) {
cx.focus(&this.input_editor);
}
})
.ok();
}
})
.detach();
})
.aligned()
.into_any()
}
fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
if let Some((chat, _)) = self.active_chat.as_ref() {
let body = self.input_editor.update(cx, |editor, cx| {
let body = editor.text(cx);
editor.clear(cx);
body
});
if let Some(task) = chat
.update(cx, |chat, cx| chat.send_message(body, cx))
.log_err()
{
task.detach();
}
}
}
fn remove_message(&mut self, id: u64, cx: &mut ViewContext<Self>) {
if let Some((chat, _)) = self.active_chat.as_ref() {
chat.update(cx, |chat, cx| chat.remove_message(id, cx).detach())
}
}
fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext<Self>) {
if let Some((chat, _)) = self.active_chat.as_ref() {
chat.update(cx, |channel, cx| {
channel.load_more_messages(cx);
})
}
}
pub fn select_channel(
&mut self,
selected_channel_id: u64,
cx: &mut ViewContext<ChatPanel>,
) -> Task<Result<()>> {
if let Some((chat, _)) = &self.active_chat {
if chat.read(cx).channel().id == selected_channel_id {
return Task::ready(Ok(()));
}
}
let open_chat = self.channel_store.update(cx, |store, cx| {
store.open_channel_chat(selected_channel_id, cx)
});
cx.spawn(|this, mut cx| async move {
let chat = open_chat.await?;
this.update(&mut cx, |this, cx| {
this.set_active_chat(chat, cx);
})
})
}
fn open_notes(&mut self, _: &OpenChannelNotes, cx: &mut ViewContext<Self>) {
if let Some((chat, _)) = &self.active_chat {
let channel_id = chat.read(cx).channel().id;
if let Some(workspace) = self.workspace.upgrade(cx) {
ChannelView::deploy(channel_id, workspace, cx);
}
}
}
fn join_call(&mut self, _: &JoinCall, cx: &mut ViewContext<Self>) {
if let Some((chat, _)) = &self.active_chat {
let channel_id = chat.read(cx).channel().id;
ActiveCall::global(cx)
.update(cx, |call, cx| call.join_channel(channel_id, cx))
.detach_and_log_err(cx);
}
}
}
impl Entity for ChatPanel {
type Event = Event;
}
impl View for ChatPanel {
fn ui_name() -> &'static str {
"ChatPanel"
}
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
let theme = theme::current(cx);
let element = if self.client.user_id().is_some() {
self.render_channel(cx)
} else {
self.render_sign_in_prompt(&theme, cx)
};
element
.contained()
.with_style(theme.chat_panel.container)
.constrained()
.with_min_width(150.)
.into_any()
}
fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
self.has_focus = true;
if matches!(
*self.client.status().borrow(),
client::Status::Connected { .. }
) {
cx.focus(&self.input_editor);
}
}
fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
self.has_focus = false;
}
}
impl Panel for ChatPanel {
fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
settings::get::<ChatPanelSettings>(cx).dock
}
fn position_is_valid(&self, position: DockPosition) -> bool {
matches!(position, DockPosition::Left | DockPosition::Right)
}
fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
settings::update_settings_file::<ChatPanelSettings>(self.fs.clone(), cx, move |settings| {
settings.dock = Some(position)
});
}
fn size(&self, cx: &gpui::WindowContext) -> f32 {
self.width
.unwrap_or_else(|| settings::get::<ChatPanelSettings>(cx).default_width)
}
fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
self.width = size;
self.serialize(cx);
cx.notify();
}
fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
if active && !is_chat_feature_enabled(cx) {
cx.emit(Event::Dismissed);
}
}
fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
(settings::get::<ChatPanelSettings>(cx).button && is_chat_feature_enabled(cx))
.then(|| "icons/conversations.svg")
}
fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
("Chat Panel".to_string(), Some(Box::new(ToggleFocus)))
}
fn should_change_position_on_event(event: &Self::Event) -> bool {
matches!(event, Event::DockPositionChanged)
}
fn should_close_on_event(event: &Self::Event) -> bool {
matches!(event, Event::Dismissed)
}
fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
self.has_focus
}
fn is_focus_event(event: &Self::Event) -> bool {
matches!(event, Event::Focus)
}
}
fn is_chat_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool {
cx.is_staff() || cx.has_flag::<ChannelsAlpha>()
}
fn format_timestamp(
mut timestamp: OffsetDateTime,
mut now: OffsetDateTime,
local_timezone: UtcOffset,
) -> String {
timestamp = timestamp.to_offset(local_timezone);
now = now.to_offset(local_timezone);
let today = now.date();
let date = timestamp.date();
let mut hour = timestamp.hour();
let mut part = "am";
if hour > 12 {
hour -= 12;
part = "pm";
}
if date == today {
format!("{:02}:{:02}{}", hour, timestamp.minute(), part)
} else if date.next_day() == Some(today) {
format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part)
} else {
format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
}
}
fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> impl Element<V> {
Svg::new(svg_path)
.with_color(style.color)
.constrained()
.with_width(style.icon_width)
.aligned()
.constrained()
.with_width(style.button_width)
.with_height(style.button_width)
.contained()
.with_style(style.container)
}

View File

@ -1,15 +1,21 @@
mod channel_modal;
mod contact_finder;
mod panel_settings;
use crate::{
channel_view::{self, ChannelView},
chat_panel::ChatPanel,
face_pile::FacePile,
CollaborationPanelSettings,
};
use anyhow::Result;
use call::ActiveCall;
use channel::{Channel, ChannelEvent, ChannelId, ChannelStore};
use channel_modal::ChannelModal;
use client::{proto::PeerId, Client, Contact, User, UserStore};
use contact_finder::ContactFinder;
use context_menu::{ContextMenu, ContextMenuItem};
use db::kvp::KEY_VALUE_STORE;
use editor::{Cancel, Editor};
use feature_flags::{ChannelsAlpha, FeatureFlagAppExt, FeatureFlagViewExt};
use futures::StreamExt;
use fuzzy::{match_strings, StringMatchCandidate};
@ -31,7 +37,6 @@ use gpui::{
Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
};
use menu::{Confirm, SelectNext, SelectPrev};
use panel_settings::{CollaborationPanelDockPosition, CollaborationPanelSettings};
use project::{Fs, Project};
use serde_derive::{Deserialize, Serialize};
use settings::SettingsStore;
@ -44,14 +49,6 @@ use workspace::{
Workspace,
};
use crate::{
channel_view::{self, ChannelView},
face_pile::FacePile,
};
use channel_modal::ChannelModal;
use self::contact_finder::ContactFinder;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
struct RemoveChannel {
channel_id: u64,
@ -83,8 +80,13 @@ struct RenameChannel {
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
struct OpenChannelBuffer {
channel_id: u64,
pub struct OpenChannelNotes {
pub channel_id: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct JoinChannelCall {
pub channel_id: u64,
}
actions!(
@ -107,14 +109,14 @@ impl_actions!(
ManageMembers,
RenameChannel,
ToggleCollapse,
OpenChannelBuffer
OpenChannelNotes,
JoinChannelCall,
]
);
const COLLABORATION_PANEL_KEY: &'static str = "CollaborationPanel";
pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
settings::register::<panel_settings::CollaborationPanelSettings>(cx);
pub fn init(cx: &mut AppContext) {
contact_finder::init(cx);
channel_modal::init(cx);
channel_view::init(cx);
@ -134,7 +136,7 @@ pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
cx.add_action(CollabPanel::toggle_channel_collapsed);
cx.add_action(CollabPanel::collapse_selected_channel);
cx.add_action(CollabPanel::expand_selected_channel);
cx.add_action(CollabPanel::open_channel_buffer);
cx.add_action(CollabPanel::open_channel_notes);
}
#[derive(Debug)]
@ -208,7 +210,7 @@ enum Section {
#[derive(Clone, Debug)]
enum ListEntry {
Header(Section, usize),
Header(Section),
CallParticipant {
user: Arc<User>,
is_pending: bool,
@ -274,7 +276,7 @@ impl CollabPanel {
this.selection = this
.entries
.iter()
.position(|entry| !matches!(entry, ListEntry::Header(_, _)));
.position(|entry| !matches!(entry, ListEntry::Header(_)));
}
}
})
@ -310,16 +312,9 @@ impl CollabPanel {
let current_project_id = this.project.read(cx).remote_id();
match &this.entries[ix] {
ListEntry::Header(section, depth) => {
ListEntry::Header(section) => {
let is_collapsed = this.collapsed_sections.contains(section);
this.render_header(
*section,
&theme,
*depth,
is_selected,
is_collapsed,
cx,
)
this.render_header(*section, &theme, is_selected, is_collapsed, cx)
}
ListEntry::CallParticipant { user, is_pending } => {
Self::render_call_participant(
@ -452,7 +447,7 @@ impl CollabPanel {
let mut old_dock_position = this.position(cx);
this.subscriptions
.push(
cx.observe_global::<SettingsStore, _>(move |this: &mut CollabPanel, cx| {
cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
let new_dock_position = this.position(cx);
if new_dock_position != old_dock_position {
old_dock_position = new_dock_position;
@ -563,7 +558,7 @@ impl CollabPanel {
let old_entries = mem::take(&mut self.entries);
if let Some(room) = ActiveCall::global(cx).read(cx).room() {
self.entries.push(ListEntry::Header(Section::ActiveCall, 0));
self.entries.push(ListEntry::Header(Section::ActiveCall));
if !self.collapsed_sections.contains(&Section::ActiveCall) {
let room = room.read(cx);
@ -678,7 +673,7 @@ impl CollabPanel {
let mut request_entries = Vec::new();
if cx.has_flag::<ChannelsAlpha>() {
self.entries.push(ListEntry::Header(Section::Channels, 0));
self.entries.push(ListEntry::Header(Section::Channels));
if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
self.match_candidates.clear();
@ -781,7 +776,7 @@ impl CollabPanel {
if !request_entries.is_empty() {
self.entries
.push(ListEntry::Header(Section::ChannelInvites, 1));
.push(ListEntry::Header(Section::ChannelInvites));
if !self.collapsed_sections.contains(&Section::ChannelInvites) {
self.entries.append(&mut request_entries);
}
@ -789,7 +784,7 @@ impl CollabPanel {
}
}
self.entries.push(ListEntry::Header(Section::Contacts, 0));
self.entries.push(ListEntry::Header(Section::Contacts));
request_entries.clear();
let incoming = user_store.incoming_contact_requests();
@ -852,7 +847,7 @@ impl CollabPanel {
if !request_entries.is_empty() {
self.entries
.push(ListEntry::Header(Section::ContactRequests, 1));
.push(ListEntry::Header(Section::ContactRequests));
if !self.collapsed_sections.contains(&Section::ContactRequests) {
self.entries.append(&mut request_entries);
}
@ -891,7 +886,7 @@ impl CollabPanel {
(offline_contacts, Section::Offline),
] {
if !matches.is_empty() {
self.entries.push(ListEntry::Header(section, 1));
self.entries.push(ListEntry::Header(section));
if !self.collapsed_sections.contains(&section) {
let active_call = &ActiveCall::global(cx).read(cx);
for mat in matches {
@ -1132,7 +1127,7 @@ impl CollabPanel {
cx.font_cache(),
))
.with_child(
Svg::new("icons/disable_screen_sharing_12.svg")
Svg::new("icons/desktop.svg")
.with_color(theme.channel_hash.color)
.constrained()
.with_width(theme.channel_hash.width)
@ -1179,7 +1174,6 @@ impl CollabPanel {
&self,
section: Section,
theme: &theme::Theme,
depth: usize,
is_selected: bool,
is_collapsed: bool,
cx: &mut ViewContext<Self>,
@ -1287,7 +1281,13 @@ impl CollabPanel {
_ => None,
};
let can_collapse = depth > 0;
let can_collapse = match section {
Section::ActiveCall | Section::Channels | Section::Contacts => false,
Section::ChannelInvites
| Section::ContactRequests
| Section::Online
| Section::Offline => true,
};
let icon_size = (&theme.collab_panel).section_icon_size;
let mut result = MouseEventHandler::new::<Header, _>(section as usize, cx, |state, _| {
let header_style = if can_collapse {
@ -1567,7 +1567,11 @@ impl CollabPanel {
const FACEPILE_LIMIT: usize = 3;
enum ChannelCall {}
MouseEventHandler::new::<Channel, _>(channel.id as usize, cx, |state, cx| {
let row_hovered = state.hovered();
Flex::<Self>::row()
.with_child(
Svg::new("icons/hash.svg")
@ -1585,37 +1589,52 @@ impl CollabPanel {
.left()
.flex(1., true),
)
.with_children({
let participants = self.channel_store.read(cx).channel_participants(channel_id);
if !participants.is_empty() {
let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
.with_child(
MouseEventHandler::new::<ChannelCall, _>(
channel.id as usize,
cx,
move |_, cx| {
let participants =
self.channel_store.read(cx).channel_participants(channel_id);
if !participants.is_empty() {
let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
Some(
FacePile::new(theme.face_overlap)
.with_children(
participants
.iter()
.filter_map(|user| {
Some(
Image::from_data(user.avatar.clone()?)
.with_style(theme.channel_avatar),
)
})
.take(FACEPILE_LIMIT),
)
.with_children((extra_count > 0).then(|| {
Label::new(
format!("+{}", extra_count),
theme.extra_participant_label.text.clone(),
FacePile::new(theme.face_overlap)
.with_children(
participants
.iter()
.filter_map(|user| {
Some(
Image::from_data(user.avatar.clone()?)
.with_style(theme.channel_avatar),
)
})
.take(FACEPILE_LIMIT),
)
.contained()
.with_style(theme.extra_participant_label.container)
})),
)
} else {
None
}
})
.with_children((extra_count > 0).then(|| {
Label::new(
format!("+{}", extra_count),
theme.extra_participant_label.text.clone(),
)
.contained()
.with_style(theme.extra_participant_label.container)
}))
.into_any()
} else if row_hovered {
Svg::new("icons/speaker-loud.svg")
.with_color(theme.channel_hash.color)
.constrained()
.with_width(theme.channel_hash.width)
.into_any()
} else {
Empty::new().into_any()
}
},
)
.on_click(MouseButton::Left, move |_, this, cx| {
this.join_channel_call(channel_id, cx);
}),
)
.align_children_center()
.styleable_component()
.disclosable(disclosed, Box::new(ToggleCollapse { channel_id }))
@ -1632,7 +1651,7 @@ impl CollabPanel {
)
})
.on_click(MouseButton::Left, move |_, this, cx| {
this.join_channel(channel_id, cx);
this.join_channel_chat(channel_id, cx);
})
.on_click(MouseButton::Right, move |e, this, cx| {
this.deploy_channel_context_menu(Some(e.position), channel_id, cx);
@ -1690,7 +1709,7 @@ impl CollabPanel {
.with_padding_left(theme.channel_row.default_style().padding.left)
})
.on_click(MouseButton::Left, move |_, this, cx| {
this.open_channel_buffer(&OpenChannelBuffer { channel_id }, cx);
this.open_channel_notes(&OpenChannelNotes { channel_id }, cx);
})
.with_cursor_style(CursorStyle::PointingHand)
.into_any()
@ -1902,7 +1921,7 @@ impl CollabPanel {
let mut items = vec![
ContextMenuItem::action(expand_action_name, ToggleCollapse { channel_id }),
ContextMenuItem::action("Open Notes", OpenChannelBuffer { channel_id }),
ContextMenuItem::action("Open Notes", OpenChannelNotes { channel_id }),
];
if self.channel_store.read(cx).is_user_admin(channel_id) {
@ -1987,7 +2006,7 @@ impl CollabPanel {
if let Some(selection) = self.selection {
if let Some(entry) = self.entries.get(selection) {
match entry {
ListEntry::Header(section, _) => match section {
ListEntry::Header(section) => match section {
Section::ActiveCall => Self::leave_call(cx),
Section::Channels => self.new_root_channel(cx),
Section::Contacts => self.toggle_contact_finder(cx),
@ -2027,7 +2046,7 @@ impl CollabPanel {
}
}
ListEntry::Channel { channel, .. } => {
self.join_channel(channel.id, cx);
self.join_channel_chat(channel.id, cx);
}
ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
_ => {}
@ -2237,31 +2256,9 @@ impl CollabPanel {
}
}
fn open_channel_buffer(&mut self, action: &OpenChannelBuffer, cx: &mut ViewContext<Self>) {
fn open_channel_notes(&mut self, action: &OpenChannelNotes, cx: &mut ViewContext<Self>) {
if let Some(workspace) = self.workspace.upgrade(cx) {
let pane = workspace.read(cx).active_pane().clone();
let channel_id = action.channel_id;
let channel_view = ChannelView::open(channel_id, pane.clone(), workspace, cx);
cx.spawn(|_, mut cx| async move {
let channel_view = channel_view.await?;
pane.update(&mut cx, |pane, cx| {
pane.add_item(Box::new(channel_view), true, true, None, cx)
});
anyhow::Ok(())
})
.detach();
let room_id = ActiveCall::global(cx)
.read(cx)
.room()
.map(|room| room.read(cx).id());
ActiveCall::report_call_event_for_room(
"open channel notes",
room_id,
Some(channel_id),
&self.client,
cx,
);
ChannelView::deploy(action.channel_id, workspace, cx);
}
}
@ -2416,11 +2413,25 @@ impl CollabPanel {
.detach_and_log_err(cx);
}
fn join_channel(&self, channel: u64, cx: &mut ViewContext<Self>) {
fn join_channel_call(&self, channel: u64, cx: &mut ViewContext<Self>) {
ActiveCall::global(cx)
.update(cx, |call, cx| call.join_channel(channel, cx))
.detach_and_log_err(cx);
}
fn join_channel_chat(&mut self, channel_id: u64, cx: &mut ViewContext<Self>) {
if let Some(workspace) = self.workspace.upgrade(cx) {
cx.app_context().defer(move |cx| {
workspace.update(cx, |workspace, cx| {
if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.select_channel(channel_id, cx).detach_and_log_err(cx);
});
}
});
});
}
}
}
fn render_tree_branch(
@ -2556,10 +2567,7 @@ impl View for CollabPanel {
impl Panel for CollabPanel {
fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
match settings::get::<CollaborationPanelSettings>(cx).dock {
CollaborationPanelDockPosition::Left => DockPosition::Left,
CollaborationPanelDockPosition::Right => DockPosition::Right,
}
settings::get::<CollaborationPanelSettings>(cx).dock
}
fn position_is_valid(&self, position: DockPosition) -> bool {
@ -2570,15 +2578,7 @@ impl Panel for CollabPanel {
settings::update_settings_file::<CollaborationPanelSettings>(
self.fs.clone(),
cx,
move |settings| {
let dock = match position {
DockPosition::Left | DockPosition::Bottom => {
CollaborationPanelDockPosition::Left
}
DockPosition::Right => CollaborationPanelDockPosition::Right,
};
settings.dock = Some(dock);
},
move |settings| settings.dock = Some(position),
);
}
@ -2596,7 +2596,7 @@ impl Panel for CollabPanel {
fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
settings::get::<CollaborationPanelSettings>(cx)
.button
.then(|| "icons/conversations.svg")
.then(|| "icons/user_group_16.svg")
}
fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
@ -2622,9 +2622,9 @@ impl Panel for CollabPanel {
impl PartialEq for ListEntry {
fn eq(&self, other: &Self) -> bool {
match self {
ListEntry::Header(section_1, depth_1) => {
if let ListEntry::Header(section_2, depth_2) = other {
return section_1 == section_2 && depth_1 == depth_2;
ListEntry::Header(section_1) => {
if let ListEntry::Header(section_2) = other {
return section_1 == section_2;
}
}
ListEntry::CallParticipant { user: user_1, .. } => {

View File

@ -209,7 +209,7 @@ impl PickerDelegate for ContactFinderDelegate {
let icon_path = match request_status {
ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
Some("icons/check_8.svg")
Some("icons/check.svg")
}
ContactRequestStatus::RequestSent => Some("icons/x.svg"),
ContactRequestStatus::RequestAccepted => None,

View File

@ -1,15 +1,16 @@
pub mod channel_view;
pub mod chat_panel;
pub mod collab_panel;
mod collab_titlebar_item;
mod contact_notification;
mod face_pile;
mod incoming_call_notification;
mod notifications;
mod panel_settings;
mod project_shared_notification;
mod sharing_status_indicator;
use call::{ActiveCall, Room};
pub use collab_titlebar_item::CollabTitlebarItem;
use gpui::{
actions,
geometry::{
@ -23,15 +24,22 @@ use std::{rc::Rc, sync::Arc};
use util::ResultExt;
use workspace::AppState;
pub use collab_titlebar_item::CollabTitlebarItem;
pub use panel_settings::{ChatPanelSettings, CollaborationPanelSettings};
actions!(
collab,
[ToggleScreenSharing, ToggleMute, ToggleDeafen, LeaveCall]
);
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
settings::register::<CollaborationPanelSettings>(cx);
settings::register::<ChatPanelSettings>(cx);
vcs_menu::init(cx);
collab_titlebar_item::init(cx);
collab_panel::init(app_state.client.clone(), cx);
collab_panel::init(cx);
chat_panel::init(cx);
incoming_call_notification::init(&app_state, cx);
project_shared_notification::init(&app_state, cx);
sharing_status_indicator::init(cx);

View File

@ -2,32 +2,47 @@ use anyhow;
use schemars::JsonSchema;
use serde_derive::{Deserialize, Serialize};
use settings::Setting;
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CollaborationPanelDockPosition {
Left,
Right,
}
use workspace::dock::DockPosition;
#[derive(Deserialize, Debug)]
pub struct CollaborationPanelSettings {
pub button: bool,
pub dock: CollaborationPanelDockPosition,
pub dock: DockPosition,
pub default_width: f32,
}
#[derive(Deserialize, Debug)]
pub struct ChatPanelSettings {
pub button: bool,
pub dock: DockPosition,
pub default_width: f32,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
pub struct CollaborationPanelSettingsContent {
pub struct PanelSettingsContent {
pub button: Option<bool>,
pub dock: Option<CollaborationPanelDockPosition>,
pub dock: Option<DockPosition>,
pub default_width: Option<f32>,
}
impl Setting for CollaborationPanelSettings {
const KEY: Option<&'static str> = Some("collaboration_panel");
type FileContent = CollaborationPanelSettingsContent;
type FileContent = PanelSettingsContent;
fn load(
default_value: &Self::FileContent,
user_values: &[&Self::FileContent],
_: &gpui::AppContext,
) -> anyhow::Result<Self> {
Self::load_via_json_merge(default_value, user_values)
}
}
impl Setting for ChatPanelSettings {
const KEY: Option<&'static str> = Some("chat_panel");
type FileContent = PanelSettingsContent;
fn load(
default_value: &Self::FileContent,

View File

@ -48,7 +48,7 @@ impl View for SharingStatusIndicator {
};
MouseEventHandler::new::<Self, _>(0, cx, |_, _| {
Svg::new("icons/disable_screen_sharing_12.svg")
Svg::new("icons/desktop.svg")
.with_color(color)
.constrained()
.with_width(18.)

View File

@ -45,7 +45,7 @@ util = { path = "../util" }
sqlez = { path = "../sqlez" }
workspace = { path = "../workspace" }
aho-corasick = "0.7"
aho-corasick = "1.1"
anyhow.workspace = true
convert_case = "0.6.0"
futures.workspace = true

View File

@ -5936,7 +5936,7 @@ impl Editor {
}
}
pub fn select_next(&mut self, action: &SelectNext, cx: &mut ViewContext<Self>) {
pub fn select_next(&mut self, action: &SelectNext, cx: &mut ViewContext<Self>) -> Result<()> {
self.push_to_selection_history();
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let buffer = &display_map.buffer_snapshot;
@ -6005,7 +6005,7 @@ impl Editor {
.text_for_range(selection.start..selection.end)
.collect::<String>();
let select_state = SelectNextState {
query: AhoCorasick::new_auto_configured(&[query]),
query: AhoCorasick::new(&[query])?,
wordwise: true,
done: false,
};
@ -6019,16 +6019,21 @@ impl Editor {
.text_for_range(selection.start..selection.end)
.collect::<String>();
self.select_next_state = Some(SelectNextState {
query: AhoCorasick::new_auto_configured(&[query]),
query: AhoCorasick::new(&[query])?,
wordwise: false,
done: false,
});
self.select_next(action, cx);
self.select_next(action, cx)?;
}
}
Ok(())
}
pub fn select_previous(&mut self, action: &SelectPrevious, cx: &mut ViewContext<Self>) {
pub fn select_previous(
&mut self,
action: &SelectPrevious,
cx: &mut ViewContext<Self>,
) -> Result<()> {
self.push_to_selection_history();
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let buffer = &display_map.buffer_snapshot;
@ -6099,7 +6104,7 @@ impl Editor {
.collect::<String>();
let query = query.chars().rev().collect::<String>();
let select_state = SelectNextState {
query: AhoCorasick::new_auto_configured(&[query]),
query: AhoCorasick::new(&[query])?,
wordwise: true,
done: false,
};
@ -6114,13 +6119,14 @@ impl Editor {
.collect::<String>();
let query = query.chars().rev().collect::<String>();
self.select_prev_state = Some(SelectNextState {
query: AhoCorasick::new_auto_configured(&[query]),
query: AhoCorasick::new(&[query])?,
wordwise: false,
done: false,
});
self.select_previous(action, cx);
self.select_previous(action, cx)?;
}
}
Ok(())
}
pub fn toggle_comments(&mut self, action: &ToggleComments, cx: &mut ViewContext<Self>) {

View File

@ -3669,10 +3669,12 @@ async fn test_select_next(cx: &mut gpui::TestAppContext) {
let mut cx = EditorTestContext::new(cx).await;
cx.set_state("abc\nˇabc abc\ndefabc\nabc");
cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx));
cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx))
.unwrap();
cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc");
cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx));
cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx))
.unwrap();
cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\nabc");
cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx));
@ -3681,10 +3683,12 @@ async fn test_select_next(cx: &mut gpui::TestAppContext) {
cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx));
cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\nabc");
cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx));
cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx))
.unwrap();
cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»");
cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx));
cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx))
.unwrap();
cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»");
}
@ -3696,10 +3700,12 @@ async fn test_select_previous(cx: &mut gpui::TestAppContext) {
let mut cx = EditorTestContext::new(cx).await;
cx.set_state("abc\nˇabc abc\ndefabc\nabc");
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx));
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
.unwrap();
cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc");
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx));
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
.unwrap();
cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc");
cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx));
@ -3708,10 +3714,12 @@ async fn test_select_previous(cx: &mut gpui::TestAppContext) {
cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx));
cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc");
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx));
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
.unwrap();
cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\n«abcˇ»");
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx));
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
.unwrap();
cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»");
}
{
@ -3719,10 +3727,12 @@ async fn test_select_previous(cx: &mut gpui::TestAppContext) {
let mut cx = EditorTestContext::new(cx).await;
cx.set_state("abc\n«ˇabc» abc\ndefabc\nabc");
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx));
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
.unwrap();
cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\nabc");
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx));
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
.unwrap();
cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\n«abcˇ»");
cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx));
@ -3731,10 +3741,12 @@ async fn test_select_previous(cx: &mut gpui::TestAppContext) {
cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx));
cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\n«abcˇ»");
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx));
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
.unwrap();
cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndef«abcˇ»\n«abcˇ»");
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx));
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
.unwrap();
cx.assert_editor_state("«abcˇ»\n«ˇabc» «abcˇ»\ndef«abcˇ»\n«abcˇ»");
}
}

View File

@ -684,23 +684,41 @@ impl AppContext {
);
},
);
fn inner(
this: &mut AppContext,
name: &'static str,
deserializer: fn(serde_json::Value) -> anyhow::Result<Box<dyn Action>>,
action_id: TypeId,
view_id: TypeId,
handler: Box<ActionCallback>,
capture: bool,
) {
this.action_deserializers
.entry(name)
.or_insert((action_id.clone(), deserializer));
self.action_deserializers
.entry(A::qualified_name())
.or_insert((TypeId::of::<A>(), A::from_json_str));
let actions = if capture {
&mut this.capture_actions
} else {
&mut this.actions
};
let actions = if capture {
&mut self.capture_actions
} else {
&mut self.actions
};
actions
.entry(TypeId::of::<V>())
.or_default()
.entry(TypeId::of::<A>())
.or_default()
.push(handler);
actions
.entry(view_id)
.or_default()
.entry(action_id)
.or_default()
.push(handler);
}
inner(
self,
A::qualified_name(),
A::from_json_str,
TypeId::of::<A>(),
TypeId::of::<V>(),
handler,
capture,
);
}
pub fn add_async_action<A, V, F>(&mut self, mut handler: F)

View File

@ -2,6 +2,4 @@ mod select;
pub use select::{ItemType, Select, SelectStyle};
pub fn init(cx: &mut super::AppContext) {
select::init(cx);
}
pub fn init(_: &mut super::AppContext) {}

View File

@ -1,13 +1,12 @@
use serde::Deserialize;
use crate::{
actions, elements::*, impl_actions, platform::MouseButton, AppContext, Entity, View,
ViewContext, WeakViewHandle,
elements::*,
platform::{CursorStyle, MouseButton},
AppContext, Entity, View, ViewContext, WeakViewHandle,
};
pub struct Select {
handle: WeakViewHandle<Self>,
render_item: Box<dyn Fn(usize, ItemType, bool, &AppContext) -> AnyElement<Self>>,
render_item: Box<dyn Fn(usize, ItemType, bool, &mut ViewContext<Select>) -> AnyElement<Self>>,
selected_item_ix: usize,
item_count: usize,
is_open: bool,
@ -27,21 +26,12 @@ pub enum ItemType {
Unselected,
}
#[derive(Clone, Deserialize, PartialEq)]
pub struct SelectItem(pub usize);
actions!(select, [ToggleSelect]);
impl_actions!(select, [SelectItem]);
pub enum Event {}
pub fn init(cx: &mut AppContext) {
cx.add_action(Select::toggle);
cx.add_action(Select::select_item);
}
impl Select {
pub fn new<F: 'static + Fn(usize, ItemType, bool, &AppContext) -> AnyElement<Self>>(
pub fn new<
F: 'static + Fn(usize, ItemType, bool, &mut ViewContext<Self>) -> AnyElement<Self>,
>(
item_count: usize,
cx: &mut ViewContext<Self>,
render_item: F,
@ -67,13 +57,13 @@ impl Select {
cx.notify();
}
fn toggle(&mut self, _: &ToggleSelect, cx: &mut ViewContext<Self>) {
fn toggle(&mut self, cx: &mut ViewContext<Self>) {
self.is_open = !self.is_open;
cx.notify();
}
fn select_item(&mut self, action: &SelectItem, cx: &mut ViewContext<Self>) {
self.selected_item_ix = action.0;
pub fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Self>) {
self.selected_item_ix = ix;
self.is_open = false;
cx.notify();
}
@ -116,8 +106,9 @@ impl View for Select {
.contained()
.with_style(style.header)
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, this, cx| {
this.toggle(&Default::default(), cx);
this.toggle(cx);
}),
);
if self.is_open {
@ -142,8 +133,9 @@ impl View for Select {
cx,
)
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, this, cx| {
this.select_item(&SelectItem(ix), cx);
this.set_selected_index(ix, cx);
})
.into_any()
}))

View File

@ -37,7 +37,7 @@ sum_tree = { path = "../sum_tree" }
terminal = { path = "../terminal" }
util = { path = "../util" }
aho-corasick = "0.7"
aho-corasick = "1.1"
anyhow.workspace = true
async-trait.workspace = true
backtrace = "0.3"

View File

@ -3598,7 +3598,7 @@ async fn test_search(cx: &mut gpui::TestAppContext) {
assert_eq!(
search(
&project,
SearchQuery::text("TWO", false, true, Vec::new(), Vec::new()),
SearchQuery::text("TWO", false, true, Vec::new(), Vec::new()).unwrap(),
cx
)
.await
@ -3623,7 +3623,7 @@ async fn test_search(cx: &mut gpui::TestAppContext) {
assert_eq!(
search(
&project,
SearchQuery::text("TWO", false, true, Vec::new(), Vec::new()),
SearchQuery::text("TWO", false, true, Vec::new(), Vec::new()).unwrap(),
cx
)
.await
@ -3664,7 +3664,8 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
true,
vec![PathMatcher::new("*.odd").unwrap()],
Vec::new()
),
)
.unwrap(),
cx
)
.await
@ -3682,7 +3683,8 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
true,
vec![PathMatcher::new("*.rs").unwrap()],
Vec::new()
),
)
.unwrap(),
cx
)
.await
@ -3706,7 +3708,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
PathMatcher::new("*.odd").unwrap(),
],
Vec::new()
),
).unwrap(),
cx
)
.await
@ -3731,7 +3733,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
PathMatcher::new("*.odd").unwrap(),
],
Vec::new()
),
).unwrap(),
cx
)
.await
@ -3774,7 +3776,8 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
true,
Vec::new(),
vec![PathMatcher::new("*.odd").unwrap()],
),
)
.unwrap(),
cx
)
.await
@ -3797,7 +3800,8 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
true,
Vec::new(),
vec![PathMatcher::new("*.rs").unwrap()],
),
)
.unwrap(),
cx
)
.await
@ -3821,7 +3825,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
PathMatcher::new("*.ts").unwrap(),
PathMatcher::new("*.odd").unwrap(),
],
),
).unwrap(),
cx
)
.await
@ -3846,7 +3850,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
PathMatcher::new("*.ts").unwrap(),
PathMatcher::new("*.odd").unwrap(),
],
),
).unwrap(),
cx
)
.await
@ -3883,7 +3887,8 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex
true,
vec![PathMatcher::new("*.odd").unwrap()],
vec![PathMatcher::new("*.odd").unwrap()],
),
)
.unwrap(),
cx
)
.await
@ -3901,7 +3906,7 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex
true,
vec![PathMatcher::new("*.ts").unwrap()],
vec![PathMatcher::new("*.ts").unwrap()],
),
).unwrap(),
cx
)
.await
@ -3925,7 +3930,8 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex
PathMatcher::new("*.ts").unwrap(),
PathMatcher::new("*.odd").unwrap()
],
),
)
.unwrap(),
cx
)
.await
@ -3949,7 +3955,8 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex
PathMatcher::new("*.rs").unwrap(),
PathMatcher::new("*.odd").unwrap()
],
),
)
.unwrap(),
cx
)
.await

View File

@ -35,7 +35,7 @@ impl SearchInputs {
#[derive(Clone, Debug)]
pub enum SearchQuery {
Text {
search: Arc<AhoCorasick<usize>>,
search: Arc<AhoCorasick>,
replacement: Option<String>,
whole_word: bool,
case_sensitive: bool,
@ -84,24 +84,23 @@ impl SearchQuery {
case_sensitive: bool,
files_to_include: Vec<PathMatcher>,
files_to_exclude: Vec<PathMatcher>,
) -> Self {
) -> Result<Self> {
let query = query.to_string();
let search = AhoCorasickBuilder::new()
.auto_configure(&[&query])
.ascii_case_insensitive(!case_sensitive)
.build(&[&query]);
.build(&[&query])?;
let inner = SearchInputs {
query: query.into(),
files_to_exclude,
files_to_include,
};
Self::Text {
Ok(Self::Text {
search: Arc::new(search),
replacement: None,
whole_word,
case_sensitive,
inner,
}
})
}
pub fn regex(
@ -151,13 +150,13 @@ impl SearchQuery {
deserialize_path_matches(&message.files_to_exclude)?,
)
} else {
Ok(Self::text(
Self::text(
message.query,
message.whole_word,
message.case_sensitive,
deserialize_path_matches(&message.files_to_include)?,
deserialize_path_matches(&message.files_to_exclude)?,
))
)
}
}
pub fn with_replacement(mut self, new_replacement: Option<String>) -> Self {

View File

@ -122,6 +122,7 @@ actions!(
CopyPath,
CopyRelativePath,
RevealInFinder,
OpenInTerminal,
Cut,
Paste,
Delete,
@ -156,6 +157,7 @@ pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
cx.add_action(ProjectPanel::copy_path);
cx.add_action(ProjectPanel::copy_relative_path);
cx.add_action(ProjectPanel::reveal_in_finder);
cx.add_action(ProjectPanel::open_in_terminal);
cx.add_action(ProjectPanel::new_search_in_directory);
cx.add_action(
|this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
@ -423,24 +425,30 @@ impl ProjectPanel {
menu_entries.push(ContextMenuItem::Separator);
menu_entries.push(ContextMenuItem::action("Cut", Cut));
menu_entries.push(ContextMenuItem::action("Copy", Copy));
if let Some(clipboard_entry) = self.clipboard_entry {
if clipboard_entry.worktree_id() == worktree.id() {
menu_entries.push(ContextMenuItem::action("Paste", Paste));
}
}
menu_entries.push(ContextMenuItem::Separator);
menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
menu_entries.push(ContextMenuItem::action(
"Copy Relative Path",
CopyRelativePath,
));
if entry.is_dir() {
menu_entries.push(ContextMenuItem::Separator);
}
menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
if entry.is_dir() {
menu_entries.push(ContextMenuItem::action("Open in Terminal", OpenInTerminal));
menu_entries.push(ContextMenuItem::action(
"Search Inside",
NewSearchInDirectory,
));
}
if let Some(clipboard_entry) = self.clipboard_entry {
if clipboard_entry.worktree_id() == worktree.id() {
menu_entries.push(ContextMenuItem::action("Paste", Paste));
}
}
menu_entries.push(ContextMenuItem::Separator);
menu_entries.push(ContextMenuItem::action("Rename", Rename));
if !is_root {
@ -965,6 +973,26 @@ impl ProjectPanel {
}
}
fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
if let Some((worktree, entry)) = self.selected_entry(cx) {
let window = cx.window();
let view_id = cx.view_id();
let path = worktree.abs_path().join(&entry.path);
cx.app_context()
.spawn(|mut cx| async move {
window.dispatch_action(
view_id,
&workspace::OpenTerminal {
working_directory: path,
},
&mut cx,
);
})
.detach();
}
}
pub fn new_search_in_directory(
&mut self,
_: &NewSearchInDirectory,

View File

@ -155,7 +155,17 @@ message Envelope {
RemoveChannelBufferCollaborator remove_channel_buffer_collaborator = 136;
UpdateChannelBufferCollaborator update_channel_buffer_collaborator = 139;
RejoinChannelBuffers rejoin_channel_buffers = 140;
RejoinChannelBuffersResponse rejoin_channel_buffers_response = 141; // Current max
RejoinChannelBuffersResponse rejoin_channel_buffers_response = 141;
JoinChannelChat join_channel_chat = 142;
JoinChannelChatResponse join_channel_chat_response = 143;
LeaveChannelChat leave_channel_chat = 144;
SendChannelMessage send_channel_message = 145;
SendChannelMessageResponse send_channel_message_response = 146;
ChannelMessageSent channel_message_sent = 147;
GetChannelMessages get_channel_messages = 148;
GetChannelMessagesResponse get_channel_messages_response = 149;
RemoveChannelMessage remove_channel_message = 150; // Current max
}
}
@ -1021,10 +1031,61 @@ message RenameChannel {
string name = 2;
}
message JoinChannelChat {
uint64 channel_id = 1;
}
message JoinChannelChatResponse {
repeated ChannelMessage messages = 1;
bool done = 2;
}
message LeaveChannelChat {
uint64 channel_id = 1;
}
message SendChannelMessage {
uint64 channel_id = 1;
string body = 2;
Nonce nonce = 3;
}
message RemoveChannelMessage {
uint64 channel_id = 1;
uint64 message_id = 2;
}
message SendChannelMessageResponse {
ChannelMessage message = 1;
}
message ChannelMessageSent {
uint64 channel_id = 1;
ChannelMessage message = 2;
}
message GetChannelMessages {
uint64 channel_id = 1;
uint64 before_message_id = 2;
}
message GetChannelMessagesResponse {
repeated ChannelMessage messages = 1;
bool done = 2;
}
message JoinChannelBuffer {
uint64 channel_id = 1;
}
message ChannelMessage {
uint64 id = 1;
string body = 2;
uint64 timestamp = 3;
uint64 sender_id = 4;
Nonce nonce = 5;
}
message RejoinChannelBuffers {
repeated ChannelBufferVersion buffers = 1;
}

View File

@ -147,6 +147,7 @@ messages!(
(CreateBufferForPeer, Foreground),
(CreateChannel, Foreground),
(ChannelResponse, Foreground),
(ChannelMessageSent, Foreground),
(CreateProjectEntry, Foreground),
(CreateRoom, Foreground),
(CreateRoomResponse, Foreground),
@ -163,6 +164,10 @@ messages!(
(GetCodeActionsResponse, Background),
(GetHover, Background),
(GetHoverResponse, Background),
(GetChannelMessages, Background),
(GetChannelMessagesResponse, Background),
(SendChannelMessage, Background),
(SendChannelMessageResponse, Background),
(GetCompletions, Background),
(GetCompletionsResponse, Background),
(GetDefinition, Background),
@ -184,6 +189,9 @@ messages!(
(JoinProjectResponse, Foreground),
(JoinRoom, Foreground),
(JoinRoomResponse, Foreground),
(JoinChannelChat, Foreground),
(JoinChannelChatResponse, Foreground),
(LeaveChannelChat, Foreground),
(LeaveProject, Foreground),
(LeaveRoom, Foreground),
(OpenBufferById, Background),
@ -209,6 +217,7 @@ messages!(
(RejoinRoomResponse, Foreground),
(RemoveContact, Foreground),
(RemoveChannelMember, Foreground),
(RemoveChannelMessage, Foreground),
(ReloadBuffers, Foreground),
(ReloadBuffersResponse, Foreground),
(RemoveProjectCollaborator, Foreground),
@ -293,6 +302,7 @@ request_messages!(
(InviteChannelMember, Ack),
(JoinProject, JoinProjectResponse),
(JoinRoom, JoinRoomResponse),
(JoinChannelChat, JoinChannelChatResponse),
(LeaveRoom, Ack),
(RejoinRoom, RejoinRoomResponse),
(IncomingCall, Ack),
@ -313,9 +323,12 @@ request_messages!(
(RespondToContactRequest, Ack),
(RespondToChannelInvite, Ack),
(SetChannelMemberAdmin, Ack),
(SendChannelMessage, SendChannelMessageResponse),
(GetChannelMessages, GetChannelMessagesResponse),
(GetChannelMembers, GetChannelMembersResponse),
(JoinChannel, JoinRoomResponse),
(RemoveChannel, Ack),
(RemoveChannelMessage, Ack),
(RenameProjectEntry, ProjectEntryResponse),
(RenameChannel, ChannelResponse),
(SaveBuffer, BufferSaved),
@ -388,8 +401,10 @@ entity_messages!(
entity_messages!(
channel_id,
ChannelMessageSent,
UpdateChannelBuffer,
RemoveChannelBufferCollaborator,
RemoveChannelMessage,
AddChannelBufferCollaborator,
UpdateChannelBufferCollaborator
);

View File

@ -6,4 +6,4 @@ pub use conn::Connection;
pub use peer::*;
mod macros;
pub const PROTOCOL_VERSION: u32 = 62;
pub const PROTOCOL_VERSION: u32 = 63;

View File

@ -783,14 +783,21 @@ impl BufferSearchBar {
}
}
} else {
SearchQuery::text(
match SearchQuery::text(
query,
self.search_options.contains(SearchOptions::WHOLE_WORD),
self.search_options.contains(SearchOptions::CASE_SENSITIVE),
Vec::new(),
Vec::new(),
)
.with_replacement(Some(self.replacement(cx)).filter(|s| !s.is_empty()))
) {
Ok(query) => query
.with_replacement(Some(self.replacement(cx)).filter(|s| !s.is_empty())),
Err(_) => {
self.query_contains_error = true;
cx.notify();
return done_rx;
}
}
}
.into();
self.active_search = Some(query.clone());

View File

@ -1050,13 +1050,23 @@ impl ProjectSearchView {
}
}
}
_ => Some(SearchQuery::text(
_ => match SearchQuery::text(
text,
self.search_options.contains(SearchOptions::WHOLE_WORD),
self.search_options.contains(SearchOptions::CASE_SENSITIVE),
included_files,
excluded_files,
)),
) {
Ok(query) => {
self.panels_with_errors.remove(&InputPanel::Query);
Some(query)
}
Err(_e) => {
self.panels_with_errors.insert(InputPanel::Query);
cx.notify();
None
}
},
}
}

View File

@ -714,7 +714,14 @@ impl SemanticIndex {
let search_start = Instant::now();
let modified_buffer_results = this.update(&mut cx, |this, cx| {
this.search_modified_buffers(&project, query.clone(), limit, &excludes, cx)
this.search_modified_buffers(
&project,
query.clone(),
limit,
&includes,
&excludes,
cx,
)
});
let file_results = this.update(&mut cx, |this, cx| {
this.search_files(project, query, limit, includes, excludes, cx)
@ -877,6 +884,7 @@ impl SemanticIndex {
project: &ModelHandle<Project>,
query: Embedding,
limit: usize,
includes: &[PathMatcher],
excludes: &[PathMatcher],
cx: &mut ModelContext<Self>,
) -> Task<Result<Vec<SearchResult>>> {
@ -890,7 +898,16 @@ impl SemanticIndex {
let excluded = snapshot.resolve_file_path(cx, false).map_or(false, |path| {
excludes.iter().any(|matcher| matcher.is_match(&path))
});
if buffer.is_dirty() && !excluded {
let included = if includes.len() == 0 {
true
} else {
snapshot.resolve_file_path(cx, false).map_or(false, |path| {
includes.iter().any(|matcher| matcher.is_match(&path))
})
};
if buffer.is_dirty() && !excluded && included {
Some((buffer_handle, snapshot))
} else {
None

View File

@ -1,4 +1,4 @@
use std::sync::Arc;
use std::{path::PathBuf, sync::Arc};
use crate::TerminalView;
use db::kvp::KEY_VALUE_STORE;
@ -23,6 +23,7 @@ actions!(terminal_panel, [ToggleFocus]);
pub fn init(cx: &mut AppContext) {
cx.add_action(TerminalPanel::new_terminal);
cx.add_action(TerminalPanel::open_terminal);
}
#[derive(Debug)]
@ -79,7 +80,7 @@ impl TerminalPanel {
cx.window_context().defer(move |cx| {
if let Some(this) = this.upgrade(cx) {
this.update(cx, |this, cx| {
this.add_terminal(cx);
this.add_terminal(None, cx);
});
}
})
@ -230,6 +231,21 @@ impl TerminalPanel {
}
}
pub fn open_terminal(
workspace: &mut Workspace,
action: &workspace::OpenTerminal,
cx: &mut ViewContext<Workspace>,
) {
let Some(this) = workspace.focus_panel::<Self>(cx) else {
return;
};
this.update(cx, |this, cx| {
this.add_terminal(Some(action.working_directory.clone()), cx)
})
}
///Create a new Terminal in the current working directory or the user's home directory
fn new_terminal(
workspace: &mut Workspace,
_: &workspace::NewTerminal,
@ -239,19 +255,23 @@ impl TerminalPanel {
return;
};
this.update(cx, |this, cx| this.add_terminal(cx))
this.update(cx, |this, cx| this.add_terminal(None, cx))
}
fn add_terminal(&mut self, cx: &mut ViewContext<Self>) {
fn add_terminal(&mut self, working_directory: Option<PathBuf>, cx: &mut ViewContext<Self>) {
let workspace = self.workspace.clone();
cx.spawn(|this, mut cx| async move {
let pane = this.read_with(&cx, |this, _| this.pane.clone())?;
workspace.update(&mut cx, |workspace, cx| {
let working_directory_strategy = settings::get::<TerminalSettings>(cx)
.working_directory
.clone();
let working_directory =
crate::get_working_directory(workspace, cx, working_directory_strategy);
let working_directory = if let Some(working_directory) = working_directory {
Some(working_directory)
} else {
let working_directory_strategy = settings::get::<TerminalSettings>(cx)
.working_directory
.clone();
crate::get_working_directory(workspace, cx, working_directory_strategy)
};
let window = cx.window();
if let Some(terminal) = workspace.project().update(cx, |project, cx| {
project
@ -389,7 +409,7 @@ impl Panel for TerminalPanel {
fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
if active && self.pane.read(cx).items_len() == 0 {
self.add_terminal(cx)
self.add_terminal(None, cx)
}
}

View File

@ -26,7 +26,6 @@ impl<C: SafeStylable> ComponentExt<C> for C {
}
pub mod disclosure {
use gpui::{
elements::{Component, ContainerStyle, Empty, Flex, ParentElement, SafeStylable},
Action, Element,

View File

@ -52,6 +52,7 @@ pub struct Theme {
pub copilot: Copilot,
pub collab_panel: CollabPanel,
pub project_panel: ProjectPanel,
pub chat_panel: ChatPanel,
pub command_palette: CommandPalette,
pub picker: Picker,
pub editor: Editor,
@ -624,6 +625,19 @@ pub struct IconButton {
pub button_width: f32,
}
#[derive(Deserialize, Default, JsonSchema)]
pub struct ChatPanel {
#[serde(flatten)]
pub container: ContainerStyle,
pub list: ContainerStyle,
pub channel_select: ChannelSelect,
pub input_editor: FieldEditor,
pub message: ChatMessage,
pub pending_message: ChatMessage,
pub sign_in_prompt: Interactive<TextStyle>,
pub icon_button: Interactive<IconButton>,
}
#[derive(Deserialize, Default, JsonSchema)]
pub struct ChatMessage {
#[serde(flatten)]
@ -641,7 +655,6 @@ pub struct ChannelSelect {
pub item: ChannelName,
pub active_item: ChannelName,
pub hovered_item: ChannelName,
pub hovered_active_item: ChannelName,
pub menu: ContainerStyle,
}

View File

@ -34,11 +34,11 @@ fn focused(EditorFocused(editor): &EditorFocused, cx: &mut AppContext) {
fn blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut AppContext) {
editor.window().update(cx, |cx| {
Vim::update(cx, |vim, cx| {
vim.clear_operator(cx);
vim.workspace_state.recording = false;
vim.workspace_state.recorded_actions.clear();
if let Some(previous_editor) = vim.active_editor.clone() {
if previous_editor == editor.clone() {
vim.clear_operator(cx);
vim.active_editor = None;
}
}
@ -60,3 +60,31 @@ fn released(EditorReleased(editor): &EditorReleased, cx: &mut AppContext) {
});
});
}
#[cfg(test)]
mod test {
use crate::{test::VimTestContext, Vim};
use editor::Editor;
use gpui::View;
use language::Buffer;
// regression test for blur called with a different active editor
#[gpui::test]
async fn test_blur_focus(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
let buffer = cx.add_model(|_| Buffer::new(0, 0, "a = 1\nb = 2\n"));
let window2 = cx.add_window(|cx| Editor::for_buffer(buffer, None, cx));
let editor2 = cx.read(|cx| window2.root(cx)).unwrap();
cx.update(|cx| {
let vim = Vim::read(cx);
assert_eq!(vim.active_editor.unwrap().id(), editor2.id())
});
// no panic when blurring an editor in a different window.
cx.update_editor(|editor1, cx| {
editor1.focus_out(cx.handle().into_any(), cx);
});
}
}

View File

@ -536,8 +536,12 @@ fn down(
map.buffer_snapshot.max_point().row,
);
let new_col = cmp::min(goal_column, map.fold_snapshot.line_len(new_row));
let point = map.fold_point_to_display_point(FoldPoint::new(new_row, new_col));
let point = map.fold_point_to_display_point(
map.fold_snapshot
.clip_point(FoldPoint::new(new_row, new_col), Bias::Left),
);
// clip twice to "clip at end of line"
(map.clip_point(point, Bias::Left), goal)
}
@ -573,7 +577,10 @@ pub(crate) fn up(
let new_row = start.row().saturating_sub(times as u32);
let new_col = cmp::min(goal_column, map.fold_snapshot.line_len(new_row));
let point = map.fold_point_to_display_point(FoldPoint::new(new_row, new_col));
let point = map.fold_point_to_display_point(
map.fold_snapshot
.clip_point(FoldPoint::new(new_row, new_col), Bias::Left),
);
(map.clip_point(point, Bias::Left), goal)
}

View File

@ -26,10 +26,11 @@ pub fn copy_selections_content(editor: &mut Editor, linewise: bool, cx: &mut App
let is_last_line = linewise
&& end.row == buffer.max_buffer_row()
&& buffer.max_point().column > 0
&& start.row < buffer.max_buffer_row()
&& start == Point::new(start.row, buffer.line_len(start.row));
if is_last_line {
start = Point::new(buffer.max_buffer_row(), 0);
start = Point::new(start.row + 1, 0);
}
for chunk in buffer.text_for_range(start..end) {
text.push_str(chunk);

View File

@ -12,7 +12,7 @@ use language::{Selection, SelectionGoal};
use workspace::Workspace;
use crate::{
motion::Motion,
motion::{start_of_line, Motion},
object::Object,
state::{Mode, Operator},
utils::copy_selections_content,
@ -326,7 +326,10 @@ pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext<Workspace>)
let line_mode = editor.selections.line_mode;
copy_selections_content(editor, line_mode, cx);
editor.change_selections(None, cx, |s| {
s.move_with(|_, selection| {
s.move_with(|map, selection| {
if line_mode {
selection.start = start_of_line(map, false, selection.start);
};
selection.collapse_to(selection.start, SelectionGoal::None)
});
if vim.state().mode == Mode::VisualBlock {
@ -672,6 +675,21 @@ mod test {
the lazy dog"})
.await;
cx.assert_clipboard_content(Some("The q"));
cx.set_shared_state(indoc! {"
The quick brown
fox ˇjumps over
the lazy dog"})
.await;
cx.simulate_shared_keystrokes(["shift-v", "shift-g", "shift-y"])
.await;
cx.assert_shared_state(indoc! {"
The quick brown
ˇfox jumps over
the lazy dog"})
.await;
cx.assert_shared_clipboard("fox jumps over\nthe lazy dog\n")
.await;
}
#[gpui::test]

View File

@ -27,3 +27,9 @@
{"Key":"k"}
{"Key":"y"}
{"Get":{"state":"ˇThe quick brown\nfox jumps over\nthe lazy dog","mode":"Normal"}}
{"Put":{"state":"The quick brown\nfox ˇjumps over\nthe lazy dog"}}
{"Key":"shift-v"}
{"Key":"shift-g"}
{"Key":"shift-y"}
{"Get":{"state":"The quick brown\nˇfox jumps over\nthe lazy dog","mode":"Normal"}}
{"ReadRegister":{"name":"\"","value":"fox jumps over\nthe lazy dog\n"}}

View File

@ -4,7 +4,8 @@ use gpui::{
elements::*, platform::CursorStyle, platform::MouseButton, Action, AnyViewHandle, AppContext,
Axis, Entity, Subscription, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
};
use serde::Deserialize;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::rc::Rc;
use theme::ThemeSettings;
@ -132,7 +133,8 @@ pub struct Dock {
active_panel_index: usize,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum DockPosition {
Left,
Bottom,

View File

@ -112,7 +112,7 @@ impl Item for SharedScreen {
) -> gpui::AnyElement<V> {
Flex::row()
.with_child(
Svg::new("icons/disable_screen_sharing_12.svg")
Svg::new("icons/desktop.svg")
.with_color(style.label.text.color)
.constrained()
.with_width(style.type_icon_width)

View File

@ -203,7 +203,15 @@ impl Clone for Toast {
}
}
impl_actions!(workspace, [ActivatePane, ActivatePaneInDirection, Toast]);
#[derive(Clone, Deserialize, PartialEq)]
pub struct OpenTerminal {
pub working_directory: PathBuf,
}
impl_actions!(
workspace,
[ActivatePane, ActivatePaneInDirection, Toast, OpenTerminal]
);
pub type WorkspaceId = i64;

View File

@ -119,12 +119,6 @@ fn main() {
app.run(move |cx| {
cx.set_global(*RELEASE_CHANNEL);
#[cfg(debug_assertions)]
{
use feature_flags::FeatureFlagAppExt;
cx.set_staff(true);
}
let mut store = SettingsStore::default();
store
.set_default_settings(default_settings().as_ref(), cx)

View File

@ -214,6 +214,13 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
workspace.toggle_panel_focus::<collab_ui::collab_panel::CollabPanel>(cx);
},
);
cx.add_action(
|workspace: &mut Workspace,
_: &collab_ui::chat_panel::ToggleFocus,
cx: &mut ViewContext<Workspace>| {
workspace.toggle_panel_focus::<collab_ui::chat_panel::ChatPanel>(cx);
},
);
cx.add_action(
|workspace: &mut Workspace,
_: &terminal_panel::ToggleFocus,
@ -338,11 +345,14 @@ pub fn initialize_workspace(
let assistant_panel = AssistantPanel::load(workspace_handle.clone(), cx.clone());
let channels_panel =
collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone());
let (project_panel, terminal_panel, assistant_panel, channels_panel) = futures::try_join!(
let chat_panel =
collab_ui::chat_panel::ChatPanel::load(workspace_handle.clone(), cx.clone());
let (project_panel, terminal_panel, assistant_panel, channels_panel, chat_panel) = futures::try_join!(
project_panel,
terminal_panel,
assistant_panel,
channels_panel
channels_panel,
chat_panel,
)?;
workspace_handle.update(&mut cx, |workspace, cx| {
let project_panel_position = project_panel.position(cx);
@ -362,6 +372,7 @@ pub fn initialize_workspace(
workspace.add_panel(terminal_panel, cx);
workspace.add_panel(assistant_panel, cx);
workspace.add_panel(channels_panel, cx);
workspace.add_panel(chat_panel, cx);
if !was_deserialized
&& workspace

View File

@ -12,6 +12,7 @@ import simple_message_notification from "./simple_message_notification"
import project_shared_notification from "./project_shared_notification"
import tooltip from "./tooltip"
import terminal from "./terminal"
import chat_panel from "./chat_panel"
import collab_panel from "./collab_panel"
import toolbar_dropdown_menu from "./toolbar_dropdown_menu"
import incoming_call_notification from "./incoming_call_notification"
@ -55,6 +56,7 @@ export default function app(): any {
terminal: terminal(),
assistant: assistant(),
feedback: feedback(),
chat_panel: chat_panel(),
component_test: component_test(),
}
}

View File

@ -0,0 +1,106 @@
import {
background,
border,
text,
} from "./components"
import { icon_button } from "../component/icon_button"
import { useTheme } from "../theme"
export default function chat_panel(): any {
const theme = useTheme()
const layer = theme.middle
const SPACING = 12 as const
const channel_name = {
padding: {
left: SPACING,
right: SPACING,
top: 4,
bottom: 4,
},
hash: {
...text(layer, "sans", "base"),
},
name: text(layer, "sans", "base"),
}
return {
background: background(layer),
list: {
margin: {
left: SPACING,
right: SPACING,
}
},
channel_select: {
header: {
...channel_name,
border: border(layer, { bottom: true })
},
item: channel_name,
active_item: {
...channel_name,
background: background(layer, "on", "active"),
},
hovered_item: {
...channel_name,
background: background(layer, "on", "hovered"),
},
menu: {
background: background(layer, "on"),
border: border(layer, { bottom: true })
}
},
icon_button: icon_button({
variant: "ghost",
color: "variant",
size: "sm",
}),
input_editor: {
background: background(layer, "on"),
corner_radius: 6,
text: text(layer, "sans", "base"),
placeholder_text: text(layer, "sans", "base", "disabled", {
size: "xs",
}),
selection: theme.players[0],
border: border(layer, "on"),
margin: {
left: SPACING,
right: SPACING,
bottom: SPACING,
},
padding: {
bottom: 4,
left: 8,
right: 8,
top: 4,
},
},
message: {
body: text(layer, "sans", "base"),
sender: {
margin: {
right: 8,
},
...text(layer, "sans", "base", { weight: "bold" }),
},
timestamp: text(layer, "sans", "base", "disabled"),
margin: { bottom: SPACING }
},
pending_message: {
body: text(layer, "sans", "base"),
sender: {
margin: {
right: 8,
},
...text(layer, "sans", "base", "disabled"),
},
timestamp: text(layer, "sans", "base"),
},
sign_in_prompt: {
default: text(layer, "sans", "base"),
}
}
}

View File

@ -128,7 +128,7 @@ export default function welcome(): any {
},
icon: svg(
foreground(theme.highest, "on"),
"icons/check_12.svg",
"icons/check.svg",
12,
12
),