Detailed changes
@@ -20,9 +20,7 @@ jobs:
id: get-content
with:
stringToTruncate: |
- 📣 Zed ${{ github.event.release.tag_name }} was just released!
-
- Restart your Zed or head to ${{ steps.get-release-url.outputs.URL }} to grab it.
+ 📣 Zed [${{ github.event.release.tag_name }}](${{ steps.get-release-url.outputs.URL }}) was just released!
${{ github.event.release.body }}
maxLength: 2000
@@ -103,7 +103,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
- "tiktoken-rs 0.5.4",
+ "tiktoken-rs",
"util",
]
@@ -316,12 +316,13 @@ dependencies = [
"regex",
"schemars",
"search",
+ "semantic_index",
"serde",
"serde_json",
"settings",
"smol",
"theme",
- "tiktoken-rs 0.4.5",
+ "tiktoken-rs",
"util",
"uuid 1.4.1",
"workspace",
@@ -1466,7 +1467,7 @@ dependencies = [
[[package]]
name = "collab"
-version = "0.24.0"
+version = "0.25.0"
dependencies = [
"anyhow",
"async-trait",
@@ -1629,6 +1630,7 @@ dependencies = [
"theme",
"util",
"workspace",
+ "zed-actions",
]
[[package]]
@@ -6975,7 +6977,7 @@ dependencies = [
"smol",
"tempdir",
"theme",
- "tiktoken-rs 0.5.4",
+ "tiktoken-rs",
"tree-sitter",
"tree-sitter-cpp",
"tree-sitter-elixir",
@@ -8166,21 +8168,6 @@ dependencies = [
"weezl",
]
-[[package]]
-name = "tiktoken-rs"
-version = "0.4.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52aacc1cff93ba9d5f198c62c49c77fa0355025c729eed3326beaf7f33bc8614"
-dependencies = [
- "anyhow",
- "base64 0.21.4",
- "bstr",
- "fancy-regex",
- "lazy_static",
- "parking_lot 0.12.1",
- "rustc-hash",
-]
-
[[package]]
name = "tiktoken-rs"
version = "0.5.4"
@@ -10103,7 +10090,7 @@ dependencies = [
[[package]]
name = "zed"
-version = "0.109.0"
+version = "0.110.0"
dependencies = [
"activity_indicator",
"ai",
@@ -10238,6 +10225,7 @@ name = "zed-actions"
version = "0.1.0"
dependencies = [
"gpui",
+ "serde",
]
[[package]]
@@ -1,4 +1,4 @@
web: cd ../zed.dev && PORT=3000 npm run dev
-collab: cd crates/collab && RUST_LOG=${RUST_LOG:-collab=info} cargo run serve
+collab: cd crates/collab && RUST_LOG=${RUST_LOG:-warn,collab=info} cargo run serve
livekit: livekit-server --dev
postgrest: postgrest crates/collab/admin_api.conf
@@ -0,0 +1,3 @@
+<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -0,0 +1,3 @@
+<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M3.74393 2.00204C3.41963 1.97524 3.13502 2.37572 3.10823 2.70001C3.08143 3.0243 3.32558 3.47321 3.64986 3.50001C7.99878 3.85934 11.1406 7.00122 11.5 11.3501C11.5267 11.6744 11.9756 12.0269 12.3 12C12.6243 11.9733 13.0247 11.5804 12.998 11.2561C12.5912 6.33295 8.66704 2.40882 3.74393 2.00204ZM2.9 6.00001C2.96411 5.68099 3.33084 5.29361 3.64986 5.35772C6.66377 5.96341 9.03654 8.33618 9.64223 11.3501C9.70634 11.6691 9.319 12.0359 8.99999 12.1C8.68097 12.1641 8.06411 11.819 7.99999 11.5C7.48788 8.95167 6.0483 7.51213 3.49999 7.00001C3.18097 6.9359 2.8359 6.31902 2.9 6.00001ZM2 9.20001C2.0641 8.88099 2.38635 8.65788 2.70537 8.722C4.50255 9.08317 5.91684 10.4975 6.27801 12.2946C6.34212 12.6137 6.13547 12.9242 5.81646 12.9883C5.49744 13.0525 4.86411 12.819 4.8 12.5C4.53239 11.1683 3.83158 10.4676 2.5 10.2C2.18098 10.1359 1.93588 9.51902 2 9.20001Z" fill="black"/>
+</svg>
@@ -0,0 +1,8 @@
+<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path
+ fill-rule="evenodd"
+ clip-rule="evenodd"
@@ -85,25 +85,6 @@ impl Embedding {
}
}
-// impl FromSql for Embedding {
-// fn column_result(value: ValueRef) -> FromSqlResult<Self> {
-// let bytes = value.as_blob()?;
-// let embedding: Result<Vec<f32>, Box<bincode::ErrorKind>> = bincode::deserialize(bytes);
-// if embedding.is_err() {
-// return Err(rusqlite::types::FromSqlError::Other(embedding.unwrap_err()));
-// }
-// Ok(Embedding(embedding.unwrap()))
-// }
-// }
-
-// impl ToSql for Embedding {
-// fn to_sql(&self) -> rusqlite::Result<ToSqlOutput> {
-// let bytes = bincode::serialize(&self.0)
-// .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?;
-// Ok(ToSqlOutput::Owned(rusqlite::types::Value::Blob(bytes)))
-// }
-// }
-
#[derive(Clone)]
pub struct OpenAIEmbeddings {
pub client: Arc<dyn HttpClient>,
@@ -300,6 +281,7 @@ impl EmbeddingProvider for OpenAIEmbeddings {
request_timeout,
)
.await?;
+
request_number += 1;
match response.status() {
@@ -22,8 +22,11 @@ settings = { path = "../settings" }
theme = { path = "../theme" }
util = { path = "../util" }
workspace = { path = "../workspace" }
-uuid.workspace = true
+semantic_index = { path = "../semantic_index" }
+project = { path = "../project" }
+uuid.workspace = true
+log.workspace = true
anyhow.workspace = true
chrono = { version = "0.4", features = ["serde"] }
futures.workspace = true
@@ -36,7 +39,7 @@ schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
smol.workspace = true
-tiktoken-rs = "0.4"
+tiktoken-rs = "0.5"
[dev-dependencies]
editor = { path = "../editor", features = ["test-support"] }
@@ -1,7 +1,7 @@
use crate::{
assistant_settings::{AssistantDockPosition, AssistantSettings, OpenAIModel},
codegen::{self, Codegen, CodegenKind},
- prompts::generate_content_prompt,
+ prompts::{generate_content_prompt, PromptCodeSnippet},
MessageId, MessageMetadata, MessageStatus, Role, SavedConversation, SavedConversationMetadata,
SavedMessage,
};
@@ -29,13 +29,15 @@ use gpui::{
},
fonts::HighlightStyle,
geometry::vector::{vec2f, Vector2F},
- platform::{CursorStyle, MouseButton},
+ platform::{CursorStyle, MouseButton, PromptLevel},
Action, AnyElement, AppContext, AsyncAppContext, ClipboardItem, Element, Entity, ModelContext,
- ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
- WindowContext,
+ ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle,
+ WeakModelHandle, WeakViewHandle, WindowContext,
};
use language::{language_settings::SoftWrap, Buffer, LanguageRegistry, ToOffset as _};
+use project::Project;
use search::BufferSearchBar;
+use semantic_index::{SemanticIndex, SemanticIndexStatus};
use settings::SettingsStore;
use std::{
cell::{Cell, RefCell},
@@ -46,7 +48,7 @@ use std::{
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
- time::Duration,
+ time::{Duration, Instant},
};
use theme::{
components::{action_button::Button, ComponentExt},
@@ -72,6 +74,7 @@ actions!(
ResetKey,
InlineAssist,
ToggleIncludeConversation,
+ ToggleRetrieveContext,
]
);
@@ -108,6 +111,7 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(InlineAssistant::confirm);
cx.add_action(InlineAssistant::cancel);
cx.add_action(InlineAssistant::toggle_include_conversation);
+ cx.add_action(InlineAssistant::toggle_retrieve_context);
cx.add_action(InlineAssistant::move_up);
cx.add_action(InlineAssistant::move_down);
}
@@ -145,6 +149,8 @@ pub struct AssistantPanel {
include_conversation_in_next_inline_assist: bool,
inline_prompt_history: VecDeque<String>,
_watch_saved_conversations: Task<Result<()>>,
+ semantic_index: Option<ModelHandle<SemanticIndex>>,
+ retrieve_context_in_next_inline_assist: bool,
}
impl AssistantPanel {
@@ -191,6 +197,9 @@ impl AssistantPanel {
toolbar.add_item(cx.add_view(|cx| BufferSearchBar::new(cx)), cx);
toolbar
});
+
+ let semantic_index = SemanticIndex::global(cx);
+
let mut this = Self {
workspace: workspace_handle,
active_editor_index: Default::default(),
@@ -215,6 +224,8 @@ impl AssistantPanel {
include_conversation_in_next_inline_assist: false,
inline_prompt_history: Default::default(),
_watch_saved_conversations,
+ semantic_index,
+ retrieve_context_in_next_inline_assist: false,
};
let mut old_dock_position = this.position(cx);
@@ -262,12 +273,19 @@ impl AssistantPanel {
return;
};
+ let project = workspace.project();
+
this.update(cx, |assistant, cx| {
- assistant.new_inline_assist(&active_editor, cx)
+ assistant.new_inline_assist(&active_editor, cx, project)
});
}
- fn new_inline_assist(&mut self, editor: &ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
+ fn new_inline_assist(
+ &mut self,
+ editor: &ViewHandle<Editor>,
+ cx: &mut ViewContext<Self>,
+ project: &ModelHandle<Project>,
+ ) {
let api_key = if let Some(api_key) = self.api_key.borrow().clone() {
api_key
} else {
@@ -312,6 +330,27 @@ impl AssistantPanel {
Codegen::new(editor.read(cx).buffer().clone(), codegen_kind, provider, cx)
});
+ if let Some(semantic_index) = self.semantic_index.clone() {
+ let project = project.clone();
+ cx.spawn(|_, mut cx| async move {
+ let previously_indexed = semantic_index
+ .update(&mut cx, |index, cx| {
+ index.project_previously_indexed(&project, cx)
+ })
+ .await
+ .unwrap_or(false);
+ if previously_indexed {
+ let _ = semantic_index
+ .update(&mut cx, |index, cx| {
+ index.index_project(project.clone(), cx)
+ })
+ .await;
+ }
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
let measurements = Rc::new(Cell::new(BlockMeasurements::default()));
let inline_assistant = cx.add_view(|cx| {
let assistant = InlineAssistant::new(
@@ -322,6 +361,9 @@ impl AssistantPanel {
codegen.clone(),
self.workspace.clone(),
cx,
+ self.retrieve_context_in_next_inline_assist,
+ self.semantic_index.clone(),
+ project.clone(),
);
cx.focus_self();
assistant
@@ -362,6 +404,7 @@ impl AssistantPanel {
editor: editor.downgrade(),
inline_assistant: Some((block_id, inline_assistant.clone())),
codegen: codegen.clone(),
+ project: project.downgrade(),
_subscriptions: vec![
cx.subscribe(&inline_assistant, Self::handle_inline_assistant_event),
cx.subscribe(editor, {
@@ -440,8 +483,15 @@ impl AssistantPanel {
InlineAssistantEvent::Confirmed {
prompt,
include_conversation,
+ retrieve_context,
} => {
- self.confirm_inline_assist(assist_id, prompt, *include_conversation, cx);
+ self.confirm_inline_assist(
+ assist_id,
+ prompt,
+ *include_conversation,
+ cx,
+ *retrieve_context,
+ );
}
InlineAssistantEvent::Canceled => {
self.finish_inline_assist(assist_id, true, cx);
@@ -454,6 +504,9 @@ impl AssistantPanel {
} => {
self.include_conversation_in_next_inline_assist = *include_conversation;
}
+ InlineAssistantEvent::RetrieveContextToggled { retrieve_context } => {
+ self.retrieve_context_in_next_inline_assist = *retrieve_context
+ }
}
}
@@ -532,6 +585,7 @@ impl AssistantPanel {
user_prompt: &str,
include_conversation: bool,
cx: &mut ViewContext<Self>,
+ retrieve_context: bool,
) {
let conversation = if include_conversation {
self.active_editor()
@@ -553,6 +607,8 @@ impl AssistantPanel {
return;
};
+ let project = pending_assist.project.clone();
+
self.inline_prompt_history
.retain(|prompt| prompt != user_prompt);
self.inline_prompt_history.push_back(user_prompt.into());
@@ -593,10 +649,62 @@ impl AssistantPanel {
let codegen_kind = codegen.read(cx).kind().clone();
let user_prompt = user_prompt.to_string();
- let mut messages = Vec::new();
+ let snippets = if retrieve_context {
+ let Some(project) = project.upgrade(cx) else {
+ return;
+ };
+
+ let search_results = if let Some(semantic_index) = self.semantic_index.clone() {
+ let search_results = semantic_index.update(cx, |this, cx| {
+ this.search_project(project, user_prompt.to_string(), 10, vec![], vec![], cx)
+ });
+
+ cx.background()
+ .spawn(async move { search_results.await.unwrap_or_default() })
+ } else {
+ Task::ready(Vec::new())
+ };
+
+ let snippets = cx.spawn(|_, cx| async move {
+ let mut snippets = Vec::new();
+ for result in search_results.await {
+ snippets.push(PromptCodeSnippet::new(result, &cx));
+
+ // snippets.push(result.buffer.read_with(&cx, |buffer, _| {
+ // buffer
+ // .snapshot()
+ // .text_for_range(result.range)
+ // .collect::<String>()
+ // }));
+ }
+ snippets
+ });
+ snippets
+ } else {
+ Task::ready(Vec::new())
+ };
+
let mut model = settings::get::<AssistantSettings>(cx)
.default_open_ai_model
.clone();
+ let model_name = model.full_name();
+
+ let prompt = cx.background().spawn(async move {
+ let snippets = snippets.await;
+
+ let language_name = language_name.as_deref();
+ generate_content_prompt(
+ user_prompt,
+ language_name,
+ &buffer,
+ range,
+ codegen_kind,
+ snippets,
+ model_name,
+ )
+ });
+
+ let mut messages = Vec::new();
if let Some(conversation) = conversation {
let conversation = conversation.read(cx);
let buffer = conversation.buffer.read(cx);
@@ -608,11 +716,6 @@ impl AssistantPanel {
model = conversation.model.clone();
}
- let prompt = cx.background().spawn(async move {
- let language_name = language_name.as_deref();
- generate_content_prompt(user_prompt, language_name, &buffer, range, codegen_kind)
- });
-
cx.spawn(|_, mut cx| async move {
let prompt = prompt.await;
@@ -1514,12 +1617,14 @@ impl Conversation {
Role::Assistant => "assistant".into(),
Role::System => "system".into(),
},
- content: self
- .buffer
- .read(cx)
- .text_for_range(message.offset_range)
- .collect(),
+ content: Some(
+ self.buffer
+ .read(cx)
+ .text_for_range(message.offset_range)
+ .collect(),
+ ),
name: None,
+ function_call: None,
})
})
.collect::<Vec<_>>();
@@ -2638,12 +2743,16 @@ enum InlineAssistantEvent {
Confirmed {
prompt: String,
include_conversation: bool,
+ retrieve_context: bool,
},
Canceled,
Dismissed,
IncludeConversationToggled {
include_conversation: bool,
},
+ RetrieveContextToggled {
+ retrieve_context: bool,
+ },
}
struct InlineAssistant {
@@ -2659,6 +2768,11 @@ struct InlineAssistant {
pending_prompt: String,
codegen: ModelHandle<Codegen>,
_subscriptions: Vec<Subscription>,
+ retrieve_context: bool,
+ semantic_index: Option<ModelHandle<SemanticIndex>>,
+ semantic_permissioned: Option<bool>,
+ project: WeakModelHandle<Project>,
+ maintain_rate_limit: Option<Task<()>>,
}
impl Entity for InlineAssistant {
@@ -2675,51 +2789,65 @@ impl View for InlineAssistant {
let theme = theme::current(cx);
Flex::row()
- .with_child(
- Flex::row()
- .with_child(
- Button::action(ToggleIncludeConversation)
- .with_tooltip("Include Conversation", theme.tooltip.clone())
+ .with_children([Flex::row()
+ .with_child(
+ Button::action(ToggleIncludeConversation)
+ .with_tooltip("Include Conversation", theme.tooltip.clone())
+ .with_id(self.id)
+ .with_contents(theme::components::svg::Svg::new("icons/ai.svg"))
+ .toggleable(self.include_conversation)
+ .with_style(theme.assistant.inline.include_conversation.clone())
+ .element()
+ .aligned(),
+ )
+ .with_children(if SemanticIndex::enabled(cx) {
+ Some(
+ Button::action(ToggleRetrieveContext)
+ .with_tooltip("Retrieve Context", theme.tooltip.clone())
.with_id(self.id)
- .with_contents(theme::components::svg::Svg::new("icons/ai.svg"))
- .toggleable(self.include_conversation)
- .with_style(theme.assistant.inline.include_conversation.clone())
+ .with_contents(theme::components::svg::Svg::new(
+ "icons/magnifying_glass.svg",
+ ))
+ .toggleable(self.retrieve_context)
+ .with_style(theme.assistant.inline.retrieve_context.clone())
.element()
.aligned(),
)
- .with_children(if let Some(error) = self.codegen.read(cx).error() {
- Some(
- Svg::new("icons/error.svg")
- .with_color(theme.assistant.error_icon.color)
- .constrained()
- .with_width(theme.assistant.error_icon.width)
- .contained()
- .with_style(theme.assistant.error_icon.container)
- .with_tooltip::<ErrorIcon>(
- self.id,
- error.to_string(),
- None,
- theme.tooltip.clone(),
- cx,
- )
- .aligned(),
- )
- } else {
- None
- })
- .aligned()
- .constrained()
- .dynamically({
- let measurements = self.measurements.clone();
- move |constraint, _, _| {
- let measurements = measurements.get();
- SizeConstraint {
- min: vec2f(measurements.gutter_width, constraint.min.y()),
- max: vec2f(measurements.gutter_width, constraint.max.y()),
- }
+ } else {
+ None
+ })
+ .with_children(if let Some(error) = self.codegen.read(cx).error() {
+ Some(
+ Svg::new("icons/error.svg")
+ .with_color(theme.assistant.error_icon.color)
+ .constrained()
+ .with_width(theme.assistant.error_icon.width)
+ .contained()
+ .with_style(theme.assistant.error_icon.container)
+ .with_tooltip::<ErrorIcon>(
+ self.id,
+ error.to_string(),
+ None,
+ theme.tooltip.clone(),
+ cx,
+ )
+ .aligned(),
+ )
+ } else {
+ None
+ })
+ .aligned()
+ .constrained()
+ .dynamically({
+ let measurements = self.measurements.clone();
+ move |constraint, _, _| {
+ let measurements = measurements.get();
+ SizeConstraint {
+ min: vec2f(measurements.gutter_width, constraint.min.y()),
+ max: vec2f(measurements.gutter_width, constraint.max.y()),
}
- }),
- )
+ }
+ })])
.with_child(Empty::new().constrained().dynamically({
let measurements = self.measurements.clone();
move |constraint, _, _| {
@@ -2742,6 +2870,16 @@ impl View for InlineAssistant {
.left()
.flex(1., true),
)
+ .with_children(if self.retrieve_context {
+ Some(
+ Flex::row()
+ .with_children(self.retrieve_context_status(cx))
+ .flex(1., true)
+ .aligned(),
+ )
+ } else {
+ None
+ })
.contained()
.with_style(theme.assistant.inline.container)
.into_any()
@@ -2767,6 +2905,9 @@ impl InlineAssistant {
codegen: ModelHandle<Codegen>,
workspace: WeakViewHandle<Workspace>,
cx: &mut ViewContext<Self>,
+ retrieve_context: bool,
+ semantic_index: Option<ModelHandle<SemanticIndex>>,
+ project: ModelHandle<Project>,
) -> Self {
let prompt_editor = cx.add_view(|cx| {
let mut editor = Editor::single_line(
@@ -2780,11 +2921,16 @@ impl InlineAssistant {
editor.set_placeholder_text(placeholder, cx);
editor
});
- let subscriptions = vec![
+ let mut subscriptions = vec![
cx.observe(&codegen, Self::handle_codegen_changed),
cx.subscribe(&prompt_editor, Self::handle_prompt_editor_events),
];
- Self {
+
+ if let Some(semantic_index) = semantic_index.clone() {
+ subscriptions.push(cx.observe(&semantic_index, Self::semantic_index_changed));
+ }
+
+ let assistant = Self {
id,
prompt_editor,
workspace,
@@ -2797,7 +2943,33 @@ impl InlineAssistant {
pending_prompt: String::new(),
codegen,
_subscriptions: subscriptions,
+ retrieve_context,
+ semantic_permissioned: None,
+ semantic_index,
+ project: project.downgrade(),
+ maintain_rate_limit: None,
+ };
+
+ assistant.index_project(cx).log_err();
+
+ assistant
+ }
+
+ fn semantic_permissioned(&self, cx: &mut ViewContext<Self>) -> Task<Result<bool>> {
+ if let Some(value) = self.semantic_permissioned {
+ return Task::ready(Ok(value));
}
+
+ let Some(project) = self.project.upgrade(cx) else {
+ return Task::ready(Err(anyhow!("project was dropped")));
+ };
+
+ self.semantic_index
+ .as_ref()
+ .map(|semantic| {
+ semantic.update(cx, |this, cx| this.project_previously_indexed(&project, cx))
+ })
+ .unwrap_or(Task::ready(Ok(false)))
}
fn handle_prompt_editor_events(
@@ -2812,6 +2984,37 @@ impl InlineAssistant {
}
}
+ fn semantic_index_changed(
+ &mut self,
+ semantic_index: ModelHandle<SemanticIndex>,
+ cx: &mut ViewContext<Self>,
+ ) {
+ let Some(project) = self.project.upgrade(cx) else {
+ return;
+ };
+
+ let status = semantic_index.read(cx).status(&project);
+ match status {
+ SemanticIndexStatus::Indexing {
+ rate_limit_expiry: Some(_),
+ ..
+ } => {
+ if self.maintain_rate_limit.is_none() {
+ self.maintain_rate_limit = Some(cx.spawn(|this, mut cx| async move {
+ loop {
+ cx.background().timer(Duration::from_secs(1)).await;
+ this.update(&mut cx, |_, cx| cx.notify()).log_err();
+ }
+ }));
+ }
+ return;
+ }
+ _ => {
+ self.maintain_rate_limit = None;
+ }
+ }
+ }
+
fn handle_codegen_changed(&mut self, _: ModelHandle<Codegen>, cx: &mut ViewContext<Self>) {
let is_read_only = !self.codegen.read(cx).idle();
self.prompt_editor.update(cx, |editor, cx| {
@@ -2861,12 +3064,241 @@ impl InlineAssistant {
cx.emit(InlineAssistantEvent::Confirmed {
prompt,
include_conversation: self.include_conversation,
+ retrieve_context: self.retrieve_context,
});
self.confirmed = true;
cx.notify();
}
}
+ fn toggle_retrieve_context(&mut self, _: &ToggleRetrieveContext, cx: &mut ViewContext<Self>) {
+ let semantic_permissioned = self.semantic_permissioned(cx);
+
+ let Some(project) = self.project.upgrade(cx) else {
+ return;
+ };
+
+ let project_name = project
+ .read(cx)
+ .worktree_root_names(cx)
+ .collect::<Vec<&str>>()
+ .join("/");
+ let is_plural = project_name.chars().filter(|letter| *letter == '/').count() > 0;
+ let prompt_text = format!("Would you like to index the '{}' project{} for context retrieval? This requires sending code to the OpenAI API", project_name,
+ if is_plural {
+ "s"
+ } else {""});
+
+ cx.spawn(|this, mut cx| async move {
+ // If Necessary prompt user
+ if !semantic_permissioned.await.unwrap_or(false) {
+ let mut answer = this.update(&mut cx, |_, cx| {
+ cx.prompt(
+ PromptLevel::Info,
+ prompt_text.as_str(),
+ &["Continue", "Cancel"],
+ )
+ })?;
+
+ if answer.next().await == Some(0) {
+ this.update(&mut cx, |this, _| {
+ this.semantic_permissioned = Some(true);
+ })?;
+ } else {
+ return anyhow::Ok(());
+ }
+ }
+
+ // If permissioned, update context appropriately
+ this.update(&mut cx, |this, cx| {
+ this.retrieve_context = !this.retrieve_context;
+
+ cx.emit(InlineAssistantEvent::RetrieveContextToggled {
+ retrieve_context: this.retrieve_context,
+ });
+
+ if this.retrieve_context {
+ this.index_project(cx).log_err();
+ }
+
+ cx.notify();
+ })?;
+
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
+ fn index_project(&self, cx: &mut ViewContext<Self>) -> anyhow::Result<()> {
+ let Some(project) = self.project.upgrade(cx) else {
+ return Err(anyhow!("project was dropped!"));
+ };
+
+ let semantic_permissioned = self.semantic_permissioned(cx);
+ if let Some(semantic_index) = SemanticIndex::global(cx) {
+ cx.spawn(|_, mut cx| async move {
+ // This has to be updated to accomodate for semantic_permissions
+ if semantic_permissioned.await.unwrap_or(false) {
+ semantic_index
+ .update(&mut cx, |index, cx| index.index_project(project, cx))
+ .await
+ } else {
+ Err(anyhow!("project is not permissioned for semantic indexing"))
+ }
+ })
+ .detach_and_log_err(cx);
+ }
+
+ anyhow::Ok(())
+ }
+
+ fn retrieve_context_status(
+ &self,
+ cx: &mut ViewContext<Self>,
+ ) -> Option<AnyElement<InlineAssistant>> {
+ enum ContextStatusIcon {}
+
+ let Some(project) = self.project.upgrade(cx) else {
+ return None;
+ };
+
+ if let Some(semantic_index) = SemanticIndex::global(cx) {
+ let status = semantic_index.update(cx, |index, _| index.status(&project));
+ let theme = theme::current(cx);
+ match status {
+ SemanticIndexStatus::NotAuthenticated {} => Some(
+ Svg::new("icons/error.svg")
+ .with_color(theme.assistant.error_icon.color)
+ .constrained()
+ .with_width(theme.assistant.error_icon.width)
+ .contained()
+ .with_style(theme.assistant.error_icon.container)
+ .with_tooltip::<ContextStatusIcon>(
+ self.id,
+ "Not Authenticated. Please ensure you have a valid 'OPENAI_API_KEY' in your environment variables.",
+ None,
+ theme.tooltip.clone(),
+ cx,
+ )
+ .aligned()
+ .into_any(),
+ ),
+ SemanticIndexStatus::NotIndexed {} => Some(
+ Svg::new("icons/error.svg")
+ .with_color(theme.assistant.inline.context_status.error_icon.color)
+ .constrained()
+ .with_width(theme.assistant.inline.context_status.error_icon.width)
+ .contained()
+ .with_style(theme.assistant.inline.context_status.error_icon.container)
+ .with_tooltip::<ContextStatusIcon>(
+ self.id,
+ "Not Indexed",
+ None,
+ theme.tooltip.clone(),
+ cx,
+ )
+ .aligned()
+ .into_any(),
+ ),
+ SemanticIndexStatus::Indexing {
+ remaining_files,
+ rate_limit_expiry,
+ } => {
+
+ let mut status_text = if remaining_files == 0 {
+ "Indexing...".to_string()
+ } else {
+ format!("Remaining files to index: {remaining_files}")
+ };
+
+ if let Some(rate_limit_expiry) = rate_limit_expiry {
+ let remaining_seconds = rate_limit_expiry.duration_since(Instant::now());
+ if remaining_seconds > Duration::from_secs(0) && remaining_files > 0 {
+ write!(
+ status_text,
+ " (rate limit expires in {}s)",
+ remaining_seconds.as_secs()
+ )
+ .unwrap();
+ }
+ }
+ Some(
+ Svg::new("icons/update.svg")
+ .with_color(theme.assistant.inline.context_status.in_progress_icon.color)
+ .constrained()
+ .with_width(theme.assistant.inline.context_status.in_progress_icon.width)
+ .contained()
+ .with_style(theme.assistant.inline.context_status.in_progress_icon.container)
+ .with_tooltip::<ContextStatusIcon>(
+ self.id,
+ status_text,
+ None,
+ theme.tooltip.clone(),
+ cx,
+ )
+ .aligned()
+ .into_any(),
+ )
+ }
+ SemanticIndexStatus::Indexed {} => Some(
+ Svg::new("icons/check.svg")
+ .with_color(theme.assistant.inline.context_status.complete_icon.color)
+ .constrained()
+ .with_width(theme.assistant.inline.context_status.complete_icon.width)
+ .contained()
+ .with_style(theme.assistant.inline.context_status.complete_icon.container)
+ .with_tooltip::<ContextStatusIcon>(
+ self.id,
+ "Index up to date",
+ None,
+ theme.tooltip.clone(),
+ cx,
+ )
+ .aligned()
+ .into_any(),
+ ),
+ }
+ } else {
+ None
+ }
+ }
+
+ // fn retrieve_context_status(&self, cx: &mut ViewContext<Self>) -> String {
+ // let project = self.project.clone();
+ // if let Some(semantic_index) = self.semantic_index.clone() {
+ // let status = semantic_index.update(cx, |index, cx| index.status(&project));
+ // return match status {
+ // // This theoretically shouldnt be a valid code path
+ // // As the inline assistant cant be launched without an API key
+ // // We keep it here for safety
+ // semantic_index::SemanticIndexStatus::NotAuthenticated => {
+ // "Not Authenticated!\nPlease ensure you have an `OPENAI_API_KEY` in your environment variables.".to_string()
+ // }
+ // semantic_index::SemanticIndexStatus::Indexed => {
+ // "Indexing Complete!".to_string()
+ // }
+ // semantic_index::SemanticIndexStatus::Indexing { remaining_files, rate_limit_expiry } => {
+
+ // let mut status = format!("Remaining files to index for Context Retrieval: {remaining_files}");
+
+ // if let Some(rate_limit_expiry) = rate_limit_expiry {
+ // let remaining_seconds =
+ // rate_limit_expiry.duration_since(Instant::now());
+ // if remaining_seconds > Duration::from_secs(0) {
+ // write!(status, " (rate limit resets in {}s)", remaining_seconds.as_secs()).unwrap();
+ // }
+ // }
+ // status
+ // }
+ // semantic_index::SemanticIndexStatus::NotIndexed => {
+ // "Not Indexed for Context Retrieval".to_string()
+ // }
+ // };
+ // }
+
+ // "".to_string()
+ // }
+
fn toggle_include_conversation(
&mut self,
_: &ToggleIncludeConversation,
@@ -2929,6 +3361,7 @@ struct PendingInlineAssist {
inline_assistant: Option<(BlockId, ViewHandle<InlineAssistant>)>,
codegen: ModelHandle<Codegen>,
_subscriptions: Vec<Subscription>,
+ project: WeakModelHandle<Project>,
}
fn merge_ranges(ranges: &mut Vec<Range<Anchor>>, buffer: &MultiBufferSnapshot) {
@@ -1,8 +1,60 @@
use crate::codegen::CodegenKind;
+use gpui::AsyncAppContext;
use language::{BufferSnapshot, OffsetRangeExt, ToOffset};
+use semantic_index::SearchResult;
use std::cmp::{self, Reverse};
use std::fmt::Write;
use std::ops::Range;
+use std::path::PathBuf;
+use tiktoken_rs::ChatCompletionRequestMessage;
+
+pub struct PromptCodeSnippet {
+ path: Option<PathBuf>,
+ language_name: Option<String>,
+ content: String,
+}
+
+impl PromptCodeSnippet {
+ pub fn new(search_result: SearchResult, cx: &AsyncAppContext) -> Self {
+ let (content, language_name, file_path) =
+ search_result.buffer.read_with(cx, |buffer, _| {
+ let snapshot = buffer.snapshot();
+ let content = snapshot
+ .text_for_range(search_result.range.clone())
+ .collect::<String>();
+
+ let language_name = buffer
+ .language()
+ .and_then(|language| Some(language.name().to_string()));
+
+ let file_path = buffer
+ .file()
+ .and_then(|file| Some(file.path().to_path_buf()));
+
+ (content, language_name, file_path)
+ });
+
+ PromptCodeSnippet {
+ path: file_path,
+ language_name,
+ content,
+ }
+ }
+}
+
+impl ToString for PromptCodeSnippet {
+ fn to_string(&self) -> String {
+ let path = self
+ .path
+ .as_ref()
+ .and_then(|path| Some(path.to_string_lossy().to_string()))
+ .unwrap_or("".to_string());
+ let language_name = self.language_name.clone().unwrap_or("".to_string());
+ let content = self.content.clone();
+
+ format!("The below code snippet may be relevant from file: {path}\n```{language_name}\n{content}\n```")
+ }
+}
#[allow(dead_code)]
fn summarize(buffer: &BufferSnapshot, selected_range: Range<impl ToOffset>) -> String {
@@ -121,17 +173,25 @@ pub fn generate_content_prompt(
buffer: &BufferSnapshot,
range: Range<impl ToOffset>,
kind: CodegenKind,
+ search_results: Vec<PromptCodeSnippet>,
+ model: &str,
) -> String {
+ const MAXIMUM_SNIPPET_TOKEN_COUNT: usize = 500;
+ const RESERVED_TOKENS_FOR_GENERATION: usize = 1000;
+
+ let mut prompts = Vec::new();
let range = range.to_offset(buffer);
- let mut prompt = String::new();
// General Preamble
if let Some(language_name) = language_name {
- writeln!(prompt, "You're an expert {language_name} engineer.\n").unwrap();
+ prompts.push(format!("You're an expert {language_name} engineer.\n"));
} else {
- writeln!(prompt, "You're an expert engineer.\n").unwrap();
+ prompts.push("You're an expert engineer.\n".to_string());
}
+ // Snippets
+ let mut snippet_position = prompts.len() - 1;
+
let mut content = String::new();
content.extend(buffer.text_for_range(0..range.start));
if range.start == range.end {
@@ -145,59 +205,103 @@ pub fn generate_content_prompt(
}
content.extend(buffer.text_for_range(range.end..buffer.len()));
- writeln!(
- prompt,
- "The file you are currently working on has the following content:"
- )
- .unwrap();
+ prompts.push("The file you are currently working on has the following content:\n".to_string());
+
if let Some(language_name) = language_name {
let language_name = language_name.to_lowercase();
- writeln!(prompt, "```{language_name}\n{content}\n```").unwrap();
+ prompts.push(format!("```{language_name}\n{content}\n```"));
} else {
- writeln!(prompt, "```\n{content}\n```").unwrap();
+ prompts.push(format!("```\n{content}\n```"));
}
match kind {
CodegenKind::Generate { position: _ } => {
- writeln!(prompt, "In particular, the user's cursor is current on the '<|START|>' span in the above outline, with no text selected.").unwrap();
- writeln!(
- prompt,
- "Assume the cursor is located where the `<|START|` marker is."
- )
- .unwrap();
- writeln!(
- prompt,
+ prompts.push("In particular, the user's cursor is currently on the '<|START|>' span in the above outline, with no text selected.".to_string());
+ prompts
+ .push("Assume the cursor is located where the `<|START|` marker is.".to_string());
+ prompts.push(
"Text can't be replaced, so assume your answer will be inserted at the cursor."
- )
- .unwrap();
- writeln!(
- prompt,
+ .to_string(),
+ );
+ prompts.push(format!(
"Generate text based on the users prompt: {user_prompt}"
- )
- .unwrap();
+ ));
}
CodegenKind::Transform { range: _ } => {
- writeln!(prompt, "In particular, the user has selected a section of the text between the '<|START|' and '|END|>' spans.").unwrap();
- writeln!(
- prompt,
- "Modify the users code selected text based upon the users prompt: {user_prompt}"
- )
- .unwrap();
- writeln!(
- prompt,
- "You MUST reply with only the adjusted code (within the '<|START|' and '|END|>' spans), not the entire file."
- )
- .unwrap();
+ prompts.push("In particular, the user has selected a section of the text between the '<|START|' and '|END|>' spans.".to_string());
+ prompts.push(format!(
+ "Modify the users code selected text based upon the users prompt: '{user_prompt}'"
+ ));
+ prompts.push("You MUST reply with only the adjusted code (within the '<|START|' and '|END|>' spans), not the entire file.".to_string());
}
}
if let Some(language_name) = language_name {
- writeln!(prompt, "Your answer MUST always be valid {language_name}").unwrap();
+ prompts.push(format!(
+ "Your answer MUST always and only be valid {language_name}"
+ ));
+ }
+ prompts.push("Never make remarks about the output.".to_string());
+ prompts.push("Do not return any text, except the generated code.".to_string());
+ prompts.push("Always wrap your code in a Markdown block".to_string());
+
+ let current_messages = [ChatCompletionRequestMessage {
+ role: "user".to_string(),
+ content: Some(prompts.join("\n")),
+ function_call: None,
+ name: None,
+ }];
+
+ let mut remaining_token_count = if let Ok(current_token_count) =
+ tiktoken_rs::num_tokens_from_messages(model, ¤t_messages)
+ {
+ let max_token_count = tiktoken_rs::model::get_context_size(model);
+ let intermediate_token_count = if max_token_count > current_token_count {
+ max_token_count - current_token_count
+ } else {
+ 0
+ };
+
+ if intermediate_token_count < RESERVED_TOKENS_FOR_GENERATION {
+ 0
+ } else {
+ intermediate_token_count - RESERVED_TOKENS_FOR_GENERATION
+ }
+ } else {
+ // If tiktoken fails to count token count, assume we have no space remaining.
+ 0
+ };
+
+ // TODO:
+ // - add repository name to snippet
+ // - add file path
+ // - add language
+ if let Ok(encoding) = tiktoken_rs::get_bpe_from_model(model) {
+ let mut template = "You are working inside a large repository, here are a few code snippets that may be useful";
+
+ for search_result in search_results {
+ let mut snippet_prompt = template.to_string();
+ let snippet = search_result.to_string();
+ writeln!(snippet_prompt, "```\n{snippet}\n```").unwrap();
+
+ let token_count = encoding
+ .encode_with_special_tokens(snippet_prompt.as_str())
+ .len();
+ if token_count <= remaining_token_count {
+ if token_count < MAXIMUM_SNIPPET_TOKEN_COUNT {
+ prompts.insert(snippet_position, snippet_prompt);
+ snippet_position += 1;
+ remaining_token_count -= token_count;
+ // If you have already added the template to the prompt, remove the template.
+ template = "";
+ }
+ } else {
+ break;
+ }
+ }
}
- writeln!(prompt, "Always wrap your response in a Markdown codeblock").unwrap();
- writeln!(prompt, "Never make remarks about the output.").unwrap();
- prompt
+ prompts.join("\n")
}
#[cfg(test)]
@@ -9,7 +9,7 @@ use db::RELEASE_CHANNEL;
use futures::{channel::mpsc, future::Shared, Future, FutureExt, StreamExt};
use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task, WeakModelHandle};
use rpc::{
- proto::{self, ChannelEdge, ChannelPermission},
+ proto::{self, ChannelEdge, ChannelPermission, ChannelRole, ChannelVisibility},
TypedEnvelope,
};
use serde_derive::{Deserialize, Serialize};
@@ -49,6 +49,7 @@ pub type ChannelData = (Channel, ChannelPath);
pub struct Channel {
pub id: ChannelId,
pub name: String,
+ pub visibility: proto::ChannelVisibility,
pub unseen_note_version: Option<(u64, clock::Global)>,
pub unseen_message_id: Option<u64>,
}
@@ -79,7 +80,32 @@ pub struct ChannelPath(Arc<[ChannelId]>);
pub struct ChannelMembership {
pub user: Arc<User>,
pub kind: proto::channel_member::Kind,
- pub admin: bool,
+ pub role: proto::ChannelRole,
+}
+impl ChannelMembership {
+ pub fn sort_key(&self) -> MembershipSortKey {
+ MembershipSortKey {
+ role_order: match self.role {
+ proto::ChannelRole::Admin => 0,
+ proto::ChannelRole::Member => 1,
+ proto::ChannelRole::Banned => 2,
+ proto::ChannelRole::Guest => 3,
+ },
+ kind_order: match self.kind {
+ proto::channel_member::Kind::Member => 0,
+ proto::channel_member::Kind::AncestorMember => 1,
+ proto::channel_member::Kind::Invitee => 2,
+ },
+ username_order: self.user.github_login.as_str(),
+ }
+ }
+}
+
+#[derive(PartialOrd, Ord, PartialEq, Eq)]
+pub struct MembershipSortKey<'a> {
+ role_order: u8,
+ kind_order: u8,
+ username_order: &'a str,
}
pub enum ChannelEvent {
@@ -475,7 +501,7 @@ impl ChannelStore {
insert_edge: parent_edge,
channel_permissions: vec![ChannelPermission {
channel_id,
- is_admin: true,
+ role: ChannelRole::Admin.into(),
}],
..Default::default()
},
@@ -547,11 +573,30 @@ impl ChannelStore {
})
}
+ pub fn set_channel_visibility(
+ &mut self,
+ channel_id: ChannelId,
+ visibility: ChannelVisibility,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ let client = self.client.clone();
+ cx.spawn(|_, _| async move {
+ let _ = client
+ .request(proto::SetChannelVisibility {
+ channel_id,
+ visibility: visibility.into(),
+ })
+ .await?;
+
+ Ok(())
+ })
+ }
+
pub fn invite_member(
&mut self,
channel_id: ChannelId,
user_id: UserId,
- admin: bool,
+ role: proto::ChannelRole,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
if !self.outgoing_invites.insert((channel_id, user_id)) {
@@ -565,7 +610,7 @@ impl ChannelStore {
.request(proto::InviteChannelMember {
channel_id,
user_id,
- admin,
+ role: role.into(),
})
.await;
@@ -609,11 +654,11 @@ impl ChannelStore {
})
}
- pub fn set_member_admin(
+ pub fn set_member_role(
&mut self,
channel_id: ChannelId,
user_id: UserId,
- admin: bool,
+ role: proto::ChannelRole,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
if !self.outgoing_invites.insert((channel_id, user_id)) {
@@ -624,10 +669,10 @@ impl ChannelStore {
let client = self.client.clone();
cx.spawn(|this, mut cx| async move {
let result = client
- .request(proto::SetChannelMemberAdmin {
+ .request(proto::SetChannelMemberRole {
channel_id,
user_id,
- admin,
+ role: role.into(),
})
.await;
@@ -716,8 +761,8 @@ impl ChannelStore {
.filter_map(|(user, member)| {
Some(ChannelMembership {
user,
- admin: member.admin,
- kind: proto::channel_member::Kind::from_i32(member.kind)?,
+ role: member.role(),
+ kind: member.kind(),
})
})
.collect())
@@ -912,6 +957,7 @@ impl ChannelStore {
ix,
Arc::new(Channel {
id: channel.id,
+ visibility: channel.visibility(),
name: channel.name,
unseen_note_version: None,
unseen_message_id: None,
@@ -978,7 +1024,7 @@ impl ChannelStore {
}
for permission in payload.channel_permissions {
- if permission.is_admin {
+ if permission.role() == proto::ChannelRole::Admin {
self.channels_with_admin_privileges
.insert(permission.channel_id);
} else {
@@ -123,12 +123,15 @@ impl<'a> ChannelPathsInsertGuard<'a> {
pub fn insert(&mut self, channel_proto: proto::Channel) {
if let Some(existing_channel) = self.channels_by_id.get_mut(&channel_proto.id) {
- Arc::make_mut(existing_channel).name = channel_proto.name;
+ let existing_channel = Arc::make_mut(existing_channel);
+ existing_channel.visibility = channel_proto.visibility();
+ existing_channel.name = channel_proto.name;
} else {
self.channels_by_id.insert(
channel_proto.id,
Arc::new(Channel {
id: channel_proto.id,
+ visibility: channel_proto.visibility(),
name: channel_proto.name,
unseen_note_version: None,
unseen_message_id: None,
@@ -3,7 +3,7 @@ use crate::channel_chat::ChannelChatEvent;
use super::*;
use client::{test::FakeServer, Client, UserStore};
use gpui::{AppContext, ModelHandle, TestAppContext};
-use rpc::proto;
+use rpc::proto::{self};
use settings::SettingsStore;
use util::http::FakeHttpClient;
@@ -18,15 +18,17 @@ fn test_update_channels(cx: &mut AppContext) {
proto::Channel {
id: 1,
name: "b".to_string(),
+ visibility: proto::ChannelVisibility::Members as i32,
},
proto::Channel {
id: 2,
name: "a".to_string(),
+ visibility: proto::ChannelVisibility::Members as i32,
},
],
channel_permissions: vec![proto::ChannelPermission {
channel_id: 1,
- is_admin: true,
+ role: proto::ChannelRole::Admin.into(),
}],
..Default::default()
},
@@ -49,10 +51,12 @@ fn test_update_channels(cx: &mut AppContext) {
proto::Channel {
id: 3,
name: "x".to_string(),
+ visibility: proto::ChannelVisibility::Members as i32,
},
proto::Channel {
id: 4,
name: "y".to_string(),
+ visibility: proto::ChannelVisibility::Members as i32,
},
],
insert_edge: vec![
@@ -92,14 +96,17 @@ fn test_dangling_channel_paths(cx: &mut AppContext) {
proto::Channel {
id: 0,
name: "a".to_string(),
+ visibility: proto::ChannelVisibility::Members as i32,
},
proto::Channel {
id: 1,
name: "b".to_string(),
+ visibility: proto::ChannelVisibility::Members as i32,
},
proto::Channel {
id: 2,
name: "c".to_string(),
+ visibility: proto::ChannelVisibility::Members as i32,
},
],
insert_edge: vec![
@@ -114,7 +121,7 @@ fn test_dangling_channel_paths(cx: &mut AppContext) {
],
channel_permissions: vec![proto::ChannelPermission {
channel_id: 0,
- is_admin: true,
+ role: proto::ChannelRole::Admin.into(),
}],
..Default::default()
},
@@ -158,6 +165,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
channels: vec![proto::Channel {
id: channel_id,
name: "the-channel".to_string(),
+ visibility: proto::ChannelVisibility::Members as i32,
}],
..Default::default()
});
@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathan@zed.dev>"]
default-run = "collab"
edition = "2021"
name = "collab"
-version = "0.24.0"
+version = "0.25.0"
publish = false
[[bin]]
@@ -44,7 +44,7 @@ CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id");
CREATE TABLE "projects" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
- "room_id" INTEGER REFERENCES rooms (id) NOT NULL,
+ "room_id" INTEGER REFERENCES rooms (id) ON DELETE CASCADE NOT NULL,
"host_user_id" INTEGER REFERENCES users (id) NOT NULL,
"host_connection_id" INTEGER,
"host_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE,
@@ -192,7 +192,8 @@ 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 CURRENT_TIMESTAMP
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "visibility" VARCHAR NOT NULL
);
CREATE TABLE IF NOT EXISTS "channel_chat_participants" (
@@ -234,6 +235,7 @@ CREATE TABLE "channel_members" (
"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,
+ "role" VARCHAR,
"accepted" BOOLEAN NOT NULL DEFAULT false,
"updated_at" TIMESTAMP NOT NULL DEFAULT now
);
@@ -0,0 +1,4 @@
+ALTER TABLE channel_members ADD COLUMN role TEXT;
+UPDATE channel_members SET role = CASE WHEN admin THEN 'admin' ELSE 'member' END;
+
+ALTER TABLE channels ADD COLUMN visibility TEXT NOT NULL DEFAULT 'members';
@@ -0,0 +1,8 @@
+-- Add migration script here
+
+ALTER TABLE projects
+ DROP CONSTRAINT projects_room_id_fkey,
+ ADD CONSTRAINT projects_room_id_fkey
+ FOREIGN KEY (room_id)
+ REFERENCES rooms (id)
+ ON DELETE CASCADE;
@@ -432,6 +432,7 @@ pub struct NewUserResult {
pub struct Channel {
pub id: ChannelId,
pub name: String,
+ pub visibility: ChannelVisibility,
}
#[derive(Debug, PartialEq)]
@@ -1,4 +1,5 @@
use crate::Result;
+use rpc::proto;
use sea_orm::{entity::prelude::*, DbErr};
use serde::{Deserialize, Serialize};
@@ -82,3 +83,101 @@ id_type!(ChannelBufferCollaboratorId);
id_type!(FlagId);
id_type!(NotificationId);
id_type!(NotificationKindId);
+
+#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default)]
+#[sea_orm(rs_type = "String", db_type = "String(None)")]
+pub enum ChannelRole {
+ #[sea_orm(string_value = "admin")]
+ Admin,
+ #[sea_orm(string_value = "member")]
+ #[default]
+ Member,
+ #[sea_orm(string_value = "guest")]
+ Guest,
+ #[sea_orm(string_value = "banned")]
+ Banned,
+}
+
+impl ChannelRole {
+ pub fn should_override(&self, other: Self) -> bool {
+ use ChannelRole::*;
+ match self {
+ Admin => matches!(other, Member | Banned | Guest),
+ Member => matches!(other, Banned | Guest),
+ Banned => matches!(other, Guest),
+ Guest => false,
+ }
+ }
+
+ pub fn max(&self, other: Self) -> Self {
+ if self.should_override(other) {
+ *self
+ } else {
+ other
+ }
+ }
+}
+
+impl From<proto::ChannelRole> for ChannelRole {
+ fn from(value: proto::ChannelRole) -> Self {
+ match value {
+ proto::ChannelRole::Admin => ChannelRole::Admin,
+ proto::ChannelRole::Member => ChannelRole::Member,
+ proto::ChannelRole::Guest => ChannelRole::Guest,
+ proto::ChannelRole::Banned => ChannelRole::Banned,
+ }
+ }
+}
+
+impl Into<proto::ChannelRole> for ChannelRole {
+ fn into(self) -> proto::ChannelRole {
+ match self {
+ ChannelRole::Admin => proto::ChannelRole::Admin,
+ ChannelRole::Member => proto::ChannelRole::Member,
+ ChannelRole::Guest => proto::ChannelRole::Guest,
+ ChannelRole::Banned => proto::ChannelRole::Banned,
+ }
+ }
+}
+
+impl Into<i32> for ChannelRole {
+ fn into(self) -> i32 {
+ let proto: proto::ChannelRole = self.into();
+ proto.into()
+ }
+}
+
+#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash)]
+#[sea_orm(rs_type = "String", db_type = "String(None)")]
+pub enum ChannelVisibility {
+ #[sea_orm(string_value = "public")]
+ Public,
+ #[sea_orm(string_value = "members")]
+ #[default]
+ Members,
+}
+
+impl From<proto::ChannelVisibility> for ChannelVisibility {
+ fn from(value: proto::ChannelVisibility) -> Self {
+ match value {
+ proto::ChannelVisibility::Public => ChannelVisibility::Public,
+ proto::ChannelVisibility::Members => ChannelVisibility::Members,
+ }
+ }
+}
+
+impl Into<proto::ChannelVisibility> for ChannelVisibility {
+ fn into(self) -> proto::ChannelVisibility {
+ match self {
+ ChannelVisibility::Public => proto::ChannelVisibility::Public,
+ ChannelVisibility::Members => proto::ChannelVisibility::Members,
+ }
+ }
+}
+
+impl Into<i32> for ChannelVisibility {
+ fn into(self) -> i32 {
+ let proto: proto::ChannelVisibility = self.into();
+ proto.into()
+ }
+}
@@ -482,7 +482,9 @@ impl Database {
)
.await?;
- channel_members = self.get_channel_members_internal(channel_id, &*tx).await?;
+ channel_members = self
+ .get_channel_participants_internal(channel_id, &*tx)
+ .await?;
let collaborators = self
.get_channel_buffer_collaborators_internal(channel_id, &*tx)
.await?;
@@ -1,8 +1,5 @@
use super::*;
-use rpc::proto::ChannelEdge;
-use smallvec::SmallVec;
-
-type ChannelDescendants = HashMap<ChannelId, SmallSet<ChannelId>>;
+use rpc::proto::{channel_member::Kind, ChannelEdge};
impl Database {
#[cfg(test)]
@@ -37,8 +34,9 @@ impl Database {
}
let channel = channel::ActiveModel {
+ id: ActiveValue::NotSet,
name: ActiveValue::Set(name.to_string()),
- ..Default::default()
+ visibility: ActiveValue::Set(ChannelVisibility::Members),
}
.insert(&*tx)
.await?;
@@ -74,11 +72,11 @@ impl Database {
}
channel_member::ActiveModel {
+ id: ActiveValue::NotSet,
channel_id: ActiveValue::Set(channel.id),
user_id: ActiveValue::Set(creator_id),
accepted: ActiveValue::Set(true),
- admin: ActiveValue::Set(true),
- ..Default::default()
+ role: ActiveValue::Set(ChannelRole::Admin),
}
.insert(&*tx)
.await?;
@@ -88,6 +86,116 @@ impl Database {
.await
}
+ pub async fn join_channel(
+ &self,
+ channel_id: ChannelId,
+ user_id: UserId,
+ connection: ConnectionId,
+ environment: &str,
+ ) -> Result<(JoinRoom, Option<ChannelId>)> {
+ self.transaction(move |tx| async move {
+ let mut joined_channel_id = None;
+
+ let channel = channel::Entity::find()
+ .filter(channel::Column::Id.eq(channel_id))
+ .one(&*tx)
+ .await?;
+
+ let mut role = self
+ .channel_role_for_user(channel_id, user_id, &*tx)
+ .await?;
+
+ if role.is_none() && channel.is_some() {
+ if let Some(invitation) = self
+ .pending_invite_for_channel(channel_id, user_id, &*tx)
+ .await?
+ {
+ // note, this may be a parent channel
+ joined_channel_id = Some(invitation.channel_id);
+ role = Some(invitation.role);
+
+ channel_member::Entity::update(channel_member::ActiveModel {
+ accepted: ActiveValue::Set(true),
+ ..invitation.into_active_model()
+ })
+ .exec(&*tx)
+ .await?;
+
+ debug_assert!(
+ self.channel_role_for_user(channel_id, user_id, &*tx)
+ .await?
+ == role
+ );
+ }
+ }
+ if role.is_none()
+ && channel.as_ref().map(|c| c.visibility) == Some(ChannelVisibility::Public)
+ {
+ let channel_id_to_join = self
+ .most_public_ancestor_for_channel(channel_id, &*tx)
+ .await?
+ .unwrap_or(channel_id);
+ // TODO: change this back to Guest.
+ role = Some(ChannelRole::Member);
+ joined_channel_id = Some(channel_id_to_join);
+
+ channel_member::Entity::insert(channel_member::ActiveModel {
+ id: ActiveValue::NotSet,
+ channel_id: ActiveValue::Set(channel_id_to_join),
+ user_id: ActiveValue::Set(user_id),
+ accepted: ActiveValue::Set(true),
+ // TODO: change this back to Guest.
+ role: ActiveValue::Set(ChannelRole::Member),
+ })
+ .exec(&*tx)
+ .await?;
+
+ debug_assert!(
+ self.channel_role_for_user(channel_id, user_id, &*tx)
+ .await?
+ == role
+ );
+ }
+
+ if channel.is_none() || role.is_none() || role == Some(ChannelRole::Banned) {
+ Err(anyhow!("no such channel, or not allowed"))?
+ }
+
+ let live_kit_room = format!("channel-{}", nanoid::nanoid!(30));
+ let room_id = self
+ .get_or_create_channel_room(channel_id, &live_kit_room, environment, &*tx)
+ .await?;
+
+ self.join_channel_room_internal(channel_id, room_id, user_id, connection, &*tx)
+ .await
+ .map(|jr| (jr, joined_channel_id))
+ })
+ .await
+ }
+
+ pub async fn set_channel_visibility(
+ &self,
+ channel_id: ChannelId,
+ visibility: ChannelVisibility,
+ user_id: UserId,
+ ) -> Result<channel::Model> {
+ self.transaction(move |tx| async move {
+ self.check_user_is_channel_admin(channel_id, user_id, &*tx)
+ .await?;
+
+ let channel = channel::ActiveModel {
+ id: ActiveValue::Unchanged(channel_id),
+ visibility: ActiveValue::Set(visibility),
+ ..Default::default()
+ }
+ .update(&*tx)
+ .await?;
+
+ Ok(channel)
+ })
+ .await
+ }
+
pub async fn delete_channel(
&self,
channel_id: ChannelId,
@@ -98,17 +206,19 @@ impl Database {
.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_remove: HashSet<ChannelId> = HashSet::default();
+ channels_to_remove.insert(channel_id);
+
+ let graph = self.get_channel_descendants([channel_id], &*tx).await?;
+ for edge in graph.iter() {
+ channels_to_remove.insert(ChannelId::from_proto(edge.channel_id));
+ }
+
{
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),
- )
+ .is_in(channels_to_remove.iter().copied())
.and(
channel_path::Column::IdPath
.not_like(&format!("%/{}/%", channel_id)),
@@ -133,7 +243,7 @@ impl Database {
.await?;
channel::Entity::delete_many()
- .filter(channel::Column::Id.is_in(channels_to_remove.keys().copied()))
+ .filter(channel::Column::Id.is_in(channels_to_remove.iter().copied()))
.exec(&*tx)
.await?;
@@ -150,7 +260,7 @@ impl Database {
);
tx.execute(channel_paths_stmt).await?;
- Ok((channels_to_remove.into_keys().collect(), members_to_notify))
+ Ok((channels_to_remove.into_iter().collect(), members_to_notify))
})
.await
}
@@ -160,7 +270,7 @@ impl Database {
channel_id: ChannelId,
invitee_id: UserId,
inviter_id: UserId,
- is_admin: bool,
+ role: ChannelRole,
) -> Result<NotificationBatch> {
self.transaction(move |tx| async move {
self.check_user_is_channel_admin(channel_id, inviter_id, &*tx)
@@ -172,11 +282,11 @@ impl Database {
.ok_or_else(|| anyhow!("no such channel"))?;
channel_member::ActiveModel {
+ id: ActiveValue::NotSet,
channel_id: ActiveValue::Set(channel_id),
user_id: ActiveValue::Set(invitee_id),
accepted: ActiveValue::Set(false),
- admin: ActiveValue::Set(is_admin),
- ..Default::default()
+ role: ActiveValue::Set(role),
}
.insert(&*tx)
.await?;
@@ -212,14 +322,14 @@ impl Database {
channel_id: ChannelId,
user_id: UserId,
new_name: &str,
- ) -> Result<String> {
+ ) -> Result<Channel> {
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 {
+ let channel = channel::ActiveModel {
id: ActiveValue::Unchanged(channel_id),
name: ActiveValue::Set(new_name.clone()),
..Default::default()
@@ -227,7 +337,11 @@ impl Database {
.update(&*tx)
.await?;
- Ok(new_name)
+ Ok(Channel {
+ id: channel.id,
+ name: channel.name,
+ visibility: channel.visibility,
+ })
})
.await
}
@@ -293,10 +407,10 @@ impl Database {
&self,
channel_id: ChannelId,
member_id: UserId,
- remover_id: UserId,
+ admin_id: UserId,
) -> Result<Option<NotificationId>> {
self.transaction(|tx| async move {
- self.check_user_is_channel_admin(channel_id, remover_id, &*tx)
+ self.check_user_is_channel_admin(channel_id, admin_id, &*tx)
.await?;
let result = channel_member::Entity::delete_many()
@@ -354,6 +468,7 @@ impl Database {
.map(|channel| Channel {
id: channel.id,
name: channel.name,
+ visibility: channel.visibility,
})
.collect();
@@ -362,49 +477,6 @@ impl Database {
.await
}
- async fn get_channel_graph(
- &self,
- parents_by_child_id: ChannelDescendants,
- trim_dangling_parents: bool,
- tx: &DatabaseTransaction,
- ) -> Result<ChannelGraph> {
- 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,
- })
- }
- }
-
- let mut edges = Vec::with_capacity(parents_by_child_id.len());
- for (channel, parents) in parents_by_child_id.iter() {
- for parent in parents.into_iter() {
- if trim_dangling_parents {
- if parents_by_child_id.contains_key(parent) {
- edges.push(ChannelEdge {
- channel_id: channel.to_proto(),
- parent_id: parent.to_proto(),
- });
- }
- } else {
- edges.push(ChannelEdge {
- channel_id: channel.to_proto(),
- parent_id: parent.to_proto(),
- });
- }
- }
- }
-
- Ok(ChannelGraph { channels, edges })
- }
-
pub async fn get_channels_for_user(&self, user_id: UserId) -> Result<ChannelsForUser> {
self.transaction(|tx| async move {
let tx = tx;
@@ -454,19 +526,108 @@ impl Database {
channel_memberships: Vec<channel_member::Model>,
tx: &DatabaseTransaction,
) -> Result<ChannelsForUser> {
- let parents_by_child_id = self
+ let mut edges = 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 role_for_channel: HashMap<ChannelId, ChannelRole> = HashMap::default();
- let graph = self
- .get_channel_graph(parents_by_child_id, true, &tx)
+ for membership in channel_memberships.iter() {
+ role_for_channel.insert(membership.channel_id, membership.role);
+ }
+
+ for ChannelEdge {
+ parent_id,
+ channel_id,
+ } in edges.iter()
+ {
+ let parent_id = ChannelId::from_proto(*parent_id);
+ let channel_id = ChannelId::from_proto(*channel_id);
+ debug_assert!(role_for_channel.get(&parent_id).is_some());
+ let parent_role = role_for_channel[&parent_id];
+ if let Some(existing_role) = role_for_channel.get(&channel_id) {
+ if existing_role.should_override(parent_role) {
+ continue;
+ }
+ }
+ role_for_channel.insert(channel_id, parent_role);
+ }
+
+ let mut channels: Vec<Channel> = Vec::new();
+ let mut channels_with_admin_privileges: HashSet<ChannelId> = HashSet::default();
+ let mut channels_to_remove: HashSet<u64> = HashSet::default();
+
+ let mut rows = channel::Entity::find()
+ .filter(channel::Column::Id.is_in(role_for_channel.keys().copied()))
+ .stream(&*tx)
.await?;
+ while let Some(row) = rows.next().await {
+ let channel = row?;
+ let role = role_for_channel[&channel.id];
+
+ if role == ChannelRole::Banned
+ || role == ChannelRole::Guest && channel.visibility != ChannelVisibility::Public
+ {
+ channels_to_remove.insert(channel.id.0 as u64);
+ continue;
+ }
+
+ channels.push(Channel {
+ id: channel.id,
+ name: channel.name,
+ visibility: channel.visibility,
+ });
+
+ if role == ChannelRole::Admin {
+ channels_with_admin_privileges.insert(channel.id);
+ }
+ }
+ drop(rows);
+
+ if !channels_to_remove.is_empty() {
+ // Note: this code assumes each channel has one parent.
+ // If there are multiple valid public paths to a channel,
+ // e.g.
+ // If both of these paths are present (* indicating public):
+ // - zed* -> projects -> vim*
+ // - zed* -> conrad -> public-projects* -> vim*
+ // Users would only see one of them (based on edge sort order)
+ let mut replacement_parent: HashMap<u64, u64> = HashMap::default();
+ for ChannelEdge {
+ parent_id,
+ channel_id,
+ } in edges.iter()
+ {
+ if channels_to_remove.contains(channel_id) {
+ replacement_parent.insert(*channel_id, *parent_id);
+ }
+ }
+
+ let mut new_edges: Vec<ChannelEdge> = Vec::new();
+ 'outer: for ChannelEdge {
+ mut parent_id,
+ channel_id,
+ } in edges.iter()
+ {
+ if channels_to_remove.contains(channel_id) {
+ continue;
+ }
+ while channels_to_remove.contains(&parent_id) {
+ if let Some(new_parent_id) = replacement_parent.get(&parent_id) {
+ parent_id = *new_parent_id;
+ } else {
+ continue 'outer;
+ }
+ }
+ new_edges.push(ChannelEdge {
+ parent_id,
+ channel_id: *channel_id,
+ })
+ }
+ edges = new_edges;
+ }
+
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryUserIdsAndChannelIds {
ChannelId,
@@ -477,7 +638,7 @@ impl Database {
{
let mut rows = room_participant::Entity::find()
.inner_join(room::Entity)
- .filter(room::Column::ChannelId.is_in(graph.channels.iter().map(|c| c.id)))
+ .filter(room::Column::ChannelId.is_in(channels.iter().map(|c| c.id)))
.select_only()
.column(room::Column::ChannelId)
.column(room_participant::Column::UserId)
@@ -490,7 +651,7 @@ impl Database {
}
}
- let channel_ids = graph.channels.iter().map(|c| c.id).collect::<Vec<_>>();
+ let channel_ids = channels.iter().map(|c| c.id).collect::<Vec<_>>();
let channel_buffer_changes = self
.unseen_channel_buffer_changes(user_id, &channel_ids, &*tx)
.await?;
@@ -500,7 +661,7 @@ impl Database {
.await?;
Ok(ChannelsForUser {
- channels: graph,
+ channels: ChannelGraph { channels, edges },
channel_participants,
channels_with_admin_privileges,
unseen_buffer_changes: channel_buffer_changes,
@@ -509,125 +670,154 @@ impl Database {
}
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 })
+ self.transaction(|tx| async move { self.get_channel_participants_internal(id, &*tx).await })
.await
}
- pub async fn set_channel_member_admin(
+ pub async fn set_channel_member_role(
&self,
channel_id: ChannelId,
- from: UserId,
+ admin_id: UserId,
for_user: UserId,
- admin: bool,
- ) -> Result<()> {
+ role: ChannelRole,
+ ) -> Result<channel_member::Model> {
self.transaction(|tx| async move {
- self.check_user_is_channel_admin(channel_id, from, &*tx)
+ self.check_user_is_channel_admin(channel_id, admin_id, &*tx)
.await?;
- let result = channel_member::Entity::update_many()
+ let membership = channel_member::Entity::find()
.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)
+ .one(&*tx)
.await?;
- if result.rows_affected == 0 {
- Err(anyhow!("no such member"))?;
- }
+ let Some(membership) = membership else {
+ Err(anyhow!("no such member"))?
+ };
- Ok(())
+ let mut update = membership.into_active_model();
+ update.role = ActiveValue::Set(role);
+ let updated = channel_member::Entity::update(update).exec(&*tx).await?;
+
+ Ok(updated)
})
.await
}
- pub async fn get_channel_member_details(
+ pub async fn get_channel_participant_details(
&self,
channel_id: ChannelId,
user_id: UserId,
) -> Result<Vec<proto::ChannelMember>> {
self.transaction(|tx| async move {
- let user_membership = self
+ let role = self
.check_user_is_channel_member(channel_id, user_id, &*tx)
.await?;
+ let channel_visibility = channel::Entity::find()
+ .filter(channel::Column::Id.eq(channel_id))
+ .one(&*tx)
+ .await?
+ .map(|channel| channel.visibility)
+ .unwrap_or(ChannelVisibility::Members);
+
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryMemberDetails {
UserId,
- Admin,
+ Role,
IsDirectMember,
Accepted,
+ Visibility,
}
let tx = tx;
let ancestor_ids = self.get_channel_ancestors(channel_id, &*tx).await?;
let mut stream = channel_member::Entity::find()
- .distinct()
+ .left_join(channel::Entity)
.filter(channel_member::Column::ChannelId.is_in(ancestor_ids.iter().copied()))
.select_only()
.column(channel_member::Column::UserId)
- .column(channel_member::Column::Admin)
+ .column(channel_member::Column::Role)
.column_as(
channel_member::Column::ChannelId.eq(channel_id),
QueryMemberDetails::IsDirectMember,
)
.column(channel_member::Column::Accepted)
- .order_by_asc(channel_member::Column::UserId)
+ .column(channel::Column::Visibility)
.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): (
+ struct UserDetail {
+ kind: Kind,
+ channel_role: ChannelRole,
+ }
+ let mut user_details: HashMap<UserId, UserDetail> = HashMap::default();
+
+ while let Some(user_membership) = stream.next().await {
+ let (user_id, channel_role, is_direct_member, is_invite_accepted, visibility): (
UserId,
+ ChannelRole,
bool,
bool,
- bool,
- ) = row?;
+ ChannelVisibility,
+ ) = user_membership?;
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;
+
+ if channel_role == ChannelRole::Guest
+ && visibility != ChannelVisibility::Public
+ && channel_visibility != ChannelVisibility::Public
+ {
+ continue;
+ }
+
+ if let Some(details_mut) = user_details.get_mut(&user_id) {
+ if channel_role.should_override(details_mut.channel_role) {
+ details_mut.channel_role = channel_role;
}
+ if kind == Kind::Member {
+ details_mut.kind = kind;
+ // the UI is going to be a bit confusing if you already have permissions
+ // that are greater than or equal to the ones you're being invited to.
+ } else if kind == Kind::Invitee && details_mut.kind == Kind::AncestorMember {
+ details_mut.kind = kind;
+ }
+ } else {
+ user_details.insert(user_id, UserDetail { kind, channel_role });
}
- rows.push(proto::ChannelMember {
- user_id,
- kind,
- admin: is_admin,
- });
}
- // If the user is not an admin, don't give them all of the details
- if !user_membership.admin {
- rows.retain_mut(|row| {
- row.admin = false;
- row.kind != proto::channel_member::Kind::Invitee as i32
- });
- }
+ Ok(user_details
+ .into_iter()
+ .filter_map(|(user_id, details)| {
+ // If the user is not an admin, don't give them all of the details
+ if role != ChannelRole::Admin {
+ if details.kind == Kind::AncestorMember {
+ return None;
+ }
+ return None;
+ }
- Ok(rows)
+ Some(proto::ChannelMember {
+ user_id: user_id.to_proto(),
+ kind: details.kind.into(),
+ role: details.channel_role.into(),
+ })
+ })
+ .collect())
})
.await
}
- pub async fn get_channel_members_internal(
+ pub async fn get_channel_participants_internal(
&self,
id: ChannelId,
tx: &DatabaseTransaction,
@@ -648,45 +838,198 @@ impl Database {
Ok(user_ids)
}
+ pub async fn check_user_is_channel_admin(
+ &self,
+ channel_id: ChannelId,
+ user_id: UserId,
+ tx: &DatabaseTransaction,
+ ) -> Result<()> {
+ match self.channel_role_for_user(channel_id, user_id, tx).await? {
+ Some(ChannelRole::Admin) => Ok(()),
+ Some(ChannelRole::Member)
+ | Some(ChannelRole::Banned)
+ | Some(ChannelRole::Guest)
+ | None => Err(anyhow!(
+ "user is not a channel admin or channel does not exist"
+ ))?,
+ }
+ }
+
pub async fn check_user_is_channel_member(
&self,
channel_id: ChannelId,
user_id: UserId,
tx: &DatabaseTransaction,
- ) -> Result<channel_member::Model> {
+ ) -> Result<ChannelRole> {
+ let channel_role = self.channel_role_for_user(channel_id, user_id, tx).await?;
+ match channel_role {
+ Some(ChannelRole::Admin) | Some(ChannelRole::Member) => Ok(channel_role.unwrap()),
+ Some(ChannelRole::Banned) | Some(ChannelRole::Guest) | None => Err(anyhow!(
+ "user is not a channel member or channel does not exist"
+ ))?,
+ }
+ }
+
+ pub async fn check_user_is_channel_participant(
+ &self,
+ channel_id: ChannelId,
+ user_id: UserId,
+ tx: &DatabaseTransaction,
+ ) -> Result<()> {
+ match self.channel_role_for_user(channel_id, user_id, tx).await? {
+ Some(ChannelRole::Admin) | Some(ChannelRole::Member) | Some(ChannelRole::Guest) => {
+ Ok(())
+ }
+ Some(ChannelRole::Banned) | None => Err(anyhow!(
+ "user is not a channel participant or channel does not exist"
+ ))?,
+ }
+ }
+
+ pub async fn pending_invite_for_channel(
+ &self,
+ channel_id: ChannelId,
+ user_id: UserId,
+ tx: &DatabaseTransaction,
+ ) -> Result<Option<channel_member::Model>> {
let channel_ids = self.get_channel_ancestors(channel_id, tx).await?;
- Ok(channel_member::Entity::find()
- .filter(
- channel_member::Column::ChannelId
- .is_in(channel_ids)
- .and(channel_member::Column::UserId.eq(user_id)),
- )
+
+ let row = channel_member::Entity::find()
+ .filter(channel_member::Column::ChannelId.is_in(channel_ids))
+ .filter(channel_member::Column::UserId.eq(user_id))
+ .filter(channel_member::Column::Accepted.eq(false))
.one(&*tx)
- .await?
- .ok_or_else(|| anyhow!("user is not a channel member or channel does not exist"))?)
+ .await?;
+
+ Ok(row)
}
- pub async fn check_user_is_channel_admin(
+ pub async fn most_public_ancestor_for_channel(
+ &self,
+ channel_id: ChannelId,
+ tx: &DatabaseTransaction,
+ ) -> Result<Option<ChannelId>> {
+ // Note: if there are many paths to a channel, this will return just one
+ let arbitary_path = channel_path::Entity::find()
+ .filter(channel_path::Column::ChannelId.eq(channel_id))
+ .order_by(channel_path::Column::IdPath, sea_orm::Order::Desc)
+ .one(tx)
+ .await?;
+
+ let Some(path) = arbitary_path else {
+ return Ok(None);
+ };
+
+ let ancestor_ids: Vec<ChannelId> = path
+ .id_path
+ .trim_matches('/')
+ .split('/')
+ .map(|id| ChannelId::from_proto(id.parse().unwrap()))
+ .collect();
+
+ let rows = channel::Entity::find()
+ .filter(channel::Column::Id.is_in(ancestor_ids.iter().copied()))
+ .filter(channel::Column::Visibility.eq(ChannelVisibility::Public))
+ .all(&*tx)
+ .await?;
+
+ let mut visible_channels: HashSet<ChannelId> = HashSet::default();
+
+ for row in rows {
+ visible_channels.insert(row.id);
+ }
+
+ for ancestor in ancestor_ids {
+ if visible_channels.contains(&ancestor) {
+ return Ok(Some(ancestor));
+ }
+ }
+
+ Ok(None)
+ }
+
+ pub async fn channel_role_for_user(
&self,
channel_id: ChannelId,
user_id: UserId,
tx: &DatabaseTransaction,
- ) -> Result<()> {
+ ) -> Result<Option<ChannelRole>> {
let channel_ids = self.get_channel_ancestors(channel_id, tx).await?;
- channel_member::Entity::find()
+
+ #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+ enum QueryChannelMembership {
+ ChannelId,
+ Role,
+ Visibility,
+ }
+
+ let mut rows = channel_member::Entity::find()
+ .left_join(channel::Entity)
.filter(
channel_member::Column::ChannelId
.is_in(channel_ids)
.and(channel_member::Column::UserId.eq(user_id))
- .and(channel_member::Column::Admin.eq(true)),
+ .and(channel_member::Column::Accepted.eq(true)),
)
- .one(&*tx)
- .await?
- .ok_or_else(|| anyhow!("user is not a channel admin or channel does not exist"))?;
- Ok(())
+ .select_only()
+ .column(channel_member::Column::ChannelId)
+ .column(channel_member::Column::Role)
+ .column(channel::Column::Visibility)
+ .into_values::<_, QueryChannelMembership>()
+ .stream(&*tx)
+ .await?;
+
+ let mut user_role: Option<ChannelRole> = None;
+
+ let mut is_participant = false;
+ let mut current_channel_visibility = None;
+
+ // note these channels are not iterated in any particular order,
+ // our current logic takes the highest permission available.
+ while let Some(row) = rows.next().await {
+ let (membership_channel, role, visibility): (
+ ChannelId,
+ ChannelRole,
+ ChannelVisibility,
+ ) = row?;
+
+ match role {
+ ChannelRole::Admin | ChannelRole::Member | ChannelRole::Banned => {
+ if let Some(users_role) = user_role {
+ user_role = Some(users_role.max(role));
+ } else {
+ user_role = Some(role)
+ }
+ }
+ ChannelRole::Guest if visibility == ChannelVisibility::Public => {
+ is_participant = true
+ }
+ ChannelRole::Guest => {}
+ }
+ if channel_id == membership_channel {
+ current_channel_visibility = Some(visibility);
+ }
+ }
+ // free up database connection
+ drop(rows);
+
+ if is_participant && user_role.is_none() {
+ if current_channel_visibility.is_none() {
+ current_channel_visibility = channel::Entity::find()
+ .filter(channel::Column::Id.eq(channel_id))
+ .one(&*tx)
+ .await?
+ .map(|channel| channel.visibility);
+ }
+ if current_channel_visibility == Some(ChannelVisibility::Public) {
+ user_role = Some(ChannelRole::Guest);
+ }
+ }
+
+ Ok(user_role)
}
- /// Returns the channel ancestors, deepest first
+ /// Returns the channel ancestors in arbitrary order
pub async fn get_channel_ancestors(
&self,
channel_id: ChannelId,
@@ -711,25 +1054,14 @@ impl Database {
Ok(channel_ids)
}
- /// Returns the channel descendants,
- /// Structured as a map from child ids to their parent ids
- /// For example, the descendants of 'a' in this DAG:
- ///
- /// /- b -\
- /// a -- c -- d
- ///
- /// would be:
- /// {
- /// a: [],
- /// b: [a],
- /// c: [a],
- /// d: [a, c],
- /// }
+ // Returns the channel desendants as a sorted list of edges for further processing.
+ // The edges are sorted such that you will see unknown channel ids as children
+ // before you see them as parents.
async fn get_channel_descendants(
&self,
channel_ids: impl IntoIterator<Item = ChannelId>,
tx: &DatabaseTransaction,
- ) -> Result<ChannelDescendants> {
+ ) -> Result<Vec<ChannelEdge>> {
let mut values = String::new();
for id in channel_ids {
if !values.is_empty() {
@@ -739,7 +1071,7 @@ impl Database {
}
if values.is_empty() {
- return Ok(HashMap::default());
+ return Ok(vec![]);
}
let sql = format!(
@@ -750,121 +1082,90 @@ impl Database {
channel_paths parent_paths, channel_paths descendant_paths
WHERE
parent_paths.channel_id IN ({values}) AND
+ descendant_paths.id_path != parent_paths.id_path AND
descendant_paths.id_path LIKE (parent_paths.id_path || '%')
+ ORDER BY
+ descendant_paths.id_path
"#
);
let stmt = Statement::from_string(self.pool.get_database_backend(), sql);
- let mut parents_by_child_id: ChannelDescendants = HashMap::default();
let mut paths = channel_path::Entity::find()
.from_raw_sql(stmt)
.stream(tx)
.await?;
+ let mut results: Vec<ChannelEdge> = Vec::new();
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);
- }
- }
- let entry = parents_by_child_id.entry(path.channel_id).or_default();
- if let Some(parent_id) = parent_id {
- entry.insert(parent_id);
- }
+ let ids: Vec<&str> = path.id_path.trim_matches('/').split('/').collect();
+
+ debug_assert!(ids.len() >= 2);
+ debug_assert!(ids[ids.len() - 1] == path.channel_id.to_string());
+
+ results.push(ChannelEdge {
+ parent_id: ids[ids.len() - 2].parse().unwrap(),
+ channel_id: ids[ids.len() - 1].parse().unwrap(),
+ })
}
- Ok(parents_by_child_id)
+ Ok(results)
}
- /// 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)>> {
+ /// Returns the channel with the given ID
+ pub async fn get_channel(&self, channel_id: ChannelId, user_id: UserId) -> Result<Channel> {
self.transaction(|tx| async move {
- let tx = tx;
+ self.check_user_is_channel_participant(channel_id, user_id, &*tx)
+ .await?;
let channel = channel::Entity::find_by_id(channel_id).one(&*tx).await?;
+ let Some(channel) = channel else {
+ Err(anyhow!("no such channel"))?
+ };
- 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,
- },
- is_accepted,
- )))
- } else {
- Ok(None)
- }
+ Ok(Channel {
+ id: channel.id,
+ visibility: channel.visibility,
+ name: channel.name,
+ })
})
.await
}
- pub async fn get_or_create_channel_room(
+ pub(crate) async fn get_or_create_channel_room(
&self,
channel_id: ChannelId,
live_kit_room: &str,
- enviroment: &str,
+ environment: &str,
+ tx: &DatabaseTransaction,
) -> Result<RoomId> {
- self.transaction(|tx| async move {
- let tx = tx;
-
- let room = room::Entity::find()
- .filter(room::Column::ChannelId.eq(channel_id))
- .one(&*tx)
- .await?;
+ let room = room::Entity::find()
+ .filter(room::Column::ChannelId.eq(channel_id))
+ .one(&*tx)
+ .await?;
- let room_id = if let Some(room) = room {
- room.id
- } else {
- let result = room::Entity::insert(room::ActiveModel {
- channel_id: ActiveValue::Set(Some(channel_id)),
- live_kit_room: ActiveValue::Set(live_kit_room.to_string()),
- enviroment: ActiveValue::Set(Some(enviroment.to_string())),
- ..Default::default()
- })
- .exec(&*tx)
- .await?;
+ let room_id = if let Some(room) = room {
+ if let Some(env) = room.enviroment {
+ if &env != environment {
+ Err(anyhow!("must join using the {} release", env))?;
+ }
+ }
+ room.id
+ } else {
+ let result = room::Entity::insert(room::ActiveModel {
+ channel_id: ActiveValue::Set(Some(channel_id)),
+ live_kit_room: ActiveValue::Set(live_kit_room.to_string()),
+ enviroment: ActiveValue::Set(Some(environment.to_string())),
+ ..Default::default()
+ })
+ .exec(&*tx)
+ .await?;
- result.last_insert_id
- };
+ result.last_insert_id
+ };
- Ok(room_id)
- })
- .await
+ Ok(room_id)
}
// Insert an edge from the given channel to the given other channel.
@@ -233,7 +233,9 @@ impl Database {
self.observe_channel_message_internal(channel_id, user_id, message_id, &*tx)
.await?;
- let mut channel_members = self.get_channel_members_internal(channel_id, &*tx).await?;
+ let mut channel_members = self
+ .get_channel_participants_internal(channel_id, &*tx)
+ .await?;
channel_members.retain(|member| !participant_user_ids.contains(member));
Ok((message_id, participant_connection_ids, channel_members))
@@ -386,8 +388,22 @@ impl Database {
.filter(channel_message::Column::SenderId.eq(user_id))
.exec(&*tx)
.await?;
+
if result.rows_affected == 0 {
- Err(anyhow!("no such message"))?;
+ if self
+ .check_user_is_channel_admin(channel_id, user_id, &*tx)
+ .await
+ .is_ok()
+ {
+ let result = channel_message::Entity::delete_by_id(message_id)
+ .exec(&*tx)
+ .await?;
+ if result.rows_affected == 0 {
+ Err(anyhow!("no such message"))?;
+ }
+ } else {
+ Err(anyhow!("operation could not be completed"))?;
+ }
}
Ok(participant_connection_ids)
@@ -53,7 +53,9 @@ impl Database {
let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
let channel_members;
if let Some(channel_id) = channel_id {
- channel_members = self.get_channel_members_internal(channel_id, &tx).await?;
+ channel_members = self
+ .get_channel_participants_internal(channel_id, &tx)
+ .await?;
} else {
channel_members = Vec::new();
@@ -298,98 +300,139 @@ impl Database {
}
}
- #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
- enum QueryParticipantIndices {
- ParticipantIndex,
- }
- let existing_participant_indices: Vec<i32> = room_participant::Entity::find()
- .filter(
- room_participant::Column::RoomId
- .eq(room_id)
- .and(room_participant::Column::ParticipantIndex.is_not_null()),
- )
- .select_only()
- .column(room_participant::Column::ParticipantIndex)
- .into_values::<_, QueryParticipantIndices>()
- .all(&*tx)
- .await?;
-
- let mut participant_index = 0;
- while existing_participant_indices.contains(&participant_index) {
- participant_index += 1;
+ if channel_id.is_some() {
+ Err(anyhow!("tried to join channel call directly"))?
}
- if let Some(channel_id) = channel_id {
- self.check_user_is_channel_member(channel_id, user_id, &*tx)
- .await?;
+ let participant_index = self
+ .get_next_participant_index_internal(room_id, &*tx)
+ .await?;
- room_participant::Entity::insert_many([room_participant::ActiveModel {
- room_id: ActiveValue::set(room_id),
- user_id: ActiveValue::set(user_id),
+ let result = room_participant::Entity::update_many()
+ .filter(
+ Condition::all()
+ .add(room_participant::Column::RoomId.eq(room_id))
+ .add(room_participant::Column::UserId.eq(user_id))
+ .add(room_participant::Column::AnsweringConnectionId.is_null()),
+ )
+ .set(room_participant::ActiveModel {
+ participant_index: ActiveValue::Set(Some(participant_index)),
answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
answering_connection_server_id: ActiveValue::set(Some(ServerId(
connection.owner_id as i32,
))),
answering_connection_lost: ActiveValue::set(false),
- calling_user_id: ActiveValue::set(user_id),
- calling_connection_id: ActiveValue::set(connection.id as i32),
- calling_connection_server_id: ActiveValue::set(Some(ServerId(
- connection.owner_id as i32,
- ))),
- participant_index: ActiveValue::Set(Some(participant_index)),
..Default::default()
- }])
- .on_conflict(
- OnConflict::columns([room_participant::Column::UserId])
- .update_columns([
- room_participant::Column::AnsweringConnectionId,
- room_participant::Column::AnsweringConnectionServerId,
- room_participant::Column::AnsweringConnectionLost,
- room_participant::Column::ParticipantIndex,
- ])
- .to_owned(),
- )
+ })
.exec(&*tx)
.await?;
- } else {
- let result = room_participant::Entity::update_many()
- .filter(
- Condition::all()
- .add(room_participant::Column::RoomId.eq(room_id))
- .add(room_participant::Column::UserId.eq(user_id))
- .add(room_participant::Column::AnsweringConnectionId.is_null()),
- )
- .set(room_participant::ActiveModel {
- participant_index: ActiveValue::Set(Some(participant_index)),
- answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
- answering_connection_server_id: ActiveValue::set(Some(ServerId(
- connection.owner_id as i32,
- ))),
- answering_connection_lost: ActiveValue::set(false),
- ..Default::default()
- })
- .exec(&*tx)
- .await?;
- if result.rows_affected == 0 {
- Err(anyhow!("room does not exist or was already joined"))?;
- }
+ if result.rows_affected == 0 {
+ Err(anyhow!("room does not exist or was already joined"))?;
}
let room = self.get_room(room_id, &tx).await?;
- let channel_members = if let Some(channel_id) = channel_id {
- self.get_channel_members_internal(channel_id, &tx).await?
- } else {
- Vec::new()
- };
Ok(JoinRoom {
room,
- channel_id,
- channel_members,
+ channel_id: None,
+ channel_members: vec![],
})
})
.await
}
+ async fn get_next_participant_index_internal(
+ &self,
+ room_id: RoomId,
+ tx: &DatabaseTransaction,
+ ) -> Result<i32> {
+ #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+ enum QueryParticipantIndices {
+ ParticipantIndex,
+ }
+ let existing_participant_indices: Vec<i32> = room_participant::Entity::find()
+ .filter(
+ room_participant::Column::RoomId
+ .eq(room_id)
+ .and(room_participant::Column::ParticipantIndex.is_not_null()),
+ )
+ .select_only()
+ .column(room_participant::Column::ParticipantIndex)
+ .into_values::<_, QueryParticipantIndices>()
+ .all(&*tx)
+ .await?;
+
+ let mut participant_index = 0;
+ while existing_participant_indices.contains(&participant_index) {
+ participant_index += 1;
+ }
+
+ Ok(participant_index)
+ }
+
+ pub async fn channel_id_for_room(&self, room_id: RoomId) -> Result<Option<ChannelId>> {
+ self.transaction(|tx| async move {
+ let room: Option<room::Model> = room::Entity::find()
+ .filter(room::Column::Id.eq(room_id))
+ .one(&*tx)
+ .await?;
+
+ Ok(room.and_then(|room| room.channel_id))
+ })
+ .await
+ }
+
+ pub(crate) async fn join_channel_room_internal(
+ &self,
+ channel_id: ChannelId,
+ room_id: RoomId,
+ user_id: UserId,
+ connection: ConnectionId,
+ tx: &DatabaseTransaction,
+ ) -> Result<JoinRoom> {
+ let participant_index = self
+ .get_next_participant_index_internal(room_id, &*tx)
+ .await?;
+
+ room_participant::Entity::insert_many([room_participant::ActiveModel {
+ room_id: ActiveValue::set(room_id),
+ user_id: ActiveValue::set(user_id),
+ answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
+ answering_connection_server_id: ActiveValue::set(Some(ServerId(
+ connection.owner_id as i32,
+ ))),
+ answering_connection_lost: ActiveValue::set(false),
+ calling_user_id: ActiveValue::set(user_id),
+ calling_connection_id: ActiveValue::set(connection.id as i32),
+ calling_connection_server_id: ActiveValue::set(Some(ServerId(
+ connection.owner_id as i32,
+ ))),
+ participant_index: ActiveValue::Set(Some(participant_index)),
+ ..Default::default()
+ }])
+ .on_conflict(
+ OnConflict::columns([room_participant::Column::UserId])
+ .update_columns([
+ room_participant::Column::AnsweringConnectionId,
+ room_participant::Column::AnsweringConnectionServerId,
+ room_participant::Column::AnsweringConnectionLost,
+ room_participant::Column::ParticipantIndex,
+ ])
+ .to_owned(),
+ )
+ .exec(&*tx)
+ .await?;
+
+ let room = self.get_room(room_id, &tx).await?;
+ let channel_members = self
+ .get_channel_participants_internal(channel_id, &tx)
+ .await?;
+ Ok(JoinRoom {
+ room,
+ channel_id: Some(channel_id),
+ channel_members,
+ })
+ }
+
pub async fn rejoin_room(
&self,
rejoin_room: proto::RejoinRoom,
@@ -681,7 +724,8 @@ impl Database {
let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
let channel_members = if let Some(channel_id) = channel_id {
- self.get_channel_members_internal(channel_id, &tx).await?
+ self.get_channel_participants_internal(channel_id, &tx)
+ .await?
} else {
Vec::new()
};
@@ -839,7 +883,8 @@ impl Database {
};
let channel_members = if let Some(channel_id) = channel_id {
- self.get_channel_members_internal(channel_id, &tx).await?
+ self.get_channel_participants_internal(channel_id, &tx)
+ .await?
} else {
Vec::new()
};
@@ -1,4 +1,4 @@
-use crate::db::ChannelId;
+use crate::db::{ChannelId, ChannelVisibility};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
@@ -7,6 +7,7 @@ pub struct Model {
#[sea_orm(primary_key)]
pub id: ChannelId,
pub name: String,
+ pub visibility: ChannelVisibility,
}
impl ActiveModelBehavior for ActiveModel {}
@@ -1,7 +1,7 @@
-use crate::db::{channel_member, ChannelId, ChannelMemberId, UserId};
+use crate::db::{channel_member, ChannelId, ChannelMemberId, ChannelRole, UserId};
use sea_orm::entity::prelude::*;
-#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
+#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "channel_members")]
pub struct Model {
#[sea_orm(primary_key)]
@@ -9,7 +9,7 @@ pub struct Model {
pub channel_id: ChannelId,
pub user_id: UserId,
pub accepted: bool,
- pub admin: bool,
+ pub role: ChannelRole,
}
impl ActiveModelBehavior for ActiveModel {}
@@ -161,6 +161,7 @@ fn graph(channels: &[(ChannelId, &'static str)], edges: &[(ChannelId, ChannelId)
graph.channels.push(Channel {
id: *id,
name: name.to_string(),
+ visibility: ChannelVisibility::Members,
})
}
@@ -53,7 +53,7 @@ async fn test_channel_buffers(db: &Arc<Database>) {
let zed_id = db.create_root_channel("zed", a_id).await.unwrap();
- db.invite_channel_member(zed_id, b_id, a_id, false)
+ db.invite_channel_member(zed_id, b_id, a_id, ChannelRole::Member)
.await
.unwrap();
@@ -206,7 +206,7 @@ async fn test_channel_buffers_last_operations(db: &Database) {
.await
.unwrap();
- db.invite_channel_member(channel, observer_id, user_id, false)
+ db.invite_channel_member(channel, observer_id, user_id, ChannelRole::Member)
.await
.unwrap();
db.respond_to_channel_invite(channel, observer_id, true)
@@ -8,11 +8,14 @@ use crate::{
db::{
queries::channels::ChannelGraph,
tests::{graph, TEST_RELEASE_CHANNEL},
- ChannelId, Database, NewUserParams,
+ ChannelId, ChannelRole, Database, NewUserParams, RoomId, UserId,
},
test_both_dbs,
};
-use std::sync::Arc;
+use std::sync::{
+ atomic::{AtomicI32, Ordering},
+ Arc,
+};
test_both_dbs!(test_channels, test_channels_postgres, test_channels_sqlite);
@@ -46,9 +49,9 @@ async fn test_channels(db: &Arc<Database>) {
let zed_id = db.create_root_channel("zed", 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());
+ assert!(db.get_channel(zed_id, b_id).await.is_err());
- db.invite_channel_member(zed_id, b_id, a_id, false)
+ db.invite_channel_member(zed_id, b_id, a_id, ChannelRole::Member)
.await
.unwrap();
@@ -123,9 +126,13 @@ async fn test_channels(db: &Arc<Database>) {
);
// Update member permissions
- let set_subchannel_admin = db.set_channel_member_admin(crdb_id, a_id, b_id, true).await;
+ let set_subchannel_admin = db
+ .set_channel_member_role(crdb_id, a_id, b_id, ChannelRole::Admin)
+ .await;
assert!(set_subchannel_admin.is_err());
- let set_channel_admin = db.set_channel_member_admin(zed_id, a_id, b_id, true).await;
+ let set_channel_admin = db
+ .set_channel_member_role(zed_id, a_id, b_id, ChannelRole::Admin)
+ .await;
assert!(set_channel_admin.is_ok());
let result = db.get_channels_for_user(b_id).await.unwrap();
@@ -148,7 +155,7 @@ async fn test_channels(db: &Arc<Database>) {
// Remove a single channel
db.delete_channel(crdb_id, a_id).await.unwrap();
- assert!(db.get_channel(crdb_id, a_id).await.unwrap().is_none());
+ assert!(db.get_channel(crdb_id, a_id).await.is_err());
// Remove a channel tree
let (mut channel_ids, user_ids) = db.delete_channel(rust_id, a_id).await.unwrap();
@@ -156,9 +163,9 @@ async fn test_channels(db: &Arc<Database>) {
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());
+ assert!(db.get_channel(rust_id, a_id).await.is_err());
+ assert!(db.get_channel(cargo_id, a_id).await.is_err());
+ assert!(db.get_channel(cargo_ra_id, a_id).await.is_err());
}
test_both_dbs!(
@@ -196,15 +203,11 @@ async fn test_joining_channels(db: &Arc<Database>) {
.user_id;
let channel_1 = db.create_root_channel("channel_1", user_1).await.unwrap();
- let room_1 = db
- .get_or_create_channel_room(channel_1, "1", TEST_RELEASE_CHANNEL)
- .await
- .unwrap();
// can join a room with membership to its channel
- let joined_room = db
- .join_room(
- room_1,
+ let (joined_room, _) = db
+ .join_channel(
+ channel_1,
user_1,
ConnectionId { owner_id, id: 1 },
TEST_RELEASE_CHANNEL,
@@ -213,11 +216,12 @@ async fn test_joining_channels(db: &Arc<Database>) {
.unwrap();
assert_eq!(joined_room.room.participants.len(), 1);
+ let room_id = RoomId::from_proto(joined_room.room.id);
drop(joined_room);
// cannot join a room without membership to its channel
assert!(db
.join_room(
- room_1,
+ room_id,
user_2,
ConnectionId { owner_id, id: 1 },
TEST_RELEASE_CHANNEL
@@ -235,55 +239,21 @@ test_both_dbs!(
async fn test_channel_invites(db: &Arc<Database>) {
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,
- },
- )
- .await
- .unwrap()
- .user_id;
- let user_2 = db
- .create_user(
- "user2@example.com",
- false,
- NewUserParams {
- github_login: "user2".into(),
- github_user_id: 6,
- },
- )
- .await
- .unwrap()
- .user_id;
-
- let user_3 = db
- .create_user(
- "user3@example.com",
- false,
- NewUserParams {
- github_login: "user3".into(),
- github_user_id: 7,
- },
- )
- .await
- .unwrap()
- .user_id;
+ let user_1 = new_test_user(db, "user1@example.com").await;
+ let user_2 = new_test_user(db, "user2@example.com").await;
+ let user_3 = new_test_user(db, "user3@example.com").await;
let channel_1_1 = db.create_root_channel("channel_1", user_1).await.unwrap();
let channel_1_2 = db.create_root_channel("channel_2", user_1).await.unwrap();
- db.invite_channel_member(channel_1_1, user_2, user_1, false)
+ db.invite_channel_member(channel_1_1, user_2, user_1, ChannelRole::Member)
.await
.unwrap();
- db.invite_channel_member(channel_1_2, user_2, user_1, false)
+ db.invite_channel_member(channel_1_2, user_2, user_1, ChannelRole::Member)
.await
.unwrap();
- db.invite_channel_member(channel_1_1, user_3, user_1, true)
+ db.invite_channel_member(channel_1_1, user_3, user_1, ChannelRole::Admin)
.await
.unwrap();
@@ -307,27 +277,29 @@ async fn test_channel_invites(db: &Arc<Database>) {
assert_eq!(user_3_invites, &[channel_1_1]);
- let members = db
- .get_channel_member_details(channel_1_1, user_1)
+ let mut members = db
+ .get_channel_participant_details(channel_1_1, user_1)
.await
.unwrap();
+
+ members.sort_by_key(|member| member.user_id);
assert_eq!(
members,
&[
proto::ChannelMember {
user_id: user_1.to_proto(),
kind: proto::channel_member::Kind::Member.into(),
- admin: true,
+ role: proto::ChannelRole::Admin.into(),
},
proto::ChannelMember {
user_id: user_2.to_proto(),
kind: proto::channel_member::Kind::Invitee.into(),
- admin: false,
+ role: proto::ChannelRole::Member.into(),
},
proto::ChannelMember {
user_id: user_3.to_proto(),
kind: proto::channel_member::Kind::Invitee.into(),
- admin: true,
+ role: proto::ChannelRole::Admin.into(),
},
]
);
@@ -342,7 +314,7 @@ async fn test_channel_invites(db: &Arc<Database>) {
.unwrap();
let members = db
- .get_channel_member_details(channel_1_3, user_1)
+ .get_channel_participant_details(channel_1_3, user_1)
.await
.unwrap();
assert_eq!(
@@ -351,12 +323,12 @@ async fn test_channel_invites(db: &Arc<Database>) {
proto::ChannelMember {
user_id: user_1.to_proto(),
kind: proto::channel_member::Kind::Member.into(),
- admin: true,
+ role: proto::ChannelRole::Admin.into(),
},
proto::ChannelMember {
user_id: user_2.to_proto(),
kind: proto::channel_member::Kind::AncestorMember.into(),
- admin: false,
+ role: proto::ChannelRole::Member.into(),
},
]
);
@@ -405,11 +377,7 @@ async fn test_channel_renames(db: &Arc<Database>) {
let zed_archive_id = zed_id;
- let (channel, _) = db
- .get_channel(zed_archive_id, user_1)
- .await
- .unwrap()
- .unwrap();
+ let channel = db.get_channel(zed_archive_id, user_1).await.unwrap();
assert_eq!(channel.name, "zed-archive");
let non_permissioned_rename = db
@@ -835,6 +803,284 @@ async fn test_db_channel_moving_bugs(db: &Arc<Database>) {
);
}
+test_both_dbs!(
+ test_user_is_channel_participant,
+ test_user_is_channel_participant_postgres,
+ test_user_is_channel_participant_sqlite
+);
+
+async fn test_user_is_channel_participant(db: &Arc<Database>) {
+ let admin = new_test_user(db, "admin@example.com").await;
+ let member = new_test_user(db, "member@example.com").await;
+ let guest = new_test_user(db, "guest@example.com").await;
+
+ let zed_channel = db.create_root_channel("zed", admin).await.unwrap();
+ let active_channel = db
+ .create_channel("active", Some(zed_channel), admin)
+ .await
+ .unwrap();
+ let vim_channel = db
+ .create_channel("vim", Some(active_channel), admin)
+ .await
+ .unwrap();
+
+ db.set_channel_visibility(vim_channel, crate::db::ChannelVisibility::Public, admin)
+ .await
+ .unwrap();
+ db.invite_channel_member(active_channel, member, admin, ChannelRole::Member)
+ .await
+ .unwrap();
+ db.invite_channel_member(vim_channel, guest, admin, ChannelRole::Guest)
+ .await
+ .unwrap();
+
+ db.respond_to_channel_invite(active_channel, member, true)
+ .await
+ .unwrap();
+
+ db.transaction(|tx| async move {
+ db.check_user_is_channel_participant(vim_channel, admin, &*tx)
+ .await
+ })
+ .await
+ .unwrap();
+ db.transaction(|tx| async move {
+ db.check_user_is_channel_participant(vim_channel, member, &*tx)
+ .await
+ })
+ .await
+ .unwrap();
+
+ let mut members = db
+ .get_channel_participant_details(vim_channel, admin)
+ .await
+ .unwrap();
+
+ members.sort_by_key(|member| member.user_id);
+
+ assert_eq!(
+ members,
+ &[
+ proto::ChannelMember {
+ user_id: admin.to_proto(),
+ kind: proto::channel_member::Kind::Member.into(),
+ role: proto::ChannelRole::Admin.into(),
+ },
+ proto::ChannelMember {
+ user_id: member.to_proto(),
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ role: proto::ChannelRole::Member.into(),
+ },
+ proto::ChannelMember {
+ user_id: guest.to_proto(),
+ kind: proto::channel_member::Kind::Invitee.into(),
+ role: proto::ChannelRole::Guest.into(),
+ },
+ ]
+ );
+
+ db.respond_to_channel_invite(vim_channel, guest, true)
+ .await
+ .unwrap();
+
+ db.transaction(|tx| async move {
+ db.check_user_is_channel_participant(vim_channel, guest, &*tx)
+ .await
+ })
+ .await
+ .unwrap();
+
+ let channels = db.get_channels_for_user(guest).await.unwrap().channels;
+ assert_dag(channels, &[(vim_channel, None)]);
+ let channels = db.get_channels_for_user(member).await.unwrap().channels;
+ assert_dag(
+ channels,
+ &[(active_channel, None), (vim_channel, Some(active_channel))],
+ );
+
+ db.set_channel_member_role(vim_channel, admin, guest, ChannelRole::Banned)
+ .await
+ .unwrap();
+ assert!(db
+ .transaction(|tx| async move {
+ db.check_user_is_channel_participant(vim_channel, guest, &*tx)
+ .await
+ })
+ .await
+ .is_err());
+
+ let mut members = db
+ .get_channel_participant_details(vim_channel, admin)
+ .await
+ .unwrap();
+
+ members.sort_by_key(|member| member.user_id);
+
+ assert_eq!(
+ members,
+ &[
+ proto::ChannelMember {
+ user_id: admin.to_proto(),
+ kind: proto::channel_member::Kind::Member.into(),
+ role: proto::ChannelRole::Admin.into(),
+ },
+ proto::ChannelMember {
+ user_id: member.to_proto(),
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ role: proto::ChannelRole::Member.into(),
+ },
+ proto::ChannelMember {
+ user_id: guest.to_proto(),
+ kind: proto::channel_member::Kind::Member.into(),
+ role: proto::ChannelRole::Banned.into(),
+ },
+ ]
+ );
+
+ db.remove_channel_member(vim_channel, guest, admin)
+ .await
+ .unwrap();
+
+ db.set_channel_visibility(zed_channel, crate::db::ChannelVisibility::Public, admin)
+ .await
+ .unwrap();
+
+ db.invite_channel_member(zed_channel, guest, admin, ChannelRole::Guest)
+ .await
+ .unwrap();
+
+ // currently people invited to parent channels are not shown here
+ let mut members = db
+ .get_channel_participant_details(vim_channel, admin)
+ .await
+ .unwrap();
+
+ members.sort_by_key(|member| member.user_id);
+
+ assert_eq!(
+ members,
+ &[
+ proto::ChannelMember {
+ user_id: admin.to_proto(),
+ kind: proto::channel_member::Kind::Member.into(),
+ role: proto::ChannelRole::Admin.into(),
+ },
+ proto::ChannelMember {
+ user_id: member.to_proto(),
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ role: proto::ChannelRole::Member.into(),
+ },
+ ]
+ );
+
+ db.respond_to_channel_invite(zed_channel, guest, true)
+ .await
+ .unwrap();
+
+ db.transaction(|tx| async move {
+ db.check_user_is_channel_participant(zed_channel, guest, &*tx)
+ .await
+ })
+ .await
+ .unwrap();
+ assert!(db
+ .transaction(|tx| async move {
+ db.check_user_is_channel_participant(active_channel, guest, &*tx)
+ .await
+ })
+ .await
+ .is_err(),);
+
+ db.transaction(|tx| async move {
+ db.check_user_is_channel_participant(vim_channel, guest, &*tx)
+ .await
+ })
+ .await
+ .unwrap();
+
+ let mut members = db
+ .get_channel_participant_details(vim_channel, admin)
+ .await
+ .unwrap();
+
+ members.sort_by_key(|member| member.user_id);
+
+ assert_eq!(
+ members,
+ &[
+ proto::ChannelMember {
+ user_id: admin.to_proto(),
+ kind: proto::channel_member::Kind::Member.into(),
+ role: proto::ChannelRole::Admin.into(),
+ },
+ proto::ChannelMember {
+ user_id: member.to_proto(),
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ role: proto::ChannelRole::Member.into(),
+ },
+ proto::ChannelMember {
+ user_id: guest.to_proto(),
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ role: proto::ChannelRole::Guest.into(),
+ },
+ ]
+ );
+
+ let channels = db.get_channels_for_user(guest).await.unwrap().channels;
+ assert_dag(
+ channels,
+ &[(zed_channel, None), (vim_channel, Some(zed_channel))],
+ )
+}
+
+test_both_dbs!(
+ test_user_joins_correct_channel,
+ test_user_joins_correct_channel_postgres,
+ test_user_joins_correct_channel_sqlite
+);
+
+async fn test_user_joins_correct_channel(db: &Arc<Database>) {
+ let admin = new_test_user(db, "admin@example.com").await;
+
+ let zed_channel = db.create_root_channel("zed", admin).await.unwrap();
+
+ let active_channel = db
+ .create_channel("active", Some(zed_channel), admin)
+ .await
+ .unwrap();
+
+ let vim_channel = db
+ .create_channel("vim", Some(active_channel), admin)
+ .await
+ .unwrap();
+
+ let vim2_channel = db
+ .create_channel("vim2", Some(vim_channel), admin)
+ .await
+ .unwrap();
+
+ db.set_channel_visibility(zed_channel, crate::db::ChannelVisibility::Public, admin)
+ .await
+ .unwrap();
+
+ db.set_channel_visibility(vim_channel, crate::db::ChannelVisibility::Public, admin)
+ .await
+ .unwrap();
+
+ db.set_channel_visibility(vim2_channel, crate::db::ChannelVisibility::Public, admin)
+ .await
+ .unwrap();
+
+ let most_public = db
+ .transaction(
+ |tx| async move { db.most_public_ancestor_for_channel(vim_channel, &*tx).await },
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(most_public, Some(zed_channel))
+}
+
#[track_caller]
fn assert_dag(actual: ChannelGraph, expected: &[(ChannelId, Option<ChannelId>)]) {
let mut actual_map: HashMap<ChannelId, HashSet<ChannelId>> = HashMap::default();
@@ -859,3 +1105,19 @@ fn assert_dag(actual: ChannelGraph, expected: &[(ChannelId, Option<ChannelId>)])
pretty_assertions::assert_eq!(actual_map, expected_map)
}
+
+static GITHUB_USER_ID: AtomicI32 = AtomicI32::new(5);
+
+async fn new_test_user(db: &Arc<Database>, email: &str) -> UserId {
+ db.create_user(
+ email,
+ false,
+ NewUserParams {
+ github_login: email[0..email.find("@").unwrap()].to_string(),
+ github_user_id: GITHUB_USER_ID.fetch_add(1, Ordering::SeqCst),
+ },
+ )
+ .await
+ .unwrap()
+ .user_id
+}
@@ -1,5 +1,5 @@
use crate::{
- db::{Database, MessageId, NewUserParams},
+ db::{ChannelRole, Database, MessageId, NewUserParams},
test_both_dbs,
};
use channel::mentions_to_proto;
@@ -158,12 +158,13 @@ async fn test_unseen_channel_messages(db: &Arc<Database>) {
let channel_1 = db.create_channel("channel", None, user).await.unwrap();
let channel_2 = db.create_channel("channel-2", None, user).await.unwrap();
- db.invite_channel_member(channel_1, observer, user, false)
+ db.invite_channel_member(channel_1, observer, user, ChannelRole::Member)
.await
.unwrap();
- db.invite_channel_member(channel_2, observer, user, false)
+ db.invite_channel_member(channel_2, observer, user, ChannelRole::Member)
.await
.unwrap();
+
db.respond_to_channel_invite(channel_1, observer, true)
.await
.unwrap();
@@ -341,7 +342,7 @@ async fn test_channel_message_mentions(db: &Arc<Database>) {
.user_id;
let channel = db.create_channel("channel", None, user_a).await.unwrap();
- db.invite_channel_member(channel, user_b, user_a, false)
+ db.invite_channel_member(channel, user_b, user_a, ChannelRole::Member)
.await
.unwrap();
db.respond_to_channel_invite(channel, user_b, true)
@@ -3,8 +3,8 @@ mod connection_pool;
use crate::{
auth,
db::{
- self, BufferId, ChannelId, ChannelsForUser, Database, MessageId, ProjectId, RoomId,
- ServerId, User, UserId,
+ self, BufferId, ChannelId, ChannelVisibility, ChannelsForUser, Database, MessageId,
+ ProjectId, RoomId, ServerId, User, UserId,
},
executor::Executor,
AppState, Result,
@@ -256,7 +256,8 @@ impl Server {
.add_request_handler(delete_channel)
.add_request_handler(invite_channel_member)
.add_request_handler(remove_channel_member)
- .add_request_handler(set_channel_member_admin)
+ .add_request_handler(set_channel_member_role)
+ .add_request_handler(set_channel_visibility)
.add_request_handler(rename_channel)
.add_request_handler(join_channel_buffer)
.add_request_handler(leave_channel_buffer)
@@ -979,6 +980,13 @@ async fn join_room(
session: Session,
) -> Result<()> {
let room_id = RoomId::from_proto(request.id);
+
+ let channel_id = session.db().await.channel_id_for_room(room_id).await?;
+
+ if let Some(channel_id) = channel_id {
+ return join_channel_internal(channel_id, Box::new(response), session).await;
+ }
+
let joined_room = {
let room = session
.db()
@@ -994,16 +1002,6 @@ async fn join_room(
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
@@ -1041,7 +1039,7 @@ async fn join_room(
response.send(proto::JoinRoomResponse {
room: Some(joined_room.room),
- channel_id: joined_room.channel_id.map(|id| id.to_proto()),
+ channel_id: None,
live_kit_connection_info,
})?;
@@ -2224,6 +2222,7 @@ async fn create_channel(
let channel = proto::Channel {
id: id.to_proto(),
name: request.name,
+ visibility: proto::ChannelVisibility::Members as i32,
};
response.send(proto::CreateChannelResponse {
@@ -2297,17 +2296,20 @@ async fn invite_channel_member(
let channel_id = ChannelId::from_proto(request.channel_id);
let invitee_id = UserId::from_proto(request.user_id);
let notifications = db
- .invite_channel_member(channel_id, invitee_id, session.user_id, request.admin)
+ .invite_channel_member(
+ channel_id,
+ invitee_id,
+ session.user_id,
+ request.role().into(),
+ )
.await?;
- let (channel, _) = db
- .get_channel(channel_id, session.user_id)
- .await?
- .ok_or_else(|| anyhow!("channel not found"))?;
+ let channel = db.get_channel(channel_id, session.user_id).await?;
let mut update = proto::UpdateChannels::default();
update.channel_invitations.push(proto::Channel {
id: channel.id.to_proto(),
+ visibility: channel.visibility.into(),
name: channel.name,
});
@@ -2361,27 +2363,63 @@ async fn remove_channel_member(
Ok(())
}
-async fn set_channel_member_admin(
- request: proto::SetChannelMemberAdmin,
- response: Response<proto::SetChannelMemberAdmin>,
+async fn set_channel_visibility(
+ request: proto::SetChannelVisibility,
+ response: Response<proto::SetChannelVisibility>,
+ session: Session,
+) -> Result<()> {
+ let db = session.db().await;
+ let channel_id = ChannelId::from_proto(request.channel_id);
+ let visibility = request.visibility().into();
+
+ let channel = db
+ .set_channel_visibility(channel_id, visibility, session.user_id)
+ .await?;
+
+ let mut update = proto::UpdateChannels::default();
+ update.channels.push(proto::Channel {
+ id: channel.id.to_proto(),
+ name: channel.name,
+ visibility: channel.visibility.into(),
+ });
+
+ 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())?;
+ }
+ }
+
+ response.send(proto::Ack {})?;
+ Ok(())
+}
+
+async fn set_channel_member_role(
+ request: proto::SetChannelMemberRole,
+ response: Response<proto::SetChannelMemberRole>,
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)
+ let channel_member = db
+ .set_channel_member_role(
+ channel_id,
+ session.user_id,
+ member_id,
+ request.role().into(),
+ )
.await?;
- let (channel, has_accepted) = db
- .get_channel(channel_id, member_id)
- .await?
- .ok_or_else(|| anyhow!("channel not found"))?;
+ let channel = db.get_channel(channel_id, session.user_id).await?;
let mut update = proto::UpdateChannels::default();
- if has_accepted {
+ if channel_member.accepted {
update.channel_permissions.push(proto::ChannelPermission {
channel_id: channel.id.to_proto(),
- is_admin: request.admin,
+ role: request.role,
});
}
@@ -2404,13 +2442,14 @@ async fn rename_channel(
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
- let new_name = db
+ let channel = db
.rename_channel(channel_id, session.user_id, &request.name)
.await?;
let channel = proto::Channel {
- id: request.channel_id,
- name: new_name,
+ id: channel.id.to_proto(),
+ name: channel.name,
+ visibility: channel.visibility.into(),
};
response.send(proto::RenameChannelResponse {
channel: Some(channel.clone()),
@@ -2448,6 +2487,7 @@ async fn link_channel(
.into_iter()
.map(|channel| proto::Channel {
id: channel.id.to_proto(),
+ visibility: channel.visibility.into(),
name: channel.name,
})
.collect(),
@@ -2539,6 +2579,7 @@ async fn move_channel(
.into_iter()
.map(|channel| proto::Channel {
id: channel.id.to_proto(),
+ visibility: channel.visibility.into(),
name: channel.name,
})
.collect(),
@@ -2564,7 +2605,7 @@ async fn get_channel_members(
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)
+ .get_channel_participant_details(channel_id, session.user_id)
.await?;
response.send(proto::GetChannelMembersResponse { members })?;
Ok(())
@@ -2581,51 +2622,16 @@ async fn respond_to_channel_invite(
.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_channel_for_user(channel_id, session.user_id).await?;
- update
- .channels
- .extend(
- result
- .channels
- .channels
- .into_iter()
- .map(|channel| proto::Channel {
- id: channel.id.to_proto(),
- name: channel.name,
- }),
- );
- update.unseen_channel_messages = result.channel_messages;
- update.unseen_channel_buffer_changes = result.unseen_buffer_changes;
- update.insert_edge = result.channels.edges;
- 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(),
- }),
- );
+ channel_membership_updated(db, channel_id, &session).await?;
+ } else {
+ let mut update = proto::UpdateChannels::default();
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,
- }),
- );
+ .remove_channel_invitations
+ .push(channel_id.to_proto());
+ session.peer.send(session.connection_id, update)?;
}
- session.peer.send(session.connection_id, update)?;
+
send_notifications(
&*session.connection_pool().await,
&session.peer,
@@ -2636,25 +2642,92 @@ async fn respond_to_channel_invite(
Ok(())
}
+async fn channel_membership_updated(
+ db: tokio::sync::MutexGuard<'_, DbHandle>,
+ channel_id: ChannelId,
+ session: &Session,
+) -> Result<(), crate::Error> {
+ let mut update = proto::UpdateChannels::default();
+ update
+ .remove_channel_invitations
+ .push(channel_id.to_proto());
+
+ let result = db.get_channel_for_user(channel_id, session.user_id).await?;
+ update.channels.extend(
+ result
+ .channels
+ .channels
+ .into_iter()
+ .map(|channel| proto::Channel {
+ id: channel.id.to_proto(),
+ visibility: channel.visibility.into(),
+ name: channel.name,
+ }),
+ );
+ update.unseen_channel_messages = result.channel_messages;
+ update.unseen_channel_buffer_changes = result.unseen_buffer_changes;
+ update.insert_edge = result.channels.edges;
+ 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(),
+ role: proto::ChannelRole::Admin.into(),
+ }),
+ );
+ session.peer.send(session.connection_id, update)?;
+ 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 live_kit_room = format!("channel-{}", nanoid::nanoid!(30));
+ join_channel_internal(channel_id, Box::new(response), session).await
+}
+
+trait JoinChannelInternalResponse {
+ fn send(self, result: proto::JoinRoomResponse) -> Result<()>;
+}
+impl JoinChannelInternalResponse for Response<proto::JoinChannel> {
+ fn send(self, result: proto::JoinRoomResponse) -> Result<()> {
+ Response::<proto::JoinChannel>::send(self, result)
+ }
+}
+impl JoinChannelInternalResponse for Response<proto::JoinRoom> {
+ fn send(self, result: proto::JoinRoomResponse) -> Result<()> {
+ Response::<proto::JoinRoom>::send(self, result)
+ }
+}
+async fn join_channel_internal(
+ channel_id: ChannelId,
+ response: Box<impl JoinChannelInternalResponse>,
+ session: Session,
+) -> Result<()> {
let joined_room = {
leave_room_for_session(&session).await?;
let db = session.db().await;
- let room_id = db
- .get_or_create_channel_room(channel_id, &live_kit_room, &*RELEASE_CHANNEL_NAME)
- .await?;
-
- let joined_room = db
- .join_room(
- room_id,
+ let (joined_room, joined_channel) = db
+ .join_channel(
+ channel_id,
session.user_id,
session.connection_id,
RELEASE_CHANNEL_NAME.as_str(),
@@ -2681,9 +2754,13 @@ async fn join_channel(
live_kit_connection_info,
})?;
+ if let Some(joined_channel) = joined_channel {
+ channel_membership_updated(db, joined_channel, &session).await?
+ }
+
room_updated(&joined_room.room, &session.peer);
- joined_room.into_inner()
+ joined_room
};
channel_updated(
@@ -2695,7 +2772,6 @@ async fn join_channel(
);
update_user_contacts(session.user_id, &session).await?;
-
Ok(())
}
@@ -3152,6 +3228,7 @@ fn build_initial_channels_update(
update.channels.push(proto::Channel {
id: channel.id.to_proto(),
name: channel.name,
+ visibility: channel.visibility.into(),
});
}
@@ -3176,7 +3253,7 @@ fn build_initial_channels_update(
.into_iter()
.map(|id| proto::ChannelPermission {
channel_id: id.to_proto(),
- is_admin: true,
+ role: proto::ChannelRole::Admin.into(),
}),
);
@@ -3184,6 +3261,8 @@ fn build_initial_channels_update(
update.channel_invitations.push(proto::Channel {
id: channel.id.to_proto(),
name: channel.name,
+ // TODO: Visibility
+ visibility: ChannelVisibility::Public.into(),
});
}
@@ -11,7 +11,10 @@ use collections::HashMap;
use editor::{Anchor, Editor, ToOffset};
use futures::future;
use gpui::{executor::Deterministic, ModelHandle, TestAppContext, ViewContext};
-use rpc::{proto::PeerId, RECEIVE_TIMEOUT};
+use rpc::{
+ proto::{self, PeerId},
+ RECEIVE_TIMEOUT,
+};
use serde_json::json;
use std::{ops::Range, sync::Arc};
@@ -445,6 +448,7 @@ fn channel(id: u64, name: &'static str) -> Channel {
Channel {
id,
name: name.to_string(),
+ visibility: proto::ChannelVisibility::Members,
unseen_note_version: None,
unseen_message_id: None,
}
@@ -6,7 +6,10 @@ use call::ActiveCall;
use channel::{ChannelId, ChannelMembership, ChannelStore};
use client::User;
use gpui::{executor::Deterministic, ModelHandle, TestAppContext};
-use rpc::{proto, RECEIVE_TIMEOUT};
+use rpc::{
+ proto::{self, ChannelRole},
+ RECEIVE_TIMEOUT,
+};
use std::sync::Arc;
#[gpui::test]
@@ -68,7 +71,12 @@ async fn test_core_channels(
.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);
+ let invite = store.invite_member(
+ channel_a_id,
+ client_b.user_id().unwrap(),
+ proto::ChannelRole::Member,
+ cx,
+ );
// Make sure we're synchronously storing the pending invite
assert!(store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap()));
@@ -103,12 +111,12 @@ async fn test_core_channels(
&[
(
client_a.user_id().unwrap(),
- true,
+ proto::ChannelRole::Admin,
proto::channel_member::Kind::Member,
),
(
client_b.user_id().unwrap(),
- false,
+ proto::ChannelRole::Member,
proto::channel_member::Kind::Invitee,
),
],
@@ -183,7 +191,12 @@ async fn test_core_channels(
client_a
.channel_store()
.update(cx_a, |store, cx| {
- store.set_member_admin(channel_a_id, client_b.user_id().unwrap(), true, cx)
+ store.set_member_role(
+ channel_a_id,
+ client_b.user_id().unwrap(),
+ proto::ChannelRole::Admin,
+ cx,
+ )
})
.await
.unwrap();
@@ -305,12 +318,12 @@ fn assert_participants_eq(participants: &[Arc<User>], expected_partitipants: &[u
#[track_caller]
fn assert_members_eq(
members: &[ChannelMembership],
- expected_members: &[(u64, bool, proto::channel_member::Kind)],
+ expected_members: &[(u64, proto::ChannelRole, proto::channel_member::Kind)],
) {
assert_eq!(
members
.iter()
- .map(|member| (member.user.id, member.admin, member.kind))
+ .map(|member| (member.user.id, member.role, member.kind))
.collect::<Vec<_>>(),
expected_members
);
@@ -611,7 +624,12 @@ async fn test_permissions_update_while_invited(
client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
- channel_store.invite_member(rust_id, client_b.user_id().unwrap(), false, cx)
+ channel_store.invite_member(
+ rust_id,
+ client_b.user_id().unwrap(),
+ proto::ChannelRole::Member,
+ cx,
+ )
})
.await
.unwrap();
@@ -634,7 +652,12 @@ async fn test_permissions_update_while_invited(
client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
- channel_store.set_member_admin(rust_id, client_b.user_id().unwrap(), true, cx)
+ channel_store.set_member_role(
+ rust_id,
+ client_b.user_id().unwrap(),
+ proto::ChannelRole::Admin,
+ cx,
+ )
})
.await
.unwrap();
@@ -803,7 +826,12 @@ async fn test_lost_channel_creation(
client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
- channel_store.invite_member(channel_id, client_b.user_id().unwrap(), false, cx)
+ channel_store.invite_member(
+ channel_id,
+ client_b.user_id().unwrap(),
+ proto::ChannelRole::Member,
+ cx,
+ )
})
.await
.unwrap();
@@ -884,6 +912,119 @@ async fn test_lost_channel_creation(
],
);
}
+#[gpui::test]
+async fn test_guest_access(
+ 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 channels = server
+ .make_channel_tree(&[("channel-a", None)], (&client_a, cx_a))
+ .await;
+ let channel_a_id = channels[0];
+
+ let active_call_b = cx_b.read(ActiveCall::global);
+
+ // should not be allowed to join
+ assert!(active_call_b
+ .update(cx_b, |call, cx| call.join_channel(channel_a_id, cx))
+ .await
+ .is_err());
+
+ client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.set_channel_visibility(channel_a_id, proto::ChannelVisibility::Public, cx)
+ })
+ .await
+ .unwrap();
+
+ active_call_b
+ .update(cx_b, |call, cx| call.join_channel(channel_a_id, cx))
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ assert!(client_b
+ .channel_store()
+ .update(cx_b, |channel_store, _| channel_store
+ .channel_for_id(channel_a_id)
+ .is_some()));
+
+ client_a.channel_store().update(cx_a, |channel_store, _| {
+ let participants = channel_store.channel_participants(channel_a_id);
+ assert_eq!(participants.len(), 1);
+ assert_eq!(participants[0].id, client_b.user_id().unwrap());
+ })
+}
+
+#[gpui::test]
+async fn test_invite_access(
+ 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 channels = server
+ .make_channel_tree(
+ &[("channel-a", None), ("channel-b", Some("channel-a"))],
+ (&client_a, cx_a),
+ )
+ .await;
+ let channel_a_id = channels[0];
+ let channel_b_id = channels[0];
+
+ let active_call_b = cx_b.read(ActiveCall::global);
+
+ // should not be allowed to join
+ assert!(active_call_b
+ .update(cx_b, |call, cx| call.join_channel(channel_b_id, cx))
+ .await
+ .is_err());
+
+ client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.invite_member(
+ channel_a_id,
+ client_b.user_id().unwrap(),
+ ChannelRole::Member,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ active_call_b
+ .update(cx_b, |call, cx| call.join_channel(channel_b_id, cx))
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ client_b.channel_store().update(cx_b, |channel_store, _| {
+ assert!(channel_store.channel_for_id(channel_b_id).is_some());
+ assert!(channel_store.channel_for_id(channel_a_id).is_some());
+ });
+
+ client_a.channel_store().update(cx_a, |channel_store, _| {
+ let participants = channel_store.channel_participants(channel_b_id);
+ assert_eq!(participants.len(), 1);
+ assert_eq!(participants[0].id, client_b.user_id().unwrap());
+ })
+}
#[gpui::test]
async fn test_channel_moving(
@@ -2,7 +2,7 @@ use crate::tests::TestServer;
use gpui::{executor::Deterministic, TestAppContext};
use notifications::NotificationEvent;
use parking_lot::Mutex;
-use rpc::Notification;
+use rpc::{proto, Notification};
use std::sync::Arc;
#[gpui::test]
@@ -120,7 +120,7 @@ async fn test_notifications(
client_a
.channel_store()
.update(cx_a, |store, cx| {
- store.invite_member(channel_id, client_b.id(), false, cx)
+ store.invite_member(channel_id, client_b.id(), proto::ChannelRole::Member, cx)
})
.await
.unwrap();
@@ -1,3 +1,5 @@
+use crate::db::ChannelRole;
+
use super::{run_randomized_test, RandomizedTest, TestClient, TestError, TestServer, UserTestPlan};
use anyhow::Result;
use async_trait::async_trait;
@@ -50,7 +52,7 @@ impl RandomizedTest for RandomChannelBufferTest {
.await
.unwrap();
for user in &users[1..] {
- db.invite_channel_member(id, user.user_id, users[0].user_id, false)
+ db.invite_channel_member(id, user.user_id, users[0].user_id, ChannelRole::Member)
.await
.unwrap();
db.respond_to_channel_invite(id, user.user_id, true)
@@ -19,7 +19,7 @@ use node_runtime::FakeNodeRuntime;
use notifications::NotificationStore;
use parking_lot::Mutex;
use project::{Project, WorktreeId};
-use rpc::RECEIVE_TIMEOUT;
+use rpc::{proto::ChannelRole, RECEIVE_TIMEOUT};
use settings::SettingsStore;
use std::{
cell::{Ref, RefCell, RefMut},
@@ -330,7 +330,7 @@ impl TestServer {
channel_store.invite_member(
channel_id,
member_client.user_id().unwrap(),
- false,
+ ChannelRole::Member,
cx,
)
})
@@ -623,7 +623,12 @@ impl TestClient {
cx_self
.read(ChannelStore::global)
.update(cx_self, |channel_store, cx| {
- channel_store.invite_member(channel, other_client.user_id().unwrap(), true, cx)
+ channel_store.invite_member(
+ channel,
+ other_client.user_id().unwrap(),
+ ChannelRole::Admin,
+ cx,
+ )
})
.await
.unwrap();
@@ -345,8 +345,12 @@ impl ChatPanel {
}
fn render_message(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
- let (message, is_continuation, is_last) = {
+ let (message, is_continuation, is_last, is_admin) = {
let active_chat = self.active_chat.as_ref().unwrap().0.read(cx);
+ let is_admin = self
+ .channel_store
+ .read(cx)
+ .is_user_admin(active_chat.channel().id);
let last_message = active_chat.message(ix.saturating_sub(1));
let this_message = active_chat.message(ix);
let is_continuation = last_message.id != this_message.id
@@ -356,6 +360,7 @@ impl ChatPanel {
active_chat.message(ix).clone(),
is_continuation,
active_chat.message_count() == ix + 1,
+ is_admin,
)
};
@@ -376,12 +381,13 @@ impl ChatPanel {
};
let belongs_to_user = Some(message.sender.id) == self.client.user_id();
- let message_id_to_remove =
- if let (ChannelMessageId::Saved(id), true) = (message.id, belongs_to_user) {
- Some(id)
- } else {
- None
- };
+ let message_id_to_remove = if let (ChannelMessageId::Saved(id), true) =
+ (message.id, belongs_to_user || is_admin)
+ {
+ Some(id)
+ } else {
+ None
+ };
enum MessageBackgroundHighlight {}
MouseEventHandler::new::<MessageBackgroundHighlight, _>(ix, cx, |state, cx| {
@@ -232,7 +232,7 @@ mod tests {
avatar: None,
}),
kind: proto::channel_member::Kind::Member,
- admin: false,
+ role: proto::ChannelRole::Member,
},
ChannelMembership {
user: Arc::new(User {
@@ -241,7 +241,7 @@ mod tests {
avatar: None,
}),
kind: proto::channel_member::Kind::Member,
- admin: false,
+ role: proto::ChannelRole::Member,
},
],
cx,
@@ -11,7 +11,10 @@ use anyhow::Result;
use call::ActiveCall;
use channel::{Channel, ChannelData, ChannelEvent, ChannelId, ChannelPath, ChannelStore};
use channel_modal::ChannelModal;
-use client::{proto::PeerId, Client, Contact, User, UserStore};
+use client::{
+ proto::{self, PeerId},
+ Client, Contact, User, UserStore,
+};
use contact_finder::ContactFinder;
use context_menu::{ContextMenu, ContextMenuItem};
use db::kvp::KEY_VALUE_STORE;
@@ -428,7 +431,7 @@ enum ListEntry {
is_last: bool,
},
ParticipantScreen {
- peer_id: PeerId,
+ peer_id: Option<PeerId>,
is_last: bool,
},
IncomingRequest(Arc<User>),
@@ -442,6 +445,9 @@ enum ListEntry {
ChannelNotes {
channel_id: ChannelId,
},
+ ChannelChat {
+ channel_id: ChannelId,
+ },
ChannelEditor {
depth: usize,
},
@@ -602,6 +608,13 @@ impl CollabPanel {
ix,
cx,
),
+ ListEntry::ChannelChat { channel_id } => this.render_channel_chat(
+ *channel_id,
+ &theme.collab_panel,
+ is_selected,
+ ix,
+ cx,
+ ),
ListEntry::ChannelInvite(channel) => Self::render_channel_invite(
channel.clone(),
this.channel_store.clone(),
@@ -804,7 +817,8 @@ impl CollabPanel {
let room = room.read(cx);
if let Some(channel_id) = room.channel_id() {
- self.entries.push(ListEntry::ChannelNotes { channel_id })
+ self.entries.push(ListEntry::ChannelNotes { channel_id });
+ self.entries.push(ListEntry::ChannelChat { channel_id })
}
// Populate the active user.
@@ -836,7 +850,13 @@ impl CollabPanel {
project_id: project.id,
worktree_root_names: project.worktree_root_names.clone(),
host_user_id: user_id,
- is_last: projects.peek().is_none(),
+ is_last: projects.peek().is_none() && !room.is_screen_sharing(),
+ });
+ }
+ if room.is_screen_sharing() {
+ self.entries.push(ListEntry::ParticipantScreen {
+ peer_id: None,
+ is_last: true,
});
}
}
@@ -880,7 +900,7 @@ impl CollabPanel {
}
if !participant.video_tracks.is_empty() {
self.entries.push(ListEntry::ParticipantScreen {
- peer_id: participant.peer_id,
+ peer_id: Some(participant.peer_id),
is_last: true,
});
}
@@ -1225,14 +1245,18 @@ impl CollabPanel {
) -> AnyElement<Self> {
enum CallParticipant {}
enum CallParticipantTooltip {}
+ enum LeaveCallButton {}
+ enum LeaveCallTooltip {}
let collab_theme = &theme.collab_panel;
let is_current_user =
user_store.read(cx).current_user().map(|user| user.id) == Some(user.id);
- let content =
- MouseEventHandler::new::<CallParticipant, _>(user.id as usize, cx, |mouse_state, _| {
+ let content = MouseEventHandler::new::<CallParticipant, _>(
+ user.id as usize,
+ cx,
+ |mouse_state, cx| {
let style = if is_current_user {
*collab_theme
.contact_row
@@ -1268,14 +1292,32 @@ impl CollabPanel {
Label::new("Calling", collab_theme.calling_indicator.text.clone())
.contained()
.with_style(collab_theme.calling_indicator.container)
- .aligned(),
+ .aligned()
+ .into_any(),
)
} else if is_current_user {
Some(
- Label::new("You", collab_theme.calling_indicator.text.clone())
- .contained()
- .with_style(collab_theme.calling_indicator.container)
- .aligned(),
+ MouseEventHandler::new::<LeaveCallButton, _>(0, cx, |state, _| {
+ render_icon_button(
+ theme
+ .collab_panel
+ .leave_call_button
+ .style_for(is_selected, state),
+ "icons/exit.svg",
+ )
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, _, cx| {
+ Self::leave_call(cx);
+ })
+ .with_tooltip::<LeaveCallTooltip>(
+ 0,
+ "Leave call",
+ None,
+ theme.tooltip.clone(),
+ cx,
+ )
+ .into_any(),
)
} else {
None
@@ -1284,7 +1326,8 @@ impl CollabPanel {
.with_height(collab_theme.row_height)
.contained()
.with_style(style)
- });
+ },
+ );
if is_current_user || is_pending || peer_id.is_none() {
return content.into_any();
@@ -1406,7 +1449,7 @@ impl CollabPanel {
}
fn render_participant_screen(
- peer_id: PeerId,
+ peer_id: Option<PeerId>,
is_last: bool,
is_selected: bool,
theme: &theme::CollabPanel,
@@ -1421,8 +1464,8 @@ impl CollabPanel {
.unwrap_or(0.);
let tree_branch = theme.tree_branch;
- MouseEventHandler::new::<OpenSharedScreen, _>(
- peer_id.as_u64() as usize,
+ let handler = MouseEventHandler::new::<OpenSharedScreen, _>(
+ peer_id.map(|id| id.as_u64()).unwrap_or(0) as usize,
cx,
|mouse_state, cx| {
let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
@@ -1460,16 +1503,20 @@ impl CollabPanel {
.contained()
.with_style(row.container)
},
- )
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, this, cx| {
- if let Some(workspace) = this.workspace.upgrade(cx) {
- workspace.update(cx, |workspace, cx| {
- workspace.open_shared_screen(peer_id, cx)
- });
- }
- })
- .into_any()
+ );
+ if peer_id.is_none() {
+ return handler.into_any();
+ }
+ handler
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ if let Some(workspace) = this.workspace.upgrade(cx) {
+ workspace.update(cx, |workspace, cx| {
+ workspace.open_shared_screen(peer_id.unwrap(), cx)
+ });
+ }
+ })
+ .into_any()
}
fn take_editing_state(&mut self, cx: &mut ViewContext<Self>) -> bool {
@@ -1496,23 +1543,32 @@ impl CollabPanel {
enum AddChannel {}
let tooltip_style = &theme.tooltip;
+ let mut channel_link = None;
+ let mut channel_tooltip_text = None;
+ let mut channel_icon = None;
+
let text = match section {
Section::ActiveCall => {
let channel_name = iife!({
let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
- let name = self
- .channel_store
- .read(cx)
- .channel_for_id(channel_id)?
- .name
- .as_str();
+ let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
+
+ channel_link = Some(channel.link());
+ (channel_icon, channel_tooltip_text) = match channel.visibility {
+ proto::ChannelVisibility::Public => {
+ (Some("icons/public.svg"), Some("Copy public channel link."))
+ }
+ proto::ChannelVisibility::Members => {
+ (Some("icons/hash.svg"), Some("Copy private channel link."))
+ }
+ };
- Some(name)
+ Some(channel.name.as_str())
});
if let Some(name) = channel_name {
- Cow::Owned(format!("#{}", name))
+ Cow::Owned(format!("{}", name))
} else {
Cow::Borrowed("Current Call")
}
@@ -1527,28 +1583,30 @@ impl CollabPanel {
enum AddContact {}
let button = match section {
- Section::ActiveCall => Some(
+ Section::ActiveCall => channel_link.map(|channel_link| {
+ let channel_link_copy = channel_link.clone();
MouseEventHandler::new::<AddContact, _>(0, cx, |state, _| {
render_icon_button(
theme
.collab_panel
.leave_call_button
.style_for(is_selected, state),
- "icons/exit.svg",
+ "icons/link.svg",
)
})
.with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, |_, _, cx| {
- Self::leave_call(cx);
+ .on_click(MouseButton::Left, move |_, _, cx| {
+ let item = ClipboardItem::new(channel_link_copy.clone());
+ cx.write_to_clipboard(item)
})
.with_tooltip::<AddContact>(
0,
- "Leave call",
+ channel_tooltip_text.unwrap(),
None,
tooltip_style.clone(),
cx,
- ),
- ),
+ )
+ }),
Section::Contacts => Some(
MouseEventHandler::new::<LeaveCallContactList, _>(0, cx, |state, _| {
render_icon_button(
@@ -1633,6 +1691,21 @@ impl CollabPanel {
theme.collab_panel.contact_username.container.margin.left,
),
)
+ } else if let Some(channel_icon) = channel_icon {
+ Some(
+ Svg::new(channel_icon)
+ .with_color(header_style.text.color)
+ .constrained()
+ .with_max_width(icon_size)
+ .with_max_height(icon_size)
+ .aligned()
+ .constrained()
+ .with_width(icon_size)
+ .contained()
+ .with_margin_right(
+ theme.collab_panel.contact_username.container.margin.left,
+ ),
+ )
} else {
None
})
@@ -1908,6 +1981,12 @@ impl CollabPanel {
let channel_id = channel.id;
let collab_theme = &theme.collab_panel;
let has_children = self.channel_store.read(cx).has_children(channel_id);
+ let is_public = self
+ .channel_store
+ .read(cx)
+ .channel_for_id(channel_id)
+ .map(|channel| channel.visibility)
+ == Some(proto::ChannelVisibility::Public);
let other_selected =
self.selected_channel().map(|channel| channel.0.id) == Some(channel.id);
let disclosed = has_children.then(|| !self.collapsed_channels.binary_search(&path).is_ok());
@@ -1965,12 +2044,16 @@ impl CollabPanel {
Flex::<Self>::row()
.with_child(
- Svg::new("icons/hash.svg")
- .with_color(collab_theme.channel_hash.color)
- .constrained()
- .with_width(collab_theme.channel_hash.width)
- .aligned()
- .left(),
+ Svg::new(if is_public {
+ "icons/public.svg"
+ } else {
+ "icons/hash.svg"
+ })
+ .with_color(collab_theme.channel_hash.color)
+ .constrained()
+ .with_width(collab_theme.channel_hash.width)
+ .aligned()
+ .left(),
)
.with_child({
let style = collab_theme.channel_name.inactive_state();
@@ -2275,7 +2358,7 @@ impl CollabPanel {
.with_child(render_tree_branch(
tree_branch,
&row.name.text,
- true,
+ false,
vec2f(host_avatar_width, theme.row_height),
cx.font_cache(),
))
@@ -2308,6 +2391,62 @@ impl CollabPanel {
.into_any()
}
+ fn render_channel_chat(
+ &self,
+ channel_id: ChannelId,
+ theme: &theme::CollabPanel,
+ is_selected: bool,
+ ix: usize,
+ cx: &mut ViewContext<Self>,
+ ) -> AnyElement<Self> {
+ enum ChannelChat {}
+ let host_avatar_width = theme
+ .contact_avatar
+ .width
+ .or(theme.contact_avatar.height)
+ .unwrap_or(0.);
+
+ MouseEventHandler::new::<ChannelChat, _>(ix as usize, cx, |state, cx| {
+ let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state);
+ let row = theme.project_row.in_state(is_selected).style_for(state);
+
+ Flex::<Self>::row()
+ .with_child(render_tree_branch(
+ tree_branch,
+ &row.name.text,
+ true,
+ vec2f(host_avatar_width, theme.row_height),
+ cx.font_cache(),
+ ))
+ .with_child(
+ Svg::new("icons/conversations.svg")
+ .with_color(theme.channel_hash.color)
+ .constrained()
+ .with_width(theme.channel_hash.width)
+ .aligned()
+ .left(),
+ )
+ .with_child(
+ Label::new("chat", theme.channel_name.text.clone())
+ .contained()
+ .with_style(theme.channel_name.container)
+ .aligned()
+ .left()
+ .flex(1., true),
+ )
+ .constrained()
+ .with_height(theme.row_height)
+ .contained()
+ .with_style(*theme.channel_row.style_for(is_selected, state))
+ .with_padding_left(theme.channel_row.default_style().padding.left)
+ })
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.join_channel_chat(&JoinChannelChat { channel_id }, cx);
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .into_any()
+ }
+
fn render_channel_invite(
channel: Arc<Channel>,
channel_store: ModelHandle<ChannelStore>,
@@ -2771,6 +2910,9 @@ impl CollabPanel {
}
}
ListEntry::ParticipantScreen { peer_id, .. } => {
+ let Some(peer_id) = peer_id else {
+ return;
+ };
if let Some(workspace) = self.workspace.upgrade(cx) {
workspace.update(cx, |workspace, cx| {
workspace.open_shared_screen(*peer_id, cx)
@@ -3499,6 +3641,14 @@ impl PartialEq for ListEntry {
return channel_id == other_id;
}
}
+ ListEntry::ChannelChat { channel_id } => {
+ if let ListEntry::ChannelChat {
+ channel_id: other_id,
+ } = other
+ {
+ return channel_id == other_id;
+ }
+ }
ListEntry::ChannelInvite(channel_1) => {
if let ListEntry::ChannelInvite(channel_2) = other {
return channel_1.id == channel_2.id;
@@ -1,12 +1,16 @@
use channel::{ChannelId, ChannelMembership, ChannelStore};
-use client::{proto, User, UserId, UserStore};
+use client::{
+ proto::{self, ChannelRole, ChannelVisibility},
+ 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,
+ AppContext, ClipboardItem, Entity, ModelHandle, MouseState, Task, View, ViewContext,
+ ViewHandle,
};
use picker::{Picker, PickerDelegate, PickerEvent};
use std::sync::Arc;
@@ -96,11 +100,14 @@ impl ChannelModal {
let channel_id = self.channel_id;
cx.spawn(|this, mut cx| async move {
if mode == Mode::ManageMembers {
- let members = channel_store
+ let mut members = channel_store
.update(&mut cx, |channel_store, cx| {
channel_store.get_channel_member_details(channel_id, cx)
})
.await?;
+
+ members.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
+
this.update(&mut cx, |this, cx| {
this.picker
.update(cx, |picker, _| picker.delegate_mut().members = members);
@@ -182,6 +189,81 @@ impl View for ChannelModal {
.into_any()
}
+ fn render_visibility(
+ channel_id: ChannelId,
+ visibility: ChannelVisibility,
+ theme: &theme::TabbedModal,
+ cx: &mut ViewContext<ChannelModal>,
+ ) -> AnyElement<ChannelModal> {
+ enum TogglePublic {}
+
+ if visibility == ChannelVisibility::Members {
+ return Flex::row()
+ .with_child(
+ MouseEventHandler::new::<TogglePublic, _>(0, cx, move |state, _| {
+ let style = theme.visibility_toggle.style_for(state);
+ Label::new(format!("{}", "Public access: OFF"), style.text.clone())
+ .contained()
+ .with_style(style.container.clone())
+ })
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.channel_store
+ .update(cx, |channel_store, cx| {
+ channel_store.set_channel_visibility(
+ channel_id,
+ ChannelVisibility::Public,
+ cx,
+ )
+ })
+ .detach_and_log_err(cx);
+ })
+ .with_cursor_style(CursorStyle::PointingHand),
+ )
+ .into_any();
+ }
+
+ Flex::row()
+ .with_child(
+ MouseEventHandler::new::<TogglePublic, _>(0, cx, move |state, _| {
+ let style = theme.visibility_toggle.style_for(state);
+ Label::new(format!("{}", "Public access: ON"), style.text.clone())
+ .contained()
+ .with_style(style.container.clone())
+ })
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.channel_store
+ .update(cx, |channel_store, cx| {
+ channel_store.set_channel_visibility(
+ channel_id,
+ ChannelVisibility::Members,
+ cx,
+ )
+ })
+ .detach_and_log_err(cx);
+ })
+ .with_cursor_style(CursorStyle::PointingHand),
+ )
+ .with_spacing(14.0)
+ .with_child(
+ MouseEventHandler::new::<TogglePublic, _>(1, cx, move |state, _| {
+ let style = theme.channel_link.style_for(state);
+ Label::new(format!("{}", "copy link"), style.text.clone())
+ .contained()
+ .with_style(style.container.clone())
+ })
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ if let Some(channel) =
+ this.channel_store.read(cx).channel_for_id(channel_id)
+ {
+ let item = ClipboardItem::new(channel.link());
+ cx.write_to_clipboard(item);
+ }
+ })
+ .with_cursor_style(CursorStyle::PointingHand),
+ )
+ .into_any()
+ }
+
Flex::column()
.with_child(
Flex::column()
@@ -190,6 +272,7 @@ impl View for ChannelModal {
.contained()
.with_style(theme.title.container.clone()),
)
+ .with_child(render_visibility(channel.id, channel.visibility, theme, cx))
.with_child(Flex::row().with_children([
render_mode_button::<InviteMembers>(
Mode::InviteMembers,
@@ -343,9 +426,11 @@ impl PickerDelegate for ChannelModalDelegate {
}
fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
- if let Some((selected_user, admin)) = self.user_at_index(self.selected_index) {
+ if let Some((selected_user, role)) = self.user_at_index(self.selected_index) {
match self.mode {
- Mode::ManageMembers => self.show_context_menu(admin.unwrap_or(false), cx),
+ Mode::ManageMembers => {
+ self.show_context_menu(role.unwrap_or(ChannelRole::Member), cx)
+ }
Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
Some(proto::channel_member::Kind::Invitee) => {
self.remove_selected_member(cx);
@@ -373,7 +458,7 @@ impl PickerDelegate for ChannelModalDelegate {
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 (user, role) = self.user_at_index(ix).unwrap();
let request_status = self.member_status(user.id, cx);
let style = tabbed_modal
@@ -409,15 +494,25 @@ impl PickerDelegate for ChannelModalDelegate {
},
)
})
- .with_children(admin.and_then(|admin| {
- (in_manage && admin).then(|| {
+ .with_children(if in_manage && role == Some(ChannelRole::Admin) {
+ Some(
Label::new("Admin", theme.member_tag.text.clone())
.contained()
.with_style(theme.member_tag.container)
.aligned()
- .left()
- })
- }))
+ .left(),
+ )
+ } else if in_manage && role == Some(ChannelRole::Guest) {
+ Some(
+ Label::new("Guest", theme.member_tag.text.clone())
+ .contained()
+ .with_style(theme.member_tag.container)
+ .aligned()
+ .left(),
+ )
+ } else {
+ None
+ })
.with_children({
let svg = match self.mode {
Mode::ManageMembers => Some(
@@ -502,13 +597,13 @@ impl ChannelModalDelegate {
})
}
- fn user_at_index(&self, ix: usize) -> Option<(Arc<User>, Option<bool>)> {
+ fn user_at_index(&self, ix: usize) -> Option<(Arc<User>, Option<ChannelRole>)> {
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),
+ Some(channel_membership.role),
))
}),
Mode::InviteMembers => Some((self.matching_users.get(ix).cloned()?, None)),
@@ -516,17 +611,21 @@ impl ChannelModalDelegate {
}
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 (user, role) = self.user_at_index(self.selected_index)?;
+ let new_role = if role == Some(ChannelRole::Admin) {
+ ChannelRole::Member
+ } else {
+ ChannelRole::Admin
+ };
let update = self.channel_store.update(cx, |store, cx| {
- store.set_member_admin(self.channel_id, user.id, admin, cx)
+ store.set_member_role(self.channel_id, user.id, new_role, 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;
+ member.role = new_role;
}
cx.focus_self();
cx.notify();
@@ -572,25 +671,30 @@ impl ChannelModalDelegate {
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)
+ store.invite_member(self.channel_id, user.id, ChannelRole::Member, cx)
});
cx.spawn(|this, mut cx| async move {
invite_member.await?;
this.update(&mut cx, |this, cx| {
- this.delegate_mut().members.push(ChannelMembership {
+ let new_member = ChannelMembership {
user,
kind: proto::channel_member::Kind::Invitee,
- admin: false,
- });
+ role: ChannelRole::Member,
+ };
+ let members = &mut this.delegate_mut().members;
+ match members.binary_search_by_key(&new_member.sort_key(), |k| k.sort_key()) {
+ Ok(ix) | Err(ix) => members.insert(ix, new_member),
+ }
+
cx.notify();
})
})
.detach_and_log_err(cx);
}
- fn show_context_menu(&mut self, user_is_admin: bool, cx: &mut ViewContext<Picker<Self>>) {
+ fn show_context_menu(&mut self, role: ChannelRole, cx: &mut ViewContext<Picker<Self>>) {
self.context_menu.update(cx, |context_menu, cx| {
context_menu.show(
Default::default(),
@@ -598,7 +702,7 @@ impl ChannelModalDelegate {
vec![
ContextMenuItem::action("Remove", RemoveMember),
ContextMenuItem::action(
- if user_is_admin {
+ if role == ChannelRole::Admin {
"Make non-admin"
} else {
"Make admin"
@@ -19,6 +19,7 @@ settings = { path = "../settings" }
util = { path = "../util" }
theme = { path = "../theme" }
workspace = { path = "../workspace" }
+zed-actions = { path = "../zed-actions" }
[dev-dependencies]
gpui = { path = "../gpui", features = ["test-support"] }
@@ -6,8 +6,12 @@ use gpui::{
};
use picker::{Picker, PickerDelegate, PickerEvent};
use std::cmp::{self, Reverse};
-use util::ResultExt;
+use util::{
+ channel::{parse_zed_link, ReleaseChannel, RELEASE_CHANNEL},
+ ResultExt,
+};
use workspace::Workspace;
+use zed_actions::OpenZedURL;
pub fn init(cx: &mut AppContext) {
cx.add_action(toggle_command_palette);
@@ -167,13 +171,22 @@ impl PickerDelegate for CommandPaletteDelegate {
)
.await
};
- let intercept_result = cx.read(|cx| {
+ let mut intercept_result = cx.read(|cx| {
if cx.has_global::<CommandPaletteInterceptor>() {
cx.global::<CommandPaletteInterceptor>()(&query, cx)
} else {
None
}
});
+ if *RELEASE_CHANNEL == ReleaseChannel::Dev {
+ if parse_zed_link(&query).is_some() {
+ intercept_result = Some(CommandInterceptResult {
+ action: OpenZedURL { url: query.clone() }.boxed_clone(),
+ string: query.clone(),
+ positions: vec![],
+ })
+ }
+ }
if let Some(CommandInterceptResult {
action,
string,
@@ -1,5 +1,5 @@
-use collections::HashMap;
-use editor::Editor;
+use collections::{HashMap, VecDeque};
+use editor::{Editor, MoveToEnd};
use futures::{channel::mpsc, StreamExt};
use gpui::{
actions,
@@ -11,7 +11,7 @@ use gpui::{
AnyElement, AppContext, Element, Entity, ModelContext, ModelHandle, Subscription, View,
ViewContext, ViewHandle, WeakModelHandle,
};
-use language::{Buffer, LanguageServerId, LanguageServerName};
+use language::{LanguageServerId, LanguageServerName};
use lsp::IoKind;
use project::{search::SearchQuery, Project};
use std::{borrow::Cow, sync::Arc};
@@ -22,8 +22,9 @@ use workspace::{
ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceCreated,
};
-const SEND_LINE: &str = "// Send:\n";
-const RECEIVE_LINE: &str = "// Receive:\n";
+const SEND_LINE: &str = "// Send:";
+const RECEIVE_LINE: &str = "// Receive:";
+const MAX_STORED_LOG_ENTRIES: usize = 2000;
pub struct LogStore {
projects: HashMap<WeakModelHandle<Project>, ProjectState>,
@@ -36,24 +37,25 @@ struct ProjectState {
}
struct LanguageServerState {
- log_buffer: ModelHandle<Buffer>,
+ log_messages: VecDeque<String>,
rpc_state: Option<LanguageServerRpcState>,
_io_logs_subscription: Option<lsp::Subscription>,
_lsp_logs_subscription: Option<lsp::Subscription>,
}
struct LanguageServerRpcState {
- buffer: ModelHandle<Buffer>,
+ rpc_messages: VecDeque<String>,
last_message_kind: Option<MessageKind>,
}
pub struct LspLogView {
pub(crate) editor: ViewHandle<Editor>,
+ editor_subscription: Subscription,
log_store: ModelHandle<LogStore>,
current_server_id: Option<LanguageServerId>,
is_showing_rpc_trace: bool,
project: ModelHandle<Project>,
- _log_store_subscription: Subscription,
+ _log_store_subscriptions: Vec<Subscription>,
}
pub struct LspLogToolbarItemView {
@@ -122,10 +124,9 @@ impl LogStore {
io_tx,
};
cx.spawn_weak(|this, mut cx| async move {
- while let Some((project, server_id, io_kind, mut message)) = io_rx.next().await {
+ while let Some((project, server_id, io_kind, message)) = io_rx.next().await {
if let Some(this) = this.upgrade(&cx) {
this.update(&mut cx, |this, cx| {
- message.push('\n');
this.on_io(project, server_id, io_kind, &message, cx);
});
}
@@ -168,15 +169,13 @@ impl LogStore {
project: &ModelHandle<Project>,
id: LanguageServerId,
cx: &mut ModelContext<Self>,
- ) -> Option<ModelHandle<Buffer>> {
+ ) -> Option<&mut LanguageServerState> {
let project_state = self.projects.get_mut(&project.downgrade())?;
let server_state = project_state.servers.entry(id).or_insert_with(|| {
cx.notify();
LanguageServerState {
rpc_state: None,
- log_buffer: cx
- .add_model(|cx| Buffer::new(0, cx.model_id() as u64, ""))
- .clone(),
+ log_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES),
_io_logs_subscription: None,
_lsp_logs_subscription: None,
}
@@ -186,7 +185,7 @@ impl LogStore {
if let Some(server) = server.as_deref() {
if server.has_notification_handler::<lsp::notification::LogMessage>() {
// Another event wants to re-add the server that was already added and subscribed to, avoid doing it again.
- return Some(server_state.log_buffer.clone());
+ return Some(server_state);
}
}
@@ -215,7 +214,7 @@ impl LogStore {
}
})
});
- Some(server_state.log_buffer.clone())
+ Some(server_state)
}
fn add_language_server_log(
@@ -225,24 +224,26 @@ impl LogStore {
message: &str,
cx: &mut ModelContext<Self>,
) -> Option<()> {
- let buffer = match self
+ let language_server_state = match self
.projects
.get_mut(&project.downgrade())?
.servers
- .get(&id)
- .map(|state| state.log_buffer.clone())
+ .get_mut(&id)
{
- Some(existing_buffer) => existing_buffer,
+ Some(existing_state) => existing_state,
None => self.add_language_server(&project, id, cx)?,
};
- buffer.update(cx, |buffer, cx| {
- let len = buffer.len();
- let has_newline = message.ends_with("\n");
- buffer.edit([(len..len, message)], None, cx);
- if !has_newline {
- let len = buffer.len();
- buffer.edit([(len..len, "\n")], None, cx);
- }
+
+ let log_lines = &mut language_server_state.log_messages;
+ while log_lines.len() >= MAX_STORED_LOG_ENTRIES {
+ log_lines.pop_front();
+ }
+ let message = message.trim();
+ log_lines.push_back(message.to_string());
+ cx.emit(Event::NewServerLogEntry {
+ id,
+ entry: message.to_string(),
+ is_rpc: false,
});
cx.notify();
Some(())
@@ -260,46 +261,32 @@ impl LogStore {
Some(())
}
- pub fn log_buffer_for_server(
+ fn server_logs(
&self,
project: &ModelHandle<Project>,
server_id: LanguageServerId,
- ) -> Option<ModelHandle<Buffer>> {
+ ) -> Option<&VecDeque<String>> {
let weak_project = project.downgrade();
let project_state = self.projects.get(&weak_project)?;
let server_state = project_state.servers.get(&server_id)?;
- Some(server_state.log_buffer.clone())
+ Some(&server_state.log_messages)
}
fn enable_rpc_trace_for_language_server(
&mut self,
project: &ModelHandle<Project>,
server_id: LanguageServerId,
- cx: &mut ModelContext<Self>,
- ) -> Option<ModelHandle<Buffer>> {
+ ) -> Option<&mut LanguageServerRpcState> {
let weak_project = project.downgrade();
let project_state = self.projects.get_mut(&weak_project)?;
let server_state = project_state.servers.get_mut(&server_id)?;
- let rpc_state = server_state.rpc_state.get_or_insert_with(|| {
- let language = project.read(cx).languages().language_for_name("JSON");
- let buffer = cx.add_model(|cx| Buffer::new(0, cx.model_id() as u64, ""));
- cx.spawn_weak({
- let buffer = buffer.clone();
- |_, mut cx| async move {
- let language = language.await.ok();
- buffer.update(&mut cx, |buffer, cx| {
- buffer.set_language(language, cx);
- });
- }
- })
- .detach();
-
- LanguageServerRpcState {
- buffer,
+ let rpc_state = server_state
+ .rpc_state
+ .get_or_insert_with(|| LanguageServerRpcState {
+ rpc_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES),
last_message_kind: None,
- }
- });
- Some(rpc_state.buffer.clone())
+ });
+ Some(rpc_state)
}
pub fn disable_rpc_trace_for_language_server(
@@ -328,7 +315,7 @@ impl LogStore {
IoKind::StdIn => false,
IoKind::StdErr => {
let project = project.upgrade(cx)?;
- let message = format!("stderr: {}\n", message.trim());
+ let message = format!("stderr: {}", message.trim());
self.add_language_server_log(&project, language_server_id, &message, cx);
return Some(());
}
@@ -341,24 +328,37 @@ impl LogStore {
.get_mut(&language_server_id)?
.rpc_state
.as_mut()?;
- state.buffer.update(cx, |buffer, cx| {
- let kind = if is_received {
- MessageKind::Receive
- } else {
- MessageKind::Send
+ let kind = if is_received {
+ MessageKind::Receive
+ } else {
+ MessageKind::Send
+ };
+
+ let rpc_log_lines = &mut state.rpc_messages;
+ if state.last_message_kind != Some(kind) {
+ let line_before_message = match kind {
+ MessageKind::Send => SEND_LINE,
+ MessageKind::Receive => RECEIVE_LINE,
};
- if state.last_message_kind != Some(kind) {
- let len = buffer.len();
- let line = match kind {
- MessageKind::Send => SEND_LINE,
- MessageKind::Receive => RECEIVE_LINE,
- };
- buffer.edit([(len..len, line)], None, cx);
- state.last_message_kind = Some(kind);
- }
- let len = buffer.len();
- buffer.edit([(len..len, message)], None, cx);
+ rpc_log_lines.push_back(line_before_message.to_string());
+ cx.emit(Event::NewServerLogEntry {
+ id: language_server_id,
+ entry: line_before_message.to_string(),
+ is_rpc: true,
+ });
+ }
+
+ while rpc_log_lines.len() >= MAX_STORED_LOG_ENTRIES {
+ rpc_log_lines.pop_front();
+ }
+ let message = message.trim();
+ rpc_log_lines.push_back(message.to_string());
+ cx.emit(Event::NewServerLogEntry {
+ id: language_server_id,
+ entry: message.to_string(),
+ is_rpc: true,
});
+ cx.notify();
Some(())
}
}
@@ -374,8 +374,7 @@ impl LspLogView {
.projects
.get(&project.downgrade())
.and_then(|project| project.servers.keys().copied().next());
- let buffer = cx.add_model(|cx| Buffer::new(0, cx.model_id() as u64, ""));
- let _log_store_subscription = cx.observe(&log_store, |this, store, cx| {
+ let model_changes_subscription = cx.observe(&log_store, |this, store, cx| {
(|| -> Option<()> {
let project_state = store.read(cx).projects.get(&this.project.downgrade())?;
if let Some(current_lsp) = this.current_server_id {
@@ -411,13 +410,31 @@ impl LspLogView {
cx.notify();
});
+ let events_subscriptions = cx.subscribe(&log_store, |log_view, _, e, cx| match e {
+ Event::NewServerLogEntry { id, entry, is_rpc } => {
+ if log_view.current_server_id == Some(*id) {
+ if (*is_rpc && log_view.is_showing_rpc_trace)
+ || (!*is_rpc && !log_view.is_showing_rpc_trace)
+ {
+ log_view.editor.update(cx, |editor, cx| {
+ editor.set_read_only(false);
+ editor.handle_input(entry.trim(), cx);
+ editor.handle_input("\n", cx);
+ editor.set_read_only(true);
+ });
+ }
+ }
+ }
+ });
+ let (editor, editor_subscription) = Self::editor_for_logs(String::new(), cx);
let mut this = Self {
- editor: Self::editor_for_buffer(project.clone(), buffer, cx),
+ editor,
+ editor_subscription,
project,
log_store,
current_server_id: None,
is_showing_rpc_trace: false,
- _log_store_subscription,
+ _log_store_subscriptions: vec![model_changes_subscription, events_subscriptions],
};
if let Some(server_id) = server_id {
this.show_logs_for_server(server_id, cx);
@@ -425,20 +442,19 @@ impl LspLogView {
this
}
- fn editor_for_buffer(
- project: ModelHandle<Project>,
- buffer: ModelHandle<Buffer>,
+ fn editor_for_logs(
+ log_contents: String,
cx: &mut ViewContext<Self>,
- ) -> ViewHandle<Editor> {
+ ) -> (ViewHandle<Editor>, Subscription) {
let editor = cx.add_view(|cx| {
- let mut editor = Editor::for_buffer(buffer, Some(project), cx);
+ let mut editor = Editor::multi_line(None, cx);
+ editor.set_text(log_contents, cx);
+ editor.move_to_end(&MoveToEnd, cx);
editor.set_read_only(true);
- editor.move_to_end(&Default::default(), cx);
editor
});
- cx.subscribe(&editor, |_, _, event, cx| cx.emit(event.clone()))
- .detach();
- editor
+ let editor_subscription = cx.subscribe(&editor, |_, _, event, cx| cx.emit(event.clone()));
+ (editor, editor_subscription)
}
pub(crate) fn menu_items<'a>(&'a self, cx: &'a AppContext) -> Option<Vec<LogMenuItem>> {
@@ -487,14 +503,17 @@ impl LspLogView {
}
fn show_logs_for_server(&mut self, server_id: LanguageServerId, cx: &mut ViewContext<Self>) {
- let buffer = self
+ let log_contents = self
.log_store
.read(cx)
- .log_buffer_for_server(&self.project, server_id);
- if let Some(buffer) = buffer {
+ .server_logs(&self.project, server_id)
+ .map(log_contents);
+ if let Some(log_contents) = log_contents {
self.current_server_id = Some(server_id);
self.is_showing_rpc_trace = false;
- self.editor = Self::editor_for_buffer(self.project.clone(), buffer, cx);
+ let (editor, editor_subscription) = Self::editor_for_logs(log_contents, cx);
+ self.editor = editor;
+ self.editor_subscription = editor_subscription;
cx.notify();
}
}
@@ -504,13 +523,37 @@ impl LspLogView {
server_id: LanguageServerId,
cx: &mut ViewContext<Self>,
) {
- let buffer = self.log_store.update(cx, |log_set, cx| {
- log_set.enable_rpc_trace_for_language_server(&self.project, server_id, cx)
+ let rpc_log = self.log_store.update(cx, |log_store, _| {
+ log_store
+ .enable_rpc_trace_for_language_server(&self.project, server_id)
+ .map(|state| log_contents(&state.rpc_messages))
});
- if let Some(buffer) = buffer {
+ if let Some(rpc_log) = rpc_log {
self.current_server_id = Some(server_id);
self.is_showing_rpc_trace = true;
- self.editor = Self::editor_for_buffer(self.project.clone(), buffer, cx);
+ let (editor, editor_subscription) = Self::editor_for_logs(rpc_log, cx);
+ let language = self.project.read(cx).languages().language_for_name("JSON");
+ editor
+ .read(cx)
+ .buffer()
+ .read(cx)
+ .as_singleton()
+ .expect("log buffer should be a singleton")
+ .update(cx, |_, cx| {
+ cx.spawn_weak({
+ let buffer = cx.handle();
+ |_, mut cx| async move {
+ let language = language.await.ok();
+ buffer.update(&mut cx, |buffer, cx| {
+ buffer.set_language(language, cx);
+ });
+ }
+ })
+ .detach();
+ });
+
+ self.editor = editor;
+ self.editor_subscription = editor_subscription;
cx.notify();
}
}
@@ -523,7 +566,7 @@ impl LspLogView {
) {
self.log_store.update(cx, |log_store, cx| {
if enabled {
- log_store.enable_rpc_trace_for_language_server(&self.project, server_id, cx);
+ log_store.enable_rpc_trace_for_language_server(&self.project, server_id);
} else {
log_store.disable_rpc_trace_for_language_server(&self.project, server_id, cx);
}
@@ -535,6 +578,16 @@ impl LspLogView {
}
}
+fn log_contents(lines: &VecDeque<String>) -> String {
+ let (a, b) = lines.as_slices();
+ let log_contents = a.join("\n");
+ if b.is_empty() {
+ log_contents
+ } else {
+ log_contents + "\n" + &b.join("\n")
+ }
+}
+
impl View for LspLogView {
fn ui_name() -> &'static str {
"LspLogView"
@@ -685,6 +738,7 @@ impl View for LspLogToolbarItemView {
});
let server_selected = current_server.is_some();
+ enum LspLogScroll {}
enum Menu {}
let lsp_menu = Stack::new()
.with_child(Self::render_language_server_menu_header(
@@ -697,7 +751,7 @@ impl View for LspLogToolbarItemView {
Overlay::new(
MouseEventHandler::new::<Menu, _>(0, cx, move |_, cx| {
Flex::column()
- .scrollable::<Self>(0, None, cx)
+ .scrollable::<LspLogScroll>(0, None, cx)
.with_children(menu_rows.into_iter().map(|row| {
Self::render_language_server_menu_item(
row.server_id,
@@ -876,6 +930,7 @@ impl LspLogToolbarItemView {
) -> impl Element<Self> {
enum ActivateLog {}
enum ActivateRpcTrace {}
+ enum LanguageServerCheckbox {}
Flex::column()
.with_child({
@@ -921,7 +976,7 @@ impl LspLogToolbarItemView {
.with_height(theme.toolbar_dropdown_menu.row_height),
)
.with_child(
- ui::checkbox_with_label::<Self, _, Self, _>(
+ ui::checkbox_with_label::<LanguageServerCheckbox, _, Self, _>(
Empty::new(),
&theme.welcome.checkbox,
rpc_trace_enabled,
@@ -947,8 +1002,16 @@ impl LspLogToolbarItemView {
}
}
+pub enum Event {
+ NewServerLogEntry {
+ id: LanguageServerId,
+ entry: String,
+ is_rpc: bool,
+ },
+}
+
impl Entity for LogStore {
- type Event = ();
+ type Event = Event;
}
impl Entity for LspLogView {
@@ -2027,11 +2027,16 @@ impl LocalSnapshot {
fn ignore_stack_for_abs_path(&self, abs_path: &Path, is_dir: bool) -> Arc<IgnoreStack> {
let mut new_ignores = Vec::new();
- for ancestor in abs_path.ancestors().skip(1) {
- if let Some((ignore, _)) = self.ignores_by_parent_abs_path.get(ancestor) {
- new_ignores.push((ancestor, Some(ignore.clone())));
- } else {
- new_ignores.push((ancestor, None));
+ for (index, ancestor) in abs_path.ancestors().enumerate() {
+ if index > 0 {
+ if let Some((ignore, _)) = self.ignores_by_parent_abs_path.get(ancestor) {
+ new_ignores.push((ancestor, Some(ignore.clone())));
+ } else {
+ new_ignores.push((ancestor, None));
+ }
+ }
+ if ancestor.join(&*DOT_GIT).is_dir() {
+ break;
}
}
@@ -2048,7 +2053,6 @@ impl LocalSnapshot {
if ignore_stack.is_abs_path_ignored(abs_path, is_dir) {
ignore_stack = IgnoreStack::all();
}
-
ignore_stack
}
@@ -3064,14 +3068,21 @@ impl BackgroundScanner {
// Populate ignores above the root.
let root_abs_path = self.state.lock().snapshot.abs_path.clone();
- for ancestor in root_abs_path.ancestors().skip(1) {
- if let Ok(ignore) = build_gitignore(&ancestor.join(&*GITIGNORE), self.fs.as_ref()).await
- {
- self.state
- .lock()
- .snapshot
- .ignores_by_parent_abs_path
- .insert(ancestor.into(), (ignore.into(), false));
+ for (index, ancestor) in root_abs_path.ancestors().enumerate() {
+ if index != 0 {
+ if let Ok(ignore) =
+ build_gitignore(&ancestor.join(&*GITIGNORE), self.fs.as_ref()).await
+ {
+ self.state
+ .lock()
+ .snapshot
+ .ignores_by_parent_abs_path
+ .insert(ancestor.into(), (ignore.into(), false));
+ }
+ }
+ if ancestor.join(&*DOT_GIT).is_dir() {
+ // Reached root of git repository.
+ break;
}
}
@@ -146,7 +146,7 @@ message Envelope {
DeleteChannel delete_channel = 120;
GetChannelMembers get_channel_members = 121;
GetChannelMembersResponse get_channel_members_response = 122;
- SetChannelMemberAdmin set_channel_member_admin = 123;
+ SetChannelMemberRole set_channel_member_role = 123;
RenameChannel rename_channel = 124;
RenameChannelResponse rename_channel_response = 125;
@@ -174,12 +174,13 @@ message Envelope {
LinkChannel link_channel = 145;
UnlinkChannel unlink_channel = 146;
MoveChannel move_channel = 147;
+ SetChannelVisibility set_channel_visibility = 148;
- NewNotification new_notification = 148;
- GetNotifications get_notifications = 149;
- GetNotificationsResponse get_notifications_response = 150;
- DeleteNotification delete_notification = 151;
- MarkNotificationsRead mark_notifications_read = 152; // Current max
+ NewNotification new_notification = 149;
+ GetNotifications get_notifications = 150;
+ GetNotificationsResponse get_notifications_response = 151;
+ DeleteNotification delete_notification = 152;
+ MarkNotificationsRead mark_notifications_read = 153; // Current max
}
}
@@ -999,7 +1000,7 @@ message ChannelEdge {
message ChannelPermission {
uint64 channel_id = 1;
- bool is_admin = 2;
+ ChannelRole role = 3;
}
message ChannelParticipants {
@@ -1025,8 +1026,8 @@ message GetChannelMembersResponse {
message ChannelMember {
uint64 user_id = 1;
- bool admin = 2;
Kind kind = 3;
+ ChannelRole role = 4;
enum Kind {
Member = 0;
@@ -1048,7 +1049,7 @@ message CreateChannelResponse {
message InviteChannelMember {
uint64 channel_id = 1;
uint64 user_id = 2;
- bool admin = 3;
+ ChannelRole role = 4;
}
message RemoveChannelMember {
@@ -1056,10 +1057,22 @@ message RemoveChannelMember {
uint64 user_id = 2;
}
-message SetChannelMemberAdmin {
+enum ChannelRole {
+ Admin = 0;
+ Member = 1;
+ Guest = 2;
+ Banned = 3;
+}
+
+message SetChannelMemberRole {
uint64 channel_id = 1;
uint64 user_id = 2;
- bool admin = 3;
+ ChannelRole role = 3;
+}
+
+message SetChannelVisibility {
+ uint64 channel_id = 1;
+ ChannelVisibility visibility = 2;
}
message RenameChannel {
@@ -1563,9 +1576,15 @@ message Nonce {
uint64 lower_half = 2;
}
+enum ChannelVisibility {
+ Public = 0;
+ Members = 1;
+}
+
message Channel {
uint64 id = 1;
string name = 2;
+ ChannelVisibility visibility = 3;
}
message Contact {
@@ -249,11 +249,12 @@ messages!(
(RespondToContactRequest, Foreground),
(RoomUpdated, Foreground),
(SaveBuffer, Foreground),
+ (SetChannelMemberRole, Foreground),
+ (SetChannelVisibility, Foreground),
(SearchProject, Background),
(SearchProjectResponse, Background),
(SendChannelMessage, Background),
(SendChannelMessageResponse, Background),
- (SetChannelMemberAdmin, Foreground),
(ShareProject, Foreground),
(ShareProjectResponse, Foreground),
(ShowContacts, Foreground),
@@ -356,7 +357,8 @@ request_messages!(
(SaveBuffer, BufferSaved),
(SearchProject, SearchProjectResponse),
(SendChannelMessage, SendChannelMessageResponse),
- (SetChannelMemberAdmin, Ack),
+ (SetChannelMemberRole, Ack),
+ (SetChannelVisibility, Ack),
(ShareProject, ShareProjectResponse),
(SynchronizeBuffers, SynchronizeBuffersResponse),
(Test, Test),
@@ -537,6 +537,7 @@ impl BufferSearchBar {
self.active_searchable_item
.as_ref()
.map(|searchable_item| searchable_item.query_suggestion(cx))
+ .filter(|suggestion| !suggestion.is_empty())
}
pub fn set_replacement(&mut self, replacement: Option<&str>, cx: &mut ViewContext<Self>) {
@@ -287,6 +287,8 @@ pub struct TabbedModal {
pub header: ContainerStyle,
pub body: ContainerStyle,
pub title: ContainedText,
+ pub visibility_toggle: Interactive<ContainedText>,
+ pub channel_link: Interactive<ContainedText>,
pub picker: Picker,
pub max_height: f32,
pub max_width: f32,
@@ -1216,6 +1218,15 @@ pub struct InlineAssistantStyle {
pub disabled_editor: FieldEditor,
pub pending_edit_background: Color,
pub include_conversation: ToggleIconButtonStyle,
+ pub retrieve_context: ToggleIconButtonStyle,
+ pub context_status: ContextStatusStyle,
+}
+
+#[derive(Clone, Deserialize, Default, JsonSchema)]
+pub struct ContextStatusStyle {
+ pub error_icon: Icon,
+ pub in_progress_icon: Icon,
+ pub complete_icon: Icon,
}
#[derive(Clone, Deserialize, Default, JsonSchema)]
@@ -289,6 +289,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
cx.add_global_action(restart);
cx.add_async_action(Workspace::save_all);
cx.add_action(Workspace::add_folder_to_project);
+
cx.add_action(
|workspace: &mut Workspace, _: &Unfollow, cx: &mut ViewContext<Workspace>| {
let pane = workspace.active_pane().clone();
@@ -8,3 +8,4 @@ publish = false
[dependencies]
gpui = { path = "../gpui" }
+serde.workspace = true
@@ -1,4 +1,7 @@
-use gpui::actions;
+use std::sync::Arc;
+
+use gpui::{actions, impl_actions};
+use serde::Deserialize;
actions!(
zed,
@@ -26,3 +29,13 @@ actions!(
ResetDatabase,
]
);
+
+#[derive(Deserialize, Clone, PartialEq)]
+pub struct OpenBrowser {
+ pub url: Arc<str>,
+}
+#[derive(Deserialize, Clone, PartialEq)]
+pub struct OpenZedURL {
+ pub url: String,
+}
+impl_actions!(zed, [OpenBrowser, OpenZedURL]);
@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathansobo@gmail.com>"]
description = "The fast, collaborative code editor."
edition = "2021"
name = "zed"
-version = "0.109.0"
+version = "0.110.0"
publish = false
[lib]
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
+ <key>com.apple.developer.associated-domains</key>
+ <array><string>applinks:zed.dev</string></array>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
@@ -10,14 +12,8 @@
<true/>
<key>com.apple.security.device.camera</key>
<true/>
- <key>com.apple.security.personal-information.addressbook</key>
- <true/>
- <key>com.apple.security.personal-information.calendars</key>
- <true/>
- <key>com.apple.security.personal-information.location</key>
- <true/>
- <key>com.apple.security.personal-information.photos-library</key>
- <true/>
+ <key>com.apple.security.keychain-access-groups</key>
+ <array><string>MQ55VZLNZQ.dev.zed.Shared</string></array>
<!-- <key>com.apple.security.cs.disable-library-validation</key>
<true/> -->
</dict>
@@ -3,22 +3,16 @@
use anyhow::{anyhow, Context, Result};
use backtrace::Backtrace;
-use cli::{
- ipc::{self, IpcSender},
- CliRequest, CliResponse, IpcHandshake, FORCE_CLI_MODE_ENV_VAR_NAME,
-};
+use cli::FORCE_CLI_MODE_ENV_VAR_NAME;
use client::{
self, Client, TelemetrySettings, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN,
};
use db::kvp::KEY_VALUE_STORE;
-use editor::{scroll::autoscroll::Autoscroll, Editor};
-use futures::{
- channel::{mpsc, oneshot},
- FutureExt, SinkExt, StreamExt,
-};
+use editor::Editor;
+use futures::StreamExt;
use gpui::{Action, App, AppContext, AssetSource, AsyncAppContext, Task};
use isahc::{config::Configurable, Request};
-use language::{LanguageRegistry, Point};
+use language::LanguageRegistry;
use log::LevelFilter;
use node_runtime::RealNodeRuntime;
use parking_lot::Mutex;
@@ -28,7 +22,6 @@ use settings::{default_settings, handle_settings_file_changes, watch_config_file
use simplelog::ConfigBuilder;
use smol::process::Command;
use std::{
- collections::HashMap,
env,
ffi::OsStr,
fs::OpenOptions,
@@ -42,11 +35,9 @@ use std::{
thread,
time::{Duration, SystemTime, UNIX_EPOCH},
};
-use sum_tree::Bias;
use util::{
channel::{parse_zed_link, ReleaseChannel},
http::{self, HttpClient},
- paths::PathLikeWithPosition,
};
use uuid::Uuid;
use welcome::{show_welcome_experience, FIRST_OPEN};
@@ -58,12 +49,9 @@ use zed::{
assets::Assets,
build_window_options, handle_keymap_file_changes, initialize_workspace, languages, menus,
only_instance::{ensure_only_instance, IsOnlyInstance},
+ open_listener::{handle_cli_connection, OpenListener, OpenRequest},
};
-use crate::open_listener::{OpenListener, OpenRequest};
-
-mod open_listener;
-
fn main() {
let http = http::client();
init_paths();
@@ -113,6 +101,7 @@ fn main() {
app.run(move |cx| {
cx.set_global(*RELEASE_CHANNEL);
+ cx.set_global(listener.clone());
let mut store = SettingsStore::default();
store
@@ -736,189 +725,6 @@ async fn watch_languages(_: Arc<dyn Fs>, _: Arc<LanguageRegistry>) -> Option<()>
#[cfg(not(debug_assertions))]
fn watch_file_types(_fs: Arc<dyn Fs>, _cx: &mut AppContext) {}
-fn connect_to_cli(
- server_name: &str,
-) -> Result<(mpsc::Receiver<CliRequest>, IpcSender<CliResponse>)> {
- let handshake_tx = cli::ipc::IpcSender::<IpcHandshake>::connect(server_name.to_string())
- .context("error connecting to cli")?;
- let (request_tx, request_rx) = ipc::channel::<CliRequest>()?;
- let (response_tx, response_rx) = ipc::channel::<CliResponse>()?;
-
- handshake_tx
- .send(IpcHandshake {
- requests: request_tx,
- responses: response_rx,
- })
- .context("error sending ipc handshake")?;
-
- let (mut async_request_tx, async_request_rx) =
- futures::channel::mpsc::channel::<CliRequest>(16);
- thread::spawn(move || {
- while let Ok(cli_request) = request_rx.recv() {
- if smol::block_on(async_request_tx.send(cli_request)).is_err() {
- break;
- }
- }
- Ok::<_, anyhow::Error>(())
- });
-
- Ok((async_request_rx, response_tx))
-}
-
-async fn handle_cli_connection(
- (mut requests, responses): (mpsc::Receiver<CliRequest>, IpcSender<CliResponse>),
- app_state: Arc<AppState>,
- mut cx: AsyncAppContext,
-) {
- if let Some(request) = requests.next().await {
- match request {
- CliRequest::Open { paths, wait } => {
- let mut caret_positions = HashMap::new();
-
- let paths = if paths.is_empty() {
- workspace::last_opened_workspace_paths()
- .await
- .map(|location| location.paths().to_vec())
- .unwrap_or_default()
- } else {
- paths
- .into_iter()
- .filter_map(|path_with_position_string| {
- let path_with_position = PathLikeWithPosition::parse_str(
- &path_with_position_string,
- |path_str| {
- Ok::<_, std::convert::Infallible>(
- Path::new(path_str).to_path_buf(),
- )
- },
- )
- .expect("Infallible");
- let path = path_with_position.path_like;
- if let Some(row) = path_with_position.row {
- if path.is_file() {
- let row = row.saturating_sub(1);
- let col =
- path_with_position.column.unwrap_or(0).saturating_sub(1);
- caret_positions.insert(path.clone(), Point::new(row, col));
- }
- }
- Some(path)
- })
- .collect()
- };
-
- let mut errored = false;
- match cx
- .update(|cx| workspace::open_paths(&paths, &app_state, None, cx))
- .await
- {
- Ok((workspace, items)) => {
- let mut item_release_futures = Vec::new();
-
- for (item, path) in items.into_iter().zip(&paths) {
- match item {
- Some(Ok(item)) => {
- if let Some(point) = caret_positions.remove(path) {
- if let Some(active_editor) = item.downcast::<Editor>() {
- active_editor
- .downgrade()
- .update(&mut cx, |editor, cx| {
- let snapshot =
- editor.snapshot(cx).display_snapshot;
- let point = snapshot
- .buffer_snapshot
- .clip_point(point, Bias::Left);
- editor.change_selections(
- Some(Autoscroll::center()),
- cx,
- |s| s.select_ranges([point..point]),
- );
- })
- .log_err();
- }
- }
-
- let released = oneshot::channel();
- cx.update(|cx| {
- item.on_release(
- cx,
- Box::new(move |_| {
- let _ = released.0.send(());
- }),
- )
- .detach();
- });
- item_release_futures.push(released.1);
- }
- Some(Err(err)) => {
- responses
- .send(CliResponse::Stderr {
- message: format!("error opening {:?}: {}", path, err),
- })
- .log_err();
- errored = true;
- }
- None => {}
- }
- }
-
- if wait {
- let background = cx.background();
- let wait = async move {
- if paths.is_empty() {
- let (done_tx, done_rx) = oneshot::channel();
- if let Some(workspace) = workspace.upgrade(&cx) {
- let _subscription = cx.update(|cx| {
- cx.observe_release(&workspace, move |_, _| {
- let _ = done_tx.send(());
- })
- });
- drop(workspace);
- let _ = done_rx.await;
- }
- } else {
- let _ =
- futures::future::try_join_all(item_release_futures).await;
- };
- }
- .fuse();
- futures::pin_mut!(wait);
-
- loop {
- // Repeatedly check if CLI is still open to avoid wasting resources
- // waiting for files or workspaces to close.
- let mut timer = background.timer(Duration::from_secs(1)).fuse();
- futures::select_biased! {
- _ = wait => break,
- _ = timer => {
- if responses.send(CliResponse::Ping).is_err() {
- break;
- }
- }
- }
- }
- }
- }
- Err(error) => {
- errored = true;
- responses
- .send(CliResponse::Stderr {
- message: format!("error opening {:?}: {}", paths, error),
- })
- .log_err();
- }
- }
-
- responses
- .send(CliResponse::Exit {
- status: i32::from(errored),
- })
- .log_err();
- }
- }
- }
-}
-
pub fn background_actions() -> &'static [(&'static str, &'static dyn Action)] {
&[
("Go to file", &file_finder::Toggle),
@@ -1,15 +1,26 @@
-use anyhow::anyhow;
+use anyhow::{anyhow, Context, Result};
+use cli::{ipc, IpcHandshake};
use cli::{ipc::IpcSender, CliRequest, CliResponse};
-use futures::channel::mpsc;
+use editor::scroll::autoscroll::Autoscroll;
+use editor::Editor;
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
+use futures::channel::{mpsc, oneshot};
+use futures::{FutureExt, SinkExt, StreamExt};
+use gpui::AsyncAppContext;
+use language::{Bias, Point};
+use std::collections::HashMap;
use std::ffi::OsStr;
use std::os::unix::prelude::OsStrExt;
+use std::path::Path;
use std::sync::atomic::Ordering;
+use std::sync::Arc;
+use std::thread;
+use std::time::Duration;
use std::{path::PathBuf, sync::atomic::AtomicBool};
use util::channel::parse_zed_link;
+use util::paths::PathLikeWithPosition;
use util::ResultExt;
-
-use crate::connect_to_cli;
+use workspace::AppState;
pub enum OpenRequest {
Paths {
@@ -96,3 +107,186 @@ impl OpenListener {
Some(OpenRequest::Paths { paths })
}
}
+
+fn connect_to_cli(
+ server_name: &str,
+) -> Result<(mpsc::Receiver<CliRequest>, IpcSender<CliResponse>)> {
+ let handshake_tx = cli::ipc::IpcSender::<IpcHandshake>::connect(server_name.to_string())
+ .context("error connecting to cli")?;
+ let (request_tx, request_rx) = ipc::channel::<CliRequest>()?;
+ let (response_tx, response_rx) = ipc::channel::<CliResponse>()?;
+
+ handshake_tx
+ .send(IpcHandshake {
+ requests: request_tx,
+ responses: response_rx,
+ })
+ .context("error sending ipc handshake")?;
+
+ let (mut async_request_tx, async_request_rx) =
+ futures::channel::mpsc::channel::<CliRequest>(16);
+ thread::spawn(move || {
+ while let Ok(cli_request) = request_rx.recv() {
+ if smol::block_on(async_request_tx.send(cli_request)).is_err() {
+ break;
+ }
+ }
+ Ok::<_, anyhow::Error>(())
+ });
+
+ Ok((async_request_rx, response_tx))
+}
+
+pub async fn handle_cli_connection(
+ (mut requests, responses): (mpsc::Receiver<CliRequest>, IpcSender<CliResponse>),
+ app_state: Arc<AppState>,
+ mut cx: AsyncAppContext,
+) {
+ if let Some(request) = requests.next().await {
+ match request {
+ CliRequest::Open { paths, wait } => {
+ let mut caret_positions = HashMap::new();
+
+ let paths = if paths.is_empty() {
+ workspace::last_opened_workspace_paths()
+ .await
+ .map(|location| location.paths().to_vec())
+ .unwrap_or_default()
+ } else {
+ paths
+ .into_iter()
+ .filter_map(|path_with_position_string| {
+ let path_with_position = PathLikeWithPosition::parse_str(
+ &path_with_position_string,
+ |path_str| {
+ Ok::<_, std::convert::Infallible>(
+ Path::new(path_str).to_path_buf(),
+ )
+ },
+ )
+ .expect("Infallible");
+ let path = path_with_position.path_like;
+ if let Some(row) = path_with_position.row {
+ if path.is_file() {
+ let row = row.saturating_sub(1);
+ let col =
+ path_with_position.column.unwrap_or(0).saturating_sub(1);
+ caret_positions.insert(path.clone(), Point::new(row, col));
+ }
+ }
+ Some(path)
+ })
+ .collect()
+ };
+
+ let mut errored = false;
+ match cx
+ .update(|cx| workspace::open_paths(&paths, &app_state, None, cx))
+ .await
+ {
+ Ok((workspace, items)) => {
+ let mut item_release_futures = Vec::new();
+
+ for (item, path) in items.into_iter().zip(&paths) {
+ match item {
+ Some(Ok(item)) => {
+ if let Some(point) = caret_positions.remove(path) {
+ if let Some(active_editor) = item.downcast::<Editor>() {
+ active_editor
+ .downgrade()
+ .update(&mut cx, |editor, cx| {
+ let snapshot =
+ editor.snapshot(cx).display_snapshot;
+ let point = snapshot
+ .buffer_snapshot
+ .clip_point(point, Bias::Left);
+ editor.change_selections(
+ Some(Autoscroll::center()),
+ cx,
+ |s| s.select_ranges([point..point]),
+ );
+ })
+ .log_err();
+ }
+ }
+
+ let released = oneshot::channel();
+ cx.update(|cx| {
+ item.on_release(
+ cx,
+ Box::new(move |_| {
+ let _ = released.0.send(());
+ }),
+ )
+ .detach();
+ });
+ item_release_futures.push(released.1);
+ }
+ Some(Err(err)) => {
+ responses
+ .send(CliResponse::Stderr {
+ message: format!("error opening {:?}: {}", path, err),
+ })
+ .log_err();
+ errored = true;
+ }
+ None => {}
+ }
+ }
+
+ if wait {
+ let background = cx.background();
+ let wait = async move {
+ if paths.is_empty() {
+ let (done_tx, done_rx) = oneshot::channel();
+ if let Some(workspace) = workspace.upgrade(&cx) {
+ let _subscription = cx.update(|cx| {
+ cx.observe_release(&workspace, move |_, _| {
+ let _ = done_tx.send(());
+ })
+ });
+ drop(workspace);
+ let _ = done_rx.await;
+ }
+ } else {
+ let _ =
+ futures::future::try_join_all(item_release_futures).await;
+ };
+ }
+ .fuse();
+ futures::pin_mut!(wait);
+
+ loop {
+ // Repeatedly check if CLI is still open to avoid wasting resources
+ // waiting for files or workspaces to close.
+ let mut timer = background.timer(Duration::from_secs(1)).fuse();
+ futures::select_biased! {
+ _ = wait => break,
+ _ = timer => {
+ if responses.send(CliResponse::Ping).is_err() {
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+ Err(error) => {
+ errored = true;
+ responses
+ .send(CliResponse::Stderr {
+ message: format!("error opening {:?}: {}", paths, error),
+ })
+ .log_err();
+ }
+ }
+
+ responses
+ .send(CliResponse::Exit {
+ status: i32::from(errored),
+ })
+ .log_err();
+ }
+ }
+ }
+}
@@ -2,6 +2,7 @@ pub mod assets;
pub mod languages;
pub mod menus;
pub mod only_instance;
+pub mod open_listener;
#[cfg(any(test, feature = "test-support"))]
pub mod test;
@@ -28,6 +29,7 @@ use gpui::{
AppContext, AsyncAppContext, Task, ViewContext, WeakViewHandle,
};
pub use lsp;
+use open_listener::OpenListener;
pub use project;
use project_panel::ProjectPanel;
use quick_action_bar::QuickActionBar;
@@ -87,6 +89,10 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
},
);
cx.add_global_action(quit);
+ cx.add_global_action(move |action: &OpenZedURL, cx| {
+ cx.global::<Arc<OpenListener>>()
+ .open_urls(vec![action.url.clone()])
+ });
cx.add_global_action(move |action: &OpenBrowser, cx| cx.platform().open_url(&action.url));
cx.add_global_action(move |_: &IncreaseBufferFontSize, cx| {
theme::adjust_font_size(cx, |size| *size += 1.0)
@@ -134,6 +134,8 @@ else
cp -R target/${target_dir}/WebRTC.framework "${app_path}/Contents/Frameworks/"
fi
+cp crates/zed/contents/$channel/embedded.provisionprofile "${app_path}/Contents/"
+
if [[ -n $MACOS_CERTIFICATE && -n $MACOS_CERTIFICATE_PASSWORD && -n $APPLE_NOTARIZATION_USERNAME && -n $APPLE_NOTARIZATION_PASSWORD ]]; then
echo "Signing bundle with Apple-issued certificate"
security create-keychain -p "$MACOS_CERTIFICATE_PASSWORD" zed.keychain || echo ""
@@ -143,14 +145,32 @@ if [[ -n $MACOS_CERTIFICATE && -n $MACOS_CERTIFICATE_PASSWORD && -n $APPLE_NOTAR
security import /tmp/zed-certificate.p12 -k zed.keychain -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
rm /tmp/zed-certificate.p12
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CERTIFICATE_PASSWORD" zed.keychain
- /usr/bin/codesign --force --deep --timestamp --options runtime --entitlements crates/zed/resources/zed.entitlements --sign "Zed Industries, Inc." "${app_path}" -v
+
+ # sequence of codesign commands modeled after this example: https://developer.apple.com/forums/thread/701514
+ /usr/bin/codesign --force --timestamp --sign "Zed Industries, Inc." "${app_path}/Contents/Frameworks/WebRTC.framework" -v
+ /usr/bin/codesign --force --timestamp --options runtime --sign "Zed Industries, Inc." "${app_path}/Contents/MacOS/cli" -v
+ /usr/bin/codesign --force --timestamp --options runtime --entitlements crates/zed/resources/zed.entitlements --sign "Zed Industries, Inc." "${app_path}" -v
+
security default-keychain -s login.keychain
else
echo "One or more of the following variables are missing: MACOS_CERTIFICATE, MACOS_CERTIFICATE_PASSWORD, APPLE_NOTARIZATION_USERNAME, APPLE_NOTARIZATION_PASSWORD"
- echo "Performing an ad-hoc signature, but this bundle should not be distributed"
- echo "If you see 'The application cannot be opened for an unexpected reason,' you likely don't have the necessary entitlements to run the application in your signing keychain"
- echo "You will need to download a new signing key from developer.apple.com, add it to keychain, and export MACOS_SIGNING_KEY=<email address of signing key>"
- codesign --force --deep --entitlements crates/zed/resources/zed.entitlements --sign ${MACOS_SIGNING_KEY:- -} "${app_path}" -v
+ if [[ "$local_only" = false ]]; then
+ echo "To create a self-signed local build use ./scripts/build.sh -ldf"
+ exit 1
+ fi
+
+ echo "====== WARNING ======"
+ echo "This bundle is being signed without all entitlements, some features (e.g. universal links) will not work"
+ echo "====== WARNING ======"
+
+ # NOTE: if you need to test universal links you have a few paths forward:
+ # - create a PR and tag it with the `run-build-dmg` label, and download the .dmg file from there.
+ # - get a signing key for the MQ55VZLNZQ team from Nathan.
+ # - create your own signing key, and update references to MQ55VZLNZQ to your own team ID
+ # then comment out this line.
+ cat crates/zed/resources/zed.entitlements | sed '/com.apple.developer.associated-domains/,+1d' > "${app_path}/Contents/Resources/zed.entitlements"
+
+ codesign --force --deep --entitlements "${app_path}/Contents/Resources/zed.entitlements" --sign ${MACOS_SIGNING_KEY:- -} "${app_path}" -v
fi
if [[ "$target_dir" = "debug" && "$local_only" = false ]]; then
@@ -79,6 +79,80 @@ export default function assistant(): any {
},
},
pending_edit_background: background(theme.highest, "positive"),
+ context_status: {
+ error_icon: {
+ margin: { left: 8, right: 18 },
+ color: foreground(theme.highest, "negative"),
+ width: 12,
+ },
+ in_progress_icon: {
+ margin: { left: 8, right: 18 },
+ color: foreground(theme.highest, "positive"),
+ width: 12,
+ },
+ complete_icon: {
+ margin: { left: 8, right: 18 },
+ color: foreground(theme.highest, "positive"),
+ width: 12,
+ }
+ },
+ retrieve_context: toggleable({
+ base: interactive({
+ base: {
+ icon_size: 12,
+ color: foreground(theme.highest, "variant"),
+
+ button_width: 12,
+ background: background(theme.highest, "on"),
+ corner_radius: 2,
+ border: {
+ width: 1., color: background(theme.highest, "on")
+ },
+ margin: { left: 2 },
+ padding: {
+ left: 4,
+ right: 4,
+ top: 4,
+ bottom: 4,
+ },
+ },
+ state: {
+ hovered: {
+ ...text(theme.highest, "mono", "variant", "hovered"),
+ background: background(theme.highest, "on", "hovered"),
+ border: {
+ width: 1., color: background(theme.highest, "on", "hovered")
+ },
+ },
+ clicked: {
+ ...text(theme.highest, "mono", "variant", "pressed"),
+ background: background(theme.highest, "on", "pressed"),
+ border: {
+ width: 1., color: background(theme.highest, "on", "pressed")
+ },
+ },
+ },
+ }),
+ state: {
+ active: {
+ default: {
+ icon_size: 12,
+ button_width: 12,
+ color: foreground(theme.highest, "variant"),
+ background: background(theme.highest, "accent"),
+ border: border(theme.highest, "accent"),
+ },
+ hovered: {
+ background: background(theme.highest, "accent", "hovered"),
+ border: border(theme.highest, "accent", "hovered"),
+ },
+ clicked: {
+ background: background(theme.highest, "accent", "pressed"),
+ border: border(theme.highest, "accent", "pressed"),
+ },
+ },
+ },
+ }),
include_conversation: toggleable({
base: interactive({
base: {
@@ -1,10 +1,11 @@
-import { useTheme } from "../theme"
+import { StyleSets, useTheme } from "../theme"
import { background, border, foreground, text } from "./components"
import picker from "./picker"
import { input } from "../component/input"
import contact_finder from "./contact_finder"
import { tab } from "../component/tab"
import { icon_button } from "../component/icon_button"
+import { interactive } from "../element/interactive"
export default function channel_modal(): any {
const theme = useTheme()
@@ -27,6 +28,24 @@ export default function channel_modal(): any {
const picker_input = input()
+ const interactive_text = (styleset: StyleSets) =>
+ interactive({
+ base: {
+ padding: {
+ left: 8,
+ top: 8
+ },
+ ...text(theme.middle, "sans", styleset, "default"),
+ }, state: {
+ hovered: {
+ ...text(theme.middle, "sans", styleset, "hovered"),
+ },
+ clicked: {
+ ...text(theme.middle, "sans", styleset, "active"),
+ }
+ }
+ })
+
const member_icon_style = icon_button({
variant: "ghost",
size: "sm",
@@ -88,6 +107,8 @@ export default function channel_modal(): any {
left: BUTTON_OFFSET,
},
},
+ visibility_toggle: interactive_text("base"),
+ channel_link: interactive_text("accent"),
picker: {
empty_container: {},
item: {