fix Cargo.lock for merge

This commit is contained in:
KCaverly 2023-09-19 11:44:51 -04:00
commit 183758a7c5
592 changed files with 3418 additions and 4563 deletions

View file

@ -16,8 +16,8 @@ use workspace::{item::ItemHandle, StatusItemView, Workspace};
actions!(lsp_status, [ShowErrorMessage]);
const DOWNLOAD_ICON: &str = "icons/download_12.svg";
const WARNING_ICON: &str = "icons/triangle_exclamation_12.svg";
const DOWNLOAD_ICON: &str = "icons/download.svg";
const WARNING_ICON: &str = "icons/warning.svg";
pub enum Event {
ShowError { lsp_name: Arc<str>, error: String },

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| {
@ -2376,7 +2401,7 @@ impl ConversationEditor {
.with_children(
if let MessageStatus::Error(error) = &message.status {
Some(
Svg::new("icons/circle_x_mark_12.svg")
Svg::new("icons/error.svg")
.with_color(style.error_icon.color)
.constrained()
.with_width(style.error_icon.width)
@ -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,
@ -2704,7 +2730,7 @@ impl View for InlineAssistant {
)
.with_children(if let Some(error) = self.codegen.read(cx).error() {
Some(
Svg::new("icons/circle_x_mark_12.svg")
Svg::new("icons/error.svg")
.with_color(theme.assistant.error_icon.color)
.constrained()
.with_width(theme.assistant.error_icon.width)
@ -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

@ -50,7 +50,7 @@ impl View for UpdateNotification {
.with_child(
MouseEventHandler::new::<Cancel, _>(0, cx, |state, _| {
let style = theme.dismiss_button.style_for(state);
Svg::new("icons/x_mark_8.svg")
Svg::new("icons/x.svg")
.with_color(style.color)
.constrained()
.with_width(style.icon_width)

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>,
@ -1249,7 +1243,7 @@ impl CollabPanel {
.collab_panel
.add_contact_button
.style_for(is_selected, state),
"icons/plus_16.svg",
"icons/plus.svg",
)
})
.with_cursor_style(CursorStyle::PointingHand)
@ -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);
@ -1668,7 +1687,7 @@ impl CollabPanel {
cx.font_cache(),
))
.with_child(
Svg::new("icons/radix/file.svg")
Svg::new("icons/file.svg")
.with_color(theme.channel_hash.color)
.constrained()
.with_width(theme.channel_hash.width)
@ -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,9 +209,9 @@ 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_mark_8.svg"),
ContactRequestStatus::RequestSent => Some("icons/x.svg"),
ContactRequestStatus::RequestAccepted => None,
};
let button_style = if self.user_store.read(cx).is_contact_request_pending(user) {

View file

@ -483,10 +483,10 @@ impl CollabTitlebarItem {
let icon;
let tooltip;
if room.read(cx).is_screen_sharing() {
icon = "icons/radix/desktop.svg";
icon = "icons/desktop.svg";
tooltip = "Stop Sharing Screen"
} else {
icon = "icons/radix/desktop.svg";
icon = "icons/desktop.svg";
tooltip = "Share Screen";
}
@ -533,10 +533,10 @@ impl CollabTitlebarItem {
let tooltip;
let is_muted = room.read(cx).is_muted(cx);
if is_muted {
icon = "icons/radix/mic-mute.svg";
icon = "icons/mic-mute.svg";
tooltip = "Unmute microphone";
} else {
icon = "icons/radix/mic.svg";
icon = "icons/mic.svg";
tooltip = "Mute microphone";
}
@ -586,10 +586,10 @@ impl CollabTitlebarItem {
let tooltip;
let is_deafened = room.read(cx).is_deafened().unwrap_or(false);
if is_deafened {
icon = "icons/radix/speaker-off.svg";
icon = "icons/speaker-off.svg";
tooltip = "Unmute speakers";
} else {
icon = "icons/radix/speaker-loud.svg";
icon = "icons/speaker-loud.svg";
tooltip = "Mute speakers";
}
@ -625,7 +625,7 @@ impl CollabTitlebarItem {
.into_any()
}
fn render_leave_call(&self, theme: &Theme, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
let icon = "icons/radix/exit.svg";
let icon = "icons/exit.svg";
let tooltip = "Leave call";
let titlebar = &theme.titlebar;
@ -748,7 +748,7 @@ impl CollabTitlebarItem {
dropdown
.with_child(
Svg::new("icons/caret_down_8.svg")
Svg::new("icons/caret_down.svg")
.with_color(user_menu_button_style.icon.color)
.constrained()
.with_width(user_menu_button_style.icon.width)
@ -1116,7 +1116,7 @@ impl CollabTitlebarItem {
| client::Status::Reauthenticating { .. }
| client::Status::Reconnecting { .. }
| client::Status::ReconnectionError { .. } => Some(
Svg::new("icons/cloud_slash_12.svg")
Svg::new("icons/disconnected.svg")
.with_color(theme.titlebar.offline_icon.color)
.constrained()
.with_width(theme.titlebar.offline_icon.width)

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

@ -53,7 +53,7 @@ where
.with_child(
MouseEventHandler::new::<Dismiss, _>(user.id as usize, cx, |state, _| {
let style = theme.dismiss_button.style_for(state);
Svg::new("icons/x_mark_8.svg")
Svg::new("icons/x.svg")
.with_color(style.color)
.constrained()
.with_width(style.icon_width)

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

@ -78,15 +78,15 @@ impl View for CopilotButton {
.with_child(
Svg::new({
match status {
Status::Error(_) => "icons/copilot_error_16.svg",
Status::Error(_) => "icons/copilot_error.svg",
Status::Authorized => {
if enabled {
"icons/copilot_16.svg"
"icons/copilot.svg"
} else {
"icons/copilot_disabled_16.svg"
"icons/copilot_disabled.svg"
}
}
_ => "icons/copilot_init_16.svg",
_ => "icons/copilot_init.svg",
}
})
.with_color(style.icon_color)

View file

@ -686,11 +686,9 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
let font_size = (style.text_scale_factor * settings.buffer_font_size(cx)).round();
let icon_width = cx.em_width * style.icon_width_factor;
let icon = if diagnostic.severity == DiagnosticSeverity::ERROR {
Svg::new("icons/circle_x_mark_12.svg")
.with_color(theme.error_diagnostic.message.text.color)
Svg::new("icons/error.svg").with_color(theme.error_diagnostic.message.text.color)
} else {
Svg::new("icons/triangle_exclamation_12.svg")
.with_color(theme.warning_diagnostic.message.text.color)
Svg::new("icons/warning.svg").with_color(theme.warning_diagnostic.message.text.color)
};
Flex::row()
@ -748,7 +746,7 @@ pub(crate) fn render_summary<T: 'static>(
let summary_spacing = theme.tab_summary_spacing;
Flex::row()
.with_child(
Svg::new("icons/circle_x_mark_12.svg")
Svg::new("icons/error.svg")
.with_color(text_style.color)
.constrained()
.with_width(icon_width)
@ -767,7 +765,7 @@ pub(crate) fn render_summary<T: 'static>(
.aligned(),
)
.with_child(
Svg::new("icons/triangle_exclamation_12.svg")
Svg::new("icons/warning.svg")
.with_color(text_style.color)
.constrained()
.with_width(icon_width)

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

@ -3827,7 +3827,7 @@ impl Editor {
enum CodeActions {}
Some(
MouseEventHandler::new::<CodeActions, _>(0, cx, |state, _| {
Svg::new("icons/bolt_8.svg").with_color(
Svg::new("icons/bolt.svg").with_color(
style
.code_actions
.indicator
@ -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

@ -1735,7 +1735,7 @@ impl EditorElement {
enum JumpIcon {}
MouseEventHandler::new::<JumpIcon, _>((*id).into(), cx, |state, _| {
let style = style.jump_icon.style_for(state);
Svg::new("icons/arrow_up_right_8.svg")
Svg::new("icons/arrow_up_right.svg")
.with_color(style.color)
.constrained()
.with_width(style.icon_width)

View file

@ -276,7 +276,7 @@ impl Item for FeedbackEditor {
) -> AnyElement<T> {
Flex::row()
.with_child(
Svg::new("icons/feedback_16.svg")
Svg::new("icons/feedback.svg")
.with_color(style.label.text.color)
.constrained()
.with_width(style.type_icon_width)

View file

@ -67,6 +67,7 @@ dhat = "0.3"
env_logger.workspace = true
png = "0.16"
simplelog = "0.9"
util = { path = "../util", features = ["test-support"] }
[target.'cfg(target_os = "macos")'.dependencies]
media = { path = "../media" }

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

@ -75,7 +75,6 @@ impl KeymapMatcher {
keystroke: Keystroke,
mut dispatch_path: Vec<(usize, KeymapContext)>,
) -> MatchResult {
let mut any_pending = false;
// Collect matched bindings into an ordered list using the position in the matching binding first,
// and then the order the binding matched in the view tree second.
// The key is the reverse position of the binding in the bindings list so that later bindings
@ -84,7 +83,8 @@ impl KeymapMatcher {
let no_action_id = (NoAction {}).id();
let first_keystroke = self.pending_keystrokes.is_empty();
self.pending_keystrokes.push(keystroke.clone());
let mut pending_key = None;
let mut previous_keystrokes = self.pending_keystrokes.clone();
self.contexts.clear();
self.contexts
@ -106,24 +106,32 @@ impl KeymapMatcher {
}
for binding in self.keymap.bindings().iter().rev() {
match binding.match_keys_and_context(&self.pending_keystrokes, &self.contexts[i..])
{
BindingMatchResult::Complete(action) => {
if action.id() != no_action_id {
matched_bindings.push((*view_id, action));
for possibility in keystroke.match_possibilities() {
previous_keystrokes.push(possibility.clone());
match binding.match_keys_and_context(&previous_keystrokes, &self.contexts[i..])
{
BindingMatchResult::Complete(action) => {
if action.id() != no_action_id {
matched_bindings.push((*view_id, action));
}
}
BindingMatchResult::Partial => {
if pending_key == None || pending_key == Some(possibility.clone()) {
self.pending_views
.insert(*view_id, self.contexts[i].clone());
pending_key = Some(possibility)
}
}
_ => {}
}
BindingMatchResult::Partial => {
self.pending_views
.insert(*view_id, self.contexts[i].clone());
any_pending = true;
}
_ => {}
previous_keystrokes.pop();
}
}
}
if !any_pending {
if pending_key.is_some() {
self.pending_keystrokes.push(pending_key.unwrap());
} else {
self.clear_pending();
}
@ -131,7 +139,7 @@ impl KeymapMatcher {
// Collect the sorted matched bindings into the final vec for ease of use
// Matched bindings are in order by precedence
MatchResult::Matches(matched_bindings)
} else if any_pending {
} else if !self.pending_keystrokes.is_empty() {
MatchResult::Pending
} else {
MatchResult::None
@ -340,6 +348,7 @@ mod tests {
shift: false,
cmd: false,
function: false,
ime_key: None,
}
);
@ -352,6 +361,7 @@ mod tests {
shift: true,
cmd: false,
function: false,
ime_key: None,
}
);
@ -364,6 +374,7 @@ mod tests {
shift: true,
cmd: true,
function: false,
ime_key: None,
}
);
@ -466,7 +477,7 @@ mod tests {
#[derive(Clone, Deserialize, PartialEq, Eq, Debug)]
pub struct A(pub String);
impl_actions!(test, [A]);
actions!(test, [B, Ab]);
actions!(test, [B, Ab, Dollar, Quote, Ess, Backtick]);
#[derive(Clone, Debug, Eq, PartialEq)]
struct ActionArg {
@ -477,6 +488,10 @@ mod tests {
Binding::new("a", A("x".to_string()), Some("a")),
Binding::new("b", B, Some("a")),
Binding::new("a b", Ab, Some("a || b")),
Binding::new("$", Dollar, Some("a")),
Binding::new("\"", Quote, Some("a")),
Binding::new("alt-s", Ess, Some("a")),
Binding::new("ctrl-`", Backtick, Some("a")),
]);
let mut context_a = KeymapContext::default();
@ -543,6 +558,30 @@ mod tests {
MatchResult::Matches(vec![(1, Box::new(Ab))])
);
// handle Czech $ (option + 4 key)
assert_eq!(
matcher.push_keystroke(Keystroke::parse("alt-ç->$")?, vec![(1, context_a.clone())]),
MatchResult::Matches(vec![(1, Box::new(Dollar))])
);
// handle Brazillian quote (quote key then space key)
assert_eq!(
matcher.push_keystroke(Keystroke::parse("space->\"")?, vec![(1, context_a.clone())]),
MatchResult::Matches(vec![(1, Box::new(Quote))])
);
// handle ctrl+` on a brazillian keyboard
assert_eq!(
matcher.push_keystroke(Keystroke::parse("ctrl-->`")?, vec![(1, context_a.clone())]),
MatchResult::Matches(vec![(1, Box::new(Backtick))])
);
// handle alt-s on a US keyboard
assert_eq!(
matcher.push_keystroke(Keystroke::parse("alt-s->ß")?, vec![(1, context_a.clone())]),
MatchResult::Matches(vec![(1, Box::new(Ess))])
);
Ok(())
}
}

View file

@ -162,7 +162,8 @@ mod tests {
shift: false,
cmd: false,
function: false,
key: "q".to_string()
key: "q".to_string(),
ime_key: None,
}],
"{keystroke_duplicate_to_1:?} should have the expected keystroke in the keymap"
);
@ -179,7 +180,8 @@ mod tests {
shift: false,
cmd: false,
function: false,
key: "w".to_string()
key: "w".to_string(),
ime_key: None,
},
&Keystroke {
ctrl: true,
@ -187,7 +189,8 @@ mod tests {
shift: false,
cmd: false,
function: false,
key: "w".to_string()
key: "w".to_string(),
ime_key: None,
}
],
"{full_duplicate_to_2:?} should have a duplicated keystroke in the keymap"
@ -339,7 +342,8 @@ mod tests {
shift: false,
cmd: false,
function: false,
key: expected_key.to_string()
key: expected_key.to_string(),
ime_key: None,
}],
"{expected:?} should have the expected keystroke with key '{expected_key}' in the keymap for element {i}"
);

View file

@ -2,6 +2,7 @@ use std::fmt::Write;
use anyhow::anyhow;
use serde::Deserialize;
use smallvec::SmallVec;
#[derive(Clone, Debug, Eq, PartialEq, Default, Deserialize, Hash)]
pub struct Keystroke {
@ -10,10 +11,47 @@ pub struct Keystroke {
pub shift: bool,
pub cmd: bool,
pub function: bool,
/// key is the character printed on the key that was pressed
/// e.g. for option-s, key is "s"
pub key: String,
/// ime_key is the character inserted by the IME engine when that key was pressed.
/// e.g. for option-s, ime_key is "ß"
pub ime_key: Option<String>,
}
impl Keystroke {
// When matching a key we cannot know whether the user intended to type
// the ime_key or the key. On some non-US keyboards keys we use in our
// bindings are behind option (for example `$` is typed `alt-ç` on a Czech keyboard),
// and on some keyboards the IME handler converts a sequence of keys into a
// specific character (for example `"` is typed as `" space` on a brazillian keyboard).
pub fn match_possibilities(&self) -> SmallVec<[Keystroke; 2]> {
let mut possibilities = SmallVec::new();
match self.ime_key.as_ref() {
None => possibilities.push(self.clone()),
Some(ime_key) => {
possibilities.push(Keystroke {
ctrl: self.ctrl,
alt: false,
shift: false,
cmd: false,
function: false,
key: ime_key.to_string(),
ime_key: None,
});
possibilities.push(Keystroke {
ime_key: None,
..self.clone()
});
}
}
possibilities
}
/// key syntax is:
/// [ctrl-][alt-][shift-][cmd-][fn-]key[->ime_key]
/// ime_key is only used for generating test events,
/// when matching a key with an ime_key set will be matched without it.
pub fn parse(source: &str) -> anyhow::Result<Self> {
let mut ctrl = false;
let mut alt = false;
@ -21,6 +59,7 @@ impl Keystroke {
let mut cmd = false;
let mut function = false;
let mut key = None;
let mut ime_key = None;
let mut components = source.split('-').peekable();
while let Some(component) = components.next() {
@ -31,10 +70,14 @@ impl Keystroke {
"cmd" => cmd = true,
"fn" => function = true,
_ => {
if let Some(component) = components.peek() {
if component.is_empty() && source.ends_with('-') {
if let Some(next) = components.peek() {
if next.is_empty() && source.ends_with('-') {
key = Some(String::from("-"));
break;
} else if next.len() > 1 && next.starts_with('>') {
key = Some(String::from(component));
ime_key = Some(String::from(&next[1..]));
components.next();
} else {
return Err(anyhow!("Invalid keystroke `{}`", source));
}
@ -54,6 +97,7 @@ impl Keystroke {
cmd,
function,
key,
ime_key,
})
}

View file

@ -327,6 +327,7 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke {
cmd,
function,
key,
ime_key: None,
}
}

View file

@ -285,6 +285,7 @@ enum ImeState {
None,
}
#[derive(Debug)]
struct InsertText {
replacement_range: Option<Range<usize>>,
text: String,
@ -1006,40 +1007,7 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent:
.flatten()
.is_some();
if !is_composing {
// if the IME has changed the key, we'll first emit an event with the character
// generated by the IME system; then fallback to the keystroke if that is not
// handled.
// cases that we have working:
// - " on a brazillian layout by typing <quote><space>
// - ctrl-` on a brazillian layout by typing <ctrl-`>
// - $ on a czech QWERTY layout by typing <alt-4>
// - 4 on a czech QWERTY layout by typing <shift-4>
// - ctrl-4 on a czech QWERTY layout by typing <ctrl-alt-4> (or <ctrl-shift-4>)
if ime_text.is_some() && ime_text.as_ref() != Some(&event.keystroke.key) {
let event_with_ime_text = KeyDownEvent {
is_held: false,
keystroke: Keystroke {
// we match ctrl because some use-cases need it.
// we don't match alt because it's often used to generate the optional character
// we don't match shift because we're not here with letters (usually)
// we don't match cmd/fn because they don't seem to use IME
ctrl: event.keystroke.ctrl,
alt: false,
shift: false,
cmd: false,
function: false,
key: ime_text.clone().unwrap(),
},
};
handled = callback(Event::KeyDown(event_with_ime_text));
}
if !handled {
// empty key happens when you type a deadkey in input composition.
// (e.g. on a brazillian keyboard typing quote is a deadkey)
if !event.keystroke.key.is_empty() {
handled = callback(Event::KeyDown(event));
}
}
handled = callback(Event::KeyDown(event));
}
if !handled {
@ -1197,6 +1165,7 @@ extern "C" fn cancel_operation(this: &Object, _sel: Sel, _sender: id) {
shift: false,
function: false,
key: ".".into(),
ime_key: None,
};
let event = Event::KeyDown(KeyDownEvent {
keystroke: keystroke.clone(),
@ -1479,6 +1448,9 @@ extern "C" fn insert_text(this: &Object, _: Sel, text: id, replacement_range: NS
replacement_range,
text: text.to_string(),
});
if text.to_string().to_ascii_lowercase() != pending_key_down.0.keystroke.key {
pending_key_down.0.keystroke.ime_key = Some(text.to_string());
}
window_state.borrow_mut().pending_key_down = Some(pending_key_down);
}
}

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

@ -94,7 +94,7 @@ impl View for QuickActionBar {
bar.add_child(render_quick_action_bar_button(
2,
"icons/radix/magic-wand.svg",
"icons/magic-wand.svg",
false,
("Inline Assist".into(), Some(Box::new(InlineAssist))),
cx,

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

@ -511,7 +511,7 @@ impl Item for ProjectSearchView {
) -> AnyElement<T> {
Flex::row()
.with_child(
Svg::new("icons/magnifying_glass_12.svg")
Svg::new("icons/magnifying_glass.svg")
.with_color(tab_theme.label.text.color)
.constrained()
.with_width(tab_theme.type_icon_width)
@ -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
}
},
}
}
@ -1440,7 +1450,7 @@ impl View for ProjectSearchBar {
let search = _search.read(cx);
let filter_button = render_option_button_icon(
search.filters_enabled,
"icons/filter_12.svg",
"icons/filter.svg",
0,
"Toggle filters",
Box::new(ToggleFilters),
@ -1471,14 +1481,14 @@ impl View for ProjectSearchBar {
};
let case_sensitive = is_semantic_disabled.then(|| {
render_option_button_icon(
"icons/case_insensitive_12.svg",
"icons/case_insensitive.svg",
SearchOptions::CASE_SENSITIVE,
cx,
)
});
let whole_word = is_semantic_disabled.then(|| {
render_option_button_icon("icons/word_search_12.svg", SearchOptions::WHOLE_WORD, cx)
render_option_button_icon("icons/word_search.svg", SearchOptions::WHOLE_WORD, cx)
});
let search_button_for_mode = |mode, side, cx: &mut ViewContext<ProjectSearchBar>| {

View file

@ -63,8 +63,8 @@ impl SearchOptions {
pub fn icon(&self) -> &'static str {
match *self {
SearchOptions::WHOLE_WORD => "icons/word_search_12.svg",
SearchOptions::CASE_SENSITIVE => "icons/case_insensitive_12.svg",
SearchOptions::WHOLE_WORD => "icons/word_search.svg",
SearchOptions::CASE_SENSITIVE => "icons/case_insensitive.svg",
_ => panic!("{:?} is not a named SearchOption", self),
}
}

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

@ -132,9 +132,9 @@ impl<V: 'static> CollabPanelElement<V> {
div().flex().h_full().gap_1().items_center().child(
svg()
.path(if expanded {
"icons/radix/caret-down.svg"
"icons/caret_down.svg"
} else {
"icons/radix/caret-up.svg"
"icons/caret_up.svg"
})
.w_3p5()
.h_3p5()

View file

@ -217,7 +217,7 @@ impl TitleBar {
.fill(theme.lowest.base.pressed.background)
.child(
svg()
.path("icons/radix/speaker-loud.svg")
.path("icons/speaker-loud.svg")
.size_3p5()
.fill(theme.lowest.base.default.foreground),
),
@ -237,7 +237,7 @@ impl TitleBar {
.fill(theme.lowest.base.pressed.background)
.child(
svg()
.path("icons/radix/desktop.svg")
.path("icons/desktop.svg")
.size_3p5()
.fill(theme.lowest.base.default.foreground),
),
@ -269,7 +269,7 @@ impl TitleBar {
)
.child(
svg()
.path("icons/caret_down_8.svg")
.path("icons/caret_down.svg")
.w_2()
.h_2()
.fill(theme.lowest.variant.default.foreground),

View file

@ -333,6 +333,7 @@ mod test {
cmd: false,
function: false,
key: "🖖🏻".to_string(), //2 char string
ime_key: None,
};
assert_eq!(to_esc_str(&ks, &TermMode::NONE, false), 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)]
@ -70,7 +71,7 @@ impl TerminalPanel {
Flex::row()
.with_child(Pane::render_tab_bar_button(
0,
"icons/plus_12.svg",
"icons/plus.svg",
false,
Some(("New Terminal", Some(Box::new(workspace::NewTerminal)))),
cx,
@ -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);
});
}
})
@ -90,9 +91,9 @@ impl TerminalPanel {
.with_child(Pane::render_tab_bar_button(
1,
if pane.is_zoomed() {
"icons/minimize_8.svg"
"icons/minimize.svg"
} else {
"icons/maximize_8.svg"
"icons/maximize.svg"
},
pane.is_zoomed(),
Some(("Toggle Zoom".into(), Some(Box::new(workspace::ToggleZoom)))),
@ -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

@ -292,7 +292,7 @@ pub mod simple_message_notification {
.with_child(
MouseEventHandler::new::<Cancel, _>(0, cx, |state, _| {
let style = theme.dismiss_button.style_for(state);
Svg::new("icons/x_mark_8.svg")
Svg::new("icons/x.svg")
.with_color(style.color)
.constrained()
.with_width(style.icon_width)

View file

@ -337,7 +337,7 @@ impl Pane {
// New menu
.with_child(Self::render_tab_bar_button(
0,
"icons/plus_12.svg",
"icons/plus.svg",
false,
Some(("New...".into(), None)),
cx,
@ -352,7 +352,7 @@ impl Pane {
))
.with_child(Self::render_tab_bar_button(
1,
"icons/split_12.svg",
"icons/split.svg",
false,
Some(("Split Pane".into(), None)),
cx,
@ -369,10 +369,10 @@ impl Pane {
let icon_path;
let tooltip_label;
if pane.is_zoomed() {
icon_path = "icons/minimize_8.svg";
icon_path = "icons/minimize.svg";
tooltip_label = "Zoom In";
} else {
icon_path = "icons/maximize_8.svg";
icon_path = "icons/maximize.svg";
tooltip_label = "Zoom In";
}
@ -1535,7 +1535,7 @@ impl Pane {
let close_element = if hovered {
let item_id = item.id();
enum TabCloseButton {}
let icon = Svg::new("icons/x_mark_8.svg");
let icon = Svg::new("icons/x.svg");
MouseEventHandler::new::<TabCloseButton, _>(item_id, cx, |mouse_state, _| {
if mouse_state.hovered() {
icon.with_color(tab_style.icon_close_active)
@ -1701,7 +1701,7 @@ impl View for Pane {
let mut tab_row = Flex::row()
.with_child(nav_button(
"icons/arrow_left_16.svg",
"icons/arrow_left.svg",
button_style.clone(),
nav_button_height,
tooltip_style.clone(),
@ -1726,7 +1726,7 @@ impl View for Pane {
))
.with_child(
nav_button(
"icons/arrow_right_16.svg",
"icons/arrow_right.svg",
button_style.clone(),
nav_button_height,
tooltip_style,

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