Merge branch 'main' into divs

This commit is contained in:
Nathan Sobo 2023-08-22 16:35:56 -06:00
commit d375f7992d
277 changed files with 19044 additions and 8896 deletions

View file

@ -318,7 +318,7 @@ impl View for ActivityIndicator {
on_click,
} = self.content_to_render(cx);
let mut element = MouseEventHandler::<Self, _>::new(0, cx, |state, cx| {
let mut element = MouseEventHandler::new::<Self, _>(0, cx, |state, cx| {
let theme = &theme::current(cx).workspace.status_bar.lsp_status;
let style = if state.hovered() && on_click.is_some() {
theme.hovered.as_ref().unwrap_or(&theme.default)

View file

@ -3,6 +3,7 @@ mod assistant_settings;
use anyhow::Result;
pub use assistant::AssistantPanel;
use assistant_settings::OpenAIModel;
use chrono::{DateTime, Local};
use collections::HashMap;
use fs::Fs;
@ -60,7 +61,7 @@ struct SavedConversation {
messages: Vec<SavedMessage>,
message_metadata: HashMap<MessageId, MessageMetadata>,
summary: String,
model: String,
model: OpenAIModel,
}
impl SavedConversation {

View file

@ -1,5 +1,5 @@
use crate::{
assistant_settings::{AssistantDockPosition, AssistantSettings},
assistant_settings::{AssistantDockPosition, AssistantSettings, OpenAIModel},
MessageId, MessageMetadata, MessageStatus, OpenAIRequest, OpenAIResponseStreamEvent,
RequestMessage, Role, SavedConversation, SavedConversationMetadata, SavedMessage,
};
@ -158,7 +158,7 @@ impl AssistantPanel {
});
let toolbar = cx.add_view(|cx| {
let mut toolbar = Toolbar::new(None);
let mut toolbar = Toolbar::new();
toolbar.set_can_navigate(false, cx);
toolbar.add_item(cx.add_view(|cx| BufferSearchBar::new(cx)), cx);
toolbar
@ -192,6 +192,7 @@ impl AssistantPanel {
old_dock_position = new_dock_position;
cx.emit(AssistantPanelEvent::DockPositionChanged);
}
cx.notify();
})];
this
@ -348,7 +349,7 @@ impl AssistantPanel {
enum History {}
let theme = theme::current(cx);
let tooltip_style = theme::current(cx).tooltip.clone();
MouseEventHandler::<History, _>::new(0, cx, |state, _| {
MouseEventHandler::new::<History, _>(0, cx, |state, _| {
let style = theme.assistant.hamburger_button.style_for(state);
Svg::for_style(style.icon.clone())
.contained()
@ -380,7 +381,7 @@ impl AssistantPanel {
fn render_split_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
let theme = theme::current(cx);
let tooltip_style = theme::current(cx).tooltip.clone();
MouseEventHandler::<Split, _>::new(0, cx, |state, _| {
MouseEventHandler::new::<Split, _>(0, cx, |state, _| {
let style = theme.assistant.split_button.style_for(state);
Svg::for_style(style.icon.clone())
.contained()
@ -404,7 +405,7 @@ impl AssistantPanel {
fn render_assist_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
let theme = theme::current(cx);
let tooltip_style = theme::current(cx).tooltip.clone();
MouseEventHandler::<Assist, _>::new(0, cx, |state, _| {
MouseEventHandler::new::<Assist, _>(0, cx, |state, _| {
let style = theme.assistant.assist_button.style_for(state);
Svg::for_style(style.icon.clone())
.contained()
@ -422,7 +423,7 @@ impl AssistantPanel {
fn render_quote_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
let theme = theme::current(cx);
let tooltip_style = theme::current(cx).tooltip.clone();
MouseEventHandler::<QuoteSelection, _>::new(0, cx, |state, _| {
MouseEventHandler::new::<QuoteSelection, _>(0, cx, |state, _| {
let style = theme.assistant.quote_button.style_for(state);
Svg::for_style(style.icon.clone())
.contained()
@ -450,7 +451,7 @@ impl AssistantPanel {
fn render_plus_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
let theme = theme::current(cx);
let tooltip_style = theme::current(cx).tooltip.clone();
MouseEventHandler::<NewConversation, _>::new(0, cx, |state, _| {
MouseEventHandler::new::<NewConversation, _>(0, cx, |state, _| {
let style = theme.assistant.plus_button.style_for(state);
Svg::for_style(style.icon.clone())
.contained()
@ -480,7 +481,7 @@ impl AssistantPanel {
&theme.assistant.zoom_in_button
};
MouseEventHandler::<ToggleZoomButton, _>::new(0, cx, |state, _| {
MouseEventHandler::new::<ToggleZoomButton, _>(0, cx, |state, _| {
let style = style.style_for(state);
Svg::for_style(style.icon.clone())
.contained()
@ -506,7 +507,7 @@ impl AssistantPanel {
) -> impl Element<Self> {
let conversation = &self.saved_conversations[index];
let path = conversation.path.clone();
MouseEventHandler::<SavedConversationMetadata, _>::new(index, cx, move |state, cx| {
MouseEventHandler::new::<SavedConversationMetadata, _>(index, cx, move |state, cx| {
let style = &theme::current(cx).assistant.saved_conversation;
Flex::row()
.with_child(
@ -725,10 +726,10 @@ impl Panel for AssistantPanel {
}
}
fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
match self.position(cx) {
DockPosition::Left | DockPosition::Right => self.width = Some(size),
DockPosition::Bottom => self.height = Some(size),
DockPosition::Left | DockPosition::Right => self.width = size,
DockPosition::Bottom => self.height = size,
}
cx.notify();
}
@ -780,8 +781,10 @@ impl Panel for AssistantPanel {
}
}
fn icon_path(&self) -> &'static str {
"icons/robot_14.svg"
fn icon_path(&self, cx: &WindowContext) -> Option<&'static str> {
settings::get::<AssistantSettings>(cx)
.button
.then(|| "icons/ai.svg")
}
fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
@ -830,7 +833,7 @@ struct Conversation {
pending_summary: Task<Option<()>>,
completion_count: usize,
pending_completions: Vec<PendingCompletion>,
model: String,
model: OpenAIModel,
token_count: Option<usize>,
max_token_count: usize,
pending_token_count: Task<Option<()>>,
@ -850,7 +853,6 @@ impl Conversation {
language_registry: Arc<LanguageRegistry>,
cx: &mut ModelContext<Self>,
) -> Self {
let model = "gpt-3.5-turbo-0613";
let markdown = language_registry.language_for_name("Markdown");
let buffer = cx.add_model(|cx| {
let mut buffer = Buffer::new(0, "", cx);
@ -869,6 +871,9 @@ impl Conversation {
buffer
});
let settings = settings::get::<AssistantSettings>(cx);
let model = settings.default_open_ai_model.clone();
let mut this = Self {
message_anchors: Default::default(),
messages_metadata: Default::default(),
@ -878,9 +883,9 @@ impl Conversation {
completion_count: Default::default(),
pending_completions: Default::default(),
token_count: None,
max_token_count: tiktoken_rs::model::get_context_size(model),
max_token_count: tiktoken_rs::model::get_context_size(&model.full_name()),
pending_token_count: Task::ready(None),
model: model.into(),
model: model.clone(),
_subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)],
pending_save: Task::ready(Ok(())),
path: None,
@ -974,7 +979,7 @@ impl Conversation {
completion_count: Default::default(),
pending_completions: Default::default(),
token_count: None,
max_token_count: tiktoken_rs::model::get_context_size(&model),
max_token_count: tiktoken_rs::model::get_context_size(&model.full_name()),
pending_token_count: Task::ready(None),
model,
_subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)],
@ -1028,13 +1033,16 @@ impl Conversation {
cx.background().timer(Duration::from_millis(200)).await;
let token_count = cx
.background()
.spawn(async move { tiktoken_rs::num_tokens_from_messages(&model, &messages) })
.spawn(async move {
tiktoken_rs::num_tokens_from_messages(&model.full_name(), &messages)
})
.await?;
this.upgrade(&cx)
.ok_or_else(|| anyhow!("conversation was dropped"))?
.update(&mut cx, |this, cx| {
this.max_token_count = tiktoken_rs::model::get_context_size(&this.model);
this.max_token_count =
tiktoken_rs::model::get_context_size(&this.model.full_name());
this.token_count = Some(token_count);
cx.notify()
});
@ -1048,7 +1056,7 @@ impl Conversation {
Some(self.max_token_count as isize - self.token_count? as isize)
}
fn set_model(&mut self, model: String, cx: &mut ModelContext<Self>) {
fn set_model(&mut self, model: OpenAIModel, cx: &mut ModelContext<Self>) {
self.model = model;
self.count_remaining_tokens(cx);
cx.notify();
@ -1090,7 +1098,7 @@ impl Conversation {
}
} else {
let request = OpenAIRequest {
model: self.model.clone(),
model: self.model.full_name().to_string(),
messages: self
.messages(cx)
.filter(|message| matches!(message.status, MessageStatus::Done))
@ -1416,7 +1424,7 @@ impl Conversation {
.into(),
}));
let request = OpenAIRequest {
model: self.model.clone(),
model: self.model.full_name().to_string(),
messages: messages.collect(),
stream: true,
};
@ -1818,7 +1826,7 @@ impl ConversationEditor {
let theme = theme::current(cx);
let style = &theme.assistant;
let message_id = message.id;
let sender = MouseEventHandler::<Sender, _>::new(
let sender = MouseEventHandler::new::<Sender, _>(
message_id.0,
cx,
|state, _| match message.role {
@ -2020,11 +2028,8 @@ impl ConversationEditor {
fn cycle_model(&mut self, cx: &mut ViewContext<Self>) {
self.conversation.update(cx, |conversation, cx| {
let new_model = match conversation.model.as_str() {
"gpt-4-0613" => "gpt-3.5-turbo-0613",
_ => "gpt-4-0613",
};
conversation.set_model(new_model.into(), cx);
let new_model = conversation.model.cycle();
conversation.set_model(new_model, cx);
});
}
@ -2044,9 +2049,10 @@ impl ConversationEditor {
) -> impl Element<Self> {
enum Model {}
MouseEventHandler::<Model, _>::new(0, cx, |state, cx| {
MouseEventHandler::new::<Model, _>(0, cx, |state, cx| {
let style = style.model.style_for(state);
Label::new(self.conversation.read(cx).model.clone(), style.text.clone())
let model_display_name = self.conversation.read(cx).model.short_name();
Label::new(model_display_name, style.text.clone())
.contained()
.with_style(style.container)
})
@ -2235,6 +2241,8 @@ mod tests {
#[gpui::test]
fn test_inserting_and_removing_messages(cx: &mut AppContext) {
cx.set_global(SettingsStore::test(cx));
init(cx);
let registry = Arc::new(LanguageRegistry::test());
let conversation = cx.add_model(|cx| Conversation::new(Default::default(), registry, cx));
let buffer = conversation.read(cx).buffer.clone();
@ -2361,6 +2369,8 @@ mod tests {
#[gpui::test]
fn test_message_splitting(cx: &mut AppContext) {
cx.set_global(SettingsStore::test(cx));
init(cx);
let registry = Arc::new(LanguageRegistry::test());
let conversation = cx.add_model(|cx| Conversation::new(Default::default(), registry, cx));
let buffer = conversation.read(cx).buffer.clone();
@ -2455,6 +2465,8 @@ mod tests {
#[gpui::test]
fn test_messages_for_offsets(cx: &mut AppContext) {
cx.set_global(SettingsStore::test(cx));
init(cx);
let registry = Arc::new(LanguageRegistry::test());
let conversation = cx.add_model(|cx| Conversation::new(Default::default(), registry, cx));
let buffer = conversation.read(cx).buffer.clone();
@ -2535,6 +2547,8 @@ mod tests {
#[gpui::test]
fn test_serialization(cx: &mut AppContext) {
cx.set_global(SettingsStore::test(cx));
init(cx);
let registry = Arc::new(LanguageRegistry::test());
let conversation =
cx.add_model(|cx| Conversation::new(Default::default(), registry.clone(), cx));

View file

@ -3,6 +3,37 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Setting;
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
pub enum OpenAIModel {
#[serde(rename = "gpt-3.5-turbo-0613")]
ThreePointFiveTurbo,
#[serde(rename = "gpt-4-0613")]
Four,
}
impl OpenAIModel {
pub fn full_name(&self) -> &'static str {
match self {
OpenAIModel::ThreePointFiveTurbo => "gpt-3.5-turbo-0613",
OpenAIModel::Four => "gpt-4-0613",
}
}
pub fn short_name(&self) -> &'static str {
match self {
OpenAIModel::ThreePointFiveTurbo => "gpt-3.5-turbo",
OpenAIModel::Four => "gpt-4",
}
}
pub fn cycle(&self) -> Self {
match self {
OpenAIModel::ThreePointFiveTurbo => OpenAIModel::Four,
OpenAIModel::Four => OpenAIModel::ThreePointFiveTurbo,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AssistantDockPosition {
@ -13,16 +44,20 @@ pub enum AssistantDockPosition {
#[derive(Deserialize, Debug)]
pub struct AssistantSettings {
pub button: bool,
pub dock: AssistantDockPosition,
pub default_width: f32,
pub default_height: f32,
pub default_open_ai_model: OpenAIModel,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
pub struct AssistantSettingsContent {
pub button: Option<bool>,
pub dock: Option<AssistantDockPosition>,
pub default_width: Option<f32>,
pub default_height: Option<f32>,
pub default_open_ai_model: Option<OpenAIModel>,
}
impl Setting for AssistantSettings {

View file

@ -13,7 +13,7 @@ gpui = { path = "../gpui" }
collections = { path = "../collections" }
util = { path = "../util" }
rodio = "0.17.1"
rodio ={version = "0.17.1", default-features=false, features = ["wav"]}
log.workspace = true

View file

@ -39,29 +39,43 @@ pub struct Audio {
impl Audio {
pub fn new() -> Self {
let (_output_stream, output_handle) = OutputStream::try_default().log_err().unzip();
Self {
_output_stream,
output_handle,
_output_stream: None,
output_handle: None,
}
}
pub fn play_sound(sound: Sound, cx: &AppContext) {
fn ensure_output_exists(&mut self) -> Option<&OutputStreamHandle> {
if self.output_handle.is_none() {
let (_output_stream, output_handle) = OutputStream::try_default().log_err().unzip();
self.output_handle = output_handle;
self._output_stream = _output_stream;
}
self.output_handle.as_ref()
}
pub fn play_sound(sound: Sound, cx: &mut AppContext) {
if !cx.has_global::<Self>() {
return;
}
let this = cx.global::<Self>();
cx.update_global::<Self, _, _>(|this, cx| {
let output_handle = this.ensure_output_exists()?;
let source = SoundRegistry::global(cx).get(sound.file()).log_err()?;
output_handle.play_raw(source).log_err()?;
Some(())
});
}
let Some(output_handle) = this.output_handle.as_ref() else {
pub fn end_call(cx: &mut AppContext) {
if !cx.has_global::<Self>() {
return;
};
}
let Some(source) = SoundRegistry::global(cx).get(sound.file()).log_err() else {
return;
};
output_handle.play_raw(source).log_err();
cx.update_global::<Self, _, _>(|this, _| {
this._output_stream.take();
this.output_handle.take();
});
}
}

View file

@ -31,7 +31,7 @@ impl View for UpdateNotification {
let app_name = cx.global::<ReleaseChannel>().display_name();
MouseEventHandler::<ViewReleaseNotes, _>::new(0, cx, |state, cx| {
MouseEventHandler::new::<ViewReleaseNotes, _>(0, cx, |state, cx| {
Flex::column()
.with_child(
Flex::row()
@ -48,7 +48,7 @@ impl View for UpdateNotification {
.flex(1., true),
)
.with_child(
MouseEventHandler::<Cancel, _>::new(0, cx, |state, _| {
MouseEventHandler::new::<Cancel, _>(0, cx, |state, _| {
let style = theme.dismiss_button.style_for(state);
Svg::new("icons/x_mark_8.svg")
.with_color(style.color)

View file

@ -82,7 +82,7 @@ impl View for Breadcrumbs {
.into_any();
}
MouseEventHandler::<Breadcrumbs, Breadcrumbs>::new(0, cx, |state, _| {
MouseEventHandler::new::<Breadcrumbs, _>(0, cx, |state, _| {
let style = style.style_for(state);
crumbs.with_style(style.container)
})

View file

@ -5,8 +5,11 @@ pub mod room;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use audio::Audio;
use call_settings::CallSettings;
use client::{proto, ClickhouseEvent, Client, TelemetrySettings, TypedEnvelope, User, UserStore};
use client::{
proto, ChannelId, ClickhouseEvent, Client, TelemetrySettings, TypedEnvelope, User, UserStore,
};
use collections::HashSet;
use futures::{future::Shared, FutureExt};
use postage::watch;
@ -75,6 +78,10 @@ impl ActiveCall {
}
}
pub fn channel_id(&self, cx: &AppContext) -> Option<ChannelId> {
self.room()?.read(cx).channel_id()
}
async fn handle_incoming_call(
this: ModelHandle<Self>,
envelope: TypedEnvelope<proto::IncomingCall>,
@ -267,16 +274,43 @@ impl ActiveCall {
.borrow_mut()
.take()
.ok_or_else(|| anyhow!("no incoming call"))?;
Self::report_call_event_for_room("decline incoming", call.room_id, &self.client, cx);
Self::report_call_event_for_room("decline incoming", call.room_id, None, &self.client, cx);
self.client.send(proto::DeclineCall {
room_id: call.room_id,
})?;
Ok(())
}
pub fn join_channel(
&mut self,
channel_id: u64,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
if let Some(room) = self.room().cloned() {
if room.read(cx).channel_id() == Some(channel_id) {
return Task::ready(Ok(()));
} else {
room.update(cx, |room, cx| room.clear_state(cx));
}
}
let join = Room::join_channel(channel_id, self.client.clone(), self.user_store.clone(), cx);
cx.spawn(|this, mut cx| async move {
let room = join.await?;
this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx))
.await?;
this.update(&mut cx, |this, cx| {
this.report_call_event("join channel", cx)
});
Ok(())
})
}
pub fn hang_up(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
cx.notify();
self.report_call_event("hang up", cx);
Audio::end_call(cx);
if let Some((room, _)) = self.room.take() {
room.update(cx, |room, cx| room.leave(cx))
} else {
@ -372,19 +406,31 @@ impl ActiveCall {
fn report_call_event(&self, operation: &'static str, cx: &AppContext) {
if let Some(room) = self.room() {
Self::report_call_event_for_room(operation, room.read(cx).id(), &self.client, cx)
let room = room.read(cx);
Self::report_call_event_for_room(
operation,
room.id(),
room.channel_id(),
&self.client,
cx,
)
}
}
pub fn report_call_event_for_room(
operation: &'static str,
room_id: u64,
channel_id: Option<u64>,
client: &Arc<Client>,
cx: &AppContext,
) {
let telemetry = client.telemetry();
let telemetry_settings = *settings::get::<TelemetrySettings>(cx);
let event = ClickhouseEvent::Call { operation, room_id };
let event = ClickhouseEvent::Call {
operation,
room_id,
channel_id,
};
telemetry.report_clickhouse_event(event, telemetry_settings);
}
}

View file

@ -49,6 +49,7 @@ pub enum Event {
pub struct Room {
id: u64,
channel_id: Option<u64>,
live_kit: Option<LiveKitRoom>,
status: RoomStatus,
shared_projects: HashSet<WeakModelHandle<Project>>,
@ -93,8 +94,25 @@ impl Entity for Room {
}
impl Room {
pub fn channel_id(&self) -> Option<u64> {
self.channel_id
}
#[cfg(any(test, feature = "test-support"))]
pub fn is_connected(&self) -> bool {
if let Some(live_kit) = self.live_kit.as_ref() {
matches!(
*live_kit.room.status().borrow(),
live_kit_client::ConnectionState::Connected { .. }
)
} else {
false
}
}
fn new(
id: u64,
channel_id: Option<u64>,
live_kit_connection_info: Option<proto::LiveKitConnectionInfo>,
client: Arc<Client>,
user_store: ModelHandle<UserStore>,
@ -185,6 +203,7 @@ impl Room {
Self {
id,
channel_id,
live_kit: live_kit_room,
status: RoomStatus::Online,
shared_projects: Default::default(),
@ -217,6 +236,7 @@ impl Room {
let room = cx.add_model(|cx| {
Self::new(
room_proto.id,
None,
response.live_kit_connection_info,
client,
user_store,
@ -248,35 +268,64 @@ impl Room {
})
}
pub(crate) fn join_channel(
channel_id: u64,
client: Arc<Client>,
user_store: ModelHandle<UserStore>,
cx: &mut AppContext,
) -> Task<Result<ModelHandle<Self>>> {
cx.spawn(|cx| async move {
Self::from_join_response(
client.request(proto::JoinChannel { channel_id }).await?,
client,
user_store,
cx,
)
})
}
pub(crate) fn join(
call: &IncomingCall,
client: Arc<Client>,
user_store: ModelHandle<UserStore>,
cx: &mut AppContext,
) -> Task<Result<ModelHandle<Self>>> {
let room_id = call.room_id;
cx.spawn(|mut cx| async move {
let response = client.request(proto::JoinRoom { id: room_id }).await?;
let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
let room = cx.add_model(|cx| {
Self::new(
room_id,
response.live_kit_connection_info,
client,
user_store,
cx,
)
});
room.update(&mut cx, |room, cx| {
room.leave_when_empty = true;
room.apply_room_update(room_proto, cx)?;
anyhow::Ok(())
})?;
Ok(room)
let id = call.room_id;
cx.spawn(|cx| async move {
Self::from_join_response(
client.request(proto::JoinRoom { id }).await?,
client,
user_store,
cx,
)
})
}
fn from_join_response(
response: proto::JoinRoomResponse,
client: Arc<Client>,
user_store: ModelHandle<UserStore>,
mut cx: AsyncAppContext,
) -> Result<ModelHandle<Self>> {
let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
let room = cx.add_model(|cx| {
Self::new(
room_proto.id,
response.channel_id,
response.live_kit_connection_info,
client,
user_store,
cx,
)
});
room.update(&mut cx, |room, cx| {
room.leave_when_empty = room.channel_id.is_none();
room.apply_room_update(room_proto, cx)?;
anyhow::Ok(())
})?;
Ok(room)
}
fn should_leave(&self) -> bool {
self.leave_when_empty
&& self.pending_room_update.is_none()
@ -297,7 +346,18 @@ impl Room {
}
log::info!("leaving room");
Audio::play_sound(Sound::Leave, cx);
self.clear_state(cx);
let leave_room = self.client.request(proto::LeaveRoom {});
cx.background().spawn(async move {
leave_room.await?;
anyhow::Ok(())
})
}
pub(crate) fn clear_state(&mut self, cx: &mut AppContext) {
for project in self.shared_projects.drain() {
if let Some(project) = project.upgrade(cx) {
project.update(cx, |project, cx| {
@ -314,8 +374,6 @@ impl Room {
}
}
Audio::play_sound(Sound::Leave, cx);
self.status = RoomStatus::Offline;
self.remote_participants.clear();
self.pending_participants.clear();
@ -324,12 +382,6 @@ impl Room {
self.live_kit.take();
self.pending_room_update.take();
self.maintain_connection.take();
let leave_room = self.client.request(proto::LeaveRoom {});
cx.background().spawn(async move {
leave_room.await?;
anyhow::Ok(())
})
}
async fn maintain_connection(
@ -1066,11 +1118,11 @@ impl Room {
})
}
pub fn is_muted(&self) -> bool {
pub fn is_muted(&self, cx: &AppContext) -> bool {
self.live_kit
.as_ref()
.and_then(|live_kit| match &live_kit.microphone_track {
LocalTrack::None => Some(true),
LocalTrack::None => Some(settings::get::<CallSettings>(cx).mute_on_join),
LocalTrack::Pending { muted, .. } => Some(*muted),
LocalTrack::Published { muted, .. } => Some(*muted),
})
@ -1260,7 +1312,7 @@ impl Room {
}
pub fn toggle_mute(&mut self, cx: &mut ModelContext<Self>) -> Result<Task<Result<()>>> {
let should_mute = !self.is_muted();
let should_mute = !self.is_muted(cx);
if let Some(live_kit) = self.live_kit.as_mut() {
if matches!(live_kit.microphone_track, LocalTrack::None) {
return Ok(self.share_microphone(cx));

View file

@ -0,0 +1,550 @@
use crate::Status;
use crate::{Client, Subscription, User, UserStore};
use anyhow::anyhow;
use anyhow::Result;
use collections::HashMap;
use collections::HashSet;
use futures::channel::mpsc;
use futures::Future;
use futures::StreamExt;
use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, Task};
use rpc::{proto, TypedEnvelope};
use std::sync::Arc;
use util::ResultExt;
pub type ChannelId = u64;
pub type UserId = u64;
pub struct ChannelStore {
channels_by_id: HashMap<ChannelId, Arc<Channel>>,
channel_paths: Vec<Vec<ChannelId>>,
channel_invitations: Vec<Arc<Channel>>,
channel_participants: HashMap<ChannelId, Vec<Arc<User>>>,
channels_with_admin_privileges: HashSet<ChannelId>,
outgoing_invites: HashSet<(ChannelId, UserId)>,
update_channels_tx: mpsc::UnboundedSender<proto::UpdateChannels>,
client: Arc<Client>,
user_store: ModelHandle<UserStore>,
_rpc_subscription: Subscription,
_watch_connection_status: Task<()>,
_update_channels: Task<()>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Channel {
pub id: ChannelId,
pub name: String,
}
pub struct ChannelMembership {
pub user: Arc<User>,
pub kind: proto::channel_member::Kind,
pub admin: bool,
}
pub enum ChannelEvent {
ChannelCreated(ChannelId),
ChannelRenamed(ChannelId),
}
impl Entity for ChannelStore {
type Event = ChannelEvent;
}
pub enum ChannelMemberStatus {
Invited,
Member,
NotMember,
}
impl ChannelStore {
pub fn new(
client: Arc<Client>,
user_store: ModelHandle<UserStore>,
cx: &mut ModelContext<Self>,
) -> Self {
let rpc_subscription =
client.add_message_handler(cx.handle(), Self::handle_update_channels);
let (update_channels_tx, mut update_channels_rx) = mpsc::unbounded();
let mut connection_status = client.status();
let watch_connection_status = cx.spawn_weak(|this, mut cx| async move {
while let Some(status) = connection_status.next().await {
if matches!(status, Status::ConnectionLost | Status::SignedOut) {
if let Some(this) = this.upgrade(&cx) {
this.update(&mut cx, |this, cx| {
this.channels_by_id.clear();
this.channel_invitations.clear();
this.channel_participants.clear();
this.channels_with_admin_privileges.clear();
this.channel_paths.clear();
this.outgoing_invites.clear();
cx.notify();
});
} else {
break;
}
}
}
});
Self {
channels_by_id: HashMap::default(),
channel_invitations: Vec::default(),
channel_paths: Vec::default(),
channel_participants: Default::default(),
channels_with_admin_privileges: Default::default(),
outgoing_invites: Default::default(),
update_channels_tx,
client,
user_store,
_rpc_subscription: rpc_subscription,
_watch_connection_status: watch_connection_status,
_update_channels: cx.spawn_weak(|this, mut cx| async move {
while let Some(update_channels) = update_channels_rx.next().await {
if let Some(this) = this.upgrade(&cx) {
let update_task = this.update(&mut cx, |this, cx| {
this.update_channels(update_channels, cx)
});
if let Some(update_task) = update_task {
update_task.await.log_err();
}
}
}
}),
}
}
pub fn channel_count(&self) -> usize {
self.channel_paths.len()
}
pub fn channels(&self) -> impl '_ + Iterator<Item = (usize, &Arc<Channel>)> {
self.channel_paths.iter().map(move |path| {
let id = path.last().unwrap();
let channel = self.channel_for_id(*id).unwrap();
(path.len() - 1, channel)
})
}
pub fn channel_at_index(&self, ix: usize) -> Option<(usize, &Arc<Channel>)> {
let path = self.channel_paths.get(ix)?;
let id = path.last().unwrap();
let channel = self.channel_for_id(*id).unwrap();
Some((path.len() - 1, channel))
}
pub fn channel_invitations(&self) -> &[Arc<Channel>] {
&self.channel_invitations
}
pub fn channel_for_id(&self, channel_id: ChannelId) -> Option<&Arc<Channel>> {
self.channels_by_id.get(&channel_id)
}
pub fn is_user_admin(&self, channel_id: ChannelId) -> bool {
self.channel_paths.iter().any(|path| {
if let Some(ix) = path.iter().position(|id| *id == channel_id) {
path[..=ix]
.iter()
.any(|id| self.channels_with_admin_privileges.contains(id))
} else {
false
}
})
}
pub fn channel_participants(&self, channel_id: ChannelId) -> &[Arc<User>] {
self.channel_participants
.get(&channel_id)
.map_or(&[], |v| v.as_slice())
}
pub fn create_channel(
&self,
name: &str,
parent_id: Option<ChannelId>,
cx: &mut ModelContext<Self>,
) -> Task<Result<ChannelId>> {
let client = self.client.clone();
let name = name.trim_start_matches("#").to_owned();
cx.spawn(|this, mut cx| async move {
let channel = client
.request(proto::CreateChannel { name, parent_id })
.await?
.channel
.ok_or_else(|| anyhow!("missing channel in response"))?;
let channel_id = channel.id;
this.update(&mut cx, |this, cx| {
let task = this.update_channels(
proto::UpdateChannels {
channels: vec![channel],
..Default::default()
},
cx,
);
assert!(task.is_none());
// This event is emitted because the collab panel wants to clear the pending edit state
// before this frame is rendered. But we can't guarantee that the collab panel's future
// will resolve before this flush_effects finishes. Synchronously emitting this event
// ensures that the collab panel will observe this creation before the frame completes
cx.emit(ChannelEvent::ChannelCreated(channel_id));
});
Ok(channel_id)
})
}
pub fn invite_member(
&mut self,
channel_id: ChannelId,
user_id: UserId,
admin: bool,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
if !self.outgoing_invites.insert((channel_id, user_id)) {
return Task::ready(Err(anyhow!("invite request already in progress")));
}
cx.notify();
let client = self.client.clone();
cx.spawn(|this, mut cx| async move {
let result = client
.request(proto::InviteChannelMember {
channel_id,
user_id,
admin,
})
.await;
this.update(&mut cx, |this, cx| {
this.outgoing_invites.remove(&(channel_id, user_id));
cx.notify();
});
result?;
Ok(())
})
}
pub fn remove_member(
&mut self,
channel_id: ChannelId,
user_id: u64,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
if !self.outgoing_invites.insert((channel_id, user_id)) {
return Task::ready(Err(anyhow!("invite request already in progress")));
}
cx.notify();
let client = self.client.clone();
cx.spawn(|this, mut cx| async move {
let result = client
.request(proto::RemoveChannelMember {
channel_id,
user_id,
})
.await;
this.update(&mut cx, |this, cx| {
this.outgoing_invites.remove(&(channel_id, user_id));
cx.notify();
});
result?;
Ok(())
})
}
pub fn set_member_admin(
&mut self,
channel_id: ChannelId,
user_id: UserId,
admin: bool,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
if !self.outgoing_invites.insert((channel_id, user_id)) {
return Task::ready(Err(anyhow!("member request already in progress")));
}
cx.notify();
let client = self.client.clone();
cx.spawn(|this, mut cx| async move {
let result = client
.request(proto::SetChannelMemberAdmin {
channel_id,
user_id,
admin,
})
.await;
this.update(&mut cx, |this, cx| {
this.outgoing_invites.remove(&(channel_id, user_id));
cx.notify();
});
result?;
Ok(())
})
}
pub fn rename(
&mut self,
channel_id: ChannelId,
new_name: &str,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
let client = self.client.clone();
let name = new_name.to_string();
cx.spawn(|this, mut cx| async move {
let channel = client
.request(proto::RenameChannel { channel_id, name })
.await?
.channel
.ok_or_else(|| anyhow!("missing channel in response"))?;
this.update(&mut cx, |this, cx| {
let task = this.update_channels(
proto::UpdateChannels {
channels: vec![channel],
..Default::default()
},
cx,
);
assert!(task.is_none());
// This event is emitted because the collab panel wants to clear the pending edit state
// before this frame is rendered. But we can't guarantee that the collab panel's future
// will resolve before this flush_effects finishes. Synchronously emitting this event
// ensures that the collab panel will observe this creation before the frame complete
cx.emit(ChannelEvent::ChannelRenamed(channel_id))
});
Ok(())
})
}
pub fn respond_to_channel_invite(
&mut self,
channel_id: ChannelId,
accept: bool,
) -> impl Future<Output = Result<()>> {
let client = self.client.clone();
async move {
client
.request(proto::RespondToChannelInvite { channel_id, accept })
.await?;
Ok(())
}
}
pub fn get_channel_member_details(
&self,
channel_id: ChannelId,
cx: &mut ModelContext<Self>,
) -> Task<Result<Vec<ChannelMembership>>> {
let client = self.client.clone();
let user_store = self.user_store.downgrade();
cx.spawn(|_, mut cx| async move {
let response = client
.request(proto::GetChannelMembers { channel_id })
.await?;
let user_ids = response.members.iter().map(|m| m.user_id).collect();
let user_store = user_store
.upgrade(&cx)
.ok_or_else(|| anyhow!("user store dropped"))?;
let users = user_store
.update(&mut cx, |user_store, cx| user_store.get_users(user_ids, cx))
.await?;
Ok(users
.into_iter()
.zip(response.members)
.filter_map(|(user, member)| {
Some(ChannelMembership {
user,
admin: member.admin,
kind: proto::channel_member::Kind::from_i32(member.kind)?,
})
})
.collect())
})
}
pub fn remove_channel(&self, channel_id: ChannelId) -> impl Future<Output = Result<()>> {
let client = self.client.clone();
async move {
client.request(proto::RemoveChannel { channel_id }).await?;
Ok(())
}
}
pub fn has_pending_channel_invite_response(&self, _: &Arc<Channel>) -> bool {
false
}
pub fn has_pending_channel_invite(&self, channel_id: ChannelId, user_id: UserId) -> bool {
self.outgoing_invites.contains(&(channel_id, user_id))
}
async fn handle_update_channels(
this: ModelHandle<Self>,
message: TypedEnvelope<proto::UpdateChannels>,
_: Arc<Client>,
mut cx: AsyncAppContext,
) -> Result<()> {
this.update(&mut cx, |this, _| {
this.update_channels_tx
.unbounded_send(message.payload)
.unwrap();
});
Ok(())
}
pub(crate) fn update_channels(
&mut self,
payload: proto::UpdateChannels,
cx: &mut ModelContext<ChannelStore>,
) -> Option<Task<Result<()>>> {
if !payload.remove_channel_invitations.is_empty() {
self.channel_invitations
.retain(|channel| !payload.remove_channel_invitations.contains(&channel.id));
}
for channel in payload.channel_invitations {
match self
.channel_invitations
.binary_search_by_key(&channel.id, |c| c.id)
{
Ok(ix) => Arc::make_mut(&mut self.channel_invitations[ix]).name = channel.name,
Err(ix) => self.channel_invitations.insert(
ix,
Arc::new(Channel {
id: channel.id,
name: channel.name,
}),
),
}
}
let channels_changed = !payload.channels.is_empty() || !payload.remove_channels.is_empty();
if channels_changed {
if !payload.remove_channels.is_empty() {
self.channels_by_id
.retain(|channel_id, _| !payload.remove_channels.contains(channel_id));
self.channel_participants
.retain(|channel_id, _| !payload.remove_channels.contains(channel_id));
self.channels_with_admin_privileges
.retain(|channel_id| !payload.remove_channels.contains(channel_id));
}
for channel in payload.channels {
if let Some(existing_channel) = self.channels_by_id.get_mut(&channel.id) {
// FIXME: We may be missing a path for this existing channel in certain cases
let existing_channel = Arc::make_mut(existing_channel);
existing_channel.name = channel.name;
continue;
}
self.channels_by_id.insert(
channel.id,
Arc::new(Channel {
id: channel.id,
name: channel.name,
}),
);
if let Some(parent_id) = channel.parent_id {
let mut ix = 0;
while ix < self.channel_paths.len() {
let path = &self.channel_paths[ix];
if path.ends_with(&[parent_id]) {
let mut new_path = path.clone();
new_path.push(channel.id);
self.channel_paths.insert(ix + 1, new_path);
ix += 1;
}
ix += 1;
}
} else {
self.channel_paths.push(vec![channel.id]);
}
}
self.channel_paths.sort_by(|a, b| {
let a = Self::channel_path_sorting_key(a, &self.channels_by_id);
let b = Self::channel_path_sorting_key(b, &self.channels_by_id);
a.cmp(b)
});
self.channel_paths.dedup();
self.channel_paths.retain(|path| {
path.iter()
.all(|channel_id| self.channels_by_id.contains_key(channel_id))
});
}
for permission in payload.channel_permissions {
if permission.is_admin {
self.channels_with_admin_privileges
.insert(permission.channel_id);
} else {
self.channels_with_admin_privileges
.remove(&permission.channel_id);
}
}
cx.notify();
if payload.channel_participants.is_empty() {
return None;
}
let mut all_user_ids = Vec::new();
let channel_participants = payload.channel_participants;
for entry in &channel_participants {
for user_id in entry.participant_user_ids.iter() {
if let Err(ix) = all_user_ids.binary_search(user_id) {
all_user_ids.insert(ix, *user_id);
}
}
}
let users = self
.user_store
.update(cx, |user_store, cx| user_store.get_users(all_user_ids, cx));
Some(cx.spawn(|this, mut cx| async move {
let users = users.await?;
this.update(&mut cx, |this, cx| {
for entry in &channel_participants {
let mut participants: Vec<_> = entry
.participant_user_ids
.iter()
.filter_map(|user_id| {
users
.binary_search_by_key(&user_id, |user| &user.id)
.ok()
.map(|ix| users[ix].clone())
})
.collect();
participants.sort_by_key(|u| u.id);
this.channel_participants
.insert(entry.channel_id, participants);
}
cx.notify();
});
anyhow::Ok(())
}))
}
fn channel_path_sorting_key<'a>(
path: &'a [ChannelId],
channels_by_id: &'a HashMap<ChannelId, Arc<Channel>>,
) -> impl 'a + Iterator<Item = Option<&'a str>> {
path.iter()
.map(|id| Some(channels_by_id.get(id)?.name.as_str()))
}
}

View file

@ -0,0 +1,165 @@
use super::*;
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));
update_channels(
&channel_store,
proto::UpdateChannels {
channels: vec![
proto::Channel {
id: 1,
name: "b".to_string(),
parent_id: None,
},
proto::Channel {
id: 2,
name: "a".to_string(),
parent_id: None,
},
],
channel_permissions: vec![proto::ChannelPermission {
channel_id: 1,
is_admin: true,
}],
..Default::default()
},
cx,
);
assert_channels(
&channel_store,
&[
//
(0, "a".to_string(), false),
(0, "b".to_string(), true),
],
cx,
);
update_channels(
&channel_store,
proto::UpdateChannels {
channels: vec![
proto::Channel {
id: 3,
name: "x".to_string(),
parent_id: Some(1),
},
proto::Channel {
id: 4,
name: "y".to_string(),
parent_id: Some(2),
},
],
..Default::default()
},
cx,
);
assert_channels(
&channel_store,
&[
(0, "a".to_string(), false),
(1, "y".to_string(), false),
(0, "b".to_string(), true),
(1, "x".to_string(), true),
],
cx,
);
}
#[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));
update_channels(
&channel_store,
proto::UpdateChannels {
channels: vec![
proto::Channel {
id: 0,
name: "a".to_string(),
parent_id: None,
},
proto::Channel {
id: 1,
name: "b".to_string(),
parent_id: Some(0),
},
proto::Channel {
id: 2,
name: "c".to_string(),
parent_id: Some(1),
},
],
channel_permissions: vec![proto::ChannelPermission {
channel_id: 0,
is_admin: true,
}],
..Default::default()
},
cx,
);
// Sanity check
assert_channels(
&channel_store,
&[
//
(0, "a".to_string(), true),
(1, "b".to_string(), true),
(2, "c".to_string(), true),
],
cx,
);
update_channels(
&channel_store,
proto::UpdateChannels {
remove_channels: vec![1, 2],
..Default::default()
},
cx,
);
// Make sure that the 1/2/3 path is gone
assert_channels(&channel_store, &[(0, "a".to_string(), true)], cx);
}
fn update_channels(
channel_store: &ModelHandle<ChannelStore>,
message: proto::UpdateChannels,
cx: &mut AppContext,
) {
let task = channel_store.update(cx, |store, cx| store.update_channels(message, cx));
assert!(task.is_none());
}
#[track_caller]
fn assert_channels(
channel_store: &ModelHandle<ChannelStore>,
expected_channels: &[(usize, String, bool)],
cx: &AppContext,
) {
let actual = channel_store.read_with(cx, |store, _| {
store
.channels()
.map(|(depth, channel)| {
(
depth,
channel.name.to_string(),
store.is_user_admin(channel.id),
)
})
.collect::<Vec<_>>()
});
assert_eq!(actual, expected_channels);
}

View file

@ -1,6 +1,10 @@
#[cfg(any(test, feature = "test-support"))]
pub mod test;
#[cfg(test)]
mod channel_store_tests;
pub mod channel_store;
pub mod telemetry;
pub mod user;
@ -44,6 +48,7 @@ use util::channel::ReleaseChannel;
use util::http::HttpClient;
use util::{ResultExt, TryFutureExt};
pub use channel_store::*;
pub use rpc::*;
pub use telemetry::ClickhouseEvent;
pub use user::*;
@ -535,6 +540,7 @@ impl Client {
}
}
#[track_caller]
pub fn add_message_handler<M, E, H, F>(
self: &Arc<Self>,
model: ModelHandle<E>,
@ -570,7 +576,13 @@ impl Client {
}),
);
if prev_handler.is_some() {
panic!("registered handler for the same message twice");
let location = std::panic::Location::caller();
panic!(
"{}:{} registered handler for the same message {} twice",
location.file(),
location.line(),
std::any::type_name::<M>()
);
}
Subscription::Message {

View file

@ -74,6 +74,7 @@ pub enum ClickhouseEvent {
Call {
operation: &'static str,
room_id: u64,
channel_id: Option<u64>,
},
}

View file

@ -165,17 +165,29 @@ impl UserStore {
});
current_user_tx.send(user).await.ok();
this.update(&mut cx, |_, cx| {
cx.notify();
});
}
}
Status::SignedOut => {
current_user_tx.send(None).await.ok();
if let Some(this) = this.upgrade(&cx) {
this.update(&mut cx, |this, _| this.clear_contacts()).await;
this.update(&mut cx, |this, cx| {
cx.notify();
this.clear_contacts()
})
.await;
}
}
Status::ConnectionLost => {
if let Some(this) = this.upgrade(&cx) {
this.update(&mut cx, |this, _| this.clear_contacts()).await;
this.update(&mut cx, |this, cx| {
cx.notify();
this.clear_contacts()
})
.await;
}
}
_ => {}

View file

@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathan@zed.dev>"]
default-run = "collab"
edition = "2021"
name = "collab"
version = "0.16.0"
version = "0.17.0"
publish = false
[[bin]]

View file

@ -36,7 +36,8 @@ CREATE INDEX "index_contacts_user_id_b" ON "contacts" ("user_id_b");
CREATE TABLE "rooms" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"live_kit_room" VARCHAR NOT NULL
"live_kit_room" VARCHAR NOT NULL,
"channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE
);
CREATE TABLE "projects" (
@ -184,3 +185,26 @@ CREATE UNIQUE INDEX
"index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id"
ON "followers" ("project_id", "leader_connection_server_id", "leader_connection_id", "follower_connection_server_id", "follower_connection_id");
CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id");
CREATE TABLE "channels" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"name" VARCHAR NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT now
);
CREATE TABLE "channel_paths" (
"id_path" TEXT NOT NULL PRIMARY KEY,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE
);
CREATE INDEX "index_channel_paths_on_channel_id" ON "channel_paths" ("channel_id");
CREATE TABLE "channel_members" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
"admin" BOOLEAN NOT NULL DEFAULT false,
"accepted" BOOLEAN NOT NULL DEFAULT false,
"updated_at" TIMESTAMP NOT NULL DEFAULT now
);
CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channel_members" ("channel_id", "user_id");

View file

@ -0,0 +1,30 @@
DROP TABLE "channel_messages";
DROP TABLE "channel_memberships";
DROP TABLE "org_memberships";
DROP TABLE "orgs";
DROP TABLE "channels";
CREATE TABLE "channels" (
"id" SERIAL PRIMARY KEY,
"name" VARCHAR NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT now()
);
CREATE TABLE "channel_paths" (
"id_path" VARCHAR NOT NULL PRIMARY KEY,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE
);
CREATE INDEX "index_channel_paths_on_channel_id" ON "channel_paths" ("channel_id");
CREATE TABLE "channel_members" (
"id" SERIAL PRIMARY KEY,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
"admin" BOOLEAN NOT NULL DEFAULT false,
"accepted" BOOLEAN NOT NULL DEFAULT false,
"updated_at" TIMESTAMP NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channel_members" ("channel_id", "user_id");
ALTER TABLE rooms ADD COLUMN "channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE;

View file

@ -64,9 +64,9 @@ async fn main() {
.expect("failed to fetch user")
.is_none()
{
if let Some(email) = &github_user.email {
if admin {
db.create_user(
email,
&format!("{}@zed.dev", github_user.login),
admin,
db::NewUserParams {
github_login: github_user.login,
@ -76,15 +76,11 @@ async fn main() {
)
.await
.expect("failed to insert user");
} else if admin {
db.create_user(
&format!("{}@zed.dev", github_user.login),
admin,
db::NewUserParams {
github_login: github_user.login,
github_user_id: github_user.id,
invite_count: 5,
},
} else {
db.get_or_create_user_by_github_account(
&github_user.login,
Some(github_user.id),
github_user.email.as_deref(),
)
.await
.expect("failed to insert user");

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,8 @@
use super::*;
use gpui::executor::{Background, Deterministic};
use std::sync::Arc;
#[cfg(test)]
use pretty_assertions::{assert_eq, assert_ne};
use std::sync::Arc;
use test_db::TestDb;
macro_rules! test_both_dbs {
($postgres_test_name:ident, $sqlite_test_name:ident, $db:ident, $body:block) => {
@ -879,6 +878,453 @@ async fn test_invite_codes() {
assert!(db.has_contact(user5, user1).await.unwrap());
}
test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, {
let a_id = db
.create_user(
"user1@example.com",
false,
NewUserParams {
github_login: "user1".into(),
github_user_id: 5,
invite_count: 0,
},
)
.await
.unwrap()
.user_id;
let b_id = db
.create_user(
"user2@example.com",
false,
NewUserParams {
github_login: "user2".into(),
github_user_id: 6,
invite_count: 0,
},
)
.await
.unwrap()
.user_id;
let zed_id = db.create_root_channel("zed", "1", a_id).await.unwrap();
// Make sure that people cannot read channels they haven't been invited to
assert!(db.get_channel(zed_id, b_id).await.unwrap().is_none());
db.invite_channel_member(zed_id, b_id, a_id, false)
.await
.unwrap();
db.respond_to_channel_invite(zed_id, b_id, true)
.await
.unwrap();
let crdb_id = db
.create_channel("crdb", Some(zed_id), "2", a_id)
.await
.unwrap();
let livestreaming_id = db
.create_channel("livestreaming", Some(zed_id), "3", a_id)
.await
.unwrap();
let replace_id = db
.create_channel("replace", Some(zed_id), "4", a_id)
.await
.unwrap();
let mut members = db.get_channel_members(replace_id).await.unwrap();
members.sort();
assert_eq!(members, &[a_id, b_id]);
let rust_id = db.create_root_channel("rust", "5", a_id).await.unwrap();
let cargo_id = db
.create_channel("cargo", Some(rust_id), "6", a_id)
.await
.unwrap();
let cargo_ra_id = db
.create_channel("cargo-ra", Some(cargo_id), "7", a_id)
.await
.unwrap();
let result = db.get_channels_for_user(a_id).await.unwrap();
assert_eq!(
result.channels,
vec![
Channel {
id: zed_id,
name: "zed".to_string(),
parent_id: None,
},
Channel {
id: crdb_id,
name: "crdb".to_string(),
parent_id: Some(zed_id),
},
Channel {
id: livestreaming_id,
name: "livestreaming".to_string(),
parent_id: Some(zed_id),
},
Channel {
id: replace_id,
name: "replace".to_string(),
parent_id: Some(zed_id),
},
Channel {
id: rust_id,
name: "rust".to_string(),
parent_id: None,
},
Channel {
id: cargo_id,
name: "cargo".to_string(),
parent_id: Some(rust_id),
},
Channel {
id: cargo_ra_id,
name: "cargo-ra".to_string(),
parent_id: Some(cargo_id),
}
]
);
let result = db.get_channels_for_user(b_id).await.unwrap();
assert_eq!(
result.channels,
vec![
Channel {
id: zed_id,
name: "zed".to_string(),
parent_id: None,
},
Channel {
id: crdb_id,
name: "crdb".to_string(),
parent_id: Some(zed_id),
},
Channel {
id: livestreaming_id,
name: "livestreaming".to_string(),
parent_id: Some(zed_id),
},
Channel {
id: replace_id,
name: "replace".to_string(),
parent_id: Some(zed_id),
},
]
);
// Update member permissions
let set_subchannel_admin = db.set_channel_member_admin(crdb_id, a_id, b_id, true).await;
assert!(set_subchannel_admin.is_err());
let set_channel_admin = db.set_channel_member_admin(zed_id, a_id, b_id, true).await;
assert!(set_channel_admin.is_ok());
let result = db.get_channels_for_user(b_id).await.unwrap();
assert_eq!(
result.channels,
vec![
Channel {
id: zed_id,
name: "zed".to_string(),
parent_id: None,
},
Channel {
id: crdb_id,
name: "crdb".to_string(),
parent_id: Some(zed_id),
},
Channel {
id: livestreaming_id,
name: "livestreaming".to_string(),
parent_id: Some(zed_id),
},
Channel {
id: replace_id,
name: "replace".to_string(),
parent_id: Some(zed_id),
},
]
);
// Remove a single channel
db.remove_channel(crdb_id, a_id).await.unwrap();
assert!(db.get_channel(crdb_id, a_id).await.unwrap().is_none());
// Remove a channel tree
let (mut channel_ids, user_ids) = db.remove_channel(rust_id, a_id).await.unwrap();
channel_ids.sort();
assert_eq!(channel_ids, &[rust_id, cargo_id, cargo_ra_id]);
assert_eq!(user_ids, &[a_id]);
assert!(db.get_channel(rust_id, a_id).await.unwrap().is_none());
assert!(db.get_channel(cargo_id, a_id).await.unwrap().is_none());
assert!(db.get_channel(cargo_ra_id, a_id).await.unwrap().is_none());
});
test_both_dbs!(
test_joining_channels_postgres,
test_joining_channels_sqlite,
db,
{
let owner_id = db.create_server("test").await.unwrap().0 as u32;
let user_1 = db
.create_user(
"user1@example.com",
false,
NewUserParams {
github_login: "user1".into(),
github_user_id: 5,
invite_count: 0,
},
)
.await
.unwrap()
.user_id;
let user_2 = db
.create_user(
"user2@example.com",
false,
NewUserParams {
github_login: "user2".into(),
github_user_id: 6,
invite_count: 0,
},
)
.await
.unwrap()
.user_id;
let channel_1 = db
.create_root_channel("channel_1", "1", user_1)
.await
.unwrap();
let room_1 = db.room_id_for_channel(channel_1).await.unwrap();
// can join a room with membership to its channel
let joined_room = db
.join_room(room_1, user_1, ConnectionId { owner_id, id: 1 })
.await
.unwrap();
assert_eq!(joined_room.room.participants.len(), 1);
drop(joined_room);
// cannot join a room without membership to its channel
assert!(db
.join_room(room_1, user_2, ConnectionId { owner_id, id: 1 })
.await
.is_err());
}
);
test_both_dbs!(
test_channel_invites_postgres,
test_channel_invites_sqlite,
db,
{
db.create_server("test").await.unwrap();
let user_1 = db
.create_user(
"user1@example.com",
false,
NewUserParams {
github_login: "user1".into(),
github_user_id: 5,
invite_count: 0,
},
)
.await
.unwrap()
.user_id;
let user_2 = db
.create_user(
"user2@example.com",
false,
NewUserParams {
github_login: "user2".into(),
github_user_id: 6,
invite_count: 0,
},
)
.await
.unwrap()
.user_id;
let user_3 = db
.create_user(
"user3@example.com",
false,
NewUserParams {
github_login: "user3".into(),
github_user_id: 7,
invite_count: 0,
},
)
.await
.unwrap()
.user_id;
let channel_1_1 = db
.create_root_channel("channel_1", "1", user_1)
.await
.unwrap();
let channel_1_2 = db
.create_root_channel("channel_2", "2", user_1)
.await
.unwrap();
db.invite_channel_member(channel_1_1, user_2, user_1, false)
.await
.unwrap();
db.invite_channel_member(channel_1_2, user_2, user_1, false)
.await
.unwrap();
db.invite_channel_member(channel_1_1, user_3, user_1, true)
.await
.unwrap();
let user_2_invites = db
.get_channel_invites_for_user(user_2) // -> [channel_1_1, channel_1_2]
.await
.unwrap()
.into_iter()
.map(|channel| channel.id)
.collect::<Vec<_>>();
assert_eq!(user_2_invites, &[channel_1_1, channel_1_2]);
let user_3_invites = db
.get_channel_invites_for_user(user_3) // -> [channel_1_1]
.await
.unwrap()
.into_iter()
.map(|channel| channel.id)
.collect::<Vec<_>>();
assert_eq!(user_3_invites, &[channel_1_1]);
let members = db
.get_channel_member_details(channel_1_1, user_1)
.await
.unwrap();
assert_eq!(
members,
&[
proto::ChannelMember {
user_id: user_1.to_proto(),
kind: proto::channel_member::Kind::Member.into(),
admin: true,
},
proto::ChannelMember {
user_id: user_2.to_proto(),
kind: proto::channel_member::Kind::Invitee.into(),
admin: false,
},
proto::ChannelMember {
user_id: user_3.to_proto(),
kind: proto::channel_member::Kind::Invitee.into(),
admin: true,
},
]
);
db.respond_to_channel_invite(channel_1_1, user_2, true)
.await
.unwrap();
let channel_1_3 = db
.create_channel("channel_3", Some(channel_1_1), "1", user_1)
.await
.unwrap();
let members = db
.get_channel_member_details(channel_1_3, user_1)
.await
.unwrap();
assert_eq!(
members,
&[
proto::ChannelMember {
user_id: user_1.to_proto(),
kind: proto::channel_member::Kind::Member.into(),
admin: true,
},
proto::ChannelMember {
user_id: user_2.to_proto(),
kind: proto::channel_member::Kind::AncestorMember.into(),
admin: false,
},
]
);
}
);
test_both_dbs!(
test_channel_renames_postgres,
test_channel_renames_sqlite,
db,
{
db.create_server("test").await.unwrap();
let user_1 = db
.create_user(
"user1@example.com",
false,
NewUserParams {
github_login: "user1".into(),
github_user_id: 5,
invite_count: 0,
},
)
.await
.unwrap()
.user_id;
let user_2 = db
.create_user(
"user2@example.com",
false,
NewUserParams {
github_login: "user2".into(),
github_user_id: 6,
invite_count: 0,
},
)
.await
.unwrap()
.user_id;
let zed_id = db.create_root_channel("zed", "1", user_1).await.unwrap();
db.rename_channel(zed_id, user_1, "#zed-archive")
.await
.unwrap();
let zed_archive_id = zed_id;
let (channel, _) = db
.get_channel(zed_archive_id, user_1)
.await
.unwrap()
.unwrap();
assert_eq!(channel.name, "zed-archive");
let non_permissioned_rename = db
.rename_channel(zed_archive_id, user_2, "hacked-lol")
.await;
assert!(non_permissioned_rename.is_err());
let bad_name_rename = db.rename_channel(zed_id, user_1, "#").await;
assert!(bad_name_rename.is_err())
}
);
#[gpui::test]
async fn test_multiple_signup_overwrite() {
let test_db = TestDb::postgres(build_background_executor());

125
crates/collab/src/db/ids.rs Normal file
View file

@ -0,0 +1,125 @@
use crate::Result;
use sea_orm::DbErr;
use sea_query::{Value, ValueTypeErr};
use serde::{Deserialize, Serialize};
macro_rules! id_type {
($name:ident) => {
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,
)]
#[serde(transparent)]
pub struct $name(pub i32);
impl $name {
#[allow(unused)]
pub const MAX: Self = Self(i32::MAX);
#[allow(unused)]
pub fn from_proto(value: u64) -> Self {
Self(value as i32)
}
#[allow(unused)]
pub fn to_proto(self) -> u64 {
self.0 as u64
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl From<$name> for sea_query::Value {
fn from(value: $name) -> Self {
sea_query::Value::Int(Some(value.0))
}
}
impl sea_orm::TryGetable for $name {
fn try_get(
res: &sea_orm::QueryResult,
pre: &str,
col: &str,
) -> Result<Self, sea_orm::TryGetError> {
Ok(Self(i32::try_get(res, pre, col)?))
}
}
impl sea_query::ValueType for $name {
fn try_from(v: Value) -> Result<Self, sea_query::ValueTypeErr> {
Ok(Self(value_to_integer(v)?))
}
fn type_name() -> String {
stringify!($name).into()
}
fn array_type() -> sea_query::ArrayType {
sea_query::ArrayType::Int
}
fn column_type() -> sea_query::ColumnType {
sea_query::ColumnType::Integer(None)
}
}
impl sea_orm::TryFromU64 for $name {
fn try_from_u64(n: u64) -> Result<Self, DbErr> {
Ok(Self(n.try_into().map_err(|_| {
DbErr::ConvertFromU64(concat!(
"error converting ",
stringify!($name),
" to u64"
))
})?))
}
}
impl sea_query::Nullable for $name {
fn null() -> Value {
Value::Int(None)
}
}
};
}
fn value_to_integer(v: Value) -> Result<i32, ValueTypeErr> {
match v {
Value::TinyInt(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
Value::SmallInt(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
Value::Int(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
Value::BigInt(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
Value::TinyUnsigned(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
Value::SmallUnsigned(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
Value::Unsigned(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
Value::BigUnsigned(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
_ => Err(ValueTypeErr),
}
}
id_type!(AccessTokenId);
id_type!(ChannelId);
id_type!(ChannelMemberId);
id_type!(ContactId);
id_type!(FollowerId);
id_type!(RoomId);
id_type!(RoomParticipantId);
id_type!(ProjectId);
id_type!(ProjectCollaboratorId);
id_type!(ReplicaId);
id_type!(ServerId);
id_type!(SignupId);
id_type!(UserId);

View file

@ -0,0 +1,10 @@
use super::*;
pub mod access_tokens;
pub mod channels;
pub mod contacts;
pub mod projects;
pub mod rooms;
pub mod servers;
pub mod signups;
pub mod users;

View file

@ -0,0 +1,53 @@
use super::*;
impl Database {
pub async fn create_access_token(
&self,
user_id: UserId,
access_token_hash: &str,
max_access_token_count: usize,
) -> Result<AccessTokenId> {
self.transaction(|tx| async {
let tx = tx;
let token = access_token::ActiveModel {
user_id: ActiveValue::set(user_id),
hash: ActiveValue::set(access_token_hash.into()),
..Default::default()
}
.insert(&*tx)
.await?;
access_token::Entity::delete_many()
.filter(
access_token::Column::Id.in_subquery(
Query::select()
.column(access_token::Column::Id)
.from(access_token::Entity)
.and_where(access_token::Column::UserId.eq(user_id))
.order_by(access_token::Column::Id, sea_orm::Order::Desc)
.limit(10000)
.offset(max_access_token_count as u64)
.to_owned(),
),
)
.exec(&*tx)
.await?;
Ok(token.id)
})
.await
}
pub async fn get_access_token(
&self,
access_token_id: AccessTokenId,
) -> Result<access_token::Model> {
self.transaction(|tx| async move {
Ok(access_token::Entity::find_by_id(access_token_id)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such access token"))?)
})
.await
}
}

View file

@ -0,0 +1,697 @@
use super::*;
impl Database {
pub async fn create_root_channel(
&self,
name: &str,
live_kit_room: &str,
creator_id: UserId,
) -> Result<ChannelId> {
self.create_channel(name, None, live_kit_room, creator_id)
.await
}
pub async fn create_channel(
&self,
name: &str,
parent: Option<ChannelId>,
live_kit_room: &str,
creator_id: UserId,
) -> Result<ChannelId> {
let name = Self::sanitize_channel_name(name)?;
self.transaction(move |tx| async move {
if let Some(parent) = parent {
self.check_user_is_channel_admin(parent, creator_id, &*tx)
.await?;
}
let channel = channel::ActiveModel {
name: ActiveValue::Set(name.to_string()),
..Default::default()
}
.insert(&*tx)
.await?;
let channel_paths_stmt;
if let Some(parent) = parent {
let sql = r#"
INSERT INTO channel_paths
(id_path, channel_id)
SELECT
id_path || $1 || '/', $2
FROM
channel_paths
WHERE
channel_id = $3
"#;
channel_paths_stmt = Statement::from_sql_and_values(
self.pool.get_database_backend(),
sql,
[
channel.id.to_proto().into(),
channel.id.to_proto().into(),
parent.to_proto().into(),
],
);
tx.execute(channel_paths_stmt).await?;
} else {
channel_path::Entity::insert(channel_path::ActiveModel {
channel_id: ActiveValue::Set(channel.id),
id_path: ActiveValue::Set(format!("/{}/", channel.id)),
})
.exec(&*tx)
.await?;
}
channel_member::ActiveModel {
channel_id: ActiveValue::Set(channel.id),
user_id: ActiveValue::Set(creator_id),
accepted: ActiveValue::Set(true),
admin: ActiveValue::Set(true),
..Default::default()
}
.insert(&*tx)
.await?;
room::ActiveModel {
channel_id: ActiveValue::Set(Some(channel.id)),
live_kit_room: ActiveValue::Set(live_kit_room.to_string()),
..Default::default()
}
.insert(&*tx)
.await?;
Ok(channel.id)
})
.await
}
pub async fn remove_channel(
&self,
channel_id: ChannelId,
user_id: UserId,
) -> Result<(Vec<ChannelId>, Vec<UserId>)> {
self.transaction(move |tx| async move {
self.check_user_is_channel_admin(channel_id, user_id, &*tx)
.await?;
// Don't remove descendant channels that have additional parents.
let mut channels_to_remove = self.get_channel_descendants([channel_id], &*tx).await?;
{
let mut channels_to_keep = channel_path::Entity::find()
.filter(
channel_path::Column::ChannelId
.is_in(
channels_to_remove
.keys()
.copied()
.filter(|&id| id != channel_id),
)
.and(
channel_path::Column::IdPath
.not_like(&format!("%/{}/%", channel_id)),
),
)
.stream(&*tx)
.await?;
while let Some(row) = channels_to_keep.next().await {
let row = row?;
channels_to_remove.remove(&row.channel_id);
}
}
let channel_ancestors = self.get_channel_ancestors(channel_id, &*tx).await?;
let members_to_notify: Vec<UserId> = channel_member::Entity::find()
.filter(channel_member::Column::ChannelId.is_in(channel_ancestors))
.select_only()
.column(channel_member::Column::UserId)
.distinct()
.into_values::<_, QueryUserIds>()
.all(&*tx)
.await?;
channel::Entity::delete_many()
.filter(channel::Column::Id.is_in(channels_to_remove.keys().copied()))
.exec(&*tx)
.await?;
Ok((channels_to_remove.into_keys().collect(), members_to_notify))
})
.await
}
pub async fn invite_channel_member(
&self,
channel_id: ChannelId,
invitee_id: UserId,
inviter_id: UserId,
is_admin: bool,
) -> Result<()> {
self.transaction(move |tx| async move {
self.check_user_is_channel_admin(channel_id, inviter_id, &*tx)
.await?;
channel_member::ActiveModel {
channel_id: ActiveValue::Set(channel_id),
user_id: ActiveValue::Set(invitee_id),
accepted: ActiveValue::Set(false),
admin: ActiveValue::Set(is_admin),
..Default::default()
}
.insert(&*tx)
.await?;
Ok(())
})
.await
}
fn sanitize_channel_name(name: &str) -> Result<&str> {
let new_name = name.trim().trim_start_matches('#');
if new_name == "" {
Err(anyhow!("channel name can't be blank"))?;
}
Ok(new_name)
}
pub async fn rename_channel(
&self,
channel_id: ChannelId,
user_id: UserId,
new_name: &str,
) -> Result<String> {
self.transaction(move |tx| async move {
let new_name = Self::sanitize_channel_name(new_name)?.to_string();
self.check_user_is_channel_admin(channel_id, user_id, &*tx)
.await?;
channel::ActiveModel {
id: ActiveValue::Unchanged(channel_id),
name: ActiveValue::Set(new_name.clone()),
..Default::default()
}
.update(&*tx)
.await?;
Ok(new_name)
})
.await
}
pub async fn respond_to_channel_invite(
&self,
channel_id: ChannelId,
user_id: UserId,
accept: bool,
) -> Result<()> {
self.transaction(move |tx| async move {
let rows_affected = if accept {
channel_member::Entity::update_many()
.set(channel_member::ActiveModel {
accepted: ActiveValue::Set(accept),
..Default::default()
})
.filter(
channel_member::Column::ChannelId
.eq(channel_id)
.and(channel_member::Column::UserId.eq(user_id))
.and(channel_member::Column::Accepted.eq(false)),
)
.exec(&*tx)
.await?
.rows_affected
} else {
channel_member::ActiveModel {
channel_id: ActiveValue::Unchanged(channel_id),
user_id: ActiveValue::Unchanged(user_id),
..Default::default()
}
.delete(&*tx)
.await?
.rows_affected
};
if rows_affected == 0 {
Err(anyhow!("no such invitation"))?;
}
Ok(())
})
.await
}
pub async fn remove_channel_member(
&self,
channel_id: ChannelId,
member_id: UserId,
remover_id: UserId,
) -> Result<()> {
self.transaction(|tx| async move {
self.check_user_is_channel_admin(channel_id, remover_id, &*tx)
.await?;
let result = channel_member::Entity::delete_many()
.filter(
channel_member::Column::ChannelId
.eq(channel_id)
.and(channel_member::Column::UserId.eq(member_id)),
)
.exec(&*tx)
.await?;
if result.rows_affected == 0 {
Err(anyhow!("no such member"))?;
}
Ok(())
})
.await
}
pub async fn get_channel_invites_for_user(&self, user_id: UserId) -> Result<Vec<Channel>> {
self.transaction(|tx| async move {
let channel_invites = channel_member::Entity::find()
.filter(
channel_member::Column::UserId
.eq(user_id)
.and(channel_member::Column::Accepted.eq(false)),
)
.all(&*tx)
.await?;
let channels = channel::Entity::find()
.filter(
channel::Column::Id.is_in(
channel_invites
.into_iter()
.map(|channel_member| channel_member.channel_id),
),
)
.all(&*tx)
.await?;
let channels = channels
.into_iter()
.map(|channel| Channel {
id: channel.id,
name: channel.name,
parent_id: None,
})
.collect();
Ok(channels)
})
.await
}
pub async fn get_channels_for_user(&self, user_id: UserId) -> Result<ChannelsForUser> {
self.transaction(|tx| async move {
let tx = tx;
let channel_memberships = channel_member::Entity::find()
.filter(
channel_member::Column::UserId
.eq(user_id)
.and(channel_member::Column::Accepted.eq(true)),
)
.all(&*tx)
.await?;
let parents_by_child_id = self
.get_channel_descendants(channel_memberships.iter().map(|m| m.channel_id), &*tx)
.await?;
let channels_with_admin_privileges = channel_memberships
.iter()
.filter_map(|membership| membership.admin.then_some(membership.channel_id))
.collect();
let mut channels = Vec::with_capacity(parents_by_child_id.len());
{
let mut rows = channel::Entity::find()
.filter(channel::Column::Id.is_in(parents_by_child_id.keys().copied()))
.stream(&*tx)
.await?;
while let Some(row) = rows.next().await {
let row = row?;
channels.push(Channel {
id: row.id,
name: row.name,
parent_id: parents_by_child_id.get(&row.id).copied().flatten(),
});
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryUserIdsAndChannelIds {
ChannelId,
UserId,
}
let mut channel_participants: HashMap<ChannelId, Vec<UserId>> = HashMap::default();
{
let mut rows = room_participant::Entity::find()
.inner_join(room::Entity)
.filter(room::Column::ChannelId.is_in(channels.iter().map(|c| c.id)))
.select_only()
.column(room::Column::ChannelId)
.column(room_participant::Column::UserId)
.into_values::<_, QueryUserIdsAndChannelIds>()
.stream(&*tx)
.await?;
while let Some(row) = rows.next().await {
let row: (ChannelId, UserId) = row?;
channel_participants.entry(row.0).or_default().push(row.1)
}
}
Ok(ChannelsForUser {
channels,
channel_participants,
channels_with_admin_privileges,
})
})
.await
}
pub async fn get_channel_members(&self, id: ChannelId) -> Result<Vec<UserId>> {
self.transaction(|tx| async move { self.get_channel_members_internal(id, &*tx).await })
.await
}
pub async fn set_channel_member_admin(
&self,
channel_id: ChannelId,
from: UserId,
for_user: UserId,
admin: bool,
) -> Result<()> {
self.transaction(|tx| async move {
self.check_user_is_channel_admin(channel_id, from, &*tx)
.await?;
let result = channel_member::Entity::update_many()
.filter(
channel_member::Column::ChannelId
.eq(channel_id)
.and(channel_member::Column::UserId.eq(for_user)),
)
.set(channel_member::ActiveModel {
admin: ActiveValue::set(admin),
..Default::default()
})
.exec(&*tx)
.await?;
if result.rows_affected == 0 {
Err(anyhow!("no such member"))?;
}
Ok(())
})
.await
}
pub async fn get_channel_member_details(
&self,
channel_id: ChannelId,
user_id: UserId,
) -> Result<Vec<proto::ChannelMember>> {
self.transaction(|tx| async move {
self.check_user_is_channel_admin(channel_id, user_id, &*tx)
.await?;
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryMemberDetails {
UserId,
Admin,
IsDirectMember,
Accepted,
}
let tx = tx;
let ancestor_ids = self.get_channel_ancestors(channel_id, &*tx).await?;
let mut stream = channel_member::Entity::find()
.distinct()
.filter(channel_member::Column::ChannelId.is_in(ancestor_ids.iter().copied()))
.select_only()
.column(channel_member::Column::UserId)
.column(channel_member::Column::Admin)
.column_as(
channel_member::Column::ChannelId.eq(channel_id),
QueryMemberDetails::IsDirectMember,
)
.column(channel_member::Column::Accepted)
.order_by_asc(channel_member::Column::UserId)
.into_values::<_, QueryMemberDetails>()
.stream(&*tx)
.await?;
let mut rows = Vec::<proto::ChannelMember>::new();
while let Some(row) = stream.next().await {
let (user_id, is_admin, is_direct_member, is_invite_accepted): (
UserId,
bool,
bool,
bool,
) = row?;
let kind = match (is_direct_member, is_invite_accepted) {
(true, true) => proto::channel_member::Kind::Member,
(true, false) => proto::channel_member::Kind::Invitee,
(false, true) => proto::channel_member::Kind::AncestorMember,
(false, false) => continue,
};
let user_id = user_id.to_proto();
let kind = kind.into();
if let Some(last_row) = rows.last_mut() {
if last_row.user_id == user_id {
if is_direct_member {
last_row.kind = kind;
last_row.admin = is_admin;
}
continue;
}
}
rows.push(proto::ChannelMember {
user_id,
kind,
admin: is_admin,
});
}
Ok(rows)
})
.await
}
pub async fn get_channel_members_internal(
&self,
id: ChannelId,
tx: &DatabaseTransaction,
) -> Result<Vec<UserId>> {
let ancestor_ids = self.get_channel_ancestors(id, tx).await?;
let user_ids = channel_member::Entity::find()
.distinct()
.filter(
channel_member::Column::ChannelId
.is_in(ancestor_ids.iter().copied())
.and(channel_member::Column::Accepted.eq(true)),
)
.select_only()
.column(channel_member::Column::UserId)
.into_values::<_, QueryUserIds>()
.all(&*tx)
.await?;
Ok(user_ids)
}
pub async fn check_user_is_channel_member(
&self,
channel_id: ChannelId,
user_id: UserId,
tx: &DatabaseTransaction,
) -> Result<()> {
let channel_ids = self.get_channel_ancestors(channel_id, tx).await?;
channel_member::Entity::find()
.filter(
channel_member::Column::ChannelId
.is_in(channel_ids)
.and(channel_member::Column::UserId.eq(user_id)),
)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("user is not a channel member or channel does not exist"))?;
Ok(())
}
pub async fn check_user_is_channel_admin(
&self,
channel_id: ChannelId,
user_id: UserId,
tx: &DatabaseTransaction,
) -> Result<()> {
let channel_ids = self.get_channel_ancestors(channel_id, tx).await?;
channel_member::Entity::find()
.filter(
channel_member::Column::ChannelId
.is_in(channel_ids)
.and(channel_member::Column::UserId.eq(user_id))
.and(channel_member::Column::Admin.eq(true)),
)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("user is not a channel admin or channel does not exist"))?;
Ok(())
}
pub async fn get_channel_ancestors(
&self,
channel_id: ChannelId,
tx: &DatabaseTransaction,
) -> Result<Vec<ChannelId>> {
let paths = channel_path::Entity::find()
.filter(channel_path::Column::ChannelId.eq(channel_id))
.all(tx)
.await?;
let mut channel_ids = Vec::new();
for path in paths {
for id in path.id_path.trim_matches('/').split('/') {
if let Ok(id) = id.parse() {
let id = ChannelId::from_proto(id);
if let Err(ix) = channel_ids.binary_search(&id) {
channel_ids.insert(ix, id);
}
}
}
}
Ok(channel_ids)
}
async fn get_channel_descendants(
&self,
channel_ids: impl IntoIterator<Item = ChannelId>,
tx: &DatabaseTransaction,
) -> Result<HashMap<ChannelId, Option<ChannelId>>> {
let mut values = String::new();
for id in channel_ids {
if !values.is_empty() {
values.push_str(", ");
}
write!(&mut values, "({})", id).unwrap();
}
if values.is_empty() {
return Ok(HashMap::default());
}
let sql = format!(
r#"
SELECT
descendant_paths.*
FROM
channel_paths parent_paths, channel_paths descendant_paths
WHERE
parent_paths.channel_id IN ({values}) AND
descendant_paths.id_path LIKE (parent_paths.id_path || '%')
"#
);
let stmt = Statement::from_string(self.pool.get_database_backend(), sql);
let mut parents_by_child_id = HashMap::default();
let mut paths = channel_path::Entity::find()
.from_raw_sql(stmt)
.stream(tx)
.await?;
while let Some(path) = paths.next().await {
let path = path?;
let ids = path.id_path.trim_matches('/').split('/');
let mut parent_id = None;
for id in ids {
if let Ok(id) = id.parse() {
let id = ChannelId::from_proto(id);
if id == path.channel_id {
break;
}
parent_id = Some(id);
}
}
parents_by_child_id.insert(path.channel_id, parent_id);
}
Ok(parents_by_child_id)
}
/// Returns the channel with the given ID and:
/// - true if the user is a member
/// - false if the user hasn't accepted the invitation yet
pub async fn get_channel(
&self,
channel_id: ChannelId,
user_id: UserId,
) -> Result<Option<(Channel, bool)>> {
self.transaction(|tx| async move {
let tx = tx;
let channel = channel::Entity::find_by_id(channel_id).one(&*tx).await?;
if let Some(channel) = channel {
if self
.check_user_is_channel_member(channel_id, user_id, &*tx)
.await
.is_err()
{
return Ok(None);
}
let channel_membership = channel_member::Entity::find()
.filter(
channel_member::Column::ChannelId
.eq(channel_id)
.and(channel_member::Column::UserId.eq(user_id)),
)
.one(&*tx)
.await?;
let is_accepted = channel_membership
.map(|membership| membership.accepted)
.unwrap_or(false);
Ok(Some((
Channel {
id: channel.id,
name: channel.name,
parent_id: None,
},
is_accepted,
)))
} else {
Ok(None)
}
})
.await
}
pub async fn room_id_for_channel(&self, channel_id: ChannelId) -> Result<RoomId> {
self.transaction(|tx| async move {
let tx = tx;
let room = channel::Model {
id: channel_id,
..Default::default()
}
.find_related(room::Entity)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("invalid channel"))?;
Ok(room.id)
})
.await
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryUserIds {
UserId,
}

View file

@ -0,0 +1,298 @@
use super::*;
impl Database {
pub async fn get_contacts(&self, user_id: UserId) -> Result<Vec<Contact>> {
#[derive(Debug, FromQueryResult)]
struct ContactWithUserBusyStatuses {
user_id_a: UserId,
user_id_b: UserId,
a_to_b: bool,
accepted: bool,
should_notify: bool,
user_a_busy: bool,
user_b_busy: bool,
}
self.transaction(|tx| async move {
let user_a_participant = Alias::new("user_a_participant");
let user_b_participant = Alias::new("user_b_participant");
let mut db_contacts = contact::Entity::find()
.column_as(
Expr::tbl(user_a_participant.clone(), room_participant::Column::Id)
.is_not_null(),
"user_a_busy",
)
.column_as(
Expr::tbl(user_b_participant.clone(), room_participant::Column::Id)
.is_not_null(),
"user_b_busy",
)
.filter(
contact::Column::UserIdA
.eq(user_id)
.or(contact::Column::UserIdB.eq(user_id)),
)
.join_as(
JoinType::LeftJoin,
contact::Relation::UserARoomParticipant.def(),
user_a_participant,
)
.join_as(
JoinType::LeftJoin,
contact::Relation::UserBRoomParticipant.def(),
user_b_participant,
)
.into_model::<ContactWithUserBusyStatuses>()
.stream(&*tx)
.await?;
let mut contacts = Vec::new();
while let Some(db_contact) = db_contacts.next().await {
let db_contact = db_contact?;
if db_contact.user_id_a == user_id {
if db_contact.accepted {
contacts.push(Contact::Accepted {
user_id: db_contact.user_id_b,
should_notify: db_contact.should_notify && db_contact.a_to_b,
busy: db_contact.user_b_busy,
});
} else if db_contact.a_to_b {
contacts.push(Contact::Outgoing {
user_id: db_contact.user_id_b,
})
} else {
contacts.push(Contact::Incoming {
user_id: db_contact.user_id_b,
should_notify: db_contact.should_notify,
});
}
} else if db_contact.accepted {
contacts.push(Contact::Accepted {
user_id: db_contact.user_id_a,
should_notify: db_contact.should_notify && !db_contact.a_to_b,
busy: db_contact.user_a_busy,
});
} else if db_contact.a_to_b {
contacts.push(Contact::Incoming {
user_id: db_contact.user_id_a,
should_notify: db_contact.should_notify,
});
} else {
contacts.push(Contact::Outgoing {
user_id: db_contact.user_id_a,
});
}
}
contacts.sort_unstable_by_key(|contact| contact.user_id());
Ok(contacts)
})
.await
}
pub async fn is_user_busy(&self, user_id: UserId) -> Result<bool> {
self.transaction(|tx| async move {
let participant = room_participant::Entity::find()
.filter(room_participant::Column::UserId.eq(user_id))
.one(&*tx)
.await?;
Ok(participant.is_some())
})
.await
}
pub async fn has_contact(&self, user_id_1: UserId, user_id_2: UserId) -> Result<bool> {
self.transaction(|tx| async move {
let (id_a, id_b) = if user_id_1 < user_id_2 {
(user_id_1, user_id_2)
} else {
(user_id_2, user_id_1)
};
Ok(contact::Entity::find()
.filter(
contact::Column::UserIdA
.eq(id_a)
.and(contact::Column::UserIdB.eq(id_b))
.and(contact::Column::Accepted.eq(true)),
)
.one(&*tx)
.await?
.is_some())
})
.await
}
pub async fn send_contact_request(&self, sender_id: UserId, receiver_id: UserId) -> Result<()> {
self.transaction(|tx| async move {
let (id_a, id_b, a_to_b) = if sender_id < receiver_id {
(sender_id, receiver_id, true)
} else {
(receiver_id, sender_id, false)
};
let rows_affected = contact::Entity::insert(contact::ActiveModel {
user_id_a: ActiveValue::set(id_a),
user_id_b: ActiveValue::set(id_b),
a_to_b: ActiveValue::set(a_to_b),
accepted: ActiveValue::set(false),
should_notify: ActiveValue::set(true),
..Default::default()
})
.on_conflict(
OnConflict::columns([contact::Column::UserIdA, contact::Column::UserIdB])
.values([
(contact::Column::Accepted, true.into()),
(contact::Column::ShouldNotify, false.into()),
])
.action_and_where(
contact::Column::Accepted.eq(false).and(
contact::Column::AToB
.eq(a_to_b)
.and(contact::Column::UserIdA.eq(id_b))
.or(contact::Column::AToB
.ne(a_to_b)
.and(contact::Column::UserIdA.eq(id_a))),
),
)
.to_owned(),
)
.exec_without_returning(&*tx)
.await?;
if rows_affected == 1 {
Ok(())
} else {
Err(anyhow!("contact already requested"))?
}
})
.await
}
/// Returns a bool indicating whether the removed contact had originally accepted or not
///
/// Deletes the contact identified by the requester and responder ids, and then returns
/// whether the deleted contact had originally accepted or was a pending contact request.
///
/// # Arguments
///
/// * `requester_id` - The user that initiates this request
/// * `responder_id` - The user that will be removed
pub async fn remove_contact(&self, requester_id: UserId, responder_id: UserId) -> Result<bool> {
self.transaction(|tx| async move {
let (id_a, id_b) = if responder_id < requester_id {
(responder_id, requester_id)
} else {
(requester_id, responder_id)
};
let contact = contact::Entity::find()
.filter(
contact::Column::UserIdA
.eq(id_a)
.and(contact::Column::UserIdB.eq(id_b)),
)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such contact"))?;
contact::Entity::delete_by_id(contact.id).exec(&*tx).await?;
Ok(contact.accepted)
})
.await
}
pub async fn dismiss_contact_notification(
&self,
user_id: UserId,
contact_user_id: UserId,
) -> Result<()> {
self.transaction(|tx| async move {
let (id_a, id_b, a_to_b) = if user_id < contact_user_id {
(user_id, contact_user_id, true)
} else {
(contact_user_id, user_id, false)
};
let result = contact::Entity::update_many()
.set(contact::ActiveModel {
should_notify: ActiveValue::set(false),
..Default::default()
})
.filter(
contact::Column::UserIdA
.eq(id_a)
.and(contact::Column::UserIdB.eq(id_b))
.and(
contact::Column::AToB
.eq(a_to_b)
.and(contact::Column::Accepted.eq(true))
.or(contact::Column::AToB
.ne(a_to_b)
.and(contact::Column::Accepted.eq(false))),
),
)
.exec(&*tx)
.await?;
if result.rows_affected == 0 {
Err(anyhow!("no such contact request"))?
} else {
Ok(())
}
})
.await
}
pub async fn respond_to_contact_request(
&self,
responder_id: UserId,
requester_id: UserId,
accept: bool,
) -> Result<()> {
self.transaction(|tx| async move {
let (id_a, id_b, a_to_b) = if responder_id < requester_id {
(responder_id, requester_id, false)
} else {
(requester_id, responder_id, true)
};
let rows_affected = if accept {
let result = contact::Entity::update_many()
.set(contact::ActiveModel {
accepted: ActiveValue::set(true),
should_notify: ActiveValue::set(true),
..Default::default()
})
.filter(
contact::Column::UserIdA
.eq(id_a)
.and(contact::Column::UserIdB.eq(id_b))
.and(contact::Column::AToB.eq(a_to_b)),
)
.exec(&*tx)
.await?;
result.rows_affected
} else {
let result = contact::Entity::delete_many()
.filter(
contact::Column::UserIdA
.eq(id_a)
.and(contact::Column::UserIdB.eq(id_b))
.and(contact::Column::AToB.eq(a_to_b))
.and(contact::Column::Accepted.eq(false)),
)
.exec(&*tx)
.await?;
result.rows_affected
};
if rows_affected == 1 {
Ok(())
} else {
Err(anyhow!("no such contact request"))?
}
})
.await
}
}

View file

@ -0,0 +1,926 @@
use super::*;
impl Database {
pub async fn project_count_excluding_admins(&self) -> Result<usize> {
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryAs {
Count,
}
self.transaction(|tx| async move {
Ok(project::Entity::find()
.select_only()
.column_as(project::Column::Id.count(), QueryAs::Count)
.inner_join(user::Entity)
.filter(user::Column::Admin.eq(false))
.into_values::<_, QueryAs>()
.one(&*tx)
.await?
.unwrap_or(0i64) as usize)
})
.await
}
pub async fn share_project(
&self,
room_id: RoomId,
connection: ConnectionId,
worktrees: &[proto::WorktreeMetadata],
) -> Result<RoomGuard<(ProjectId, proto::Room)>> {
self.room_transaction(room_id, |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)
.await?
.ok_or_else(|| anyhow!("could not find participant"))?;
if participant.room_id != room_id {
return Err(anyhow!("shared project on unexpected room"))?;
}
let project = project::ActiveModel {
room_id: ActiveValue::set(participant.room_id),
host_user_id: ActiveValue::set(participant.user_id),
host_connection_id: ActiveValue::set(Some(connection.id as i32)),
host_connection_server_id: ActiveValue::set(Some(ServerId(
connection.owner_id as i32,
))),
..Default::default()
}
.insert(&*tx)
.await?;
if !worktrees.is_empty() {
worktree::Entity::insert_many(worktrees.iter().map(|worktree| {
worktree::ActiveModel {
id: ActiveValue::set(worktree.id as i64),
project_id: ActiveValue::set(project.id),
abs_path: ActiveValue::set(worktree.abs_path.clone()),
root_name: ActiveValue::set(worktree.root_name.clone()),
visible: ActiveValue::set(worktree.visible),
scan_id: ActiveValue::set(0),
completed_scan_id: ActiveValue::set(0),
}
}))
.exec(&*tx)
.await?;
}
project_collaborator::ActiveModel {
project_id: ActiveValue::set(project.id),
connection_id: ActiveValue::set(connection.id as i32),
connection_server_id: ActiveValue::set(ServerId(connection.owner_id as i32)),
user_id: ActiveValue::set(participant.user_id),
replica_id: ActiveValue::set(ReplicaId(0)),
is_host: ActiveValue::set(true),
..Default::default()
}
.insert(&*tx)
.await?;
let room = self.get_room(room_id, &tx).await?;
Ok((project.id, room))
})
.await
}
pub async fn unshare_project(
&self,
project_id: ProjectId,
connection: ConnectionId,
) -> Result<RoomGuard<(proto::Room, Vec<ConnectionId>)>> {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let guest_connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
let project = project::Entity::find_by_id(project_id)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("project not found"))?;
if project.host_connection()? == connection {
project::Entity::delete(project.into_active_model())
.exec(&*tx)
.await?;
let room = self.get_room(room_id, &tx).await?;
Ok((room, guest_connection_ids))
} else {
Err(anyhow!("cannot unshare a project hosted by another user"))?
}
})
.await
}
pub async fn update_project(
&self,
project_id: ProjectId,
connection: ConnectionId,
worktrees: &[proto::WorktreeMetadata],
) -> Result<RoomGuard<(proto::Room, Vec<ConnectionId>)>> {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let project = project::Entity::find_by_id(project_id)
.filter(
Condition::all()
.add(project::Column::HostConnectionId.eq(connection.id as i32))
.add(
project::Column::HostConnectionServerId.eq(connection.owner_id as i32),
),
)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such project"))?;
self.update_project_worktrees(project.id, worktrees, &tx)
.await?;
let guest_connection_ids = self.project_guest_connection_ids(project.id, &tx).await?;
let room = self.get_room(project.room_id, &tx).await?;
Ok((room, guest_connection_ids))
})
.await
}
pub(in crate::db) async fn update_project_worktrees(
&self,
project_id: ProjectId,
worktrees: &[proto::WorktreeMetadata],
tx: &DatabaseTransaction,
) -> Result<()> {
if !worktrees.is_empty() {
worktree::Entity::insert_many(worktrees.iter().map(|worktree| worktree::ActiveModel {
id: ActiveValue::set(worktree.id as i64),
project_id: ActiveValue::set(project_id),
abs_path: ActiveValue::set(worktree.abs_path.clone()),
root_name: ActiveValue::set(worktree.root_name.clone()),
visible: ActiveValue::set(worktree.visible),
scan_id: ActiveValue::set(0),
completed_scan_id: ActiveValue::set(0),
}))
.on_conflict(
OnConflict::columns([worktree::Column::ProjectId, worktree::Column::Id])
.update_column(worktree::Column::RootName)
.to_owned(),
)
.exec(&*tx)
.await?;
}
worktree::Entity::delete_many()
.filter(worktree::Column::ProjectId.eq(project_id).and(
worktree::Column::Id.is_not_in(worktrees.iter().map(|worktree| worktree.id as i64)),
))
.exec(&*tx)
.await?;
Ok(())
}
pub async fn update_worktree(
&self,
update: &proto::UpdateWorktree,
connection: ConnectionId,
) -> Result<RoomGuard<Vec<ConnectionId>>> {
let project_id = ProjectId::from_proto(update.project_id);
let worktree_id = update.worktree_id as i64;
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
// Ensure the update comes from the host.
let _project = project::Entity::find_by_id(project_id)
.filter(
Condition::all()
.add(project::Column::HostConnectionId.eq(connection.id as i32))
.add(
project::Column::HostConnectionServerId.eq(connection.owner_id as i32),
),
)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such project"))?;
// Update metadata.
worktree::Entity::update(worktree::ActiveModel {
id: ActiveValue::set(worktree_id),
project_id: ActiveValue::set(project_id),
root_name: ActiveValue::set(update.root_name.clone()),
scan_id: ActiveValue::set(update.scan_id as i64),
completed_scan_id: if update.is_last_update {
ActiveValue::set(update.scan_id as i64)
} else {
ActiveValue::default()
},
abs_path: ActiveValue::set(update.abs_path.clone()),
..Default::default()
})
.exec(&*tx)
.await?;
if !update.updated_entries.is_empty() {
worktree_entry::Entity::insert_many(update.updated_entries.iter().map(|entry| {
let mtime = entry.mtime.clone().unwrap_or_default();
worktree_entry::ActiveModel {
project_id: ActiveValue::set(project_id),
worktree_id: ActiveValue::set(worktree_id),
id: ActiveValue::set(entry.id as i64),
is_dir: ActiveValue::set(entry.is_dir),
path: ActiveValue::set(entry.path.clone()),
inode: ActiveValue::set(entry.inode as i64),
mtime_seconds: ActiveValue::set(mtime.seconds as i64),
mtime_nanos: ActiveValue::set(mtime.nanos as i32),
is_symlink: ActiveValue::set(entry.is_symlink),
is_ignored: ActiveValue::set(entry.is_ignored),
is_external: ActiveValue::set(entry.is_external),
git_status: ActiveValue::set(entry.git_status.map(|status| status as i64)),
is_deleted: ActiveValue::set(false),
scan_id: ActiveValue::set(update.scan_id as i64),
}
}))
.on_conflict(
OnConflict::columns([
worktree_entry::Column::ProjectId,
worktree_entry::Column::WorktreeId,
worktree_entry::Column::Id,
])
.update_columns([
worktree_entry::Column::IsDir,
worktree_entry::Column::Path,
worktree_entry::Column::Inode,
worktree_entry::Column::MtimeSeconds,
worktree_entry::Column::MtimeNanos,
worktree_entry::Column::IsSymlink,
worktree_entry::Column::IsIgnored,
worktree_entry::Column::GitStatus,
worktree_entry::Column::ScanId,
])
.to_owned(),
)
.exec(&*tx)
.await?;
}
if !update.removed_entries.is_empty() {
worktree_entry::Entity::update_many()
.filter(
worktree_entry::Column::ProjectId
.eq(project_id)
.and(worktree_entry::Column::WorktreeId.eq(worktree_id))
.and(
worktree_entry::Column::Id
.is_in(update.removed_entries.iter().map(|id| *id as i64)),
),
)
.set(worktree_entry::ActiveModel {
is_deleted: ActiveValue::Set(true),
scan_id: ActiveValue::Set(update.scan_id as i64),
..Default::default()
})
.exec(&*tx)
.await?;
}
if !update.updated_repositories.is_empty() {
worktree_repository::Entity::insert_many(update.updated_repositories.iter().map(
|repository| worktree_repository::ActiveModel {
project_id: ActiveValue::set(project_id),
worktree_id: ActiveValue::set(worktree_id),
work_directory_id: ActiveValue::set(repository.work_directory_id as i64),
scan_id: ActiveValue::set(update.scan_id as i64),
branch: ActiveValue::set(repository.branch.clone()),
is_deleted: ActiveValue::set(false),
},
))
.on_conflict(
OnConflict::columns([
worktree_repository::Column::ProjectId,
worktree_repository::Column::WorktreeId,
worktree_repository::Column::WorkDirectoryId,
])
.update_columns([
worktree_repository::Column::ScanId,
worktree_repository::Column::Branch,
])
.to_owned(),
)
.exec(&*tx)
.await?;
}
if !update.removed_repositories.is_empty() {
worktree_repository::Entity::update_many()
.filter(
worktree_repository::Column::ProjectId
.eq(project_id)
.and(worktree_repository::Column::WorktreeId.eq(worktree_id))
.and(
worktree_repository::Column::WorkDirectoryId
.is_in(update.removed_repositories.iter().map(|id| *id as i64)),
),
)
.set(worktree_repository::ActiveModel {
is_deleted: ActiveValue::Set(true),
scan_id: ActiveValue::Set(update.scan_id as i64),
..Default::default()
})
.exec(&*tx)
.await?;
}
let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
Ok(connection_ids)
})
.await
}
pub async fn update_diagnostic_summary(
&self,
update: &proto::UpdateDiagnosticSummary,
connection: ConnectionId,
) -> Result<RoomGuard<Vec<ConnectionId>>> {
let project_id = ProjectId::from_proto(update.project_id);
let worktree_id = update.worktree_id as i64;
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let summary = update
.summary
.as_ref()
.ok_or_else(|| anyhow!("invalid summary"))?;
// Ensure the update comes from the host.
let project = project::Entity::find_by_id(project_id)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such project"))?;
if project.host_connection()? != connection {
return Err(anyhow!("can't update a project hosted by someone else"))?;
}
// Update summary.
worktree_diagnostic_summary::Entity::insert(worktree_diagnostic_summary::ActiveModel {
project_id: ActiveValue::set(project_id),
worktree_id: ActiveValue::set(worktree_id),
path: ActiveValue::set(summary.path.clone()),
language_server_id: ActiveValue::set(summary.language_server_id as i64),
error_count: ActiveValue::set(summary.error_count as i32),
warning_count: ActiveValue::set(summary.warning_count as i32),
..Default::default()
})
.on_conflict(
OnConflict::columns([
worktree_diagnostic_summary::Column::ProjectId,
worktree_diagnostic_summary::Column::WorktreeId,
worktree_diagnostic_summary::Column::Path,
])
.update_columns([
worktree_diagnostic_summary::Column::LanguageServerId,
worktree_diagnostic_summary::Column::ErrorCount,
worktree_diagnostic_summary::Column::WarningCount,
])
.to_owned(),
)
.exec(&*tx)
.await?;
let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
Ok(connection_ids)
})
.await
}
pub async fn start_language_server(
&self,
update: &proto::StartLanguageServer,
connection: ConnectionId,
) -> Result<RoomGuard<Vec<ConnectionId>>> {
let project_id = ProjectId::from_proto(update.project_id);
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let server = update
.server
.as_ref()
.ok_or_else(|| anyhow!("invalid language server"))?;
// Ensure the update comes from the host.
let project = project::Entity::find_by_id(project_id)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such project"))?;
if project.host_connection()? != connection {
return Err(anyhow!("can't update a project hosted by someone else"))?;
}
// Add the newly-started language server.
language_server::Entity::insert(language_server::ActiveModel {
project_id: ActiveValue::set(project_id),
id: ActiveValue::set(server.id as i64),
name: ActiveValue::set(server.name.clone()),
..Default::default()
})
.on_conflict(
OnConflict::columns([
language_server::Column::ProjectId,
language_server::Column::Id,
])
.update_column(language_server::Column::Name)
.to_owned(),
)
.exec(&*tx)
.await?;
let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
Ok(connection_ids)
})
.await
}
pub async fn update_worktree_settings(
&self,
update: &proto::UpdateWorktreeSettings,
connection: ConnectionId,
) -> Result<RoomGuard<Vec<ConnectionId>>> {
let project_id = ProjectId::from_proto(update.project_id);
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
// Ensure the update comes from the host.
let project = project::Entity::find_by_id(project_id)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such project"))?;
if project.host_connection()? != connection {
return Err(anyhow!("can't update a project hosted by someone else"))?;
}
if let Some(content) = &update.content {
worktree_settings_file::Entity::insert(worktree_settings_file::ActiveModel {
project_id: ActiveValue::Set(project_id),
worktree_id: ActiveValue::Set(update.worktree_id as i64),
path: ActiveValue::Set(update.path.clone()),
content: ActiveValue::Set(content.clone()),
})
.on_conflict(
OnConflict::columns([
worktree_settings_file::Column::ProjectId,
worktree_settings_file::Column::WorktreeId,
worktree_settings_file::Column::Path,
])
.update_column(worktree_settings_file::Column::Content)
.to_owned(),
)
.exec(&*tx)
.await?;
} else {
worktree_settings_file::Entity::delete(worktree_settings_file::ActiveModel {
project_id: ActiveValue::Set(project_id),
worktree_id: ActiveValue::Set(update.worktree_id as i64),
path: ActiveValue::Set(update.path.clone()),
..Default::default()
})
.exec(&*tx)
.await?;
}
let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
Ok(connection_ids)
})
.await
}
pub async fn join_project(
&self,
project_id: ProjectId,
connection: ConnectionId,
) -> Result<RoomGuard<(Project, ReplicaId)>> {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |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)
.await?
.ok_or_else(|| anyhow!("must join a room first"))?;
let project = project::Entity::find_by_id(project_id)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such project"))?;
if project.room_id != participant.room_id {
return Err(anyhow!("no such project"))?;
}
let mut collaborators = project
.find_related(project_collaborator::Entity)
.all(&*tx)
.await?;
let replica_ids = collaborators
.iter()
.map(|c| c.replica_id)
.collect::<HashSet<_>>();
let mut replica_id = ReplicaId(1);
while replica_ids.contains(&replica_id) {
replica_id.0 += 1;
}
let new_collaborator = project_collaborator::ActiveModel {
project_id: ActiveValue::set(project_id),
connection_id: ActiveValue::set(connection.id as i32),
connection_server_id: ActiveValue::set(ServerId(connection.owner_id as i32)),
user_id: ActiveValue::set(participant.user_id),
replica_id: ActiveValue::set(replica_id),
is_host: ActiveValue::set(false),
..Default::default()
}
.insert(&*tx)
.await?;
collaborators.push(new_collaborator);
let db_worktrees = project.find_related(worktree::Entity).all(&*tx).await?;
let mut worktrees = db_worktrees
.into_iter()
.map(|db_worktree| {
(
db_worktree.id as u64,
Worktree {
id: db_worktree.id as u64,
abs_path: db_worktree.abs_path,
root_name: db_worktree.root_name,
visible: db_worktree.visible,
entries: Default::default(),
repository_entries: Default::default(),
diagnostic_summaries: Default::default(),
settings_files: Default::default(),
scan_id: db_worktree.scan_id as u64,
completed_scan_id: db_worktree.completed_scan_id as u64,
},
)
})
.collect::<BTreeMap<_, _>>();
// Populate worktree entries.
{
let mut db_entries = worktree_entry::Entity::find()
.filter(
Condition::all()
.add(worktree_entry::Column::ProjectId.eq(project_id))
.add(worktree_entry::Column::IsDeleted.eq(false)),
)
.stream(&*tx)
.await?;
while let Some(db_entry) = db_entries.next().await {
let db_entry = db_entry?;
if let Some(worktree) = worktrees.get_mut(&(db_entry.worktree_id as u64)) {
worktree.entries.push(proto::Entry {
id: db_entry.id as u64,
is_dir: db_entry.is_dir,
path: db_entry.path,
inode: db_entry.inode as u64,
mtime: Some(proto::Timestamp {
seconds: db_entry.mtime_seconds as u64,
nanos: db_entry.mtime_nanos as u32,
}),
is_symlink: db_entry.is_symlink,
is_ignored: db_entry.is_ignored,
is_external: db_entry.is_external,
git_status: db_entry.git_status.map(|status| status as i32),
});
}
}
}
// Populate repository entries.
{
let mut db_repository_entries = worktree_repository::Entity::find()
.filter(
Condition::all()
.add(worktree_repository::Column::ProjectId.eq(project_id))
.add(worktree_repository::Column::IsDeleted.eq(false)),
)
.stream(&*tx)
.await?;
while let Some(db_repository_entry) = db_repository_entries.next().await {
let db_repository_entry = db_repository_entry?;
if let Some(worktree) =
worktrees.get_mut(&(db_repository_entry.worktree_id as u64))
{
worktree.repository_entries.insert(
db_repository_entry.work_directory_id as u64,
proto::RepositoryEntry {
work_directory_id: db_repository_entry.work_directory_id as u64,
branch: db_repository_entry.branch,
},
);
}
}
}
// Populate worktree diagnostic summaries.
{
let mut db_summaries = worktree_diagnostic_summary::Entity::find()
.filter(worktree_diagnostic_summary::Column::ProjectId.eq(project_id))
.stream(&*tx)
.await?;
while let Some(db_summary) = db_summaries.next().await {
let db_summary = db_summary?;
if let Some(worktree) = worktrees.get_mut(&(db_summary.worktree_id as u64)) {
worktree
.diagnostic_summaries
.push(proto::DiagnosticSummary {
path: db_summary.path,
language_server_id: db_summary.language_server_id as u64,
error_count: db_summary.error_count as u32,
warning_count: db_summary.warning_count as u32,
});
}
}
}
// Populate worktree settings files
{
let mut db_settings_files = worktree_settings_file::Entity::find()
.filter(worktree_settings_file::Column::ProjectId.eq(project_id))
.stream(&*tx)
.await?;
while let Some(db_settings_file) = db_settings_files.next().await {
let db_settings_file = db_settings_file?;
if let Some(worktree) =
worktrees.get_mut(&(db_settings_file.worktree_id as u64))
{
worktree.settings_files.push(WorktreeSettingsFile {
path: db_settings_file.path,
content: db_settings_file.content,
});
}
}
}
// Populate language servers.
let language_servers = project
.find_related(language_server::Entity)
.all(&*tx)
.await?;
let project = Project {
collaborators: collaborators
.into_iter()
.map(|collaborator| ProjectCollaborator {
connection_id: collaborator.connection(),
user_id: collaborator.user_id,
replica_id: collaborator.replica_id,
is_host: collaborator.is_host,
})
.collect(),
worktrees,
language_servers: language_servers
.into_iter()
.map(|language_server| proto::LanguageServer {
id: language_server.id as u64,
name: language_server.name,
})
.collect(),
};
Ok((project, replica_id as ReplicaId))
})
.await
}
pub async fn leave_project(
&self,
project_id: ProjectId,
connection: ConnectionId,
) -> Result<RoomGuard<(proto::Room, LeftProject)>> {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let result = project_collaborator::Entity::delete_many()
.filter(
Condition::all()
.add(project_collaborator::Column::ProjectId.eq(project_id))
.add(project_collaborator::Column::ConnectionId.eq(connection.id as i32))
.add(
project_collaborator::Column::ConnectionServerId
.eq(connection.owner_id as i32),
),
)
.exec(&*tx)
.await?;
if result.rows_affected == 0 {
Err(anyhow!("not a collaborator on this project"))?;
}
let project = project::Entity::find_by_id(project_id)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such project"))?;
let collaborators = project
.find_related(project_collaborator::Entity)
.all(&*tx)
.await?;
let connection_ids = collaborators
.into_iter()
.map(|collaborator| collaborator.connection())
.collect();
follower::Entity::delete_many()
.filter(
Condition::any()
.add(
Condition::all()
.add(follower::Column::ProjectId.eq(project_id))
.add(
follower::Column::LeaderConnectionServerId
.eq(connection.owner_id),
)
.add(follower::Column::LeaderConnectionId.eq(connection.id)),
)
.add(
Condition::all()
.add(follower::Column::ProjectId.eq(project_id))
.add(
follower::Column::FollowerConnectionServerId
.eq(connection.owner_id),
)
.add(follower::Column::FollowerConnectionId.eq(connection.id)),
),
)
.exec(&*tx)
.await?;
let room = self.get_room(project.room_id, &tx).await?;
let left_project = LeftProject {
id: project_id,
host_user_id: project.host_user_id,
host_connection_id: project.host_connection()?,
connection_ids,
};
Ok((room, left_project))
})
.await
}
pub async fn project_collaborators(
&self,
project_id: ProjectId,
connection_id: ConnectionId,
) -> Result<RoomGuard<Vec<ProjectCollaborator>>> {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let collaborators = project_collaborator::Entity::find()
.filter(project_collaborator::Column::ProjectId.eq(project_id))
.all(&*tx)
.await?
.into_iter()
.map(|collaborator| ProjectCollaborator {
connection_id: collaborator.connection(),
user_id: collaborator.user_id,
replica_id: collaborator.replica_id,
is_host: collaborator.is_host,
})
.collect::<Vec<_>>();
if collaborators
.iter()
.any(|collaborator| collaborator.connection_id == connection_id)
{
Ok(collaborators)
} else {
Err(anyhow!("no such project"))?
}
})
.await
}
pub async fn project_connection_ids(
&self,
project_id: ProjectId,
connection_id: ConnectionId,
) -> Result<RoomGuard<HashSet<ConnectionId>>> {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let mut collaborators = project_collaborator::Entity::find()
.filter(project_collaborator::Column::ProjectId.eq(project_id))
.stream(&*tx)
.await?;
let mut connection_ids = HashSet::default();
while let Some(collaborator) = collaborators.next().await {
let collaborator = collaborator?;
connection_ids.insert(collaborator.connection());
}
if connection_ids.contains(&connection_id) {
Ok(connection_ids)
} else {
Err(anyhow!("no such project"))?
}
})
.await
}
async fn project_guest_connection_ids(
&self,
project_id: ProjectId,
tx: &DatabaseTransaction,
) -> Result<Vec<ConnectionId>> {
let mut collaborators = project_collaborator::Entity::find()
.filter(
project_collaborator::Column::ProjectId
.eq(project_id)
.and(project_collaborator::Column::IsHost.eq(false)),
)
.stream(tx)
.await?;
let mut guest_connection_ids = Vec::new();
while let Some(collaborator) = collaborators.next().await {
let collaborator = collaborator?;
guest_connection_ids.push(collaborator.connection());
}
Ok(guest_connection_ids)
}
pub async fn room_id_for_project(&self, project_id: ProjectId) -> Result<RoomId> {
self.transaction(|tx| async move {
let project = project::Entity::find_by_id(project_id)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("project {} not found", project_id))?;
Ok(project.room_id)
})
.await
}
pub async fn follow(
&self,
project_id: ProjectId,
leader_connection: ConnectionId,
follower_connection: ConnectionId,
) -> Result<RoomGuard<proto::Room>> {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
follower::ActiveModel {
room_id: ActiveValue::set(room_id),
project_id: ActiveValue::set(project_id),
leader_connection_server_id: ActiveValue::set(ServerId(
leader_connection.owner_id as i32,
)),
leader_connection_id: ActiveValue::set(leader_connection.id as i32),
follower_connection_server_id: ActiveValue::set(ServerId(
follower_connection.owner_id as i32,
)),
follower_connection_id: ActiveValue::set(follower_connection.id as i32),
..Default::default()
}
.insert(&*tx)
.await?;
let room = self.get_room(room_id, &*tx).await?;
Ok(room)
})
.await
}
pub async fn unfollow(
&self,
project_id: ProjectId,
leader_connection: ConnectionId,
follower_connection: ConnectionId,
) -> Result<RoomGuard<proto::Room>> {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
follower::Entity::delete_many()
.filter(
Condition::all()
.add(follower::Column::ProjectId.eq(project_id))
.add(
follower::Column::LeaderConnectionServerId
.eq(leader_connection.owner_id),
)
.add(follower::Column::LeaderConnectionId.eq(leader_connection.id))
.add(
follower::Column::FollowerConnectionServerId
.eq(follower_connection.owner_id),
)
.add(follower::Column::FollowerConnectionId.eq(follower_connection.id)),
)
.exec(&*tx)
.await?;
let room = self.get_room(room_id, &*tx).await?;
Ok(room)
})
.await
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,81 @@
use super::*;
impl Database {
pub async fn create_server(&self, environment: &str) -> Result<ServerId> {
self.transaction(|tx| async move {
let server = server::ActiveModel {
environment: ActiveValue::set(environment.into()),
..Default::default()
}
.insert(&*tx)
.await?;
Ok(server.id)
})
.await
}
pub async fn stale_room_ids(
&self,
environment: &str,
new_server_id: ServerId,
) -> Result<Vec<RoomId>> {
self.transaction(|tx| async move {
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryAs {
RoomId,
}
let stale_server_epochs = self
.stale_server_ids(environment, new_server_id, &tx)
.await?;
Ok(room_participant::Entity::find()
.select_only()
.column(room_participant::Column::RoomId)
.distinct()
.filter(
room_participant::Column::AnsweringConnectionServerId
.is_in(stale_server_epochs),
)
.into_values::<_, QueryAs>()
.all(&*tx)
.await?)
})
.await
}
pub async fn delete_stale_servers(
&self,
environment: &str,
new_server_id: ServerId,
) -> Result<()> {
self.transaction(|tx| async move {
server::Entity::delete_many()
.filter(
Condition::all()
.add(server::Column::Environment.eq(environment))
.add(server::Column::Id.ne(new_server_id)),
)
.exec(&*tx)
.await?;
Ok(())
})
.await
}
async fn stale_server_ids(
&self,
environment: &str,
new_server_id: ServerId,
tx: &DatabaseTransaction,
) -> Result<Vec<ServerId>> {
let stale_servers = server::Entity::find()
.filter(
Condition::all()
.add(server::Column::Environment.eq(environment))
.add(server::Column::Id.ne(new_server_id)),
)
.all(&*tx)
.await?;
Ok(stale_servers.into_iter().map(|server| server.id).collect())
}
}

View file

@ -0,0 +1,349 @@
use super::*;
use hyper::StatusCode;
impl Database {
pub async fn create_invite_from_code(
&self,
code: &str,
email_address: &str,
device_id: Option<&str>,
added_to_mailing_list: bool,
) -> Result<Invite> {
self.transaction(|tx| async move {
let existing_user = user::Entity::find()
.filter(user::Column::EmailAddress.eq(email_address))
.one(&*tx)
.await?;
if existing_user.is_some() {
Err(anyhow!("email address is already in use"))?;
}
let inviting_user_with_invites = match user::Entity::find()
.filter(
user::Column::InviteCode
.eq(code)
.and(user::Column::InviteCount.gt(0)),
)
.one(&*tx)
.await?
{
Some(inviting_user) => inviting_user,
None => {
return Err(Error::Http(
StatusCode::UNAUTHORIZED,
"unable to find an invite code with invites remaining".to_string(),
))?
}
};
user::Entity::update_many()
.filter(
user::Column::Id
.eq(inviting_user_with_invites.id)
.and(user::Column::InviteCount.gt(0)),
)
.col_expr(
user::Column::InviteCount,
Expr::col(user::Column::InviteCount).sub(1),
)
.exec(&*tx)
.await?;
let signup = signup::Entity::insert(signup::ActiveModel {
email_address: ActiveValue::set(email_address.into()),
email_confirmation_code: ActiveValue::set(random_email_confirmation_code()),
email_confirmation_sent: ActiveValue::set(false),
inviting_user_id: ActiveValue::set(Some(inviting_user_with_invites.id)),
platform_linux: ActiveValue::set(false),
platform_mac: ActiveValue::set(false),
platform_windows: ActiveValue::set(false),
platform_unknown: ActiveValue::set(true),
device_id: ActiveValue::set(device_id.map(|device_id| device_id.into())),
added_to_mailing_list: ActiveValue::set(added_to_mailing_list),
..Default::default()
})
.on_conflict(
OnConflict::column(signup::Column::EmailAddress)
.update_column(signup::Column::InvitingUserId)
.to_owned(),
)
.exec_with_returning(&*tx)
.await?;
Ok(Invite {
email_address: signup.email_address,
email_confirmation_code: signup.email_confirmation_code,
})
})
.await
}
pub async fn create_user_from_invite(
&self,
invite: &Invite,
user: NewUserParams,
) -> Result<Option<NewUserResult>> {
self.transaction(|tx| async {
let tx = tx;
let signup = signup::Entity::find()
.filter(
signup::Column::EmailAddress
.eq(invite.email_address.as_str())
.and(
signup::Column::EmailConfirmationCode
.eq(invite.email_confirmation_code.as_str()),
),
)
.one(&*tx)
.await?
.ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "no such invite".to_string()))?;
if signup.user_id.is_some() {
return Ok(None);
}
let user = user::Entity::insert(user::ActiveModel {
email_address: ActiveValue::set(Some(invite.email_address.clone())),
github_login: ActiveValue::set(user.github_login.clone()),
github_user_id: ActiveValue::set(Some(user.github_user_id)),
admin: ActiveValue::set(false),
invite_count: ActiveValue::set(user.invite_count),
invite_code: ActiveValue::set(Some(random_invite_code())),
metrics_id: ActiveValue::set(Uuid::new_v4()),
..Default::default()
})
.on_conflict(
OnConflict::column(user::Column::GithubLogin)
.update_columns([
user::Column::EmailAddress,
user::Column::GithubUserId,
user::Column::Admin,
])
.to_owned(),
)
.exec_with_returning(&*tx)
.await?;
let mut signup = signup.into_active_model();
signup.user_id = ActiveValue::set(Some(user.id));
let signup = signup.update(&*tx).await?;
if let Some(inviting_user_id) = signup.inviting_user_id {
let (user_id_a, user_id_b, a_to_b) = if inviting_user_id < user.id {
(inviting_user_id, user.id, true)
} else {
(user.id, inviting_user_id, false)
};
contact::Entity::insert(contact::ActiveModel {
user_id_a: ActiveValue::set(user_id_a),
user_id_b: ActiveValue::set(user_id_b),
a_to_b: ActiveValue::set(a_to_b),
should_notify: ActiveValue::set(true),
accepted: ActiveValue::set(true),
..Default::default()
})
.on_conflict(OnConflict::new().do_nothing().to_owned())
.exec_without_returning(&*tx)
.await?;
}
Ok(Some(NewUserResult {
user_id: user.id,
metrics_id: user.metrics_id.to_string(),
inviting_user_id: signup.inviting_user_id,
signup_device_id: signup.device_id,
}))
})
.await
}
pub async fn set_invite_count_for_user(&self, id: UserId, count: i32) -> Result<()> {
self.transaction(|tx| async move {
if count > 0 {
user::Entity::update_many()
.filter(
user::Column::Id
.eq(id)
.and(user::Column::InviteCode.is_null()),
)
.set(user::ActiveModel {
invite_code: ActiveValue::set(Some(random_invite_code())),
..Default::default()
})
.exec(&*tx)
.await?;
}
user::Entity::update_many()
.filter(user::Column::Id.eq(id))
.set(user::ActiveModel {
invite_count: ActiveValue::set(count),
..Default::default()
})
.exec(&*tx)
.await?;
Ok(())
})
.await
}
pub async fn get_invite_code_for_user(&self, id: UserId) -> Result<Option<(String, i32)>> {
self.transaction(|tx| async move {
match user::Entity::find_by_id(id).one(&*tx).await? {
Some(user) if user.invite_code.is_some() => {
Ok(Some((user.invite_code.unwrap(), user.invite_count)))
}
_ => Ok(None),
}
})
.await
}
pub async fn get_user_for_invite_code(&self, code: &str) -> Result<User> {
self.transaction(|tx| async move {
user::Entity::find()
.filter(user::Column::InviteCode.eq(code))
.one(&*tx)
.await?
.ok_or_else(|| {
Error::Http(
StatusCode::NOT_FOUND,
"that invite code does not exist".to_string(),
)
})
})
.await
}
pub async fn create_signup(&self, signup: &NewSignup) -> Result<()> {
self.transaction(|tx| async move {
signup::Entity::insert(signup::ActiveModel {
email_address: ActiveValue::set(signup.email_address.clone()),
email_confirmation_code: ActiveValue::set(random_email_confirmation_code()),
email_confirmation_sent: ActiveValue::set(false),
platform_mac: ActiveValue::set(signup.platform_mac),
platform_windows: ActiveValue::set(signup.platform_windows),
platform_linux: ActiveValue::set(signup.platform_linux),
platform_unknown: ActiveValue::set(false),
editor_features: ActiveValue::set(Some(signup.editor_features.clone())),
programming_languages: ActiveValue::set(Some(signup.programming_languages.clone())),
device_id: ActiveValue::set(signup.device_id.clone()),
added_to_mailing_list: ActiveValue::set(signup.added_to_mailing_list),
..Default::default()
})
.on_conflict(
OnConflict::column(signup::Column::EmailAddress)
.update_columns([
signup::Column::PlatformMac,
signup::Column::PlatformWindows,
signup::Column::PlatformLinux,
signup::Column::EditorFeatures,
signup::Column::ProgrammingLanguages,
signup::Column::DeviceId,
signup::Column::AddedToMailingList,
])
.to_owned(),
)
.exec(&*tx)
.await?;
Ok(())
})
.await
}
pub async fn get_signup(&self, email_address: &str) -> Result<signup::Model> {
self.transaction(|tx| async move {
let signup = signup::Entity::find()
.filter(signup::Column::EmailAddress.eq(email_address))
.one(&*tx)
.await?
.ok_or_else(|| {
anyhow!("signup with email address {} doesn't exist", email_address)
})?;
Ok(signup)
})
.await
}
pub async fn get_waitlist_summary(&self) -> Result<WaitlistSummary> {
self.transaction(|tx| async move {
let query = "
SELECT
COUNT(*) as count,
COALESCE(SUM(CASE WHEN platform_linux THEN 1 ELSE 0 END), 0) as linux_count,
COALESCE(SUM(CASE WHEN platform_mac THEN 1 ELSE 0 END), 0) as mac_count,
COALESCE(SUM(CASE WHEN platform_windows THEN 1 ELSE 0 END), 0) as windows_count,
COALESCE(SUM(CASE WHEN platform_unknown THEN 1 ELSE 0 END), 0) as unknown_count
FROM (
SELECT *
FROM signups
WHERE
NOT email_confirmation_sent
) AS unsent
";
Ok(
WaitlistSummary::find_by_statement(Statement::from_sql_and_values(
self.pool.get_database_backend(),
query.into(),
vec![],
))
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("invalid result"))?,
)
})
.await
}
pub async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()> {
let emails = invites
.iter()
.map(|s| s.email_address.as_str())
.collect::<Vec<_>>();
self.transaction(|tx| async {
let tx = tx;
signup::Entity::update_many()
.filter(signup::Column::EmailAddress.is_in(emails.iter().copied()))
.set(signup::ActiveModel {
email_confirmation_sent: ActiveValue::set(true),
..Default::default()
})
.exec(&*tx)
.await?;
Ok(())
})
.await
}
pub async fn get_unsent_invites(&self, count: usize) -> Result<Vec<Invite>> {
self.transaction(|tx| async move {
Ok(signup::Entity::find()
.select_only()
.column(signup::Column::EmailAddress)
.column(signup::Column::EmailConfirmationCode)
.filter(
signup::Column::EmailConfirmationSent.eq(false).and(
signup::Column::PlatformMac
.eq(true)
.or(signup::Column::PlatformUnknown.eq(true)),
),
)
.order_by_asc(signup::Column::CreatedAt)
.limit(count as u64)
.into_model()
.all(&*tx)
.await?)
})
.await
}
}
fn random_invite_code() -> String {
nanoid::nanoid!(16)
}
fn random_email_confirmation_code() -> String {
nanoid::nanoid!(64)
}

View file

@ -0,0 +1,243 @@
use super::*;
impl Database {
pub async fn create_user(
&self,
email_address: &str,
admin: bool,
params: NewUserParams,
) -> Result<NewUserResult> {
self.transaction(|tx| async {
let tx = tx;
let user = user::Entity::insert(user::ActiveModel {
email_address: ActiveValue::set(Some(email_address.into())),
github_login: ActiveValue::set(params.github_login.clone()),
github_user_id: ActiveValue::set(Some(params.github_user_id)),
admin: ActiveValue::set(admin),
metrics_id: ActiveValue::set(Uuid::new_v4()),
..Default::default()
})
.on_conflict(
OnConflict::column(user::Column::GithubLogin)
.update_column(user::Column::GithubLogin)
.to_owned(),
)
.exec_with_returning(&*tx)
.await?;
Ok(NewUserResult {
user_id: user.id,
metrics_id: user.metrics_id.to_string(),
signup_device_id: None,
inviting_user_id: None,
})
})
.await
}
pub async fn get_user_by_id(&self, id: UserId) -> Result<Option<user::Model>> {
self.transaction(|tx| async move { Ok(user::Entity::find_by_id(id).one(&*tx).await?) })
.await
}
pub async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<user::Model>> {
self.transaction(|tx| async {
let tx = tx;
Ok(user::Entity::find()
.filter(user::Column::Id.is_in(ids.iter().copied()))
.all(&*tx)
.await?)
})
.await
}
pub async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
self.transaction(|tx| async move {
Ok(user::Entity::find()
.filter(user::Column::GithubLogin.eq(github_login))
.one(&*tx)
.await?)
})
.await
}
pub async fn get_or_create_user_by_github_account(
&self,
github_login: &str,
github_user_id: Option<i32>,
github_email: Option<&str>,
) -> Result<Option<User>> {
self.transaction(|tx| async move {
let tx = &*tx;
if let Some(github_user_id) = github_user_id {
if let Some(user_by_github_user_id) = user::Entity::find()
.filter(user::Column::GithubUserId.eq(github_user_id))
.one(tx)
.await?
{
let mut user_by_github_user_id = user_by_github_user_id.into_active_model();
user_by_github_user_id.github_login = ActiveValue::set(github_login.into());
Ok(Some(user_by_github_user_id.update(tx).await?))
} else if let Some(user_by_github_login) = user::Entity::find()
.filter(user::Column::GithubLogin.eq(github_login))
.one(tx)
.await?
{
let mut user_by_github_login = user_by_github_login.into_active_model();
user_by_github_login.github_user_id = ActiveValue::set(Some(github_user_id));
Ok(Some(user_by_github_login.update(tx).await?))
} else {
let user = user::Entity::insert(user::ActiveModel {
email_address: ActiveValue::set(github_email.map(|email| email.into())),
github_login: ActiveValue::set(github_login.into()),
github_user_id: ActiveValue::set(Some(github_user_id)),
admin: ActiveValue::set(false),
invite_count: ActiveValue::set(0),
invite_code: ActiveValue::set(None),
metrics_id: ActiveValue::set(Uuid::new_v4()),
..Default::default()
})
.exec_with_returning(&*tx)
.await?;
Ok(Some(user))
}
} else {
Ok(user::Entity::find()
.filter(user::Column::GithubLogin.eq(github_login))
.one(tx)
.await?)
}
})
.await
}
pub async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<User>> {
self.transaction(|tx| async move {
Ok(user::Entity::find()
.order_by_asc(user::Column::GithubLogin)
.limit(limit as u64)
.offset(page as u64 * limit as u64)
.all(&*tx)
.await?)
})
.await
}
pub async fn get_users_with_no_invites(
&self,
invited_by_another_user: bool,
) -> Result<Vec<User>> {
self.transaction(|tx| async move {
Ok(user::Entity::find()
.filter(
user::Column::InviteCount
.eq(0)
.and(if invited_by_another_user {
user::Column::InviterId.is_not_null()
} else {
user::Column::InviterId.is_null()
}),
)
.all(&*tx)
.await?)
})
.await
}
pub async fn get_user_metrics_id(&self, id: UserId) -> Result<String> {
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryAs {
MetricsId,
}
self.transaction(|tx| async move {
let metrics_id: Uuid = user::Entity::find_by_id(id)
.select_only()
.column(user::Column::MetricsId)
.into_values::<_, QueryAs>()
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("could not find user"))?;
Ok(metrics_id.to_string())
})
.await
}
pub async fn set_user_is_admin(&self, id: UserId, is_admin: bool) -> Result<()> {
self.transaction(|tx| async move {
user::Entity::update_many()
.filter(user::Column::Id.eq(id))
.set(user::ActiveModel {
admin: ActiveValue::set(is_admin),
..Default::default()
})
.exec(&*tx)
.await?;
Ok(())
})
.await
}
pub async fn set_user_connected_once(&self, id: UserId, connected_once: bool) -> Result<()> {
self.transaction(|tx| async move {
user::Entity::update_many()
.filter(user::Column::Id.eq(id))
.set(user::ActiveModel {
connected_once: ActiveValue::set(connected_once),
..Default::default()
})
.exec(&*tx)
.await?;
Ok(())
})
.await
}
pub async fn destroy_user(&self, id: UserId) -> Result<()> {
self.transaction(|tx| async move {
access_token::Entity::delete_many()
.filter(access_token::Column::UserId.eq(id))
.exec(&*tx)
.await?;
user::Entity::delete_by_id(id).exec(&*tx).await?;
Ok(())
})
.await
}
pub async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result<Vec<User>> {
self.transaction(|tx| async {
let tx = tx;
let like_string = Self::fuzzy_like_string(name_query);
let query = "
SELECT users.*
FROM users
WHERE github_login ILIKE $1
ORDER BY github_login <-> $2
LIMIT $3
";
Ok(user::Entity::find()
.from_raw_sql(Statement::from_sql_and_values(
self.pool.get_database_backend(),
query.into(),
vec![like_string.into(), name_query.into(), limit.into()],
))
.all(&*tx)
.await?)
})
.await
}
pub fn fuzzy_like_string(string: &str) -> String {
let mut result = String::with_capacity(string.len() * 2 + 1);
for c in string.chars() {
if c.is_alphanumeric() {
result.push('%');
result.push(c);
}
}
result.push('%');
result
}
}

View file

@ -1,57 +0,0 @@
use super::{SignupId, UserId};
use sea_orm::{entity::prelude::*, FromQueryResult};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "signups")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: SignupId,
pub email_address: String,
pub email_confirmation_code: String,
pub email_confirmation_sent: bool,
pub created_at: DateTime,
pub device_id: Option<String>,
pub user_id: Option<UserId>,
pub inviting_user_id: Option<UserId>,
pub platform_mac: bool,
pub platform_linux: bool,
pub platform_windows: bool,
pub platform_unknown: bool,
pub editor_features: Option<Vec<String>>,
pub programming_languages: Option<Vec<String>>,
pub added_to_mailing_list: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
#[derive(Clone, Debug, PartialEq, Eq, FromQueryResult, Serialize, Deserialize)]
pub struct Invite {
pub email_address: String,
pub email_confirmation_code: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct NewSignup {
pub email_address: String,
pub platform_mac: bool,
pub platform_windows: bool,
pub platform_linux: bool,
pub editor_features: Vec<String>,
pub programming_languages: Vec<String>,
pub device_id: Option<String>,
pub added_to_mailing_list: bool,
pub created_at: Option<DateTime>,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, FromQueryResult)]
pub struct WaitlistSummary {
pub count: i64,
pub linux_count: i64,
pub mac_count: i64,
pub windows_count: i64,
pub unknown_count: i64,
}

View file

@ -0,0 +1,20 @@
pub mod access_token;
pub mod channel;
pub mod channel_member;
pub mod channel_path;
pub mod contact;
pub mod follower;
pub mod language_server;
pub mod project;
pub mod project_collaborator;
pub mod room;
pub mod room_participant;
pub mod server;
pub mod signup;
pub mod user;
pub mod worktree;
pub mod worktree_diagnostic_summary;
pub mod worktree_entry;
pub mod worktree_repository;
pub mod worktree_repository_statuses;
pub mod worktree_settings_file;

View file

@ -1,4 +1,4 @@
use super::{AccessTokenId, UserId};
use crate::db::{AccessTokenId, UserId};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -0,0 +1,32 @@
use crate::db::ChannelId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "channels")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: ChannelId,
pub name: String,
}
impl ActiveModelBehavior for ActiveModel {}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_one = "super::room::Entity")]
Room,
#[sea_orm(has_many = "super::channel_member::Entity")]
Member,
}
impl Related<super::channel_member::Entity> for Entity {
fn to() -> RelationDef {
Relation::Member.def()
}
}
impl Related<super::room::Entity> for Entity {
fn to() -> RelationDef {
Relation::Room.def()
}
}

View file

@ -0,0 +1,59 @@
use crate::db::{channel_member, ChannelId, ChannelMemberId, UserId};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "channel_members")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: ChannelMemberId,
pub channel_id: ChannelId,
pub user_id: UserId,
pub accepted: bool,
pub admin: bool,
}
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::UserId",
to = "super::user::Column::Id"
)]
User,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
#[derive(Debug)]
pub struct UserToChannel;
impl Linked for UserToChannel {
type FromEntity = super::user::Entity;
type ToEntity = super::channel::Entity;
fn link(&self) -> Vec<RelationDef> {
vec![
channel_member::Relation::User.def().rev(),
channel_member::Relation::Channel.def(),
]
}
}

View file

@ -0,0 +1,15 @@
use crate::db::ChannelId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "channel_paths")]
pub struct Model {
#[sea_orm(primary_key)]
pub id_path: String,
pub channel_id: ChannelId,
}
impl ActiveModelBehavior for ActiveModel {}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

View file

@ -1,4 +1,4 @@
use super::{ContactId, UserId};
use crate::db::{ContactId, UserId};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
@ -30,29 +30,3 @@ pub enum Relation {
}
impl ActiveModelBehavior for ActiveModel {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Contact {
Accepted {
user_id: UserId,
should_notify: bool,
busy: bool,
},
Outgoing {
user_id: UserId,
},
Incoming {
user_id: UserId,
should_notify: bool,
},
}
impl Contact {
pub fn user_id(&self) -> UserId {
match self {
Contact::Accepted { user_id, .. } => *user_id,
Contact::Outgoing { user_id } => *user_id,
Contact::Incoming { user_id, .. } => *user_id,
}
}
}

View file

@ -1,9 +1,8 @@
use super::{FollowerId, ProjectId, RoomId, ServerId};
use crate::db::{FollowerId, ProjectId, RoomId, ServerId};
use rpc::ConnectionId;
use sea_orm::entity::prelude::*;
use serde::Serialize;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)]
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "followers")]
pub struct Model {
#[sea_orm(primary_key)]

View file

@ -1,4 +1,4 @@
use super::ProjectId;
use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::{ProjectId, Result, RoomId, ServerId, UserId};
use crate::db::{ProjectId, Result, RoomId, ServerId, UserId};
use anyhow::anyhow;
use rpc::ConnectionId;
use sea_orm::entity::prelude::*;

View file

@ -1,4 +1,4 @@
use super::{ProjectCollaboratorId, ProjectId, ReplicaId, ServerId, UserId};
use crate::db::{ProjectCollaboratorId, ProjectId, ReplicaId, ServerId, UserId};
use rpc::ConnectionId;
use sea_orm::entity::prelude::*;

View file

@ -1,12 +1,13 @@
use super::RoomId;
use crate::db::{ChannelId, RoomId};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[derive(Clone, Default, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "rooms")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: RoomId,
pub live_kit_room: String,
pub channel_id: Option<ChannelId>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@ -17,6 +18,12 @@ pub enum Relation {
Project,
#[sea_orm(has_many = "super::follower::Entity")]
Follower,
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id"
)]
Channel,
}
impl Related<super::room_participant::Entity> for Entity {
@ -37,4 +44,10 @@ impl Related<super::follower::Entity> for Entity {
}
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -1,4 +1,4 @@
use super::{ProjectId, RoomId, RoomParticipantId, ServerId, UserId};
use crate::db::{ProjectId, RoomId, RoomParticipantId, ServerId, UserId};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::ServerId;
use crate::db::ServerId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -0,0 +1,28 @@
use crate::db::{SignupId, UserId};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "signups")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: SignupId,
pub email_address: String,
pub email_confirmation_code: String,
pub email_confirmation_sent: bool,
pub created_at: DateTime,
pub device_id: Option<String>,
pub user_id: Option<UserId>,
pub inviting_user_id: Option<UserId>,
pub platform_mac: bool,
pub platform_linux: bool,
pub platform_windows: bool,
pub platform_unknown: bool,
pub editor_features: Option<Vec<String>>,
pub programming_languages: Option<Vec<String>>,
pub added_to_mailing_list: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -1,4 +1,4 @@
use super::UserId;
use crate::db::UserId;
use sea_orm::entity::prelude::*;
use serde::Serialize;
@ -26,6 +26,8 @@ pub enum Relation {
RoomParticipant,
#[sea_orm(has_many = "super::project::Entity")]
HostedProjects,
#[sea_orm(has_many = "super::channel_member::Entity")]
ChannelMemberships,
}
impl Related<super::access_token::Entity> for Entity {
@ -46,4 +48,10 @@ impl Related<super::project::Entity> for Entity {
}
}
impl Related<super::channel_member::Entity> for Entity {
fn to() -> RelationDef {
Relation::ChannelMemberships.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -1,4 +1,4 @@
use super::ProjectId;
use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::ProjectId;
use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::ProjectId;
use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::ProjectId;
use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::ProjectId;
use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::ProjectId;
use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -0,0 +1,120 @@
use super::*;
use gpui::executor::Background;
use parking_lot::Mutex;
use sea_orm::ConnectionTrait;
use sqlx::migrate::MigrateDatabase;
use std::sync::Arc;
pub struct TestDb {
pub db: Option<Arc<Database>>,
pub connection: Option<sqlx::AnyConnection>,
}
impl TestDb {
pub fn sqlite(background: Arc<Background>) -> Self {
let url = format!("sqlite::memory:");
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.unwrap();
let mut db = runtime.block_on(async {
let mut options = ConnectOptions::new(url);
options.max_connections(5);
let db = Database::new(options, Executor::Deterministic(background))
.await
.unwrap();
let sql = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/migrations.sqlite/20221109000000_test_schema.sql"
));
db.pool
.execute(sea_orm::Statement::from_string(
db.pool.get_database_backend(),
sql.into(),
))
.await
.unwrap();
db
});
db.runtime = Some(runtime);
Self {
db: Some(Arc::new(db)),
connection: None,
}
}
pub fn postgres(background: Arc<Background>) -> Self {
static LOCK: Mutex<()> = Mutex::new(());
let _guard = LOCK.lock();
let mut rng = StdRng::from_entropy();
let url = format!(
"postgres://postgres@localhost/zed-test-{}",
rng.gen::<u128>()
);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.unwrap();
let mut db = runtime.block_on(async {
sqlx::Postgres::create_database(&url)
.await
.expect("failed to create test db");
let mut options = ConnectOptions::new(url);
options
.max_connections(5)
.idle_timeout(Duration::from_secs(0));
let db = Database::new(options, Executor::Deterministic(background))
.await
.unwrap();
let migrations_path = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations");
db.migrate(Path::new(migrations_path), false).await.unwrap();
db
});
db.runtime = Some(runtime);
Self {
db: Some(Arc::new(db)),
connection: None,
}
}
pub fn db(&self) -> &Arc<Database> {
self.db.as_ref().unwrap()
}
}
impl Drop for TestDb {
fn drop(&mut self) {
let db = self.db.take().unwrap();
if let sea_orm::DatabaseBackend::Postgres = db.pool.get_database_backend() {
db.runtime.as_ref().unwrap().block_on(async {
use util::ResultExt;
let query = "
SELECT pg_terminate_backend(pg_stat_activity.pid)
FROM pg_stat_activity
WHERE
pg_stat_activity.datname = current_database() AND
pid <> pg_backend_pid();
";
db.pool
.execute(sea_orm::Statement::from_string(
db.pool.get_database_backend(),
query.into(),
))
.await
.log_err();
sqlx::Postgres::drop_database(db.options.get_url())
.await
.log_err();
})
}
}
}

View file

@ -2,7 +2,7 @@ mod connection_pool;
use crate::{
auth,
db::{self, Database, ProjectId, RoomId, ServerId, User, UserId},
db::{self, ChannelId, ChannelsForUser, Database, ProjectId, RoomId, ServerId, User, UserId},
executor::Executor,
AppState, Result,
};
@ -34,7 +34,10 @@ use futures::{
use lazy_static::lazy_static;
use prometheus::{register_int_gauge, IntGauge};
use rpc::{
proto::{self, AnyTypedEnvelope, EntityMessage, EnvelopedMessage, RequestMessage},
proto::{
self, AnyTypedEnvelope, EntityMessage, EnvelopedMessage, LiveKitConnectionInfo,
RequestMessage,
},
Connection, ConnectionId, Peer, Receipt, TypedEnvelope,
};
use serde::{Serialize, Serializer};
@ -239,6 +242,15 @@ impl Server {
.add_request_handler(request_contact)
.add_request_handler(remove_contact)
.add_request_handler(respond_to_contact_request)
.add_request_handler(create_channel)
.add_request_handler(remove_channel)
.add_request_handler(invite_channel_member)
.add_request_handler(remove_channel_member)
.add_request_handler(set_channel_member_admin)
.add_request_handler(rename_channel)
.add_request_handler(get_channel_members)
.add_request_handler(respond_to_channel_invite)
.add_request_handler(join_channel)
.add_request_handler(follow)
.add_message_handler(unfollow)
.add_message_handler(update_followers)
@ -287,6 +299,15 @@ impl Server {
"refreshed room"
);
room_updated(&refreshed_room.room, &peer);
if let Some(channel_id) = refreshed_room.channel_id {
channel_updated(
channel_id,
&refreshed_room.room,
&refreshed_room.channel_members,
&peer,
&*pool.lock(),
);
}
contacts_to_update
.extend(refreshed_room.stale_participant_user_ids.iter().copied());
contacts_to_update
@ -508,15 +529,21 @@ impl Server {
this.app_state.db.set_user_connected_once(user_id, true).await?;
}
let (contacts, invite_code) = future::try_join(
let (contacts, invite_code, channels_for_user, channel_invites) = future::try_join4(
this.app_state.db.get_contacts(user_id),
this.app_state.db.get_invite_code_for_user(user_id)
this.app_state.db.get_invite_code_for_user(user_id),
this.app_state.db.get_channels_for_user(user_id),
this.app_state.db.get_channel_invites_for_user(user_id)
).await?;
{
let mut pool = this.connection_pool.lock();
pool.add_connection(connection_id, user_id, user.admin);
this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?;
this.peer.send(connection_id, build_initial_channels_update(
channels_for_user,
channel_invites
))?;
if let Some((code, count)) = invite_code {
this.peer.send(connection_id, proto::UpdateInviteInfo {
@ -857,42 +884,41 @@ async fn create_room(
session: Session,
) -> Result<()> {
let live_kit_room = nanoid::nanoid!(30);
let live_kit_connection_info = if let Some(live_kit) = session.live_kit_client.as_ref() {
if let Some(_) = live_kit
.create_room(live_kit_room.clone())
.await
.trace_err()
{
if let Some(token) = live_kit
let live_kit_connection_info = {
let live_kit_room = live_kit_room.clone();
let live_kit = session.live_kit_client.as_ref();
util::async_iife!({
let live_kit = live_kit?;
live_kit
.create_room(live_kit_room.clone())
.await
.trace_err()?;
let token = live_kit
.room_token(&live_kit_room, &session.user_id.to_string())
.trace_err()
{
Some(proto::LiveKitConnectionInfo {
server_url: live_kit.url().into(),
token,
})
} else {
None
}
} else {
None
}
} else {
None
};
.trace_err()?;
{
let room = session
.db()
.await
.create_room(session.user_id, session.connection_id, &live_kit_room)
.await?;
response.send(proto::CreateRoomResponse {
room: Some(room.clone()),
live_kit_connection_info,
})?;
Some(proto::LiveKitConnectionInfo {
server_url: live_kit.url().into(),
token,
})
})
}
.await;
let room = session
.db()
.await
.create_room(session.user_id, session.connection_id, &live_kit_room)
.await?;
response.send(proto::CreateRoomResponse {
room: Some(room.clone()),
live_kit_connection_info,
})?;
update_user_contacts(session.user_id, &session).await?;
Ok(())
@ -904,16 +930,26 @@ async fn join_room(
session: Session,
) -> Result<()> {
let room_id = RoomId::from_proto(request.id);
let room = {
let joined_room = {
let room = session
.db()
.await
.join_room(room_id, session.user_id, session.connection_id)
.await?;
room_updated(&room, &session.peer);
room.clone()
room_updated(&room.room, &session.peer);
room.into_inner()
};
if let Some(channel_id) = joined_room.channel_id {
channel_updated(
channel_id,
&joined_room.room,
&joined_room.channel_members,
&session.peer,
&*session.connection_pool().await,
)
}
for connection_id in session
.connection_pool()
.await
@ -932,7 +968,10 @@ async fn join_room(
let live_kit_connection_info = if let Some(live_kit) = session.live_kit_client.as_ref() {
if let Some(token) = live_kit
.room_token(&room.live_kit_room, &session.user_id.to_string())
.room_token(
&joined_room.room.live_kit_room,
&session.user_id.to_string(),
)
.trace_err()
{
Some(proto::LiveKitConnectionInfo {
@ -947,7 +986,8 @@ async fn join_room(
};
response.send(proto::JoinRoomResponse {
room: Some(room),
room: Some(joined_room.room),
channel_id: joined_room.channel_id.map(|id| id.to_proto()),
live_kit_connection_info,
})?;
@ -960,6 +1000,9 @@ async fn rejoin_room(
response: Response<proto::RejoinRoom>,
session: Session,
) -> Result<()> {
let room;
let channel_id;
let channel_members;
{
let mut rejoined_room = session
.db()
@ -1121,6 +1164,22 @@ async fn rejoin_room(
)?;
}
}
let rejoined_room = rejoined_room.into_inner();
room = rejoined_room.room;
channel_id = rejoined_room.channel_id;
channel_members = rejoined_room.channel_members;
}
if let Some(channel_id) = channel_id {
channel_updated(
channel_id,
&room,
&channel_members,
&session.peer,
&*session.connection_pool().await,
);
}
update_user_contacts(session.user_id, &session).await?;
@ -1282,11 +1341,12 @@ async fn update_participant_location(
let location = request
.location
.ok_or_else(|| anyhow!("invalid location"))?;
let room = session
.db()
.await
let db = session.db().await;
let room = db
.update_room_participant_location(room_id, session.connection_id, location)
.await?;
room_updated(&room, &session.peer);
response.send(proto::Ack {})?;
Ok(())
@ -2084,6 +2144,340 @@ async fn remove_contact(
Ok(())
}
async fn create_channel(
request: proto::CreateChannel,
response: Response<proto::CreateChannel>,
session: Session,
) -> Result<()> {
let db = session.db().await;
let live_kit_room = format!("channel-{}", nanoid::nanoid!(30));
if let Some(live_kit) = session.live_kit_client.as_ref() {
live_kit.create_room(live_kit_room.clone()).await?;
}
let parent_id = request.parent_id.map(|id| ChannelId::from_proto(id));
let id = db
.create_channel(&request.name, parent_id, &live_kit_room, session.user_id)
.await?;
let channel = proto::Channel {
id: id.to_proto(),
name: request.name,
parent_id: request.parent_id,
};
response.send(proto::ChannelResponse {
channel: Some(channel.clone()),
})?;
let mut update = proto::UpdateChannels::default();
update.channels.push(channel);
let user_ids_to_notify = if let Some(parent_id) = parent_id {
db.get_channel_members(parent_id).await?
} else {
vec![session.user_id]
};
let connection_pool = session.connection_pool().await;
for user_id in user_ids_to_notify {
for connection_id in connection_pool.user_connection_ids(user_id) {
let mut update = update.clone();
if user_id == session.user_id {
update.channel_permissions.push(proto::ChannelPermission {
channel_id: id.to_proto(),
is_admin: true,
});
}
session.peer.send(connection_id, update)?;
}
}
Ok(())
}
async fn remove_channel(
request: proto::RemoveChannel,
response: Response<proto::RemoveChannel>,
session: Session,
) -> Result<()> {
let db = session.db().await;
let channel_id = request.channel_id;
let (removed_channels, member_ids) = db
.remove_channel(ChannelId::from_proto(channel_id), session.user_id)
.await?;
response.send(proto::Ack {})?;
// Notify members of removed channels
let mut update = proto::UpdateChannels::default();
update
.remove_channels
.extend(removed_channels.into_iter().map(|id| id.to_proto()));
let connection_pool = session.connection_pool().await;
for member_id in member_ids {
for connection_id in connection_pool.user_connection_ids(member_id) {
session.peer.send(connection_id, update.clone())?;
}
}
Ok(())
}
async fn invite_channel_member(
request: proto::InviteChannelMember,
response: Response<proto::InviteChannelMember>,
session: Session,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
let invitee_id = UserId::from_proto(request.user_id);
db.invite_channel_member(channel_id, invitee_id, session.user_id, request.admin)
.await?;
let (channel, _) = db
.get_channel(channel_id, session.user_id)
.await?
.ok_or_else(|| anyhow!("channel not found"))?;
let mut update = proto::UpdateChannels::default();
update.channel_invitations.push(proto::Channel {
id: channel.id.to_proto(),
name: channel.name,
parent_id: None,
});
for connection_id in session
.connection_pool()
.await
.user_connection_ids(invitee_id)
{
session.peer.send(connection_id, update.clone())?;
}
response.send(proto::Ack {})?;
Ok(())
}
async fn remove_channel_member(
request: proto::RemoveChannelMember,
response: Response<proto::RemoveChannelMember>,
session: Session,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
let member_id = UserId::from_proto(request.user_id);
db.remove_channel_member(channel_id, member_id, session.user_id)
.await?;
let mut update = proto::UpdateChannels::default();
update.remove_channels.push(channel_id.to_proto());
for connection_id in session
.connection_pool()
.await
.user_connection_ids(member_id)
{
session.peer.send(connection_id, update.clone())?;
}
response.send(proto::Ack {})?;
Ok(())
}
async fn set_channel_member_admin(
request: proto::SetChannelMemberAdmin,
response: Response<proto::SetChannelMemberAdmin>,
session: Session,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
let member_id = UserId::from_proto(request.user_id);
db.set_channel_member_admin(channel_id, session.user_id, member_id, request.admin)
.await?;
let (channel, has_accepted) = db
.get_channel(channel_id, member_id)
.await?
.ok_or_else(|| anyhow!("channel not found"))?;
let mut update = proto::UpdateChannels::default();
if has_accepted {
update.channel_permissions.push(proto::ChannelPermission {
channel_id: channel.id.to_proto(),
is_admin: request.admin,
});
}
for connection_id in session
.connection_pool()
.await
.user_connection_ids(member_id)
{
session.peer.send(connection_id, update.clone())?;
}
response.send(proto::Ack {})?;
Ok(())
}
async fn rename_channel(
request: proto::RenameChannel,
response: Response<proto::RenameChannel>,
session: Session,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
let new_name = db
.rename_channel(channel_id, session.user_id, &request.name)
.await?;
let channel = proto::Channel {
id: request.channel_id,
name: new_name,
parent_id: None,
};
response.send(proto::ChannelResponse {
channel: Some(channel.clone()),
})?;
let mut update = proto::UpdateChannels::default();
update.channels.push(channel);
let member_ids = db.get_channel_members(channel_id).await?;
let connection_pool = session.connection_pool().await;
for member_id in member_ids {
for connection_id in connection_pool.user_connection_ids(member_id) {
session.peer.send(connection_id, update.clone())?;
}
}
Ok(())
}
async fn get_channel_members(
request: proto::GetChannelMembers,
response: Response<proto::GetChannelMembers>,
session: Session,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
let members = db
.get_channel_member_details(channel_id, session.user_id)
.await?;
response.send(proto::GetChannelMembersResponse { members })?;
Ok(())
}
async fn respond_to_channel_invite(
request: proto::RespondToChannelInvite,
response: Response<proto::RespondToChannelInvite>,
session: Session,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
db.respond_to_channel_invite(channel_id, session.user_id, request.accept)
.await?;
let mut update = proto::UpdateChannels::default();
update
.remove_channel_invitations
.push(channel_id.to_proto());
if request.accept {
let result = db.get_channels_for_user(session.user_id).await?;
update
.channels
.extend(result.channels.into_iter().map(|channel| proto::Channel {
id: channel.id.to_proto(),
name: channel.name,
parent_id: channel.parent_id.map(ChannelId::to_proto),
}));
update
.channel_participants
.extend(
result
.channel_participants
.into_iter()
.map(|(channel_id, user_ids)| proto::ChannelParticipants {
channel_id: channel_id.to_proto(),
participant_user_ids: user_ids.into_iter().map(UserId::to_proto).collect(),
}),
);
update
.channel_permissions
.extend(
result
.channels_with_admin_privileges
.into_iter()
.map(|channel_id| proto::ChannelPermission {
channel_id: channel_id.to_proto(),
is_admin: true,
}),
);
}
session.peer.send(session.connection_id, update)?;
response.send(proto::Ack {})?;
Ok(())
}
async fn join_channel(
request: proto::JoinChannel,
response: Response<proto::JoinChannel>,
session: Session,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
let joined_room = {
leave_room_for_session(&session).await?;
let db = session.db().await;
let room_id = db.room_id_for_channel(channel_id).await?;
let joined_room = db
.join_room(room_id, session.user_id, session.connection_id)
.await?;
let live_kit_connection_info = session.live_kit_client.as_ref().and_then(|live_kit| {
let token = live_kit
.room_token(
&joined_room.room.live_kit_room,
&session.user_id.to_string(),
)
.trace_err()?;
Some(LiveKitConnectionInfo {
server_url: live_kit.url().into(),
token,
})
});
response.send(proto::JoinRoomResponse {
room: Some(joined_room.room.clone()),
channel_id: joined_room.channel_id.map(|id| id.to_proto()),
live_kit_connection_info,
})?;
room_updated(&joined_room.room, &session.peer);
joined_room.into_inner()
};
channel_updated(
channel_id,
&joined_room.room,
&joined_room.channel_members,
&session.peer,
&*session.connection_pool().await,
);
update_user_contacts(session.user_id, &session).await?;
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
@ -2154,6 +2548,52 @@ fn to_tungstenite_message(message: AxumMessage) -> TungsteniteMessage {
}
}
fn build_initial_channels_update(
channels: ChannelsForUser,
channel_invites: Vec<db::Channel>,
) -> proto::UpdateChannels {
let mut update = proto::UpdateChannels::default();
for channel in channels.channels {
update.channels.push(proto::Channel {
id: channel.id.to_proto(),
name: channel.name,
parent_id: channel.parent_id.map(|id| id.to_proto()),
});
}
for (channel_id, participants) in channels.channel_participants {
update
.channel_participants
.push(proto::ChannelParticipants {
channel_id: channel_id.to_proto(),
participant_user_ids: participants.into_iter().map(|id| id.to_proto()).collect(),
});
}
update
.channel_permissions
.extend(
channels
.channels_with_admin_privileges
.into_iter()
.map(|id| proto::ChannelPermission {
channel_id: id.to_proto(),
is_admin: true,
}),
);
for channel in channel_invites {
update.channel_invitations.push(proto::Channel {
id: channel.id.to_proto(),
name: channel.name,
parent_id: None,
});
}
update
}
fn build_initial_contacts_update(
contacts: Vec<db::Contact>,
pool: &ConnectionPool,
@ -2218,8 +2658,42 @@ fn room_updated(room: &proto::Room, peer: &Peer) {
);
}
fn channel_updated(
channel_id: ChannelId,
room: &proto::Room,
channel_members: &[UserId],
peer: &Peer,
pool: &ConnectionPool,
) {
let participants = room
.participants
.iter()
.map(|p| p.user_id)
.collect::<Vec<_>>();
broadcast(
None,
channel_members
.iter()
.flat_map(|user_id| pool.user_connection_ids(*user_id)),
|peer_id| {
peer.send(
peer_id.into(),
proto::UpdateChannels {
channel_participants: vec![proto::ChannelParticipants {
channel_id: channel_id.to_proto(),
participant_user_ids: participants.clone(),
}],
..Default::default()
},
)
},
);
}
async fn update_user_contacts(user_id: UserId, session: &Session) -> Result<()> {
let db = session.db().await;
let contacts = db.get_contacts(user_id).await?;
let busy = db.is_user_busy(user_id).await?;
@ -2259,6 +2733,10 @@ async fn leave_room_for_session(session: &Session) -> Result<()> {
let canceled_calls_to_user_ids;
let live_kit_room;
let delete_live_kit_room;
let room;
let channel_members;
let channel_id;
if let Some(mut left_room) = session.db().await.leave_room(session.connection_id).await? {
contacts_to_update.insert(session.user_id);
@ -2266,15 +2744,29 @@ async fn leave_room_for_session(session: &Session) -> Result<()> {
project_left(project, session);
}
room_updated(&left_room.room, &session.peer);
room_id = RoomId::from_proto(left_room.room.id);
canceled_calls_to_user_ids = mem::take(&mut left_room.canceled_calls_to_user_ids);
live_kit_room = mem::take(&mut left_room.room.live_kit_room);
delete_live_kit_room = left_room.room.participants.is_empty();
delete_live_kit_room = left_room.deleted;
room = mem::take(&mut left_room.room);
channel_members = mem::take(&mut left_room.channel_members);
channel_id = left_room.channel_id;
room_updated(&room, &session.peer);
} else {
return Ok(());
}
if let Some(channel_id) = channel_id {
channel_updated(
channel_id,
&room,
&channel_members,
&session.peer,
&*session.connection_pool().await,
);
}
{
let pool = session.connection_pool().await;
for canceled_user_id in canceled_calls_to_user_ids {

View file

@ -1,18 +1,19 @@
use crate::{
db::{NewUserParams, TestDb, UserId},
db::{test_db::TestDb, NewUserParams, UserId},
executor::Executor,
rpc::{Server, CLEANUP_TIMEOUT},
AppState,
};
use anyhow::anyhow;
use call::ActiveCall;
use call::{ActiveCall, Room};
use client::{
self, proto::PeerId, Client, Connection, Credentials, EstablishConnectionError, UserStore,
self, proto::PeerId, ChannelStore, Client, Connection, Credentials, EstablishConnectionError,
UserStore,
};
use collections::{HashMap, HashSet};
use fs::FakeFs;
use futures::{channel::oneshot, StreamExt as _};
use gpui::{executor::Deterministic, ModelHandle, TestAppContext, WindowHandle};
use gpui::{executor::Deterministic, ModelHandle, Task, TestAppContext, WindowHandle};
use language::LanguageRegistry;
use parking_lot::Mutex;
use project::{Project, WorktreeId};
@ -30,6 +31,7 @@ use std::{
use util::http::FakeHttpClient;
use workspace::Workspace;
mod channel_tests;
mod integration_tests;
mod randomized_integration_tests;
@ -98,6 +100,9 @@ impl TestServer {
async fn create_client(&mut self, cx: &mut TestAppContext, name: &str) -> TestClient {
cx.update(|cx| {
if cx.has_global::<SettingsStore>() {
panic!("Same cx used to create two test clients")
}
cx.set_global(SettingsStore::test(cx));
});
@ -183,13 +188,16 @@ impl TestServer {
let fs = FakeFs::new(cx.background());
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
let channel_store =
cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
let app_state = Arc::new(workspace::AppState {
client: client.clone(),
user_store: user_store.clone(),
channel_store: channel_store.clone(),
languages: Arc::new(LanguageRegistry::test()),
fs: fs.clone(),
build_window_options: |_, _, _| Default::default(),
initialize_workspace: |_, _, _, _| unimplemented!(),
initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
background_actions: || &[],
});
@ -210,12 +218,9 @@ impl TestServer {
.unwrap();
let client = TestClient {
client,
app_state,
username: name.to_string(),
state: Default::default(),
user_store,
fs,
language_registry: Arc::new(LanguageRegistry::test()),
};
client.wait_for_current_user(cx).await;
client
@ -243,6 +248,7 @@ impl TestServer {
let (client_a, cx_a) = left.last_mut().unwrap();
for (client_b, cx_b) in right {
client_a
.app_state
.user_store
.update(*cx_a, |store, cx| {
store.request_contact(client_b.user_id().unwrap(), cx)
@ -251,6 +257,7 @@ impl TestServer {
.unwrap();
cx_a.foreground().run_until_parked();
client_b
.app_state
.user_store
.update(*cx_b, |store, cx| {
store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx)
@ -261,6 +268,52 @@ impl TestServer {
}
}
async fn make_channel(
&self,
channel: &str,
admin: (&TestClient, &mut TestAppContext),
members: &mut [(&TestClient, &mut TestAppContext)],
) -> u64 {
let (admin_client, admin_cx) = admin;
let channel_id = admin_client
.app_state
.channel_store
.update(admin_cx, |channel_store, cx| {
channel_store.create_channel(channel, None, cx)
})
.await
.unwrap();
for (member_client, member_cx) in members {
admin_client
.app_state
.channel_store
.update(admin_cx, |channel_store, cx| {
channel_store.invite_member(
channel_id,
member_client.user_id().unwrap(),
false,
cx,
)
})
.await
.unwrap();
admin_cx.foreground().run_until_parked();
member_client
.app_state
.channel_store
.update(*member_cx, |channels, _| {
channels.respond_to_channel_invite(channel_id, true)
})
.await
.unwrap();
}
channel_id
}
async fn create_room(&self, clients: &mut [(&TestClient, &mut TestAppContext)]) {
self.make_contacts(clients).await;
@ -312,12 +365,9 @@ impl Drop for TestServer {
}
struct TestClient {
client: Arc<Client>,
username: String,
state: RefCell<TestClientState>,
pub user_store: ModelHandle<UserStore>,
language_registry: Arc<LanguageRegistry>,
fs: Arc<FakeFs>,
app_state: Arc<workspace::AppState>,
}
#[derive(Default)]
@ -331,7 +381,7 @@ impl Deref for TestClient {
type Target = Arc<Client>;
fn deref(&self) -> &Self::Target {
&self.client
&self.app_state.client
}
}
@ -342,22 +392,45 @@ struct ContactsSummary {
}
impl TestClient {
pub fn fs(&self) -> &FakeFs {
self.app_state.fs.as_fake()
}
pub fn channel_store(&self) -> &ModelHandle<ChannelStore> {
&self.app_state.channel_store
}
pub fn user_store(&self) -> &ModelHandle<UserStore> {
&self.app_state.user_store
}
pub fn language_registry(&self) -> &Arc<LanguageRegistry> {
&self.app_state.languages
}
pub fn client(&self) -> &Arc<Client> {
&self.app_state.client
}
pub fn current_user_id(&self, cx: &TestAppContext) -> UserId {
UserId::from_proto(
self.user_store
self.app_state
.user_store
.read_with(cx, |user_store, _| user_store.current_user().unwrap().id),
)
}
async fn wait_for_current_user(&self, cx: &TestAppContext) {
let mut authed_user = self
.app_state
.user_store
.read_with(cx, |user_store, _| user_store.watch_current_user());
while authed_user.next().await.unwrap().is_none() {}
}
async fn clear_contacts(&self, cx: &mut TestAppContext) {
self.user_store
self.app_state
.user_store
.update(cx, |store, _| store.clear_contacts())
.await;
}
@ -395,23 +468,25 @@ impl TestClient {
}
fn summarize_contacts(&self, cx: &TestAppContext) -> ContactsSummary {
self.user_store.read_with(cx, |store, _| ContactsSummary {
current: store
.contacts()
.iter()
.map(|contact| contact.user.github_login.clone())
.collect(),
outgoing_requests: store
.outgoing_contact_requests()
.iter()
.map(|user| user.github_login.clone())
.collect(),
incoming_requests: store
.incoming_contact_requests()
.iter()
.map(|user| user.github_login.clone())
.collect(),
})
self.app_state
.user_store
.read_with(cx, |store, _| ContactsSummary {
current: store
.contacts()
.iter()
.map(|contact| contact.user.github_login.clone())
.collect(),
outgoing_requests: store
.outgoing_contact_requests()
.iter()
.map(|user| user.github_login.clone())
.collect(),
incoming_requests: store
.incoming_contact_requests()
.iter()
.map(|user| user.github_login.clone())
.collect(),
})
}
async fn build_local_project(
@ -421,10 +496,10 @@ impl TestClient {
) -> (ModelHandle<Project>, WorktreeId) {
let project = cx.update(|cx| {
Project::local(
self.client.clone(),
self.user_store.clone(),
self.language_registry.clone(),
self.fs.clone(),
self.client().clone(),
self.app_state.user_store.clone(),
self.app_state.languages.clone(),
self.app_state.fs.clone(),
cx,
)
});
@ -450,8 +525,8 @@ impl TestClient {
room.update(guest_cx, |room, cx| {
room.join_project(
host_project_id,
self.language_registry.clone(),
self.fs.clone(),
self.app_state.languages.clone(),
self.app_state.fs.clone(),
cx,
)
})
@ -464,12 +539,36 @@ impl TestClient {
project: &ModelHandle<Project>,
cx: &mut TestAppContext,
) -> WindowHandle<Workspace> {
cx.add_window(|cx| Workspace::test_new(project.clone(), cx))
cx.add_window(|cx| Workspace::new(0, project.clone(), self.app_state.clone(), cx))
}
}
impl Drop for TestClient {
fn drop(&mut self) {
self.client.teardown();
self.app_state.client.teardown();
}
}
#[derive(Debug, Eq, PartialEq)]
struct RoomParticipants {
remote: Vec<String>,
pending: Vec<String>,
}
fn room_participants(room: &ModelHandle<Room>, cx: &mut TestAppContext) -> RoomParticipants {
room.read_with(cx, |room, _| {
let mut remote = room
.remote_participants()
.iter()
.map(|(_, participant)| participant.user.github_login.clone())
.collect::<Vec<_>>();
let mut pending = room
.pending_participants()
.iter()
.map(|user| user.github_login.clone())
.collect::<Vec<_>>();
remote.sort();
pending.sort();
RoomParticipants { remote, pending }
})
}

View file

@ -0,0 +1,922 @@
use crate::{
rpc::RECONNECT_TIMEOUT,
tests::{room_participants, RoomParticipants, TestServer},
};
use call::ActiveCall;
use client::{ChannelId, ChannelMembership, ChannelStore, User};
use gpui::{executor::Deterministic, ModelHandle, TestAppContext};
use rpc::{proto, RECEIVE_TIMEOUT};
use std::sync::Arc;
#[gpui::test]
async fn test_core_channels(
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_a_id = client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
channel_store.create_channel("channel-a", None, cx)
})
.await
.unwrap();
let channel_b_id = client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
channel_store.create_channel("channel-b", Some(channel_a_id), cx)
})
.await
.unwrap();
deterministic.run_until_parked();
assert_channels(
client_a.channel_store(),
cx_a,
&[
ExpectedChannel {
id: channel_a_id,
name: "channel-a".to_string(),
depth: 0,
user_is_admin: true,
},
ExpectedChannel {
id: channel_b_id,
name: "channel-b".to_string(),
depth: 1,
user_is_admin: true,
},
],
);
client_b.channel_store().read_with(cx_b, |channels, _| {
assert!(channels.channels().collect::<Vec<_>>().is_empty())
});
// Invite client B to channel A as client A.
client_a
.channel_store()
.update(cx_a, |store, cx| {
assert!(!store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap()));
let invite = store.invite_member(channel_a_id, client_b.user_id().unwrap(), false, cx);
// Make sure we're synchronously storing the pending invite
assert!(store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap()));
invite
})
.await
.unwrap();
// Client A sees that B has been invited.
deterministic.run_until_parked();
assert_channel_invitations(
client_b.channel_store(),
cx_b,
&[ExpectedChannel {
id: channel_a_id,
name: "channel-a".to_string(),
depth: 0,
user_is_admin: false,
}],
);
let members = client_a
.channel_store()
.update(cx_a, |store, cx| {
assert!(!store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap()));
store.get_channel_member_details(channel_a_id, cx)
})
.await
.unwrap();
assert_members_eq(
&members,
&[
(
client_a.user_id().unwrap(),
true,
proto::channel_member::Kind::Member,
),
(
client_b.user_id().unwrap(),
false,
proto::channel_member::Kind::Invitee,
),
],
);
// Client B accepts the invitation.
client_b
.channel_store()
.update(cx_b, |channels, _| {
channels.respond_to_channel_invite(channel_a_id, true)
})
.await
.unwrap();
deterministic.run_until_parked();
// Client B now sees that they are a member of channel A and its existing subchannels.
assert_channel_invitations(client_b.channel_store(), cx_b, &[]);
assert_channels(
client_b.channel_store(),
cx_b,
&[
ExpectedChannel {
id: channel_a_id,
name: "channel-a".to_string(),
user_is_admin: false,
depth: 0,
},
ExpectedChannel {
id: channel_b_id,
name: "channel-b".to_string(),
user_is_admin: false,
depth: 1,
},
],
);
let channel_c_id = client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
channel_store.create_channel("channel-c", Some(channel_b_id), cx)
})
.await
.unwrap();
deterministic.run_until_parked();
assert_channels(
client_b.channel_store(),
cx_b,
&[
ExpectedChannel {
id: channel_a_id,
name: "channel-a".to_string(),
user_is_admin: false,
depth: 0,
},
ExpectedChannel {
id: channel_b_id,
name: "channel-b".to_string(),
user_is_admin: false,
depth: 1,
},
ExpectedChannel {
id: channel_c_id,
name: "channel-c".to_string(),
user_is_admin: false,
depth: 2,
},
],
);
// Update client B's membership to channel A to be an admin.
client_a
.channel_store()
.update(cx_a, |store, cx| {
store.set_member_admin(channel_a_id, client_b.user_id().unwrap(), true, cx)
})
.await
.unwrap();
deterministic.run_until_parked();
// Observe that client B is now an admin of channel A, and that
// their admin priveleges extend to subchannels of channel A.
assert_channel_invitations(client_b.channel_store(), cx_b, &[]);
assert_channels(
client_b.channel_store(),
cx_b,
&[
ExpectedChannel {
id: channel_a_id,
name: "channel-a".to_string(),
depth: 0,
user_is_admin: true,
},
ExpectedChannel {
id: channel_b_id,
name: "channel-b".to_string(),
depth: 1,
user_is_admin: true,
},
ExpectedChannel {
id: channel_c_id,
name: "channel-c".to_string(),
depth: 2,
user_is_admin: true,
},
],
);
// Client A deletes the channel, deletion also deletes subchannels.
client_a
.channel_store()
.update(cx_a, |channel_store, _| {
channel_store.remove_channel(channel_b_id)
})
.await
.unwrap();
deterministic.run_until_parked();
assert_channels(
client_a.channel_store(),
cx_a,
&[ExpectedChannel {
id: channel_a_id,
name: "channel-a".to_string(),
depth: 0,
user_is_admin: true,
}],
);
assert_channels(
client_b.channel_store(),
cx_b,
&[ExpectedChannel {
id: channel_a_id,
name: "channel-a".to_string(),
depth: 0,
user_is_admin: true,
}],
);
// Remove client B
client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
channel_store.remove_member(channel_a_id, client_b.user_id().unwrap(), cx)
})
.await
.unwrap();
deterministic.run_until_parked();
// Client A still has their channel
assert_channels(
client_a.channel_store(),
cx_a,
&[ExpectedChannel {
id: channel_a_id,
name: "channel-a".to_string(),
depth: 0,
user_is_admin: true,
}],
);
// Client B no longer has access to the channel
assert_channels(client_b.channel_store(), cx_b, &[]);
// When disconnected, client A sees no channels.
server.forbid_connections();
server.disconnect_client(client_a.peer_id().unwrap());
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
assert_channels(client_a.channel_store(), cx_a, &[]);
server.allow_connections();
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
assert_channels(
client_a.channel_store(),
cx_a,
&[ExpectedChannel {
id: channel_a_id,
name: "channel-a".to_string(),
depth: 0,
user_is_admin: true,
}],
);
}
#[track_caller]
fn assert_participants_eq(participants: &[Arc<User>], expected_partitipants: &[u64]) {
assert_eq!(
participants.iter().map(|p| p.id).collect::<Vec<_>>(),
expected_partitipants
);
}
#[track_caller]
fn assert_members_eq(
members: &[ChannelMembership],
expected_members: &[(u64, bool, proto::channel_member::Kind)],
) {
assert_eq!(
members
.iter()
.map(|member| (member.user.id, member.admin, member.kind))
.collect::<Vec<_>>(),
expected_members
);
}
#[gpui::test]
async fn test_joining_channel_ancestor_member(
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 parent_id = server
.make_channel("parent", (&client_a, cx_a), &mut [(&client_b, cx_b)])
.await;
let sub_id = client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
channel_store.create_channel("sub_channel", Some(parent_id), cx)
})
.await
.unwrap();
let active_call_b = cx_b.read(ActiveCall::global);
assert!(active_call_b
.update(cx_b, |active_call, cx| active_call.join_channel(sub_id, cx))
.await
.is_ok());
}
#[gpui::test]
async fn test_channel_room(
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 zed_id = server
.make_channel(
"zed",
(&client_a, cx_a),
&mut [(&client_b, cx_b), (&client_c, cx_c)],
)
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
active_call_a
.update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx))
.await
.unwrap();
// Give everyone a chance to observe user A joining
deterministic.run_until_parked();
client_a.channel_store().read_with(cx_a, |channels, _| {
assert_participants_eq(
channels.channel_participants(zed_id),
&[client_a.user_id().unwrap()],
);
});
assert_channels(
client_b.channel_store(),
cx_b,
&[ExpectedChannel {
id: zed_id,
name: "zed".to_string(),
depth: 0,
user_is_admin: false,
}],
);
client_b.channel_store().read_with(cx_b, |channels, _| {
assert_participants_eq(
channels.channel_participants(zed_id),
&[client_a.user_id().unwrap()],
);
});
client_c.channel_store().read_with(cx_c, |channels, _| {
assert_participants_eq(
channels.channel_participants(zed_id),
&[client_a.user_id().unwrap()],
);
});
active_call_b
.update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx))
.await
.unwrap();
deterministic.run_until_parked();
client_a.channel_store().read_with(cx_a, |channels, _| {
assert_participants_eq(
channels.channel_participants(zed_id),
&[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
);
});
client_b.channel_store().read_with(cx_b, |channels, _| {
assert_participants_eq(
channels.channel_participants(zed_id),
&[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
);
});
client_c.channel_store().read_with(cx_c, |channels, _| {
assert_participants_eq(
channels.channel_participants(zed_id),
&[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
);
});
let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
room_a.read_with(cx_a, |room, _| assert!(room.is_connected()));
assert_eq!(
room_participants(&room_a, cx_a),
RoomParticipants {
remote: vec!["user_b".to_string()],
pending: vec![]
}
);
let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
room_b.read_with(cx_b, |room, _| assert!(room.is_connected()));
assert_eq!(
room_participants(&room_b, cx_b),
RoomParticipants {
remote: vec!["user_a".to_string()],
pending: vec![]
}
);
// Make sure that leaving and rejoining works
active_call_a
.update(cx_a, |active_call, cx| active_call.hang_up(cx))
.await
.unwrap();
deterministic.run_until_parked();
client_a.channel_store().read_with(cx_a, |channels, _| {
assert_participants_eq(
channels.channel_participants(zed_id),
&[client_b.user_id().unwrap()],
);
});
client_b.channel_store().read_with(cx_b, |channels, _| {
assert_participants_eq(
channels.channel_participants(zed_id),
&[client_b.user_id().unwrap()],
);
});
client_c.channel_store().read_with(cx_c, |channels, _| {
assert_participants_eq(
channels.channel_participants(zed_id),
&[client_b.user_id().unwrap()],
);
});
active_call_b
.update(cx_b, |active_call, cx| active_call.hang_up(cx))
.await
.unwrap();
deterministic.run_until_parked();
client_a.channel_store().read_with(cx_a, |channels, _| {
assert_participants_eq(channels.channel_participants(zed_id), &[]);
});
client_b.channel_store().read_with(cx_b, |channels, _| {
assert_participants_eq(channels.channel_participants(zed_id), &[]);
});
client_c.channel_store().read_with(cx_c, |channels, _| {
assert_participants_eq(channels.channel_participants(zed_id), &[]);
});
active_call_a
.update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx))
.await
.unwrap();
active_call_b
.update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx))
.await
.unwrap();
deterministic.run_until_parked();
let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
room_a.read_with(cx_a, |room, _| assert!(room.is_connected()));
assert_eq!(
room_participants(&room_a, cx_a),
RoomParticipants {
remote: vec!["user_b".to_string()],
pending: vec![]
}
);
let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
room_b.read_with(cx_b, |room, _| assert!(room.is_connected()));
assert_eq!(
room_participants(&room_b, cx_b),
RoomParticipants {
remote: vec!["user_a".to_string()],
pending: vec![]
}
);
}
#[gpui::test]
async fn test_channel_jumping(deterministic: Arc<Deterministic>, cx_a: &mut TestAppContext) {
deterministic.forbid_parking();
let mut server = TestServer::start(&deterministic).await;
let client_a = server.create_client(cx_a, "user_a").await;
let zed_id = server.make_channel("zed", (&client_a, cx_a), &mut []).await;
let rust_id = server
.make_channel("rust", (&client_a, cx_a), &mut [])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
active_call_a
.update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx))
.await
.unwrap();
// Give everything a chance to observe user A joining
deterministic.run_until_parked();
client_a.channel_store().read_with(cx_a, |channels, _| {
assert_participants_eq(
channels.channel_participants(zed_id),
&[client_a.user_id().unwrap()],
);
assert_participants_eq(channels.channel_participants(rust_id), &[]);
});
active_call_a
.update(cx_a, |active_call, cx| {
active_call.join_channel(rust_id, cx)
})
.await
.unwrap();
deterministic.run_until_parked();
client_a.channel_store().read_with(cx_a, |channels, _| {
assert_participants_eq(channels.channel_participants(zed_id), &[]);
assert_participants_eq(
channels.channel_participants(rust_id),
&[client_a.user_id().unwrap()],
);
});
}
#[gpui::test]
async fn test_permissions_update_while_invited(
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 rust_id = server
.make_channel("rust", (&client_a, cx_a), &mut [])
.await;
client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
channel_store.invite_member(rust_id, client_b.user_id().unwrap(), false, cx)
})
.await
.unwrap();
deterministic.run_until_parked();
assert_channel_invitations(
client_b.channel_store(),
cx_b,
&[ExpectedChannel {
depth: 0,
id: rust_id,
name: "rust".to_string(),
user_is_admin: false,
}],
);
assert_channels(client_b.channel_store(), cx_b, &[]);
// Update B's invite before they've accepted it
client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
channel_store.set_member_admin(rust_id, client_b.user_id().unwrap(), true, cx)
})
.await
.unwrap();
deterministic.run_until_parked();
assert_channel_invitations(
client_b.channel_store(),
cx_b,
&[ExpectedChannel {
depth: 0,
id: rust_id,
name: "rust".to_string(),
user_is_admin: false,
}],
);
assert_channels(client_b.channel_store(), cx_b, &[]);
}
#[gpui::test]
async fn test_channel_rename(
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 rust_id = server
.make_channel("rust", (&client_a, cx_a), &mut [(&client_b, cx_b)])
.await;
// Rename the channel
client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
channel_store.rename(rust_id, "#rust-archive", cx)
})
.await
.unwrap();
deterministic.run_until_parked();
// Client A sees the channel with its new name.
assert_channels(
client_a.channel_store(),
cx_a,
&[ExpectedChannel {
depth: 0,
id: rust_id,
name: "rust-archive".to_string(),
user_is_admin: true,
}],
);
// Client B sees the channel with its new name.
assert_channels(
client_b.channel_store(),
cx_b,
&[ExpectedChannel {
depth: 0,
id: rust_id,
name: "rust-archive".to_string(),
user_is_admin: false,
}],
);
}
#[gpui::test]
async fn test_call_from_channel(
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;
server
.make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let channel_id = server
.make_channel(
"x",
(&client_a, cx_a),
&mut [(&client_b, cx_b), (&client_c, cx_c)],
)
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
active_call_a
.update(cx_a, |call, cx| call.join_channel(channel_id, cx))
.await
.unwrap();
// Client A calls client B while in the channel.
active_call_a
.update(cx_a, |call, cx| {
call.invite(client_b.user_id().unwrap(), None, cx)
})
.await
.unwrap();
// Client B accepts the call.
deterministic.run_until_parked();
active_call_b
.update(cx_b, |call, cx| call.accept_incoming(cx))
.await
.unwrap();
// Client B sees that they are now in the channel
deterministic.run_until_parked();
active_call_b.read_with(cx_b, |call, cx| {
assert_eq!(call.channel_id(cx), Some(channel_id));
});
client_b.channel_store().read_with(cx_b, |channels, _| {
assert_participants_eq(
channels.channel_participants(channel_id),
&[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
);
});
// Clients A and C also see that client B is in the channel.
client_a.channel_store().read_with(cx_a, |channels, _| {
assert_participants_eq(
channels.channel_participants(channel_id),
&[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
);
});
client_c.channel_store().read_with(cx_c, |channels, _| {
assert_participants_eq(
channels.channel_participants(channel_id),
&[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
);
});
}
#[gpui::test]
async fn test_lost_channel_creation(
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;
server
.make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let channel_id = server.make_channel("x", (&client_a, cx_a), &mut []).await;
// Invite a member
client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
channel_store.invite_member(channel_id, client_b.user_id().unwrap(), false, cx)
})
.await
.unwrap();
deterministic.run_until_parked();
// Sanity check
assert_channel_invitations(
client_b.channel_store(),
cx_b,
&[ExpectedChannel {
depth: 0,
id: channel_id,
name: "x".to_string(),
user_is_admin: false,
}],
);
let subchannel_id = client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
channel_store.create_channel("subchannel", Some(channel_id), cx)
})
.await
.unwrap();
deterministic.run_until_parked();
// Make sure A sees their new channel
assert_channels(
client_a.channel_store(),
cx_a,
&[
ExpectedChannel {
depth: 0,
id: channel_id,
name: "x".to_string(),
user_is_admin: true,
},
ExpectedChannel {
depth: 1,
id: subchannel_id,
name: "subchannel".to_string(),
user_is_admin: true,
},
],
);
// Accept the invite
client_b
.channel_store()
.update(cx_b, |channel_store, _| {
channel_store.respond_to_channel_invite(channel_id, true)
})
.await
.unwrap();
deterministic.run_until_parked();
// B should now see the channel
assert_channels(
client_b.channel_store(),
cx_b,
&[
ExpectedChannel {
depth: 0,
id: channel_id,
name: "x".to_string(),
user_is_admin: false,
},
ExpectedChannel {
depth: 1,
id: subchannel_id,
name: "subchannel".to_string(),
user_is_admin: false,
},
],
);
}
#[derive(Debug, PartialEq)]
struct ExpectedChannel {
depth: usize,
id: ChannelId,
name: String,
user_is_admin: bool,
}
#[track_caller]
fn assert_channel_invitations(
channel_store: &ModelHandle<ChannelStore>,
cx: &TestAppContext,
expected_channels: &[ExpectedChannel],
) {
let actual = channel_store.read_with(cx, |store, _| {
store
.channel_invitations()
.iter()
.map(|channel| ExpectedChannel {
depth: 0,
name: channel.name.clone(),
id: channel.id,
user_is_admin: store.is_user_admin(channel.id),
})
.collect::<Vec<_>>()
});
assert_eq!(actual, expected_channels);
}
#[track_caller]
fn assert_channels(
channel_store: &ModelHandle<ChannelStore>,
cx: &TestAppContext,
expected_channels: &[ExpectedChannel],
) {
let actual = channel_store.read_with(cx, |store, _| {
store
.channels()
.map(|(depth, channel)| ExpectedChannel {
depth,
name: channel.name.clone(),
id: channel.id,
user_is_admin: store.is_user_admin(channel.id),
})
.collect::<Vec<_>>()
});
assert_eq!(actual, expected_channels);
}

View file

@ -1,6 +1,6 @@
use crate::{
rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT},
tests::{TestClient, TestServer},
tests::{room_participants, RoomParticipants, TestClient, TestServer},
};
use call::{room, ActiveCall, ParticipantLocation, Room};
use client::{User, RECEIVE_TIMEOUT};
@ -748,7 +748,7 @@ async fn test_server_restarts(
let mut server = TestServer::start(&deterministic).await;
let client_a = server.create_client(cx_a, "user_a").await;
client_a
.fs
.fs()
.insert_tree("/a", json!({ "a.txt": "a-contents" }))
.await;
@ -1220,7 +1220,7 @@ async fn test_share_project(
let active_call_c = cx_c.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
@ -1387,7 +1387,7 @@ async fn test_unshare_project(
let active_call_b = cx_b.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
@ -1476,7 +1476,7 @@ async fn test_host_disconnect(
cx_b.update(editor::init);
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
@ -1498,7 +1498,8 @@ async fn test_host_disconnect(
deterministic.run_until_parked();
assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
let window_b = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx));
let window_b =
cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx));
let workspace_b = window_b.root(cx_b);
let editor_b = workspace_b
.update(cx_b, |workspace, cx| {
@ -1581,7 +1582,7 @@ async fn test_project_reconnect(
cx_b.update(editor::init);
client_a
.fs
.fs()
.insert_tree(
"/root-1",
json!({
@ -1609,7 +1610,7 @@ async fn test_project_reconnect(
)
.await;
client_a
.fs
.fs()
.insert_tree(
"/root-2",
json!({
@ -1618,7 +1619,7 @@ async fn test_project_reconnect(
)
.await;
client_a
.fs
.fs()
.insert_tree(
"/root-3",
json!({
@ -1698,7 +1699,7 @@ async fn test_project_reconnect(
// While client A is disconnected, add and remove files from client A's project.
client_a
.fs
.fs()
.insert_tree(
"/root-1/dir1/subdir2",
json!({
@ -1710,7 +1711,7 @@ async fn test_project_reconnect(
)
.await;
client_a
.fs
.fs()
.remove_dir(
"/root-1/dir1/subdir1".as_ref(),
RemoveOptions {
@ -1832,11 +1833,11 @@ async fn test_project_reconnect(
// While client B is disconnected, add and remove files from client A's project
client_a
.fs
.fs()
.insert_file("/root-1/dir1/subdir2/j.txt", "j-contents".into())
.await;
client_a
.fs
.fs()
.remove_file("/root-1/dir1/subdir2/i.txt".as_ref(), Default::default())
.await
.unwrap();
@ -1922,8 +1923,8 @@ async fn test_active_call_events(
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;
client_a.fs.insert_tree("/a", json!({})).await;
client_b.fs.insert_tree("/b", json!({})).await;
client_a.fs().insert_tree("/a", json!({})).await;
client_b.fs().insert_tree("/b", json!({})).await;
let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
let (project_b, _) = client_b.build_local_project("/b", cx_b).await;
@ -2011,8 +2012,8 @@ async fn test_room_location(
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;
client_a.fs.insert_tree("/a", json!({})).await;
client_b.fs.insert_tree("/b", json!({})).await;
client_a.fs().insert_tree("/a", json!({})).await;
client_b.fs().insert_tree("/b", json!({})).await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
@ -2201,12 +2202,12 @@ async fn test_propagate_saves_and_fs_changes(
Some(tree_sitter_rust::language()),
));
for client in [&client_a, &client_b, &client_c] {
client.language_registry.add(rust.clone());
client.language_registry.add(javascript.clone());
client.language_registry().add(rust.clone());
client.language_registry().add(javascript.clone());
}
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
@ -2276,7 +2277,7 @@ async fn test_propagate_saves_and_fs_changes(
buffer_a.update(cx_a, |buf, cx| buf.edit([(0..0, "hi-a, ")], None, cx));
save_b.await.unwrap();
assert_eq!(
client_a.fs.load("/a/file1.rs".as_ref()).await.unwrap(),
client_a.fs().load("/a/file1.rs".as_ref()).await.unwrap(),
"hi-a, i-am-c, i-am-b, i-am-a"
);
@ -2287,7 +2288,7 @@ async fn test_propagate_saves_and_fs_changes(
// Make changes on host's file system, see those changes on guest worktrees.
client_a
.fs
.fs()
.rename(
"/a/file1.rs".as_ref(),
"/a/file1.js".as_ref(),
@ -2296,11 +2297,11 @@ async fn test_propagate_saves_and_fs_changes(
.await
.unwrap();
client_a
.fs
.fs()
.rename("/a/file2".as_ref(), "/a/file3".as_ref(), Default::default())
.await
.unwrap();
client_a.fs.insert_file("/a/file4", "4".into()).await;
client_a.fs().insert_file("/a/file4", "4".into()).await;
deterministic.run_until_parked();
worktree_a.read_with(cx_a, |tree, _| {
@ -2394,7 +2395,7 @@ async fn test_git_diff_base_change(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree(
"/dir",
json!({
@ -2438,7 +2439,7 @@ async fn test_git_diff_base_change(
"
.unindent();
client_a.fs.as_fake().set_index_for_repo(
client_a.fs().set_index_for_repo(
Path::new("/dir/.git"),
&[(Path::new("a.txt"), diff_base.clone())],
);
@ -2483,7 +2484,7 @@ async fn test_git_diff_base_change(
);
});
client_a.fs.as_fake().set_index_for_repo(
client_a.fs().set_index_for_repo(
Path::new("/dir/.git"),
&[(Path::new("a.txt"), new_diff_base.clone())],
);
@ -2528,7 +2529,7 @@ async fn test_git_diff_base_change(
"
.unindent();
client_a.fs.as_fake().set_index_for_repo(
client_a.fs().set_index_for_repo(
Path::new("/dir/sub/.git"),
&[(Path::new("b.txt"), diff_base.clone())],
);
@ -2573,7 +2574,7 @@ async fn test_git_diff_base_change(
);
});
client_a.fs.as_fake().set_index_for_repo(
client_a.fs().set_index_for_repo(
Path::new("/dir/sub/.git"),
&[(Path::new("b.txt"), new_diff_base.clone())],
);
@ -2632,7 +2633,7 @@ async fn test_git_branch_name(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree(
"/dir",
json!({
@ -2651,8 +2652,7 @@ async fn test_git_branch_name(
let project_remote = client_b.build_remote_project(project_id, cx_b).await;
client_a
.fs
.as_fake()
.fs()
.set_branch_name(Path::new("/dir/.git"), Some("branch-1"));
// Wait for it to catch up to the new branch
@ -2677,8 +2677,7 @@ async fn test_git_branch_name(
});
client_a
.fs
.as_fake()
.fs()
.set_branch_name(Path::new("/dir/.git"), Some("branch-2"));
// Wait for buffer_local_a to receive it
@ -2717,7 +2716,7 @@ async fn test_git_status_sync(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree(
"/dir",
json!({
@ -2731,7 +2730,7 @@ async fn test_git_status_sync(
const A_TXT: &'static str = "a.txt";
const B_TXT: &'static str = "b.txt";
client_a.fs.as_fake().set_status_for_repo_via_git_operation(
client_a.fs().set_status_for_repo_via_git_operation(
Path::new("/dir/.git"),
&[
(&Path::new(A_TXT), GitFileStatus::Added),
@ -2777,16 +2776,13 @@ async fn test_git_status_sync(
assert_status(&Path::new(B_TXT), Some(GitFileStatus::Added), project, cx);
});
client_a
.fs
.as_fake()
.set_status_for_repo_via_working_copy_change(
Path::new("/dir/.git"),
&[
(&Path::new(A_TXT), GitFileStatus::Modified),
(&Path::new(B_TXT), GitFileStatus::Modified),
],
);
client_a.fs().set_status_for_repo_via_working_copy_change(
Path::new("/dir/.git"),
&[
(&Path::new(A_TXT), GitFileStatus::Modified),
(&Path::new(B_TXT), GitFileStatus::Modified),
],
);
// Wait for buffer_local_a to receive it
deterministic.run_until_parked();
@ -2857,7 +2853,7 @@ async fn test_fs_operations(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree(
"/dir",
json!({
@ -3130,7 +3126,7 @@ async fn test_local_settings(
// As client A, open a project that contains some local settings files
client_a
.fs
.fs()
.insert_tree(
"/dir",
json!({
@ -3172,7 +3168,7 @@ async fn test_local_settings(
// As client A, update a settings file. As Client B, see the changed settings.
client_a
.fs
.fs()
.insert_file("/dir/.zed/settings.json", r#"{}"#.into())
.await;
deterministic.run_until_parked();
@ -3189,17 +3185,17 @@ async fn test_local_settings(
// As client A, create and remove some settings files. As client B, see the changed settings.
client_a
.fs
.fs()
.remove_file("/dir/.zed/settings.json".as_ref(), Default::default())
.await
.unwrap();
client_a
.fs
.fs()
.create_dir("/dir/b/.zed".as_ref())
.await
.unwrap();
client_a
.fs
.fs()
.insert_file("/dir/b/.zed/settings.json", r#"{"tab_size": 4}"#.into())
.await;
deterministic.run_until_parked();
@ -3220,11 +3216,11 @@ async fn test_local_settings(
// As client A, change and remove settings files while client B is disconnected.
client_a
.fs
.fs()
.insert_file("/dir/a/.zed/settings.json", r#"{"hard_tabs":true}"#.into())
.await;
client_a
.fs
.fs()
.remove_file("/dir/b/.zed/settings.json".as_ref(), Default::default())
.await
.unwrap();
@ -3258,7 +3254,7 @@ async fn test_buffer_conflict_after_save(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree(
"/dir",
json!({
@ -3320,7 +3316,7 @@ async fn test_buffer_reloading(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree(
"/dir",
json!({
@ -3348,7 +3344,7 @@ async fn test_buffer_reloading(
let new_contents = Rope::from("d\ne\nf");
client_a
.fs
.fs()
.save("/dir/a.txt".as_ref(), &new_contents, LineEnding::Windows)
.await
.unwrap();
@ -3377,7 +3373,7 @@ async fn test_editing_while_guest_opens_buffer(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree("/dir", json!({ "a.txt": "a-contents" }))
.await;
let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
@ -3426,7 +3422,7 @@ async fn test_newline_above_or_below_does_not_move_guest_cursor(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree("/dir", json!({ "a.txt": "Some text\n" }))
.await;
let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
@ -3520,7 +3516,7 @@ async fn test_leaving_worktree_while_opening_buffer(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree("/dir", json!({ "a.txt": "a-contents" }))
.await;
let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
@ -3563,7 +3559,7 @@ async fn test_canceling_buffer_opening(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree(
"/dir",
json!({
@ -3619,7 +3615,7 @@ async fn test_leaving_project(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
@ -3707,9 +3703,9 @@ async fn test_leaving_project(
cx_b.spawn(|cx| {
Project::remote(
project_id,
client_b.client.clone(),
client_b.user_store.clone(),
client_b.language_registry.clone(),
client_b.app_state.client.clone(),
client_b.user_store().clone(),
client_b.language_registry().clone(),
FakeFs::new(cx.background()),
cx,
)
@ -3761,11 +3757,11 @@ async fn test_collaborating_with_diagnostics(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
client_a.language_registry.add(Arc::new(language));
client_a.language_registry().add(Arc::new(language));
// Share a project as client A
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
@ -4033,11 +4029,11 @@ async fn test_collaborating_with_lsp_progress_updates_and_diagnostics_ordering(
..Default::default()
}))
.await;
client_a.language_registry.add(Arc::new(language));
client_a.language_registry().add(Arc::new(language));
let file_names = &["one.rs", "two.rs", "three.rs", "four.rs", "five.rs"];
client_a
.fs
.fs()
.insert_tree(
"/test",
json!({
@ -4167,6 +4163,7 @@ async fn test_collaborating_with_completion(
capabilities: lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions {
trigger_characters: Some(vec![".".to_string()]),
resolve_provider: Some(true),
..Default::default()
}),
..Default::default()
@ -4174,10 +4171,10 @@ async fn test_collaborating_with_completion(
..Default::default()
}))
.await;
client_a.language_registry.add(Arc::new(language));
client_a.language_registry().add(Arc::new(language));
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
@ -4335,7 +4332,7 @@ async fn test_reloading_buffer_manually(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree("/a", json!({ "a.rs": "let one = 1;" }))
.await;
let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
@ -4366,7 +4363,7 @@ async fn test_reloading_buffer_manually(
buffer_a.read_with(cx_a, |buffer, _| assert_eq!(buffer.text(), "let six = 6;"));
client_a
.fs
.fs()
.save(
"/a/a.rs".as_ref(),
&Rope::from("let seven = 7;"),
@ -4437,14 +4434,14 @@ async fn test_formatting_buffer(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
client_a.language_registry.add(Arc::new(language));
client_a.language_registry().add(Arc::new(language));
// Here we insert a fake tree with a directory that exists on disk. This is needed
// because later we'll invoke a command, which requires passing a working directory
// that points to a valid location on disk.
let directory = env::current_dir().unwrap();
client_a
.fs
.fs()
.insert_tree(&directory, json!({ "a.rs": "let one = \"two\"" }))
.await;
let (project_a, worktree_id) = client_a.build_local_project(&directory, cx_a).await;
@ -4546,10 +4543,10 @@ async fn test_definition(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
client_a.language_registry.add(Arc::new(language));
client_a.language_registry().add(Arc::new(language));
client_a
.fs
.fs()
.insert_tree(
"/root",
json!({
@ -4694,10 +4691,10 @@ async fn test_references(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
client_a.language_registry.add(Arc::new(language));
client_a.language_registry().add(Arc::new(language));
client_a
.fs
.fs()
.insert_tree(
"/root",
json!({
@ -4790,7 +4787,7 @@ async fn test_project_search(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree(
"/root",
json!({
@ -4876,7 +4873,7 @@ async fn test_document_highlights(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree(
"/root-1",
json!({
@ -4895,7 +4892,7 @@ async fn test_document_highlights(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
client_a.language_registry.add(Arc::new(language));
client_a.language_registry().add(Arc::new(language));
let (project_a, worktree_id) = client_a.build_local_project("/root-1", cx_a).await;
let project_id = active_call_a
@ -4982,7 +4979,7 @@ async fn test_lsp_hover(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs
.fs()
.insert_tree(
"/root-1",
json!({
@ -5001,7 +4998,7 @@ async fn test_lsp_hover(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
client_a.language_registry.add(Arc::new(language));
client_a.language_registry().add(Arc::new(language));
let (project_a, worktree_id) = client_a.build_local_project("/root-1", cx_a).await;
let project_id = active_call_a
@ -5100,10 +5097,10 @@ async fn test_project_symbols(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
client_a.language_registry.add(Arc::new(language));
client_a.language_registry().add(Arc::new(language));
client_a
.fs
.fs()
.insert_tree(
"/code",
json!({
@ -5211,10 +5208,10 @@ async fn test_open_buffer_while_getting_definition_pointing_to_it(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
client_a.language_registry.add(Arc::new(language));
client_a.language_registry().add(Arc::new(language));
client_a
.fs
.fs()
.insert_tree(
"/root",
json!({
@ -5271,6 +5268,7 @@ async fn test_collaborating_with_code_actions(
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;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
@ -5289,10 +5287,10 @@ async fn test_collaborating_with_code_actions(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
client_a.language_registry.add(Arc::new(language));
client_a.language_registry().add(Arc::new(language));
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
@ -5309,7 +5307,8 @@ async fn test_collaborating_with_code_actions(
// Join the project as client B.
let project_b = client_b.build_remote_project(project_id, cx_b).await;
let window_b = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx));
let window_b =
cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx));
let workspace_b = window_b.root(cx_b);
let editor_b = workspace_b
.update(cx_b, |workspace, cx| {
@ -5515,10 +5514,10 @@ async fn test_collaborating_with_renames(
..Default::default()
}))
.await;
client_a.language_registry.add(Arc::new(language));
client_a.language_registry().add(Arc::new(language));
client_a
.fs
.fs()
.insert_tree(
"/dir",
json!({
@ -5534,7 +5533,8 @@ async fn test_collaborating_with_renames(
.unwrap();
let project_b = client_b.build_remote_project(project_id, cx_b).await;
let window_b = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx));
let window_b =
cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx));
let workspace_b = window_b.root(cx_b);
let editor_b = workspace_b
.update(cx_b, |workspace, cx| {
@ -5702,10 +5702,10 @@ async fn test_language_server_statuses(
..Default::default()
}))
.await;
client_a.language_registry.add(Arc::new(language));
client_a.language_registry().add(Arc::new(language));
client_a
.fs
.fs()
.insert_tree(
"/dir",
json!({
@ -6162,7 +6162,7 @@ async fn test_contacts(
// Test removing a contact
client_b
.user_store
.user_store()
.update(cx_b, |store, cx| {
store.remove_contact(client_c.user_id().unwrap(), cx)
})
@ -6185,7 +6185,7 @@ async fn test_contacts(
client: &TestClient,
cx: &TestAppContext,
) -> Vec<(String, &'static str, &'static str)> {
client.user_store.read_with(cx, |store, _| {
client.user_store().read_with(cx, |store, _| {
store
.contacts()
.iter()
@ -6228,14 +6228,14 @@ async fn test_contact_requests(
// User A and User C request that user B become their contact.
client_a
.user_store
.user_store()
.update(cx_a, |store, cx| {
store.request_contact(client_b.user_id().unwrap(), cx)
})
.await
.unwrap();
client_c
.user_store
.user_store()
.update(cx_c, |store, cx| {
store.request_contact(client_b.user_id().unwrap(), cx)
})
@ -6289,7 +6289,7 @@ async fn test_contact_requests(
// User B accepts the request from user A.
client_b
.user_store
.user_store()
.update(cx_b, |store, cx| {
store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx)
})
@ -6333,7 +6333,7 @@ async fn test_contact_requests(
// User B rejects the request from user C.
client_b
.user_store
.user_store()
.update(cx_b, |store, cx| {
store.respond_to_contact_request(client_c.user_id().unwrap(), false, cx)
})
@ -6415,7 +6415,7 @@ async fn test_basic_following(
cx_b.update(editor::init);
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
@ -6978,7 +6978,7 @@ async fn test_join_call_after_screen_was_shared(
.await
.unwrap();
client_b.user_store.update(cx_b, |user_store, _| {
client_b.user_store().update(cx_b, |user_store, _| {
user_store.clear_cache();
});
@ -7038,7 +7038,7 @@ async fn test_following_tab_order(
cx_b.update(editor::init);
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
@ -7161,7 +7161,7 @@ async fn test_peers_following_each_other(
// Client A shares a project.
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
@ -7334,7 +7334,7 @@ async fn test_auto_unfollowing(
// Client A shares a project.
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
@ -7498,7 +7498,7 @@ async fn test_peers_simultaneously_following_each_other(
cx_a.update(editor::init);
cx_b.update(editor::init);
client_a.fs.insert_tree("/a", json!({})).await;
client_a.fs().insert_tree("/a", json!({})).await;
let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a);
let project_id = active_call_a
@ -7575,10 +7575,10 @@ async fn test_on_input_format_from_host_to_guest(
..Default::default()
}))
.await;
client_a.language_registry.add(Arc::new(language));
client_a.language_registry().add(Arc::new(language));
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
@ -7704,10 +7704,10 @@ async fn test_on_input_format_from_guest_to_host(
..Default::default()
}))
.await;
client_a.language_registry.add(Arc::new(language));
client_a.language_registry().add(Arc::new(language));
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
@ -7860,15 +7860,15 @@ async fn test_mutual_editor_inlay_hint_cache_update(
}))
.await;
let language = Arc::new(language);
client_a.language_registry.add(Arc::clone(&language));
client_b.language_registry.add(language);
client_a.language_registry().add(Arc::clone(&language));
client_b.language_registry().add(language);
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
"main.rs": "fn main() { a } // and some long comment to ensure inlays are not trimmed out",
"main.rs": "fn main() { a } // and some long comment to ensure inlay hints are not trimmed out",
"other.rs": "// Test file",
}),
)
@ -7953,7 +7953,8 @@ async fn test_mutual_editor_inlay_hint_cache_update(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
inlay_cache.version, edits_made,
inlay_cache.version(),
edits_made,
"Host editor update the cache version after every cache/view change",
);
});
@ -7976,7 +7977,8 @@ async fn test_mutual_editor_inlay_hint_cache_update(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
inlay_cache.version, edits_made,
inlay_cache.version(),
edits_made,
"Guest editor update the cache version after every cache/view change"
);
});
@ -7996,7 +7998,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
"Host should get hints from the 1st edit and 1st LSP query"
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(inlay_cache.version, edits_made);
assert_eq!(inlay_cache.version(), edits_made);
});
editor_b.update(cx_b, |editor, _| {
assert_eq!(
@ -8010,7 +8012,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
"Guest should get hints the 1st edit and 2nd LSP query"
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(inlay_cache.version, edits_made);
assert_eq!(inlay_cache.version(), edits_made);
});
editor_a.update(cx_a, |editor, cx| {
@ -8035,7 +8037,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
4th query was made by guest (but not applied) due to cache invalidation logic"
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(inlay_cache.version, edits_made);
assert_eq!(inlay_cache.version(), edits_made);
});
editor_b.update(cx_b, |editor, _| {
assert_eq!(
@ -8051,7 +8053,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
"Guest should get hints from 3rd edit, 6th LSP query"
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(inlay_cache.version, edits_made);
assert_eq!(inlay_cache.version(), edits_made);
});
fake_language_server
@ -8077,7 +8079,8 @@ async fn test_mutual_editor_inlay_hint_cache_update(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
inlay_cache.version, edits_made,
inlay_cache.version(),
edits_made,
"Host should accepted all edits and bump its cache version every time"
);
});
@ -8098,7 +8101,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
inlay_cache.version,
inlay_cache.version(),
edits_made,
"Guest should accepted all edits and bump its cache version every time"
);
@ -8167,15 +8170,15 @@ async fn test_inlay_hint_refresh_is_forwarded(
}))
.await;
let language = Arc::new(language);
client_a.language_registry.add(Arc::clone(&language));
client_b.language_registry.add(language);
client_a.language_registry().add(Arc::clone(&language));
client_b.language_registry().add(language);
client_a
.fs
.fs()
.insert_tree(
"/a",
json!({
"main.rs": "fn main() { a } // and some long comment to ensure inlays are not trimmed out",
"main.rs": "fn main() { a } // and some long comment to ensure inlay hints are not trimmed out",
"other.rs": "// Test file",
}),
)
@ -8264,7 +8267,8 @@ async fn test_inlay_hint_refresh_is_forwarded(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
inlay_cache.version, 0,
inlay_cache.version(),
0,
"Host should not increment its cache version due to no changes",
);
});
@ -8279,7 +8283,8 @@ async fn test_inlay_hint_refresh_is_forwarded(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
inlay_cache.version, edits_made,
inlay_cache.version(),
edits_made,
"Guest editor update the cache version after every cache/view change"
);
});
@ -8296,7 +8301,8 @@ async fn test_inlay_hint_refresh_is_forwarded(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
inlay_cache.version, 0,
inlay_cache.version(),
0,
"Host should not increment its cache version due to no changes",
);
});
@ -8311,45 +8317,19 @@ async fn test_inlay_hint_refresh_is_forwarded(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
inlay_cache.version, edits_made,
inlay_cache.version(),
edits_made,
"Guest should accepted all edits and bump its cache version every time"
);
});
}
#[derive(Debug, Eq, PartialEq)]
struct RoomParticipants {
remote: Vec<String>,
pending: Vec<String>,
}
fn room_participants(room: &ModelHandle<Room>, cx: &mut TestAppContext) -> RoomParticipants {
room.read_with(cx, |room, _| {
let mut remote = room
.remote_participants()
.iter()
.map(|(_, participant)| participant.user.github_login.clone())
.collect::<Vec<_>>();
let mut pending = room
.pending_participants()
.iter()
.map(|user| user.github_login.clone())
.collect::<Vec<_>>();
remote.sort();
pending.sort();
RoomParticipants { remote, pending }
})
}
fn extract_hint_labels(editor: &Editor) -> Vec<String> {
let mut labels = Vec::new();
for (_, excerpt_hints) in &editor.inlay_hint_cache().hints {
let excerpt_hints = excerpt_hints.read();
for (_, inlay) in excerpt_hints.hints.iter() {
match &inlay.label {
project::InlayHintLabel::String(s) => labels.push(s.to_string()),
_ => unreachable!(),
}
for hint in editor.inlay_hint_cache().hints() {
match hint.label {
project::InlayHintLabel::String(s) => labels.push(s),
_ => unreachable!(),
}
}
labels

View file

@ -396,9 +396,9 @@ async fn apply_client_operation(
);
let root_path = Path::new("/").join(&first_root_name);
client.fs.create_dir(&root_path).await.unwrap();
client.fs().create_dir(&root_path).await.unwrap();
client
.fs
.fs()
.create_file(&root_path.join("main.rs"), Default::default())
.await
.unwrap();
@ -422,8 +422,8 @@ async fn apply_client_operation(
);
ensure_project_shared(&project, client, cx).await;
if !client.fs.paths(false).contains(&new_root_path) {
client.fs.create_dir(&new_root_path).await.unwrap();
if !client.fs().paths(false).contains(&new_root_path) {
client.fs().create_dir(&new_root_path).await.unwrap();
}
project
.update(cx, |project, cx| {
@ -475,7 +475,7 @@ async fn apply_client_operation(
Some(room.update(cx, |room, cx| {
room.join_project(
project_id,
client.language_registry.clone(),
client.language_registry().clone(),
FakeFs::new(cx.background().clone()),
cx,
)
@ -743,7 +743,7 @@ async fn apply_client_operation(
content,
} => {
if !client
.fs
.fs()
.directories(false)
.contains(&path.parent().unwrap().to_owned())
{
@ -752,14 +752,14 @@ async fn apply_client_operation(
if is_dir {
log::info!("{}: creating dir at {:?}", client.username, path);
client.fs.create_dir(&path).await.unwrap();
client.fs().create_dir(&path).await.unwrap();
} else {
let exists = client.fs.metadata(&path).await?.is_some();
let exists = client.fs().metadata(&path).await?.is_some();
let verb = if exists { "updating" } else { "creating" };
log::info!("{}: {} file at {:?}", verb, client.username, path);
client
.fs
.fs()
.save(&path, &content.as_str().into(), fs::LineEnding::Unix)
.await
.unwrap();
@ -771,12 +771,12 @@ async fn apply_client_operation(
repo_path,
contents,
} => {
if !client.fs.directories(false).contains(&repo_path) {
if !client.fs().directories(false).contains(&repo_path) {
return Err(TestError::Inapplicable);
}
for (path, _) in contents.iter() {
if !client.fs.files().contains(&repo_path.join(path)) {
if !client.fs().files().contains(&repo_path.join(path)) {
return Err(TestError::Inapplicable);
}
}
@ -793,16 +793,16 @@ async fn apply_client_operation(
.iter()
.map(|(path, contents)| (path.as_path(), contents.clone()))
.collect::<Vec<_>>();
if client.fs.metadata(&dot_git_dir).await?.is_none() {
client.fs.create_dir(&dot_git_dir).await?;
if client.fs().metadata(&dot_git_dir).await?.is_none() {
client.fs().create_dir(&dot_git_dir).await?;
}
client.fs.set_index_for_repo(&dot_git_dir, &contents);
client.fs().set_index_for_repo(&dot_git_dir, &contents);
}
GitOperation::WriteGitBranch {
repo_path,
new_branch,
} => {
if !client.fs.directories(false).contains(&repo_path) {
if !client.fs().directories(false).contains(&repo_path) {
return Err(TestError::Inapplicable);
}
@ -814,21 +814,21 @@ async fn apply_client_operation(
);
let dot_git_dir = repo_path.join(".git");
if client.fs.metadata(&dot_git_dir).await?.is_none() {
client.fs.create_dir(&dot_git_dir).await?;
if client.fs().metadata(&dot_git_dir).await?.is_none() {
client.fs().create_dir(&dot_git_dir).await?;
}
client.fs.set_branch_name(&dot_git_dir, new_branch);
client.fs().set_branch_name(&dot_git_dir, new_branch);
}
GitOperation::WriteGitStatuses {
repo_path,
statuses,
git_operation,
} => {
if !client.fs.directories(false).contains(&repo_path) {
if !client.fs().directories(false).contains(&repo_path) {
return Err(TestError::Inapplicable);
}
for (path, _) in statuses.iter() {
if !client.fs.files().contains(&repo_path.join(path)) {
if !client.fs().files().contains(&repo_path.join(path)) {
return Err(TestError::Inapplicable);
}
}
@ -847,16 +847,16 @@ async fn apply_client_operation(
.map(|(path, val)| (path.as_path(), val.clone()))
.collect::<Vec<_>>();
if client.fs.metadata(&dot_git_dir).await?.is_none() {
client.fs.create_dir(&dot_git_dir).await?;
if client.fs().metadata(&dot_git_dir).await?.is_none() {
client.fs().create_dir(&dot_git_dir).await?;
}
if git_operation {
client
.fs
.fs()
.set_status_for_repo_via_git_operation(&dot_git_dir, statuses.as_slice());
} else {
client.fs.set_status_for_repo_via_working_copy_change(
client.fs().set_status_for_repo_via_working_copy_change(
&dot_git_dir,
statuses.as_slice(),
);
@ -1499,7 +1499,7 @@ impl TestPlan {
// Invite a contact to the current call
0..=70 => {
let available_contacts =
client.user_store.read_with(cx, |user_store, _| {
client.user_store().read_with(cx, |user_store, _| {
user_store
.contacts()
.iter()
@ -1596,7 +1596,7 @@ impl TestPlan {
.choose(&mut self.rng)
.cloned() else { continue };
let project_root_name = root_name_for_project(&project, cx);
let mut paths = client.fs.paths(false);
let mut paths = client.fs().paths(false);
paths.remove(0);
let new_root_path = if paths.is_empty() || self.rng.gen() {
Path::new("/").join(&self.next_root_dir_name(user_id))
@ -1776,7 +1776,7 @@ impl TestPlan {
let is_dir = self.rng.gen::<bool>();
let content;
let mut path;
let dir_paths = client.fs.directories(false);
let dir_paths = client.fs().directories(false);
if is_dir {
content = String::new();
@ -1786,7 +1786,7 @@ impl TestPlan {
content = Alphanumeric.sample_string(&mut self.rng, 16);
// Create a new file or overwrite an existing file
let file_paths = client.fs.files();
let file_paths = client.fs().files();
if file_paths.is_empty() || self.rng.gen_bool(0.5) {
path = dir_paths.choose(&mut self.rng).unwrap().clone();
path.push(gen_file_name(&mut self.rng));
@ -1812,7 +1812,7 @@ impl TestPlan {
client: &TestClient,
) -> Vec<PathBuf> {
let mut paths = client
.fs
.fs()
.files()
.into_iter()
.filter(|path| path.starts_with(repo_path))
@ -1829,7 +1829,7 @@ impl TestPlan {
}
let repo_path = client
.fs
.fs()
.directories(false)
.choose(&mut self.rng)
.unwrap()
@ -1928,7 +1928,7 @@ async fn simulate_client(
name: "the-fake-language-server",
capabilities: lsp::LanguageServer::full_capabilities(),
initializer: Some(Box::new({
let fs = client.fs.clone();
let fs = client.app_state.fs.clone();
move |fake_server: &mut FakeLanguageServer| {
fake_server.handle_request::<lsp::request::Completion, _, _>(
|_, _| async move {
@ -1973,7 +1973,7 @@ async fn simulate_client(
let background = cx.background();
let mut rng = background.rng();
let count = rng.gen_range::<usize, _>(1..3);
let files = fs.files();
let files = fs.as_fake().files();
let files = (0..count)
.map(|_| files.choose(&mut *rng).unwrap().clone())
.collect::<Vec<_>>();
@ -2023,7 +2023,7 @@ async fn simulate_client(
..Default::default()
}))
.await;
client.language_registry.add(Arc::new(language));
client.app_state.languages.add(Arc::new(language));
while let Some(batch_id) = operation_rx.next().await {
let Some((operation, applied)) = plan.lock().next_client_operation(&client, batch_id, &cx) else { break };

View file

@ -23,6 +23,7 @@ test-support = [
[dependencies]
auto_update = { path = "../auto_update" }
db = { path = "../db" }
call = { path = "../call" }
client = { path = "../client" }
clock = { path = "../clock" }
@ -37,6 +38,7 @@ picker = { path = "../picker" }
project = { path = "../project" }
recent_projects = {path = "../recent_projects"}
settings = { path = "../settings" }
staff_mode = {path = "../staff_mode"}
theme = { path = "../theme" }
theme_selector = { path = "../theme_selector" }
vcs_menu = { path = "../vcs_menu" }
@ -44,10 +46,10 @@ util = { path = "../util" }
workspace = { path = "../workspace" }
zed-actions = {path = "../zed-actions"}
anyhow.workspace = true
futures.workspace = true
log.workspace = true
schemars.workspace = true
postage.workspace = true
serde.workspace = true
serde_derive.workspace = true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,615 @@
use client::{proto, ChannelId, ChannelMembership, ChannelStore, User, UserId, UserStore};
use context_menu::{ContextMenu, ContextMenuItem};
use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{
actions,
elements::*,
platform::{CursorStyle, MouseButton},
AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle,
};
use picker::{Picker, PickerDelegate, PickerEvent};
use std::sync::Arc;
use util::TryFutureExt;
use workspace::Modal;
actions!(
channel_modal,
[
SelectNextControl,
ToggleMode,
ToggleMemberAdmin,
RemoveMember
]
);
pub fn init(cx: &mut AppContext) {
Picker::<ChannelModalDelegate>::init(cx);
cx.add_action(ChannelModal::toggle_mode);
cx.add_action(ChannelModal::toggle_member_admin);
cx.add_action(ChannelModal::remove_member);
cx.add_action(ChannelModal::dismiss);
}
pub struct ChannelModal {
picker: ViewHandle<Picker<ChannelModalDelegate>>,
channel_store: ModelHandle<ChannelStore>,
channel_id: ChannelId,
has_focus: bool,
}
impl ChannelModal {
pub fn new(
user_store: ModelHandle<UserStore>,
channel_store: ModelHandle<ChannelStore>,
channel_id: ChannelId,
mode: Mode,
members: Vec<ChannelMembership>,
cx: &mut ViewContext<Self>,
) -> Self {
cx.observe(&channel_store, |_, _, cx| cx.notify()).detach();
let picker = cx.add_view(|cx| {
Picker::new(
ChannelModalDelegate {
matching_users: Vec::new(),
matching_member_indices: Vec::new(),
selected_index: 0,
user_store: user_store.clone(),
channel_store: channel_store.clone(),
channel_id,
match_candidates: Vec::new(),
members,
mode,
context_menu: cx.add_view(|cx| {
let mut menu = ContextMenu::new(cx.view_id(), cx);
menu.set_position_mode(OverlayPositionMode::Local);
menu
}),
},
cx,
)
.with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone())
});
cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach();
let has_focus = picker.read(cx).has_focus();
Self {
picker,
channel_store,
channel_id,
has_focus,
}
}
fn toggle_mode(&mut self, _: &ToggleMode, cx: &mut ViewContext<Self>) {
let mode = match self.picker.read(cx).delegate().mode {
Mode::ManageMembers => Mode::InviteMembers,
Mode::InviteMembers => Mode::ManageMembers,
};
self.set_mode(mode, cx);
}
fn set_mode(&mut self, mode: Mode, cx: &mut ViewContext<Self>) {
let channel_store = self.channel_store.clone();
let channel_id = self.channel_id;
cx.spawn(|this, mut cx| async move {
if mode == Mode::ManageMembers {
let members = channel_store
.update(&mut cx, |channel_store, cx| {
channel_store.get_channel_member_details(channel_id, cx)
})
.await?;
this.update(&mut cx, |this, cx| {
this.picker
.update(cx, |picker, _| picker.delegate_mut().members = members);
})?;
}
this.update(&mut cx, |this, cx| {
this.picker.update(cx, |picker, cx| {
let delegate = picker.delegate_mut();
delegate.mode = mode;
delegate.selected_index = 0;
picker.set_query("", cx);
picker.update_matches(picker.query(cx), cx);
cx.notify()
});
cx.notify()
})
})
.detach();
}
fn toggle_member_admin(&mut self, _: &ToggleMemberAdmin, cx: &mut ViewContext<Self>) {
self.picker.update(cx, |picker, cx| {
picker.delegate_mut().toggle_selected_member_admin(cx);
})
}
fn remove_member(&mut self, _: &RemoveMember, cx: &mut ViewContext<Self>) {
self.picker.update(cx, |picker, cx| {
picker.delegate_mut().remove_selected_member(cx);
});
}
fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
cx.emit(PickerEvent::Dismiss);
}
}
impl Entity for ChannelModal {
type Event = PickerEvent;
}
impl View for ChannelModal {
fn ui_name() -> &'static str {
"ChannelModal"
}
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
let theme = &theme::current(cx).collab_panel.tabbed_modal;
let mode = self.picker.read(cx).delegate().mode;
let Some(channel) = self
.channel_store
.read(cx)
.channel_for_id(self.channel_id) else {
return Empty::new().into_any()
};
enum InviteMembers {}
enum ManageMembers {}
fn render_mode_button<T: 'static>(
mode: Mode,
text: &'static str,
current_mode: Mode,
theme: &theme::TabbedModal,
cx: &mut ViewContext<ChannelModal>,
) -> AnyElement<ChannelModal> {
let active = mode == current_mode;
MouseEventHandler::new::<T, _>(0, cx, move |state, _| {
let contained_text = theme.tab_button.style_for(active, state);
Label::new(text, contained_text.text.clone())
.contained()
.with_style(contained_text.container.clone())
})
.on_click(MouseButton::Left, move |_, this, cx| {
if !active {
this.set_mode(mode, cx);
}
})
.with_cursor_style(CursorStyle::PointingHand)
.into_any()
}
Flex::column()
.with_child(
Flex::column()
.with_child(
Label::new(format!("#{}", channel.name), theme.title.text.clone())
.contained()
.with_style(theme.title.container.clone()),
)
.with_child(Flex::row().with_children([
render_mode_button::<InviteMembers>(
Mode::InviteMembers,
"Invite members",
mode,
theme,
cx,
),
render_mode_button::<ManageMembers>(
Mode::ManageMembers,
"Manage members",
mode,
theme,
cx,
),
]))
.expanded()
.contained()
.with_style(theme.header),
)
.with_child(
ChildView::new(&self.picker, cx)
.contained()
.with_style(theme.body),
)
.constrained()
.with_max_height(theme.max_height)
.with_max_width(theme.max_width)
.contained()
.with_style(theme.modal)
.into_any()
}
fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
self.has_focus = true;
if cx.is_self_focused() {
cx.focus(&self.picker)
}
}
fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
self.has_focus = false;
}
}
impl Modal for ChannelModal {
fn has_focus(&self) -> bool {
self.has_focus
}
fn dismiss_on_event(event: &Self::Event) -> bool {
match event {
PickerEvent::Dismiss => true,
}
}
}
#[derive(Copy, Clone, PartialEq)]
pub enum Mode {
ManageMembers,
InviteMembers,
}
pub struct ChannelModalDelegate {
matching_users: Vec<Arc<User>>,
matching_member_indices: Vec<usize>,
user_store: ModelHandle<UserStore>,
channel_store: ModelHandle<ChannelStore>,
channel_id: ChannelId,
selected_index: usize,
mode: Mode,
match_candidates: Vec<StringMatchCandidate>,
members: Vec<ChannelMembership>,
context_menu: ViewHandle<ContextMenu>,
}
impl PickerDelegate for ChannelModalDelegate {
fn placeholder_text(&self) -> Arc<str> {
"Search collaborator by username...".into()
}
fn match_count(&self) -> usize {
match self.mode {
Mode::ManageMembers => self.matching_member_indices.len(),
Mode::InviteMembers => self.matching_users.len(),
}
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
self.selected_index = ix;
}
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
match self.mode {
Mode::ManageMembers => {
self.match_candidates.clear();
self.match_candidates
.extend(self.members.iter().enumerate().map(|(id, member)| {
StringMatchCandidate {
id,
string: member.user.github_login.clone(),
char_bag: member.user.github_login.chars().collect(),
}
}));
let matches = cx.background().block(match_strings(
&self.match_candidates,
&query,
true,
usize::MAX,
&Default::default(),
cx.background().clone(),
));
cx.spawn(|picker, mut cx| async move {
picker
.update(&mut cx, |picker, cx| {
let delegate = picker.delegate_mut();
delegate.matching_member_indices.clear();
delegate
.matching_member_indices
.extend(matches.into_iter().map(|m| m.candidate_id));
cx.notify();
})
.ok();
})
}
Mode::InviteMembers => {
let search_users = self
.user_store
.update(cx, |store, cx| store.fuzzy_search_users(query, cx));
cx.spawn(|picker, mut cx| async move {
async {
let users = search_users.await?;
picker.update(&mut cx, |picker, cx| {
let delegate = picker.delegate_mut();
delegate.matching_users = users;
cx.notify();
})?;
anyhow::Ok(())
}
.log_err()
.await;
})
}
}
}
fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
if let Some((selected_user, admin)) = self.user_at_index(self.selected_index) {
match self.mode {
Mode::ManageMembers => self.show_context_menu(admin.unwrap_or(false), cx),
Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
Some(proto::channel_member::Kind::Invitee) => {
self.remove_selected_member(cx);
}
Some(proto::channel_member::Kind::AncestorMember) | None => {
self.invite_member(selected_user, cx)
}
Some(proto::channel_member::Kind::Member) => {}
},
}
}
}
fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
cx.emit(PickerEvent::Dismiss);
}
fn render_match(
&self,
ix: usize,
mouse_state: &mut MouseState,
selected: bool,
cx: &gpui::AppContext,
) -> AnyElement<Picker<Self>> {
let full_theme = &theme::current(cx);
let theme = &full_theme.collab_panel.channel_modal;
let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
let (user, admin) = self.user_at_index(ix).unwrap();
let request_status = self.member_status(user.id, cx);
let style = tabbed_modal
.picker
.item
.in_state(selected)
.style_for(mouse_state);
let in_manage = matches!(self.mode, Mode::ManageMembers);
let mut result = Flex::row()
.with_children(user.avatar.clone().map(|avatar| {
Image::from_data(avatar)
.with_style(theme.contact_avatar)
.aligned()
.left()
}))
.with_child(
Label::new(user.github_login.clone(), style.label.clone())
.contained()
.with_style(theme.contact_username)
.aligned()
.left(),
)
.with_children({
(in_manage && request_status == Some(proto::channel_member::Kind::Invitee)).then(
|| {
Label::new("Invited", theme.member_tag.text.clone())
.contained()
.with_style(theme.member_tag.container)
.aligned()
.left()
},
)
})
.with_children(admin.and_then(|admin| {
(in_manage && admin).then(|| {
Label::new("Admin", theme.member_tag.text.clone())
.contained()
.with_style(theme.member_tag.container)
.aligned()
.left()
})
}))
.with_children({
let svg = match self.mode {
Mode::ManageMembers => Some(
Svg::new("icons/ellipsis.svg")
.with_color(theme.member_icon.color)
.constrained()
.with_width(theme.member_icon.icon_width)
.aligned()
.constrained()
.with_width(theme.member_icon.button_width)
.with_height(theme.member_icon.button_width)
.contained()
.with_style(theme.member_icon.container),
),
Mode::InviteMembers => match request_status {
Some(proto::channel_member::Kind::Member) => Some(
Svg::new("icons/check.svg")
.with_color(theme.member_icon.color)
.constrained()
.with_width(theme.member_icon.icon_width)
.aligned()
.constrained()
.with_width(theme.member_icon.button_width)
.with_height(theme.member_icon.button_width)
.contained()
.with_style(theme.member_icon.container),
),
Some(proto::channel_member::Kind::Invitee) => Some(
Svg::new("icons/check.svg")
.with_color(theme.invitee_icon.color)
.constrained()
.with_width(theme.invitee_icon.icon_width)
.aligned()
.constrained()
.with_width(theme.invitee_icon.button_width)
.with_height(theme.invitee_icon.button_width)
.contained()
.with_style(theme.invitee_icon.container),
),
Some(proto::channel_member::Kind::AncestorMember) | None => None,
},
};
svg.map(|svg| svg.aligned().flex_float().into_any())
})
.contained()
.with_style(style.container)
.constrained()
.with_height(tabbed_modal.row_height)
.into_any();
if selected {
result = Stack::new()
.with_child(result)
.with_child(
ChildView::new(&self.context_menu, cx)
.aligned()
.top()
.right(),
)
.into_any();
}
result
}
}
impl ChannelModalDelegate {
fn member_status(
&self,
user_id: UserId,
cx: &AppContext,
) -> Option<proto::channel_member::Kind> {
self.members
.iter()
.find_map(|membership| (membership.user.id == user_id).then_some(membership.kind))
.or_else(|| {
self.channel_store
.read(cx)
.has_pending_channel_invite(self.channel_id, user_id)
.then_some(proto::channel_member::Kind::Invitee)
})
}
fn user_at_index(&self, ix: usize) -> Option<(Arc<User>, Option<bool>)> {
match self.mode {
Mode::ManageMembers => self.matching_member_indices.get(ix).and_then(|ix| {
let channel_membership = self.members.get(*ix)?;
Some((
channel_membership.user.clone(),
Some(channel_membership.admin),
))
}),
Mode::InviteMembers => Some((self.matching_users.get(ix).cloned()?, None)),
}
}
fn toggle_selected_member_admin(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
let (user, admin) = self.user_at_index(self.selected_index)?;
let admin = !admin.unwrap_or(false);
let update = self.channel_store.update(cx, |store, cx| {
store.set_member_admin(self.channel_id, user.id, admin, cx)
});
cx.spawn(|picker, mut cx| async move {
update.await?;
picker.update(&mut cx, |picker, cx| {
let this = picker.delegate_mut();
if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user.id) {
member.admin = admin;
}
cx.focus_self();
cx.notify();
})
})
.detach_and_log_err(cx);
Some(())
}
fn remove_selected_member(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
let (user, _) = self.user_at_index(self.selected_index)?;
let user_id = user.id;
let update = self.channel_store.update(cx, |store, cx| {
store.remove_member(self.channel_id, user_id, cx)
});
cx.spawn(|picker, mut cx| async move {
update.await?;
picker.update(&mut cx, |picker, cx| {
let this = picker.delegate_mut();
if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) {
this.members.remove(ix);
this.matching_member_indices.retain_mut(|member_ix| {
if *member_ix == ix {
return false;
} else if *member_ix > ix {
*member_ix -= 1;
}
true
})
}
this.selected_index = this
.selected_index
.min(this.matching_member_indices.len().saturating_sub(1));
cx.focus_self();
cx.notify();
})
})
.detach_and_log_err(cx);
Some(())
}
fn invite_member(&mut self, user: Arc<User>, cx: &mut ViewContext<Picker<Self>>) {
let invite_member = self.channel_store.update(cx, |store, cx| {
store.invite_member(self.channel_id, user.id, false, cx)
});
cx.spawn(|this, mut cx| async move {
invite_member.await?;
this.update(&mut cx, |this, cx| {
this.delegate_mut().members.push(ChannelMembership {
user,
kind: proto::channel_member::Kind::Invitee,
admin: false,
});
cx.notify();
})
})
.detach_and_log_err(cx);
}
fn show_context_menu(&mut self, user_is_admin: bool, cx: &mut ViewContext<Picker<Self>>) {
self.context_menu.update(cx, |context_menu, cx| {
context_menu.show(
Default::default(),
AnchorCorner::TopRight,
vec![
ContextMenuItem::action("Remove", RemoveMember),
ContextMenuItem::action(
if user_is_admin {
"Make non-admin"
} else {
"Make admin"
},
ToggleMemberAdmin,
),
],
cx,
)
})
}
}

View file

@ -1,28 +1,132 @@
use client::{ContactRequestStatus, User, UserStore};
use gpui::{elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext};
use gpui::{
elements::*, AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle,
};
use picker::{Picker, PickerDelegate, PickerEvent};
use std::sync::Arc;
use util::TryFutureExt;
use workspace::Modal;
pub fn init(cx: &mut AppContext) {
Picker::<ContactFinderDelegate>::init(cx);
cx.add_action(ContactFinder::dismiss)
}
pub type ContactFinder = Picker<ContactFinderDelegate>;
pub struct ContactFinder {
picker: ViewHandle<Picker<ContactFinderDelegate>>,
has_focus: bool,
}
pub fn build_contact_finder(
user_store: ModelHandle<UserStore>,
cx: &mut ViewContext<ContactFinder>,
) -> ContactFinder {
Picker::new(
ContactFinderDelegate {
user_store,
potential_contacts: Arc::from([]),
selected_index: 0,
},
cx,
)
.with_theme(|theme| theme.contact_finder.picker.clone())
impl ContactFinder {
pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
let picker = cx.add_view(|cx| {
Picker::new(
ContactFinderDelegate {
user_store,
potential_contacts: Arc::from([]),
selected_index: 0,
},
cx,
)
.with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone())
});
cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach();
Self {
picker,
has_focus: false,
}
}
pub fn set_query(&mut self, query: String, cx: &mut ViewContext<Self>) {
self.picker.update(cx, |picker, cx| {
picker.set_query(query, cx);
});
}
fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
cx.emit(PickerEvent::Dismiss);
}
}
impl Entity for ContactFinder {
type Event = PickerEvent;
}
impl View for ContactFinder {
fn ui_name() -> &'static str {
"ContactFinder"
}
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
let full_theme = &theme::current(cx);
let theme = &full_theme.collab_panel.tabbed_modal;
fn render_mode_button(
text: &'static str,
theme: &theme::TabbedModal,
_cx: &mut ViewContext<ContactFinder>,
) -> AnyElement<ContactFinder> {
let contained_text = &theme.tab_button.active_state().default;
Label::new(text, contained_text.text.clone())
.contained()
.with_style(contained_text.container.clone())
.into_any()
}
Flex::column()
.with_child(
Flex::column()
.with_child(
Label::new("Contacts", theme.title.text.clone())
.contained()
.with_style(theme.title.container.clone()),
)
.with_child(Flex::row().with_children([render_mode_button(
"Invite new contacts",
&theme,
cx,
)]))
.expanded()
.contained()
.with_style(theme.header),
)
.with_child(
ChildView::new(&self.picker, cx)
.contained()
.with_style(theme.body),
)
.constrained()
.with_max_height(theme.max_height)
.with_max_width(theme.max_width)
.contained()
.with_style(theme.modal)
.into_any()
}
fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
self.has_focus = true;
if cx.is_self_focused() {
cx.focus(&self.picker)
}
}
fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
self.has_focus = false;
}
}
impl Modal for ContactFinder {
fn has_focus(&self) -> bool {
self.has_focus
}
fn dismiss_on_event(event: &Self::Event) -> bool {
match event {
PickerEvent::Dismiss => true,
}
}
}
pub struct ContactFinderDelegate {
@ -97,7 +201,9 @@ impl PickerDelegate for ContactFinderDelegate {
selected: bool,
cx: &gpui::AppContext,
) -> AnyElement<Picker<Self>> {
let theme = &theme::current(cx);
let full_theme = &theme::current(cx);
let theme = &full_theme.collab_panel.contact_finder;
let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
let user = &self.potential_contacts[ix];
let request_status = self.user_store.read(cx).contact_request_status(user);
@ -109,12 +215,11 @@ impl PickerDelegate for ContactFinderDelegate {
ContactRequestStatus::RequestAccepted => None,
};
let button_style = if self.user_store.read(cx).is_contact_request_pending(user) {
&theme.contact_finder.disabled_contact_button
&theme.disabled_contact_button
} else {
&theme.contact_finder.contact_button
&theme.contact_button
};
let style = theme
.contact_finder
let style = tabbed_modal
.picker
.item
.in_state(selected)
@ -122,14 +227,14 @@ impl PickerDelegate for ContactFinderDelegate {
Flex::row()
.with_children(user.avatar.clone().map(|avatar| {
Image::from_data(avatar)
.with_style(theme.contact_finder.contact_avatar)
.with_style(theme.contact_avatar)
.aligned()
.left()
}))
.with_child(
Label::new(user.github_login.clone(), style.label.clone())
.contained()
.with_style(theme.contact_finder.contact_username)
.with_style(theme.contact_username)
.aligned()
.left(),
)
@ -150,7 +255,7 @@ impl PickerDelegate for ContactFinderDelegate {
.contained()
.with_style(style.container)
.constrained()
.with_height(theme.contact_finder.row_height)
.with_height(tabbed_modal.row_height)
.into_any()
}
}

View file

@ -0,0 +1,39 @@
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,
}
#[derive(Deserialize, Debug)]
pub struct CollaborationPanelSettings {
pub button: bool,
pub dock: CollaborationPanelDockPosition,
pub default_width: f32,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
pub struct CollaborationPanelSettingsContent {
pub button: Option<bool>,
pub dock: Option<CollaborationPanelDockPosition>,
pub default_width: Option<f32>,
}
impl Setting for CollaborationPanelSettings {
const KEY: Option<&'static str> = Some("collaboration_panel");
type FileContent = CollaborationPanelSettingsContent;
fn load(
default_value: &Self::FileContent,
user_values: &[&Self::FileContent],
_: &gpui::AppContext,
) -> anyhow::Result<Self> {
Self::load_via_json_merge(default_value, user_values)
}
}

View file

@ -1,12 +1,10 @@
use crate::{
contact_notification::ContactNotification, contacts_popover, face_pile::FacePile,
toggle_deafen, toggle_mute, toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute,
ToggleScreenSharing,
contact_notification::ContactNotification, face_pile::FacePile, toggle_deafen, toggle_mute,
toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute, ToggleScreenSharing,
};
use call::{ActiveCall, ParticipantLocation, Room};
use client::{proto::PeerId, Client, ContactEventKind, SignIn, SignOut, User, UserStore};
use clock::ReplicaId;
use contacts_popover::ContactsPopover;
use context_menu::{ContextMenu, ContextMenuItem};
use gpui::{
actions,
@ -33,7 +31,6 @@ const MAX_BRANCH_NAME_LENGTH: usize = 40;
actions!(
collab,
[
ToggleContactsMenu,
ToggleUserMenu,
ToggleProjectMenu,
SwitchBranch,
@ -43,7 +40,6 @@ actions!(
);
pub fn init(cx: &mut AppContext) {
cx.add_action(CollabTitlebarItem::toggle_contacts_popover);
cx.add_action(CollabTitlebarItem::share_project);
cx.add_action(CollabTitlebarItem::unshare_project);
cx.add_action(CollabTitlebarItem::toggle_user_menu);
@ -56,7 +52,6 @@ pub struct CollabTitlebarItem {
user_store: ModelHandle<UserStore>,
client: Arc<Client>,
workspace: WeakViewHandle<Workspace>,
contacts_popover: Option<ViewHandle<ContactsPopover>>,
branch_popover: Option<ViewHandle<BranchList>>,
project_popover: Option<ViewHandle<recent_projects::RecentProjects>>,
user_menu: ViewHandle<ContextMenu>,
@ -95,7 +90,7 @@ impl View for CollabTitlebarItem {
right_container
.add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
right_container.add_child(self.render_leave_call(&theme, cx));
let muted = room.read(cx).is_muted();
let muted = room.read(cx).is_muted(cx);
let speaking = room.read(cx).is_speaking();
left_container.add_child(
self.render_current_user(&workspace, &theme, &user, peer_id, muted, speaking, cx),
@ -109,7 +104,6 @@ impl View for CollabTitlebarItem {
let status = workspace.read(cx).client().status();
let status = &*status.borrow();
if matches!(status, client::Status::Connected { .. }) {
right_container.add_child(self.render_toggle_contacts_button(&theme, cx));
let avatar = user.as_ref().and_then(|user| user.avatar.clone());
right_container.add_child(self.render_user_menu_button(&theme, avatar, cx));
} else {
@ -184,7 +178,6 @@ impl CollabTitlebarItem {
project,
user_store,
client,
contacts_popover: None,
user_menu: cx.add_view(|cx| {
let view_id = cx.view_id();
let mut menu = ContextMenu::new(view_id, cx);
@ -226,7 +219,7 @@ impl CollabTitlebarItem {
let mut ret = Flex::row().with_child(
Stack::new()
.with_child(
MouseEventHandler::<ToggleProjectMenu, Self>::new(0, cx, |mouse_state, cx| {
MouseEventHandler::new::<ToggleProjectMenu, _>(0, cx, |mouse_state, cx| {
let style = project_style
.in_state(self.project_popover.is_some())
.style_for(mouse_state);
@ -266,7 +259,7 @@ impl CollabTitlebarItem {
.with_child(
Stack::new()
.with_child(
MouseEventHandler::<ToggleVcsMenu, Self>::new(
MouseEventHandler::new::<ToggleVcsMenu, _>(
0,
cx,
|mouse_state, cx| {
@ -315,9 +308,6 @@ impl CollabTitlebarItem {
}
fn active_call_changed(&mut self, cx: &mut ViewContext<Self>) {
if ActiveCall::global(cx).read(cx).room().is_none() {
self.contacts_popover = None;
}
cx.notify();
}
@ -337,32 +327,6 @@ impl CollabTitlebarItem {
.log_err();
}
pub fn toggle_contacts_popover(&mut self, _: &ToggleContactsMenu, cx: &mut ViewContext<Self>) {
if self.contacts_popover.take().is_none() {
let view = cx.add_view(|cx| {
ContactsPopover::new(
self.project.clone(),
self.user_store.clone(),
self.workspace.clone(),
cx,
)
});
cx.subscribe(&view, |this, _, event, cx| {
match event {
contacts_popover::Event::Dismissed => {
this.contacts_popover = None;
}
}
cx.notify();
})
.detach();
self.contacts_popover = Some(view);
}
cx.notify();
}
pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext<Self>) {
self.user_menu.update(cx, |user_menu, cx| {
let items = if let Some(_) = self.user_store.read(cx).current_user() {
@ -390,6 +354,7 @@ impl CollabTitlebarItem {
user_menu.toggle(Default::default(), AnchorCorner::TopRight, items, cx);
});
}
fn render_branches_popover_host<'a>(
&'a self,
_theme: &'a theme::Titlebar,
@ -398,13 +363,13 @@ impl CollabTitlebarItem {
self.branch_popover.as_ref().map(|child| {
let theme = theme::current(cx).clone();
let child = ChildView::new(child, cx);
let child = MouseEventHandler::<BranchList, Self>::new(0, cx, |_, _| {
let child = MouseEventHandler::new::<BranchList, _>(0, cx, |_, _| {
child
.flex(1., true)
.contained()
.constrained()
.with_width(theme.contacts_popover.width)
.with_height(theme.contacts_popover.height)
.with_width(theme.titlebar.menu.width)
.with_height(theme.titlebar.menu.height)
})
.on_click(MouseButton::Left, |_, _, _| {})
.on_down_out(MouseButton::Left, move |_, this, cx| {
@ -425,6 +390,7 @@ impl CollabTitlebarItem {
.into_any()
})
}
fn render_project_popover_host<'a>(
&'a self,
_theme: &'a theme::Titlebar,
@ -433,13 +399,13 @@ impl CollabTitlebarItem {
self.project_popover.as_ref().map(|child| {
let theme = theme::current(cx).clone();
let child = ChildView::new(child, cx);
let child = MouseEventHandler::<RecentProjects, Self>::new(0, cx, |_, _| {
let child = MouseEventHandler::new::<RecentProjects, _>(0, cx, |_, _| {
child
.flex(1., true)
.contained()
.constrained()
.with_width(theme.contacts_popover.width)
.with_height(theme.contacts_popover.height)
.with_width(theme.titlebar.menu.width)
.with_height(theme.titlebar.menu.height)
})
.on_click(MouseButton::Left, |_, _, _| {})
.on_down_out(MouseButton::Left, move |_, this, cx| {
@ -459,6 +425,7 @@ impl CollabTitlebarItem {
.into_any()
})
}
pub fn toggle_vcs_menu(&mut self, _: &ToggleVcsMenu, cx: &mut ViewContext<Self>) {
if self.branch_popover.take().is_none() {
if let Some(workspace) = self.workspace.upgrade(cx) {
@ -519,79 +486,7 @@ impl CollabTitlebarItem {
}
cx.notify();
}
fn render_toggle_contacts_button(
&self,
theme: &Theme,
cx: &mut ViewContext<Self>,
) -> AnyElement<Self> {
let titlebar = &theme.titlebar;
let badge = if self
.user_store
.read(cx)
.incoming_contact_requests()
.is_empty()
{
None
} else {
Some(
Empty::new()
.collapsed()
.contained()
.with_style(titlebar.toggle_contacts_badge)
.contained()
.with_margin_left(
titlebar
.toggle_contacts_button
.inactive_state()
.default
.icon_width,
)
.with_margin_top(
titlebar
.toggle_contacts_button
.inactive_state()
.default
.icon_width,
)
.aligned(),
)
};
Stack::new()
.with_child(
MouseEventHandler::<ToggleContactsMenu, Self>::new(0, cx, |state, _| {
let style = titlebar
.toggle_contacts_button
.in_state(self.contacts_popover.is_some())
.style_for(state);
Svg::new("icons/radix/person.svg")
.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)
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, this, cx| {
this.toggle_contacts_popover(&Default::default(), cx)
})
.with_tooltip::<ToggleContactsMenu>(
0,
"Show contacts menu",
Some(Box::new(ToggleContactsMenu)),
theme.tooltip.clone(),
cx,
),
)
.with_children(badge)
.with_children(self.render_contacts_popover_host(titlebar, cx))
.into_any()
}
fn render_toggle_screen_sharing_button(
&self,
theme: &Theme,
@ -610,7 +505,7 @@ impl CollabTitlebarItem {
let active = room.read(cx).is_screen_sharing();
let titlebar = &theme.titlebar;
MouseEventHandler::<ToggleScreenSharing, Self>::new(0, cx, |state, _| {
MouseEventHandler::new::<ToggleScreenSharing, _>(0, cx, |state, _| {
let style = titlebar
.screen_share_button
.in_state(active)
@ -649,7 +544,7 @@ impl CollabTitlebarItem {
) -> AnyElement<Self> {
let icon;
let tooltip;
let is_muted = room.read(cx).is_muted();
let is_muted = room.read(cx).is_muted(cx);
if is_muted {
icon = "icons/radix/mic-mute.svg";
tooltip = "Unmute microphone";
@ -659,7 +554,7 @@ impl CollabTitlebarItem {
}
let titlebar = &theme.titlebar;
MouseEventHandler::<ToggleMute, Self>::new(0, cx, |state, _| {
MouseEventHandler::new::<ToggleMute, _>(0, cx, |state, _| {
let style = titlebar
.toggle_microphone_button
.in_state(is_muted)
@ -712,7 +607,7 @@ impl CollabTitlebarItem {
}
let titlebar = &theme.titlebar;
MouseEventHandler::<ToggleDeafen, Self>::new(0, cx, |state, _| {
MouseEventHandler::new::<ToggleDeafen, _>(0, cx, |state, _| {
let style = titlebar
.toggle_speakers_button
.in_state(is_deafened)
@ -747,7 +642,7 @@ impl CollabTitlebarItem {
let tooltip = "Leave call";
let titlebar = &theme.titlebar;
MouseEventHandler::<LeaveCall, Self>::new(0, cx, |state, _| {
MouseEventHandler::new::<LeaveCall, _>(0, cx, |state, _| {
let style = titlebar.leave_call_button.style_for(state);
Svg::new(icon)
.with_color(style.color)
@ -801,7 +696,7 @@ impl CollabTitlebarItem {
Some(
Stack::new()
.with_child(
MouseEventHandler::<ShareUnshare, Self>::new(0, cx, |state, _| {
MouseEventHandler::new::<ShareUnshare, _>(0, cx, |state, _| {
//TODO: Ensure this button has consistent width for both text variations
let style = titlebar.share_button.inactive_state().style_for(state);
Label::new(label, style.text.clone())
@ -847,7 +742,7 @@ impl CollabTitlebarItem {
let avatar_style = &user_menu_button_style.avatar;
Stack::new()
.with_child(
MouseEventHandler::<ToggleUserMenu, Self>::new(0, cx, |state, _| {
MouseEventHandler::new::<ToggleUserMenu, _>(0, cx, |state, _| {
let style = user_menu_button_style
.user_menu
.inactive_state()
@ -907,7 +802,7 @@ impl CollabTitlebarItem {
fn render_sign_in_button(&self, theme: &Theme, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
let titlebar = &theme.titlebar;
MouseEventHandler::<SignIn, Self>::new(0, cx, |state, _| {
MouseEventHandler::new::<SignIn, _>(0, cx, |state, _| {
let style = titlebar.sign_in_button.inactive_state().style_for(state);
Label::new("Sign In", style.text.clone())
.contained()
@ -923,23 +818,6 @@ impl CollabTitlebarItem {
.into_any()
}
fn render_contacts_popover_host<'a>(
&'a self,
_theme: &'a theme::Titlebar,
cx: &'a ViewContext<Self>,
) -> Option<AnyElement<Self>> {
self.contacts_popover.as_ref().map(|popover| {
Overlay::new(ChildView::new(popover, cx))
.with_fit_mode(OverlayFitMode::SwitchAnchor)
.with_anchor_corner(AnchorCorner::TopLeft)
.with_z_index(999)
.aligned()
.bottom()
.right()
.into_any()
})
}
fn render_collaborators(
&self,
workspace: &ViewHandle<Workspace>,
@ -1142,7 +1020,7 @@ impl CollabTitlebarItem {
if let Some(replica_id) = replica_id {
enum ToggleFollow {}
content = MouseEventHandler::<ToggleFollow, Self>::new(
content = MouseEventHandler::new::<ToggleFollow, _>(
replica_id.into(),
cx,
move |_, _| content,
@ -1173,7 +1051,7 @@ impl CollabTitlebarItem {
enum JoinProject {}
let user_id = user.id;
content = MouseEventHandler::<JoinProject, Self>::new(
content = MouseEventHandler::new::<JoinProject, _>(
peer_id.as_u64() as usize,
cx,
move |_, _| content,
@ -1261,7 +1139,7 @@ impl CollabTitlebarItem {
.into_any(),
),
client::Status::UpgradeRequired => Some(
MouseEventHandler::<ConnectionStatusButton, Self>::new(0, cx, |_, _| {
MouseEventHandler::new::<ConnectionStatusButton, _>(0, cx, |_, _| {
Label::new(
"Please update Zed to collaborate",
theme.titlebar.outdated_warning.text.clone(),

View file

@ -1,8 +1,6 @@
pub mod collab_panel;
mod collab_titlebar_item;
mod contact_finder;
mod contact_list;
mod contact_notification;
mod contacts_popover;
mod face_pile;
mod incoming_call_notification;
mod notifications;
@ -10,9 +8,17 @@ mod project_shared_notification;
mod sharing_status_indicator;
use call::{ActiveCall, Room};
pub use collab_titlebar_item::{CollabTitlebarItem, ToggleContactsMenu};
use gpui::{actions, AppContext, Task};
use std::sync::Arc;
pub use collab_titlebar_item::CollabTitlebarItem;
use gpui::{
actions,
geometry::{
rect::RectF,
vector::{vec2f, Vector2F},
},
platform::{Screen, WindowBounds, WindowKind, WindowOptions},
AppContext, Task,
};
use std::{rc::Rc, sync::Arc};
use util::ResultExt;
use workspace::AppState;
@ -24,9 +30,7 @@ actions!(
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
vcs_menu::init(cx);
collab_titlebar_item::init(cx);
contact_list::init(cx);
contact_finder::init(cx);
contacts_popover::init(cx);
collab_panel::init(app_state.client.clone(), cx);
incoming_call_notification::init(&app_state, cx);
project_shared_notification::init(&app_state, cx);
sharing_status_indicator::init(cx);
@ -45,6 +49,7 @@ pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) {
ActiveCall::report_call_event_for_room(
"disable screen share",
room.id(),
room.channel_id(),
&client,
cx,
);
@ -53,6 +58,7 @@ pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) {
ActiveCall::report_call_event_for_room(
"enable screen share",
room.id(),
room.channel_id(),
&client,
cx,
);
@ -68,12 +74,19 @@ pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) {
if let Some(room) = call.room().cloned() {
let client = call.client();
room.update(cx, |room, cx| {
if room.is_muted() {
ActiveCall::report_call_event_for_room("enable microphone", room.id(), &client, cx);
if room.is_muted(cx) {
ActiveCall::report_call_event_for_room(
"enable microphone",
room.id(),
room.channel_id(),
&client,
cx,
);
} else {
ActiveCall::report_call_event_for_room(
"disable microphone",
room.id(),
room.channel_id(),
&client,
cx,
);
@ -92,3 +105,29 @@ pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) {
.log_err();
}
}
fn notification_window_options(
screen: Rc<dyn Screen>,
window_size: Vector2F,
) -> WindowOptions<'static> {
const NOTIFICATION_PADDING: f32 = 16.;
let screen_bounds = screen.content_bounds();
WindowOptions {
bounds: WindowBounds::Fixed(RectF::new(
screen_bounds.upper_right()
+ vec2f(
-NOTIFICATION_PADDING - window_size.x(),
NOTIFICATION_PADDING,
),
window_size,
)),
titlebar: None,
center: false,
focus: false,
show: true,
kind: WindowKind::PopUp,
is_movable: false,
screen: Some(screen),
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,137 +0,0 @@
use crate::{
contact_finder::{build_contact_finder, ContactFinder},
contact_list::ContactList,
};
use client::UserStore;
use gpui::{
actions, elements::*, platform::MouseButton, AppContext, Entity, ModelHandle, View,
ViewContext, ViewHandle, WeakViewHandle,
};
use picker::PickerEvent;
use project::Project;
use workspace::Workspace;
actions!(contacts_popover, [ToggleContactFinder]);
pub fn init(cx: &mut AppContext) {
cx.add_action(ContactsPopover::toggle_contact_finder);
}
pub enum Event {
Dismissed,
}
enum Child {
ContactList(ViewHandle<ContactList>),
ContactFinder(ViewHandle<ContactFinder>),
}
pub struct ContactsPopover {
child: Child,
project: ModelHandle<Project>,
user_store: ModelHandle<UserStore>,
workspace: WeakViewHandle<Workspace>,
_subscription: Option<gpui::Subscription>,
}
impl ContactsPopover {
pub fn new(
project: ModelHandle<Project>,
user_store: ModelHandle<UserStore>,
workspace: WeakViewHandle<Workspace>,
cx: &mut ViewContext<Self>,
) -> Self {
let mut this = Self {
child: Child::ContactList(cx.add_view(|cx| {
ContactList::new(project.clone(), user_store.clone(), workspace.clone(), cx)
})),
project,
user_store,
workspace,
_subscription: None,
};
this.show_contact_list(String::new(), cx);
this
}
fn toggle_contact_finder(&mut self, _: &ToggleContactFinder, cx: &mut ViewContext<Self>) {
match &self.child {
Child::ContactList(list) => self.show_contact_finder(list.read(cx).editor_text(cx), cx),
Child::ContactFinder(finder) => self.show_contact_list(finder.read(cx).query(cx), cx),
}
}
fn show_contact_finder(&mut self, editor_text: String, cx: &mut ViewContext<ContactsPopover>) {
let child = cx.add_view(|cx| {
let finder = build_contact_finder(self.user_store.clone(), cx);
finder.set_query(editor_text, cx);
finder
});
cx.focus(&child);
self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event {
PickerEvent::Dismiss => cx.emit(Event::Dismissed),
}));
self.child = Child::ContactFinder(child);
cx.notify();
}
fn show_contact_list(&mut self, editor_text: String, cx: &mut ViewContext<ContactsPopover>) {
let child = cx.add_view(|cx| {
ContactList::new(
self.project.clone(),
self.user_store.clone(),
self.workspace.clone(),
cx,
)
.with_editor_text(editor_text, cx)
});
cx.focus(&child);
self._subscription = Some(cx.subscribe(&child, |this, _, event, cx| match event {
crate::contact_list::Event::Dismissed => cx.emit(Event::Dismissed),
crate::contact_list::Event::ToggleContactFinder => {
this.toggle_contact_finder(&Default::default(), cx)
}
}));
self.child = Child::ContactList(child);
cx.notify();
}
}
impl Entity for ContactsPopover {
type Event = Event;
}
impl View for ContactsPopover {
fn ui_name() -> &'static str {
"ContactsPopover"
}
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
let theme = theme::current(cx).clone();
let child = match &self.child {
Child::ContactList(child) => ChildView::new(child, cx),
Child::ContactFinder(child) => ChildView::new(child, cx),
};
MouseEventHandler::<ContactsPopover, Self>::new(0, cx, |_, _| {
Flex::column()
.with_child(child.flex(1., true))
.contained()
.with_style(theme.contacts_popover.container)
.constrained()
.with_width(theme.contacts_popover.width)
.with_height(theme.contacts_popover.height)
})
.on_down_out(MouseButton::Left, move |_, _, cx| cx.emit(Event::Dismissed))
.into_any()
}
fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
if cx.is_self_focused() {
match &self.child {
Child::ContactList(child) => cx.focus(child),
Child::ContactFinder(child) => cx.focus(child),
}
}
}
}

View file

@ -7,44 +7,48 @@ use gpui::{
},
json::ToJson,
serde_json::{self, json},
AnyElement, Axis, Element, LayoutContext, PaintContext, SceneBuilder, ViewContext,
AnyElement, Axis, Element, LayoutContext, PaintContext, SceneBuilder, View, ViewContext,
};
use crate::CollabTitlebarItem;
pub(crate) struct FacePile {
pub(crate) struct FacePile<V: View> {
overlap: f32,
faces: Vec<AnyElement<CollabTitlebarItem>>,
faces: Vec<AnyElement<V>>,
}
impl FacePile {
pub fn new(overlap: f32) -> FacePile {
FacePile {
impl<V: View> FacePile<V> {
pub fn new(overlap: f32) -> Self {
Self {
overlap,
faces: Vec::new(),
}
}
}
impl Element<CollabTitlebarItem> for FacePile {
impl<V: View> Element<V> for FacePile<V> {
type LayoutState = ();
type PaintState = ();
fn layout(
&mut self,
constraint: gpui::SizeConstraint,
view: &mut CollabTitlebarItem,
cx: &mut LayoutContext<CollabTitlebarItem>,
view: &mut V,
cx: &mut LayoutContext<V>,
) -> (Vector2F, Self::LayoutState) {
debug_assert!(constraint.max_along(Axis::Horizontal) == f32::INFINITY);
let mut width = 0.;
let mut max_height = 0.;
for face in &mut self.faces {
width += face.layout(constraint, view, cx).x();
let layout = face.layout(constraint, view, cx);
width += layout.x();
max_height = f32::max(max_height, layout.y());
}
width -= self.overlap * self.faces.len().saturating_sub(1) as f32;
(Vector2F::new(width, constraint.max.y()), ())
(
Vector2F::new(width, max_height.clamp(1., constraint.max.y())),
(),
)
}
fn paint(
@ -53,8 +57,8 @@ impl Element<CollabTitlebarItem> for FacePile {
bounds: RectF,
visible_bounds: RectF,
_layout: &mut Self::LayoutState,
view: &mut CollabTitlebarItem,
cx: &mut PaintContext<CollabTitlebarItem>,
view: &mut V,
cx: &mut PaintContext<V>,
) -> Self::PaintState {
let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
@ -64,6 +68,7 @@ impl Element<CollabTitlebarItem> for FacePile {
for face in self.faces.iter_mut().rev() {
let size = face.size();
origin_x -= size.x();
let origin_y = origin_y + (bounds.height() - size.y()) / 2.0;
scene.paint_layer(None, |scene| {
face.paint(scene, vec2f(origin_x, origin_y), visible_bounds, view, cx);
});
@ -80,8 +85,8 @@ impl Element<CollabTitlebarItem> for FacePile {
_: RectF,
_: &Self::LayoutState,
_: &Self::PaintState,
_: &CollabTitlebarItem,
_: &ViewContext<CollabTitlebarItem>,
_: &V,
_: &ViewContext<V>,
) -> Option<RectF> {
None
}
@ -91,8 +96,8 @@ impl Element<CollabTitlebarItem> for FacePile {
bounds: RectF,
_: &Self::LayoutState,
_: &Self::PaintState,
_: &CollabTitlebarItem,
_: &ViewContext<CollabTitlebarItem>,
_: &V,
_: &ViewContext<V>,
) -> serde_json::Value {
json!({
"type": "FacePile",
@ -101,8 +106,8 @@ impl Element<CollabTitlebarItem> for FacePile {
}
}
impl Extend<AnyElement<CollabTitlebarItem>> for FacePile {
fn extend<T: IntoIterator<Item = AnyElement<CollabTitlebarItem>>>(&mut self, children: T) {
impl<V: View> Extend<AnyElement<V>> for FacePile<V> {
fn extend<T: IntoIterator<Item = AnyElement<V>>>(&mut self, children: T) {
self.faces.extend(children);
}
}

View file

@ -1,14 +1,14 @@
use std::sync::{Arc, Weak};
use crate::notification_window_options;
use call::{ActiveCall, IncomingCall};
use client::proto;
use futures::StreamExt;
use gpui::{
elements::*,
geometry::{rect::RectF, vector::vec2f},
platform::{CursorStyle, MouseButton, WindowBounds, WindowKind, WindowOptions},
geometry::vector::vec2f,
platform::{CursorStyle, MouseButton},
AnyElement, AppContext, Entity, View, ViewContext, WindowHandle,
};
use std::sync::{Arc, Weak};
use util::ResultExt;
use workspace::AppState;
@ -23,31 +23,16 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
}
if let Some(incoming_call) = incoming_call {
const PADDING: f32 = 16.;
let window_size = cx.read(|cx| {
let theme = &theme::current(cx).incoming_call_notification;
vec2f(theme.window_width, theme.window_height)
});
for screen in cx.platform().screens() {
let screen_bounds = screen.bounds();
let window = cx.add_window(
WindowOptions {
bounds: WindowBounds::Fixed(RectF::new(
screen_bounds.upper_right()
- vec2f(PADDING + window_size.x(), PADDING),
window_size,
)),
titlebar: None,
center: false,
focus: false,
show: true,
kind: WindowKind::PopUp,
is_movable: false,
screen: Some(screen),
},
|_| IncomingCallNotification::new(incoming_call.clone(), app_state.clone()),
);
let window = cx
.add_window(notification_window_options(screen, window_size), |_| {
IncomingCallNotification::new(incoming_call.clone(), app_state.clone())
});
notification_windows.push(window);
}
@ -173,7 +158,7 @@ impl IncomingCallNotification {
let theme = theme::current(cx);
Flex::column()
.with_child(
MouseEventHandler::<Accept, Self>::new(0, cx, |_, _| {
MouseEventHandler::new::<Accept, _>(0, cx, |_, _| {
let theme = &theme.incoming_call_notification;
Label::new("Accept", theme.accept_button.text.clone())
.aligned()
@ -187,7 +172,7 @@ impl IncomingCallNotification {
.flex(1., true),
)
.with_child(
MouseEventHandler::<Decline, Self>::new(0, cx, |_, _| {
MouseEventHandler::new::<Decline, _>(0, cx, |_, _| {
let theme = &theme.incoming_call_notification;
Label::new("Decline", theme.decline_button.text.clone())
.aligned()

View file

@ -51,7 +51,7 @@ where
.flex(1., true),
)
.with_child(
MouseEventHandler::<Dismiss, V>::new(user.id as usize, cx, |state, _| {
MouseEventHandler::new::<Dismiss, _>(user.id as usize, cx, |state, _| {
let style = theme.dismiss_button.style_for(state);
Svg::new("icons/x_mark_8.svg")
.with_color(style.color)
@ -91,7 +91,7 @@ where
Flex::row()
.with_children(buttons.into_iter().enumerate().map(
|(ix, (message, handler))| {
MouseEventHandler::<Button, V>::new(ix, cx, |state, _| {
MouseEventHandler::new::<Button, _>(ix, cx, |state, _| {
let button = theme.button.style_for(state);
Label::new(message, button.text.clone())
.contained()

View file

@ -1,10 +1,11 @@
use crate::notification_window_options;
use call::{room, ActiveCall};
use client::User;
use collections::HashMap;
use gpui::{
elements::*,
geometry::{rect::RectF, vector::vec2f},
platform::{CursorStyle, MouseButton, WindowBounds, WindowKind, WindowOptions},
geometry::vector::vec2f,
platform::{CursorStyle, MouseButton},
AppContext, Entity, View, ViewContext,
};
use std::sync::{Arc, Weak};
@ -20,35 +21,19 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
project_id,
worktree_root_names,
} => {
const PADDING: f32 = 16.;
let theme = &theme::current(cx).project_shared_notification;
let window_size = vec2f(theme.window_width, theme.window_height);
for screen in cx.platform().screens() {
let screen_bounds = screen.bounds();
let window = cx.add_window(
WindowOptions {
bounds: WindowBounds::Fixed(RectF::new(
screen_bounds.upper_right() - vec2f(PADDING + window_size.x(), PADDING),
window_size,
)),
titlebar: None,
center: false,
focus: false,
show: true,
kind: WindowKind::PopUp,
is_movable: false,
screen: Some(screen),
},
|_| {
let window =
cx.add_window(notification_window_options(screen, window_size), |_| {
ProjectSharedNotification::new(
owner.clone(),
*project_id,
worktree_root_names.clone(),
app_state.clone(),
)
},
);
});
notification_windows
.entry(*project_id)
.or_insert(Vec::new())
@ -170,7 +155,7 @@ impl ProjectSharedNotification {
let theme = theme::current(cx);
Flex::column()
.with_child(
MouseEventHandler::<Open, Self>::new(0, cx, |_, _| {
MouseEventHandler::new::<Open, _>(0, cx, |_, _| {
let theme = &theme.project_shared_notification;
Label::new("Open", theme.open_button.text.clone())
.aligned()
@ -182,7 +167,7 @@ impl ProjectSharedNotification {
.flex(1., true),
)
.with_child(
MouseEventHandler::<Dismiss, Self>::new(0, cx, |_, _| {
MouseEventHandler::new::<Dismiss, _>(0, cx, |_, _| {
let theme = &theme.project_shared_notification;
Label::new("Dismiss", theme.dismiss_button.text.clone())
.aligned()

View file

@ -47,7 +47,7 @@ impl View for SharingStatusIndicator {
Appearance::Dark | Appearance::VibrantDark => Color::white(),
};
MouseEventHandler::<Self, Self>::new(0, cx, |_, _| {
MouseEventHandler::new::<Self, _>(0, cx, |_, _| {
Svg::new("icons/disable_screen_sharing_12.svg")
.with_color(color)
.constrained()

View file

@ -439,14 +439,14 @@ impl ContextMenu {
let style = theme::current(cx).context_menu.clone();
MouseEventHandler::<Menu, ContextMenu>::new(0, cx, |_, cx| {
MouseEventHandler::new::<Menu, _>(0, cx, |_, cx| {
Flex::column()
.with_children(self.items.iter().enumerate().map(|(ix, item)| {
match item {
ContextMenuItem::Item { label, action } => {
let action = action.clone();
let view_id = self.parent_view_id;
MouseEventHandler::<MenuItem, ContextMenu>::new(ix, cx, |state, _| {
MouseEventHandler::new::<MenuItem, _>(ix, cx, |state, _| {
let style = style.item.in_state(self.selected_index == Some(ix));
let style = style.style_for(state);
let keystroke = match &action {

View file

@ -113,7 +113,7 @@ impl CopilotCodeVerification {
let device_code_style = &style.auth.prompting.device_code;
MouseEventHandler::<Self, _>::new(0, cx, |state, _cx| {
MouseEventHandler::new::<Self, _>(0, cx, |state, _cx| {
Flex::row()
.with_child(
Label::new(data.user_code.clone(), device_code_style.text.clone())

View file

@ -62,7 +62,7 @@ impl View for CopilotButton {
Stack::new()
.with_child(
MouseEventHandler::<Self, _>::new(0, cx, {
MouseEventHandler::new::<Self, _>(0, cx, {
let theme = theme.clone();
let status = status.clone();
move |state, _cx| {

View file

@ -94,7 +94,7 @@ impl View for DiagnosticIndicator {
let tooltip_style = theme::current(cx).tooltip.clone();
let in_progress = !self.in_progress_checks.is_empty();
let mut element = Flex::row().with_child(
MouseEventHandler::<Summary, _>::new(0, cx, |state, cx| {
MouseEventHandler::new::<Summary, _>(0, cx, |state, cx| {
let theme = theme::current(cx);
let style = theme
.workspace
@ -105,7 +105,7 @@ impl View for DiagnosticIndicator {
let mut summary_row = Flex::row();
if self.summary.error_count > 0 {
summary_row.add_child(
Svg::new("icons/circle_x_mark_16.svg")
Svg::new("icons/error.svg")
.with_color(style.icon_color_error)
.constrained()
.with_width(style.icon_width)
@ -121,7 +121,7 @@ impl View for DiagnosticIndicator {
if self.summary.warning_count > 0 {
summary_row.add_child(
Svg::new("icons/triangle_exclamation_16.svg")
Svg::new("icons/warning.svg")
.with_color(style.icon_color_warning)
.constrained()
.with_width(style.icon_width)
@ -142,7 +142,7 @@ impl View for DiagnosticIndicator {
if self.summary.error_count == 0 && self.summary.warning_count == 0 {
summary_row.add_child(
Svg::new("icons/circle_check_16.svg")
Svg::new("icons/check_circle.svg")
.with_color(style.icon_color_ok)
.constrained()
.with_width(style.icon_width)
@ -195,7 +195,7 @@ impl View for DiagnosticIndicator {
} else if let Some(diagnostic) = &self.current_diagnostic {
let message_style = style.diagnostic_message.clone();
element.add_child(
MouseEventHandler::<Message, _>::new(1, cx, |state, _| {
MouseEventHandler::new::<Message, _>(1, cx, |state, _| {
Label::new(
diagnostic.message.split('\n').next().unwrap().to_string(),
message_style.style_for(state).text.clone(),

View file

@ -202,7 +202,7 @@ impl<V: 'static> DragAndDrop<V> {
let position = (position - region_offset).round();
Some(
Overlay::new(
MouseEventHandler::<DraggedElementHandler, V>::new(
MouseEventHandler::new::<DraggedElementHandler, _>(
0,
cx,
|_, cx| render(payload, cx),
@ -235,7 +235,7 @@ impl<V: 'static> DragAndDrop<V> {
}
State::Canceled => Some(
MouseEventHandler::<DraggedElementHandler, V>::new(0, cx, |_, _| {
MouseEventHandler::new::<DraggedElementHandler, _>(0, cx, |_, _| {
Empty::new().constrained().with_width(0.).with_height(0.)
})
.on_up(MouseButton::Left, |_, _, cx| {
@ -301,7 +301,7 @@ pub trait Draggable<V> {
Self: Sized;
}
impl<Tag, V: 'static> Draggable<V> for MouseEventHandler<Tag, V> {
impl<V: 'static> Draggable<V> for MouseEventHandler<V> {
fn as_draggable<D: View, P: Any>(
self,
payload: P,

View file

@ -353,19 +353,26 @@ impl DisplaySnapshot {
}
}
// used by line_mode selections and tries to match vim behaviour
pub fn expand_to_line(&self, range: Range<Point>) -> Range<Point> {
let mut new_start = self.prev_line_boundary(range.start).0;
let mut new_end = self.next_line_boundary(range.end).0;
let new_start = if range.start.row == 0 {
Point::new(0, 0)
} else if range.start.row == self.max_buffer_row()
|| (range.end.column > 0 && range.end.row == self.max_buffer_row())
{
Point::new(range.start.row - 1, self.line_len(range.start.row - 1))
} else {
self.prev_line_boundary(range.start).0
};
if new_start.row == range.start.row && new_end.row == range.end.row {
if new_end.row < self.buffer_snapshot.max_point().row {
new_end.row += 1;
new_end.column = 0;
} else if new_start.row > 0 {
new_start.row -= 1;
new_start.column = self.buffer_snapshot.line_len(new_start.row);
}
}
let new_end = if range.end.column == 0 {
range.end
} else if range.end.row < self.max_buffer_row() {
self.buffer_snapshot
.clip_point(Point::new(range.end.row + 1, 0), Bias::Left)
} else {
self.buffer_snapshot.max_point()
};
new_start..new_end
}

View file

@ -302,10 +302,11 @@ actions!(
Hover,
Format,
ToggleSoftWrap,
ToggleInlayHints,
RevealInFinder,
CopyPath,
CopyRelativePath,
CopyHighlightJson
CopyHighlightJson,
]
);
@ -446,6 +447,7 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(Editor::toggle_code_actions);
cx.add_action(Editor::open_excerpts);
cx.add_action(Editor::toggle_soft_wrap);
cx.add_action(Editor::toggle_inlay_hints);
cx.add_action(Editor::reveal_in_finder);
cx.add_action(Editor::copy_path);
cx.add_action(Editor::copy_relative_path);
@ -575,6 +577,7 @@ pub struct Editor {
searchable: bool,
cursor_shape: CursorShape,
collapse_matches: bool,
autoindent_mode: Option<AutoindentMode>,
workspace: Option<(WeakViewHandle<Workspace>, i64)>,
keymap_context_layers: BTreeMap<TypeId, KeymapContext>,
input_enabled: bool,
@ -867,7 +870,7 @@ impl CompletionsMenu {
let completion = &completions[mat.candidate_id];
let item_ix = start_ix + ix;
items.push(
MouseEventHandler::<CompletionTag, _>::new(
MouseEventHandler::new::<CompletionTag, _>(
mat.candidate_id,
cx,
|state, _| {
@ -1044,7 +1047,7 @@ impl CodeActionsMenu {
for (ix, action) in actions[range].iter().enumerate() {
let item_ix = start_ix + ix;
items.push(
MouseEventHandler::<ActionTag, _>::new(item_ix, cx, |state, _| {
MouseEventHandler::new::<ActionTag, _>(item_ix, cx, |state, _| {
let item_style = if item_ix == selected_item {
style.autocomplete.selected_item
} else if state.hovered() {
@ -1237,7 +1240,8 @@ enum GotoDefinitionKind {
}
#[derive(Debug, Clone)]
enum InlayRefreshReason {
enum InlayHintRefreshReason {
Toggle(bool),
SettingsChange(InlayHintSettings),
NewLinesShown,
BufferEdited(HashSet<Arc<Language>>),
@ -1354,8 +1358,8 @@ impl Editor {
}));
}
project_subscriptions.push(cx.subscribe(project, |editor, _, event, cx| {
if let project::Event::RefreshInlays = event {
editor.refresh_inlays(InlayRefreshReason::RefreshRequested, cx);
if let project::Event::RefreshInlayHints = event {
editor.refresh_inlay_hints(InlayHintRefreshReason::RefreshRequested, cx);
};
}));
}
@ -1409,6 +1413,7 @@ impl Editor {
searchable: true,
override_text_style: None,
cursor_shape: Default::default(),
autoindent_mode: Some(AutoindentMode::EachLine),
collapse_matches: false,
workspace: None,
keymap_context_layers: Default::default(),
@ -1587,6 +1592,14 @@ impl Editor {
self.input_enabled = input_enabled;
}
pub fn set_autoindent(&mut self, autoindent: bool) {
if autoindent {
self.autoindent_mode = Some(AutoindentMode::EachLine);
} else {
self.autoindent_mode = None;
}
}
pub fn set_read_only(&mut self, read_only: bool) {
self.read_only = read_only;
}
@ -1719,7 +1732,7 @@ impl Editor {
}
self.buffer.update(cx, |buffer, cx| {
buffer.edit(edits, Some(AutoindentMode::EachLine), cx)
buffer.edit(edits, self.autoindent_mode.clone(), cx)
});
}
@ -2090,12 +2103,12 @@ impl Editor {
for (selection, autoclose_region) in
self.selections_with_autoclose_regions(selections, &snapshot)
{
if let Some(language) = snapshot.language_scope_at(selection.head()) {
if let Some(scope) = snapshot.language_scope_at(selection.head()) {
// Determine if the inserted text matches the opening or closing
// bracket of any of this language's bracket pairs.
let mut bracket_pair = None;
let mut is_bracket_pair_start = false;
for (pair, enabled) in language.brackets() {
for (pair, enabled) in scope.brackets() {
if enabled && pair.close && pair.start.ends_with(text.as_ref()) {
bracket_pair = Some(pair.clone());
is_bracket_pair_start = true;
@ -2117,7 +2130,7 @@ impl Editor {
let following_text_allows_autoclose = snapshot
.chars_at(selection.start)
.next()
.map_or(true, |c| language.should_autoclose_before(c));
.map_or(true, |c| scope.should_autoclose_before(c));
let preceding_text_matches_prefix = prefix_len == 0
|| (selection.start.column >= (prefix_len as u32)
&& snapshot.contains_str_at(
@ -2194,7 +2207,7 @@ impl Editor {
drop(snapshot);
self.transact(cx, |this, cx| {
this.buffer.update(cx, |buffer, cx| {
buffer.edit(edits, Some(AutoindentMode::EachLine), cx);
buffer.edit(edits, this.autoindent_mode.clone(), cx);
});
let new_anchor_selections = new_selections.iter().map(|e| &e.0);
@ -2654,7 +2667,6 @@ impl Editor {
false
});
}
fn completion_query(buffer: &MultiBufferSnapshot, position: impl ToOffset) -> Option<String> {
let offset = position.to_offset(buffer);
let (word_range, kind) = buffer.surrounding_word(offset);
@ -2669,13 +2681,41 @@ impl Editor {
}
}
fn refresh_inlays(&mut self, reason: InlayRefreshReason, cx: &mut ViewContext<Self>) {
pub fn toggle_inlay_hints(&mut self, _: &ToggleInlayHints, cx: &mut ViewContext<Self>) {
self.refresh_inlay_hints(
InlayHintRefreshReason::Toggle(!self.inlay_hint_cache.enabled),
cx,
);
}
pub fn inlay_hints_enabled(&self) -> bool {
self.inlay_hint_cache.enabled
}
fn refresh_inlay_hints(&mut self, reason: InlayHintRefreshReason, cx: &mut ViewContext<Self>) {
if self.project.is_none() || self.mode != EditorMode::Full {
return;
}
let (invalidate_cache, required_languages) = match reason {
InlayRefreshReason::SettingsChange(new_settings) => {
InlayHintRefreshReason::Toggle(enabled) => {
self.inlay_hint_cache.enabled = enabled;
if enabled {
(InvalidationStrategy::RefreshRequested, None)
} else {
self.inlay_hint_cache.clear();
self.splice_inlay_hints(
self.visible_inlay_hints(cx)
.iter()
.map(|inlay| inlay.id)
.collect(),
Vec::new(),
cx,
);
return;
}
}
InlayHintRefreshReason::SettingsChange(new_settings) => {
match self.inlay_hint_cache.update_settings(
&self.buffer,
new_settings,
@ -2693,11 +2733,13 @@ impl Editor {
ControlFlow::Continue(()) => (InvalidationStrategy::RefreshRequested, None),
}
}
InlayRefreshReason::NewLinesShown => (InvalidationStrategy::None, None),
InlayRefreshReason::BufferEdited(buffer_languages) => {
InlayHintRefreshReason::NewLinesShown => (InvalidationStrategy::None, None),
InlayHintRefreshReason::BufferEdited(buffer_languages) => {
(InvalidationStrategy::BufferEdited, Some(buffer_languages))
}
InlayRefreshReason::RefreshRequested => (InvalidationStrategy::RefreshRequested, None),
InlayHintRefreshReason::RefreshRequested => {
(InvalidationStrategy::RefreshRequested, None)
}
};
if let Some(InlaySplice {
@ -2723,7 +2765,7 @@ impl Editor {
.collect()
}
fn excerpt_visible_offsets(
pub fn excerpt_visible_offsets(
&self,
restrict_to_languages: Option<&HashSet<Arc<Language>>>,
cx: &mut ViewContext<'_, '_, Editor>,
@ -2774,6 +2816,7 @@ impl Editor {
self.display_map.update(cx, |display_map, cx| {
display_map.splice_inlays(to_remove, to_insert, cx);
});
cx.notify();
}
fn trigger_on_type_formatting(
@ -3003,7 +3046,7 @@ impl Editor {
this.buffer.update(cx, |buffer, cx| {
buffer.edit(
ranges.iter().map(|range| (range.clone(), text)),
Some(AutoindentMode::EachLine),
this.autoindent_mode.clone(),
cx,
);
});
@ -3547,7 +3590,7 @@ impl Editor {
if self.available_code_actions.is_some() {
enum CodeActions {}
Some(
MouseEventHandler::<CodeActions, _>::new(0, cx, |state, _| {
MouseEventHandler::new::<CodeActions, _>(0, cx, |state, _| {
Svg::new("icons/bolt_8.svg").with_color(
style
.code_actions
@ -3594,7 +3637,7 @@ impl Editor {
fold_data
.map(|(fold_status, buffer_row, active)| {
(active || gutter_hovered || fold_status == FoldStatus::Folded).then(|| {
MouseEventHandler::<FoldIndicators, _>::new(
MouseEventHandler::new::<FoldIndicators, _>(
ix as usize,
cx,
|mouse_state, _| {
@ -7696,8 +7739,8 @@ impl Editor {
.cloned()
.collect::<HashSet<_>>();
if !languages_affected.is_empty() {
self.refresh_inlays(
InlayRefreshReason::BufferEdited(languages_affected),
self.refresh_inlay_hints(
InlayHintRefreshReason::BufferEdited(languages_affected),
cx,
);
}
@ -7735,8 +7778,8 @@ impl Editor {
fn settings_changed(&mut self, cx: &mut ViewContext<Self>) {
self.refresh_copilot_suggestions(true, cx);
self.refresh_inlays(
InlayRefreshReason::SettingsChange(inlay_hint_settings(
self.refresh_inlay_hints(
InlayHintRefreshReason::SettingsChange(inlay_hint_settings(
self.selections.newest_anchor().head(),
&self.buffer.read(cx).snapshot(cx),
cx,
@ -8664,7 +8707,7 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, is_valid: bool) -> Rend
let font_size = (style.text_scale_factor * settings.buffer_font_size(cx)).round();
let anchor_x = cx.anchor_x;
enum BlockContextToolip {}
MouseEventHandler::<BlockContext, _>::new(cx.block_id, cx, |_, _| {
MouseEventHandler::new::<BlockContext, _>(cx.block_id, cx, |_, _| {
Flex::column()
.with_children(highlighted_lines.iter().map(|(line, highlights)| {
Label::new(

View file

@ -5237,6 +5237,7 @@ async fn test_completion(cx: &mut gpui::TestAppContext) {
lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions {
trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
resolve_provider: Some(true),
..Default::default()
}),
..Default::default()
@ -7528,6 +7529,7 @@ async fn test_completions_with_additional_edits(cx: &mut gpui::TestAppContext) {
lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions {
trigger_characters: Some(vec![".".to_string()]),
resolve_provider: Some(true),
..Default::default()
}),
..Default::default()

View file

@ -63,6 +63,7 @@ struct SelectionLayout {
cursor_shape: CursorShape,
is_newest: bool,
range: Range<DisplayPoint>,
active_rows: Range<u32>,
}
impl SelectionLayout {
@ -73,25 +74,44 @@ impl SelectionLayout {
map: &DisplaySnapshot,
is_newest: bool,
) -> Self {
let point_selection = selection.map(|p| p.to_point(&map.buffer_snapshot));
let display_selection = point_selection.map(|p| p.to_display_point(map));
let mut range = display_selection.range();
let mut head = display_selection.head();
let mut active_rows = map.prev_line_boundary(point_selection.start).1.row()
..map.next_line_boundary(point_selection.end).1.row();
// vim visual line mode
if line_mode {
let selection = selection.map(|p| p.to_point(&map.buffer_snapshot));
let point_range = map.expand_to_line(selection.range());
Self {
head: selection.head().to_display_point(map),
cursor_shape,
is_newest,
range: point_range.start.to_display_point(map)
..point_range.end.to_display_point(map),
}
} else {
let selection = selection.map(|p| p.to_display_point(map));
Self {
head: selection.head(),
cursor_shape,
is_newest,
range: selection.range(),
let point_range = map.expand_to_line(point_selection.range());
range = point_range.start.to_display_point(map)..point_range.end.to_display_point(map);
}
// any vim visual mode (including line mode)
if cursor_shape == CursorShape::Block && !range.is_empty() && !selection.reversed {
if head.column() > 0 {
head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left)
} else if head.row() > 0 && head != map.max_point() {
head = map.clip_point(
DisplayPoint::new(head.row() - 1, map.line_len(head.row() - 1)),
Bias::Left,
);
// updating range.end is a no-op unless you're cursor is
// on the newline containing a multi-buffer divider
// in which case the clip_point may have moved the head up
// an additional row.
range.end = DisplayPoint::new(head.row() + 1, 0);
active_rows.end = head.row();
}
}
Self {
head,
cursor_shape,
is_newest,
range,
active_rows,
}
}
}
@ -1637,7 +1657,7 @@ impl EditorElement {
let jump_position = language::ToPoint::to_point(&jump_anchor, buffer);
enum JumpIcon {}
MouseEventHandler::<JumpIcon, _>::new((*id).into(), cx, |state, _| {
MouseEventHandler::new::<JumpIcon, _>((*id).into(), cx, |state, _| {
let style = style.jump_icon.style_for(state);
Svg::new("icons/arrow_up_right_8.svg")
.with_color(style.color)
@ -2152,22 +2172,37 @@ impl Element<Editor> for EditorElement {
}
selections.extend(remote_selections);
let mut newest_selection_head = None;
if editor.show_local_selections {
let mut local_selections = editor
let mut local_selections: Vec<Selection<Point>> = editor
.selections
.disjoint_in_range(start_anchor..end_anchor, cx);
local_selections.extend(editor.selections.pending(cx));
let mut layouts = Vec::new();
let newest = editor.selections.newest(cx);
for selection in &local_selections {
for selection in local_selections.drain(..) {
let is_empty = selection.start == selection.end;
let selection_start = snapshot.prev_line_boundary(selection.start).1;
let selection_end = snapshot.next_line_boundary(selection.end).1;
for row in cmp::max(selection_start.row(), start_row)
..=cmp::min(selection_end.row(), end_row)
let is_newest = selection == newest;
let layout = SelectionLayout::new(
selection,
editor.selections.line_mode,
editor.cursor_shape,
&snapshot.display_snapshot,
is_newest,
);
if is_newest {
newest_selection_head = Some(layout.head);
}
for row in cmp::max(layout.active_rows.start, start_row)
..=cmp::min(layout.active_rows.end, end_row)
{
let contains_non_empty_selection = active_rows.entry(row).or_insert(!is_empty);
*contains_non_empty_selection |= !is_empty;
}
layouts.push(layout);
}
// Render the local selections in the leader's color when following.
@ -2175,22 +2210,7 @@ impl Element<Editor> for EditorElement {
.leader_replica_id
.unwrap_or_else(|| editor.replica_id(cx));
selections.push((
local_replica_id,
local_selections
.into_iter()
.map(|selection| {
let is_newest = selection == newest;
SelectionLayout::new(
selection,
editor.selections.line_mode,
editor.cursor_shape,
&snapshot.display_snapshot,
is_newest,
)
})
.collect(),
));
selections.push((local_replica_id, layouts));
}
let scrollbar_settings = &settings::get::<EditorSettings>(cx).scrollbar;
@ -2295,28 +2315,26 @@ impl Element<Editor> for EditorElement {
snapshot = editor.snapshot(cx);
}
let newest_selection_head = editor
.selections
.newest::<usize>(cx)
.head()
.to_display_point(&snapshot);
let style = editor.style(cx);
let mut context_menu = None;
let mut code_actions_indicator = None;
if (start_row..end_row).contains(&newest_selection_head.row()) {
if editor.context_menu_visible() {
context_menu = editor.render_context_menu(newest_selection_head, style.clone(), cx);
if let Some(newest_selection_head) = newest_selection_head {
if (start_row..end_row).contains(&newest_selection_head.row()) {
if editor.context_menu_visible() {
context_menu =
editor.render_context_menu(newest_selection_head, style.clone(), cx);
}
let active = matches!(
editor.context_menu,
Some(crate::ContextMenu::CodeActions(_))
);
code_actions_indicator = editor
.render_code_actions_indicator(&style, active, cx)
.map(|indicator| (newest_selection_head.row(), indicator));
}
let active = matches!(
editor.context_menu,
Some(crate::ContextMenu::CodeActions(_))
);
code_actions_indicator = editor
.render_code_actions_indicator(&style, active, cx)
.map(|indicator| (newest_selection_head.row(), indicator));
}
let visible_rows = start_row..start_row + line_layouts.len() as u32;
@ -2995,6 +3013,154 @@ mod tests {
assert_eq!(layouts.len(), 6);
}
#[gpui::test]
async fn test_vim_visual_selections(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let editor = cx
.add_window(|cx| {
let buffer = MultiBuffer::build_simple(&(sample_text(6, 6, 'a') + "\n"), cx);
Editor::new(EditorMode::Full, buffer, None, None, cx)
})
.root(cx);
let mut element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx)));
let (_, state) = editor.update(cx, |editor, cx| {
editor.cursor_shape = CursorShape::Block;
editor.change_selections(None, cx, |s| {
s.select_ranges([
Point::new(0, 0)..Point::new(1, 0),
Point::new(3, 2)..Point::new(3, 3),
Point::new(5, 6)..Point::new(6, 0),
]);
});
let mut new_parents = Default::default();
let mut notify_views_if_parents_change = Default::default();
let mut layout_cx = LayoutContext::new(
cx,
&mut new_parents,
&mut notify_views_if_parents_change,
false,
);
element.layout(
SizeConstraint::new(vec2f(500., 500.), vec2f(500., 500.)),
editor,
&mut layout_cx,
)
});
assert_eq!(state.selections.len(), 1);
let local_selections = &state.selections[0].1;
assert_eq!(local_selections.len(), 3);
// moves cursor back one line
assert_eq!(local_selections[0].head, DisplayPoint::new(0, 6));
assert_eq!(
local_selections[0].range,
DisplayPoint::new(0, 0)..DisplayPoint::new(1, 0)
);
// moves cursor back one column
assert_eq!(
local_selections[1].range,
DisplayPoint::new(3, 2)..DisplayPoint::new(3, 3)
);
assert_eq!(local_selections[1].head, DisplayPoint::new(3, 2));
// leaves cursor on the max point
assert_eq!(
local_selections[2].range,
DisplayPoint::new(5, 6)..DisplayPoint::new(6, 0)
);
assert_eq!(local_selections[2].head, DisplayPoint::new(6, 0));
// active lines does not include 1 (even though the range of the selection does)
assert_eq!(
state.active_rows.keys().cloned().collect::<Vec<u32>>(),
vec![0, 3, 5, 6]
);
// multi-buffer support
// in DisplayPoint co-ordinates, this is what we're dealing with:
// 0: [[file
// 1: header]]
// 2: aaaaaa
// 3: bbbbbb
// 4: cccccc
// 5:
// 6: ...
// 7: ffffff
// 8: gggggg
// 9: hhhhhh
// 10:
// 11: [[file
// 12: header]]
// 13: bbbbbb
// 14: cccccc
// 15: dddddd
let editor = cx
.add_window(|cx| {
let buffer = MultiBuffer::build_multi(
[
(
&(sample_text(8, 6, 'a') + "\n"),
vec![
Point::new(0, 0)..Point::new(3, 0),
Point::new(4, 0)..Point::new(7, 0),
],
),
(
&(sample_text(8, 6, 'a') + "\n"),
vec![Point::new(1, 0)..Point::new(3, 0)],
),
],
cx,
);
Editor::new(EditorMode::Full, buffer, None, None, cx)
})
.root(cx);
let mut element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx)));
let (_, state) = editor.update(cx, |editor, cx| {
editor.cursor_shape = CursorShape::Block;
editor.change_selections(None, cx, |s| {
s.select_display_ranges([
DisplayPoint::new(4, 0)..DisplayPoint::new(7, 0),
DisplayPoint::new(10, 0)..DisplayPoint::new(13, 0),
]);
});
let mut new_parents = Default::default();
let mut notify_views_if_parents_change = Default::default();
let mut layout_cx = LayoutContext::new(
cx,
&mut new_parents,
&mut notify_views_if_parents_change,
false,
);
element.layout(
SizeConstraint::new(vec2f(500., 500.), vec2f(500., 500.)),
editor,
&mut layout_cx,
)
});
assert_eq!(state.selections.len(), 1);
let local_selections = &state.selections[0].1;
assert_eq!(local_selections.len(), 2);
// moves cursor on excerpt boundary back a line
// and doesn't allow selection to bleed through
assert_eq!(
local_selections[0].range,
DisplayPoint::new(4, 0)..DisplayPoint::new(6, 0)
);
assert_eq!(local_selections[0].head, DisplayPoint::new(5, 0));
// moves cursor on buffer boundary back two lines
// and doesn't allow selection to bleed through
assert_eq!(
local_selections[1].range,
DisplayPoint::new(10, 0)..DisplayPoint::new(11, 0)
);
assert_eq!(local_selections[1].head, DisplayPoint::new(10, 0));
}
#[gpui::test]
fn test_layout_with_placeholder_text_and_blocks(cx: &mut TestAppContext) {
init_test(cx, |_| {});

View file

@ -565,7 +565,7 @@ impl InfoPopover {
)
});
MouseEventHandler::<InfoPopover, _>::new(0, cx, |_, cx| {
MouseEventHandler::new::<InfoPopover, _>(0, cx, |_, cx| {
let mut region_id = 0;
let view_id = cx.view_id();
@ -654,7 +654,7 @@ impl DiagnosticPopover {
let tooltip_style = theme::current(cx).tooltip.clone();
MouseEventHandler::<DiagnosticPopover, _>::new(0, cx, |_, _| {
MouseEventHandler::new::<DiagnosticPopover, _>(0, cx, |_, _| {
text.with_soft_wrap(true)
.contained()
.with_style(container_style)

File diff suppressed because it is too large Load diff

View file

@ -1028,7 +1028,7 @@ impl SearchableItem for Editor {
if let Some((_, _, excerpt_buffer)) = buffer.as_singleton() {
ranges.extend(
query
.search(excerpt_buffer.as_rope())
.search(excerpt_buffer, None)
.await
.into_iter()
.map(|range| {
@ -1038,17 +1038,22 @@ impl SearchableItem for Editor {
} else {
for excerpt in buffer.excerpt_boundaries_in_range(0..buffer.len()) {
let excerpt_range = excerpt.range.context.to_offset(&excerpt.buffer);
let rope = excerpt.buffer.as_rope().slice(excerpt_range.clone());
ranges.extend(query.search(&rope).await.into_iter().map(|range| {
let start = excerpt
.buffer
.anchor_after(excerpt_range.start + range.start);
let end = excerpt
.buffer
.anchor_before(excerpt_range.start + range.end);
buffer.anchor_in_excerpt(excerpt.id.clone(), start)
..buffer.anchor_in_excerpt(excerpt.id.clone(), end)
}));
ranges.extend(
query
.search(&excerpt.buffer, Some(excerpt_range.clone()))
.await
.into_iter()
.map(|range| {
let start = excerpt
.buffer
.anchor_after(excerpt_range.start + range.start);
let end = excerpt
.buffer
.anchor_before(excerpt_range.start + range.end);
buffer.anchor_in_excerpt(excerpt.id.clone(), start)
..buffer.anchor_in_excerpt(excerpt.id.clone(), end)
}),
);
}
}
ranges

View file

@ -13,6 +13,13 @@ pub fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
map.clip_point(point, Bias::Left)
}
pub fn saturating_left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
if point.column() > 0 {
*point.column_mut() -= 1;
}
map.clip_point(point, Bias::Left)
}
pub fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
let max_column = map.line_len(point.row());
if point.column() < max_column {
@ -24,6 +31,11 @@ pub fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
map.clip_point(point, Bias::Right)
}
pub fn saturating_right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
*point.column_mut() += 1;
map.clip_point(point, Bias::Right)
}
pub fn up(
map: &DisplaySnapshot,
start: DisplayPoint,
@ -49,10 +61,10 @@ pub fn up_by_rows(
goal: SelectionGoal,
preserve_column_at_start: bool,
) -> (DisplayPoint, SelectionGoal) {
let mut goal_column = if let SelectionGoal::Column(column) = goal {
column
} else {
map.column_to_chars(start.row(), start.column())
let mut goal_column = match goal {
SelectionGoal::Column(column) => column,
SelectionGoal::ColumnRange { end, .. } => end,
_ => map.column_to_chars(start.row(), start.column()),
};
let prev_row = start.row().saturating_sub(row_count);
@ -83,10 +95,10 @@ pub fn down_by_rows(
goal: SelectionGoal,
preserve_column_at_end: bool,
) -> (DisplayPoint, SelectionGoal) {
let mut goal_column = if let SelectionGoal::Column(column) = goal {
column
} else {
map.column_to_chars(start.row(), start.column())
let mut goal_column = match goal {
SelectionGoal::Column(column) => column,
SelectionGoal::ColumnRange { end, .. } => end,
_ => map.column_to_chars(start.row(), start.column()),
};
let new_row = start.row() + row_count;
@ -164,14 +176,21 @@ pub fn line_end(
}
pub fn previous_word_start(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
let raw_point = point.to_point(map);
let language = map.buffer_snapshot.language_at(raw_point);
find_preceding_boundary(map, point, |left, right| {
(char_kind(left) != char_kind(right) && !right.is_whitespace()) || left == '\n'
(char_kind(language, left) != char_kind(language, right) && !right.is_whitespace())
|| left == '\n'
})
}
pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
let raw_point = point.to_point(map);
let language = map.buffer_snapshot.language_at(raw_point);
find_preceding_boundary(map, point, |left, right| {
let is_word_start = char_kind(left) != char_kind(right) && !right.is_whitespace();
let is_word_start =
char_kind(language, left) != char_kind(language, right) && !right.is_whitespace();
let is_subword_start =
left == '_' && right != '_' || left.is_lowercase() && right.is_uppercase();
is_word_start || is_subword_start || left == '\n'
@ -179,14 +198,20 @@ pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> Dis
}
pub fn next_word_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
let raw_point = point.to_point(map);
let language = map.buffer_snapshot.language_at(raw_point);
find_boundary(map, point, |left, right| {
(char_kind(left) != char_kind(right) && !left.is_whitespace()) || right == '\n'
(char_kind(language, left) != char_kind(language, right) && !left.is_whitespace())
|| right == '\n'
})
}
pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
let raw_point = point.to_point(map);
let language = map.buffer_snapshot.language_at(raw_point);
find_boundary(map, point, |left, right| {
let is_word_end = (char_kind(left) != char_kind(right)) && !left.is_whitespace();
let is_word_end =
(char_kind(language, left) != char_kind(language, right)) && !left.is_whitespace();
let is_subword_end =
left != '_' && right == '_' || left.is_lowercase() && right.is_uppercase();
is_word_end || is_subword_end || right == '\n'
@ -373,10 +398,15 @@ pub fn find_boundary_in_line(
}
pub fn is_inside_word(map: &DisplaySnapshot, point: DisplayPoint) -> bool {
let raw_point = point.to_point(map);
let language = map.buffer_snapshot.language_at(raw_point);
let ix = map.clip_point(point, Bias::Left).to_offset(map, Bias::Left);
let text = &map.buffer_snapshot;
let next_char_kind = text.chars_at(ix).next().map(char_kind);
let prev_char_kind = text.reversed_chars_at(ix).next().map(char_kind);
let next_char_kind = text.chars_at(ix).next().map(|c| char_kind(language, c));
let prev_char_kind = text
.reversed_chars_at(ix)
.next()
.map(|c| char_kind(language, c));
prev_char_kind.zip(next_char_kind) == Some((CharKind::Word, CharKind::Word))
}

View file

@ -1565,6 +1565,25 @@ impl MultiBuffer {
cx.add_model(|cx| Self::singleton(buffer, cx))
}
pub fn build_multi<const COUNT: usize>(
excerpts: [(&str, Vec<Range<Point>>); COUNT],
cx: &mut gpui::AppContext,
) -> ModelHandle<Self> {
let multi = cx.add_model(|_| Self::new(0));
for (text, ranges) in excerpts {
let buffer = cx.add_model(|cx| Buffer::new(0, text, cx));
let excerpt_ranges = ranges.into_iter().map(|range| ExcerptRange {
context: range,
primary: None,
});
multi.update(cx, |multi, cx| {
multi.push_excerpts(buffer, excerpt_ranges, cx)
});
}
multi
}
pub fn build_from_buffer(
buffer: ModelHandle<Buffer>,
cx: &mut gpui::AppContext,
@ -1846,13 +1865,16 @@ impl MultiBufferSnapshot {
let mut end = start;
let mut next_chars = self.chars_at(start).peekable();
let mut prev_chars = self.reversed_chars_at(start).peekable();
let language = self.language_at(start);
let kind = |c| char_kind(language, c);
let word_kind = cmp::max(
prev_chars.peek().copied().map(char_kind),
next_chars.peek().copied().map(char_kind),
prev_chars.peek().copied().map(kind),
next_chars.peek().copied().map(kind),
);
for ch in prev_chars {
if Some(char_kind(ch)) == word_kind && ch != '\n' {
if Some(kind(ch)) == word_kind && ch != '\n' {
start -= ch.len_utf8();
} else {
break;
@ -1860,7 +1882,7 @@ impl MultiBufferSnapshot {
}
for ch in next_chars {
if Some(char_kind(ch)) == word_kind && ch != '\n' {
if Some(kind(ch)) == word_kind && ch != '\n' {
end += ch.len_utf8();
} else {
break;

View file

@ -13,13 +13,13 @@ use gpui::{
};
use language::{Bias, Point};
use util::ResultExt;
use workspace::{item::Item, WorkspaceId};
use workspace::WorkspaceId;
use crate::{
display_map::{DisplaySnapshot, ToDisplayPoint},
hover_popover::hide_hover,
persistence::DB,
Anchor, DisplayPoint, Editor, EditorMode, Event, InlayRefreshReason, MultiBufferSnapshot,
Anchor, DisplayPoint, Editor, EditorMode, Event, InlayHintRefreshReason, MultiBufferSnapshot,
ToPoint,
};
@ -29,6 +29,7 @@ use self::{
};
pub const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28);
pub const VERTICAL_SCROLL_MARGIN: f32 = 3.;
const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Default)]
@ -136,7 +137,7 @@ pub struct ScrollManager {
impl ScrollManager {
pub fn new() -> Self {
ScrollManager {
vertical_scroll_margin: 3.0,
vertical_scroll_margin: VERTICAL_SCROLL_MARGIN,
anchor: ScrollAnchor::new(),
ongoing: OngoingScroll::new(),
autoscroll_request: None,
@ -301,7 +302,7 @@ impl Editor {
cx.spawn(|editor, mut cx| async move {
editor
.update(&mut cx, |editor, cx| {
editor.refresh_inlays(InlayRefreshReason::NewLinesShown, cx)
editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx)
})
.ok()
})
@ -333,9 +334,7 @@ impl Editor {
cx,
);
if !self.is_singleton(cx) {
self.refresh_inlays(InlayRefreshReason::NewLinesShown, cx);
}
self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
}
pub fn scroll_position(&self, cx: &mut ViewContext<Self>) -> Vector2F {

View file

@ -1,7 +1,7 @@
use std::{
cell::Ref,
cmp, iter, mem,
ops::{Deref, Range, Sub},
ops::{Deref, DerefMut, Range, Sub},
sync::Arc,
};
@ -53,7 +53,7 @@ impl SelectionsCollection {
}
}
fn display_map(&self, cx: &mut AppContext) -> DisplaySnapshot {
pub fn display_map(&self, cx: &mut AppContext) -> DisplaySnapshot {
self.display_map.update(cx, |map, cx| map.snapshot(cx))
}
@ -250,6 +250,10 @@ impl SelectionsCollection {
resolve(self.oldest_anchor(), &self.buffer(cx))
}
pub fn first_anchor(&self) -> Selection<Anchor> {
self.disjoint[0].clone()
}
pub fn first<D: TextDimension + Ord + Sub<D, Output = D>>(
&self,
cx: &AppContext,
@ -352,7 +356,7 @@ pub struct MutableSelectionsCollection<'a> {
}
impl<'a> MutableSelectionsCollection<'a> {
fn display_map(&mut self) -> DisplaySnapshot {
pub fn display_map(&mut self) -> DisplaySnapshot {
self.collection.display_map(self.cx)
}
@ -607,6 +611,10 @@ impl<'a> MutableSelectionsCollection<'a> {
self.select_anchors(selections)
}
pub fn new_selection_id(&mut self) -> usize {
post_inc(&mut self.next_selection_id)
}
pub fn select_display_ranges<T>(&mut self, ranges: T)
where
T: IntoIterator<Item = Range<DisplayPoint>>,
@ -831,6 +839,12 @@ impl<'a> Deref for MutableSelectionsCollection<'a> {
}
}
impl<'a> DerefMut for MutableSelectionsCollection<'a> {
fn deref_mut(&mut self) -> &mut Self::Target {
self.collection
}
}
// Panics if passed selections are not in order
pub fn resolve_multiple<'a, D, I>(
selections: I,

View file

@ -35,7 +35,7 @@ impl View for DeployFeedbackButton {
let theme = theme::current(cx).clone();
Stack::new()
.with_child(
MouseEventHandler::<Self, Self>::new(0, cx, |state, _| {
MouseEventHandler::new::<Self, _>(0, cx, |state, _| {
let style = &theme
.workspace
.status_bar
@ -44,7 +44,7 @@ impl View for DeployFeedbackButton {
.in_state(active)
.style_for(state);
Svg::new("icons/feedback_16.svg")
Svg::new("icons/feedback.svg")
.with_color(style.icon_color)
.constrained()
.with_width(style.icon_size)

View file

@ -41,7 +41,7 @@ impl View for FeedbackInfoText {
.aligned(),
)
.with_child(
MouseEventHandler::<OpenZedCommunityRepo, Self>::new(0, cx, |state, _| {
MouseEventHandler::new::<OpenZedCommunityRepo, _>(0, cx, |state, _| {
let contained_text = if state.hovered() {
&theme.feedback.link_text_hover
} else {

View file

@ -52,7 +52,7 @@ impl View for SubmitFeedbackButton {
.map_or(true, |i| i.read(cx).allow_submission);
enum SubmitFeedbackButton {}
MouseEventHandler::<SubmitFeedbackButton, Self>::new(0, cx, |state, _| {
MouseEventHandler::new::<SubmitFeedbackButton, _>(0, cx, |state, _| {
let text;
let style = if allow_submission {
text = "Submit as Markdown";

View file

@ -0,0 +1,237 @@
use button_component::Button;
use gpui::{
color::Color,
elements::{Component, ContainerStyle, Flex, Label, ParentElement},
fonts::{self, TextStyle},
platform::WindowOptions,
AnyElement, App, Element, Entity, View, ViewContext,
};
use log::LevelFilter;
use pathfinder_geometry::vector::vec2f;
use simplelog::SimpleLogger;
use theme::Toggleable;
use toggleable_button::ToggleableButton;
// cargo run -p gpui --example components
fn main() {
SimpleLogger::init(LevelFilter::Info, Default::default()).expect("could not initialize logger");
App::new(()).unwrap().run(|cx| {
cx.platform().activate(true);
cx.add_window(WindowOptions::with_bounds(vec2f(300., 200.)), |_| {
TestView {
count: 0,
is_doubling: false,
}
});
});
}
pub struct TestView {
count: usize,
is_doubling: bool,
}
impl TestView {
fn increase_count(&mut self) {
if self.is_doubling {
self.count *= 2;
} else {
self.count += 1;
}
}
}
impl Entity for TestView {
type Event = ();
}
type ButtonStyle = ContainerStyle;
impl View for TestView {
fn ui_name() -> &'static str {
"TestView"
}
fn render(&mut self, cx: &mut ViewContext<'_, '_, Self>) -> AnyElement<Self> {
fonts::with_font_cache(cx.font_cache.to_owned(), || {
Flex::column()
.with_child(Label::new(
format!("Count: {}", self.count),
TextStyle::for_color(Color::red()),
))
.with_child(
Button::new(move |_, v: &mut Self, cx| {
v.increase_count();
cx.notify();
})
.with_text(
"Hello from a counting BUTTON",
TextStyle::for_color(Color::blue()),
)
.with_style(ButtonStyle::fill(Color::yellow()))
.element(),
)
.with_child(
ToggleableButton::new(self.is_doubling, move |_, v: &mut Self, cx| {
v.is_doubling = !v.is_doubling;
cx.notify();
})
.with_text("Double the count?", TextStyle::for_color(Color::black()))
.with_style(Toggleable {
inactive: ButtonStyle::fill(Color::red()),
active: ButtonStyle::fill(Color::green()),
})
.element(),
)
.expanded()
.contained()
.with_background_color(Color::white())
.into_any()
})
}
}
mod theme {
pub struct Toggleable<T> {
pub inactive: T,
pub active: T,
}
impl<T> Toggleable<T> {
pub fn style_for(&self, active: bool) -> &T {
if active {
&self.active
} else {
&self.inactive
}
}
}
}
// Component creation:
mod toggleable_button {
use gpui::{
elements::{Component, ContainerStyle, LabelStyle},
scene::MouseClick,
EventContext, View,
};
use crate::{button_component::Button, theme::Toggleable};
pub struct ToggleableButton<V: View> {
active: bool,
style: Option<Toggleable<ContainerStyle>>,
button: Button<V>,
}
impl<V: View> ToggleableButton<V> {
pub fn new<F>(active: bool, on_click: F) -> Self
where
F: Fn(MouseClick, &mut V, &mut EventContext<V>) + 'static,
{
Self {
active,
button: Button::new(on_click),
style: None,
}
}
pub fn with_text(self, text: &str, style: impl Into<LabelStyle>) -> ToggleableButton<V> {
ToggleableButton {
active: self.active,
style: self.style,
button: self.button.with_text(text, style),
}
}
pub fn with_style(self, style: Toggleable<ContainerStyle>) -> ToggleableButton<V> {
ToggleableButton {
active: self.active,
style: Some(style),
button: self.button,
}
}
}
impl<V: View> Component<V> for ToggleableButton<V> {
fn render(self, v: &mut V, cx: &mut gpui::ViewContext<V>) -> gpui::AnyElement<V> {
let button = if let Some(style) = self.style {
self.button.with_style(*style.style_for(self.active))
} else {
self.button
};
button.render(v, cx)
}
}
}
mod button_component {
use gpui::{
elements::{Component, ContainerStyle, Label, LabelStyle, MouseEventHandler},
platform::MouseButton,
scene::MouseClick,
AnyElement, Element, EventContext, TypeTag, View, ViewContext,
};
type ClickHandler<V> = Box<dyn Fn(MouseClick, &mut V, &mut EventContext<V>)>;
pub struct Button<V: View> {
click_handler: ClickHandler<V>,
tag: TypeTag,
contents: Option<AnyElement<V>>,
style: Option<ContainerStyle>,
}
impl<V: View> Button<V> {
pub fn new<F: Fn(MouseClick, &mut V, &mut EventContext<V>) + 'static>(handler: F) -> Self {
Self {
click_handler: Box::new(handler),
tag: TypeTag::new::<F>(),
style: None,
contents: None,
}
}
pub fn with_text(mut self, text: &str, style: impl Into<LabelStyle>) -> Self {
self.contents = Some(Label::new(text.to_string(), style).into_any());
self
}
pub fn _with_contents<E: Element<V>>(mut self, contents: E) -> Self {
self.contents = Some(contents.into_any());
self
}
pub fn with_style(mut self, style: ContainerStyle) -> Self {
self.style = Some(style);
self
}
}
impl<V: View> Component<V> for Button<V> {
fn render(self, _: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V> {
let click_handler = self.click_handler;
let result = MouseEventHandler::new_dynamic(self.tag, 0, cx, |_, _| {
self.contents
.unwrap_or_else(|| gpui::elements::Empty::new().into_any())
})
.on_click(MouseButton::Left, move |click, v, cx| {
click_handler(click, v, cx);
})
.contained();
let result = if let Some(style) = self.style {
result.with_style(style)
} else {
result
};
result.into_any()
}
}
}

View file

@ -574,6 +574,14 @@ impl AppContext {
}
}
pub fn optional_global<T: 'static>(&self) -> Option<&T> {
if let Some(global) = self.globals.get(&TypeId::of::<T>()) {
Some(global.downcast_ref().unwrap())
} else {
None
}
}
pub fn upgrade(&self) -> App {
App(self.weak_self.as_ref().unwrap().upgrade().unwrap())
}
@ -3284,7 +3292,11 @@ impl<'a, 'b, V: 'static> ViewContext<'a, 'b, V> {
}
pub fn mouse_state<Tag: 'static>(&self, region_id: usize) -> MouseState {
let region_id = MouseRegionId::new::<Tag>(self.view_id, region_id);
self.mouse_state_dynamic(TypeTag::new::<Tag>(), region_id)
}
pub fn mouse_state_dynamic(&self, tag: TypeTag, region_id: usize) -> MouseState {
let region_id = MouseRegionId::new(tag, self.view_id, region_id);
MouseState {
hovered: self.window.hovered_region_ids.contains(&region_id),
clicked: if let Some((clicked_region_id, button)) = self.window.clicked_region {
@ -3305,11 +3317,20 @@ impl<'a, 'b, V: 'static> ViewContext<'a, 'b, V> {
&mut self,
element_id: usize,
initial: T,
) -> ElementStateHandle<T> {
self.element_state_dynamic(TypeTag::new::<Tag>(), element_id, initial)
}
pub fn element_state_dynamic<T: 'static>(
&mut self,
tag: TypeTag,
element_id: usize,
initial: T,
) -> ElementStateHandle<T> {
let id = ElementStateId {
view_id: self.view_id(),
element_id,
tag: TypeId::of::<Tag>(),
tag,
};
self.element_states
.entry(id)
@ -3327,19 +3348,65 @@ impl<'a, 'b, V: 'static> ViewContext<'a, 'b, V> {
pub fn rem_pixels(&self) -> f32 {
16.
}
pub fn default_element_state_dynamic<T: 'static + Default>(
&mut self,
tag: TypeTag,
element_id: usize,
) -> ElementStateHandle<T> {
self.element_state_dynamic::<T>(tag, element_id, T::default())
}
}
impl<V: View> ViewContext<'_, '_, V> {
pub fn emit(&mut self, payload: V::Event) {
pub fn emit(&mut self, event: V::Event) {
self.window_context
.pending_effects
.push_back(Effect::Event {
entity_id: self.view_id,
payload: Box::new(payload),
payload: Box::new(event),
});
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TypeTag {
tag: TypeId,
composed: Option<TypeId>,
#[cfg(debug_assertions)]
tag_type_name: &'static str,
}
impl TypeTag {
pub fn new<Tag: 'static>() -> Self {
Self {
tag: TypeId::of::<Tag>(),
composed: None,
#[cfg(debug_assertions)]
tag_type_name: std::any::type_name::<Tag>(),
}
}
pub fn dynamic(tag: TypeId, #[cfg(debug_assertions)] type_name: &'static str) -> Self {
Self {
tag,
composed: None,
#[cfg(debug_assertions)]
tag_type_name: type_name,
}
}
pub fn compose(mut self, other: TypeTag) -> Self {
self.composed = Some(other.tag);
self
}
#[cfg(debug_assertions)]
pub(crate) fn type_name(&self) -> &'static str {
self.tag_type_name
}
}
impl<V> BorrowAppContext for ViewContext<'_, '_, V> {
fn read_with<T, F: FnOnce(&AppContext) -> T>(&self, f: F) -> T {
BorrowAppContext::read_with(&*self.window_context, f)
@ -4789,7 +4856,7 @@ impl Hash for AnyWeakViewHandle {
pub struct ElementStateId {
view_id: usize,
element_id: usize,
tag: TypeId,
tag: TypeTag,
}
pub struct ElementStateHandle<T> {
@ -5251,7 +5318,7 @@ mod tests {
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
enum Handler {}
let mouse_down_count = self.mouse_down_count.clone();
MouseEventHandler::<Handler, _>::new(0, cx, |_, _| Empty::new())
MouseEventHandler::new::<Handler, _>(0, cx, |_, _| Empty::new())
.on_down(MouseButton::Left, move |_, _, _| {
mouse_down_count.fetch_add(1, SeqCst);
})

View file

@ -1,10 +1,13 @@
use std::any::{Any, TypeId};
use crate::TypeTag;
pub trait Action: 'static {
fn id(&self) -> TypeId;
fn namespace(&self) -> &'static str;
fn name(&self) -> &'static str;
fn as_any(&self) -> &dyn Any;
fn type_tag(&self) -> TypeTag;
fn boxed_clone(&self) -> Box<dyn Action>;
fn eq(&self, other: &dyn Action) -> bool;
@ -107,6 +110,10 @@ macro_rules! __impl_action {
}
}
fn type_tag(&self) -> $crate::TypeTag {
$crate::TypeTag::new::<Self>()
}
$from_json_fn
}
};

View file

@ -507,7 +507,7 @@ impl<'a> WindowContext<'a> {
}
pub(crate) fn dispatch_event(&mut self, event: Event, event_reused: bool) -> bool {
self.dispatch_to_interactive_regions(&event);
self.dispatch_to_new_event_handlers(&event);
let mut mouse_events = SmallVec::<[_; 2]>::new();
let mut notified_views: HashSet<usize> = Default::default();
@ -886,9 +886,8 @@ impl<'a> WindowContext<'a> {
any_event_handled
}
fn dispatch_to_interactive_regions(&mut self, event: &Event) {
fn dispatch_to_new_event_handlers(&mut self, event: &Event) {
if let Some(mouse_event) = event.mouse_event() {
let mouse_position = event.position().expect("mouse events must have a position");
let event_handlers = self.window.take_event_handlers();
for event_handler in event_handlers.iter().rev() {
if event_handler.event_type == mouse_event.type_id() {

View file

@ -1,6 +1,7 @@
mod align;
mod canvas;
mod clipped;
mod component;
mod constrained_box;
mod container;
mod empty;
@ -21,9 +22,9 @@ mod tooltip;
mod uniform_list;
pub use self::{
align::*, canvas::*, constrained_box::*, container::*, empty::*, flex::*, hook::*, image::*,
keystroke_label::*, label::*, list::*, mouse_event_handler::*, overlay::*, resizable::*,
stack::*, svg::*, text::*, tooltip::*, uniform_list::*,
align::*, canvas::*, component::*, constrained_box::*, container::*, empty::*, flex::*,
hook::*, image::*, keystroke_label::*, label::*, list::*, mouse_event_handler::*, overlay::*,
resizable::*, stack::*, svg::*, text::*, tooltip::*, uniform_list::*,
};
pub use crate::window::ChildView;
@ -33,7 +34,7 @@ use crate::{
rect::RectF,
vector::{vec2f, Vector2F},
},
json, Action, Entity, LayoutContext, PaintContext, SceneBuilder, SizeConstraint, View,
json, Action, Entity, LayoutContext, PaintContext, SceneBuilder, SizeConstraint, TypeTag, View,
ViewContext, WeakViewHandle, WindowContext,
};
use anyhow::{anyhow, Result};
@ -41,12 +42,21 @@ use collections::HashMap;
use core::panic;
use json::ToJson;
use smallvec::SmallVec;
use std::{any::Any, borrow::Cow, mem, ops::Range};
use std::{
any::{type_name, Any},
borrow::Cow,
mem,
ops::Range,
};
pub trait Element<V: 'static>: 'static {
type LayoutState;
type PaintState;
fn view_name(&self) -> &'static str {
type_name::<V>()
}
fn layout(
&mut self,
constraint: SizeConstraint,
@ -167,6 +177,20 @@ pub trait Element<V: 'static>: 'static {
FlexItem::new(self.into_any()).float()
}
fn with_dynamic_tooltip(
self,
tag: TypeTag,
id: usize,
text: impl Into<Cow<'static, str>>,
action: Option<Box<dyn Action>>,
style: TooltipStyle,
cx: &mut ViewContext<V>,
) -> Tooltip<V>
where
Self: 'static + Sized,
{
Tooltip::new_dynamic(tag, id, text, action, style, self.into_any(), cx)
}
fn with_tooltip<Tag: 'static>(
self,
id: usize,
@ -181,23 +205,34 @@ pub trait Element<V: 'static>: 'static {
Tooltip::new::<Tag>(id, text, action, style, self.into_any(), cx)
}
fn resizable(
/// Uses the the given element to calculate resizes for the given tag
fn provide_resize_bounds<Tag: 'static>(self) -> BoundsProvider<V, Tag>
where
Self: 'static + Sized,
{
BoundsProvider::<_, Tag>::new(self.into_any())
}
/// Calls the given closure with the new size of the element whenever the
/// handle is dragged. This will be calculated in relation to the bounds
/// provided by the given tag
fn resizable<Tag: 'static>(
self,
side: HandleSide,
size: f32,
on_resize: impl 'static + FnMut(&mut V, f32, &mut ViewContext<V>),
on_resize: impl 'static + FnMut(&mut V, Option<f32>, &mut ViewContext<V>),
) -> Resizable<V>
where
Self: 'static + Sized,
{
Resizable::new(self.into_any(), side, size, on_resize)
Resizable::new::<Tag>(self.into_any(), side, size, on_resize)
}
fn mouse<Tag>(self, region_id: usize) -> MouseEventHandler<Tag, V>
fn mouse<Tag: 'static>(self, region_id: usize) -> MouseEventHandler<V>
where
Self: Sized,
{
MouseEventHandler::for_child(self.into_any(), region_id)
MouseEventHandler::for_child::<Tag>(self.into_any(), region_id)
}
}
@ -267,8 +302,16 @@ impl<V, E: Element<V>> AnyElementState<V> for ElementState<V, E> {
| ElementState::PostLayout { mut element, .. }
| ElementState::PostPaint { mut element, .. } => {
let (size, layout) = element.layout(constraint, view, cx);
debug_assert!(size.x().is_finite());
debug_assert!(size.y().is_finite());
debug_assert!(
size.x().is_finite(),
"Element for {:?} had infinite x size after layout",
element.view_name()
);
debug_assert!(
size.y().is_finite(),
"Element for {:?} had infinite y size after layout",
element.view_name()
);
result = size;
ElementState::PostLayout {

View file

@ -0,0 +1,190 @@
use std::marker::PhantomData;
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
use crate::{
AnyElement, Element, LayoutContext, PaintContext, SceneBuilder, SizeConstraint, View,
ViewContext,
};
use super::Empty;
pub trait GeneralComponent {
fn render<V: View>(self, v: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V>;
fn element<V: View>(self) -> ComponentAdapter<V, Self>
where
Self: Sized,
{
ComponentAdapter::new(self)
}
}
pub trait StyleableComponent {
type Style: Clone;
type Output: GeneralComponent;
fn with_style(self, style: Self::Style) -> Self::Output;
}
impl GeneralComponent for () {
fn render<V: View>(self, _: &mut V, _: &mut ViewContext<V>) -> AnyElement<V> {
Empty::new().into_any()
}
}
impl StyleableComponent for () {
type Style = ();
type Output = ();
fn with_style(self, _: Self::Style) -> Self::Output {
()
}
}
pub trait Component<V: View> {
fn render(self, v: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V>;
fn element(self) -> ComponentAdapter<V, Self>
where
Self: Sized,
{
ComponentAdapter::new(self)
}
}
impl<V: View, C: GeneralComponent> Component<V> for C {
fn render(self, v: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V> {
self.render(v, cx)
}
}
// StylableComponent -> GeneralComponent
pub struct StylableComponentAdapter<C: Component<V>, V: View> {
component: C,
phantom: std::marker::PhantomData<V>,
}
impl<C: Component<V>, V: View> StylableComponentAdapter<C, V> {
pub fn new(component: C) -> Self {
Self {
component,
phantom: std::marker::PhantomData,
}
}
}
impl<C: GeneralComponent, V: View> StyleableComponent for StylableComponentAdapter<C, V> {
type Style = ();
type Output = C;
fn with_style(self, _: Self::Style) -> Self::Output {
self.component
}
}
// Element -> Component
pub struct ElementAdapter<V: View> {
element: AnyElement<V>,
_phantom: std::marker::PhantomData<V>,
}
impl<V: View> ElementAdapter<V> {
pub fn new(element: AnyElement<V>) -> Self {
Self {
element,
_phantom: std::marker::PhantomData,
}
}
}
impl<V: View> Component<V> for ElementAdapter<V> {
fn render(self, _: &mut V, _: &mut ViewContext<V>) -> AnyElement<V> {
self.element
}
}
// Component -> Element
pub struct ComponentAdapter<V: View, E> {
component: Option<E>,
element: Option<AnyElement<V>>,
phantom: PhantomData<V>,
}
impl<E, V: View> ComponentAdapter<V, E> {
pub fn new(e: E) -> Self {
Self {
component: Some(e),
element: None,
phantom: PhantomData,
}
}
}
impl<V: View, C: Component<V> + 'static> Element<V> for ComponentAdapter<V, C> {
type LayoutState = ();
type PaintState = ();
fn layout(
&mut self,
constraint: SizeConstraint,
view: &mut V,
cx: &mut LayoutContext<V>,
) -> (Vector2F, Self::LayoutState) {
if self.element.is_none() {
let element = self
.component
.take()
.expect("Component can only be rendered once")
.render(view, cx.view_context());
self.element = Some(element);
}
let constraint = self.element.as_mut().unwrap().layout(constraint, view, cx);
(constraint, ())
}
fn paint(
&mut self,
scene: &mut SceneBuilder,
bounds: RectF,
visible_bounds: RectF,
_: &mut Self::LayoutState,
view: &mut V,
cx: &mut PaintContext<V>,
) -> Self::PaintState {
self.element
.as_mut()
.expect("Layout should always be called before paint")
.paint(scene, bounds.origin(), visible_bounds, view, cx)
}
fn rect_for_text_range(
&self,
range_utf16: std::ops::Range<usize>,
_: RectF,
_: RectF,
_: &Self::LayoutState,
_: &Self::PaintState,
view: &V,
cx: &ViewContext<V>,
) -> Option<RectF> {
self.element
.as_ref()
.and_then(|el| el.rect_for_text_range(range_utf16, view, cx))
}
fn debug(
&self,
_: RectF,
_: &Self::LayoutState,
_: &Self::PaintState,
view: &V,
cx: &ViewContext<V>,
) -> serde_json::Value {
serde_json::json!({
"type": "ComponentAdapter",
"child": self.element.as_ref().map(|el| el.debug(view, cx)),
})
}
}

View file

@ -37,6 +37,15 @@ pub struct ContainerStyle {
pub cursor: Option<CursorStyle>,
}
impl ContainerStyle {
pub fn fill(color: Color) -> Self {
Self {
background_color: Some(color),
..Default::default()
}
}
}
pub struct Container<V> {
child: AnyElement<V>,
style: ContainerStyle,

View file

@ -11,12 +11,12 @@ use crate::{
MouseHover, MouseMove, MouseMoveOut, MouseScrollWheel, MouseUp, MouseUpOut,
},
AnyElement, Element, EventContext, LayoutContext, MouseRegion, MouseState, PaintContext,
SceneBuilder, SizeConstraint, ViewContext,
SceneBuilder, SizeConstraint, TypeTag, ViewContext,
};
use serde_json::json;
use std::{marker::PhantomData, ops::Range};
use std::ops::Range;
pub struct MouseEventHandler<Tag: 'static, V> {
pub struct MouseEventHandler<V: 'static> {
child: AnyElement<V>,
region_id: usize,
cursor_style: Option<CursorStyle>,
@ -26,13 +26,13 @@ pub struct MouseEventHandler<Tag: 'static, V> {
notify_on_click: bool,
above: bool,
padding: Padding,
_tag: PhantomData<Tag>,
tag: TypeTag,
}
/// Element which provides a render_child callback with a MouseState and paints a mouse
/// region under (or above) it for easy mouse event handling.
impl<Tag, V: 'static> MouseEventHandler<Tag, V> {
pub fn for_child(child: impl Element<V>, region_id: usize) -> Self {
impl<V: 'static> MouseEventHandler<V> {
pub fn for_child<Tag: 'static>(child: impl Element<V>, region_id: usize) -> Self {
Self {
child: child.into_any(),
region_id,
@ -43,16 +43,19 @@ impl<Tag, V: 'static> MouseEventHandler<Tag, V> {
hoverable: false,
above: false,
padding: Default::default(),
_tag: PhantomData,
tag: TypeTag::new::<Tag>(),
}
}
pub fn new<E, F>(region_id: usize, cx: &mut ViewContext<V>, render_child: F) -> Self
pub fn new<Tag: 'static, E>(
region_id: usize,
cx: &mut ViewContext<V>,
render_child: impl FnOnce(&mut MouseState, &mut ViewContext<V>) -> E,
) -> Self
where
E: Element<V>,
F: FnOnce(&mut MouseState, &mut ViewContext<V>) -> E,
{
let mut mouse_state = cx.mouse_state::<Tag>(region_id);
let mut mouse_state = cx.mouse_state_dynamic(TypeTag::new::<Tag>(), region_id);
let child = render_child(&mut mouse_state, cx).into_any();
let notify_on_hover = mouse_state.accessed_hovered();
let notify_on_click = mouse_state.accessed_clicked();
@ -66,19 +69,46 @@ impl<Tag, V: 'static> MouseEventHandler<Tag, V> {
hoverable: true,
above: false,
padding: Default::default(),
_tag: PhantomData,
tag: TypeTag::new::<Tag>(),
}
}
pub fn new_dynamic(
tag: TypeTag,
region_id: usize,
cx: &mut ViewContext<V>,
render_child: impl FnOnce(&mut MouseState, &mut ViewContext<V>) -> AnyElement<V>,
) -> Self {
let mut mouse_state = cx.mouse_state_dynamic(tag, region_id);
let child = render_child(&mut mouse_state, cx);
let notify_on_hover = mouse_state.accessed_hovered();
let notify_on_click = mouse_state.accessed_clicked();
Self {
child,
region_id,
cursor_style: None,
handlers: Default::default(),
notify_on_hover,
notify_on_click,
hoverable: true,
above: false,
padding: Default::default(),
tag,
}
}
/// Modifies the MouseEventHandler to render the MouseRegion above the child element. Useful
/// for drag and drop handling and similar events which should be captured before the child
/// gets the opportunity
pub fn above<D, F>(region_id: usize, cx: &mut ViewContext<V>, render_child: F) -> Self
pub fn above<Tag: 'static, D>(
region_id: usize,
cx: &mut ViewContext<V>,
render_child: impl FnOnce(&mut MouseState, &mut ViewContext<V>) -> D,
) -> Self
where
D: Element<V>,
F: FnOnce(&mut MouseState, &mut ViewContext<V>) -> D,
{
let mut handler = Self::new(region_id, cx, render_child);
let mut handler = Self::new::<Tag, _>(region_id, cx, render_child);
handler.above = true;
handler
}
@ -223,7 +253,8 @@ impl<Tag, V: 'static> MouseEventHandler<Tag, V> {
});
}
scene.push_mouse_region(
MouseRegion::from_handlers::<Tag>(
MouseRegion::from_handlers(
self.tag,
cx.view_id(),
self.region_id,
hit_bounds,
@ -236,7 +267,7 @@ impl<Tag, V: 'static> MouseEventHandler<Tag, V> {
}
}
impl<Tag, V: 'static> Element<V> for MouseEventHandler<Tag, V> {
impl<V: 'static> Element<V> for MouseEventHandler<V> {
type LayoutState = ();
type PaintState = ();

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