Compare commits
25 Commits
run-comman
...
anthropic_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7682ddbafe | ||
|
|
734f94b71c | ||
|
|
136468a4df | ||
|
|
adf43d691a | ||
|
|
466a2e22d5 | ||
|
|
365c5ab45f | ||
|
|
11d81b95d4 | ||
|
|
4b3b2acf75 | ||
|
|
849424740f | ||
|
|
3e605c2c4b | ||
|
|
82b11bf77c | ||
|
|
3a437fd888 | ||
|
|
96c429d2c3 | ||
|
|
ea4073e50e | ||
|
|
8c93112869 | ||
|
|
1feffad5e8 | ||
|
|
ae54a4e1b8 | ||
|
|
4a0a7d1d27 | ||
|
|
5934d3789b | ||
|
|
acde79dae7 | ||
|
|
246c644316 | ||
|
|
e4de26e5dc | ||
|
|
7091c70a1e | ||
|
|
1884d83e6f | ||
|
|
370fe8ce23 |
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -482,6 +482,7 @@ dependencies = [
|
||||
"client",
|
||||
"cloud_llm_client",
|
||||
"component",
|
||||
"feature_flags",
|
||||
"gpui",
|
||||
"language_model",
|
||||
"serde",
|
||||
@@ -2882,11 +2883,9 @@ dependencies = [
|
||||
"language",
|
||||
"log",
|
||||
"postage",
|
||||
"rand 0.9.1",
|
||||
"release_channel",
|
||||
"rpc",
|
||||
"settings",
|
||||
"sum_tree",
|
||||
"text",
|
||||
"time",
|
||||
"util",
|
||||
@@ -3374,12 +3373,10 @@ dependencies = [
|
||||
"collections",
|
||||
"db",
|
||||
"editor",
|
||||
"emojis",
|
||||
"futures 0.3.31",
|
||||
"fuzzy",
|
||||
"gpui",
|
||||
"http_client",
|
||||
"language",
|
||||
"log",
|
||||
"menu",
|
||||
"notifications",
|
||||
@@ -3387,7 +3384,6 @@ dependencies = [
|
||||
"pretty_assertions",
|
||||
"project",
|
||||
"release_channel",
|
||||
"rich_text",
|
||||
"rpc",
|
||||
"schemars",
|
||||
"serde",
|
||||
@@ -5044,7 +5040,6 @@ dependencies = [
|
||||
"multi_buffer",
|
||||
"ordered-float 2.10.1",
|
||||
"parking_lot",
|
||||
"postage",
|
||||
"pretty_assertions",
|
||||
"project",
|
||||
"rand 0.9.1",
|
||||
@@ -20464,7 +20459,6 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"paths",
|
||||
"picker",
|
||||
"postage",
|
||||
"pretty_assertions",
|
||||
"profiling",
|
||||
"project",
|
||||
|
||||
@@ -740,16 +740,6 @@
|
||||
// Default width of the collaboration panel.
|
||||
"default_width": 240
|
||||
},
|
||||
"chat_panel": {
|
||||
// When to show the chat panel button in the status bar.
|
||||
// Can be 'never', 'always', or 'when_in_call',
|
||||
// or a boolean (interpreted as 'never'/'always').
|
||||
"button": "when_in_call",
|
||||
// Where to dock the chat panel. Can be 'left' or 'right'.
|
||||
"dock": "right",
|
||||
// Default width of the chat panel.
|
||||
"default_width": 240
|
||||
},
|
||||
"git_panel": {
|
||||
// Whether to show the git panel button in the status bar.
|
||||
"button": true,
|
||||
|
||||
@@ -1640,13 +1640,13 @@ impl AcpThread {
|
||||
cx.foreground_executor().spawn(send_task)
|
||||
}
|
||||
|
||||
/// Rewinds this thread to before the entry at `index`, removing it and all
|
||||
/// subsequent entries while reverting any changes made from that point.
|
||||
pub fn rewind(&mut self, id: UserMessageId, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
let Some(truncate) = self.connection.truncate(&self.session_id, cx) else {
|
||||
return Task::ready(Err(anyhow!("not supported")));
|
||||
};
|
||||
let Some(message) = self.user_message(&id) else {
|
||||
/// Restores the git working tree to the state at the given checkpoint (if one exists)
|
||||
pub fn restore_checkpoint(
|
||||
&mut self,
|
||||
id: UserMessageId,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let Some((_, message)) = self.user_message_mut(&id) else {
|
||||
return Task::ready(Err(anyhow!("message not found")));
|
||||
};
|
||||
|
||||
@@ -1654,15 +1654,30 @@ impl AcpThread {
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.map(|c| c.git_checkpoint.clone());
|
||||
|
||||
let rewind = self.rewind(id.clone(), cx);
|
||||
let git_store = self.project.read(cx).git_store().clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
|
||||
cx.spawn(async move |_, cx| {
|
||||
rewind.await?;
|
||||
if let Some(checkpoint) = checkpoint {
|
||||
git_store
|
||||
.update(cx, |git, cx| git.restore_checkpoint(checkpoint, cx))?
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Rewinds this thread to before the entry at `index`, removing it and all
|
||||
/// subsequent entries while rejecting any action_log changes made from that point.
|
||||
/// Unlike `restore_checkpoint`, this method does not restore from git.
|
||||
pub fn rewind(&mut self, id: UserMessageId, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
let Some(truncate) = self.connection.truncate(&self.session_id, cx) else {
|
||||
return Task::ready(Err(anyhow!("not supported")));
|
||||
};
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.update(|cx| truncate.run(id.clone(), cx))?.await?;
|
||||
this.update(cx, |this, cx| {
|
||||
if let Some((ix, _)) = this.user_message_mut(&id) {
|
||||
@@ -1670,7 +1685,11 @@ impl AcpThread {
|
||||
this.entries.truncate(ix);
|
||||
cx.emit(AcpThreadEvent::EntriesRemoved(range));
|
||||
}
|
||||
})
|
||||
this.action_log()
|
||||
.update(cx, |action_log, cx| action_log.reject_all_edits(cx))
|
||||
})?
|
||||
.await;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1727,20 +1746,6 @@ impl AcpThread {
|
||||
})
|
||||
}
|
||||
|
||||
fn user_message(&self, id: &UserMessageId) -> Option<&UserMessage> {
|
||||
self.entries.iter().find_map(|entry| {
|
||||
if let AgentThreadEntry::UserMessage(message) = entry {
|
||||
if message.id.as_ref() == Some(id) {
|
||||
Some(message)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn user_message_mut(&mut self, id: &UserMessageId) -> Option<(usize, &mut UserMessage)> {
|
||||
self.entries.iter_mut().enumerate().find_map(|(ix, entry)| {
|
||||
if let AgentThreadEntry::UserMessage(message) = entry {
|
||||
@@ -2684,7 +2689,7 @@ mod tests {
|
||||
let AgentThreadEntry::UserMessage(message) = &thread.entries[2] else {
|
||||
panic!("unexpected entries {:?}", thread.entries)
|
||||
};
|
||||
thread.rewind(message.id.clone().unwrap(), cx)
|
||||
thread.restore_checkpoint(message.id.clone().unwrap(), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -23,7 +23,7 @@ action_log.workspace = true
|
||||
agent-client-protocol.workspace = true
|
||||
agent_settings.workspace = true
|
||||
anyhow.workspace = true
|
||||
client = { workspace = true, optional = true }
|
||||
client.workspace = true
|
||||
collections.workspace = true
|
||||
env_logger = { workspace = true, optional = true }
|
||||
fs.workspace = true
|
||||
|
||||
@@ -41,6 +41,15 @@ impl AgentServer for ClaudeCode {
|
||||
let is_remote = delegate.project.read(cx).is_via_remote_server();
|
||||
let store = delegate.store.downgrade();
|
||||
|
||||
// Get the project environment variables for the root directory
|
||||
let project_env = delegate.project().update(cx, |project, cx| {
|
||||
if let Some(path) = project.active_project_directory(cx) {
|
||||
Some(project.directory_environment(path, cx))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let (command, root_dir, login) = store
|
||||
.update(cx, |store, cx| {
|
||||
|
||||
@@ -4,10 +4,12 @@ use std::{any::Any, path::Path};
|
||||
use crate::{AgentServer, AgentServerDelegate};
|
||||
use acp_thread::AgentConnection;
|
||||
use anyhow::{Context as _, Result};
|
||||
use client::ProxySettings;
|
||||
use collections::HashMap;
|
||||
use gpui::{App, SharedString, Task};
|
||||
use gpui::{App, AppContext, SharedString, Task};
|
||||
use language_models::provider::google::GoogleLanguageModelProvider;
|
||||
use project::agent_server_store::GEMINI_NAME;
|
||||
use settings::SettingsStore;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Gemini;
|
||||
@@ -35,13 +37,16 @@ impl AgentServer for Gemini {
|
||||
let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().to_string());
|
||||
let is_remote = delegate.project.read(cx).is_via_remote_server();
|
||||
let store = delegate.store.downgrade();
|
||||
let proxy_url = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings.get::<ProxySettings>(None).proxy.clone()
|
||||
});
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let mut extra_env = HashMap::default();
|
||||
if let Some(api_key) = cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() {
|
||||
extra_env.insert("GEMINI_API_KEY".into(), api_key.key);
|
||||
}
|
||||
let (command, root_dir, login) = store
|
||||
let (mut command, root_dir, login) = store
|
||||
.update(cx, |store, cx| {
|
||||
let agent = store
|
||||
.get_external_agent(&GEMINI_NAME.into())
|
||||
@@ -55,6 +60,15 @@ impl AgentServer for Gemini {
|
||||
))
|
||||
})??
|
||||
.await?;
|
||||
|
||||
// Add proxy flag if proxy settings are configured in Zed and not in the args
|
||||
if let Some(proxy_url_value) = &proxy_url
|
||||
&& !command.args.iter().any(|arg| arg.contains("--proxy"))
|
||||
{
|
||||
command.args.push("--proxy".into());
|
||||
command.args.push(proxy_url_value.clone());
|
||||
}
|
||||
|
||||
let connection =
|
||||
crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?;
|
||||
Ok((connection, login))
|
||||
|
||||
@@ -1066,13 +1066,21 @@ struct MentionCompletion {
|
||||
impl MentionCompletion {
|
||||
fn try_parse(allow_non_file_mentions: bool, line: &str, offset_to_line: usize) -> Option<Self> {
|
||||
let last_mention_start = line.rfind('@')?;
|
||||
if last_mention_start >= line.len() {
|
||||
return Some(Self::default());
|
||||
|
||||
// No whitespace immediately after '@'
|
||||
if line[last_mention_start + 1..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_whitespace())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Must be a word boundary before '@'
|
||||
if last_mention_start > 0
|
||||
&& line
|
||||
&& line[..last_mention_start]
|
||||
.chars()
|
||||
.nth(last_mention_start - 1)
|
||||
.last()
|
||||
.is_some_and(|c| !c.is_whitespace())
|
||||
{
|
||||
return None;
|
||||
@@ -1085,7 +1093,9 @@ impl MentionCompletion {
|
||||
|
||||
let mut parts = rest_of_line.split_whitespace();
|
||||
let mut end = last_mention_start + 1;
|
||||
|
||||
if let Some(mode_text) = parts.next() {
|
||||
// Safe since we check no leading whitespace above
|
||||
end += mode_text.len();
|
||||
|
||||
if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok()
|
||||
@@ -1278,5 +1288,23 @@ mod tests {
|
||||
argument: Some("main".to_string()),
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
MentionCompletion::try_parse(true, "Lorem@symbol", 0),
|
||||
None,
|
||||
"Should not parse mention inside word"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
MentionCompletion::try_parse(true, "Lorem @ file", 0),
|
||||
None,
|
||||
"Should not parse with a space after @"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
MentionCompletion::try_parse(true, "@ file", 0),
|
||||
None,
|
||||
"Should not parse with a space after @ at the start of the line"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ use agent_client_protocol as acp;
|
||||
use gpui::{Entity, FocusHandle};
|
||||
use picker::popover_menu::PickerPopoverMenu;
|
||||
use ui::{
|
||||
ButtonLike, Context, IntoElement, PopoverMenuHandle, SharedString, Tooltip, Window, prelude::*,
|
||||
ButtonLike, Context, IntoElement, PopoverMenuHandle, SharedString, TintColor, Tooltip, Window,
|
||||
prelude::*,
|
||||
};
|
||||
use zed_actions::agent::ToggleModelSelector;
|
||||
|
||||
@@ -58,15 +59,22 @@ impl Render for AcpModelSelectorPopover {
|
||||
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
|
||||
let color = if self.menu_handle.is_deployed() {
|
||||
Color::Accent
|
||||
} else {
|
||||
Color::Muted
|
||||
};
|
||||
|
||||
PickerPopoverMenu::new(
|
||||
self.selector.clone(),
|
||||
ButtonLike::new("active-model")
|
||||
.when_some(model_icon, |this, icon| {
|
||||
this.child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall))
|
||||
this.child(Icon::new(icon).color(color).size(IconSize::XSmall))
|
||||
})
|
||||
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
.child(
|
||||
Label::new(model_name)
|
||||
.color(Color::Muted)
|
||||
.color(color)
|
||||
.size(LabelSize::Small)
|
||||
.ml_0p5(),
|
||||
)
|
||||
|
||||
@@ -927,7 +927,7 @@ impl AcpThreadView {
|
||||
}
|
||||
}
|
||||
ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
|
||||
self.regenerate(event.entry_index, editor, window, cx);
|
||||
self.regenerate(event.entry_index, editor.clone(), window, cx);
|
||||
}
|
||||
ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
|
||||
self.cancel_editing(&Default::default(), window, cx);
|
||||
@@ -1151,7 +1151,7 @@ impl AcpThreadView {
|
||||
fn regenerate(
|
||||
&mut self,
|
||||
entry_ix: usize,
|
||||
message_editor: &Entity<MessageEditor>,
|
||||
message_editor: Entity<MessageEditor>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
@@ -1168,16 +1168,18 @@ impl AcpThreadView {
|
||||
return;
|
||||
};
|
||||
|
||||
let contents = message_editor.update(cx, |message_editor, cx| message_editor.contents(cx));
|
||||
|
||||
let task = cx.spawn(async move |_, cx| {
|
||||
let contents = contents.await?;
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.rewind(user_message_id, cx))?
|
||||
.await?;
|
||||
Ok(contents)
|
||||
});
|
||||
self.send_impl(task, window, cx);
|
||||
let contents =
|
||||
message_editor.update(cx, |message_editor, cx| message_editor.contents(cx))?;
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.send_impl(contents, window, cx);
|
||||
})?;
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn open_agent_diff(&mut self, _: &OpenAgentDiff, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -1635,14 +1637,16 @@ impl AcpThreadView {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn rewind(&mut self, message_id: &UserMessageId, cx: &mut Context<Self>) {
|
||||
fn restore_checkpoint(&mut self, message_id: &UserMessageId, cx: &mut Context<Self>) {
|
||||
let Some(thread) = self.thread() else {
|
||||
return;
|
||||
};
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.rewind(message_id.clone(), cx))
|
||||
.update(cx, |thread, cx| {
|
||||
thread.restore_checkpoint(message_id.clone(), cx)
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_entry(
|
||||
@@ -1712,8 +1716,9 @@ impl AcpThreadView {
|
||||
.label_size(LabelSize::XSmall)
|
||||
.icon_color(Color::Muted)
|
||||
.color(Color::Muted)
|
||||
.tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation."))
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.rewind(&message_id, cx);
|
||||
this.restore_checkpoint(&message_id, cx);
|
||||
}))
|
||||
)
|
||||
.child(Divider::horizontal())
|
||||
@@ -1784,7 +1789,7 @@ impl AcpThreadView {
|
||||
let editor = editor.clone();
|
||||
move |this, _, window, cx| {
|
||||
this.regenerate(
|
||||
entry_ix, &editor, window, cx,
|
||||
entry_ix, editor.clone(), window, cx,
|
||||
);
|
||||
}
|
||||
})).into_any_element()
|
||||
@@ -5005,6 +5010,7 @@ impl AcpThreadView {
|
||||
cloud_llm_client::Plan::ZedProTrial | cloud_llm_client::Plan::ZedFree => {
|
||||
"Upgrade to Zed Pro for more prompts."
|
||||
}
|
||||
cloud_llm_client::Plan::ZedProV2 | cloud_llm_client::Plan::ZedProTrialV2 => "",
|
||||
};
|
||||
|
||||
Callout::new()
|
||||
|
||||
@@ -516,8 +516,10 @@ impl AgentConfiguration {
|
||||
|
||||
let (plan_name, label_color, bg_color) = match plan {
|
||||
Plan::ZedFree => ("Free", Color::Default, free_chip_bg),
|
||||
Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg),
|
||||
Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg),
|
||||
Plan::ZedProTrial | Plan::ZedProTrialV2 => {
|
||||
("Pro Trial", Color::Accent, pro_chip_bg)
|
||||
}
|
||||
Plan::ZedPro | Plan::ZedProV2 => ("Pro", Color::Accent, pro_chip_bg),
|
||||
};
|
||||
|
||||
Chip::new(plan_name.to_string())
|
||||
|
||||
@@ -2538,7 +2538,7 @@ impl AgentPanel {
|
||||
}
|
||||
},
|
||||
)
|
||||
.anchor(Corner::TopLeft)
|
||||
.anchor(Corner::TopRight)
|
||||
.with_handle(self.new_thread_menu_handle.clone())
|
||||
.menu({
|
||||
let workspace = self.workspace.clone();
|
||||
@@ -3518,6 +3518,7 @@ impl AgentPanel {
|
||||
let error_message = match plan {
|
||||
Plan::ZedPro => "Upgrade to usage-based billing for more prompts.",
|
||||
Plan::ZedProTrial | Plan::ZedFree => "Upgrade to Zed Pro for more prompts.",
|
||||
Plan::ZedProV2 | Plan::ZedProTrialV2 => "",
|
||||
};
|
||||
|
||||
Callout::new()
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
use crate::agent_model_selector::AgentModelSelector;
|
||||
use crate::buffer_codegen::BufferCodegen;
|
||||
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider};
|
||||
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
|
||||
use crate::message_editor::{ContextCreasesAddon, extract_message_creases, insert_message_creases};
|
||||
use crate::terminal_codegen::TerminalCodegen;
|
||||
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext};
|
||||
use crate::{RemoveAllContext, ToggleContextPicker};
|
||||
use agent::{
|
||||
context_store::ContextStore,
|
||||
thread_store::{TextThreadStore, ThreadStore},
|
||||
};
|
||||
use client::ErrorExt;
|
||||
use collections::VecDeque;
|
||||
use db::kvp::Dismissable;
|
||||
use editor::actions::Paste;
|
||||
use editor::display_map::EditorMargins;
|
||||
use editor::{
|
||||
ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer,
|
||||
actions::{MoveDown, MoveUp},
|
||||
};
|
||||
use feature_flags::{FeatureFlagAppExt as _, ZedProFeatureFlag};
|
||||
use fs::Fs;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, Context, CursorStyle, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, FontWeight, Subscription, TextStyle, WeakEntity, Window, anchored, deferred, point,
|
||||
AnyElement, App, Context, CursorStyle, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
Subscription, TextStyle, WeakEntity, Window,
|
||||
};
|
||||
use language_model::{LanguageModel, LanguageModelRegistry};
|
||||
use parking_lot::Mutex;
|
||||
@@ -33,12 +22,19 @@ use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use theme::ThemeSettings;
|
||||
use ui::utils::WithRemSize;
|
||||
use ui::{
|
||||
CheckboxWithLabel, IconButtonShape, KeyBinding, Popover, PopoverMenuHandle, Tooltip, prelude::*,
|
||||
};
|
||||
use ui::{IconButtonShape, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*};
|
||||
use workspace::Workspace;
|
||||
use zed_actions::agent::ToggleModelSelector;
|
||||
|
||||
use crate::agent_model_selector::AgentModelSelector;
|
||||
use crate::buffer_codegen::BufferCodegen;
|
||||
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider};
|
||||
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
|
||||
use crate::message_editor::{ContextCreasesAddon, extract_message_creases, insert_message_creases};
|
||||
use crate::terminal_codegen::TerminalCodegen;
|
||||
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext};
|
||||
use crate::{RemoveAllContext, ToggleContextPicker};
|
||||
|
||||
pub struct PromptEditor<T> {
|
||||
pub editor: Entity<Editor>,
|
||||
mode: PromptEditorMode,
|
||||
@@ -144,47 +140,16 @@ impl<T: 'static> Render for PromptEditor<T> {
|
||||
};
|
||||
|
||||
let error_message = SharedString::from(error.to_string());
|
||||
if error.error_code() == proto::ErrorCode::RateLimitExceeded
|
||||
&& cx.has_flag::<ZedProFeatureFlag>()
|
||||
{
|
||||
el.child(
|
||||
v_flex()
|
||||
.child(
|
||||
IconButton::new(
|
||||
"rate-limit-error",
|
||||
IconName::XCircle,
|
||||
)
|
||||
.toggle_state(self.show_rate_limit_notice)
|
||||
.shape(IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.on_click(
|
||||
cx.listener(Self::toggle_rate_limit_notice),
|
||||
),
|
||||
)
|
||||
.children(self.show_rate_limit_notice.then(|| {
|
||||
deferred(
|
||||
anchored()
|
||||
.position_mode(
|
||||
gpui::AnchoredPositionMode::Local,
|
||||
)
|
||||
.position(point(px(0.), px(24.)))
|
||||
.anchor(gpui::Corner::TopLeft)
|
||||
.child(self.render_rate_limit_notice(cx)),
|
||||
)
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
el.child(
|
||||
div()
|
||||
.id("error")
|
||||
.tooltip(Tooltip::text(error_message))
|
||||
.child(
|
||||
Icon::new(IconName::XCircle)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Error),
|
||||
),
|
||||
)
|
||||
}
|
||||
el.child(
|
||||
div()
|
||||
.id("error")
|
||||
.tooltip(Tooltip::text(error_message))
|
||||
.child(
|
||||
Icon::new(IconName::XCircle)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Error),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
@@ -310,19 +275,6 @@ impl<T: 'static> PromptEditor<T> {
|
||||
crate::active_thread::attach_pasted_images_as_context(&self.context_store, cx);
|
||||
}
|
||||
|
||||
fn toggle_rate_limit_notice(
|
||||
&mut self,
|
||||
_: &ClickEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.show_rate_limit_notice = !self.show_rate_limit_notice;
|
||||
if self.show_rate_limit_notice {
|
||||
window.focus(&self.editor.focus_handle(cx));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn handle_prompt_editor_events(
|
||||
&mut self,
|
||||
_: &Entity<Editor>,
|
||||
@@ -707,61 +659,6 @@ impl<T: 'static> PromptEditor<T> {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_rate_limit_notice(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Popover::new().child(
|
||||
v_flex()
|
||||
.occlude()
|
||||
.p_2()
|
||||
.child(
|
||||
Label::new("Out of Tokens")
|
||||
.size(LabelSize::Small)
|
||||
.weight(FontWeight::BOLD),
|
||||
)
|
||||
.child(Label::new(
|
||||
"Try Zed Pro for higher limits, a wider range of models, and more.",
|
||||
))
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(CheckboxWithLabel::new(
|
||||
"dont-show-again",
|
||||
Label::new("Don't show again"),
|
||||
if RateLimitNotice::dismissed() {
|
||||
ui::ToggleState::Selected
|
||||
} else {
|
||||
ui::ToggleState::Unselected
|
||||
},
|
||||
|selection, _, cx| {
|
||||
let is_dismissed = match selection {
|
||||
ui::ToggleState::Unselected => false,
|
||||
ui::ToggleState::Indeterminate => return,
|
||||
ui::ToggleState::Selected => true,
|
||||
};
|
||||
|
||||
RateLimitNotice::set_dismissed(is_dismissed, cx);
|
||||
},
|
||||
))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("dismiss", "Dismiss")
|
||||
.style(ButtonStyle::Transparent)
|
||||
.on_click(cx.listener(Self::toggle_rate_limit_notice)),
|
||||
)
|
||||
.child(Button::new("more-info", "More Info").on_click(
|
||||
|_event, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(zed_actions::OpenAccountSettings),
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_editor(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let colors = cx.theme().colors();
|
||||
|
||||
@@ -978,15 +875,7 @@ impl PromptEditor<BufferCodegen> {
|
||||
self.editor
|
||||
.update(cx, |editor, _| editor.set_read_only(false));
|
||||
}
|
||||
CodegenStatus::Error(error) => {
|
||||
if cx.has_flag::<ZedProFeatureFlag>()
|
||||
&& error.error_code() == proto::ErrorCode::RateLimitExceeded
|
||||
&& !RateLimitNotice::dismissed()
|
||||
{
|
||||
self.show_rate_limit_notice = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
CodegenStatus::Error(_error) => {
|
||||
self.edited_since_done = false;
|
||||
self.editor
|
||||
.update(cx, |editor, _| editor.set_read_only(false));
|
||||
@@ -1189,12 +1078,6 @@ impl PromptEditor<TerminalCodegen> {
|
||||
}
|
||||
}
|
||||
|
||||
struct RateLimitNotice;
|
||||
|
||||
impl Dismissable for RateLimitNotice {
|
||||
const KEY: &'static str = "dismissed-rate-limit-notice";
|
||||
}
|
||||
|
||||
pub enum CodegenStatus {
|
||||
Idle,
|
||||
Pending,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use std::{cmp::Reverse, sync::Arc};
|
||||
|
||||
use cloud_llm_client::Plan;
|
||||
use collections::{HashSet, IndexMap};
|
||||
use feature_flags::ZedProFeatureFlag;
|
||||
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
|
||||
use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task};
|
||||
use language_model::{
|
||||
@@ -13,8 +11,6 @@ use ordered_float::OrderedFloat;
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use ui::{ListItem, ListItemSpacing, prelude::*};
|
||||
|
||||
const TRY_ZED_PRO_URL: &str = "https://zed.dev/pro";
|
||||
|
||||
type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
|
||||
type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
|
||||
|
||||
@@ -531,13 +527,9 @@ impl PickerDelegate for LanguageModelPickerDelegate {
|
||||
|
||||
fn render_footer(
|
||||
&self,
|
||||
_: &mut Window,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<gpui::AnyElement> {
|
||||
use feature_flags::FeatureFlagAppExt;
|
||||
|
||||
let plan = Plan::ZedPro;
|
||||
|
||||
Some(
|
||||
h_flex()
|
||||
.w_full()
|
||||
@@ -546,28 +538,6 @@ impl PickerDelegate for LanguageModelPickerDelegate {
|
||||
.p_1()
|
||||
.gap_4()
|
||||
.justify_between()
|
||||
.when(cx.has_flag::<ZedProFeatureFlag>(), |this| {
|
||||
this.child(match plan {
|
||||
Plan::ZedPro => Button::new("zed-pro", "Zed Pro")
|
||||
.icon(IconName::ZedAssistant)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.icon_position(IconPosition::Start)
|
||||
.on_click(|_, window, cx| {
|
||||
window
|
||||
.dispatch_action(Box::new(zed_actions::OpenAccountSettings), cx)
|
||||
}),
|
||||
Plan::ZedFree | Plan::ZedProTrial => Button::new(
|
||||
"try-pro",
|
||||
if plan == Plan::ZedProTrial {
|
||||
"Upgrade to Pro"
|
||||
} else {
|
||||
"Try Pro"
|
||||
},
|
||||
)
|
||||
.on_click(|_, _, cx| cx.open_url(TRY_ZED_PRO_URL)),
|
||||
})
|
||||
})
|
||||
.child(
|
||||
Button::new("configure", "Configure")
|
||||
.icon(IconName::Settings)
|
||||
|
||||
@@ -6,8 +6,8 @@ use gpui::{Action, Entity, FocusHandle, Subscription, prelude::*};
|
||||
use settings::{Settings as _, SettingsStore, update_settings_file};
|
||||
use std::sync::Arc;
|
||||
use ui::{
|
||||
ContextMenu, ContextMenuEntry, DocumentationSide, PopoverMenu, PopoverMenuHandle, Tooltip,
|
||||
prelude::*,
|
||||
ContextMenu, ContextMenuEntry, DocumentationSide, PopoverMenu, PopoverMenuHandle, TintColor,
|
||||
Tooltip, prelude::*,
|
||||
};
|
||||
|
||||
/// Trait for types that can provide and manage agent profiles
|
||||
@@ -170,7 +170,8 @@ impl Render for ProfileSelector {
|
||||
.icon(IconName::ChevronDown)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_position(IconPosition::End)
|
||||
.icon_color(Color::Muted);
|
||||
.icon_color(Color::Muted)
|
||||
.selected_style(ButtonStyle::Tinted(TintColor::Accent));
|
||||
|
||||
PopoverMenu::new("profile-selector")
|
||||
.trigger_with_tooltip(trigger_button, {
|
||||
@@ -195,6 +196,10 @@ impl Render for ProfileSelector {
|
||||
.menu(move |window, cx| {
|
||||
Some(this.update(cx, |this, cx| this.build_context_menu(window, cx)))
|
||||
})
|
||||
.offset(gpui::Point {
|
||||
x: px(0.0),
|
||||
y: px(-2.0),
|
||||
})
|
||||
.into_any_element()
|
||||
} else {
|
||||
Button::new("tools-not-supported-button", "Tools Unsupported")
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use ai_onboarding::{AgentPanelOnboardingCard, PlanDefinitions};
|
||||
use client::zed_urls;
|
||||
use feature_flags::{BillingV2FeatureFlag, FeatureFlagAppExt as _};
|
||||
use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
|
||||
use ui::{Divider, Tooltip, prelude::*};
|
||||
|
||||
@@ -18,8 +19,6 @@ impl EndTrialUpsell {
|
||||
|
||||
impl RenderOnce for EndTrialUpsell {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let plan_definitions = PlanDefinitions;
|
||||
|
||||
let pro_section = v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
@@ -33,7 +32,7 @@ impl RenderOnce for EndTrialUpsell {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(plan_definitions.pro_plan(false))
|
||||
.child(PlanDefinitions.pro_plan(cx.has_flag::<BillingV2FeatureFlag>(), false))
|
||||
.child(
|
||||
Button::new("cta-button", "Upgrade to Zed Pro")
|
||||
.full_width()
|
||||
@@ -64,7 +63,7 @@ impl RenderOnce for EndTrialUpsell {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(plan_definitions.free_plan());
|
||||
.child(PlanDefinitions.free_plan(cx.has_flag::<BillingV2FeatureFlag>()));
|
||||
|
||||
AgentPanelOnboardingCard::new()
|
||||
.child(Headline::new("Your Zed Pro Trial has expired"))
|
||||
|
||||
@@ -45,13 +45,13 @@ impl RenderOnce for UsageCallout {
|
||||
"Upgrade",
|
||||
zed_urls::account_url(cx),
|
||||
),
|
||||
Plan::ZedProTrial => (
|
||||
Plan::ZedProTrial | Plan::ZedProTrialV2 => (
|
||||
"Out of trial prompts",
|
||||
"Upgrade to Zed Pro to continue, or switch to API key.".to_string(),
|
||||
"Upgrade",
|
||||
zed_urls::account_url(cx),
|
||||
),
|
||||
Plan::ZedPro => (
|
||||
Plan::ZedPro | Plan::ZedProV2 => (
|
||||
"Out of included prompts",
|
||||
"Enable usage-based billing to continue.".to_string(),
|
||||
"Manage",
|
||||
|
||||
@@ -18,6 +18,7 @@ default = []
|
||||
client.workspace = true
|
||||
cloud_llm_client.workspace = true
|
||||
component.workspace = true
|
||||
feature_flags.workspace = true
|
||||
gpui.workspace = true
|
||||
language_model.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -18,6 +18,7 @@ pub use young_account_banner::YoungAccountBanner;
|
||||
use std::sync::Arc;
|
||||
|
||||
use client::{Client, UserStore, zed_urls};
|
||||
use feature_flags::{BillingV2FeatureFlag, FeatureFlagAppExt as _};
|
||||
use gpui::{AnyElement, Entity, IntoElement, ParentElement};
|
||||
use ui::{Divider, RegisterComponent, Tooltip, prelude::*};
|
||||
|
||||
@@ -84,9 +85,8 @@ impl ZedAiOnboarding {
|
||||
self
|
||||
}
|
||||
|
||||
fn render_sign_in_disclaimer(&self, _cx: &mut App) -> AnyElement {
|
||||
fn render_sign_in_disclaimer(&self, cx: &mut App) -> AnyElement {
|
||||
let signing_in = matches!(self.sign_in_status, SignInStatus::SigningIn);
|
||||
let plan_definitions = PlanDefinitions;
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
@@ -96,7 +96,7 @@ impl ZedAiOnboarding {
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(plan_definitions.pro_plan(false))
|
||||
.child(PlanDefinitions.pro_plan(cx.has_flag::<BillingV2FeatureFlag>(), false))
|
||||
.child(
|
||||
Button::new("sign_in", "Try Zed Pro for Free")
|
||||
.disabled(signing_in)
|
||||
@@ -114,16 +114,13 @@ impl ZedAiOnboarding {
|
||||
}
|
||||
|
||||
fn render_free_plan_state(&self, cx: &mut App) -> AnyElement {
|
||||
let young_account_banner = YoungAccountBanner;
|
||||
let plan_definitions = PlanDefinitions;
|
||||
|
||||
if self.account_too_young {
|
||||
v_flex()
|
||||
.relative()
|
||||
.max_w_full()
|
||||
.gap_1()
|
||||
.child(Headline::new("Welcome to Zed AI"))
|
||||
.child(young_account_banner)
|
||||
.child(YoungAccountBanner)
|
||||
.child(
|
||||
v_flex()
|
||||
.mt_2()
|
||||
@@ -139,7 +136,9 @@ impl ZedAiOnboarding {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(plan_definitions.pro_plan(true))
|
||||
.child(
|
||||
PlanDefinitions.pro_plan(cx.has_flag::<BillingV2FeatureFlag>(), true),
|
||||
)
|
||||
.child(
|
||||
Button::new("pro", "Get Started")
|
||||
.full_width()
|
||||
@@ -182,7 +181,7 @@ impl ZedAiOnboarding {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(plan_definitions.free_plan()),
|
||||
.child(PlanDefinitions.free_plan(cx.has_flag::<BillingV2FeatureFlag>())),
|
||||
)
|
||||
.when_some(
|
||||
self.dismiss_onboarding.as_ref(),
|
||||
@@ -220,7 +219,9 @@ impl ZedAiOnboarding {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(plan_definitions.pro_trial(true))
|
||||
.child(
|
||||
PlanDefinitions.pro_trial(cx.has_flag::<BillingV2FeatureFlag>(), true),
|
||||
)
|
||||
.child(
|
||||
Button::new("pro", "Start Free Trial")
|
||||
.full_width()
|
||||
@@ -238,9 +239,7 @@ impl ZedAiOnboarding {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_trial_state(&self, _cx: &mut App) -> AnyElement {
|
||||
let plan_definitions = PlanDefinitions;
|
||||
|
||||
fn render_trial_state(&self, is_v2: bool, _cx: &mut App) -> AnyElement {
|
||||
v_flex()
|
||||
.relative()
|
||||
.gap_1()
|
||||
@@ -250,7 +249,7 @@ impl ZedAiOnboarding {
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(plan_definitions.pro_trial(false))
|
||||
.child(PlanDefinitions.pro_trial(is_v2, false))
|
||||
.when_some(
|
||||
self.dismiss_onboarding.as_ref(),
|
||||
|this, dismiss_callback| {
|
||||
@@ -274,9 +273,7 @@ impl ZedAiOnboarding {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_pro_plan_state(&self, _cx: &mut App) -> AnyElement {
|
||||
let plan_definitions = PlanDefinitions;
|
||||
|
||||
fn render_pro_plan_state(&self, is_v2: bool, _cx: &mut App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(Headline::new("Welcome to Zed Pro"))
|
||||
@@ -285,7 +282,7 @@ impl ZedAiOnboarding {
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(plan_definitions.pro_plan(false))
|
||||
.child(PlanDefinitions.pro_plan(is_v2, false))
|
||||
.when_some(
|
||||
self.dismiss_onboarding.as_ref(),
|
||||
|this, dismiss_callback| {
|
||||
@@ -315,8 +312,10 @@ impl RenderOnce for ZedAiOnboarding {
|
||||
if matches!(self.sign_in_status, SignInStatus::SignedIn) {
|
||||
match self.plan {
|
||||
None | Some(Plan::ZedFree) => self.render_free_plan_state(cx),
|
||||
Some(Plan::ZedProTrial) => self.render_trial_state(cx),
|
||||
Some(Plan::ZedPro) => self.render_pro_plan_state(cx),
|
||||
Some(Plan::ZedProTrial) => self.render_trial_state(false, cx),
|
||||
Some(Plan::ZedProTrialV2) => self.render_trial_state(true, cx),
|
||||
Some(Plan::ZedPro) => self.render_pro_plan_state(false, cx),
|
||||
Some(Plan::ZedProV2) => self.render_pro_plan_state(true, cx),
|
||||
}
|
||||
} else {
|
||||
self.render_sign_in_disclaimer(cx)
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use client::{Client, UserStore, zed_urls};
|
||||
use cloud_llm_client::Plan;
|
||||
use feature_flags::{BillingV2FeatureFlag, FeatureFlagAppExt};
|
||||
use gpui::{AnyElement, App, Entity, IntoElement, RenderOnce, Window};
|
||||
use ui::{CommonAnimationExt, Divider, Vector, VectorName, prelude::*};
|
||||
|
||||
@@ -49,9 +50,6 @@ impl AiUpsellCard {
|
||||
|
||||
impl RenderOnce for AiUpsellCard {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let plan_definitions = PlanDefinitions;
|
||||
let young_account_banner = YoungAccountBanner;
|
||||
|
||||
let pro_section = v_flex()
|
||||
.flex_grow()
|
||||
.w_full()
|
||||
@@ -67,7 +65,7 @@ impl RenderOnce for AiUpsellCard {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(plan_definitions.pro_plan(false));
|
||||
.child(PlanDefinitions.pro_plan(cx.has_flag::<BillingV2FeatureFlag>(), false));
|
||||
|
||||
let free_section = v_flex()
|
||||
.flex_grow()
|
||||
@@ -84,7 +82,7 @@ impl RenderOnce for AiUpsellCard {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(plan_definitions.free_plan());
|
||||
.child(PlanDefinitions.free_plan(cx.has_flag::<BillingV2FeatureFlag>()));
|
||||
|
||||
let grid_bg = h_flex()
|
||||
.absolute()
|
||||
@@ -173,7 +171,7 @@ impl RenderOnce for AiUpsellCard {
|
||||
.child(Label::new("Try Zed AI").size(LabelSize::Large))
|
||||
.map(|this| {
|
||||
if self.account_too_young {
|
||||
this.child(young_account_banner).child(
|
||||
this.child(YoungAccountBanner).child(
|
||||
v_flex()
|
||||
.mt_2()
|
||||
.gap_1()
|
||||
@@ -188,7 +186,10 @@ impl RenderOnce for AiUpsellCard {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(plan_definitions.pro_plan(true))
|
||||
.child(
|
||||
PlanDefinitions
|
||||
.pro_plan(cx.has_flag::<BillingV2FeatureFlag>(), true),
|
||||
)
|
||||
.child(
|
||||
Button::new("pro", "Get Started")
|
||||
.full_width()
|
||||
@@ -235,7 +236,7 @@ impl RenderOnce for AiUpsellCard {
|
||||
)
|
||||
}
|
||||
}),
|
||||
Some(Plan::ZedProTrial) => card
|
||||
Some(plan @ Plan::ZedProTrial | plan @ Plan::ZedProTrialV2) => card
|
||||
.child(pro_trial_stamp)
|
||||
.child(Label::new("You're in the Zed Pro Trial").size(LabelSize::Large))
|
||||
.child(
|
||||
@@ -243,8 +244,8 @@ impl RenderOnce for AiUpsellCard {
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(plan_definitions.pro_trial(false)),
|
||||
Some(Plan::ZedPro) => card
|
||||
.child(PlanDefinitions.pro_trial(plan == Plan::ZedProTrialV2, false)),
|
||||
Some(plan @ Plan::ZedPro | plan @ Plan::ZedProV2) => card
|
||||
.child(certified_user_stamp)
|
||||
.child(Label::new("You're in the Zed Pro plan").size(LabelSize::Large))
|
||||
.child(
|
||||
@@ -252,7 +253,7 @@ impl RenderOnce for AiUpsellCard {
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(plan_definitions.pro_plan(false)),
|
||||
.child(PlanDefinitions.pro_plan(plan == Plan::ZedProV2, false)),
|
||||
},
|
||||
// Signed Out State
|
||||
_ => card
|
||||
|
||||
@@ -7,13 +7,13 @@ pub struct PlanDefinitions;
|
||||
impl PlanDefinitions {
|
||||
pub const AI_DESCRIPTION: &'static str = "Zed offers a complete agentic experience, with robust editing and reviewing features to collaborate with AI.";
|
||||
|
||||
pub fn free_plan(&self) -> impl IntoElement {
|
||||
pub fn free_plan(&self, _is_v2: bool) -> impl IntoElement {
|
||||
List::new()
|
||||
.child(ListBulletItem::new("50 prompts with Claude models"))
|
||||
.child(ListBulletItem::new("2,000 accepted edit predictions"))
|
||||
}
|
||||
|
||||
pub fn pro_trial(&self, period: bool) -> impl IntoElement {
|
||||
pub fn pro_trial(&self, _is_v2: bool, period: bool) -> impl IntoElement {
|
||||
List::new()
|
||||
.child(ListBulletItem::new("150 prompts with Claude models"))
|
||||
.child(ListBulletItem::new(
|
||||
@@ -26,7 +26,7 @@ impl PlanDefinitions {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pro_plan(&self, price: bool) -> impl IntoElement {
|
||||
pub fn pro_plan(&self, _is_v2: bool, price: bool) -> impl IntoElement {
|
||||
List::new()
|
||||
.child(ListBulletItem::new("500 prompts with Claude models"))
|
||||
.child(ListBulletItem::new(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use auto_update::AutoUpdater;
|
||||
use client::proto::UpdateNotification;
|
||||
use editor::{Editor, MultiBuffer};
|
||||
use gpui::{App, Context, DismissEvent, Entity, Window, actions, prelude::*};
|
||||
use http_client::HttpClient;
|
||||
@@ -138,6 +137,8 @@ pub fn notify_if_app_was_updated(cx: &mut App) {
|
||||
return;
|
||||
}
|
||||
|
||||
struct UpdateNotification;
|
||||
|
||||
let should_show_notification = updater.read(cx).should_show_update_notification(cx);
|
||||
cx.spawn(async move |cx| {
|
||||
let should_show_notification = should_show_notification.await?;
|
||||
|
||||
@@ -25,11 +25,9 @@ gpui.workspace = true
|
||||
language.workspace = true
|
||||
log.workspace = true
|
||||
postage.workspace = true
|
||||
rand.workspace = true
|
||||
release_channel.workspace = true
|
||||
rpc.workspace = true
|
||||
settings.workspace = true
|
||||
sum_tree.workspace = true
|
||||
text.workspace = true
|
||||
time.workspace = true
|
||||
util.workspace = true
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
mod channel_buffer;
|
||||
mod channel_chat;
|
||||
mod channel_store;
|
||||
|
||||
use client::{Client, UserStore};
|
||||
@@ -7,10 +6,6 @@ use gpui::{App, Entity};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use channel_buffer::{ACKNOWLEDGE_DEBOUNCE_INTERVAL, ChannelBuffer, ChannelBufferEvent};
|
||||
pub use channel_chat::{
|
||||
ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId, MessageParams,
|
||||
mentions_to_proto,
|
||||
};
|
||||
pub use channel_store::{Channel, ChannelEvent, ChannelMembership, ChannelStore};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -19,5 +14,4 @@ mod channel_store_tests;
|
||||
pub fn init(client: &Arc<Client>, user_store: Entity<UserStore>, cx: &mut App) {
|
||||
channel_store::init(client, user_store, cx);
|
||||
channel_buffer::init(&client.clone().into());
|
||||
channel_chat::init(&client.clone().into());
|
||||
}
|
||||
|
||||
@@ -1,861 +0,0 @@
|
||||
use crate::{Channel, ChannelStore};
|
||||
use anyhow::{Context as _, Result};
|
||||
use client::{
|
||||
ChannelId, Client, Subscription, TypedEnvelope, UserId, proto,
|
||||
user::{User, UserStore},
|
||||
};
|
||||
use collections::HashSet;
|
||||
use futures::lock::Mutex;
|
||||
use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Task, WeakEntity};
|
||||
use rand::prelude::*;
|
||||
use rpc::AnyProtoClient;
|
||||
use std::{
|
||||
ops::{ControlFlow, Range},
|
||||
sync::Arc,
|
||||
};
|
||||
use sum_tree::{Bias, Dimensions, SumTree};
|
||||
use time::OffsetDateTime;
|
||||
use util::{ResultExt as _, TryFutureExt, post_inc};
|
||||
|
||||
pub struct ChannelChat {
|
||||
pub channel_id: ChannelId,
|
||||
messages: SumTree<ChannelMessage>,
|
||||
acknowledged_message_ids: HashSet<u64>,
|
||||
channel_store: Entity<ChannelStore>,
|
||||
loaded_all_messages: bool,
|
||||
last_acknowledged_id: Option<u64>,
|
||||
next_pending_message_id: usize,
|
||||
first_loaded_message_id: Option<u64>,
|
||||
user_store: Entity<UserStore>,
|
||||
rpc: Arc<Client>,
|
||||
outgoing_messages_lock: Arc<Mutex<()>>,
|
||||
rng: StdRng,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct MessageParams {
|
||||
pub text: String,
|
||||
pub mentions: Vec<(Range<usize>, UserId)>,
|
||||
pub reply_to_message_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChannelMessage {
|
||||
pub id: ChannelMessageId,
|
||||
pub body: String,
|
||||
pub timestamp: OffsetDateTime,
|
||||
pub sender: Arc<User>,
|
||||
pub nonce: u128,
|
||||
pub mentions: Vec<(Range<usize>, UserId)>,
|
||||
pub reply_to_message_id: Option<u64>,
|
||||
pub edited_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum ChannelMessageId {
|
||||
Saved(u64),
|
||||
Pending(usize),
|
||||
}
|
||||
|
||||
impl From<ChannelMessageId> for Option<u64> {
|
||||
fn from(val: ChannelMessageId) -> Self {
|
||||
match val {
|
||||
ChannelMessageId::Saved(id) => Some(id),
|
||||
ChannelMessageId::Pending(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ChannelMessageSummary {
|
||||
max_id: ChannelMessageId,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct Count(usize);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ChannelChatEvent {
|
||||
MessagesUpdated {
|
||||
old_range: Range<usize>,
|
||||
new_count: usize,
|
||||
},
|
||||
UpdateMessage {
|
||||
message_id: ChannelMessageId,
|
||||
message_ix: usize,
|
||||
},
|
||||
NewMessage {
|
||||
channel_id: ChannelId,
|
||||
message_id: u64,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventEmitter<ChannelChatEvent> for ChannelChat {}
|
||||
pub fn init(client: &AnyProtoClient) {
|
||||
client.add_entity_message_handler(ChannelChat::handle_message_sent);
|
||||
client.add_entity_message_handler(ChannelChat::handle_message_removed);
|
||||
client.add_entity_message_handler(ChannelChat::handle_message_updated);
|
||||
}
|
||||
|
||||
impl ChannelChat {
|
||||
pub async fn new(
|
||||
channel: Arc<Channel>,
|
||||
channel_store: Entity<ChannelStore>,
|
||||
user_store: Entity<UserStore>,
|
||||
client: Arc<Client>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Entity<Self>> {
|
||||
let channel_id = channel.id;
|
||||
let subscription = client.subscribe_to_entity(channel_id.0).unwrap();
|
||||
|
||||
let response = client
|
||||
.request(proto::JoinChannelChat {
|
||||
channel_id: channel_id.0,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let handle = cx.new(|cx| {
|
||||
cx.on_release(Self::release).detach();
|
||||
Self {
|
||||
channel_id: channel.id,
|
||||
user_store: user_store.clone(),
|
||||
channel_store,
|
||||
rpc: client.clone(),
|
||||
outgoing_messages_lock: Default::default(),
|
||||
messages: Default::default(),
|
||||
acknowledged_message_ids: Default::default(),
|
||||
loaded_all_messages: false,
|
||||
next_pending_message_id: 0,
|
||||
last_acknowledged_id: None,
|
||||
rng: StdRng::from_os_rng(),
|
||||
first_loaded_message_id: None,
|
||||
_subscription: subscription.set_entity(&cx.entity(), &cx.to_async()),
|
||||
}
|
||||
})?;
|
||||
Self::handle_loaded_messages(
|
||||
handle.downgrade(),
|
||||
user_store,
|
||||
client,
|
||||
response.messages,
|
||||
response.done,
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
fn release(&mut self, _: &mut App) {
|
||||
self.rpc
|
||||
.send(proto::LeaveChannelChat {
|
||||
channel_id: self.channel_id.0,
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
|
||||
pub fn channel(&self, cx: &App) -> Option<Arc<Channel>> {
|
||||
self.channel_store
|
||||
.read(cx)
|
||||
.channel_for_id(self.channel_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn client(&self) -> &Arc<Client> {
|
||||
&self.rpc
|
||||
}
|
||||
|
||||
pub fn send_message(
|
||||
&mut self,
|
||||
message: MessageParams,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<Task<Result<u64>>> {
|
||||
anyhow::ensure!(
|
||||
!message.text.trim().is_empty(),
|
||||
"message body can't be empty"
|
||||
);
|
||||
|
||||
let current_user = self
|
||||
.user_store
|
||||
.read(cx)
|
||||
.current_user()
|
||||
.context("current_user is not present")?;
|
||||
|
||||
let channel_id = self.channel_id;
|
||||
let pending_id = ChannelMessageId::Pending(post_inc(&mut self.next_pending_message_id));
|
||||
let nonce = self.rng.random();
|
||||
self.insert_messages(
|
||||
SumTree::from_item(
|
||||
ChannelMessage {
|
||||
id: pending_id,
|
||||
body: message.text.clone(),
|
||||
sender: current_user,
|
||||
timestamp: OffsetDateTime::now_utc(),
|
||||
mentions: message.mentions.clone(),
|
||||
nonce,
|
||||
reply_to_message_id: message.reply_to_message_id,
|
||||
edited_at: None,
|
||||
},
|
||||
&(),
|
||||
),
|
||||
cx,
|
||||
);
|
||||
let user_store = self.user_store.clone();
|
||||
let rpc = self.rpc.clone();
|
||||
let outgoing_messages_lock = self.outgoing_messages_lock.clone();
|
||||
|
||||
// todo - handle messages that fail to send (e.g. >1024 chars)
|
||||
Ok(cx.spawn(async move |this, cx| {
|
||||
let outgoing_message_guard = outgoing_messages_lock.lock().await;
|
||||
let request = rpc.request(proto::SendChannelMessage {
|
||||
channel_id: channel_id.0,
|
||||
body: message.text,
|
||||
nonce: Some(nonce.into()),
|
||||
mentions: mentions_to_proto(&message.mentions),
|
||||
reply_to_message_id: message.reply_to_message_id,
|
||||
});
|
||||
let response = request.await?;
|
||||
drop(outgoing_message_guard);
|
||||
let response = response.message.context("invalid message")?;
|
||||
let id = response.id;
|
||||
let message = ChannelMessage::from_proto(response, &user_store, cx).await?;
|
||||
this.update(cx, |this, cx| {
|
||||
this.insert_messages(SumTree::from_item(message, &()), cx);
|
||||
if this.first_loaded_message_id.is_none() {
|
||||
this.first_loaded_message_id = Some(id);
|
||||
}
|
||||
})?;
|
||||
Ok(id)
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn remove_message(&mut self, id: u64, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
let response = self.rpc.request(proto::RemoveChannelMessage {
|
||||
channel_id: self.channel_id.0,
|
||||
message_id: id,
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
response.await?;
|
||||
this.update(cx, |this, cx| {
|
||||
this.message_removed(id, cx);
|
||||
})?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_message(
|
||||
&mut self,
|
||||
id: u64,
|
||||
message: MessageParams,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<Task<Result<()>>> {
|
||||
self.message_update(
|
||||
ChannelMessageId::Saved(id),
|
||||
message.text.clone(),
|
||||
message.mentions.clone(),
|
||||
Some(OffsetDateTime::now_utc()),
|
||||
cx,
|
||||
);
|
||||
|
||||
let nonce: u128 = self.rng.random();
|
||||
|
||||
let request = self.rpc.request(proto::UpdateChannelMessage {
|
||||
channel_id: self.channel_id.0,
|
||||
message_id: id,
|
||||
body: message.text,
|
||||
nonce: Some(nonce.into()),
|
||||
mentions: mentions_to_proto(&message.mentions),
|
||||
});
|
||||
Ok(cx.spawn(async move |_, _| {
|
||||
request.await?;
|
||||
Ok(())
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn load_more_messages(&mut self, cx: &mut Context<Self>) -> Option<Task<Option<()>>> {
|
||||
if self.loaded_all_messages {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rpc = self.rpc.clone();
|
||||
let user_store = self.user_store.clone();
|
||||
let channel_id = self.channel_id;
|
||||
let before_message_id = self.first_loaded_message_id()?;
|
||||
Some(cx.spawn(async move |this, cx| {
|
||||
async move {
|
||||
let response = rpc
|
||||
.request(proto::GetChannelMessages {
|
||||
channel_id: channel_id.0,
|
||||
before_message_id,
|
||||
})
|
||||
.await?;
|
||||
Self::handle_loaded_messages(
|
||||
this,
|
||||
user_store,
|
||||
rpc,
|
||||
response.messages,
|
||||
response.done,
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.log_err()
|
||||
.await
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn first_loaded_message_id(&mut self) -> Option<u64> {
|
||||
self.first_loaded_message_id
|
||||
}
|
||||
|
||||
/// Load a message by its id, if it's already stored locally.
|
||||
pub fn find_loaded_message(&self, id: u64) -> Option<&ChannelMessage> {
|
||||
self.messages.iter().find(|message| match message.id {
|
||||
ChannelMessageId::Saved(message_id) => message_id == id,
|
||||
ChannelMessageId::Pending(_) => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load all of the chat messages since a certain message id.
|
||||
///
|
||||
/// For now, we always maintain a suffix of the channel's messages.
|
||||
pub async fn load_history_since_message(
|
||||
chat: Entity<Self>,
|
||||
message_id: u64,
|
||||
mut cx: AsyncApp,
|
||||
) -> Option<usize> {
|
||||
loop {
|
||||
let step = chat
|
||||
.update(&mut cx, |chat, cx| {
|
||||
if let Some(first_id) = chat.first_loaded_message_id()
|
||||
&& first_id <= message_id
|
||||
{
|
||||
let mut cursor = chat
|
||||
.messages
|
||||
.cursor::<Dimensions<ChannelMessageId, Count>>(&());
|
||||
let message_id = ChannelMessageId::Saved(message_id);
|
||||
cursor.seek(&message_id, Bias::Left);
|
||||
return ControlFlow::Break(
|
||||
if cursor
|
||||
.item()
|
||||
.is_some_and(|message| message.id == message_id)
|
||||
{
|
||||
Some(cursor.start().1.0)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
);
|
||||
}
|
||||
ControlFlow::Continue(chat.load_more_messages(cx))
|
||||
})
|
||||
.log_err()?;
|
||||
match step {
|
||||
ControlFlow::Break(ix) => return ix,
|
||||
ControlFlow::Continue(task) => task?.await?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn acknowledge_last_message(&mut self, cx: &mut Context<Self>) {
|
||||
if let ChannelMessageId::Saved(latest_message_id) = self.messages.summary().max_id
|
||||
&& self
|
||||
.last_acknowledged_id
|
||||
.is_none_or(|acknowledged_id| acknowledged_id < latest_message_id)
|
||||
{
|
||||
self.rpc
|
||||
.send(proto::AckChannelMessage {
|
||||
channel_id: self.channel_id.0,
|
||||
message_id: latest_message_id,
|
||||
})
|
||||
.ok();
|
||||
self.last_acknowledged_id = Some(latest_message_id);
|
||||
self.channel_store.update(cx, |store, cx| {
|
||||
store.acknowledge_message_id(self.channel_id, latest_message_id, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_loaded_messages(
|
||||
this: WeakEntity<Self>,
|
||||
user_store: Entity<UserStore>,
|
||||
rpc: Arc<Client>,
|
||||
proto_messages: Vec<proto::ChannelMessage>,
|
||||
loaded_all_messages: bool,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<()> {
|
||||
let loaded_messages = messages_from_proto(proto_messages, &user_store, cx).await?;
|
||||
|
||||
let first_loaded_message_id = loaded_messages.first().map(|m| m.id);
|
||||
let loaded_message_ids = this.read_with(cx, |this, _| {
|
||||
let mut loaded_message_ids: HashSet<u64> = HashSet::default();
|
||||
for message in loaded_messages.iter() {
|
||||
if let Some(saved_message_id) = message.id.into() {
|
||||
loaded_message_ids.insert(saved_message_id);
|
||||
}
|
||||
}
|
||||
for message in this.messages.iter() {
|
||||
if let Some(saved_message_id) = message.id.into() {
|
||||
loaded_message_ids.insert(saved_message_id);
|
||||
}
|
||||
}
|
||||
loaded_message_ids
|
||||
})?;
|
||||
|
||||
let missing_ancestors = loaded_messages
|
||||
.iter()
|
||||
.filter_map(|message| {
|
||||
if let Some(ancestor_id) = message.reply_to_message_id
|
||||
&& !loaded_message_ids.contains(&ancestor_id)
|
||||
{
|
||||
return Some(ancestor_id);
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let loaded_ancestors = if missing_ancestors.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let response = rpc
|
||||
.request(proto::GetChannelMessagesById {
|
||||
message_ids: missing_ancestors,
|
||||
})
|
||||
.await?;
|
||||
Some(messages_from_proto(response.messages, &user_store, cx).await?)
|
||||
};
|
||||
this.update(cx, |this, cx| {
|
||||
this.first_loaded_message_id = first_loaded_message_id.and_then(|msg_id| msg_id.into());
|
||||
this.loaded_all_messages = loaded_all_messages;
|
||||
this.insert_messages(loaded_messages, cx);
|
||||
if let Some(loaded_ancestors) = loaded_ancestors {
|
||||
this.insert_messages(loaded_ancestors, cx);
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rejoin(&mut self, cx: &mut Context<Self>) {
|
||||
let user_store = self.user_store.clone();
|
||||
let rpc = self.rpc.clone();
|
||||
let channel_id = self.channel_id;
|
||||
cx.spawn(async move |this, cx| {
|
||||
async move {
|
||||
let response = rpc
|
||||
.request(proto::JoinChannelChat {
|
||||
channel_id: channel_id.0,
|
||||
})
|
||||
.await?;
|
||||
Self::handle_loaded_messages(
|
||||
this.clone(),
|
||||
user_store.clone(),
|
||||
rpc.clone(),
|
||||
response.messages,
|
||||
response.done,
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let pending_messages = this.read_with(cx, |this, _| {
|
||||
this.pending_messages().cloned().collect::<Vec<_>>()
|
||||
})?;
|
||||
|
||||
for pending_message in pending_messages {
|
||||
let request = rpc.request(proto::SendChannelMessage {
|
||||
channel_id: channel_id.0,
|
||||
body: pending_message.body,
|
||||
mentions: mentions_to_proto(&pending_message.mentions),
|
||||
nonce: Some(pending_message.nonce.into()),
|
||||
reply_to_message_id: pending_message.reply_to_message_id,
|
||||
});
|
||||
let response = request.await?;
|
||||
let message = ChannelMessage::from_proto(
|
||||
response.message.context("invalid message")?,
|
||||
&user_store,
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
this.update(cx, |this, cx| {
|
||||
this.insert_messages(SumTree::from_item(message, &()), cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.log_err()
|
||||
.await
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn message_count(&self) -> usize {
|
||||
self.messages.summary().count
|
||||
}
|
||||
|
||||
pub fn messages(&self) -> &SumTree<ChannelMessage> {
|
||||
&self.messages
|
||||
}
|
||||
|
||||
pub fn message(&self, ix: usize) -> &ChannelMessage {
|
||||
let mut cursor = self.messages.cursor::<Count>(&());
|
||||
cursor.seek(&Count(ix), Bias::Right);
|
||||
cursor.item().unwrap()
|
||||
}
|
||||
|
||||
pub fn acknowledge_message(&mut self, id: u64) {
|
||||
if self.acknowledged_message_ids.insert(id) {
|
||||
self.rpc
|
||||
.send(proto::AckChannelMessage {
|
||||
channel_id: self.channel_id.0,
|
||||
message_id: id,
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn messages_in_range(&self, range: Range<usize>) -> impl Iterator<Item = &ChannelMessage> {
|
||||
let mut cursor = self.messages.cursor::<Count>(&());
|
||||
cursor.seek(&Count(range.start), Bias::Right);
|
||||
cursor.take(range.len())
|
||||
}
|
||||
|
||||
pub fn pending_messages(&self) -> impl Iterator<Item = &ChannelMessage> {
|
||||
let mut cursor = self.messages.cursor::<ChannelMessageId>(&());
|
||||
cursor.seek(&ChannelMessageId::Pending(0), Bias::Left);
|
||||
cursor
|
||||
}
|
||||
|
||||
async fn handle_message_sent(
|
||||
this: Entity<Self>,
|
||||
message: TypedEnvelope<proto::ChannelMessageSent>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<()> {
|
||||
let user_store = this.read_with(&cx, |this, _| this.user_store.clone())?;
|
||||
let message = message.payload.message.context("empty message")?;
|
||||
let message_id = message.id;
|
||||
|
||||
let message = ChannelMessage::from_proto(message, &user_store, &mut cx).await?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.insert_messages(SumTree::from_item(message, &()), cx);
|
||||
cx.emit(ChannelChatEvent::NewMessage {
|
||||
channel_id: this.channel_id,
|
||||
message_id,
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_message_removed(
|
||||
this: Entity<Self>,
|
||||
message: TypedEnvelope<proto::RemoveChannelMessage>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<()> {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.message_removed(message.payload.message_id, cx)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_message_updated(
|
||||
this: Entity<Self>,
|
||||
message: TypedEnvelope<proto::ChannelMessageUpdate>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<()> {
|
||||
let user_store = this.read_with(&cx, |this, _| this.user_store.clone())?;
|
||||
let message = message.payload.message.context("empty message")?;
|
||||
|
||||
let message = ChannelMessage::from_proto(message, &user_store, &mut cx).await?;
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.message_update(
|
||||
message.id,
|
||||
message.body,
|
||||
message.mentions,
|
||||
message.edited_at,
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_messages(&mut self, messages: SumTree<ChannelMessage>, cx: &mut Context<Self>) {
|
||||
if let Some((first_message, last_message)) = messages.first().zip(messages.last()) {
|
||||
let nonces = messages
|
||||
.cursor::<()>(&())
|
||||
.map(|m| m.nonce)
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let mut old_cursor = self
|
||||
.messages
|
||||
.cursor::<Dimensions<ChannelMessageId, Count>>(&());
|
||||
let mut new_messages = old_cursor.slice(&first_message.id, Bias::Left);
|
||||
let start_ix = old_cursor.start().1.0;
|
||||
let removed_messages = old_cursor.slice(&last_message.id, Bias::Right);
|
||||
let removed_count = removed_messages.summary().count;
|
||||
let new_count = messages.summary().count;
|
||||
let end_ix = start_ix + removed_count;
|
||||
|
||||
new_messages.append(messages, &());
|
||||
|
||||
let mut ranges = Vec::<Range<usize>>::new();
|
||||
if new_messages.last().unwrap().is_pending() {
|
||||
new_messages.append(old_cursor.suffix(), &());
|
||||
} else {
|
||||
new_messages.append(
|
||||
old_cursor.slice(&ChannelMessageId::Pending(0), Bias::Left),
|
||||
&(),
|
||||
);
|
||||
|
||||
while let Some(message) = old_cursor.item() {
|
||||
let message_ix = old_cursor.start().1.0;
|
||||
if nonces.contains(&message.nonce) {
|
||||
if ranges.last().is_some_and(|r| r.end == message_ix) {
|
||||
ranges.last_mut().unwrap().end += 1;
|
||||
} else {
|
||||
ranges.push(message_ix..message_ix + 1);
|
||||
}
|
||||
} else {
|
||||
new_messages.push(message.clone(), &());
|
||||
}
|
||||
old_cursor.next();
|
||||
}
|
||||
}
|
||||
|
||||
drop(old_cursor);
|
||||
self.messages = new_messages;
|
||||
|
||||
for range in ranges.into_iter().rev() {
|
||||
cx.emit(ChannelChatEvent::MessagesUpdated {
|
||||
old_range: range,
|
||||
new_count: 0,
|
||||
});
|
||||
}
|
||||
cx.emit(ChannelChatEvent::MessagesUpdated {
|
||||
old_range: start_ix..end_ix,
|
||||
new_count,
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn message_removed(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
let mut cursor = self.messages.cursor::<ChannelMessageId>(&());
|
||||
let mut messages = cursor.slice(&ChannelMessageId::Saved(id), Bias::Left);
|
||||
if let Some(item) = cursor.item()
|
||||
&& item.id == ChannelMessageId::Saved(id)
|
||||
{
|
||||
let deleted_message_ix = messages.summary().count;
|
||||
cursor.next();
|
||||
messages.append(cursor.suffix(), &());
|
||||
drop(cursor);
|
||||
self.messages = messages;
|
||||
|
||||
// If the message that was deleted was the last acknowledged message,
|
||||
// replace the acknowledged message with an earlier one.
|
||||
self.channel_store.update(cx, |store, _| {
|
||||
let summary = self.messages.summary();
|
||||
if summary.count == 0 {
|
||||
store.set_acknowledged_message_id(self.channel_id, None);
|
||||
} else if deleted_message_ix == summary.count
|
||||
&& let ChannelMessageId::Saved(id) = summary.max_id
|
||||
{
|
||||
store.set_acknowledged_message_id(self.channel_id, Some(id));
|
||||
}
|
||||
});
|
||||
|
||||
cx.emit(ChannelChatEvent::MessagesUpdated {
|
||||
old_range: deleted_message_ix..deleted_message_ix + 1,
|
||||
new_count: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn message_update(
|
||||
&mut self,
|
||||
id: ChannelMessageId,
|
||||
body: String,
|
||||
mentions: Vec<(Range<usize>, u64)>,
|
||||
edited_at: Option<OffsetDateTime>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let mut cursor = self.messages.cursor::<ChannelMessageId>(&());
|
||||
let mut messages = cursor.slice(&id, Bias::Left);
|
||||
let ix = messages.summary().count;
|
||||
|
||||
if let Some(mut message_to_update) = cursor.item().cloned() {
|
||||
message_to_update.body = body;
|
||||
message_to_update.mentions = mentions;
|
||||
message_to_update.edited_at = edited_at;
|
||||
messages.push(message_to_update, &());
|
||||
cursor.next();
|
||||
}
|
||||
|
||||
messages.append(cursor.suffix(), &());
|
||||
drop(cursor);
|
||||
self.messages = messages;
|
||||
|
||||
cx.emit(ChannelChatEvent::UpdateMessage {
|
||||
message_ix: ix,
|
||||
message_id: id,
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
async fn messages_from_proto(
|
||||
proto_messages: Vec<proto::ChannelMessage>,
|
||||
user_store: &Entity<UserStore>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<SumTree<ChannelMessage>> {
|
||||
let messages = ChannelMessage::from_proto_vec(proto_messages, user_store, cx).await?;
|
||||
let mut result = SumTree::default();
|
||||
result.extend(messages, &());
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
impl ChannelMessage {
|
||||
pub async fn from_proto(
|
||||
message: proto::ChannelMessage,
|
||||
user_store: &Entity<UserStore>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Self> {
|
||||
let sender = user_store
|
||||
.update(cx, |user_store, cx| {
|
||||
user_store.get_user(message.sender_id, cx)
|
||||
})?
|
||||
.await?;
|
||||
|
||||
let edited_at = message.edited_at.and_then(|t| -> Option<OffsetDateTime> {
|
||||
if let Ok(a) = OffsetDateTime::from_unix_timestamp(t as i64) {
|
||||
return Some(a);
|
||||
}
|
||||
|
||||
None
|
||||
});
|
||||
|
||||
Ok(ChannelMessage {
|
||||
id: ChannelMessageId::Saved(message.id),
|
||||
body: message.body,
|
||||
mentions: message
|
||||
.mentions
|
||||
.into_iter()
|
||||
.filter_map(|mention| {
|
||||
let range = mention.range?;
|
||||
Some((range.start as usize..range.end as usize, mention.user_id))
|
||||
})
|
||||
.collect(),
|
||||
timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64)?,
|
||||
sender,
|
||||
nonce: message.nonce.context("nonce is required")?.into(),
|
||||
reply_to_message_id: message.reply_to_message_id,
|
||||
edited_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_pending(&self) -> bool {
|
||||
matches!(self.id, ChannelMessageId::Pending(_))
|
||||
}
|
||||
|
||||
pub async fn from_proto_vec(
|
||||
proto_messages: Vec<proto::ChannelMessage>,
|
||||
user_store: &Entity<UserStore>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Vec<Self>> {
|
||||
let unique_user_ids = proto_messages
|
||||
.iter()
|
||||
.map(|m| m.sender_id)
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
user_store
|
||||
.update(cx, |user_store, cx| {
|
||||
user_store.get_users(unique_user_ids, cx)
|
||||
})?
|
||||
.await?;
|
||||
|
||||
let mut messages = Vec::with_capacity(proto_messages.len());
|
||||
for message in proto_messages {
|
||||
messages.push(ChannelMessage::from_proto(message, user_store, cx).await?);
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mentions_to_proto(mentions: &[(Range<usize>, UserId)]) -> Vec<proto::ChatMention> {
|
||||
mentions
|
||||
.iter()
|
||||
.map(|(range, user_id)| proto::ChatMention {
|
||||
range: Some(proto::Range {
|
||||
start: range.start as u64,
|
||||
end: range.end as u64,
|
||||
}),
|
||||
user_id: *user_id,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl sum_tree::Item for ChannelMessage {
|
||||
type Summary = ChannelMessageSummary;
|
||||
|
||||
fn summary(&self, _cx: &()) -> Self::Summary {
|
||||
ChannelMessageSummary {
|
||||
max_id: self.id,
|
||||
count: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChannelMessageId {
|
||||
fn default() -> Self {
|
||||
Self::Saved(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl sum_tree::Summary for ChannelMessageSummary {
|
||||
type Context = ();
|
||||
|
||||
fn zero(_cx: &Self::Context) -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn add_summary(&mut self, summary: &Self, _: &()) {
|
||||
self.max_id = summary.max_id;
|
||||
self.count += summary.count;
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> sum_tree::Dimension<'a, ChannelMessageSummary> for ChannelMessageId {
|
||||
fn zero(_cx: &()) -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn add_summary(&mut self, summary: &'a ChannelMessageSummary, _: &()) {
|
||||
debug_assert!(summary.max_id > *self);
|
||||
*self = summary.max_id;
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> sum_tree::Dimension<'a, ChannelMessageSummary> for Count {
|
||||
fn zero(_cx: &()) -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn add_summary(&mut self, summary: &'a ChannelMessageSummary, _: &()) {
|
||||
self.0 += summary.count;
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a str> for MessageParams {
|
||||
fn from(value: &'a str) -> Self {
|
||||
Self {
|
||||
text: value.into(),
|
||||
mentions: Vec::new(),
|
||||
reply_to_message_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
mod channel_index;
|
||||
|
||||
use crate::{ChannelMessage, channel_buffer::ChannelBuffer, channel_chat::ChannelChat};
|
||||
use crate::channel_buffer::ChannelBuffer;
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use channel_index::ChannelIndex;
|
||||
use client::{ChannelId, Client, ClientSettings, Subscription, User, UserId, UserStore};
|
||||
@@ -41,7 +41,6 @@ pub struct ChannelStore {
|
||||
outgoing_invites: HashSet<(ChannelId, UserId)>,
|
||||
update_channels_tx: mpsc::UnboundedSender<proto::UpdateChannels>,
|
||||
opened_buffers: HashMap<ChannelId, OpenEntityHandle<ChannelBuffer>>,
|
||||
opened_chats: HashMap<ChannelId, OpenEntityHandle<ChannelChat>>,
|
||||
client: Arc<Client>,
|
||||
did_subscribe: bool,
|
||||
channels_loaded: (watch::Sender<bool>, watch::Receiver<bool>),
|
||||
@@ -63,10 +62,8 @@ pub struct Channel {
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ChannelState {
|
||||
latest_chat_message: Option<u64>,
|
||||
latest_notes_version: NotesVersion,
|
||||
observed_notes_version: NotesVersion,
|
||||
observed_chat_message: Option<u64>,
|
||||
role: Option<ChannelRole>,
|
||||
}
|
||||
|
||||
@@ -196,7 +193,6 @@ impl ChannelStore {
|
||||
channel_participants: Default::default(),
|
||||
outgoing_invites: Default::default(),
|
||||
opened_buffers: Default::default(),
|
||||
opened_chats: Default::default(),
|
||||
update_channels_tx,
|
||||
client,
|
||||
user_store,
|
||||
@@ -362,89 +358,12 @@ impl ChannelStore {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn fetch_channel_messages(
|
||||
&self,
|
||||
message_ids: Vec<u64>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Vec<ChannelMessage>>> {
|
||||
let request = if message_ids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
self.client
|
||||
.request(proto::GetChannelMessagesById { message_ids }),
|
||||
)
|
||||
};
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Some(request) = request {
|
||||
let response = request.await?;
|
||||
let this = this.upgrade().context("channel store dropped")?;
|
||||
let user_store = this.read_with(cx, |this, _| this.user_store.clone())?;
|
||||
ChannelMessage::from_proto_vec(response.messages, &user_store, cx).await
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_channel_buffer_changed(&self, channel_id: ChannelId) -> bool {
|
||||
self.channel_states
|
||||
.get(&channel_id)
|
||||
.is_some_and(|state| state.has_channel_buffer_changed())
|
||||
}
|
||||
|
||||
pub fn has_new_messages(&self, channel_id: ChannelId) -> bool {
|
||||
self.channel_states
|
||||
.get(&channel_id)
|
||||
.is_some_and(|state| state.has_new_messages())
|
||||
}
|
||||
|
||||
pub fn set_acknowledged_message_id(&mut self, channel_id: ChannelId, message_id: Option<u64>) {
|
||||
if let Some(state) = self.channel_states.get_mut(&channel_id) {
|
||||
state.latest_chat_message = message_id;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn last_acknowledge_message_id(&self, channel_id: ChannelId) -> Option<u64> {
|
||||
self.channel_states.get(&channel_id).and_then(|state| {
|
||||
if let Some(last_message_id) = state.latest_chat_message
|
||||
&& state
|
||||
.last_acknowledged_message_id()
|
||||
.is_some_and(|id| id < last_message_id)
|
||||
{
|
||||
return state.last_acknowledged_message_id();
|
||||
}
|
||||
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
pub fn acknowledge_message_id(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
message_id: u64,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.channel_states
|
||||
.entry(channel_id)
|
||||
.or_default()
|
||||
.acknowledge_message_id(message_id);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn update_latest_message_id(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
message_id: u64,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.channel_states
|
||||
.entry(channel_id)
|
||||
.or_default()
|
||||
.update_latest_message_id(message_id);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn acknowledge_notes_version(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
@@ -473,23 +392,6 @@ impl ChannelStore {
|
||||
cx.notify()
|
||||
}
|
||||
|
||||
pub fn open_channel_chat(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Entity<ChannelChat>>> {
|
||||
let client = self.client.clone();
|
||||
let user_store = self.user_store.clone();
|
||||
let this = cx.entity();
|
||||
self.open_channel_resource(
|
||||
channel_id,
|
||||
"chat",
|
||||
|this| &mut this.opened_chats,
|
||||
async move |channel, cx| ChannelChat::new(channel, this, user_store, client, cx).await,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Asynchronously open a given resource associated with a channel.
|
||||
///
|
||||
/// Make sure that the resource is only opened once, even if this method
|
||||
@@ -931,13 +833,6 @@ impl ChannelStore {
|
||||
cx,
|
||||
);
|
||||
}
|
||||
for message_id in message.payload.observed_channel_message_id {
|
||||
this.acknowledge_message_id(
|
||||
ChannelId(message_id.channel_id),
|
||||
message_id.message_id,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
for membership in message.payload.channel_memberships {
|
||||
if let Some(role) = ChannelRole::from_i32(membership.role) {
|
||||
this.channel_states
|
||||
@@ -957,16 +852,6 @@ impl ChannelStore {
|
||||
self.outgoing_invites.clear();
|
||||
self.disconnect_channel_buffers_task.take();
|
||||
|
||||
for chat in self.opened_chats.values() {
|
||||
if let OpenEntityHandle::Open(chat) = chat
|
||||
&& let Some(chat) = chat.upgrade()
|
||||
{
|
||||
chat.update(cx, |chat, cx| {
|
||||
chat.rejoin(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut buffer_versions = Vec::new();
|
||||
for buffer in self.opened_buffers.values() {
|
||||
if let OpenEntityHandle::Open(buffer) = buffer
|
||||
@@ -1094,7 +979,6 @@ impl ChannelStore {
|
||||
self.channel_participants.clear();
|
||||
self.outgoing_invites.clear();
|
||||
self.opened_buffers.clear();
|
||||
self.opened_chats.clear();
|
||||
self.disconnect_channel_buffers_task = None;
|
||||
self.channel_states.clear();
|
||||
}
|
||||
@@ -1131,7 +1015,6 @@ impl ChannelStore {
|
||||
|
||||
let channels_changed = !payload.channels.is_empty()
|
||||
|| !payload.delete_channels.is_empty()
|
||||
|| !payload.latest_channel_message_ids.is_empty()
|
||||
|| !payload.latest_channel_buffer_versions.is_empty();
|
||||
|
||||
if channels_changed {
|
||||
@@ -1181,13 +1064,6 @@ impl ChannelStore {
|
||||
.update_latest_notes_version(latest_buffer_version.epoch, &version)
|
||||
}
|
||||
|
||||
for latest_channel_message in payload.latest_channel_message_ids {
|
||||
self.channel_states
|
||||
.entry(ChannelId(latest_channel_message.channel_id))
|
||||
.or_default()
|
||||
.update_latest_message_id(latest_channel_message.message_id);
|
||||
}
|
||||
|
||||
self.channels_loaded.0.try_send(true).log_err();
|
||||
}
|
||||
|
||||
@@ -1251,29 +1127,6 @@ impl ChannelState {
|
||||
.changed_since(&self.observed_notes_version.version))
|
||||
}
|
||||
|
||||
fn has_new_messages(&self) -> bool {
|
||||
let latest_message_id = self.latest_chat_message;
|
||||
let observed_message_id = self.observed_chat_message;
|
||||
|
||||
latest_message_id.is_some_and(|latest_message_id| {
|
||||
latest_message_id > observed_message_id.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
fn last_acknowledged_message_id(&self) -> Option<u64> {
|
||||
self.observed_chat_message
|
||||
}
|
||||
|
||||
fn acknowledge_message_id(&mut self, message_id: u64) {
|
||||
let observed = self.observed_chat_message.get_or_insert(message_id);
|
||||
*observed = (*observed).max(message_id);
|
||||
}
|
||||
|
||||
fn update_latest_message_id(&mut self, message_id: u64) {
|
||||
self.latest_chat_message =
|
||||
Some(message_id.max(self.latest_chat_message.unwrap_or_default()));
|
||||
}
|
||||
|
||||
fn acknowledge_notes_version(&mut self, epoch: u64, version: &clock::Global) {
|
||||
if self.observed_notes_version.epoch == epoch {
|
||||
self.observed_notes_version.version.join(version);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use crate::channel_chat::ChannelChatEvent;
|
||||
|
||||
use super::*;
|
||||
use client::{Client, UserStore, test::FakeServer};
|
||||
use client::{Client, UserStore};
|
||||
use clock::FakeSystemClock;
|
||||
use gpui::{App, AppContext as _, Entity, SemanticVersion, TestAppContext};
|
||||
use gpui::{App, AppContext as _, Entity, SemanticVersion};
|
||||
use http_client::FakeHttpClient;
|
||||
use rpc::proto::{self};
|
||||
use settings::SettingsStore;
|
||||
@@ -235,201 +233,6 @@ fn test_dangling_channel_paths(cx: &mut App) {
|
||||
assert_channels(&channel_store, &[(0, "a".to_string())], cx);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_channel_messages(cx: &mut TestAppContext) {
|
||||
let user_id = 5;
|
||||
let channel_id = 5;
|
||||
let channel_store = cx.update(init_test);
|
||||
let client = channel_store.read_with(cx, |s, _| s.client());
|
||||
let server = FakeServer::for_client(user_id, &client, cx).await;
|
||||
|
||||
// Get the available channels.
|
||||
server.send(proto::UpdateChannels {
|
||||
channels: vec![proto::Channel {
|
||||
id: channel_id,
|
||||
name: "the-channel".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: vec![],
|
||||
channel_order: 1,
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
cx.executor().run_until_parked();
|
||||
cx.update(|cx| {
|
||||
assert_channels(&channel_store, &[(0, "the-channel".to_string())], cx);
|
||||
});
|
||||
|
||||
// Join a channel and populate its existing messages.
|
||||
let channel = channel_store.update(cx, |store, cx| {
|
||||
let channel_id = store.ordered_channels().next().unwrap().1.id;
|
||||
store.open_channel_chat(channel_id, cx)
|
||||
});
|
||||
let join_channel = server.receive::<proto::JoinChannelChat>().await.unwrap();
|
||||
server.respond(
|
||||
join_channel.receipt(),
|
||||
proto::JoinChannelChatResponse {
|
||||
messages: vec![
|
||||
proto::ChannelMessage {
|
||||
id: 10,
|
||||
body: "a".into(),
|
||||
timestamp: 1000,
|
||||
sender_id: 5,
|
||||
mentions: vec![],
|
||||
nonce: Some(1.into()),
|
||||
reply_to_message_id: None,
|
||||
edited_at: None,
|
||||
},
|
||||
proto::ChannelMessage {
|
||||
id: 11,
|
||||
body: "b".into(),
|
||||
timestamp: 1001,
|
||||
sender_id: 6,
|
||||
mentions: vec![],
|
||||
nonce: Some(2.into()),
|
||||
reply_to_message_id: None,
|
||||
edited_at: None,
|
||||
},
|
||||
],
|
||||
done: false,
|
||||
},
|
||||
);
|
||||
|
||||
cx.executor().start_waiting();
|
||||
|
||||
// Client requests all users for the received messages
|
||||
let mut get_users = server.receive::<proto::GetUsers>().await.unwrap();
|
||||
get_users.payload.user_ids.sort();
|
||||
assert_eq!(get_users.payload.user_ids, vec![6]);
|
||||
server.respond(
|
||||
get_users.receipt(),
|
||||
proto::UsersResponse {
|
||||
users: vec![proto::User {
|
||||
id: 6,
|
||||
github_login: "maxbrunsfeld".into(),
|
||||
avatar_url: "http://avatar.com/maxbrunsfeld".into(),
|
||||
name: None,
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
let channel = channel.await.unwrap();
|
||||
channel.update(cx, |channel, _| {
|
||||
assert_eq!(
|
||||
channel
|
||||
.messages_in_range(0..2)
|
||||
.map(|message| (message.sender.github_login.clone(), message.body.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
&[
|
||||
("user-5".into(), "a".into()),
|
||||
("maxbrunsfeld".into(), "b".into())
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
// Receive a new message.
|
||||
server.send(proto::ChannelMessageSent {
|
||||
channel_id,
|
||||
message: Some(proto::ChannelMessage {
|
||||
id: 12,
|
||||
body: "c".into(),
|
||||
timestamp: 1002,
|
||||
sender_id: 7,
|
||||
mentions: vec![],
|
||||
nonce: Some(3.into()),
|
||||
reply_to_message_id: None,
|
||||
edited_at: None,
|
||||
}),
|
||||
});
|
||||
|
||||
// Client requests user for message since they haven't seen them yet
|
||||
let get_users = server.receive::<proto::GetUsers>().await.unwrap();
|
||||
assert_eq!(get_users.payload.user_ids, vec![7]);
|
||||
server.respond(
|
||||
get_users.receipt(),
|
||||
proto::UsersResponse {
|
||||
users: vec![proto::User {
|
||||
id: 7,
|
||||
github_login: "as-cii".into(),
|
||||
avatar_url: "http://avatar.com/as-cii".into(),
|
||||
name: None,
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
channel.next_event(cx).await,
|
||||
ChannelChatEvent::MessagesUpdated {
|
||||
old_range: 2..2,
|
||||
new_count: 1,
|
||||
}
|
||||
);
|
||||
channel.update(cx, |channel, _| {
|
||||
assert_eq!(
|
||||
channel
|
||||
.messages_in_range(2..3)
|
||||
.map(|message| (message.sender.github_login.clone(), message.body.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
&[("as-cii".into(), "c".into())]
|
||||
)
|
||||
});
|
||||
|
||||
// Scroll up to view older messages.
|
||||
channel.update(cx, |channel, cx| {
|
||||
channel.load_more_messages(cx).unwrap().detach();
|
||||
});
|
||||
let get_messages = server.receive::<proto::GetChannelMessages>().await.unwrap();
|
||||
assert_eq!(get_messages.payload.channel_id, 5);
|
||||
assert_eq!(get_messages.payload.before_message_id, 10);
|
||||
server.respond(
|
||||
get_messages.receipt(),
|
||||
proto::GetChannelMessagesResponse {
|
||||
done: true,
|
||||
messages: vec![
|
||||
proto::ChannelMessage {
|
||||
id: 8,
|
||||
body: "y".into(),
|
||||
timestamp: 998,
|
||||
sender_id: 5,
|
||||
nonce: Some(4.into()),
|
||||
mentions: vec![],
|
||||
reply_to_message_id: None,
|
||||
edited_at: None,
|
||||
},
|
||||
proto::ChannelMessage {
|
||||
id: 9,
|
||||
body: "z".into(),
|
||||
timestamp: 999,
|
||||
sender_id: 6,
|
||||
nonce: Some(5.into()),
|
||||
mentions: vec![],
|
||||
reply_to_message_id: None,
|
||||
edited_at: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
channel.next_event(cx).await,
|
||||
ChannelChatEvent::MessagesUpdated {
|
||||
old_range: 0..0,
|
||||
new_count: 2,
|
||||
}
|
||||
);
|
||||
channel.update(cx, |channel, _| {
|
||||
assert_eq!(
|
||||
channel
|
||||
.messages_in_range(0..2)
|
||||
.map(|message| (message.sender.github_login.clone(), message.body.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
&[
|
||||
("user-5".into(), "y".into()),
|
||||
("maxbrunsfeld".into(), "z".into())
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn init_test(cx: &mut App) -> Entity<ChannelStore> {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
|
||||
@@ -82,34 +82,10 @@ pub enum Plan {
|
||||
ZedFree,
|
||||
#[serde(alias = "ZedPro")]
|
||||
ZedPro,
|
||||
ZedProV2,
|
||||
#[serde(alias = "ZedProTrial")]
|
||||
ZedProTrial,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Plan::ZedFree => "zed_free",
|
||||
Plan::ZedPro => "zed_pro",
|
||||
Plan::ZedProTrial => "zed_pro_trial",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model_requests_limit(&self) -> UsageLimit {
|
||||
match self {
|
||||
Plan::ZedPro => UsageLimit::Limited(500),
|
||||
Plan::ZedProTrial => UsageLimit::Limited(150),
|
||||
Plan::ZedFree => UsageLimit::Limited(50),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn edit_predictions_limit(&self) -> UsageLimit {
|
||||
match self {
|
||||
Plan::ZedPro => UsageLimit::Unlimited,
|
||||
Plan::ZedProTrial => UsageLimit::Unlimited,
|
||||
Plan::ZedFree => UsageLimit::Limited(2_000),
|
||||
}
|
||||
}
|
||||
ZedProTrialV2,
|
||||
}
|
||||
|
||||
impl FromStr for Plan {
|
||||
@@ -353,6 +329,12 @@ mod tests {
|
||||
|
||||
let plan = serde_json::from_value::<Plan>(json!("zed_pro_trial")).unwrap();
|
||||
assert_eq!(plan, Plan::ZedProTrial);
|
||||
|
||||
let plan = serde_json::from_value::<Plan>(json!("zed_pro_v2")).unwrap();
|
||||
assert_eq!(plan, Plan::ZedProV2);
|
||||
|
||||
let plan = serde_json::from_value::<Plan>(json!("zed_pro_trial_v2")).unwrap();
|
||||
assert_eq!(plan, Plan::ZedProTrialV2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,7 +26,6 @@ use semantic_version::SemanticVersion;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ops::RangeInclusive;
|
||||
use std::{
|
||||
fmt::Write as _,
|
||||
future::Future,
|
||||
marker::PhantomData,
|
||||
ops::{Deref, DerefMut},
|
||||
@@ -486,9 +485,7 @@ pub struct ChannelsForUser {
|
||||
pub invited_channels: Vec<Channel>,
|
||||
|
||||
pub observed_buffer_versions: Vec<proto::ChannelBufferVersion>,
|
||||
pub observed_channel_messages: Vec<proto::ChannelMessageId>,
|
||||
pub latest_buffer_versions: Vec<proto::ChannelBufferVersion>,
|
||||
pub latest_channel_messages: Vec<proto::ChannelMessageId>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -7,7 +7,6 @@ pub mod contacts;
|
||||
pub mod contributors;
|
||||
pub mod embeddings;
|
||||
pub mod extensions;
|
||||
pub mod messages;
|
||||
pub mod notifications;
|
||||
pub mod projects;
|
||||
pub mod rooms;
|
||||
|
||||
@@ -618,25 +618,17 @@ impl Database {
|
||||
}
|
||||
drop(rows);
|
||||
|
||||
let latest_channel_messages = self.latest_channel_messages(&channel_ids, tx).await?;
|
||||
|
||||
let observed_buffer_versions = self
|
||||
.observed_channel_buffer_changes(&channel_ids_by_buffer_id, user_id, tx)
|
||||
.await?;
|
||||
|
||||
let observed_channel_messages = self
|
||||
.observed_channel_messages(&channel_ids, user_id, tx)
|
||||
.await?;
|
||||
|
||||
Ok(ChannelsForUser {
|
||||
channel_memberships,
|
||||
channels,
|
||||
invited_channels,
|
||||
channel_participants,
|
||||
latest_buffer_versions,
|
||||
latest_channel_messages,
|
||||
observed_buffer_versions,
|
||||
observed_channel_messages,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,725 +0,0 @@
|
||||
use super::*;
|
||||
use anyhow::Context as _;
|
||||
use rpc::Notification;
|
||||
use sea_orm::{SelectColumns, TryInsertResult};
|
||||
use time::OffsetDateTime;
|
||||
use util::ResultExt;
|
||||
|
||||
impl Database {
|
||||
/// Inserts a record representing a user joining the chat for a given channel.
|
||||
pub async fn join_channel_chat(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
connection_id: ConnectionId,
|
||||
user_id: UserId,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
let channel = self.get_channel_internal(channel_id, &tx).await?;
|
||||
self.check_user_is_channel_participant(&channel, user_id, &tx)
|
||||
.await?;
|
||||
channel_chat_participant::ActiveModel {
|
||||
id: ActiveValue::NotSet,
|
||||
channel_id: ActiveValue::Set(channel_id),
|
||||
user_id: ActiveValue::Set(user_id),
|
||||
connection_id: ActiveValue::Set(connection_id.id as i32),
|
||||
connection_server_id: ActiveValue::Set(ServerId(connection_id.owner_id as i32)),
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Removes `channel_chat_participant` records associated with the given connection ID.
|
||||
pub async fn channel_chat_connection_lost(
|
||||
&self,
|
||||
connection_id: ConnectionId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<()> {
|
||||
channel_chat_participant::Entity::delete_many()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(
|
||||
channel_chat_participant::Column::ConnectionServerId
|
||||
.eq(connection_id.owner_id),
|
||||
)
|
||||
.add(channel_chat_participant::Column::ConnectionId.eq(connection_id.id)),
|
||||
)
|
||||
.exec(tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes `channel_chat_participant` records associated with the given user ID so they
|
||||
/// will no longer get chat notifications.
|
||||
pub async fn leave_channel_chat(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
connection_id: ConnectionId,
|
||||
_user_id: UserId,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
channel_chat_participant::Entity::delete_many()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(
|
||||
channel_chat_participant::Column::ConnectionServerId
|
||||
.eq(connection_id.owner_id),
|
||||
)
|
||||
.add(channel_chat_participant::Column::ConnectionId.eq(connection_id.id))
|
||||
.add(channel_chat_participant::Column::ChannelId.eq(channel_id)),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Retrieves the messages in the specified channel.
|
||||
///
|
||||
/// Use `before_message_id` to paginate through the channel's messages.
|
||||
pub async fn get_channel_messages(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
count: usize,
|
||||
before_message_id: Option<MessageId>,
|
||||
) -> Result<Vec<proto::ChannelMessage>> {
|
||||
self.transaction(|tx| async move {
|
||||
let channel = self.get_channel_internal(channel_id, &tx).await?;
|
||||
self.check_user_is_channel_participant(&channel, user_id, &tx)
|
||||
.await?;
|
||||
|
||||
let mut condition =
|
||||
Condition::all().add(channel_message::Column::ChannelId.eq(channel_id));
|
||||
|
||||
if let Some(before_message_id) = before_message_id {
|
||||
condition = condition.add(channel_message::Column::Id.lt(before_message_id));
|
||||
}
|
||||
|
||||
let rows = channel_message::Entity::find()
|
||||
.filter(condition)
|
||||
.order_by_desc(channel_message::Column::Id)
|
||||
.limit(count as u64)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
self.load_channel_messages(rows, &tx).await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns the channel messages with the given IDs.
|
||||
pub async fn get_channel_messages_by_id(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
message_ids: &[MessageId],
|
||||
) -> Result<Vec<proto::ChannelMessage>> {
|
||||
self.transaction(|tx| async move {
|
||||
let rows = channel_message::Entity::find()
|
||||
.filter(channel_message::Column::Id.is_in(message_ids.iter().copied()))
|
||||
.order_by_desc(channel_message::Column::Id)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut channels = HashMap::<ChannelId, channel::Model>::default();
|
||||
for row in &rows {
|
||||
channels.insert(
|
||||
row.channel_id,
|
||||
self.get_channel_internal(row.channel_id, &tx).await?,
|
||||
);
|
||||
}
|
||||
|
||||
for (_, channel) in channels {
|
||||
self.check_user_is_channel_participant(&channel, user_id, &tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let messages = self.load_channel_messages(rows, &tx).await?;
|
||||
Ok(messages)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn load_channel_messages(
|
||||
&self,
|
||||
rows: Vec<channel_message::Model>,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<proto::ChannelMessage>> {
|
||||
let mut messages = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let nonce = row.nonce.as_u64_pair();
|
||||
proto::ChannelMessage {
|
||||
id: row.id.to_proto(),
|
||||
sender_id: row.sender_id.to_proto(),
|
||||
body: row.body,
|
||||
timestamp: row.sent_at.assume_utc().unix_timestamp() as u64,
|
||||
mentions: vec![],
|
||||
nonce: Some(proto::Nonce {
|
||||
upper_half: nonce.0,
|
||||
lower_half: nonce.1,
|
||||
}),
|
||||
reply_to_message_id: row.reply_to_message_id.map(|id| id.to_proto()),
|
||||
edited_at: row
|
||||
.edited_at
|
||||
.map(|t| t.assume_utc().unix_timestamp() as u64),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
messages.reverse();
|
||||
|
||||
let mut mentions = channel_message_mention::Entity::find()
|
||||
.filter(channel_message_mention::Column::MessageId.is_in(messages.iter().map(|m| m.id)))
|
||||
.order_by_asc(channel_message_mention::Column::MessageId)
|
||||
.order_by_asc(channel_message_mention::Column::StartOffset)
|
||||
.stream(tx)
|
||||
.await?;
|
||||
|
||||
let mut message_ix = 0;
|
||||
while let Some(mention) = mentions.next().await {
|
||||
let mention = mention?;
|
||||
let message_id = mention.message_id.to_proto();
|
||||
while let Some(message) = messages.get_mut(message_ix) {
|
||||
if message.id < message_id {
|
||||
message_ix += 1;
|
||||
} else {
|
||||
if message.id == message_id {
|
||||
message.mentions.push(proto::ChatMention {
|
||||
range: Some(proto::Range {
|
||||
start: mention.start_offset as u64,
|
||||
end: mention.end_offset as u64,
|
||||
}),
|
||||
user_id: mention.user_id.to_proto(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn format_mentions_to_entities(
|
||||
&self,
|
||||
message_id: MessageId,
|
||||
body: &str,
|
||||
mentions: &[proto::ChatMention],
|
||||
) -> Result<Vec<tables::channel_message_mention::ActiveModel>> {
|
||||
Ok(mentions
|
||||
.iter()
|
||||
.filter_map(|mention| {
|
||||
let range = mention.range.as_ref()?;
|
||||
if !body.is_char_boundary(range.start as usize)
|
||||
|| !body.is_char_boundary(range.end as usize)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(channel_message_mention::ActiveModel {
|
||||
message_id: ActiveValue::Set(message_id),
|
||||
start_offset: ActiveValue::Set(range.start as i32),
|
||||
end_offset: ActiveValue::Set(range.end as i32),
|
||||
user_id: ActiveValue::Set(UserId::from_proto(mention.user_id)),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Creates a new channel message.
|
||||
pub async fn create_channel_message(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
body: &str,
|
||||
mentions: &[proto::ChatMention],
|
||||
timestamp: OffsetDateTime,
|
||||
nonce: u128,
|
||||
reply_to_message_id: Option<MessageId>,
|
||||
) -> Result<CreatedChannelMessage> {
|
||||
self.transaction(|tx| async move {
|
||||
let channel = self.get_channel_internal(channel_id, &tx).await?;
|
||||
self.check_user_is_channel_participant(&channel, user_id, &tx)
|
||||
.await?;
|
||||
|
||||
let mut rows = channel_chat_participant::Entity::find()
|
||||
.filter(channel_chat_participant::Column::ChannelId.eq(channel_id))
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut is_participant = false;
|
||||
let mut participant_connection_ids = HashSet::default();
|
||||
let mut participant_user_ids = Vec::new();
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
if row.user_id == user_id {
|
||||
is_participant = true;
|
||||
}
|
||||
participant_user_ids.push(row.user_id);
|
||||
participant_connection_ids.insert(row.connection());
|
||||
}
|
||||
drop(rows);
|
||||
|
||||
if !is_participant {
|
||||
Err(anyhow!("not a chat participant"))?;
|
||||
}
|
||||
|
||||
let timestamp = timestamp.to_offset(time::UtcOffset::UTC);
|
||||
let timestamp = time::PrimitiveDateTime::new(timestamp.date(), timestamp.time());
|
||||
|
||||
let result = channel_message::Entity::insert(channel_message::ActiveModel {
|
||||
channel_id: ActiveValue::Set(channel_id),
|
||||
sender_id: ActiveValue::Set(user_id),
|
||||
body: ActiveValue::Set(body.to_string()),
|
||||
sent_at: ActiveValue::Set(timestamp),
|
||||
nonce: ActiveValue::Set(Uuid::from_u128(nonce)),
|
||||
id: ActiveValue::NotSet,
|
||||
reply_to_message_id: ActiveValue::Set(reply_to_message_id),
|
||||
edited_at: ActiveValue::NotSet,
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
channel_message::Column::SenderId,
|
||||
channel_message::Column::Nonce,
|
||||
])
|
||||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.do_nothing()
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
let message_id;
|
||||
let mut notifications = Vec::new();
|
||||
match result {
|
||||
TryInsertResult::Inserted(result) => {
|
||||
message_id = result.last_insert_id;
|
||||
let mentioned_user_ids =
|
||||
mentions.iter().map(|m| m.user_id).collect::<HashSet<_>>();
|
||||
|
||||
let mentions = self.format_mentions_to_entities(message_id, body, mentions)?;
|
||||
if !mentions.is_empty() {
|
||||
channel_message_mention::Entity::insert_many(mentions)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for mentioned_user in mentioned_user_ids {
|
||||
notifications.extend(
|
||||
self.create_notification(
|
||||
UserId::from_proto(mentioned_user),
|
||||
rpc::Notification::ChannelMessageMention {
|
||||
message_id: message_id.to_proto(),
|
||||
sender_id: user_id.to_proto(),
|
||||
channel_id: channel_id.to_proto(),
|
||||
},
|
||||
false,
|
||||
&tx,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
self.observe_channel_message_internal(channel_id, user_id, message_id, &tx)
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
message_id = channel_message::Entity::find()
|
||||
.filter(channel_message::Column::Nonce.eq(Uuid::from_u128(nonce)))
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.context("failed to insert message")?
|
||||
.id;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CreatedChannelMessage {
|
||||
message_id,
|
||||
participant_connection_ids,
|
||||
notifications,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn observe_channel_message(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
message_id: MessageId,
|
||||
) -> Result<NotificationBatch> {
|
||||
self.transaction(|tx| async move {
|
||||
self.observe_channel_message_internal(channel_id, user_id, message_id, &tx)
|
||||
.await?;
|
||||
let mut batch = NotificationBatch::default();
|
||||
batch.extend(
|
||||
self.mark_notification_as_read(
|
||||
user_id,
|
||||
&Notification::ChannelMessageMention {
|
||||
message_id: message_id.to_proto(),
|
||||
sender_id: Default::default(),
|
||||
channel_id: Default::default(),
|
||||
},
|
||||
&tx,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
Ok(batch)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn observe_channel_message_internal(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
message_id: MessageId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<()> {
|
||||
observed_channel_messages::Entity::insert(observed_channel_messages::ActiveModel {
|
||||
user_id: ActiveValue::Set(user_id),
|
||||
channel_id: ActiveValue::Set(channel_id),
|
||||
channel_message_id: ActiveValue::Set(message_id),
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
observed_channel_messages::Column::ChannelId,
|
||||
observed_channel_messages::Column::UserId,
|
||||
])
|
||||
.update_column(observed_channel_messages::Column::ChannelMessageId)
|
||||
.action_cond_where(observed_channel_messages::Column::ChannelMessageId.lt(message_id))
|
||||
.to_owned(),
|
||||
)
|
||||
// TODO: Try to upgrade SeaORM so we don't have to do this hack around their bug
|
||||
.exec_without_returning(tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn observed_channel_messages(
|
||||
&self,
|
||||
channel_ids: &[ChannelId],
|
||||
user_id: UserId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<proto::ChannelMessageId>> {
|
||||
let rows = observed_channel_messages::Entity::find()
|
||||
.filter(observed_channel_messages::Column::UserId.eq(user_id))
|
||||
.filter(
|
||||
observed_channel_messages::Column::ChannelId
|
||||
.is_in(channel_ids.iter().map(|id| id.0)),
|
||||
)
|
||||
.all(tx)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|message| proto::ChannelMessageId {
|
||||
channel_id: message.channel_id.to_proto(),
|
||||
message_id: message.channel_message_id.to_proto(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn latest_channel_messages(
|
||||
&self,
|
||||
channel_ids: &[ChannelId],
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<proto::ChannelMessageId>> {
|
||||
let mut values = String::new();
|
||||
for id in channel_ids {
|
||||
if !values.is_empty() {
|
||||
values.push_str(", ");
|
||||
}
|
||||
write!(&mut values, "({})", id).unwrap();
|
||||
}
|
||||
|
||||
if values.is_empty() {
|
||||
return Ok(Vec::default());
|
||||
}
|
||||
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT
|
||||
*
|
||||
FROM (
|
||||
SELECT
|
||||
*,
|
||||
row_number() OVER (
|
||||
PARTITION BY channel_id
|
||||
ORDER BY id DESC
|
||||
) as row_number
|
||||
FROM channel_messages
|
||||
WHERE
|
||||
channel_id in ({values})
|
||||
) AS messages
|
||||
WHERE
|
||||
row_number = 1
|
||||
"#,
|
||||
);
|
||||
|
||||
let stmt = Statement::from_string(self.pool.get_database_backend(), sql);
|
||||
let mut last_messages = channel_message::Model::find_by_statement(stmt)
|
||||
.stream(tx)
|
||||
.await?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
while let Some(result) = last_messages.next().await {
|
||||
let message = result?;
|
||||
results.push(proto::ChannelMessageId {
|
||||
channel_id: message.channel_id.to_proto(),
|
||||
message_id: message.id.to_proto(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn get_notification_kind_id_by_name(&self, notification_kind: &str) -> Option<i32> {
|
||||
self.notification_kinds_by_id
|
||||
.iter()
|
||||
.find(|(_, kind)| **kind == notification_kind)
|
||||
.map(|kind| kind.0.0)
|
||||
}
|
||||
|
||||
/// Removes the channel message with the given ID.
|
||||
pub async fn remove_channel_message(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
message_id: MessageId,
|
||||
user_id: UserId,
|
||||
) -> Result<(Vec<ConnectionId>, Vec<NotificationId>)> {
|
||||
self.transaction(|tx| async move {
|
||||
let mut rows = channel_chat_participant::Entity::find()
|
||||
.filter(channel_chat_participant::Column::ChannelId.eq(channel_id))
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut is_participant = false;
|
||||
let mut participant_connection_ids = Vec::new();
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
if row.user_id == user_id {
|
||||
is_participant = true;
|
||||
}
|
||||
participant_connection_ids.push(row.connection());
|
||||
}
|
||||
drop(rows);
|
||||
|
||||
if !is_participant {
|
||||
Err(anyhow!("not a chat participant"))?;
|
||||
}
|
||||
|
||||
let result = channel_message::Entity::delete_by_id(message_id)
|
||||
.filter(channel_message::Column::SenderId.eq(user_id))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected == 0 {
|
||||
let channel = self.get_channel_internal(channel_id, &tx).await?;
|
||||
if self
|
||||
.check_user_is_channel_admin(&channel, 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"))?;
|
||||
}
|
||||
}
|
||||
|
||||
let notification_kind_id =
|
||||
self.get_notification_kind_id_by_name("ChannelMessageMention");
|
||||
|
||||
let existing_notifications = notification::Entity::find()
|
||||
.filter(notification::Column::EntityId.eq(message_id))
|
||||
.filter(notification::Column::Kind.eq(notification_kind_id))
|
||||
.select_column(notification::Column::Id)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
let existing_notification_ids = existing_notifications
|
||||
.into_iter()
|
||||
.map(|notification| notification.id)
|
||||
.collect();
|
||||
|
||||
// remove all the mention notifications for this message
|
||||
notification::Entity::delete_many()
|
||||
.filter(notification::Column::EntityId.eq(message_id))
|
||||
.filter(notification::Column::Kind.eq(notification_kind_id))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok((participant_connection_ids, existing_notification_ids))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Updates the channel message with the given ID, body and timestamp(edited_at).
|
||||
pub async fn update_channel_message(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
message_id: MessageId,
|
||||
user_id: UserId,
|
||||
body: &str,
|
||||
mentions: &[proto::ChatMention],
|
||||
edited_at: OffsetDateTime,
|
||||
) -> Result<UpdatedChannelMessage> {
|
||||
self.transaction(|tx| async move {
|
||||
let channel = self.get_channel_internal(channel_id, &tx).await?;
|
||||
self.check_user_is_channel_participant(&channel, user_id, &tx)
|
||||
.await?;
|
||||
|
||||
let mut rows = channel_chat_participant::Entity::find()
|
||||
.filter(channel_chat_participant::Column::ChannelId.eq(channel_id))
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut is_participant = false;
|
||||
let mut participant_connection_ids = Vec::new();
|
||||
let mut participant_user_ids = Vec::new();
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
if row.user_id == user_id {
|
||||
is_participant = true;
|
||||
}
|
||||
participant_user_ids.push(row.user_id);
|
||||
participant_connection_ids.push(row.connection());
|
||||
}
|
||||
drop(rows);
|
||||
|
||||
if !is_participant {
|
||||
Err(anyhow!("not a chat participant"))?;
|
||||
}
|
||||
|
||||
let channel_message = channel_message::Entity::find_by_id(message_id)
|
||||
.filter(channel_message::Column::SenderId.eq(user_id))
|
||||
.one(&*tx)
|
||||
.await?;
|
||||
|
||||
let Some(channel_message) = channel_message else {
|
||||
Err(anyhow!("Channel message not found"))?
|
||||
};
|
||||
|
||||
let edited_at = edited_at.to_offset(time::UtcOffset::UTC);
|
||||
let edited_at = time::PrimitiveDateTime::new(edited_at.date(), edited_at.time());
|
||||
|
||||
let updated_message = channel_message::ActiveModel {
|
||||
body: ActiveValue::Set(body.to_string()),
|
||||
edited_at: ActiveValue::Set(Some(edited_at)),
|
||||
reply_to_message_id: ActiveValue::Unchanged(channel_message.reply_to_message_id),
|
||||
id: ActiveValue::Unchanged(message_id),
|
||||
channel_id: ActiveValue::Unchanged(channel_id),
|
||||
sender_id: ActiveValue::Unchanged(user_id),
|
||||
sent_at: ActiveValue::Unchanged(channel_message.sent_at),
|
||||
nonce: ActiveValue::Unchanged(channel_message.nonce),
|
||||
};
|
||||
|
||||
let result = channel_message::Entity::update_many()
|
||||
.set(updated_message)
|
||||
.filter(channel_message::Column::Id.eq(message_id))
|
||||
.filter(channel_message::Column::SenderId.eq(user_id))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
if result.rows_affected == 0 {
|
||||
return Err(anyhow!(
|
||||
"Attempted to edit a message (id: {message_id}) which does not exist anymore."
|
||||
))?;
|
||||
}
|
||||
|
||||
// we have to fetch the old mentions,
|
||||
// so we don't send a notification when the message has been edited that you are mentioned in
|
||||
let old_mentions = channel_message_mention::Entity::find()
|
||||
.filter(channel_message_mention::Column::MessageId.eq(message_id))
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
// remove all existing mentions
|
||||
channel_message_mention::Entity::delete_many()
|
||||
.filter(channel_message_mention::Column::MessageId.eq(message_id))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
let new_mentions = self.format_mentions_to_entities(message_id, body, mentions)?;
|
||||
if !new_mentions.is_empty() {
|
||||
// insert new mentions
|
||||
channel_message_mention::Entity::insert_many(new_mentions)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut update_mention_user_ids = HashSet::default();
|
||||
let mut new_mention_user_ids =
|
||||
mentions.iter().map(|m| m.user_id).collect::<HashSet<_>>();
|
||||
// Filter out users that were mentioned before
|
||||
for mention in &old_mentions {
|
||||
if new_mention_user_ids.contains(&mention.user_id.to_proto()) {
|
||||
update_mention_user_ids.insert(mention.user_id.to_proto());
|
||||
}
|
||||
|
||||
new_mention_user_ids.remove(&mention.user_id.to_proto());
|
||||
}
|
||||
|
||||
let notification_kind_id =
|
||||
self.get_notification_kind_id_by_name("ChannelMessageMention");
|
||||
|
||||
let existing_notifications = notification::Entity::find()
|
||||
.filter(notification::Column::EntityId.eq(message_id))
|
||||
.filter(notification::Column::Kind.eq(notification_kind_id))
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
// determine which notifications should be updated or deleted
|
||||
let mut deleted_notification_ids = HashSet::default();
|
||||
let mut updated_mention_notifications = Vec::new();
|
||||
for notification in existing_notifications {
|
||||
if update_mention_user_ids.contains(¬ification.recipient_id.to_proto()) {
|
||||
if let Some(notification) =
|
||||
self::notifications::model_to_proto(self, notification).log_err()
|
||||
{
|
||||
updated_mention_notifications.push(notification);
|
||||
}
|
||||
} else {
|
||||
deleted_notification_ids.insert(notification.id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut notifications = Vec::new();
|
||||
for mentioned_user in new_mention_user_ids {
|
||||
notifications.extend(
|
||||
self.create_notification(
|
||||
UserId::from_proto(mentioned_user),
|
||||
rpc::Notification::ChannelMessageMention {
|
||||
message_id: message_id.to_proto(),
|
||||
sender_id: user_id.to_proto(),
|
||||
channel_id: channel_id.to_proto(),
|
||||
},
|
||||
false,
|
||||
&tx,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(UpdatedChannelMessage {
|
||||
message_id,
|
||||
participant_connection_ids,
|
||||
notifications,
|
||||
reply_to_message_id: channel_message.reply_to_message_id,
|
||||
timestamp: channel_message.sent_at,
|
||||
deleted_mention_notification_ids: deleted_notification_ids
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
updated_mention_notifications,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -1193,7 +1193,6 @@ impl Database {
|
||||
self.transaction(|tx| async move {
|
||||
self.room_connection_lost(connection, &tx).await?;
|
||||
self.channel_buffer_connection_lost(connection, &tx).await?;
|
||||
self.channel_chat_connection_lost(connection, &tx).await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -7,7 +7,6 @@ mod db_tests;
|
||||
mod embedding_tests;
|
||||
mod extension_tests;
|
||||
mod feature_flag_tests;
|
||||
mod message_tests;
|
||||
mod user_tests;
|
||||
|
||||
use crate::migrations::run_database_migrations;
|
||||
@@ -21,7 +20,7 @@ use sqlx::migrate::MigrateDatabase;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicI32, AtomicU32, Ordering::SeqCst},
|
||||
atomic::{AtomicI32, Ordering::SeqCst},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
@@ -224,11 +223,3 @@ async fn new_test_user(db: &Arc<Database>, email: &str) -> UserId {
|
||||
.unwrap()
|
||||
.user_id
|
||||
}
|
||||
|
||||
static TEST_CONNECTION_ID: AtomicU32 = AtomicU32::new(1);
|
||||
fn new_test_connection(server: ServerId) -> ConnectionId {
|
||||
ConnectionId {
|
||||
id: TEST_CONNECTION_ID.fetch_add(1, SeqCst),
|
||||
owner_id: server.0 as u32,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
db::{
|
||||
Channel, ChannelId, ChannelRole, Database, NewUserParams, RoomId, UserId,
|
||||
tests::{assert_channel_tree_matches, channel_tree, new_test_connection, new_test_user},
|
||||
tests::{assert_channel_tree_matches, channel_tree, new_test_user},
|
||||
},
|
||||
test_both_dbs,
|
||||
};
|
||||
@@ -949,41 +949,6 @@ async fn test_user_is_channel_participant(db: &Arc<Database>) {
|
||||
)
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_guest_access,
|
||||
test_guest_access_postgres,
|
||||
test_guest_access_sqlite
|
||||
);
|
||||
|
||||
async fn test_guest_access(db: &Arc<Database>) {
|
||||
let server = db.create_server("test").await.unwrap();
|
||||
|
||||
let admin = new_test_user(db, "admin@example.com").await;
|
||||
let guest = new_test_user(db, "guest@example.com").await;
|
||||
let guest_connection = new_test_connection(server);
|
||||
|
||||
let zed_channel = db.create_root_channel("zed", admin).await.unwrap();
|
||||
db.set_channel_visibility(zed_channel, crate::db::ChannelVisibility::Public, admin)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
db.join_channel_chat(zed_channel, guest_connection, guest)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
db.join_channel(zed_channel, guest, guest_connection)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
db.join_channel_chat(zed_channel, guest_connection, guest)
|
||||
.await
|
||||
.is_ok()
|
||||
)
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_channel_tree(actual: Vec<Channel>, expected: &[(ChannelId, &[ChannelId])]) {
|
||||
let actual = actual
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
use super::new_test_user;
|
||||
use crate::{
|
||||
db::{ChannelRole, Database, MessageId},
|
||||
test_both_dbs,
|
||||
};
|
||||
use channel::mentions_to_proto;
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
test_both_dbs!(
|
||||
test_channel_message_retrieval,
|
||||
test_channel_message_retrieval_postgres,
|
||||
test_channel_message_retrieval_sqlite
|
||||
);
|
||||
|
||||
async fn test_channel_message_retrieval(db: &Arc<Database>) {
|
||||
let user = new_test_user(db, "user@example.com").await;
|
||||
let channel = db.create_channel("channel", None, user).await.unwrap().0;
|
||||
|
||||
let owner_id = db.create_server("test").await.unwrap().0 as u32;
|
||||
db.join_channel_chat(channel.id, rpc::ConnectionId { owner_id, id: 0 }, user)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut all_messages = Vec::new();
|
||||
for i in 0..10 {
|
||||
all_messages.push(
|
||||
db.create_channel_message(
|
||||
channel.id,
|
||||
user,
|
||||
&i.to_string(),
|
||||
&[],
|
||||
OffsetDateTime::now_utc(),
|
||||
i,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id
|
||||
.to_proto(),
|
||||
);
|
||||
}
|
||||
|
||||
let messages = db
|
||||
.get_channel_messages(channel.id, user, 3, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|message| message.id)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(messages, &all_messages[7..10]);
|
||||
|
||||
let messages = db
|
||||
.get_channel_messages(
|
||||
channel.id,
|
||||
user,
|
||||
4,
|
||||
Some(MessageId::from_proto(all_messages[6])),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|message| message.id)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(messages, &all_messages[2..6]);
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_channel_message_nonces,
|
||||
test_channel_message_nonces_postgres,
|
||||
test_channel_message_nonces_sqlite
|
||||
);
|
||||
|
||||
async fn test_channel_message_nonces(db: &Arc<Database>) {
|
||||
let user_a = new_test_user(db, "user_a@example.com").await;
|
||||
let user_b = new_test_user(db, "user_b@example.com").await;
|
||||
let user_c = new_test_user(db, "user_c@example.com").await;
|
||||
let channel = db.create_root_channel("channel", user_a).await.unwrap();
|
||||
db.invite_channel_member(channel, user_b, user_a, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
db.invite_channel_member(channel, user_c, user_a, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
db.respond_to_channel_invite(channel, user_b, true)
|
||||
.await
|
||||
.unwrap();
|
||||
db.respond_to_channel_invite(channel, user_c, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let owner_id = db.create_server("test").await.unwrap().0 as u32;
|
||||
db.join_channel_chat(channel, rpc::ConnectionId { owner_id, id: 0 }, user_a)
|
||||
.await
|
||||
.unwrap();
|
||||
db.join_channel_chat(channel, rpc::ConnectionId { owner_id, id: 1 }, user_b)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// As user A, create messages that reuse the same nonces. The requests
|
||||
// succeed, but return the same ids.
|
||||
let id1 = db
|
||||
.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"hi @user_b",
|
||||
&mentions_to_proto(&[(3..10, user_b.to_proto())]),
|
||||
OffsetDateTime::now_utc(),
|
||||
100,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
let id2 = db
|
||||
.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"hello, fellow users",
|
||||
&mentions_to_proto(&[]),
|
||||
OffsetDateTime::now_utc(),
|
||||
200,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
let id3 = db
|
||||
.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"bye @user_c (same nonce as first message)",
|
||||
&mentions_to_proto(&[(4..11, user_c.to_proto())]),
|
||||
OffsetDateTime::now_utc(),
|
||||
100,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
let id4 = db
|
||||
.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"omg (same nonce as second message)",
|
||||
&mentions_to_proto(&[]),
|
||||
OffsetDateTime::now_utc(),
|
||||
200,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
|
||||
// As a different user, reuse one of the same nonces. This request succeeds
|
||||
// and returns a different id.
|
||||
let id5 = db
|
||||
.create_channel_message(
|
||||
channel,
|
||||
user_b,
|
||||
"omg @user_a (same nonce as user_a's first message)",
|
||||
&mentions_to_proto(&[(4..11, user_a.to_proto())]),
|
||||
OffsetDateTime::now_utc(),
|
||||
100,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
|
||||
assert_ne!(id1, id2);
|
||||
assert_eq!(id1, id3);
|
||||
assert_eq!(id2, id4);
|
||||
assert_ne!(id5, id1);
|
||||
|
||||
let messages = db
|
||||
.get_channel_messages(channel, user_a, 5, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|m| (m.id, m.body, m.mentions))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
messages,
|
||||
&[
|
||||
(
|
||||
id1.to_proto(),
|
||||
"hi @user_b".into(),
|
||||
mentions_to_proto(&[(3..10, user_b.to_proto())]),
|
||||
),
|
||||
(
|
||||
id2.to_proto(),
|
||||
"hello, fellow users".into(),
|
||||
mentions_to_proto(&[])
|
||||
),
|
||||
(
|
||||
id5.to_proto(),
|
||||
"omg @user_a (same nonce as user_a's first message)".into(),
|
||||
mentions_to_proto(&[(4..11, user_a.to_proto())]),
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_unseen_channel_messages,
|
||||
test_unseen_channel_messages_postgres,
|
||||
test_unseen_channel_messages_sqlite
|
||||
);
|
||||
|
||||
async fn test_unseen_channel_messages(db: &Arc<Database>) {
|
||||
let user = new_test_user(db, "user_a@example.com").await;
|
||||
let observer = new_test_user(db, "user_b@example.com").await;
|
||||
|
||||
let channel_1 = db.create_root_channel("channel", user).await.unwrap();
|
||||
let channel_2 = db.create_root_channel("channel-2", user).await.unwrap();
|
||||
|
||||
db.invite_channel_member(channel_1, observer, user, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
db.invite_channel_member(channel_2, observer, user, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.respond_to_channel_invite(channel_1, observer, true)
|
||||
.await
|
||||
.unwrap();
|
||||
db.respond_to_channel_invite(channel_2, observer, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let owner_id = db.create_server("test").await.unwrap().0 as u32;
|
||||
let user_connection_id = rpc::ConnectionId { owner_id, id: 0 };
|
||||
|
||||
db.join_channel_chat(channel_1, user_connection_id, user)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _ = db
|
||||
.create_channel_message(
|
||||
channel_1,
|
||||
user,
|
||||
"1_1",
|
||||
&[],
|
||||
OffsetDateTime::now_utc(),
|
||||
1,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _ = db
|
||||
.create_channel_message(
|
||||
channel_1,
|
||||
user,
|
||||
"1_2",
|
||||
&[],
|
||||
OffsetDateTime::now_utc(),
|
||||
2,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let third_message = db
|
||||
.create_channel_message(
|
||||
channel_1,
|
||||
user,
|
||||
"1_3",
|
||||
&[],
|
||||
OffsetDateTime::now_utc(),
|
||||
3,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
|
||||
db.join_channel_chat(channel_2, user_connection_id, user)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fourth_message = db
|
||||
.create_channel_message(
|
||||
channel_2,
|
||||
user,
|
||||
"2_1",
|
||||
&[],
|
||||
OffsetDateTime::now_utc(),
|
||||
4,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
|
||||
// Check that observer has new messages
|
||||
let latest_messages = db
|
||||
.transaction(|tx| async move {
|
||||
db.latest_channel_messages(&[channel_1, channel_2], &tx)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
latest_messages,
|
||||
[
|
||||
rpc::proto::ChannelMessageId {
|
||||
channel_id: channel_1.to_proto(),
|
||||
message_id: third_message.to_proto(),
|
||||
},
|
||||
rpc::proto::ChannelMessageId {
|
||||
channel_id: channel_2.to_proto(),
|
||||
message_id: fourth_message.to_proto(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_channel_message_mentions,
|
||||
test_channel_message_mentions_postgres,
|
||||
test_channel_message_mentions_sqlite
|
||||
);
|
||||
|
||||
async fn test_channel_message_mentions(db: &Arc<Database>) {
|
||||
let user_a = new_test_user(db, "user_a@example.com").await;
|
||||
let user_b = new_test_user(db, "user_b@example.com").await;
|
||||
let user_c = new_test_user(db, "user_c@example.com").await;
|
||||
|
||||
let channel = db
|
||||
.create_channel("channel", None, user_a)
|
||||
.await
|
||||
.unwrap()
|
||||
.0
|
||||
.id;
|
||||
db.invite_channel_member(channel, user_b, user_a, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
db.respond_to_channel_invite(channel, user_b, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let owner_id = db.create_server("test").await.unwrap().0 as u32;
|
||||
let connection_id = rpc::ConnectionId { owner_id, id: 0 };
|
||||
db.join_channel_chat(channel, connection_id, user_a)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"hi @user_b and @user_c",
|
||||
&mentions_to_proto(&[(3..10, user_b.to_proto()), (15..22, user_c.to_proto())]),
|
||||
OffsetDateTime::now_utc(),
|
||||
1,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"bye @user_c",
|
||||
&mentions_to_proto(&[(4..11, user_c.to_proto())]),
|
||||
OffsetDateTime::now_utc(),
|
||||
2,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"umm",
|
||||
&mentions_to_proto(&[]),
|
||||
OffsetDateTime::now_utc(),
|
||||
3,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"@user_b, stop.",
|
||||
&mentions_to_proto(&[(0..7, user_b.to_proto())]),
|
||||
OffsetDateTime::now_utc(),
|
||||
4,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let messages = db
|
||||
.get_channel_messages(channel, user_b, 5, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|m| (m.body, m.mentions))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
&messages,
|
||||
&[
|
||||
(
|
||||
"hi @user_b and @user_c".into(),
|
||||
mentions_to_proto(&[(3..10, user_b.to_proto()), (15..22, user_c.to_proto())]),
|
||||
),
|
||||
(
|
||||
"bye @user_c".into(),
|
||||
mentions_to_proto(&[(4..11, user_c.to_proto())]),
|
||||
),
|
||||
("umm".into(), mentions_to_proto(&[]),),
|
||||
(
|
||||
"@user_b, stop.".into(),
|
||||
mentions_to_proto(&[(0..7, user_b.to_proto())]),
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,9 @@ use crate::api::{CloudflareIpCountryHeader, SystemIdHeader};
|
||||
use crate::{
|
||||
AppState, Error, Result, auth,
|
||||
db::{
|
||||
self, BufferId, Capability, Channel, ChannelId, ChannelRole, ChannelsForUser,
|
||||
CreatedChannelMessage, Database, InviteMemberResult, MembershipUpdated, MessageId,
|
||||
NotificationId, ProjectId, RejoinedProject, RemoveChannelMemberResult,
|
||||
RespondToChannelInvite, RoomId, ServerId, UpdatedChannelMessage, User, UserId,
|
||||
self, BufferId, Capability, Channel, ChannelId, ChannelRole, ChannelsForUser, Database,
|
||||
InviteMemberResult, MembershipUpdated, NotificationId, ProjectId, RejoinedProject,
|
||||
RemoveChannelMemberResult, RespondToChannelInvite, RoomId, ServerId, User, UserId,
|
||||
},
|
||||
executor::Executor,
|
||||
};
|
||||
@@ -66,7 +65,6 @@ use std::{
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::{Semaphore, watch};
|
||||
use tower::ServiceBuilder;
|
||||
use tracing::{
|
||||
@@ -80,8 +78,6 @@ pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
// kubernetes gives terminated pods 10s to shutdown gracefully. After they're gone, we can clean up old resources.
|
||||
pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
const MESSAGE_COUNT_PER_PAGE: usize = 100;
|
||||
const MAX_MESSAGE_LEN: usize = 1024;
|
||||
const NOTIFICATION_COUNT_PER_PAGE: usize = 50;
|
||||
const MAX_CONCURRENT_CONNECTIONS: usize = 512;
|
||||
|
||||
@@ -3597,235 +3593,36 @@ fn send_notifications(
|
||||
|
||||
/// Send a message to the channel
|
||||
async fn send_channel_message(
|
||||
request: proto::SendChannelMessage,
|
||||
response: Response<proto::SendChannelMessage>,
|
||||
session: MessageContext,
|
||||
_request: proto::SendChannelMessage,
|
||||
_response: Response<proto::SendChannelMessage>,
|
||||
_session: MessageContext,
|
||||
) -> Result<()> {
|
||||
// Validate the message body.
|
||||
let body = request.body.trim().to_string();
|
||||
if body.len() > MAX_MESSAGE_LEN {
|
||||
return Err(anyhow!("message is too long"))?;
|
||||
}
|
||||
if body.is_empty() {
|
||||
return Err(anyhow!("message can't be blank"))?;
|
||||
}
|
||||
|
||||
// TODO: adjust mentions if body is trimmed
|
||||
|
||||
let timestamp = OffsetDateTime::now_utc();
|
||||
let nonce = request.nonce.context("nonce can't be blank")?;
|
||||
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let CreatedChannelMessage {
|
||||
message_id,
|
||||
participant_connection_ids,
|
||||
notifications,
|
||||
} = session
|
||||
.db()
|
||||
.await
|
||||
.create_channel_message(
|
||||
channel_id,
|
||||
session.user_id(),
|
||||
&body,
|
||||
&request.mentions,
|
||||
timestamp,
|
||||
nonce.clone().into(),
|
||||
request.reply_to_message_id.map(MessageId::from_proto),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let message = proto::ChannelMessage {
|
||||
sender_id: session.user_id().to_proto(),
|
||||
id: message_id.to_proto(),
|
||||
body,
|
||||
mentions: request.mentions,
|
||||
timestamp: timestamp.unix_timestamp() as u64,
|
||||
nonce: Some(nonce),
|
||||
reply_to_message_id: request.reply_to_message_id,
|
||||
edited_at: None,
|
||||
};
|
||||
broadcast(
|
||||
Some(session.connection_id),
|
||||
participant_connection_ids.clone(),
|
||||
|connection| {
|
||||
session.peer.send(
|
||||
connection,
|
||||
proto::ChannelMessageSent {
|
||||
channel_id: channel_id.to_proto(),
|
||||
message: Some(message.clone()),
|
||||
},
|
||||
)
|
||||
},
|
||||
);
|
||||
response.send(proto::SendChannelMessageResponse {
|
||||
message: Some(message),
|
||||
})?;
|
||||
|
||||
let pool = &*session.connection_pool().await;
|
||||
let non_participants =
|
||||
pool.channel_connection_ids(channel_id)
|
||||
.filter_map(|(connection_id, _)| {
|
||||
if participant_connection_ids.contains(&connection_id) {
|
||||
None
|
||||
} else {
|
||||
Some(connection_id)
|
||||
}
|
||||
});
|
||||
broadcast(None, non_participants, |peer_id| {
|
||||
session.peer.send(
|
||||
peer_id,
|
||||
proto::UpdateChannels {
|
||||
latest_channel_message_ids: vec![proto::ChannelMessageId {
|
||||
channel_id: channel_id.to_proto(),
|
||||
message_id: message_id.to_proto(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
});
|
||||
send_notifications(pool, &session.peer, notifications);
|
||||
|
||||
Ok(())
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
}
|
||||
|
||||
/// Delete a channel message
|
||||
async fn remove_channel_message(
|
||||
request: proto::RemoveChannelMessage,
|
||||
response: Response<proto::RemoveChannelMessage>,
|
||||
session: MessageContext,
|
||||
_request: proto::RemoveChannelMessage,
|
||||
_response: Response<proto::RemoveChannelMessage>,
|
||||
_session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let message_id = MessageId::from_proto(request.message_id);
|
||||
let (connection_ids, existing_notification_ids) = session
|
||||
.db()
|
||||
.await
|
||||
.remove_channel_message(channel_id, message_id, session.user_id())
|
||||
.await?;
|
||||
|
||||
broadcast(
|
||||
Some(session.connection_id),
|
||||
connection_ids,
|
||||
move |connection| {
|
||||
session.peer.send(connection, request.clone())?;
|
||||
|
||||
for notification_id in &existing_notification_ids {
|
||||
session.peer.send(
|
||||
connection,
|
||||
proto::DeleteNotification {
|
||||
notification_id: (*notification_id).to_proto(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
response.send(proto::Ack {})?;
|
||||
Ok(())
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
}
|
||||
|
||||
async fn update_channel_message(
|
||||
request: proto::UpdateChannelMessage,
|
||||
response: Response<proto::UpdateChannelMessage>,
|
||||
session: MessageContext,
|
||||
_request: proto::UpdateChannelMessage,
|
||||
_response: Response<proto::UpdateChannelMessage>,
|
||||
_session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let message_id = MessageId::from_proto(request.message_id);
|
||||
let updated_at = OffsetDateTime::now_utc();
|
||||
let UpdatedChannelMessage {
|
||||
message_id,
|
||||
participant_connection_ids,
|
||||
notifications,
|
||||
reply_to_message_id,
|
||||
timestamp,
|
||||
deleted_mention_notification_ids,
|
||||
updated_mention_notifications,
|
||||
} = session
|
||||
.db()
|
||||
.await
|
||||
.update_channel_message(
|
||||
channel_id,
|
||||
message_id,
|
||||
session.user_id(),
|
||||
request.body.as_str(),
|
||||
&request.mentions,
|
||||
updated_at,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let nonce = request.nonce.clone().context("nonce can't be blank")?;
|
||||
|
||||
let message = proto::ChannelMessage {
|
||||
sender_id: session.user_id().to_proto(),
|
||||
id: message_id.to_proto(),
|
||||
body: request.body.clone(),
|
||||
mentions: request.mentions.clone(),
|
||||
timestamp: timestamp.assume_utc().unix_timestamp() as u64,
|
||||
nonce: Some(nonce),
|
||||
reply_to_message_id: reply_to_message_id.map(|id| id.to_proto()),
|
||||
edited_at: Some(updated_at.unix_timestamp() as u64),
|
||||
};
|
||||
|
||||
response.send(proto::Ack {})?;
|
||||
|
||||
let pool = &*session.connection_pool().await;
|
||||
broadcast(
|
||||
Some(session.connection_id),
|
||||
participant_connection_ids,
|
||||
|connection| {
|
||||
session.peer.send(
|
||||
connection,
|
||||
proto::ChannelMessageUpdate {
|
||||
channel_id: channel_id.to_proto(),
|
||||
message: Some(message.clone()),
|
||||
},
|
||||
)?;
|
||||
|
||||
for notification_id in &deleted_mention_notification_ids {
|
||||
session.peer.send(
|
||||
connection,
|
||||
proto::DeleteNotification {
|
||||
notification_id: (*notification_id).to_proto(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
for notification in &updated_mention_notifications {
|
||||
session.peer.send(
|
||||
connection,
|
||||
proto::UpdateNotification {
|
||||
notification: Some(notification.clone()),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
|
||||
send_notifications(pool, &session.peer, notifications);
|
||||
|
||||
Ok(())
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
}
|
||||
|
||||
/// Mark a channel message as read
|
||||
async fn acknowledge_channel_message(
|
||||
request: proto::AckChannelMessage,
|
||||
session: MessageContext,
|
||||
_request: proto::AckChannelMessage,
|
||||
_session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let message_id = MessageId::from_proto(request.message_id);
|
||||
let notifications = session
|
||||
.db()
|
||||
.await
|
||||
.observe_channel_message(channel_id, session.user_id(), message_id)
|
||||
.await?;
|
||||
send_notifications(
|
||||
&*session.connection_pool().await,
|
||||
&session.peer,
|
||||
notifications,
|
||||
);
|
||||
Ok(())
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
}
|
||||
|
||||
/// Mark a buffer version as synced
|
||||
@@ -3878,84 +3675,37 @@ async fn get_supermaven_api_key(
|
||||
|
||||
/// Start receiving chat updates for a channel
|
||||
async fn join_channel_chat(
|
||||
request: proto::JoinChannelChat,
|
||||
response: Response<proto::JoinChannelChat>,
|
||||
session: MessageContext,
|
||||
_request: proto::JoinChannelChat,
|
||||
_response: Response<proto::JoinChannelChat>,
|
||||
_session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
|
||||
let db = session.db().await;
|
||||
db.join_channel_chat(channel_id, session.connection_id, session.user_id())
|
||||
.await?;
|
||||
let messages = db
|
||||
.get_channel_messages(channel_id, session.user_id(), MESSAGE_COUNT_PER_PAGE, None)
|
||||
.await?;
|
||||
response.send(proto::JoinChannelChatResponse {
|
||||
done: messages.len() < MESSAGE_COUNT_PER_PAGE,
|
||||
messages,
|
||||
})?;
|
||||
Ok(())
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
}
|
||||
|
||||
/// Stop receiving chat updates for a channel
|
||||
async fn leave_channel_chat(
|
||||
request: proto::LeaveChannelChat,
|
||||
session: MessageContext,
|
||||
_request: proto::LeaveChannelChat,
|
||||
_session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
session
|
||||
.db()
|
||||
.await
|
||||
.leave_channel_chat(channel_id, session.connection_id, session.user_id())
|
||||
.await?;
|
||||
Ok(())
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
}
|
||||
|
||||
/// Retrieve the chat history for a channel
|
||||
async fn get_channel_messages(
|
||||
request: proto::GetChannelMessages,
|
||||
response: Response<proto::GetChannelMessages>,
|
||||
session: MessageContext,
|
||||
_request: proto::GetChannelMessages,
|
||||
_response: Response<proto::GetChannelMessages>,
|
||||
_session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let messages = session
|
||||
.db()
|
||||
.await
|
||||
.get_channel_messages(
|
||||
channel_id,
|
||||
session.user_id(),
|
||||
MESSAGE_COUNT_PER_PAGE,
|
||||
Some(MessageId::from_proto(request.before_message_id)),
|
||||
)
|
||||
.await?;
|
||||
response.send(proto::GetChannelMessagesResponse {
|
||||
done: messages.len() < MESSAGE_COUNT_PER_PAGE,
|
||||
messages,
|
||||
})?;
|
||||
Ok(())
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
}
|
||||
|
||||
/// Retrieve specific chat messages
|
||||
async fn get_channel_messages_by_id(
|
||||
request: proto::GetChannelMessagesById,
|
||||
response: Response<proto::GetChannelMessagesById>,
|
||||
session: MessageContext,
|
||||
_request: proto::GetChannelMessagesById,
|
||||
_response: Response<proto::GetChannelMessagesById>,
|
||||
_session: MessageContext,
|
||||
) -> Result<()> {
|
||||
let message_ids = request
|
||||
.message_ids
|
||||
.iter()
|
||||
.map(|id| MessageId::from_proto(*id))
|
||||
.collect::<Vec<_>>();
|
||||
let messages = session
|
||||
.db()
|
||||
.await
|
||||
.get_channel_messages_by_id(session.user_id(), &message_ids)
|
||||
.await?;
|
||||
response.send(proto::GetChannelMessagesResponse {
|
||||
done: messages.len() < MESSAGE_COUNT_PER_PAGE,
|
||||
messages,
|
||||
})?;
|
||||
Ok(())
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
}
|
||||
|
||||
/// Retrieve the current users notifications
|
||||
@@ -4095,7 +3845,6 @@ fn build_update_user_channels(channels: &ChannelsForUser) -> proto::UpdateUserCh
|
||||
})
|
||||
.collect(),
|
||||
observed_channel_buffer_version: channels.observed_buffer_versions.clone(),
|
||||
observed_channel_message_id: channels.observed_channel_messages.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4107,7 +3856,6 @@ fn build_channels_update(channels: ChannelsForUser) -> proto::UpdateChannels {
|
||||
}
|
||||
|
||||
update.latest_channel_buffer_versions = channels.latest_buffer_versions;
|
||||
update.latest_channel_message_ids = channels.latest_channel_messages;
|
||||
|
||||
for (channel_id, participants) in channels.channel_participants {
|
||||
update
|
||||
|
||||
@@ -6,7 +6,6 @@ use gpui::{Entity, TestAppContext};
|
||||
|
||||
mod channel_buffer_tests;
|
||||
mod channel_guest_tests;
|
||||
mod channel_message_tests;
|
||||
mod channel_tests;
|
||||
mod editor_tests;
|
||||
mod following_tests;
|
||||
|
||||
@@ -1,725 +0,0 @@
|
||||
use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer};
|
||||
use channel::{ChannelChat, ChannelMessageId, MessageParams};
|
||||
use collab_ui::chat_panel::ChatPanel;
|
||||
use gpui::{BackgroundExecutor, Entity, TestAppContext};
|
||||
use rpc::Notification;
|
||||
use workspace::dock::Panel;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_basic_channel_messages(
|
||||
executor: BackgroundExecutor,
|
||||
mut cx_a: &mut TestAppContext,
|
||||
mut cx_b: &mut TestAppContext,
|
||||
mut cx_c: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
let client_c = server.create_client(cx_c, "user_c").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b), (&client_c, cx_c)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let channel_chat_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_chat_b = client_b
|
||||
.channel_store()
|
||||
.update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let message_id = channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
c.send_message(
|
||||
MessageParams {
|
||||
text: "hi @user_c!".into(),
|
||||
mentions: vec![(3..10, client_c.id())],
|
||||
reply_to_message_id: None,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("two".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
channel_chat_b
|
||||
.update(cx_b, |c, cx| c.send_message("three".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let channel_chat_c = client_c
|
||||
.channel_store()
|
||||
.update(cx_c, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for (chat, cx) in [
|
||||
(&channel_chat_a, &mut cx_a),
|
||||
(&channel_chat_b, &mut cx_b),
|
||||
(&channel_chat_c, &mut cx_c),
|
||||
] {
|
||||
chat.update(*cx, |c, _| {
|
||||
assert_eq!(
|
||||
c.messages()
|
||||
.iter()
|
||||
.map(|m| (m.body.as_str(), m.mentions.as_slice()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("hi @user_c!", [(3..10, client_c.id())].as_slice()),
|
||||
("two", &[]),
|
||||
("three", &[])
|
||||
],
|
||||
"results for user {}",
|
||||
c.client().id(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
client_c.notification_store().update(cx_c, |store, _| {
|
||||
assert_eq!(store.notification_count(), 2);
|
||||
assert_eq!(store.unread_notification_count(), 1);
|
||||
assert_eq!(
|
||||
store.notification_at(0).unwrap().notification,
|
||||
Notification::ChannelMessageMention {
|
||||
message_id,
|
||||
sender_id: client_a.id(),
|
||||
channel_id: channel_id.0,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
store.notification_at(1).unwrap().notification,
|
||||
Notification::ChannelInvitation {
|
||||
channel_id: channel_id.0,
|
||||
channel_name: "the-channel".to_string(),
|
||||
inviter_id: client_a.id()
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_rejoin_channel_chat(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let channel_chat_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_chat_b = client_b
|
||||
.channel_store()
|
||||
.update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
channel_chat_b
|
||||
.update(cx_b, |c, cx| c.send_message("two".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
server.forbid_connections();
|
||||
server.disconnect_client(client_a.peer_id().unwrap());
|
||||
|
||||
// While client A is disconnected, clients A and B both send new messages.
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("three".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("four".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
channel_chat_b
|
||||
.update(cx_b, |c, cx| c.send_message("five".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
channel_chat_b
|
||||
.update(cx_b, |c, cx| c.send_message("six".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client A reconnects.
|
||||
server.allow_connections();
|
||||
executor.advance_clock(RECONNECT_TIMEOUT);
|
||||
|
||||
// Client A fetches the messages that were sent while they were disconnected
|
||||
// and resends their own messages which failed to send.
|
||||
let expected_messages = &["one", "two", "five", "six", "three", "four"];
|
||||
assert_messages(&channel_chat_a, expected_messages, cx_a);
|
||||
assert_messages(&channel_chat_b, expected_messages, cx_b);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_remove_channel_message(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
cx_c: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
let client_c = server.create_client(cx_c, "user_c").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b), (&client_c, cx_c)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let channel_chat_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_chat_b = client_b
|
||||
.channel_store()
|
||||
.update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client A sends some messages.
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let msg_id_2 = channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
c.send_message(
|
||||
MessageParams {
|
||||
text: "two @user_b".to_string(),
|
||||
mentions: vec![(4..12, client_b.id())],
|
||||
reply_to_message_id: None,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("three".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Clients A and B see all of the messages.
|
||||
executor.run_until_parked();
|
||||
let expected_messages = &["one", "two @user_b", "three"];
|
||||
assert_messages(&channel_chat_a, expected_messages, cx_a);
|
||||
assert_messages(&channel_chat_b, expected_messages, cx_b);
|
||||
|
||||
// Ensure that client B received a notification for the mention.
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
assert_eq!(store.notification_count(), 2);
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert_eq!(
|
||||
entry.notification,
|
||||
Notification::ChannelMessageMention {
|
||||
message_id: msg_id_2,
|
||||
sender_id: client_a.id(),
|
||||
channel_id: channel_id.0,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Client A deletes one of their messages.
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
let ChannelMessageId::Saved(id) = c.message(1).id else {
|
||||
panic!("message not saved")
|
||||
};
|
||||
c.remove_message(id, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client B sees that the message is gone.
|
||||
executor.run_until_parked();
|
||||
let expected_messages = &["one", "three"];
|
||||
assert_messages(&channel_chat_a, expected_messages, cx_a);
|
||||
assert_messages(&channel_chat_b, expected_messages, cx_b);
|
||||
|
||||
// Client C joins the channel chat, and does not see the deleted message.
|
||||
let channel_chat_c = client_c
|
||||
.channel_store()
|
||||
.update(cx_c, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_messages(&channel_chat_c, expected_messages, cx_c);
|
||||
|
||||
// Ensure we remove the notifications when the message is removed
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
// First notification is the channel invitation, second would be the mention
|
||||
// notification, which should now be removed.
|
||||
assert_eq!(store.notification_count(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_messages(chat: &Entity<ChannelChat>, messages: &[&str], cx: &mut TestAppContext) {
|
||||
assert_eq!(
|
||||
chat.read_with(cx, |chat, _| {
|
||||
chat.messages()
|
||||
.iter()
|
||||
.map(|m| m.body.clone())
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
messages
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_channel_message_changes(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b)],
|
||||
)
|
||||
.await;
|
||||
|
||||
// Client A sends a message, client B should see that there is a new message.
|
||||
let channel_chat_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let b_has_messages = cx_b.update(|cx| {
|
||||
client_b
|
||||
.channel_store()
|
||||
.read(cx)
|
||||
.has_new_messages(channel_id)
|
||||
});
|
||||
|
||||
assert!(b_has_messages);
|
||||
|
||||
// Opening the chat should clear the changed flag.
|
||||
cx_b.update(|cx| {
|
||||
collab_ui::init(&client_b.app_state, cx);
|
||||
});
|
||||
let project_b = client_b.build_empty_local_project(cx_b);
|
||||
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
|
||||
|
||||
let chat_panel_b = workspace_b.update_in(cx_b, ChatPanel::new);
|
||||
chat_panel_b
|
||||
.update_in(cx_b, |chat_panel, window, cx| {
|
||||
chat_panel.set_active(true, window, cx);
|
||||
chat_panel.select_channel(channel_id, None, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let b_has_messages = cx_b.update(|_, cx| {
|
||||
client_b
|
||||
.channel_store()
|
||||
.read(cx)
|
||||
.has_new_messages(channel_id)
|
||||
});
|
||||
|
||||
assert!(!b_has_messages);
|
||||
|
||||
// Sending a message while the chat is open should not change the flag.
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("two".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let b_has_messages = cx_b.update(|_, cx| {
|
||||
client_b
|
||||
.channel_store()
|
||||
.read(cx)
|
||||
.has_new_messages(channel_id)
|
||||
});
|
||||
|
||||
assert!(!b_has_messages);
|
||||
|
||||
// Sending a message while the chat is closed should change the flag.
|
||||
chat_panel_b.update_in(cx_b, |chat_panel, window, cx| {
|
||||
chat_panel.set_active(false, window, cx);
|
||||
});
|
||||
|
||||
// Sending a message while the chat is open should not change the flag.
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("three".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let b_has_messages = cx_b.update(|_, cx| {
|
||||
client_b
|
||||
.channel_store()
|
||||
.read(cx)
|
||||
.has_new_messages(channel_id)
|
||||
});
|
||||
|
||||
assert!(b_has_messages);
|
||||
|
||||
// Closing the chat should re-enable change tracking
|
||||
cx_b.update(|_, _| drop(chat_panel_b));
|
||||
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("four".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let b_has_messages = cx_b.update(|_, cx| {
|
||||
client_b
|
||||
.channel_store()
|
||||
.read(cx)
|
||||
.has_new_messages(channel_id)
|
||||
});
|
||||
|
||||
assert!(b_has_messages);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_chat_replies(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
|
||||
let mut server = TestServer::start(cx_a.executor()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b)],
|
||||
)
|
||||
.await;
|
||||
|
||||
// Client A sends a message, client B should see that there is a new message.
|
||||
let channel_chat_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let channel_chat_b = client_b
|
||||
.channel_store()
|
||||
.update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let msg_id = channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_a.run_until_parked();
|
||||
|
||||
let reply_id = channel_chat_b
|
||||
.update(cx_b, |c, cx| {
|
||||
c.send_message(
|
||||
MessageParams {
|
||||
text: "reply".into(),
|
||||
reply_to_message_id: Some(msg_id),
|
||||
mentions: Vec::new(),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_a.run_until_parked();
|
||||
|
||||
channel_chat_a.update(cx_a, |channel_chat, _| {
|
||||
assert_eq!(
|
||||
channel_chat
|
||||
.find_loaded_message(reply_id)
|
||||
.unwrap()
|
||||
.reply_to_message_id,
|
||||
Some(msg_id),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_chat_editing(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
|
||||
let mut server = TestServer::start(cx_a.executor()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b)],
|
||||
)
|
||||
.await;
|
||||
|
||||
// Client A sends a message, client B should see that there is a new message.
|
||||
let channel_chat_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let channel_chat_b = client_b
|
||||
.channel_store()
|
||||
.update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let msg_id = channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
c.send_message(
|
||||
MessageParams {
|
||||
text: "Initial message".into(),
|
||||
reply_to_message_id: None,
|
||||
mentions: Vec::new(),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_a.run_until_parked();
|
||||
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
c.update_message(
|
||||
msg_id,
|
||||
MessageParams {
|
||||
text: "Updated body".into(),
|
||||
reply_to_message_id: None,
|
||||
mentions: Vec::new(),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_a.run_until_parked();
|
||||
cx_b.run_until_parked();
|
||||
|
||||
channel_chat_a.update(cx_a, |channel_chat, _| {
|
||||
let update_message = channel_chat.find_loaded_message(msg_id).unwrap();
|
||||
|
||||
assert_eq!(update_message.body, "Updated body");
|
||||
assert_eq!(update_message.mentions, Vec::new());
|
||||
});
|
||||
channel_chat_b.update(cx_b, |channel_chat, _| {
|
||||
let update_message = channel_chat.find_loaded_message(msg_id).unwrap();
|
||||
|
||||
assert_eq!(update_message.body, "Updated body");
|
||||
assert_eq!(update_message.mentions, Vec::new());
|
||||
});
|
||||
|
||||
// test mentions are updated correctly
|
||||
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
assert_eq!(store.notification_count(), 1);
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert!(matches!(
|
||||
entry.notification,
|
||||
Notification::ChannelInvitation { .. }
|
||||
),);
|
||||
});
|
||||
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
c.update_message(
|
||||
msg_id,
|
||||
MessageParams {
|
||||
text: "Updated body including a mention for @user_b".into(),
|
||||
reply_to_message_id: None,
|
||||
mentions: vec![(37..45, client_b.id())],
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_a.run_until_parked();
|
||||
cx_b.run_until_parked();
|
||||
|
||||
channel_chat_a.update(cx_a, |channel_chat, _| {
|
||||
assert_eq!(
|
||||
channel_chat.find_loaded_message(msg_id).unwrap().body,
|
||||
"Updated body including a mention for @user_b",
|
||||
)
|
||||
});
|
||||
channel_chat_b.update(cx_b, |channel_chat, _| {
|
||||
assert_eq!(
|
||||
channel_chat.find_loaded_message(msg_id).unwrap().body,
|
||||
"Updated body including a mention for @user_b",
|
||||
)
|
||||
});
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
assert_eq!(store.notification_count(), 2);
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert_eq!(
|
||||
entry.notification,
|
||||
Notification::ChannelMessageMention {
|
||||
message_id: msg_id,
|
||||
sender_id: client_a.id(),
|
||||
channel_id: channel_id.0,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Test update message and keep the mention and check that the body is updated correctly
|
||||
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
c.update_message(
|
||||
msg_id,
|
||||
MessageParams {
|
||||
text: "Updated body v2 including a mention for @user_b".into(),
|
||||
reply_to_message_id: None,
|
||||
mentions: vec![(37..45, client_b.id())],
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_a.run_until_parked();
|
||||
cx_b.run_until_parked();
|
||||
|
||||
channel_chat_a.update(cx_a, |channel_chat, _| {
|
||||
assert_eq!(
|
||||
channel_chat.find_loaded_message(msg_id).unwrap().body,
|
||||
"Updated body v2 including a mention for @user_b",
|
||||
)
|
||||
});
|
||||
channel_chat_b.update(cx_b, |channel_chat, _| {
|
||||
assert_eq!(
|
||||
channel_chat.find_loaded_message(msg_id).unwrap().body,
|
||||
"Updated body v2 including a mention for @user_b",
|
||||
)
|
||||
});
|
||||
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
let message = store.channel_message_for_id(msg_id);
|
||||
assert!(message.is_some());
|
||||
assert_eq!(
|
||||
message.unwrap().body,
|
||||
"Updated body v2 including a mention for @user_b"
|
||||
);
|
||||
assert_eq!(store.notification_count(), 2);
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert_eq!(
|
||||
entry.notification,
|
||||
Notification::ChannelMessageMention {
|
||||
message_id: msg_id,
|
||||
sender_id: client_a.id(),
|
||||
channel_id: channel_id.0,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// If we remove a mention from a message the corresponding mention notification
|
||||
// should also be removed.
|
||||
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
c.update_message(
|
||||
msg_id,
|
||||
MessageParams {
|
||||
text: "Updated body without a mention".into(),
|
||||
reply_to_message_id: None,
|
||||
mentions: vec![],
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_a.run_until_parked();
|
||||
cx_b.run_until_parked();
|
||||
|
||||
channel_chat_a.update(cx_a, |channel_chat, _| {
|
||||
assert_eq!(
|
||||
channel_chat.find_loaded_message(msg_id).unwrap().body,
|
||||
"Updated body without a mention",
|
||||
)
|
||||
});
|
||||
channel_chat_b.update(cx_b, |channel_chat, _| {
|
||||
assert_eq!(
|
||||
channel_chat.find_loaded_message(msg_id).unwrap().body,
|
||||
"Updated body without a mention",
|
||||
)
|
||||
});
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
// First notification is the channel invitation, second would be the mention
|
||||
// notification, which should now be removed.
|
||||
assert_eq!(store.notification_count(), 1);
|
||||
});
|
||||
}
|
||||
@@ -37,18 +37,15 @@ client.workspace = true
|
||||
collections.workspace = true
|
||||
db.workspace = true
|
||||
editor.workspace = true
|
||||
emojis.workspace = true
|
||||
futures.workspace = true
|
||||
fuzzy.workspace = true
|
||||
gpui.workspace = true
|
||||
language.workspace = true
|
||||
log.workspace = true
|
||||
menu.workspace = true
|
||||
notifications.workspace = true
|
||||
picker.workspace = true
|
||||
project.workspace = true
|
||||
release_channel.workspace = true
|
||||
rich_text.workspace = true
|
||||
rpc.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,548 +0,0 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use channel::{ChannelChat, ChannelStore, MessageParams};
|
||||
use client::{UserId, UserStore};
|
||||
use collections::HashSet;
|
||||
use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle, ExcerptId};
|
||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
use gpui::{
|
||||
AsyncApp, AsyncWindowContext, Context, Entity, Focusable, FontStyle, FontWeight,
|
||||
HighlightStyle, IntoElement, Render, Task, TextStyle, WeakEntity, Window,
|
||||
};
|
||||
use language::{
|
||||
Anchor, Buffer, BufferSnapshot, CodeLabel, LanguageRegistry, ToOffset,
|
||||
language_settings::SoftWrap,
|
||||
};
|
||||
use project::{
|
||||
Completion, CompletionDisplayOptions, CompletionResponse, CompletionSource, search::SearchQuery,
|
||||
};
|
||||
use settings::Settings;
|
||||
use std::{
|
||||
ops::Range,
|
||||
rc::Rc,
|
||||
sync::{Arc, LazyLock},
|
||||
time::Duration,
|
||||
};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{TextSize, prelude::*};
|
||||
|
||||
use crate::panel_settings::MessageEditorSettings;
|
||||
|
||||
const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
static MENTIONS_SEARCH: LazyLock<SearchQuery> = LazyLock::new(|| {
|
||||
SearchQuery::regex(
|
||||
"@[-_\\w]+",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Default::default(),
|
||||
Default::default(),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
pub struct MessageEditor {
|
||||
pub editor: Entity<Editor>,
|
||||
user_store: Entity<UserStore>,
|
||||
channel_chat: Option<Entity<ChannelChat>>,
|
||||
mentions: Vec<UserId>,
|
||||
mentions_task: Option<Task<()>>,
|
||||
reply_to_message_id: Option<u64>,
|
||||
edit_message_id: Option<u64>,
|
||||
}
|
||||
|
||||
struct MessageEditorCompletionProvider(WeakEntity<MessageEditor>);
|
||||
|
||||
impl CompletionProvider for MessageEditorCompletionProvider {
|
||||
fn completions(
|
||||
&self,
|
||||
_excerpt_id: ExcerptId,
|
||||
buffer: &Entity<Buffer>,
|
||||
buffer_position: language::Anchor,
|
||||
_: editor::CompletionContext,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> Task<Result<Vec<CompletionResponse>>> {
|
||||
let Some(handle) = self.0.upgrade() else {
|
||||
return Task::ready(Ok(Vec::new()));
|
||||
};
|
||||
handle.update(cx, |message_editor, cx| {
|
||||
message_editor.completions(buffer, buffer_position, cx)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_completion_trigger(
|
||||
&self,
|
||||
_buffer: &Entity<Buffer>,
|
||||
_position: language::Anchor,
|
||||
text: &str,
|
||||
_trigger_in_words: bool,
|
||||
_menu_is_open: bool,
|
||||
_cx: &mut Context<Editor>,
|
||||
) -> bool {
|
||||
text == "@"
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageEditor {
|
||||
pub fn new(
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
user_store: Entity<UserStore>,
|
||||
channel_chat: Option<Entity<ChannelChat>>,
|
||||
editor: Entity<Editor>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let this = cx.entity().downgrade();
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
|
||||
editor.set_offset_content(false, cx);
|
||||
editor.set_use_autoclose(false);
|
||||
editor.set_show_gutter(false, cx);
|
||||
editor.set_show_wrap_guides(false, cx);
|
||||
editor.set_show_indent_guides(false, cx);
|
||||
editor.set_completion_provider(Some(Rc::new(MessageEditorCompletionProvider(this))));
|
||||
editor.set_auto_replace_emoji_shortcode(
|
||||
MessageEditorSettings::get_global(cx)
|
||||
.auto_replace_emoji_shortcode
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
});
|
||||
|
||||
let buffer = editor
|
||||
.read(cx)
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.as_singleton()
|
||||
.expect("message editor must be singleton");
|
||||
|
||||
cx.subscribe_in(&buffer, window, Self::on_buffer_event)
|
||||
.detach();
|
||||
cx.observe_global::<settings::SettingsStore>(|this, cx| {
|
||||
this.editor.update(cx, |editor, cx| {
|
||||
editor.set_auto_replace_emoji_shortcode(
|
||||
MessageEditorSettings::get_global(cx)
|
||||
.auto_replace_emoji_shortcode
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
|
||||
let markdown = language_registry.language_for_name("Markdown");
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
let markdown = markdown.await.context("failed to load Markdown language")?;
|
||||
buffer.update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx))
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
Self {
|
||||
editor,
|
||||
user_store,
|
||||
channel_chat,
|
||||
mentions: Vec::new(),
|
||||
mentions_task: None,
|
||||
reply_to_message_id: None,
|
||||
edit_message_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reply_to_message_id(&self) -> Option<u64> {
|
||||
self.reply_to_message_id
|
||||
}
|
||||
|
||||
pub fn set_reply_to_message_id(&mut self, reply_to_message_id: u64) {
|
||||
self.reply_to_message_id = Some(reply_to_message_id);
|
||||
}
|
||||
|
||||
pub fn clear_reply_to_message_id(&mut self) {
|
||||
self.reply_to_message_id = None;
|
||||
}
|
||||
|
||||
pub fn edit_message_id(&self) -> Option<u64> {
|
||||
self.edit_message_id
|
||||
}
|
||||
|
||||
pub fn set_edit_message_id(&mut self, edit_message_id: u64) {
|
||||
self.edit_message_id = Some(edit_message_id);
|
||||
}
|
||||
|
||||
pub fn clear_edit_message_id(&mut self) {
|
||||
self.edit_message_id = None;
|
||||
}
|
||||
|
||||
pub fn set_channel_chat(&mut self, chat: Entity<ChannelChat>, cx: &mut Context<Self>) {
|
||||
let channel_id = chat.read(cx).channel_id;
|
||||
self.channel_chat = Some(chat);
|
||||
let channel_name = ChannelStore::global(cx)
|
||||
.read(cx)
|
||||
.channel_for_id(channel_id)
|
||||
.map(|channel| channel.name.clone());
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
if let Some(channel_name) = channel_name {
|
||||
editor.set_placeholder_text(format!("Message #{channel_name}"), cx);
|
||||
} else {
|
||||
editor.set_placeholder_text("Message Channel", cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn take_message(&mut self, window: &mut Window, cx: &mut Context<Self>) -> MessageParams {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
let highlights = editor.text_highlights::<Self>(cx);
|
||||
let text = editor.text(cx);
|
||||
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
let mentions = if let Some((_, ranges)) = highlights {
|
||||
ranges
|
||||
.iter()
|
||||
.map(|range| range.to_offset(&snapshot))
|
||||
.zip(self.mentions.iter().copied())
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
editor.clear(window, cx);
|
||||
self.mentions.clear();
|
||||
let reply_to_message_id = std::mem::take(&mut self.reply_to_message_id);
|
||||
|
||||
MessageParams {
|
||||
text,
|
||||
mentions,
|
||||
reply_to_message_id,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn on_buffer_event(
|
||||
&mut self,
|
||||
buffer: &Entity<Buffer>,
|
||||
event: &language::BufferEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let language::BufferEvent::Reparsed | language::BufferEvent::Edited = event {
|
||||
let buffer = buffer.read(cx).snapshot();
|
||||
self.mentions_task = Some(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor()
|
||||
.timer(MENTIONS_DEBOUNCE_INTERVAL)
|
||||
.await;
|
||||
Self::find_mentions(this, buffer, cx).await;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn completions(
|
||||
&mut self,
|
||||
buffer: &Entity<Buffer>,
|
||||
end_anchor: Anchor,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Vec<CompletionResponse>>> {
|
||||
if let Some((start_anchor, query, candidates)) =
|
||||
self.collect_mention_candidates(buffer, end_anchor, cx)
|
||||
&& !candidates.is_empty()
|
||||
{
|
||||
return cx.spawn(async move |_, cx| {
|
||||
let completion_response = Self::completions_for_candidates(
|
||||
cx,
|
||||
query.as_str(),
|
||||
&candidates,
|
||||
start_anchor..end_anchor,
|
||||
Self::completion_for_mention,
|
||||
)
|
||||
.await;
|
||||
Ok(vec![completion_response])
|
||||
});
|
||||
}
|
||||
|
||||
if let Some((start_anchor, query, candidates)) =
|
||||
self.collect_emoji_candidates(buffer, end_anchor, cx)
|
||||
&& !candidates.is_empty()
|
||||
{
|
||||
return cx.spawn(async move |_, cx| {
|
||||
let completion_response = Self::completions_for_candidates(
|
||||
cx,
|
||||
query.as_str(),
|
||||
candidates,
|
||||
start_anchor..end_anchor,
|
||||
Self::completion_for_emoji,
|
||||
)
|
||||
.await;
|
||||
Ok(vec![completion_response])
|
||||
});
|
||||
}
|
||||
|
||||
Task::ready(Ok(vec![CompletionResponse {
|
||||
completions: Vec::new(),
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
is_incomplete: false,
|
||||
}]))
|
||||
}
|
||||
|
||||
async fn completions_for_candidates(
|
||||
cx: &AsyncApp,
|
||||
query: &str,
|
||||
candidates: &[StringMatchCandidate],
|
||||
range: Range<Anchor>,
|
||||
completion_fn: impl Fn(&StringMatch) -> (String, CodeLabel),
|
||||
) -> CompletionResponse {
|
||||
const LIMIT: usize = 10;
|
||||
let matches = fuzzy::match_strings(
|
||||
candidates,
|
||||
query,
|
||||
true,
|
||||
true,
|
||||
LIMIT,
|
||||
&Default::default(),
|
||||
cx.background_executor().clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let completions = matches
|
||||
.into_iter()
|
||||
.map(|mat| {
|
||||
let (new_text, label) = completion_fn(&mat);
|
||||
Completion {
|
||||
replace_range: range.clone(),
|
||||
new_text,
|
||||
label,
|
||||
icon_path: None,
|
||||
confirm: None,
|
||||
documentation: None,
|
||||
insert_text_mode: None,
|
||||
source: CompletionSource::Custom,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
CompletionResponse {
|
||||
is_incomplete: completions.len() >= LIMIT,
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
completions,
|
||||
}
|
||||
}
|
||||
|
||||
fn completion_for_mention(mat: &StringMatch) -> (String, CodeLabel) {
|
||||
let label = CodeLabel {
|
||||
filter_range: 1..mat.string.len() + 1,
|
||||
text: format!("@{}", mat.string),
|
||||
runs: Vec::new(),
|
||||
};
|
||||
(mat.string.clone(), label)
|
||||
}
|
||||
|
||||
fn completion_for_emoji(mat: &StringMatch) -> (String, CodeLabel) {
|
||||
let emoji = emojis::get_by_shortcode(&mat.string).unwrap();
|
||||
let label = CodeLabel {
|
||||
filter_range: 1..mat.string.len() + 1,
|
||||
text: format!(":{}: {}", mat.string, emoji),
|
||||
runs: Vec::new(),
|
||||
};
|
||||
(emoji.to_string(), label)
|
||||
}
|
||||
|
||||
fn collect_mention_candidates(
|
||||
&mut self,
|
||||
buffer: &Entity<Buffer>,
|
||||
end_anchor: Anchor,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<(Anchor, String, Vec<StringMatchCandidate>)> {
|
||||
let end_offset = end_anchor.to_offset(buffer.read(cx));
|
||||
|
||||
let query = buffer.read_with(cx, |buffer, _| {
|
||||
let mut query = String::new();
|
||||
for ch in buffer.reversed_chars_at(end_offset).take(100) {
|
||||
if ch == '@' {
|
||||
return Some(query.chars().rev().collect::<String>());
|
||||
}
|
||||
if ch.is_whitespace() || !ch.is_ascii() {
|
||||
break;
|
||||
}
|
||||
query.push(ch);
|
||||
}
|
||||
None
|
||||
})?;
|
||||
|
||||
let start_offset = end_offset - query.len();
|
||||
let start_anchor = buffer.read(cx).anchor_before(start_offset);
|
||||
|
||||
let mut names = HashSet::default();
|
||||
if let Some(chat) = self.channel_chat.as_ref() {
|
||||
let chat = chat.read(cx);
|
||||
for participant in ChannelStore::global(cx)
|
||||
.read(cx)
|
||||
.channel_participants(chat.channel_id)
|
||||
{
|
||||
names.insert(participant.github_login.clone());
|
||||
}
|
||||
for message in chat
|
||||
.messages_in_range(chat.message_count().saturating_sub(100)..chat.message_count())
|
||||
{
|
||||
names.insert(message.sender.github_login.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let candidates = names
|
||||
.into_iter()
|
||||
.map(|user| StringMatchCandidate::new(0, &user))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some((start_anchor, query, candidates))
|
||||
}
|
||||
|
||||
fn collect_emoji_candidates(
|
||||
&mut self,
|
||||
buffer: &Entity<Buffer>,
|
||||
end_anchor: Anchor,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<(Anchor, String, &'static [StringMatchCandidate])> {
|
||||
static EMOJI_FUZZY_MATCH_CANDIDATES: LazyLock<Vec<StringMatchCandidate>> =
|
||||
LazyLock::new(|| {
|
||||
emojis::iter()
|
||||
.flat_map(|s| s.shortcodes())
|
||||
.map(|emoji| StringMatchCandidate::new(0, emoji))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let end_offset = end_anchor.to_offset(buffer.read(cx));
|
||||
|
||||
let query = buffer.read_with(cx, |buffer, _| {
|
||||
let mut query = String::new();
|
||||
for ch in buffer.reversed_chars_at(end_offset).take(100) {
|
||||
if ch == ':' {
|
||||
let next_char = buffer
|
||||
.reversed_chars_at(end_offset - query.len() - 1)
|
||||
.next();
|
||||
// Ensure we are at the start of the message or that the previous character is a whitespace
|
||||
if next_char.is_none() || next_char.unwrap().is_whitespace() {
|
||||
return Some(query.chars().rev().collect::<String>());
|
||||
}
|
||||
|
||||
// If the previous character is not a whitespace, we are in the middle of a word
|
||||
// and we only want to complete the shortcode if the word is made up of other emojis
|
||||
let mut containing_word = String::new();
|
||||
for ch in buffer
|
||||
.reversed_chars_at(end_offset - query.len() - 1)
|
||||
.take(100)
|
||||
{
|
||||
if ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
containing_word.push(ch);
|
||||
}
|
||||
let containing_word = containing_word.chars().rev().collect::<String>();
|
||||
if util::word_consists_of_emojis(containing_word.as_str()) {
|
||||
return Some(query.chars().rev().collect::<String>());
|
||||
}
|
||||
break;
|
||||
}
|
||||
if ch.is_whitespace() || !ch.is_ascii() {
|
||||
break;
|
||||
}
|
||||
query.push(ch);
|
||||
}
|
||||
None
|
||||
})?;
|
||||
|
||||
let start_offset = end_offset - query.len() - 1;
|
||||
let start_anchor = buffer.read(cx).anchor_before(start_offset);
|
||||
|
||||
Some((start_anchor, query, &EMOJI_FUZZY_MATCH_CANDIDATES))
|
||||
}
|
||||
|
||||
async fn find_mentions(
|
||||
this: WeakEntity<MessageEditor>,
|
||||
buffer: BufferSnapshot,
|
||||
cx: &mut AsyncWindowContext,
|
||||
) {
|
||||
let (buffer, ranges) = cx
|
||||
.background_spawn(async move {
|
||||
let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
|
||||
(buffer, ranges)
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let mut anchor_ranges = Vec::new();
|
||||
let mut mentioned_user_ids = Vec::new();
|
||||
let mut text = String::new();
|
||||
|
||||
this.editor.update(cx, |editor, cx| {
|
||||
let multi_buffer = editor.buffer().read(cx).snapshot(cx);
|
||||
for range in ranges {
|
||||
text.clear();
|
||||
text.extend(buffer.text_for_range(range.clone()));
|
||||
if let Some(username) = text.strip_prefix('@')
|
||||
&& let Some(user) = this
|
||||
.user_store
|
||||
.read(cx)
|
||||
.cached_user_by_github_login(username)
|
||||
{
|
||||
let start = multi_buffer.anchor_after(range.start);
|
||||
let end = multi_buffer.anchor_after(range.end);
|
||||
|
||||
mentioned_user_ids.push(user.id);
|
||||
anchor_ranges.push(start..end);
|
||||
}
|
||||
}
|
||||
|
||||
editor.clear_highlights::<Self>(cx);
|
||||
editor.highlight_text::<Self>(
|
||||
anchor_ranges,
|
||||
HighlightStyle {
|
||||
font_weight: Some(FontWeight::BOLD),
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
this.mentions = mentioned_user_ids;
|
||||
this.mentions_task.take();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub(crate) fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
|
||||
self.editor.read(cx).focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for MessageEditor {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let settings = ThemeSettings::get_global(cx);
|
||||
let text_style = TextStyle {
|
||||
color: if self.editor.read(cx).read_only(cx) {
|
||||
cx.theme().colors().text_disabled
|
||||
} else {
|
||||
cx.theme().colors().text
|
||||
},
|
||||
font_family: settings.ui_font.family.clone(),
|
||||
font_features: settings.ui_font.features.clone(),
|
||||
font_fallbacks: settings.ui_font.fallbacks.clone(),
|
||||
font_size: TextSize::Small.rems(cx).into(),
|
||||
font_weight: settings.ui_font.weight,
|
||||
font_style: FontStyle::Normal,
|
||||
line_height: relative(1.3),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
div()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.rounded_sm()
|
||||
.child(EditorElement::new(
|
||||
&self.editor,
|
||||
EditorStyle {
|
||||
local_player: cx.theme().players().local(),
|
||||
text: text_style,
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ mod channel_modal;
|
||||
mod contact_finder;
|
||||
|
||||
use self::channel_modal::ChannelModal;
|
||||
use crate::{CollaborationPanelSettings, channel_view::ChannelView, chat_panel::ChatPanel};
|
||||
use crate::{CollaborationPanelSettings, channel_view::ChannelView};
|
||||
use anyhow::Context as _;
|
||||
use call::ActiveCall;
|
||||
use channel::{Channel, ChannelEvent, ChannelStore};
|
||||
@@ -38,7 +38,7 @@ use util::{ResultExt, TryFutureExt, maybe};
|
||||
use workspace::{
|
||||
Deafen, LeaveCall, Mute, OpenChannelNotes, ScreenShare, ShareProject, Workspace,
|
||||
dock::{DockPosition, Panel, PanelEvent},
|
||||
notifications::{DetachAndPromptErr, NotifyResultExt, NotifyTaskExt},
|
||||
notifications::{DetachAndPromptErr, NotifyResultExt},
|
||||
};
|
||||
|
||||
actions!(
|
||||
@@ -261,9 +261,6 @@ enum ListEntry {
|
||||
ChannelNotes {
|
||||
channel_id: ChannelId,
|
||||
},
|
||||
ChannelChat {
|
||||
channel_id: ChannelId,
|
||||
},
|
||||
ChannelEditor {
|
||||
depth: usize,
|
||||
},
|
||||
@@ -495,7 +492,6 @@ impl CollabPanel {
|
||||
&& let Some(channel_id) = room.channel_id()
|
||||
{
|
||||
self.entries.push(ListEntry::ChannelNotes { channel_id });
|
||||
self.entries.push(ListEntry::ChannelChat { channel_id });
|
||||
}
|
||||
|
||||
// Populate the active user.
|
||||
@@ -1089,39 +1085,6 @@ impl CollabPanel {
|
||||
.tooltip(Tooltip::text("Open Channel Notes"))
|
||||
}
|
||||
|
||||
fn render_channel_chat(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
is_selected: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let channel_store = self.channel_store.read(cx);
|
||||
let has_messages_notification = channel_store.has_new_messages(channel_id);
|
||||
ListItem::new("channel-chat")
|
||||
.toggle_state(is_selected)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.join_channel_chat(channel_id, window, cx);
|
||||
}))
|
||||
.start_slot(
|
||||
h_flex()
|
||||
.relative()
|
||||
.gap_1()
|
||||
.child(render_tree_branch(false, false, window, cx))
|
||||
.child(IconButton::new(0, IconName::Chat))
|
||||
.children(has_messages_notification.then(|| {
|
||||
div()
|
||||
.w_1p5()
|
||||
.absolute()
|
||||
.right(px(2.))
|
||||
.top(px(4.))
|
||||
.child(Indicator::dot().color(Color::Info))
|
||||
})),
|
||||
)
|
||||
.child(Label::new("chat"))
|
||||
.tooltip(Tooltip::text("Open Chat"))
|
||||
}
|
||||
|
||||
fn has_subchannels(&self, ix: usize) -> bool {
|
||||
self.entries.get(ix).is_some_and(|entry| {
|
||||
if let ListEntry::Channel { has_children, .. } = entry {
|
||||
@@ -1296,13 +1259,6 @@ impl CollabPanel {
|
||||
this.open_channel_notes(channel_id, window, cx)
|
||||
}),
|
||||
)
|
||||
.entry(
|
||||
"Open Chat",
|
||||
None,
|
||||
window.handler_for(&this, move |this, window, cx| {
|
||||
this.join_channel_chat(channel_id, window, cx)
|
||||
}),
|
||||
)
|
||||
.entry(
|
||||
"Copy Channel Link",
|
||||
None,
|
||||
@@ -1632,9 +1588,6 @@ impl CollabPanel {
|
||||
ListEntry::ChannelNotes { channel_id } => {
|
||||
self.open_channel_notes(*channel_id, window, cx)
|
||||
}
|
||||
ListEntry::ChannelChat { channel_id } => {
|
||||
self.join_channel_chat(*channel_id, window, cx)
|
||||
}
|
||||
ListEntry::OutgoingRequest(_) => {}
|
||||
ListEntry::ChannelEditor { .. } => {}
|
||||
}
|
||||
@@ -2258,28 +2211,6 @@ impl CollabPanel {
|
||||
.detach_and_prompt_err("Failed to join channel", window, cx, |_, _, _| None)
|
||||
}
|
||||
|
||||
fn join_channel_chat(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
window.defer(cx, move |window, cx| {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(panel) = workspace.focus_panel::<ChatPanel>(window, cx) {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel
|
||||
.select_channel(channel_id, None, cx)
|
||||
.detach_and_notify_err(window, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
|
||||
let channel_store = self.channel_store.read(cx);
|
||||
let Some(channel) = channel_store.channel_for_id(channel_id) else {
|
||||
@@ -2398,9 +2329,6 @@ impl CollabPanel {
|
||||
ListEntry::ChannelNotes { channel_id } => self
|
||||
.render_channel_notes(*channel_id, is_selected, window, cx)
|
||||
.into_any_element(),
|
||||
ListEntry::ChannelChat { channel_id } => self
|
||||
.render_channel_chat(*channel_id, is_selected, window, cx)
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2781,7 +2709,6 @@ impl CollabPanel {
|
||||
let disclosed =
|
||||
has_children.then(|| self.collapsed_channels.binary_search(&channel.id).is_err());
|
||||
|
||||
let has_messages_notification = channel_store.has_new_messages(channel_id);
|
||||
let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id);
|
||||
|
||||
const FACEPILE_LIMIT: usize = 3;
|
||||
@@ -2909,21 +2836,6 @@ impl CollabPanel {
|
||||
.rounded_l_sm()
|
||||
.gap_1()
|
||||
.px_1()
|
||||
.child(
|
||||
IconButton::new("channel_chat", IconName::Chat)
|
||||
.style(ButtonStyle::Filled)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(if has_messages_notification {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.join_channel_chat(channel_id, window, cx)
|
||||
}))
|
||||
.tooltip(Tooltip::text("Open channel chat")),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("channel_notes", IconName::Reader)
|
||||
.style(ButtonStyle::Filled)
|
||||
@@ -3183,14 +3095,6 @@ 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,5 +1,4 @@
|
||||
pub mod channel_view;
|
||||
pub mod chat_panel;
|
||||
pub mod collab_panel;
|
||||
pub mod notification_panel;
|
||||
pub mod notifications;
|
||||
@@ -13,9 +12,7 @@ use gpui::{
|
||||
WindowDecorations, WindowKind, WindowOptions, point,
|
||||
};
|
||||
use panel_settings::MessageEditorSettings;
|
||||
pub use panel_settings::{
|
||||
ChatPanelButton, ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings,
|
||||
};
|
||||
pub use panel_settings::{CollaborationPanelSettings, NotificationPanelSettings};
|
||||
use release_channel::ReleaseChannel;
|
||||
use settings::Settings;
|
||||
use ui::px;
|
||||
@@ -23,12 +20,10 @@ use workspace::AppState;
|
||||
|
||||
pub fn init(app_state: &Arc<AppState>, cx: &mut App) {
|
||||
CollaborationPanelSettings::register(cx);
|
||||
ChatPanelSettings::register(cx);
|
||||
NotificationPanelSettings::register(cx);
|
||||
MessageEditorSettings::register(cx);
|
||||
|
||||
channel_view::init(cx);
|
||||
chat_panel::init(cx);
|
||||
collab_panel::init(cx);
|
||||
notification_panel::init(cx);
|
||||
notifications::init(app_state, cx);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{NotificationPanelSettings, chat_panel::ChatPanel};
|
||||
use crate::NotificationPanelSettings;
|
||||
use anyhow::Result;
|
||||
use channel::ChannelStore;
|
||||
use client::{ChannelId, Client, Notification, User, UserStore};
|
||||
@@ -6,8 +6,8 @@ use collections::HashMap;
|
||||
use db::kvp::KEY_VALUE_STORE;
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
AnyElement, App, AsyncWindowContext, ClickEvent, Context, CursorStyle, DismissEvent, Element,
|
||||
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment,
|
||||
AnyElement, App, AsyncWindowContext, ClickEvent, Context, DismissEvent, Element, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment,
|
||||
ListScrollEvent, ListState, ParentElement, Render, StatefulInteractiveElement, Styled, Task,
|
||||
WeakEntity, Window, actions, div, img, list, px,
|
||||
};
|
||||
@@ -71,7 +71,6 @@ pub struct NotificationPresenter {
|
||||
pub text: String,
|
||||
pub icon: &'static str,
|
||||
pub needs_response: bool,
|
||||
pub can_navigate: bool,
|
||||
}
|
||||
|
||||
actions!(
|
||||
@@ -234,7 +233,6 @@ impl NotificationPanel {
|
||||
actor,
|
||||
text,
|
||||
needs_response,
|
||||
can_navigate,
|
||||
..
|
||||
} = self.present_notification(entry, cx)?;
|
||||
|
||||
@@ -269,14 +267,6 @@ impl NotificationPanel {
|
||||
.py_1()
|
||||
.gap_2()
|
||||
.hover(|style| style.bg(cx.theme().colors().element_hover))
|
||||
.when(can_navigate, |el| {
|
||||
el.cursor(CursorStyle::PointingHand).on_click({
|
||||
let notification = notification.clone();
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.did_click_notification(¬ification, window, cx)
|
||||
})
|
||||
})
|
||||
})
|
||||
.children(actor.map(|actor| {
|
||||
img(actor.avatar_uri.clone())
|
||||
.flex_none()
|
||||
@@ -369,7 +359,6 @@ impl NotificationPanel {
|
||||
text: format!("{} wants to add you as a contact", requester.github_login),
|
||||
needs_response: user_store.has_incoming_contact_request(requester.id),
|
||||
actor: Some(requester),
|
||||
can_navigate: false,
|
||||
})
|
||||
}
|
||||
Notification::ContactRequestAccepted { responder_id } => {
|
||||
@@ -379,7 +368,6 @@ impl NotificationPanel {
|
||||
text: format!("{} accepted your contact invite", responder.github_login),
|
||||
needs_response: false,
|
||||
actor: Some(responder),
|
||||
can_navigate: false,
|
||||
})
|
||||
}
|
||||
Notification::ChannelInvitation {
|
||||
@@ -396,29 +384,6 @@ impl NotificationPanel {
|
||||
),
|
||||
needs_response: channel_store.has_channel_invitation(ChannelId(channel_id)),
|
||||
actor: Some(inviter),
|
||||
can_navigate: false,
|
||||
})
|
||||
}
|
||||
Notification::ChannelMessageMention {
|
||||
sender_id,
|
||||
channel_id,
|
||||
message_id,
|
||||
} => {
|
||||
let sender = user_store.get_cached_user(sender_id)?;
|
||||
let channel = channel_store.channel_for_id(ChannelId(channel_id))?;
|
||||
let message = self
|
||||
.notification_store
|
||||
.read(cx)
|
||||
.channel_message_for_id(message_id)?;
|
||||
Some(NotificationPresenter {
|
||||
icon: "icons/conversations.svg",
|
||||
text: format!(
|
||||
"{} mentioned you in #{}:\n{}",
|
||||
sender.github_login, channel.name, message.body,
|
||||
),
|
||||
needs_response: false,
|
||||
actor: Some(sender),
|
||||
can_navigate: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -433,9 +398,7 @@ impl NotificationPanel {
|
||||
) {
|
||||
let should_mark_as_read = match notification {
|
||||
Notification::ContactRequestAccepted { .. } => true,
|
||||
Notification::ContactRequest { .. }
|
||||
| Notification::ChannelInvitation { .. }
|
||||
| Notification::ChannelMessageMention { .. } => false,
|
||||
Notification::ContactRequest { .. } | Notification::ChannelInvitation { .. } => false,
|
||||
};
|
||||
|
||||
if should_mark_as_read {
|
||||
@@ -457,55 +420,6 @@ impl NotificationPanel {
|
||||
}
|
||||
}
|
||||
|
||||
fn did_click_notification(
|
||||
&mut self,
|
||||
notification: &Notification,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Notification::ChannelMessageMention {
|
||||
message_id,
|
||||
channel_id,
|
||||
..
|
||||
} = notification.clone()
|
||||
&& let Some(workspace) = self.workspace.upgrade()
|
||||
{
|
||||
window.defer(cx, move |window, cx| {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(panel) = workspace.focus_panel::<ChatPanel>(window, cx) {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel
|
||||
.select_channel(ChannelId(channel_id), Some(message_id), cx)
|
||||
.detach_and_log_err(cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn is_showing_notification(&self, notification: &Notification, cx: &mut Context<Self>) -> bool {
|
||||
if !self.active {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification
|
||||
&& let Some(workspace) = self.workspace.upgrade()
|
||||
{
|
||||
return if let Some(panel) = workspace.read(cx).panel::<ChatPanel>(cx) {
|
||||
let panel = panel.read(cx);
|
||||
panel.is_scrolled_to_bottom()
|
||||
&& panel
|
||||
.active_chat()
|
||||
.is_some_and(|chat| chat.read(cx).channel_id.0 == *channel_id)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn on_notification_event(
|
||||
&mut self,
|
||||
_: &Entity<NotificationStore>,
|
||||
@@ -515,9 +429,7 @@ impl NotificationPanel {
|
||||
) {
|
||||
match event {
|
||||
NotificationEvent::NewNotification { entry } => {
|
||||
if !self.is_showing_notification(&entry.notification, cx) {
|
||||
self.unseen_notifications.push(entry.clone());
|
||||
}
|
||||
self.unseen_notifications.push(entry.clone());
|
||||
self.add_toast(entry, window, cx);
|
||||
}
|
||||
NotificationEvent::NotificationRemoved { entry }
|
||||
@@ -541,10 +453,6 @@ impl NotificationPanel {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.is_showing_notification(&entry.notification, cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
|
||||
else {
|
||||
return;
|
||||
@@ -568,7 +476,6 @@ impl NotificationPanel {
|
||||
workspace.show_notification(id, cx, |cx| {
|
||||
let workspace = cx.entity().downgrade();
|
||||
cx.new(|cx| NotificationToast {
|
||||
notification_id,
|
||||
actor,
|
||||
text,
|
||||
workspace,
|
||||
@@ -781,7 +688,6 @@ impl Panel for NotificationPanel {
|
||||
}
|
||||
|
||||
pub struct NotificationToast {
|
||||
notification_id: u64,
|
||||
actor: Option<Arc<User>>,
|
||||
text: String,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
@@ -799,22 +705,10 @@ impl WorkspaceNotification for NotificationToast {}
|
||||
impl NotificationToast {
|
||||
fn focus_notification_panel(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let workspace = self.workspace.clone();
|
||||
let notification_id = self.notification_id;
|
||||
window.defer(cx, move |window, cx| {
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
if let Some(panel) = workspace.focus_panel::<NotificationPanel>(window, cx) {
|
||||
panel.update(cx, |panel, cx| {
|
||||
let store = panel.notification_store.read(cx);
|
||||
if let Some(entry) = store.notification_for_id(notification_id) {
|
||||
panel.did_click_notification(
|
||||
&entry.clone().notification,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
workspace.focus_panel::<NotificationPanel>(window, cx)
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
|
||||
@@ -11,39 +11,6 @@ pub struct CollaborationPanelSettings {
|
||||
pub default_width: Pixels,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, Serialize, Deserialize, JsonSchema, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChatPanelButton {
|
||||
Never,
|
||||
Always,
|
||||
#[default]
|
||||
WhenInCall,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ChatPanelSettings {
|
||||
pub button: ChatPanelButton,
|
||||
pub dock: DockPosition,
|
||||
pub default_width: Pixels,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi, SettingsKey)]
|
||||
#[settings_key(key = "chat_panel")]
|
||||
pub struct ChatPanelSettingsContent {
|
||||
/// When to show the panel button in the status bar.
|
||||
///
|
||||
/// Default: only when in a call
|
||||
pub button: Option<ChatPanelButton>,
|
||||
/// Where to dock the panel.
|
||||
///
|
||||
/// Default: right
|
||||
pub dock: Option<DockPosition>,
|
||||
/// Default width of the panel in pixels.
|
||||
///
|
||||
/// Default: 240
|
||||
pub default_width: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi, SettingsKey)]
|
||||
#[settings_key(key = "collaboration_panel")]
|
||||
pub struct PanelSettingsContent {
|
||||
@@ -108,19 +75,6 @@ impl Settings for CollaborationPanelSettings {
|
||||
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
|
||||
}
|
||||
|
||||
impl Settings for ChatPanelSettings {
|
||||
type FileContent = ChatPanelSettingsContent;
|
||||
|
||||
fn load(
|
||||
sources: SettingsSources<Self::FileContent>,
|
||||
_: &mut gpui::App,
|
||||
) -> anyhow::Result<Self> {
|
||||
sources.json_merge()
|
||||
}
|
||||
|
||||
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
|
||||
}
|
||||
|
||||
impl Settings for NotificationPanelSettings {
|
||||
type FileContent = NotificationPanelSettingsContent;
|
||||
|
||||
|
||||
@@ -92,7 +92,6 @@ uuid.workspace = true
|
||||
workspace.workspace = true
|
||||
zed_actions.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
postage.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ctor.workspace = true
|
||||
|
||||
@@ -640,6 +640,10 @@ actions!(
|
||||
SelectEnclosingSymbol,
|
||||
/// Selects the next larger syntax node.
|
||||
SelectLargerSyntaxNode,
|
||||
/// Selects the next syntax node sibling.
|
||||
SelectNextSyntaxNode,
|
||||
/// Selects the previous syntax node sibling.
|
||||
SelectPreviousSyntaxNode,
|
||||
/// Extends selection left.
|
||||
SelectLeft,
|
||||
/// Selects the current line.
|
||||
|
||||
@@ -1502,6 +1502,7 @@ impl CodeActionsMenu {
|
||||
this.child(
|
||||
h_flex()
|
||||
.overflow_hidden()
|
||||
.text_sm()
|
||||
.child(
|
||||
// TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here.
|
||||
action.lsp_action.title().replace("\n", ""),
|
||||
|
||||
@@ -177,15 +177,17 @@ use snippet::Snippet;
|
||||
use std::{
|
||||
any::TypeId,
|
||||
borrow::Cow,
|
||||
cell::{OnceCell, RefCell},
|
||||
cell::OnceCell,
|
||||
cell::RefCell,
|
||||
cmp::{self, Ordering, Reverse},
|
||||
iter::Peekable,
|
||||
mem,
|
||||
num::NonZeroU32,
|
||||
ops::{ControlFlow, Deref, DerefMut, Not, Range, RangeInclusive},
|
||||
ops::Not,
|
||||
ops::{ControlFlow, Deref, DerefMut, Range, RangeInclusive},
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::{Arc, LazyLock},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables};
|
||||
@@ -234,21 +236,6 @@ pub(crate) const EDIT_PREDICTION_KEY_CONTEXT: &str = "edit_prediction";
|
||||
pub(crate) const EDIT_PREDICTION_CONFLICT_KEY_CONTEXT: &str = "edit_prediction_conflict";
|
||||
pub(crate) const MINIMAP_FONT_SIZE: AbsoluteLength = AbsoluteLength::Pixels(px(2.));
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct LastCursorPosition {
|
||||
pub path: PathBuf,
|
||||
pub worktree_path: Arc<Path>,
|
||||
pub point: Point,
|
||||
}
|
||||
|
||||
pub static LAST_CURSOR_POSITION_WATCH: LazyLock<(
|
||||
Mutex<postage::watch::Sender<Option<LastCursorPosition>>>,
|
||||
postage::watch::Receiver<Option<LastCursorPosition>>,
|
||||
)> = LazyLock::new(|| {
|
||||
let (sender, receiver) = postage::watch::channel();
|
||||
(Mutex::new(sender), receiver)
|
||||
});
|
||||
|
||||
pub type RenderDiffHunkControlsFn = Arc<
|
||||
dyn Fn(
|
||||
u32,
|
||||
@@ -3077,28 +3064,10 @@ impl Editor {
|
||||
let new_cursor_position = newest_selection.head();
|
||||
let selection_start = newest_selection.start;
|
||||
|
||||
let new_cursor_point = new_cursor_position.to_point(buffer);
|
||||
if let Some(project) = self.project()
|
||||
&& let Some((path, worktree_path)) =
|
||||
self.file_at(new_cursor_point, cx).and_then(|file| {
|
||||
file.as_local().and_then(|file| {
|
||||
let worktree =
|
||||
project.read(cx).worktree_for_id(file.worktree_id(cx), cx)?;
|
||||
Some((file.abs_path(cx), worktree.read(cx).abs_path()))
|
||||
})
|
||||
})
|
||||
{
|
||||
*LAST_CURSOR_POSITION_WATCH.0.lock().borrow_mut() = Some(LastCursorPosition {
|
||||
path,
|
||||
worktree_path,
|
||||
point: new_cursor_point,
|
||||
});
|
||||
}
|
||||
|
||||
if effects.nav_history.is_none() || effects.nav_history == Some(true) {
|
||||
self.push_to_nav_history(
|
||||
*old_cursor_position,
|
||||
Some(new_cursor_point),
|
||||
Some(new_cursor_position.to_point(buffer)),
|
||||
false,
|
||||
effects.nav_history == Some(true),
|
||||
cx,
|
||||
@@ -15169,6 +15138,104 @@ impl Editor {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn select_next_syntax_node(
|
||||
&mut self,
|
||||
_: &SelectNextSyntaxNode,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let old_selections: Box<[_]> = self.selections.all::<usize>(cx).into();
|
||||
if old_selections.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
|
||||
|
||||
let buffer = self.buffer.read(cx).snapshot(cx);
|
||||
let mut selected_sibling = false;
|
||||
|
||||
let new_selections = old_selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let old_range = selection.start..selection.end;
|
||||
|
||||
if let Some(node) = buffer.syntax_next_sibling(old_range) {
|
||||
let new_range = node.byte_range();
|
||||
selected_sibling = true;
|
||||
Selection {
|
||||
id: selection.id,
|
||||
start: new_range.start,
|
||||
end: new_range.end,
|
||||
goal: SelectionGoal::None,
|
||||
reversed: selection.reversed,
|
||||
}
|
||||
} else {
|
||||
selection.clone()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if selected_sibling {
|
||||
self.change_selections(
|
||||
SelectionEffects::scroll(Autoscroll::fit()),
|
||||
window,
|
||||
cx,
|
||||
|s| {
|
||||
s.select(new_selections);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_prev_syntax_node(
|
||||
&mut self,
|
||||
_: &SelectPreviousSyntaxNode,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let old_selections: Box<[_]> = self.selections.all::<usize>(cx).into();
|
||||
if old_selections.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
|
||||
|
||||
let buffer = self.buffer.read(cx).snapshot(cx);
|
||||
let mut selected_sibling = false;
|
||||
|
||||
let new_selections = old_selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let old_range = selection.start..selection.end;
|
||||
|
||||
if let Some(node) = buffer.syntax_prev_sibling(old_range) {
|
||||
let new_range = node.byte_range();
|
||||
selected_sibling = true;
|
||||
Selection {
|
||||
id: selection.id,
|
||||
start: new_range.start,
|
||||
end: new_range.end,
|
||||
goal: SelectionGoal::None,
|
||||
reversed: selection.reversed,
|
||||
}
|
||||
} else {
|
||||
selection.clone()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if selected_sibling {
|
||||
self.change_selections(
|
||||
SelectionEffects::scroll(Autoscroll::fit()),
|
||||
window,
|
||||
cx,
|
||||
|s| {
|
||||
s.select(new_selections);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
|
||||
if !EditorSettings::get_global(cx).gutter.runnables {
|
||||
self.clear_tasks();
|
||||
|
||||
@@ -25330,6 +25330,101 @@ async fn test_non_utf_8_opens(cx: &mut TestAppContext) {
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_select_next_prev_syntax_node(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let language = Arc::new(Language::new(
|
||||
LanguageConfig::default(),
|
||||
Some(tree_sitter_rust::LANGUAGE.into()),
|
||||
));
|
||||
|
||||
// Test hierarchical sibling navigation
|
||||
let text = r#"
|
||||
fn outer() {
|
||||
if condition {
|
||||
let a = 1;
|
||||
}
|
||||
let b = 2;
|
||||
}
|
||||
|
||||
fn another() {
|
||||
let c = 3;
|
||||
}
|
||||
"#;
|
||||
|
||||
let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx));
|
||||
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
|
||||
let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx));
|
||||
|
||||
// Wait for parsing to complete
|
||||
editor
|
||||
.condition::<crate::EditorEvent>(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx))
|
||||
.await;
|
||||
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
// Start by selecting "let a = 1;" inside the if block
|
||||
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||
s.select_display_ranges([
|
||||
DisplayPoint::new(DisplayRow(3), 16)..DisplayPoint::new(DisplayRow(3), 26)
|
||||
]);
|
||||
});
|
||||
|
||||
let initial_selection = editor.selections.display_ranges(cx);
|
||||
assert_eq!(initial_selection.len(), 1, "Should have one selection");
|
||||
|
||||
// Test select next sibling - should move up levels to find the next sibling
|
||||
// Since "let a = 1;" has no siblings in the if block, it should move up
|
||||
// to find "let b = 2;" which is a sibling of the if block
|
||||
editor.select_next_syntax_node(&SelectNextSyntaxNode, window, cx);
|
||||
let next_selection = editor.selections.display_ranges(cx);
|
||||
|
||||
// Should have a selection and it should be different from the initial
|
||||
assert_eq!(
|
||||
next_selection.len(),
|
||||
1,
|
||||
"Should have one selection after next"
|
||||
);
|
||||
assert_ne!(
|
||||
next_selection[0], initial_selection[0],
|
||||
"Next sibling selection should be different"
|
||||
);
|
||||
|
||||
// Test hierarchical navigation by going to the end of the current function
|
||||
// and trying to navigate to the next function
|
||||
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||
s.select_display_ranges([
|
||||
DisplayPoint::new(DisplayRow(5), 12)..DisplayPoint::new(DisplayRow(5), 22)
|
||||
]);
|
||||
});
|
||||
|
||||
editor.select_next_syntax_node(&SelectNextSyntaxNode, window, cx);
|
||||
let function_next_selection = editor.selections.display_ranges(cx);
|
||||
|
||||
// Should move to the next function
|
||||
assert_eq!(
|
||||
function_next_selection.len(),
|
||||
1,
|
||||
"Should have one selection after function next"
|
||||
);
|
||||
|
||||
// Test select previous sibling navigation
|
||||
editor.select_prev_syntax_node(&SelectPreviousSyntaxNode, window, cx);
|
||||
let prev_selection = editor.selections.display_ranges(cx);
|
||||
|
||||
// Should have a selection and it should be different
|
||||
assert_eq!(
|
||||
prev_selection.len(),
|
||||
1,
|
||||
"Should have one selection after prev"
|
||||
);
|
||||
assert_ne!(
|
||||
prev_selection[0], function_next_selection[0],
|
||||
"Previous sibling selection should be different from next"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn extract_color_inlays(editor: &Editor, cx: &App) -> Vec<Rgba> {
|
||||
editor
|
||||
|
||||
@@ -365,6 +365,8 @@ impl EditorElement {
|
||||
register_action(editor, window, Editor::toggle_comments);
|
||||
register_action(editor, window, Editor::select_larger_syntax_node);
|
||||
register_action(editor, window, Editor::select_smaller_syntax_node);
|
||||
register_action(editor, window, Editor::select_next_syntax_node);
|
||||
register_action(editor, window, Editor::select_prev_syntax_node);
|
||||
register_action(editor, window, Editor::unwrap_syntax_node);
|
||||
register_action(editor, window, Editor::select_enclosing_symbol);
|
||||
register_action(editor, window, Editor::move_to_enclosing_bracket);
|
||||
@@ -8296,7 +8298,7 @@ impl Element for EditorElement {
|
||||
let (mut snapshot, is_read_only) = self.editor.update(cx, |editor, cx| {
|
||||
(editor.snapshot(window, cx), editor.read_only(cx))
|
||||
});
|
||||
let style = self.style.clone();
|
||||
let style = &self.style;
|
||||
|
||||
let rem_size = window.rem_size();
|
||||
let font_id = window.text_system().resolve_font(&style.text.font());
|
||||
@@ -8771,7 +8773,7 @@ impl Element for EditorElement {
|
||||
blame.blame_for_rows(&[row_infos], cx).next()
|
||||
})
|
||||
.flatten()?;
|
||||
let mut element = render_inline_blame_entry(blame_entry, &style, cx)?;
|
||||
let mut element = render_inline_blame_entry(blame_entry, style, cx)?;
|
||||
let inline_blame_padding = ProjectSettings::get_global(cx)
|
||||
.git
|
||||
.inline_blame
|
||||
@@ -8791,7 +8793,7 @@ impl Element for EditorElement {
|
||||
let longest_line_width = layout_line(
|
||||
snapshot.longest_row(),
|
||||
&snapshot,
|
||||
&style,
|
||||
style,
|
||||
editor_width,
|
||||
is_row_soft_wrapped,
|
||||
window,
|
||||
@@ -8949,7 +8951,7 @@ impl Element for EditorElement {
|
||||
scroll_pixel_position,
|
||||
newest_selection_head,
|
||||
editor_width,
|
||||
&style,
|
||||
style,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -8967,7 +8969,7 @@ impl Element for EditorElement {
|
||||
end_row,
|
||||
line_height,
|
||||
em_width,
|
||||
&style,
|
||||
style,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
@@ -9112,7 +9114,7 @@ impl Element for EditorElement {
|
||||
&line_layouts,
|
||||
newest_selection_head,
|
||||
newest_selection_point,
|
||||
&style,
|
||||
style,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
|
||||
@@ -66,9 +66,10 @@ impl FeatureFlag for LlmClosedBetaFeatureFlag {
|
||||
const NAME: &'static str = "llm-closed-beta";
|
||||
}
|
||||
|
||||
pub struct ZedProFeatureFlag {}
|
||||
impl FeatureFlag for ZedProFeatureFlag {
|
||||
const NAME: &'static str = "zed-pro";
|
||||
pub struct BillingV2FeatureFlag {}
|
||||
|
||||
impl FeatureFlag for BillingV2FeatureFlag {
|
||||
const NAME: &'static str = "billing-v2";
|
||||
}
|
||||
|
||||
pub struct NotebookFeatureFlag;
|
||||
|
||||
@@ -1205,10 +1205,9 @@ impl GitRepository for RealGitRepository {
|
||||
env: Arc<HashMap<String, String>>,
|
||||
) -> BoxFuture<'_, Result<()>> {
|
||||
let working_directory = self.working_directory();
|
||||
let git_binary_path = self.git_binary_path.clone();
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let mut cmd = new_smol_command(&git_binary_path);
|
||||
let mut cmd = new_smol_command("git");
|
||||
cmd.current_dir(&working_directory?)
|
||||
.envs(env.iter())
|
||||
.args(["stash", "push", "--quiet"])
|
||||
@@ -1230,10 +1229,9 @@ impl GitRepository for RealGitRepository {
|
||||
|
||||
fn stash_pop(&self, env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>> {
|
||||
let working_directory = self.working_directory();
|
||||
let git_binary_path = self.git_binary_path.clone();
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let mut cmd = new_smol_command(&git_binary_path);
|
||||
let mut cmd = new_smol_command("git");
|
||||
cmd.current_dir(&working_directory?)
|
||||
.envs(env.iter())
|
||||
.args(["stash", "pop"]);
|
||||
@@ -1258,10 +1256,9 @@ impl GitRepository for RealGitRepository {
|
||||
env: Arc<HashMap<String, String>>,
|
||||
) -> BoxFuture<'_, Result<()>> {
|
||||
let working_directory = self.working_directory();
|
||||
let git_binary_path = self.git_binary_path.clone();
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let mut cmd = new_smol_command(&git_binary_path);
|
||||
let mut cmd = new_smol_command("git");
|
||||
cmd.current_dir(&working_directory?)
|
||||
.envs(env.iter())
|
||||
.args(["commit", "--quiet", "-m"])
|
||||
@@ -1305,7 +1302,7 @@ impl GitRepository for RealGitRepository {
|
||||
let executor = cx.background_executor().clone();
|
||||
async move {
|
||||
let working_directory = working_directory?;
|
||||
let mut command = new_smol_command(&self.git_binary_path);
|
||||
let mut command = new_smol_command("git");
|
||||
command
|
||||
.envs(env.iter())
|
||||
.current_dir(&working_directory)
|
||||
@@ -1336,7 +1333,7 @@ impl GitRepository for RealGitRepository {
|
||||
let working_directory = self.working_directory();
|
||||
let executor = cx.background_executor().clone();
|
||||
async move {
|
||||
let mut command = new_smol_command(&self.git_binary_path);
|
||||
let mut command = new_smol_command("git");
|
||||
command
|
||||
.envs(env.iter())
|
||||
.current_dir(&working_directory?)
|
||||
@@ -1362,7 +1359,7 @@ impl GitRepository for RealGitRepository {
|
||||
let remote_name = format!("{}", fetch_options);
|
||||
let executor = cx.background_executor().clone();
|
||||
async move {
|
||||
let mut command = new_smol_command(&self.git_binary_path);
|
||||
let mut command = new_smol_command("git");
|
||||
command
|
||||
.envs(env.iter())
|
||||
.current_dir(&working_directory?)
|
||||
|
||||
@@ -16,7 +16,7 @@ use core_foundation::{
|
||||
use core_graphics::{
|
||||
base::{CGGlyph, kCGImageAlphaPremultipliedLast},
|
||||
color_space::CGColorSpace,
|
||||
context::CGContext,
|
||||
context::{CGContext, CGTextDrawingMode},
|
||||
display::CGPoint,
|
||||
};
|
||||
use core_text::{
|
||||
@@ -396,6 +396,12 @@ impl MacTextSystemState {
|
||||
let subpixel_shift = params
|
||||
.subpixel_variant
|
||||
.map(|v| v as f32 / SUBPIXEL_VARIANTS as f32);
|
||||
cx.set_allows_font_smoothing(true);
|
||||
cx.set_should_smooth_fonts(true);
|
||||
cx.set_text_drawing_mode(CGTextDrawingMode::CGTextFill);
|
||||
cx.set_gray_fill_color(0.0, 1.0);
|
||||
cx.set_allows_antialiasing(true);
|
||||
cx.set_should_antialias(true);
|
||||
cx.set_allows_font_subpixel_positioning(true);
|
||||
cx.set_should_subpixel_position_fonts(true);
|
||||
cx.set_allows_font_subpixel_quantization(false);
|
||||
|
||||
@@ -1576,33 +1576,6 @@ impl Render for KeymapEditor {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
right_click_menu("open-keymap-menu")
|
||||
.menu(|window, cx| {
|
||||
ContextMenu::build(window, cx, |menu, _, _| {
|
||||
menu.header("Open Keymap JSON")
|
||||
.action("User", zed_actions::OpenKeymap.boxed_clone())
|
||||
.action("Zed Default", zed_actions::OpenDefaultKeymap.boxed_clone())
|
||||
.action("Vim Default", vim::OpenDefaultKeymap.boxed_clone())
|
||||
})
|
||||
})
|
||||
.anchor(gpui::Corner::TopLeft)
|
||||
.trigger(|open, _, _|
|
||||
IconButton::new(
|
||||
"OpenKeymapJsonButton",
|
||||
IconName::Json
|
||||
)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.when(!open, |this|
|
||||
this.tooltip(move |window, cx| {
|
||||
Tooltip::with_meta("Open Keymap JSON", Some(&zed_actions::OpenKeymap),"Right click to view more options", window, cx)
|
||||
})
|
||||
)
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx);
|
||||
})
|
||||
)
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.key_context({
|
||||
@@ -1617,73 +1590,139 @@ impl Render for KeymapEditor {
|
||||
.py_1()
|
||||
.border_1()
|
||||
.border_color(theme.colors().border)
|
||||
.rounded_lg()
|
||||
.rounded_md()
|
||||
.child(self.filter_editor.clone()),
|
||||
)
|
||||
.child(
|
||||
IconButton::new(
|
||||
"KeymapEditorToggleFiltersIcon",
|
||||
IconName::Keyboard,
|
||||
)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.tooltip({
|
||||
let focus_handle = focus_handle.clone();
|
||||
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Search by Keystroke",
|
||||
&ToggleKeystrokeSearch,
|
||||
&focus_handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.min_w_64()
|
||||
.child(
|
||||
IconButton::new(
|
||||
"KeymapEditorToggleFiltersIcon",
|
||||
IconName::Keyboard,
|
||||
)
|
||||
}
|
||||
})
|
||||
.toggle_state(matches!(
|
||||
self.search_mode,
|
||||
SearchMode::KeyStroke { .. }
|
||||
))
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(ToggleKeystrokeSearch.boxed_clone(), cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("KeymapEditorConflictIcon", IconName::Warning)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.when(
|
||||
self.keybinding_conflict_state.any_user_binding_conflicts(),
|
||||
|this| {
|
||||
this.indicator(Indicator::dot().color(Color::Warning))
|
||||
},
|
||||
)
|
||||
.tooltip({
|
||||
let filter_state = self.filter_state;
|
||||
let focus_handle = focus_handle.clone();
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip({
|
||||
let focus_handle = focus_handle.clone();
|
||||
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
match filter_state {
|
||||
FilterState::All => "Show Conflicts",
|
||||
FilterState::Conflicts => "Hide Conflicts",
|
||||
},
|
||||
&ToggleConflictFilter,
|
||||
&focus_handle.clone(),
|
||||
window,
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Search by Keystroke",
|
||||
&ToggleKeystrokeSearch,
|
||||
&focus_handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.toggle_state(matches!(
|
||||
self.search_mode,
|
||||
SearchMode::KeyStroke { .. }
|
||||
))
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
ToggleKeystrokeSearch.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("KeymapEditorConflictIcon", IconName::Warning)
|
||||
.icon_size(IconSize::Small)
|
||||
.when(
|
||||
self.keybinding_conflict_state
|
||||
.any_user_binding_conflicts(),
|
||||
|this| {
|
||||
this.indicator(
|
||||
Indicator::dot().color(Color::Warning),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.selected_icon_color(Color::Warning)
|
||||
.toggle_state(matches!(
|
||||
self.filter_state,
|
||||
FilterState::Conflicts
|
||||
))
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
ToggleConflictFilter.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
.tooltip({
|
||||
let filter_state = self.filter_state;
|
||||
let focus_handle = focus_handle.clone();
|
||||
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
match filter_state {
|
||||
FilterState::All => "Show Conflicts",
|
||||
FilterState::Conflicts => {
|
||||
"Hide Conflicts"
|
||||
}
|
||||
},
|
||||
&ToggleConflictFilter,
|
||||
&focus_handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.selected_icon_color(Color::Warning)
|
||||
.toggle_state(matches!(
|
||||
self.filter_state,
|
||||
FilterState::Conflicts
|
||||
))
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
ToggleConflictFilter.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.ml_1()
|
||||
.pl_2()
|
||||
.border_l_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(
|
||||
right_click_menu("open-keymap-menu")
|
||||
.menu(|window, cx| {
|
||||
ContextMenu::build(window, cx, |menu, _, _| {
|
||||
menu.header("Open Keymap JSON")
|
||||
.action(
|
||||
"User",
|
||||
zed_actions::OpenKeymap.boxed_clone(),
|
||||
)
|
||||
.action(
|
||||
"Zed Default",
|
||||
zed_actions::OpenDefaultKeymap
|
||||
.boxed_clone(),
|
||||
)
|
||||
.action(
|
||||
"Vim Default",
|
||||
vim::OpenDefaultKeymap.boxed_clone(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.anchor(gpui::Corner::TopLeft)
|
||||
.trigger(|open, _, _| {
|
||||
IconButton::new(
|
||||
"OpenKeymapJsonButton",
|
||||
IconName::Json,
|
||||
)
|
||||
.icon_size(IconSize::Small)
|
||||
.when(!open, |this| {
|
||||
this.tooltip(move |window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Open keymap.json",
|
||||
Some(&zed_actions::OpenKeymap),
|
||||
"Right click to view more options",
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
zed_actions::OpenKeymap.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
.when_some(
|
||||
@@ -1694,48 +1733,42 @@ impl Render for KeymapEditor {
|
||||
|this, exact_match| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.map(|this| {
|
||||
if self
|
||||
.keybinding_conflict_state
|
||||
.any_user_binding_conflicts()
|
||||
{
|
||||
this.pr(rems_from_px(54.))
|
||||
} else {
|
||||
this.pr_7()
|
||||
}
|
||||
})
|
||||
.gap_2()
|
||||
.child(self.keystroke_editor.clone())
|
||||
.child(
|
||||
IconButton::new(
|
||||
"keystrokes-exact-match",
|
||||
IconName::CaseSensitive,
|
||||
)
|
||||
.tooltip({
|
||||
let keystroke_focus_handle =
|
||||
self.keystroke_editor.read(cx).focus_handle(cx);
|
||||
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Toggle Exact Match Mode",
|
||||
&ToggleExactKeystrokeMatching,
|
||||
&keystroke_focus_handle,
|
||||
window,
|
||||
cx,
|
||||
h_flex()
|
||||
.min_w_64()
|
||||
.child(
|
||||
IconButton::new(
|
||||
"keystrokes-exact-match",
|
||||
IconName::CaseSensitive,
|
||||
)
|
||||
}
|
||||
})
|
||||
.shape(IconButtonShape::Square)
|
||||
.toggle_state(exact_match)
|
||||
.on_click(
|
||||
cx.listener(|_, _, window, cx| {
|
||||
window.dispatch_action(
|
||||
ToggleExactKeystrokeMatching.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
.tooltip({
|
||||
let keystroke_focus_handle =
|
||||
self.keystroke_editor.read(cx).focus_handle(cx);
|
||||
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Toggle Exact Match Mode",
|
||||
&ToggleExactKeystrokeMatching,
|
||||
&keystroke_focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.shape(IconButtonShape::Square)
|
||||
.toggle_state(exact_match)
|
||||
.on_click(
|
||||
cx.listener(|_, _, window, cx| {
|
||||
window.dispatch_action(
|
||||
ToggleExactKeystrokeMatching.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
},
|
||||
),
|
||||
|
||||
@@ -461,7 +461,7 @@ impl Render for KeystrokeInput {
|
||||
let is_focused = self.outer_focus_handle.contains_focused(window, cx);
|
||||
let is_recording = self.is_recording(window);
|
||||
|
||||
let horizontal_padding = rems_from_px(64.);
|
||||
let width = rems_from_px(64.);
|
||||
|
||||
let recording_bg_color = colors
|
||||
.editor_background
|
||||
@@ -528,6 +528,9 @@ impl Render for KeystrokeInput {
|
||||
h_flex()
|
||||
.id("keystroke-input")
|
||||
.track_focus(&self.outer_focus_handle)
|
||||
.key_context(Self::key_context())
|
||||
.on_action(cx.listener(Self::start_recording))
|
||||
.on_action(cx.listener(Self::clear_keystrokes))
|
||||
.py_2()
|
||||
.px_3()
|
||||
.gap_2()
|
||||
@@ -535,7 +538,7 @@ impl Render for KeystrokeInput {
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.justify_between()
|
||||
.rounded_sm()
|
||||
.rounded_md()
|
||||
.overflow_hidden()
|
||||
.map(|this| {
|
||||
if is_recording {
|
||||
@@ -545,16 +548,16 @@ impl Render for KeystrokeInput {
|
||||
}
|
||||
})
|
||||
.border_1()
|
||||
.border_color(colors.border_variant)
|
||||
.when(is_focused, |parent| {
|
||||
parent.border_color(colors.border_focused)
|
||||
.map(|this| {
|
||||
if is_focused {
|
||||
this.border_color(colors.border_focused)
|
||||
} else {
|
||||
this.border_color(colors.border_variant)
|
||||
}
|
||||
})
|
||||
.key_context(Self::key_context())
|
||||
.on_action(cx.listener(Self::start_recording))
|
||||
.on_action(cx.listener(Self::clear_keystrokes))
|
||||
.child(
|
||||
h_flex()
|
||||
.w(horizontal_padding)
|
||||
.w(width)
|
||||
.gap_0p5()
|
||||
.justify_start()
|
||||
.flex_none()
|
||||
@@ -573,14 +576,13 @@ impl Render for KeystrokeInput {
|
||||
.id("keystroke-input-inner")
|
||||
.track_focus(&self.inner_focus_handle)
|
||||
.on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
|
||||
.size_full()
|
||||
.when(!self.search, |this| {
|
||||
this.focus(|mut style| {
|
||||
style.border_color = Some(colors.border_focused);
|
||||
style
|
||||
})
|
||||
})
|
||||
.w_full()
|
||||
.size_full()
|
||||
.min_w_0()
|
||||
.justify_center()
|
||||
.flex_wrap()
|
||||
@@ -589,7 +591,7 @@ impl Render for KeystrokeInput {
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.w(horizontal_padding)
|
||||
.w(width)
|
||||
.gap_0p5()
|
||||
.justify_end()
|
||||
.flex_none()
|
||||
@@ -641,9 +643,7 @@ impl Render for KeystrokeInput {
|
||||
"Clear Keystrokes",
|
||||
&ClearKeystrokes,
|
||||
))
|
||||
.when(!is_recording || !is_focused, |this| {
|
||||
this.icon_color(Color::Muted)
|
||||
})
|
||||
.when(!is_focused, |this| this.icon_color(Color::Muted))
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.clear_keystrokes(&ClearKeystrokes, window, cx);
|
||||
})),
|
||||
|
||||
@@ -3459,46 +3459,66 @@ impl BufferSnapshot {
|
||||
}
|
||||
|
||||
/// Returns the closest syntax node enclosing the given range.
|
||||
/// Positions a tree cursor at the leaf node that contains or touches the given range.
|
||||
/// This is shared logic used by syntax navigation methods.
|
||||
fn position_cursor_at_range(cursor: &mut tree_sitter::TreeCursor, range: &Range<usize>) {
|
||||
// Descend to the first leaf that touches the start of the range.
|
||||
//
|
||||
// If the range is non-empty and the current node ends exactly at the start,
|
||||
// move to the next sibling to find a node that extends beyond the start.
|
||||
//
|
||||
// If the range is empty and the current node starts after the range position,
|
||||
// move to the previous sibling to find the node that contains the position.
|
||||
while cursor.goto_first_child_for_byte(range.start).is_some() {
|
||||
if !range.is_empty() && cursor.node().end_byte() == range.start {
|
||||
cursor.goto_next_sibling();
|
||||
}
|
||||
if range.is_empty() && cursor.node().start_byte() > range.start {
|
||||
cursor.goto_previous_sibling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the cursor to find a node that contains the given range.
|
||||
/// Returns true if such a node is found, false otherwise.
|
||||
/// This is shared logic used by syntax navigation methods.
|
||||
fn find_containing_node(
|
||||
cursor: &mut tree_sitter::TreeCursor,
|
||||
range: &Range<usize>,
|
||||
strict: bool,
|
||||
) -> bool {
|
||||
loop {
|
||||
let node_range = cursor.node().byte_range();
|
||||
|
||||
if node_range.start <= range.start
|
||||
&& node_range.end >= range.end
|
||||
&& (!strict || node_range.len() > range.len())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if !cursor.goto_parent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn syntax_ancestor<'a, T: ToOffset>(
|
||||
&'a self,
|
||||
range: Range<T>,
|
||||
) -> Option<tree_sitter::Node<'a>> {
|
||||
let range = range.start.to_offset(self)..range.end.to_offset(self);
|
||||
let mut result: Option<tree_sitter::Node<'a>> = None;
|
||||
'outer: for layer in self
|
||||
for layer in self
|
||||
.syntax
|
||||
.layers_for_range(range.clone(), &self.text, true)
|
||||
{
|
||||
let mut cursor = layer.node().walk();
|
||||
|
||||
// Descend to the first leaf that touches the start of the range.
|
||||
//
|
||||
// If the range is non-empty and the current node ends exactly at the start,
|
||||
// move to the next sibling to find a node that extends beyond the start.
|
||||
//
|
||||
// If the range is empty and the current node starts after the range position,
|
||||
// move to the previous sibling to find the node that contains the position.
|
||||
while cursor.goto_first_child_for_byte(range.start).is_some() {
|
||||
if !range.is_empty() && cursor.node().end_byte() == range.start {
|
||||
cursor.goto_next_sibling();
|
||||
}
|
||||
if range.is_empty() && cursor.node().start_byte() > range.start {
|
||||
cursor.goto_previous_sibling();
|
||||
}
|
||||
}
|
||||
Self::position_cursor_at_range(&mut cursor, &range);
|
||||
|
||||
// Ascend to the smallest ancestor that strictly contains the range.
|
||||
loop {
|
||||
let node_range = cursor.node().byte_range();
|
||||
if node_range.start <= range.start
|
||||
&& node_range.end >= range.end
|
||||
&& node_range.len() > range.len()
|
||||
{
|
||||
break;
|
||||
}
|
||||
if !cursor.goto_parent() {
|
||||
continue 'outer;
|
||||
}
|
||||
if !Self::find_containing_node(&mut cursor, &range, true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let left_node = cursor.node();
|
||||
@@ -3541,6 +3561,112 @@ impl BufferSnapshot {
|
||||
result
|
||||
}
|
||||
|
||||
/// Find the previous sibling syntax node at the given range.
|
||||
///
|
||||
/// This function locates the syntax node that precedes the node containing
|
||||
/// the given range. It searches hierarchically by:
|
||||
/// 1. Finding the node that contains the given range
|
||||
/// 2. Looking for the previous sibling at the same tree level
|
||||
/// 3. If no sibling is found, moving up to parent levels and searching for siblings
|
||||
///
|
||||
/// Returns `None` if there is no previous sibling at any ancestor level.
|
||||
pub fn syntax_prev_sibling<'a, T: ToOffset>(
|
||||
&'a self,
|
||||
range: Range<T>,
|
||||
) -> Option<tree_sitter::Node<'a>> {
|
||||
let range = range.start.to_offset(self)..range.end.to_offset(self);
|
||||
let mut result: Option<tree_sitter::Node<'a>> = None;
|
||||
|
||||
for layer in self
|
||||
.syntax
|
||||
.layers_for_range(range.clone(), &self.text, true)
|
||||
{
|
||||
let mut cursor = layer.node().walk();
|
||||
|
||||
Self::position_cursor_at_range(&mut cursor, &range);
|
||||
|
||||
// Find the node that contains the range
|
||||
if !Self::find_containing_node(&mut cursor, &range, false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look for the previous sibling, moving up ancestor levels if needed
|
||||
loop {
|
||||
if cursor.goto_previous_sibling() {
|
||||
let layer_result = cursor.node();
|
||||
|
||||
if let Some(previous_result) = &result {
|
||||
if previous_result.byte_range().end < layer_result.byte_range().end {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result = Some(layer_result);
|
||||
break;
|
||||
}
|
||||
|
||||
// No sibling found at this level, try moving up to parent
|
||||
if !cursor.goto_parent() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Find the next sibling syntax node at the given range.
|
||||
///
|
||||
/// This function locates the syntax node that follows the node containing
|
||||
/// the given range. It searches hierarchically by:
|
||||
/// 1. Finding the node that contains the given range
|
||||
/// 2. Looking for the next sibling at the same tree level
|
||||
/// 3. If no sibling is found, moving up to parent levels and searching for siblings
|
||||
///
|
||||
/// Returns `None` if there is no next sibling at any ancestor level.
|
||||
pub fn syntax_next_sibling<'a, T: ToOffset>(
|
||||
&'a self,
|
||||
range: Range<T>,
|
||||
) -> Option<tree_sitter::Node<'a>> {
|
||||
let range = range.start.to_offset(self)..range.end.to_offset(self);
|
||||
let mut result: Option<tree_sitter::Node<'a>> = None;
|
||||
|
||||
for layer in self
|
||||
.syntax
|
||||
.layers_for_range(range.clone(), &self.text, true)
|
||||
{
|
||||
let mut cursor = layer.node().walk();
|
||||
|
||||
Self::position_cursor_at_range(&mut cursor, &range);
|
||||
|
||||
// Find the node that contains the range
|
||||
if !Self::find_containing_node(&mut cursor, &range, false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look for the next sibling, moving up ancestor levels if needed
|
||||
loop {
|
||||
if cursor.goto_next_sibling() {
|
||||
let layer_result = cursor.node();
|
||||
|
||||
if let Some(previous_result) = &result {
|
||||
if previous_result.byte_range().start > layer_result.byte_range().start {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result = Some(layer_result);
|
||||
break;
|
||||
}
|
||||
|
||||
// No sibling found at this level, try moving up to parent
|
||||
if !cursor.goto_parent() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Returns the root syntax node within the given row
|
||||
pub fn syntax_root_ancestor(&self, position: Anchor) -> Option<tree_sitter::Node<'_>> {
|
||||
let start_offset = position.to_offset(self);
|
||||
|
||||
@@ -317,7 +317,6 @@ pub struct AllLanguageSettingsContent {
|
||||
pub defaults: LanguageSettingsContent,
|
||||
/// The settings for individual languages.
|
||||
#[serde(default)]
|
||||
#[settings_ui(skip)]
|
||||
pub languages: LanguageToSettingsMap,
|
||||
/// Settings for associating file extensions and filenames
|
||||
/// with languages.
|
||||
@@ -331,6 +330,37 @@ pub struct AllLanguageSettingsContent {
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct LanguageToSettingsMap(pub HashMap<LanguageName, LanguageSettingsContent>);
|
||||
|
||||
impl SettingsUi for LanguageToSettingsMap {
|
||||
fn settings_ui_item() -> settings::SettingsUiItem {
|
||||
settings::SettingsUiItem::DynamicMap(settings::SettingsUiItemDynamicMap {
|
||||
item: LanguageSettingsContent::settings_ui_item,
|
||||
defaults_path: &[],
|
||||
determine_items: |settings_value, cx| {
|
||||
use settings::SettingsUiEntryMetaData;
|
||||
|
||||
// todo(settings_ui): We should be using a global LanguageRegistry, but it's not implemented yet
|
||||
_ = cx;
|
||||
|
||||
let Some(settings_language_map) = settings_value.as_object() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut languages = Vec::with_capacity(settings_language_map.len());
|
||||
|
||||
for language_name in settings_language_map.keys().map(gpui::SharedString::from) {
|
||||
languages.push(SettingsUiEntryMetaData {
|
||||
title: language_name.clone(),
|
||||
path: language_name,
|
||||
// todo(settings_ui): Implement documentation for each language
|
||||
// ideally based on the language's official docs from extension or builtin info
|
||||
documentation: None,
|
||||
});
|
||||
}
|
||||
return languages;
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit! {
|
||||
ParameterizedJsonSchema {
|
||||
add_and_get_ref: |generator, params, _cx| {
|
||||
@@ -431,11 +461,13 @@ fn default_3() -> usize {
|
||||
|
||||
/// The settings for a particular language.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema, SettingsUi)]
|
||||
#[settings_ui(group = "Default")]
|
||||
pub struct LanguageSettingsContent {
|
||||
/// How many columns a tab should occupy.
|
||||
///
|
||||
/// Default: 4
|
||||
#[serde(default)]
|
||||
#[settings_ui(skip)]
|
||||
pub tab_size: Option<NonZeroU32>,
|
||||
/// Whether to indent lines using tab characters, as opposed to multiple
|
||||
/// spaces.
|
||||
@@ -466,6 +498,7 @@ pub struct LanguageSettingsContent {
|
||||
///
|
||||
/// Default: []
|
||||
#[serde(default)]
|
||||
#[settings_ui(skip)]
|
||||
pub wrap_guides: Option<Vec<usize>>,
|
||||
/// Indent guide related settings.
|
||||
#[serde(default)]
|
||||
@@ -491,6 +524,7 @@ pub struct LanguageSettingsContent {
|
||||
///
|
||||
/// Default: auto
|
||||
#[serde(default)]
|
||||
#[settings_ui(skip)]
|
||||
pub formatter: Option<SelectedFormatter>,
|
||||
/// Zed's Prettier integration settings.
|
||||
/// Allows to enable/disable formatting with Prettier
|
||||
@@ -516,6 +550,7 @@ pub struct LanguageSettingsContent {
|
||||
///
|
||||
/// Default: ["..."]
|
||||
#[serde(default)]
|
||||
#[settings_ui(skip)]
|
||||
pub language_servers: Option<Vec<String>>,
|
||||
/// Controls where the `editor::Rewrap` action is allowed for this language.
|
||||
///
|
||||
@@ -538,6 +573,7 @@ pub struct LanguageSettingsContent {
|
||||
///
|
||||
/// Default: []
|
||||
#[serde(default)]
|
||||
#[settings_ui(skip)]
|
||||
pub edit_predictions_disabled_in: Option<Vec<String>>,
|
||||
/// Whether to show tabs and spaces in the editor.
|
||||
#[serde(default)]
|
||||
@@ -577,6 +613,7 @@ pub struct LanguageSettingsContent {
|
||||
/// These are not run if formatting is off.
|
||||
///
|
||||
/// Default: {} (or {"source.organizeImports": true} for Go).
|
||||
#[settings_ui(skip)]
|
||||
pub code_actions_on_format: Option<HashMap<String, bool>>,
|
||||
/// Whether to perform linked edits of associated ranges, if the language server supports it.
|
||||
/// For example, when editing opening <html> tag, the contents of the closing </html> tag will be edited as well.
|
||||
@@ -610,11 +647,14 @@ pub struct LanguageSettingsContent {
|
||||
/// Preferred debuggers for this language.
|
||||
///
|
||||
/// Default: []
|
||||
#[settings_ui(skip)]
|
||||
pub debuggers: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// The behavior of `editor::Rewrap`.
|
||||
#[derive(Debug, PartialEq, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(
|
||||
Debug, PartialEq, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, SettingsUi,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RewrapBehavior {
|
||||
/// Only rewrap within comments.
|
||||
@@ -696,7 +736,7 @@ pub enum SoftWrap {
|
||||
}
|
||||
|
||||
/// Controls the behavior of formatting files when they are saved.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SettingsUi)]
|
||||
pub enum FormatOnSave {
|
||||
/// Files should be formatted on save.
|
||||
On,
|
||||
@@ -795,7 +835,7 @@ impl<'de> Deserialize<'de> for FormatOnSave {
|
||||
}
|
||||
|
||||
/// Controls how whitespace should be displayedin the editor.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ShowWhitespaceSetting {
|
||||
/// Draw whitespace only for the selected text.
|
||||
@@ -816,7 +856,7 @@ pub enum ShowWhitespaceSetting {
|
||||
}
|
||||
|
||||
/// Controls which formatter should be used when formatting code.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, SettingsUi)]
|
||||
pub enum SelectedFormatter {
|
||||
/// Format files using Zed's Prettier integration (if applicable),
|
||||
/// or falling back to formatting via language server.
|
||||
@@ -1012,7 +1052,7 @@ pub enum IndentGuideBackgroundColoring {
|
||||
}
|
||||
|
||||
/// The settings for inlay hints.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi)]
|
||||
pub struct InlayHintSettings {
|
||||
/// Global switch to toggle hints on and off.
|
||||
///
|
||||
@@ -1079,7 +1119,7 @@ fn scroll_debounce_ms() -> u64 {
|
||||
}
|
||||
|
||||
/// The task settings for a particular language.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, JsonSchema)]
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, JsonSchema, SettingsUi)]
|
||||
pub struct LanguageTaskConfig {
|
||||
/// Extra task variables to set for a particular language.
|
||||
#[serde(default)]
|
||||
@@ -1622,7 +1662,7 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent
|
||||
/// Allows to enable/disable formatting with Prettier
|
||||
/// and configure default Prettier, used when no project-level Prettier installation is found.
|
||||
/// Prettier formatting is disabled by default.
|
||||
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, SettingsUi)]
|
||||
pub struct PrettierSettings {
|
||||
/// Enables or disables formatting with Prettier for a given language.
|
||||
#[serde(default)]
|
||||
@@ -1635,15 +1675,17 @@ pub struct PrettierSettings {
|
||||
/// Forces Prettier integration to use specific plugins when formatting files with the language.
|
||||
/// The default Prettier will be installed with these plugins.
|
||||
#[serde(default)]
|
||||
#[settings_ui(skip)]
|
||||
pub plugins: HashSet<String>,
|
||||
|
||||
/// Default Prettier options, in the format as in package.json section for Prettier.
|
||||
/// If project installs Prettier via its package.json, these options will be ignored.
|
||||
#[serde(flatten)]
|
||||
#[settings_ui(skip)]
|
||||
pub options: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, SettingsUi)]
|
||||
pub struct JsxTagAutoCloseSettings {
|
||||
/// Enables or disables auto-closing of JSX tags.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -36,6 +36,7 @@ impl fmt::Display for ModelRequestLimitReachedError {
|
||||
Plan::ZedProTrial => {
|
||||
"Model request limit reached. Upgrade to Zed Pro for more requests."
|
||||
}
|
||||
Plan::ZedProV2 | Plan::ZedProTrialV2 => "Model request limit reached.",
|
||||
};
|
||||
|
||||
write!(f, "{message}")
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
use db::kvp::Dismissable;
|
||||
use editor::Editor;
|
||||
use gpui::{Context, EventEmitter, Subscription};
|
||||
use ui::{
|
||||
Banner, Button, Clickable, Color, FluentBuilder as _, IconButton, IconName,
|
||||
InteractiveElement as _, IntoElement, Label, LabelCommon, LabelSize, ParentElement as _,
|
||||
Render, Styled as _, Window, div, h_flex, v_flex,
|
||||
};
|
||||
use ui::{Banner, FluentBuilder as _, prelude::*};
|
||||
use workspace::{ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace};
|
||||
|
||||
pub struct BasedPyrightBanner {
|
||||
@@ -45,29 +41,33 @@ impl Render for BasedPyrightBanner {
|
||||
.when(!self.dismissed && self.have_basedpyright, |el| {
|
||||
el.child(
|
||||
Banner::new()
|
||||
.severity(ui::Severity::Info)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.child(Label::new("Basedpyright is now the only default language server for Python").mt_0p5())
|
||||
.child(Label::new("We have disabled PyRight and pylsp by default. They can be re-enabled in your settings.").size(LabelSize::Small).color(Color::Muted))
|
||||
)
|
||||
.action_slot(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(v_flex()
|
||||
.child("Basedpyright is now the only default language server for Python")
|
||||
.child(Label::new("We have disabled PyRight and pylsp by default. They can be re-enabled in your settings.").size(LabelSize::XSmall).color(Color::Muted))
|
||||
)
|
||||
.gap_0p5()
|
||||
.child(
|
||||
Button::new("learn-more", "Learn More")
|
||||
.icon(IconName::ArrowUpRight)
|
||||
.label_size(LabelSize::Small)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Muted)
|
||||
.on_click(|_, _, cx| {
|
||||
cx.open_url("https://zed.dev/docs/languages/python")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(IconButton::new("dismiss", IconName::Close).icon_size(IconSize::Small).on_click(
|
||||
cx.listener(|this, _, _, cx| {
|
||||
this.dismissed = true;
|
||||
Self::set_dismissed(true, cx);
|
||||
cx.notify();
|
||||
}),
|
||||
))
|
||||
)
|
||||
.action_slot(IconButton::new("dismiss", IconName::Close).on_click(
|
||||
cx.listener(|this, _, _, cx| {
|
||||
this.dismissed = true;
|
||||
Self::set_dismissed(true, cx);
|
||||
cx.notify();
|
||||
}),
|
||||
))
|
||||
.into_any_element(),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -6095,6 +6095,28 @@ impl MultiBufferSnapshot {
|
||||
Some((node, range))
|
||||
}
|
||||
|
||||
pub fn syntax_next_sibling<T: ToOffset>(
|
||||
&self,
|
||||
range: Range<T>,
|
||||
) -> Option<tree_sitter::Node<'_>> {
|
||||
let range = range.start.to_offset(self)..range.end.to_offset(self);
|
||||
let mut excerpt = self.excerpt_containing(range.clone())?;
|
||||
excerpt
|
||||
.buffer()
|
||||
.syntax_next_sibling(excerpt.map_range_to_buffer(range))
|
||||
}
|
||||
|
||||
pub fn syntax_prev_sibling<T: ToOffset>(
|
||||
&self,
|
||||
range: Range<T>,
|
||||
) -> Option<tree_sitter::Node<'_>> {
|
||||
let range = range.start.to_offset(self)..range.end.to_offset(self);
|
||||
let mut excerpt = self.excerpt_containing(range.clone())?;
|
||||
excerpt
|
||||
.buffer()
|
||||
.syntax_prev_sibling(excerpt.map_range_to_buffer(range))
|
||||
}
|
||||
|
||||
pub fn outline(&self, theme: Option<&SyntaxTheme>) -> Option<Outline<Anchor>> {
|
||||
let (excerpt_id, _, buffer) = self.as_singleton()?;
|
||||
let outline = buffer.outline(theme)?;
|
||||
|
||||
@@ -24,7 +24,6 @@ test-support = [
|
||||
anyhow.workspace = true
|
||||
channel.workspace = true
|
||||
client.workspace = true
|
||||
collections.workspace = true
|
||||
component.workspace = true
|
||||
db.workspace = true
|
||||
gpui.workspace = true
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use channel::{ChannelMessage, ChannelMessageId, ChannelStore};
|
||||
use channel::ChannelStore;
|
||||
use client::{ChannelId, Client, UserStore};
|
||||
use collections::HashMap;
|
||||
use db::smol::stream::StreamExt;
|
||||
use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Global, Task};
|
||||
use rpc::{Notification, TypedEnvelope, proto};
|
||||
@@ -22,7 +21,6 @@ impl Global for GlobalNotificationStore {}
|
||||
pub struct NotificationStore {
|
||||
client: Arc<Client>,
|
||||
user_store: Entity<UserStore>,
|
||||
channel_messages: HashMap<u64, ChannelMessage>,
|
||||
channel_store: Entity<ChannelStore>,
|
||||
notifications: SumTree<NotificationEntry>,
|
||||
loaded_all_notifications: bool,
|
||||
@@ -100,12 +98,10 @@ impl NotificationStore {
|
||||
channel_store: ChannelStore::global(cx),
|
||||
notifications: Default::default(),
|
||||
loaded_all_notifications: false,
|
||||
channel_messages: Default::default(),
|
||||
_watch_connection_status: watch_connection_status,
|
||||
_subscriptions: vec![
|
||||
client.add_message_handler(cx.weak_entity(), Self::handle_new_notification),
|
||||
client.add_message_handler(cx.weak_entity(), Self::handle_delete_notification),
|
||||
client.add_message_handler(cx.weak_entity(), Self::handle_update_notification),
|
||||
],
|
||||
user_store,
|
||||
client,
|
||||
@@ -120,10 +116,6 @@ impl NotificationStore {
|
||||
self.notifications.summary().unread_count
|
||||
}
|
||||
|
||||
pub fn channel_message_for_id(&self, id: u64) -> Option<&ChannelMessage> {
|
||||
self.channel_messages.get(&id)
|
||||
}
|
||||
|
||||
// Get the nth newest notification.
|
||||
pub fn notification_at(&self, ix: usize) -> Option<&NotificationEntry> {
|
||||
let count = self.notifications.summary().count;
|
||||
@@ -185,7 +177,6 @@ impl NotificationStore {
|
||||
|
||||
fn handle_connect(&mut self, cx: &mut Context<Self>) -> Option<Task<Result<()>>> {
|
||||
self.notifications = Default::default();
|
||||
self.channel_messages = Default::default();
|
||||
cx.notify();
|
||||
self.load_more_notifications(true, cx)
|
||||
}
|
||||
@@ -223,35 +214,6 @@ impl NotificationStore {
|
||||
})?
|
||||
}
|
||||
|
||||
async fn handle_update_notification(
|
||||
this: Entity<Self>,
|
||||
envelope: TypedEnvelope<proto::UpdateNotification>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<()> {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
if let Some(notification) = envelope.payload.notification
|
||||
&& let Some(rpc::Notification::ChannelMessageMention { message_id, .. }) =
|
||||
Notification::from_proto(¬ification)
|
||||
{
|
||||
let fetch_message_task = this.channel_store.update(cx, |this, cx| {
|
||||
this.fetch_channel_messages(vec![message_id], cx)
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let messages = fetch_message_task.await?;
|
||||
this.update(cx, move |this, cx| {
|
||||
for message in messages {
|
||||
this.channel_messages.insert(message_id, message);
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx)
|
||||
}
|
||||
Ok(())
|
||||
})?
|
||||
}
|
||||
|
||||
async fn add_notifications(
|
||||
this: Entity<Self>,
|
||||
notifications: Vec<proto::Notification>,
|
||||
@@ -259,7 +221,6 @@ impl NotificationStore {
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<()> {
|
||||
let mut user_ids = Vec::new();
|
||||
let mut message_ids = Vec::new();
|
||||
|
||||
let notifications = notifications
|
||||
.into_iter()
|
||||
@@ -293,29 +254,14 @@ impl NotificationStore {
|
||||
} => {
|
||||
user_ids.push(contact_id);
|
||||
}
|
||||
Notification::ChannelMessageMention {
|
||||
sender_id,
|
||||
message_id,
|
||||
..
|
||||
} => {
|
||||
user_ids.push(sender_id);
|
||||
message_ids.push(message_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (user_store, channel_store) = this.read_with(cx, |this, _| {
|
||||
(this.user_store.clone(), this.channel_store.clone())
|
||||
})?;
|
||||
let user_store = this.read_with(cx, |this, _| this.user_store.clone())?;
|
||||
|
||||
user_store
|
||||
.update(cx, |store, cx| store.get_users(user_ids, cx))?
|
||||
.await?;
|
||||
let messages = channel_store
|
||||
.update(cx, |store, cx| {
|
||||
store.fetch_channel_messages(message_ids, cx)
|
||||
})?
|
||||
.await?;
|
||||
this.update(cx, |this, cx| {
|
||||
if options.clear_old {
|
||||
cx.emit(NotificationEvent::NotificationsUpdated {
|
||||
@@ -323,7 +269,6 @@ impl NotificationStore {
|
||||
new_count: 0,
|
||||
});
|
||||
this.notifications = SumTree::default();
|
||||
this.channel_messages.clear();
|
||||
this.loaded_all_notifications = false;
|
||||
}
|
||||
|
||||
@@ -331,15 +276,6 @@ impl NotificationStore {
|
||||
this.loaded_all_notifications = true;
|
||||
}
|
||||
|
||||
this.channel_messages
|
||||
.extend(messages.into_iter().filter_map(|message| {
|
||||
if let ChannelMessageId::Saved(id) = message.id {
|
||||
Some((id, message))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}));
|
||||
|
||||
this.splice_notifications(
|
||||
notifications
|
||||
.into_iter()
|
||||
|
||||
@@ -473,7 +473,7 @@ pub async fn stream_completion(
|
||||
.filter_map(|line| async move {
|
||||
match line {
|
||||
Ok(line) => {
|
||||
let line = line.strip_prefix("data: ")?;
|
||||
let line = line.strip_prefix("data: ").or_else(|| line.strip_prefix("data:"))?;
|
||||
if line == "[DONE]" {
|
||||
None
|
||||
} else {
|
||||
|
||||
@@ -482,6 +482,7 @@ impl LocalBufferStore {
|
||||
Some(buffer)
|
||||
} else {
|
||||
this.opened_buffers.remove(&buffer_id);
|
||||
this.non_searchable_buffers.remove(&buffer_id);
|
||||
None
|
||||
};
|
||||
|
||||
|
||||
@@ -23,16 +23,17 @@ message UpdateChannels {
|
||||
repeated Channel channel_invitations = 5;
|
||||
repeated uint64 remove_channel_invitations = 6;
|
||||
repeated ChannelParticipants channel_participants = 7;
|
||||
repeated ChannelMessageId latest_channel_message_ids = 8;
|
||||
repeated ChannelBufferVersion latest_channel_buffer_versions = 9;
|
||||
|
||||
reserved 8;
|
||||
reserved 10 to 15;
|
||||
}
|
||||
|
||||
message UpdateUserChannels {
|
||||
repeated ChannelMessageId observed_channel_message_id = 1;
|
||||
repeated ChannelBufferVersion observed_channel_buffer_version = 2;
|
||||
repeated ChannelMembership channel_memberships = 3;
|
||||
|
||||
reserved 1;
|
||||
}
|
||||
|
||||
message ChannelMembership {
|
||||
|
||||
@@ -32,12 +32,6 @@ pub enum Notification {
|
||||
channel_name: String,
|
||||
inviter_id: u64,
|
||||
},
|
||||
ChannelMessageMention {
|
||||
#[serde(rename = "entity_id")]
|
||||
message_id: u64,
|
||||
sender_id: u64,
|
||||
channel_id: u64,
|
||||
},
|
||||
}
|
||||
|
||||
impl Notification {
|
||||
@@ -91,11 +85,6 @@ mod tests {
|
||||
channel_name: "the-channel".into(),
|
||||
inviter_id: 50,
|
||||
},
|
||||
Notification::ChannelMessageMention {
|
||||
sender_id: 200,
|
||||
channel_id: 30,
|
||||
message_id: 1,
|
||||
},
|
||||
] {
|
||||
let message = notification.to_proto();
|
||||
let deserialized = Notification::from_proto(&message).unwrap();
|
||||
|
||||
@@ -36,10 +36,7 @@ use std::{
|
||||
pin::pin,
|
||||
sync::Arc,
|
||||
};
|
||||
use ui::{
|
||||
Icon, IconButton, IconButtonShape, IconName, KeyBinding, Label, LabelCommon, LabelSize,
|
||||
Toggleable, Tooltip, h_flex, prelude::*, utils::SearchInputWidth, v_flex,
|
||||
};
|
||||
use ui::{IconButtonShape, KeyBinding, Toggleable, Tooltip, prelude::*, utils::SearchInputWidth};
|
||||
use util::{ResultExt as _, paths::PathMatcher};
|
||||
use workspace::{
|
||||
DeploySearch, ItemNavHistory, NewSearch, ToolbarItemEvent, ToolbarItemLocation,
|
||||
|
||||
@@ -41,15 +41,15 @@ pub(super) fn render_action_button(
|
||||
|
||||
pub(crate) fn input_base_styles(border_color: Hsla, map: impl FnOnce(Div) -> Div) -> Div {
|
||||
h_flex()
|
||||
.min_w_32()
|
||||
.map(map)
|
||||
.min_w_32()
|
||||
.h_8()
|
||||
.pl_2()
|
||||
.pr_1()
|
||||
.py_1()
|
||||
.border_1()
|
||||
.border_color(border_color)
|
||||
.rounded_lg()
|
||||
.rounded_md()
|
||||
}
|
||||
|
||||
pub(crate) fn render_text_input(
|
||||
|
||||
@@ -601,12 +601,12 @@ impl SettingsStore {
|
||||
pub fn update_settings_file_at_path(
|
||||
&self,
|
||||
fs: Arc<dyn Fs>,
|
||||
path: &[&str],
|
||||
path: &[impl AsRef<str>],
|
||||
new_value: serde_json::Value,
|
||||
) -> oneshot::Receiver<Result<()>> {
|
||||
let key_path = path
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.map(AsRef::as_ref)
|
||||
.map(SharedString::new)
|
||||
.collect::<Vec<_>>();
|
||||
let update = move |mut old_text: String, cx: AsyncApp| {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use std::any::TypeId;
|
||||
use std::{
|
||||
any::TypeId,
|
||||
num::{NonZeroU32, NonZeroUsize},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use fs::Fs;
|
||||
use gpui::{AnyElement, App, AppContext as _, ReadGlobal as _, Window};
|
||||
use gpui::{AnyElement, App, AppContext as _, ReadGlobal as _, SharedString, Window};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::SettingsStore;
|
||||
@@ -24,6 +28,7 @@ pub trait SettingsUi {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SettingsUiEntry {
|
||||
/// The path in the settings JSON file for this setting. Relative to parent
|
||||
/// None implies `#[serde(flatten)]` or `Settings::KEY.is_none()` for top level settings
|
||||
@@ -35,6 +40,7 @@ pub struct SettingsUiEntry {
|
||||
pub item: SettingsUiItem,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum SettingsUiItemSingle {
|
||||
SwitchField,
|
||||
/// A numeric stepper for a specific type of number
|
||||
@@ -52,13 +58,13 @@ pub enum SettingsUiItemSingle {
|
||||
/// Must be the same length as `variants`
|
||||
labels: &'static [&'static str],
|
||||
},
|
||||
Custom(Box<dyn Fn(SettingsValue<serde_json::Value>, &mut Window, &mut App) -> AnyElement>),
|
||||
Custom(Rc<dyn Fn(SettingsValue<serde_json::Value>, &mut Window, &mut App) -> AnyElement>),
|
||||
}
|
||||
|
||||
pub struct SettingsValue<T> {
|
||||
pub title: &'static str,
|
||||
pub documentation: Option<&'static str>,
|
||||
pub path: SmallVec<[&'static str; 1]>,
|
||||
pub title: SharedString,
|
||||
pub documentation: Option<SharedString>,
|
||||
pub path: SmallVec<[SharedString; 1]>,
|
||||
pub value: Option<T>,
|
||||
pub default_value: T,
|
||||
}
|
||||
@@ -73,7 +79,7 @@ impl<T> SettingsValue<T> {
|
||||
}
|
||||
|
||||
impl SettingsValue<serde_json::Value> {
|
||||
pub fn write_value(path: &SmallVec<[&'static str; 1]>, value: serde_json::Value, cx: &mut App) {
|
||||
pub fn write_value(path: &SmallVec<[SharedString; 1]>, value: serde_json::Value, cx: &mut App) {
|
||||
let settings_store = SettingsStore::global(cx);
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
|
||||
@@ -90,7 +96,7 @@ impl SettingsValue<serde_json::Value> {
|
||||
|
||||
impl<T: serde::Serialize> SettingsValue<T> {
|
||||
pub fn write(
|
||||
path: &SmallVec<[&'static str; 1]>,
|
||||
path: &SmallVec<[SharedString; 1]>,
|
||||
value: T,
|
||||
cx: &mut App,
|
||||
) -> Result<(), serde_json::Error> {
|
||||
@@ -99,19 +105,36 @@ impl<T: serde::Serialize> SettingsValue<T> {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SettingsUiItemDynamic {
|
||||
#[derive(Clone)]
|
||||
pub struct SettingsUiItemUnion {
|
||||
pub options: Vec<SettingsUiEntry>,
|
||||
pub determine_option: fn(&serde_json::Value, &App) -> usize,
|
||||
}
|
||||
|
||||
pub struct SettingsUiEntryMetaData {
|
||||
pub title: SharedString,
|
||||
pub path: SharedString,
|
||||
pub documentation: Option<SharedString>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SettingsUiItemDynamicMap {
|
||||
pub item: fn() -> SettingsUiItem,
|
||||
pub determine_items: fn(&serde_json::Value, &App) -> Vec<SettingsUiEntryMetaData>,
|
||||
pub defaults_path: &'static [&'static str],
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SettingsUiItemGroup {
|
||||
pub items: Vec<SettingsUiEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum SettingsUiItem {
|
||||
Group(SettingsUiItemGroup),
|
||||
Single(SettingsUiItemSingle),
|
||||
Dynamic(SettingsUiItemDynamic),
|
||||
Union(SettingsUiItemUnion),
|
||||
DynamicMap(SettingsUiItemDynamicMap),
|
||||
None,
|
||||
}
|
||||
|
||||
@@ -134,6 +157,7 @@ pub enum NumType {
|
||||
U32 = 1,
|
||||
F32 = 2,
|
||||
USIZE = 3,
|
||||
U32NONZERO = 4,
|
||||
}
|
||||
|
||||
pub static NUM_TYPE_NAMES: std::sync::LazyLock<[&'static str; NumType::COUNT]> =
|
||||
@@ -151,6 +175,7 @@ impl NumType {
|
||||
NumType::U32 => TypeId::of::<u32>(),
|
||||
NumType::F32 => TypeId::of::<f32>(),
|
||||
NumType::USIZE => TypeId::of::<usize>(),
|
||||
NumType::U32NONZERO => TypeId::of::<NonZeroU32>(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +185,7 @@ impl NumType {
|
||||
NumType::U32 => std::any::type_name::<u32>(),
|
||||
NumType::F32 => std::any::type_name::<f32>(),
|
||||
NumType::USIZE => std::any::type_name::<usize>(),
|
||||
NumType::U32NONZERO => std::any::type_name::<NonZeroU32>(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,3 +211,4 @@ numeric_stepper_for_num_type!(u32, U32);
|
||||
// todo(settings_ui) is there a better ui for f32?
|
||||
numeric_stepper_for_num_type!(f32, F32);
|
||||
numeric_stepper_for_num_type!(usize, USIZE);
|
||||
numeric_stepper_for_num_type!(NonZeroUsize, U32NONZERO);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod appearance_settings_controls;
|
||||
|
||||
use std::any::TypeId;
|
||||
use std::num::NonZeroU32;
|
||||
use std::ops::{Not, Range};
|
||||
|
||||
use anyhow::Context as _;
|
||||
@@ -9,8 +10,9 @@ use editor::EditorSettingsControls;
|
||||
use feature_flags::{FeatureFlag, FeatureFlagViewExt};
|
||||
use gpui::{App, Entity, EventEmitter, FocusHandle, Focusable, ReadGlobal, ScrollHandle, actions};
|
||||
use settings::{
|
||||
NumType, SettingsStore, SettingsUiEntry, SettingsUiItem, SettingsUiItemDynamic,
|
||||
SettingsUiItemGroup, SettingsUiItemSingle, SettingsValue,
|
||||
NumType, SettingsStore, SettingsUiEntry, SettingsUiEntryMetaData, SettingsUiItem,
|
||||
SettingsUiItemDynamicMap, SettingsUiItemGroup, SettingsUiItemSingle, SettingsUiItemUnion,
|
||||
SettingsValue,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use ui::{NumericStepper, SwitchField, ToggleButtonGroup, ToggleButtonSimple, prelude::*};
|
||||
@@ -135,9 +137,9 @@ impl Item for SettingsPage {
|
||||
// - Do we want to show the parent groups when a item is matched?
|
||||
|
||||
struct UiEntry {
|
||||
title: &'static str,
|
||||
path: Option<&'static str>,
|
||||
documentation: Option<&'static str>,
|
||||
title: SharedString,
|
||||
path: Option<SharedString>,
|
||||
documentation: Option<SharedString>,
|
||||
_depth: usize,
|
||||
// a
|
||||
// b < a descendant range < a total descendant range
|
||||
@@ -154,6 +156,11 @@ struct UiEntry {
|
||||
/// For dynamic items this is a way to select a value from a list of values
|
||||
/// this is always none for non-dynamic items
|
||||
select_descendant: Option<fn(&serde_json::Value, &App) -> usize>,
|
||||
generate_items: Option<(
|
||||
SettingsUiItem,
|
||||
fn(&serde_json::Value, &App) -> Vec<SettingsUiEntryMetaData>,
|
||||
SmallVec<[SharedString; 1]>,
|
||||
)>,
|
||||
}
|
||||
|
||||
impl UiEntry {
|
||||
@@ -193,15 +200,16 @@ fn build_tree_item(
|
||||
) {
|
||||
let index = tree.len();
|
||||
tree.push(UiEntry {
|
||||
title: entry.title,
|
||||
path: entry.path,
|
||||
documentation: entry.documentation,
|
||||
title: entry.title.into(),
|
||||
path: entry.path.map(SharedString::new_static),
|
||||
documentation: entry.documentation.map(SharedString::new_static),
|
||||
_depth: depth,
|
||||
descendant_range: index + 1..index + 1,
|
||||
total_descendant_range: index + 1..index + 1,
|
||||
render: None,
|
||||
next_sibling: None,
|
||||
select_descendant: None,
|
||||
generate_items: None,
|
||||
});
|
||||
if let Some(prev_index) = prev_index {
|
||||
tree[prev_index].next_sibling = Some(index);
|
||||
@@ -222,7 +230,7 @@ fn build_tree_item(
|
||||
SettingsUiItem::Single(item) => {
|
||||
tree[index].render = Some(item);
|
||||
}
|
||||
SettingsUiItem::Dynamic(SettingsUiItemDynamic {
|
||||
SettingsUiItem::Union(SettingsUiItemUnion {
|
||||
options,
|
||||
determine_option,
|
||||
}) => {
|
||||
@@ -238,6 +246,21 @@ fn build_tree_item(
|
||||
tree[index].total_descendant_range.end = tree.len();
|
||||
}
|
||||
}
|
||||
SettingsUiItem::DynamicMap(SettingsUiItemDynamicMap {
|
||||
item: generate_settings_ui_item,
|
||||
determine_items,
|
||||
defaults_path,
|
||||
}) => {
|
||||
tree[index].generate_items = Some((
|
||||
generate_settings_ui_item(),
|
||||
determine_items,
|
||||
defaults_path
|
||||
.into_iter()
|
||||
.copied()
|
||||
.map(SharedString::new_static)
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
SettingsUiItem::None => {
|
||||
return;
|
||||
}
|
||||
@@ -263,7 +286,7 @@ impl SettingsUiTree {
|
||||
build_tree_item(&mut tree, item, 0, prev_root_entry_index);
|
||||
}
|
||||
|
||||
root_entry_indices.sort_by_key(|i| tree[*i].title);
|
||||
root_entry_indices.sort_by_key(|i| &tree[*i].title);
|
||||
|
||||
let active_entry_index = root_entry_indices[0];
|
||||
Self {
|
||||
@@ -276,18 +299,18 @@ impl SettingsUiTree {
|
||||
// todo(settings_ui): Make sure `Item::None` paths are added to the paths tree,
|
||||
// so that we can keep none/skip and still test in CI that all settings have
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn all_paths(&self, cx: &App) -> Vec<Vec<&'static str>> {
|
||||
pub fn all_paths(&self, cx: &App) -> Vec<Vec<SharedString>> {
|
||||
fn all_paths_rec(
|
||||
tree: &[UiEntry],
|
||||
paths: &mut Vec<Vec<&'static str>>,
|
||||
current_path: &mut Vec<&'static str>,
|
||||
paths: &mut Vec<Vec<SharedString>>,
|
||||
current_path: &mut Vec<SharedString>,
|
||||
idx: usize,
|
||||
cx: &App,
|
||||
) {
|
||||
let child = &tree[idx];
|
||||
let mut pushed_path = false;
|
||||
if let Some(path) = child.path.as_ref() {
|
||||
current_path.push(path);
|
||||
current_path.push(path.clone());
|
||||
paths.push(current_path.clone());
|
||||
pushed_path = true;
|
||||
}
|
||||
@@ -340,7 +363,7 @@ fn render_nav(tree: &SettingsUiTree, _window: &mut Window, cx: &mut Context<Sett
|
||||
settings.settings_tree.active_entry_index = index;
|
||||
}))
|
||||
.child(
|
||||
Label::new(SharedString::new_static(tree.entries[index].title))
|
||||
Label::new(tree.entries[index].title.clone())
|
||||
.size(LabelSize::Large)
|
||||
.when(tree.active_entry_index == index, |this| {
|
||||
this.color(Color::Selected)
|
||||
@@ -361,45 +384,102 @@ fn render_content(
|
||||
let mut path = smallvec::smallvec![];
|
||||
|
||||
fn render_recursive(
|
||||
tree: &SettingsUiTree,
|
||||
tree: &[UiEntry],
|
||||
index: usize,
|
||||
path: &mut SmallVec<[&'static str; 1]>,
|
||||
path: &mut SmallVec<[SharedString; 1]>,
|
||||
mut element: Div,
|
||||
// todo(settings_ui): can this be a ref without cx borrow issues?
|
||||
fallback_path: &mut Option<SmallVec<[SharedString; 1]>>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Div {
|
||||
let Some(child) = tree.entries.get(index) else {
|
||||
let Some(child) = tree.get(index) else {
|
||||
return element.child(
|
||||
Label::new(SharedString::new_static("No settings found")).color(Color::Error),
|
||||
);
|
||||
};
|
||||
|
||||
element =
|
||||
element.child(Label::new(SharedString::new_static(child.title)).size(LabelSize::Large));
|
||||
element = element.child(Label::new(child.title.clone()).size(LabelSize::Large));
|
||||
|
||||
// todo(settings_ui): subgroups?
|
||||
let mut pushed_path = false;
|
||||
if let Some(child_path) = child.path {
|
||||
path.push(child_path);
|
||||
if let Some(child_path) = child.path.as_ref() {
|
||||
path.push(child_path.clone());
|
||||
if let Some(fallback_path) = fallback_path.as_mut() {
|
||||
fallback_path.push(child_path.clone());
|
||||
}
|
||||
pushed_path = true;
|
||||
}
|
||||
// let fallback_path_copy = fallback_path.cloned();
|
||||
let settings_value = settings_value_from_settings_and_path(
|
||||
path.clone(),
|
||||
child.title,
|
||||
child.documentation,
|
||||
fallback_path.as_ref().map(|path| path.as_slice()),
|
||||
child.title.clone(),
|
||||
child.documentation.clone(),
|
||||
// PERF: how to structure this better? There feels like there's a way to avoid the clone
|
||||
// and every value lookup
|
||||
SettingsStore::global(cx).raw_user_settings(),
|
||||
SettingsStore::global(cx).raw_default_settings(),
|
||||
);
|
||||
if let Some(select_descendant) = child.select_descendant {
|
||||
let selected_descendant = child
|
||||
.nth_descendant_index(&tree.entries, select_descendant(settings_value.read(), cx));
|
||||
let selected_descendant =
|
||||
child.nth_descendant_index(tree, select_descendant(settings_value.read(), cx));
|
||||
if let Some(descendant_index) = selected_descendant {
|
||||
element = render_recursive(&tree, descendant_index, path, element, window, cx);
|
||||
element = render_recursive(
|
||||
tree,
|
||||
descendant_index,
|
||||
path,
|
||||
element,
|
||||
fallback_path,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(child_render) = child.render.as_ref() {
|
||||
} else if let Some((settings_ui_item, generate_items, defaults_path)) =
|
||||
child.generate_items.as_ref()
|
||||
{
|
||||
let generated_items = generate_items(settings_value.read(), cx);
|
||||
let mut ui_items = Vec::with_capacity(generated_items.len());
|
||||
for item in generated_items {
|
||||
let settings_ui_entry = SettingsUiEntry {
|
||||
path: None,
|
||||
title: "",
|
||||
documentation: None,
|
||||
item: settings_ui_item.clone(),
|
||||
};
|
||||
let prev_index = if ui_items.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ui_items.len() - 1)
|
||||
};
|
||||
let item_index = ui_items.len();
|
||||
build_tree_item(
|
||||
&mut ui_items,
|
||||
settings_ui_entry,
|
||||
child._depth + 1,
|
||||
prev_index,
|
||||
);
|
||||
if item_index < ui_items.len() {
|
||||
ui_items[item_index].path = None;
|
||||
ui_items[item_index].title = item.title.clone();
|
||||
ui_items[item_index].documentation = item.documentation.clone();
|
||||
|
||||
// push path instead of setting path on ui item so that the path isn't pushed to default_path as well
|
||||
// when we recurse
|
||||
path.push(item.path.clone());
|
||||
element = render_recursive(
|
||||
&ui_items,
|
||||
item_index,
|
||||
path,
|
||||
element,
|
||||
&mut Some(defaults_path.clone()),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
} else if let Some(child_render) = child.render.as_ref() {
|
||||
element = element.child(div().child(render_item_single(
|
||||
settings_value,
|
||||
child_render,
|
||||
@@ -409,8 +489,16 @@ fn render_content(
|
||||
} else if let Some(child_index) = child.first_descendant_index() {
|
||||
let mut index = Some(child_index);
|
||||
while let Some(sub_child_index) = index {
|
||||
element = render_recursive(tree, sub_child_index, path, element, window, cx);
|
||||
index = tree.entries[sub_child_index].next_sibling;
|
||||
element = render_recursive(
|
||||
tree,
|
||||
sub_child_index,
|
||||
path,
|
||||
element,
|
||||
fallback_path,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
index = tree[sub_child_index].next_sibling;
|
||||
}
|
||||
} else {
|
||||
element =
|
||||
@@ -419,15 +507,19 @@ fn render_content(
|
||||
|
||||
if pushed_path {
|
||||
path.pop();
|
||||
if let Some(fallback_path) = fallback_path.as_mut() {
|
||||
fallback_path.pop();
|
||||
}
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
return render_recursive(
|
||||
tree,
|
||||
&tree.entries,
|
||||
tree.active_entry_index,
|
||||
&mut path,
|
||||
content,
|
||||
&mut None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
@@ -484,15 +576,15 @@ fn render_old_appearance_settings(cx: &mut App) -> impl IntoElement {
|
||||
)
|
||||
}
|
||||
|
||||
fn element_id_from_path(path: &[&'static str]) -> ElementId {
|
||||
fn element_id_from_path(path: &[SharedString]) -> ElementId {
|
||||
if path.len() == 0 {
|
||||
panic!("Path length must not be zero");
|
||||
} else if path.len() == 1 {
|
||||
ElementId::Name(SharedString::new_static(path[0]))
|
||||
ElementId::Name(path[0].clone())
|
||||
} else {
|
||||
ElementId::from((
|
||||
ElementId::from(SharedString::new_static(path[path.len() - 2])),
|
||||
SharedString::new_static(path[path.len() - 1]),
|
||||
ElementId::from(path[path.len() - 2].clone()),
|
||||
path[path.len() - 1].clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -525,13 +617,13 @@ fn render_item_single(
|
||||
|
||||
pub fn read_settings_value_from_path<'a>(
|
||||
settings_contents: &'a serde_json::Value,
|
||||
path: &[&str],
|
||||
path: &[impl AsRef<str>],
|
||||
) -> Option<&'a serde_json::Value> {
|
||||
// todo(settings_ui) make non recursive, and move to `settings` alongside SettingsValue, and add method to SettingsValue to get nested
|
||||
let Some((key, remaining)) = path.split_first() else {
|
||||
return Some(settings_contents);
|
||||
};
|
||||
let Some(value) = settings_contents.get(key) else {
|
||||
let Some(value) = settings_contents.get(key.as_ref()) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -541,13 +633,17 @@ pub fn read_settings_value_from_path<'a>(
|
||||
fn downcast_any_item<T: serde::de::DeserializeOwned>(
|
||||
settings_value: SettingsValue<serde_json::Value>,
|
||||
) -> SettingsValue<T> {
|
||||
let value = settings_value
|
||||
.value
|
||||
.map(|value| serde_json::from_value::<T>(value).expect("value is not a T"));
|
||||
let value = settings_value.value.map(|value| {
|
||||
serde_json::from_value::<T>(value.clone())
|
||||
.with_context(|| format!("path: {:?}", settings_value.path.join(".")))
|
||||
.with_context(|| format!("value is not a {}: {}", std::any::type_name::<T>(), value))
|
||||
.unwrap()
|
||||
});
|
||||
// todo(settings_ui) Create test that constructs UI tree, and asserts that all elements have default values
|
||||
let default_value = serde_json::from_value::<T>(settings_value.default_value)
|
||||
.with_context(|| format!("path: {:?}", settings_value.path.join(".")))
|
||||
.expect("default value is not an Option<T>");
|
||||
.with_context(|| format!("value is not a {}", std::any::type_name::<T>()))
|
||||
.unwrap();
|
||||
let deserialized_setting_value = SettingsValue {
|
||||
title: settings_value.title,
|
||||
path: settings_value.path,
|
||||
@@ -577,8 +673,8 @@ fn render_any_numeric_stepper(
|
||||
match num_type {
|
||||
NumType::U64 => render_numeric_stepper::<u64>(
|
||||
downcast_any_item(settings_value),
|
||||
u64::saturating_sub,
|
||||
u64::saturating_add,
|
||||
|n| u64::saturating_sub(n, 1),
|
||||
|n| u64::saturating_add(n, 1),
|
||||
|n| {
|
||||
serde_json::Number::try_from(n)
|
||||
.context("Failed to convert u64 to serde_json::Number")
|
||||
@@ -588,8 +684,8 @@ fn render_any_numeric_stepper(
|
||||
),
|
||||
NumType::U32 => render_numeric_stepper::<u32>(
|
||||
downcast_any_item(settings_value),
|
||||
u32::saturating_sub,
|
||||
u32::saturating_add,
|
||||
|n| u32::saturating_sub(n, 1),
|
||||
|n| u32::saturating_add(n, 1),
|
||||
|n| {
|
||||
serde_json::Number::try_from(n)
|
||||
.context("Failed to convert u32 to serde_json::Number")
|
||||
@@ -599,8 +695,8 @@ fn render_any_numeric_stepper(
|
||||
),
|
||||
NumType::F32 => render_numeric_stepper::<f32>(
|
||||
downcast_any_item(settings_value),
|
||||
|a, b| a - b,
|
||||
|a, b| a + b,
|
||||
|a| a - 1.0,
|
||||
|a| a + 1.0,
|
||||
|n| {
|
||||
serde_json::Number::from_f64(n as f64)
|
||||
.context("Failed to convert f32 to serde_json::Number")
|
||||
@@ -610,8 +706,8 @@ fn render_any_numeric_stepper(
|
||||
),
|
||||
NumType::USIZE => render_numeric_stepper::<usize>(
|
||||
downcast_any_item(settings_value),
|
||||
usize::saturating_sub,
|
||||
usize::saturating_add,
|
||||
|n| usize::saturating_sub(n, 1),
|
||||
|n| usize::saturating_add(n, 1),
|
||||
|n| {
|
||||
serde_json::Number::try_from(n)
|
||||
.context("Failed to convert usize to serde_json::Number")
|
||||
@@ -619,15 +715,24 @@ fn render_any_numeric_stepper(
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
NumType::U32NONZERO => render_numeric_stepper::<NonZeroU32>(
|
||||
downcast_any_item(settings_value),
|
||||
|a| NonZeroU32::new(u32::saturating_sub(a.get(), 1)).unwrap_or(NonZeroU32::MIN),
|
||||
|a| NonZeroU32::new(u32::saturating_add(a.get(), 1)).unwrap_or(NonZeroU32::MAX),
|
||||
|n| {
|
||||
serde_json::Number::try_from(n.get())
|
||||
.context("Failed to convert usize to serde_json::Number")
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_numeric_stepper<
|
||||
T: serde::de::DeserializeOwned + std::fmt::Display + Copy + From<u8> + 'static,
|
||||
>(
|
||||
fn render_numeric_stepper<T: serde::de::DeserializeOwned + std::fmt::Display + Copy + 'static>(
|
||||
value: SettingsValue<T>,
|
||||
saturating_sub: fn(T, T) -> T,
|
||||
saturating_add: fn(T, T) -> T,
|
||||
saturating_sub_1: fn(T) -> T,
|
||||
saturating_add_1: fn(T) -> T,
|
||||
to_serde_number: fn(T) -> anyhow::Result<serde_json::Number>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut App,
|
||||
@@ -640,9 +745,9 @@ fn render_numeric_stepper<
|
||||
id,
|
||||
num.to_string(),
|
||||
{
|
||||
let path = value.path.clone();
|
||||
let path = value.path;
|
||||
move |_, _, cx| {
|
||||
let Some(number) = to_serde_number(saturating_sub(num, 1.into())).ok() else {
|
||||
let Some(number) = to_serde_number(saturating_sub_1(num)).ok() else {
|
||||
return;
|
||||
};
|
||||
let new_value = serde_json::Value::Number(number);
|
||||
@@ -650,7 +755,7 @@ fn render_numeric_stepper<
|
||||
}
|
||||
},
|
||||
move |_, _, cx| {
|
||||
let Some(number) = to_serde_number(saturating_add(num, 1.into())).ok() else {
|
||||
let Some(number) = to_serde_number(saturating_add_1(num)).ok() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -672,8 +777,8 @@ fn render_switch_field(
|
||||
let path = value.path.clone();
|
||||
SwitchField::new(
|
||||
id,
|
||||
SharedString::new_static(value.title),
|
||||
value.documentation.map(SharedString::new_static),
|
||||
value.title.clone(),
|
||||
value.documentation.clone(),
|
||||
match value.read() {
|
||||
true => ToggleState::Selected,
|
||||
false => ToggleState::Unselected,
|
||||
@@ -703,7 +808,6 @@ fn render_toggle_button_group(
|
||||
let value = downcast_any_item::<String>(value);
|
||||
|
||||
fn make_toggle_group<const LEN: usize>(
|
||||
group_name: &'static str,
|
||||
value: SettingsValue<String>,
|
||||
variants: &'static [&'static str],
|
||||
labels: &'static [&'static str],
|
||||
@@ -727,7 +831,7 @@ fn render_toggle_button_group(
|
||||
|
||||
let mut idx = 0;
|
||||
ToggleButtonGroup::single_row(
|
||||
group_name,
|
||||
value.title.clone(),
|
||||
variants_array.map(|(variant, label)| {
|
||||
let path = value.path.clone();
|
||||
idx += 1;
|
||||
@@ -748,7 +852,7 @@ fn render_toggle_button_group(
|
||||
macro_rules! templ_toggl_with_const_param {
|
||||
($len:expr) => {
|
||||
if variants.len() == $len {
|
||||
return make_toggle_group::<$len>(value.title, value, variants, labels);
|
||||
return make_toggle_group::<$len>(value, variants, labels);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -762,13 +866,19 @@ fn render_toggle_button_group(
|
||||
}
|
||||
|
||||
fn settings_value_from_settings_and_path(
|
||||
path: SmallVec<[&'static str; 1]>,
|
||||
title: &'static str,
|
||||
documentation: Option<&'static str>,
|
||||
path: SmallVec<[SharedString; 1]>,
|
||||
fallback_path: Option<&[SharedString]>,
|
||||
title: SharedString,
|
||||
documentation: Option<SharedString>,
|
||||
user_settings: &serde_json::Value,
|
||||
default_settings: &serde_json::Value,
|
||||
) -> SettingsValue<serde_json::Value> {
|
||||
let default_value = read_settings_value_from_path(default_settings, &path)
|
||||
.or_else(|| {
|
||||
fallback_path.and_then(|fallback_path| {
|
||||
read_settings_value_from_path(default_settings, fallback_path)
|
||||
})
|
||||
})
|
||||
.with_context(|| format!("No default value for item at path {:?}", path.join(".")))
|
||||
.expect("Default value set for item")
|
||||
.clone();
|
||||
@@ -778,7 +888,7 @@ fn settings_value_from_settings_and_path(
|
||||
default_value,
|
||||
value,
|
||||
documentation,
|
||||
path: path.clone(),
|
||||
path,
|
||||
// todo(settings_ui) is title required inside SettingsValue?
|
||||
title,
|
||||
};
|
||||
|
||||
@@ -660,8 +660,12 @@ impl TitleBar {
|
||||
|
||||
let (plan_name, label_color, bg_color) = match plan {
|
||||
None | Some(Plan::ZedFree) => ("Free", Color::Default, free_chip_bg),
|
||||
Some(Plan::ZedProTrial) => ("Pro Trial", Color::Accent, pro_chip_bg),
|
||||
Some(Plan::ZedPro) => ("Pro", Color::Accent, pro_chip_bg),
|
||||
Some(Plan::ZedProTrial | Plan::ZedProTrialV2) => {
|
||||
("Pro Trial", Color::Accent, pro_chip_bg)
|
||||
}
|
||||
Some(Plan::ZedPro | Plan::ZedProV2) => {
|
||||
("Pro", Color::Accent, pro_chip_bg)
|
||||
}
|
||||
};
|
||||
|
||||
menu.custom_entry(
|
||||
@@ -689,7 +693,7 @@ impl TitleBar {
|
||||
"Settings Profiles",
|
||||
zed_actions::settings_profile_selector::Toggle.boxed_clone(),
|
||||
)
|
||||
.action("Key Bindings", Box::new(keymap_editor::OpenKeymapEditor))
|
||||
.action("Keymap Editor", Box::new(keymap_editor::OpenKeymapEditor))
|
||||
.action(
|
||||
"Themes…",
|
||||
zed_actions::theme_selector::Toggle::default().boxed_clone(),
|
||||
|
||||
@@ -425,7 +425,7 @@ pub struct ToggleButtonGroup<T, const COLS: usize = 3, const ROWS: usize = 1>
|
||||
where
|
||||
T: ButtonBuilder,
|
||||
{
|
||||
group_name: &'static str,
|
||||
group_name: SharedString,
|
||||
rows: [[T; COLS]; ROWS],
|
||||
style: ToggleButtonGroupStyle,
|
||||
size: ToggleButtonGroupSize,
|
||||
@@ -435,9 +435,9 @@ where
|
||||
}
|
||||
|
||||
impl<T: ButtonBuilder, const COLS: usize> ToggleButtonGroup<T, COLS> {
|
||||
pub fn single_row(group_name: &'static str, buttons: [T; COLS]) -> Self {
|
||||
pub fn single_row(group_name: impl Into<SharedString>, buttons: [T; COLS]) -> Self {
|
||||
Self {
|
||||
group_name,
|
||||
group_name: group_name.into(),
|
||||
rows: [buttons],
|
||||
style: ToggleButtonGroupStyle::Transparent,
|
||||
size: ToggleButtonGroupSize::Default,
|
||||
@@ -449,9 +449,13 @@ impl<T: ButtonBuilder, const COLS: usize> ToggleButtonGroup<T, COLS> {
|
||||
}
|
||||
|
||||
impl<T: ButtonBuilder, const COLS: usize> ToggleButtonGroup<T, COLS, 2> {
|
||||
pub fn two_rows(group_name: &'static str, first_row: [T; COLS], second_row: [T; COLS]) -> Self {
|
||||
pub fn two_rows(
|
||||
group_name: impl Into<SharedString>,
|
||||
first_row: [T; COLS],
|
||||
second_row: [T; COLS],
|
||||
) -> Self {
|
||||
Self {
|
||||
group_name,
|
||||
group_name: group_name.into(),
|
||||
rows: [first_row, second_row],
|
||||
style: ToggleButtonGroupStyle::Transparent,
|
||||
size: ToggleButtonGroupSize::Default,
|
||||
@@ -512,6 +516,7 @@ impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> RenderOnce
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let entries =
|
||||
self.rows.into_iter().enumerate().map(|(row_index, row)| {
|
||||
let group_name = self.group_name.clone();
|
||||
row.into_iter().enumerate().map(move |(col_index, button)| {
|
||||
let ButtonConfiguration {
|
||||
label,
|
||||
@@ -523,7 +528,7 @@ impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> RenderOnce
|
||||
|
||||
let entry_index = row_index * COLS + col_index;
|
||||
|
||||
ButtonLike::new((self.group_name, entry_index))
|
||||
ButtonLike::new((group_name.clone(), entry_index))
|
||||
.full_width()
|
||||
.rounding(None)
|
||||
.when_some(self.tab_index, |this, tab_index| {
|
||||
|
||||
@@ -168,7 +168,7 @@ impl Render for SingleLineInput {
|
||||
.py_1p5()
|
||||
.flex_grow()
|
||||
.text_color(style.text_color)
|
||||
.rounded_sm()
|
||||
.rounded_md()
|
||||
.bg(style.background_color)
|
||||
.border_1()
|
||||
.border_color(style.border_color)
|
||||
|
||||
@@ -1268,7 +1268,6 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
|
||||
VimCommand::str(("te", "rm"), "terminal_panel::Toggle"),
|
||||
VimCommand::str(("T", "erm"), "terminal_panel::Toggle"),
|
||||
VimCommand::str(("C", "ollab"), "collab_panel::ToggleFocus"),
|
||||
VimCommand::str(("Ch", "at"), "chat_panel::ToggleFocus"),
|
||||
VimCommand::str(("No", "tifications"), "notification_panel::ToggleFocus"),
|
||||
VimCommand::str(("A", "I"), "agent::ToggleFocus"),
|
||||
VimCommand::str(("G", "it"), "git_panel::ToggleFocus"),
|
||||
|
||||
@@ -53,6 +53,10 @@ actions!(
|
||||
SelectSmallerSyntaxNode,
|
||||
/// Selects the next larger syntax node.
|
||||
SelectLargerSyntaxNode,
|
||||
/// Selects the next syntax node sibling.
|
||||
SelectNextSyntaxNode,
|
||||
/// Selects the previous syntax node sibling.
|
||||
SelectPreviousSyntaxNode,
|
||||
/// Restores the previous visual selection.
|
||||
RestoreVisualSelection,
|
||||
/// Inserts at the end of each line in visual selection.
|
||||
@@ -110,6 +114,30 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
|
||||
}
|
||||
});
|
||||
|
||||
Vim::action(editor, cx, |vim, _: &SelectNextSyntaxNode, window, cx| {
|
||||
let count = Vim::take_count(cx).unwrap_or(1);
|
||||
Vim::take_forced_motion(cx);
|
||||
for _ in 0..count {
|
||||
vim.update_editor(cx, |_, editor, cx| {
|
||||
editor.select_next_syntax_node(&Default::default(), window, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Vim::action(
|
||||
editor,
|
||||
cx,
|
||||
|vim, _: &SelectPreviousSyntaxNode, window, cx| {
|
||||
let count = Vim::take_count(cx).unwrap_or(1);
|
||||
Vim::take_forced_motion(cx);
|
||||
for _ in 0..count {
|
||||
vim.update_editor(cx, |_, editor, cx| {
|
||||
editor.select_prev_syntax_node(&Default::default(), window, cx);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Vim::action(
|
||||
editor,
|
||||
cx,
|
||||
@@ -1839,4 +1867,37 @@ mod test {
|
||||
fˇ»ox"
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_visual_syntax_sibling_selection(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
|
||||
cx.set_state(
|
||||
indoc! {"
|
||||
fn test() {
|
||||
let ˇa = 1;
|
||||
let b = 2;
|
||||
let c = 3;
|
||||
}
|
||||
"},
|
||||
Mode::Normal,
|
||||
);
|
||||
|
||||
// Enter visual mode and select the statement
|
||||
cx.simulate_keystrokes("v w w w");
|
||||
cx.assert_state(
|
||||
indoc! {"
|
||||
fn test() {
|
||||
let «a = 1;ˇ»
|
||||
let b = 2;
|
||||
let c = 3;
|
||||
}
|
||||
"},
|
||||
Mode::Visual,
|
||||
);
|
||||
|
||||
// The specific behavior of syntax sibling selection in vim mode
|
||||
// would depend on the key bindings configured, but the actions
|
||||
// are now available for use
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,6 @@ zed_env_vars.workspace = true
|
||||
zeta.workspace = true
|
||||
zlog.workspace = true
|
||||
zlog_settings.workspace = true
|
||||
postage.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows.workspace = true
|
||||
|
||||
@@ -14,10 +14,9 @@ use editor::Editor;
|
||||
use extension::ExtensionHostProxy;
|
||||
use extension_host::ExtensionStore;
|
||||
use fs::{Fs, RealFs};
|
||||
use futures::{FutureExt, StreamExt, channel::oneshot, future, select_biased};
|
||||
use futures::{StreamExt, channel::oneshot, future};
|
||||
use git::GitHostingProviderRegistry;
|
||||
use gpui::{App, AppContext, Application, AsyncApp, Focusable as _, UpdateGlobal as _};
|
||||
use postage::stream::Stream as _;
|
||||
|
||||
use gpui_tokio::Tokio;
|
||||
use http_client::{Url, read_proxy_from_env};
|
||||
@@ -39,7 +38,7 @@ use std::{
|
||||
env,
|
||||
io::{self, IsTerminal},
|
||||
path::{Path, PathBuf},
|
||||
process::{self, Stdio},
|
||||
process,
|
||||
sync::Arc,
|
||||
};
|
||||
use theme::{
|
||||
@@ -743,8 +742,6 @@ pub fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// todo! cleanup
|
||||
let fs = app_state.fs.clone();
|
||||
let app_state = app_state.clone();
|
||||
|
||||
crate::zed::component_preview::init(app_state.clone(), cx);
|
||||
@@ -760,107 +757,6 @@ pub fn main() {
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
if let Ok(selection_change_command) = env::var("ZED_SELECTION_CHANGE_CMD") {
|
||||
log::info!(
|
||||
"Will run {} when the selection changes",
|
||||
selection_change_command
|
||||
);
|
||||
|
||||
let mut cursor_receiver = editor::LAST_CURSOR_POSITION_WATCH.1.clone();
|
||||
cx.background_spawn(async move {
|
||||
// Set up file watcher for the command file
|
||||
let command_path = PathBuf::from(&selection_change_command);
|
||||
let mut file_changes = if command_path.exists() {
|
||||
let (events, _) = fs
|
||||
.watch(&command_path, std::time::Duration::from_millis(100))
|
||||
.await;
|
||||
Some(events)
|
||||
} else {
|
||||
log::warn!(
|
||||
"Command file {} does not exist, only watching selection changes",
|
||||
command_path.display()
|
||||
);
|
||||
None
|
||||
};
|
||||
|
||||
loop {
|
||||
select_biased! {
|
||||
// Handle cursor position changes
|
||||
cursor_update = cursor_receiver.recv().fuse() => {
|
||||
if cursor_update.is_none() {
|
||||
// Cursor watcher ended
|
||||
log::warn!("Cursor watcher for {} ended", command_path.display());
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
// Handle file changes to the command file
|
||||
file_change = async {
|
||||
if let Some(ref mut events) = file_changes {
|
||||
events.next().await
|
||||
} else {
|
||||
future::pending().await
|
||||
}
|
||||
}.fuse() => {
|
||||
if file_change.is_none() {
|
||||
// File watcher ended
|
||||
log::warn!("File watcher for {} ended", command_path.display());
|
||||
file_changes = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Could be more efficient
|
||||
let Some(mut cursor) = cursor_receiver.borrow().clone() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
loop {
|
||||
let cursor_position_arg = format!(
|
||||
"{}:{}:{}",
|
||||
cursor.path.display(),
|
||||
cursor.point.row + 1,
|
||||
cursor.point.column + 1
|
||||
);
|
||||
log::info!(
|
||||
"Running {} {} {}",
|
||||
selection_change_command,
|
||||
cursor.worktree_path.display(),
|
||||
cursor_position_arg
|
||||
);
|
||||
let status = smol::process::Command::new(&selection_change_command)
|
||||
.arg(cursor.worktree_path.as_ref())
|
||||
.arg(cursor_position_arg)
|
||||
.stdin(Stdio::null())
|
||||
// todo! It'd be better to distinguish the output in logs.
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.status()
|
||||
.await;
|
||||
match status {
|
||||
Ok(status) => {
|
||||
if !status.success() {
|
||||
log::error!("Command failed with status {}", status);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Command failed with error {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
let Some(new_cursor) = cursor_receiver.borrow().clone() else {
|
||||
break;
|
||||
};
|
||||
if new_cursor == cursor {
|
||||
break;
|
||||
}
|
||||
cursor = new_cursor;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1523,30 +1419,35 @@ fn watch_themes(fs: Arc<dyn fs::Fs>, cx: &mut App) {
|
||||
fn watch_languages(fs: Arc<dyn fs::Fs>, languages: Arc<LanguageRegistry>, cx: &mut App) {
|
||||
use std::time::Duration;
|
||||
|
||||
let path = {
|
||||
let p = Path::new("crates/languages/src");
|
||||
let Ok(full_path) = p.canonicalize() else {
|
||||
cx.background_spawn(async move {
|
||||
let languages_src = Path::new("crates/languages/src");
|
||||
let Some(languages_src) = fs.canonicalize(languages_src).await.log_err() else {
|
||||
return;
|
||||
};
|
||||
full_path
|
||||
};
|
||||
|
||||
cx.spawn(async move |_| {
|
||||
let (mut events, _) = fs.watch(path.as_path(), Duration::from_millis(100)).await;
|
||||
let (mut events, watcher) = fs.watch(&languages_src, Duration::from_millis(100)).await;
|
||||
|
||||
// add subdirectories since fs.watch is not recursive on Linux
|
||||
if let Some(mut paths) = fs.read_dir(&languages_src).await.log_err() {
|
||||
while let Some(path) = paths.next().await {
|
||||
if let Some(path) = path.log_err()
|
||||
&& fs.is_dir(&path).await
|
||||
{
|
||||
watcher.add(&path).log_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(event) = events.next().await {
|
||||
let has_language_file = event.iter().any(|event| {
|
||||
event
|
||||
.path
|
||||
.extension()
|
||||
.map(|ext| ext.to_string_lossy().as_ref() == "scm")
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let has_language_file = event
|
||||
.iter()
|
||||
.any(|event| event.path.extension().is_some_and(|ext| ext == "scm"));
|
||||
if has_language_file {
|
||||
languages.reload();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach()
|
||||
.detach();
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
|
||||
@@ -553,8 +553,6 @@ fn initialize_panels(
|
||||
let git_panel = GitPanel::load(workspace_handle.clone(), cx.clone());
|
||||
let channels_panel =
|
||||
collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone());
|
||||
let chat_panel =
|
||||
collab_ui::chat_panel::ChatPanel::load(workspace_handle.clone(), cx.clone());
|
||||
let notification_panel = collab_ui::notification_panel::NotificationPanel::load(
|
||||
workspace_handle.clone(),
|
||||
cx.clone(),
|
||||
@@ -567,7 +565,6 @@ fn initialize_panels(
|
||||
terminal_panel,
|
||||
git_panel,
|
||||
channels_panel,
|
||||
chat_panel,
|
||||
notification_panel,
|
||||
debug_panel,
|
||||
) = futures::try_join!(
|
||||
@@ -576,7 +573,6 @@ fn initialize_panels(
|
||||
git_panel,
|
||||
terminal_panel,
|
||||
channels_panel,
|
||||
chat_panel,
|
||||
notification_panel,
|
||||
debug_panel,
|
||||
)?;
|
||||
@@ -587,7 +583,6 @@ fn initialize_panels(
|
||||
workspace.add_panel(terminal_panel, window, cx);
|
||||
workspace.add_panel(git_panel, window, cx);
|
||||
workspace.add_panel(channels_panel, window, cx);
|
||||
workspace.add_panel(chat_panel, window, cx);
|
||||
workspace.add_panel(notification_panel, window, cx);
|
||||
workspace.add_panel(debug_panel, window, cx);
|
||||
})?;
|
||||
@@ -865,14 +860,6 @@ fn register_actions(
|
||||
workspace.toggle_panel_focus::<collab_ui::collab_panel::CollabPanel>(window, cx);
|
||||
},
|
||||
)
|
||||
.register_action(
|
||||
|workspace: &mut Workspace,
|
||||
_: &collab_ui::chat_panel::ToggleFocus,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>| {
|
||||
workspace.toggle_panel_focus::<collab_ui::chat_panel::ChatPanel>(window, cx);
|
||||
},
|
||||
)
|
||||
.register_action(
|
||||
|workspace: &mut Workspace,
|
||||
_: &collab_ui::notification_panel::ToggleFocus,
|
||||
@@ -4475,7 +4462,6 @@ mod tests {
|
||||
"branches",
|
||||
"buffer_search",
|
||||
"channel_modal",
|
||||
"chat_panel",
|
||||
"cli",
|
||||
"client",
|
||||
"collab",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use collab_ui::collab_panel;
|
||||
use gpui::{Menu, MenuItem, OsAction};
|
||||
use terminal_view::terminal_panel;
|
||||
use zed_actions::ToggleFocus as ToggleDebugPanel;
|
||||
|
||||
pub fn app_menus() -> Vec<Menu> {
|
||||
use zed_actions::Quit;
|
||||
@@ -126,6 +127,11 @@ pub fn app_menus() -> Vec<Menu> {
|
||||
),
|
||||
MenuItem::action("Expand Selection", editor::actions::SelectLargerSyntaxNode),
|
||||
MenuItem::action("Shrink Selection", editor::actions::SelectSmallerSyntaxNode),
|
||||
MenuItem::action("Select Next Sibling", editor::actions::SelectNextSyntaxNode),
|
||||
MenuItem::action(
|
||||
"Select Previous Sibling",
|
||||
editor::actions::SelectPreviousSyntaxNode,
|
||||
),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Add Cursor Above", editor::actions::AddSelectionAbove),
|
||||
MenuItem::action("Add Cursor Below", editor::actions::AddSelectionBelow),
|
||||
@@ -175,6 +181,7 @@ pub fn app_menus() -> Vec<Menu> {
|
||||
MenuItem::action("Outline Panel", outline_panel::ToggleFocus),
|
||||
MenuItem::action("Collab Panel", collab_panel::ToggleFocus),
|
||||
MenuItem::action("Terminal Panel", terminal_panel::ToggleFocus),
|
||||
MenuItem::action("Debugger Panel", ToggleDebugPanel),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Diagnostics", diagnostics::Deploy),
|
||||
MenuItem::separator(),
|
||||
|
||||
@@ -178,7 +178,7 @@ Note: This setting has no effect in Vim mode, as rewrap is already allowed every
|
||||
|
||||
You can find the names of your currently installed extensions by listing the subfolders under the [extension installation location](./extensions/installing-extensions.md#installation-location):
|
||||
|
||||
On MacOS:
|
||||
On macOS:
|
||||
|
||||
```sh
|
||||
ls ~/Library/Application\ Support/Zed/extensions/installed/
|
||||
@@ -294,7 +294,7 @@ Define extensions which should be installed (`true`) or never installed (`false`
|
||||
|
||||
**Options**
|
||||
|
||||
1. VSCode
|
||||
1. VS Code
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -326,7 +326,7 @@ Define extensions which should be installed (`true`) or never installed (`false`
|
||||
}
|
||||
```
|
||||
|
||||
5. SublimeText
|
||||
5. Sublime Text
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -2469,7 +2469,7 @@ The following languages have inlay hints preconfigured by Zed:
|
||||
- [Go](https://docs.zed.dev/languages/go)
|
||||
- [Rust](https://docs.zed.dev/languages/rust)
|
||||
- [Svelte](https://docs.zed.dev/languages/svelte)
|
||||
- [Typescript](https://docs.zed.dev/languages/typescript)
|
||||
- [TypeScript](https://docs.zed.dev/languages/typescript)
|
||||
|
||||
Use the `lsp` section for the server configuration. Examples are provided in the corresponding language documentation.
|
||||
|
||||
@@ -2699,7 +2699,7 @@ Positive `integer` values or `null` for unlimited tabs
|
||||
|
||||
**Options**
|
||||
|
||||
1. Maps to `Alt` on Linux and Windows and to `Option` on MacOS:
|
||||
1. Maps to `Alt` on Linux and Windows and to `Option` on macOS:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -2707,7 +2707,7 @@ Positive `integer` values or `null` for unlimited tabs
|
||||
}
|
||||
```
|
||||
|
||||
2. Maps `Control` on Linux and Windows and to `Command` on MacOS:
|
||||
2. Maps `Control` on Linux and Windows and to `Command` on macOS:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -4395,28 +4395,6 @@ Visit [the Configuration page](./ai/configuration.md) under the AI section to le
|
||||
- `dock`: Where to dock the collaboration panel. Can be `left` or `right`
|
||||
- `default_width`: Default width of the collaboration panel
|
||||
|
||||
## Chat Panel
|
||||
|
||||
- Description: Customizations for the chat panel.
|
||||
- Setting: `chat_panel`
|
||||
- Default:
|
||||
|
||||
```json
|
||||
{
|
||||
"chat_panel": {
|
||||
"button": "when_in_call",
|
||||
"dock": "right",
|
||||
"default_width": 240
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Options**
|
||||
|
||||
- `button`: When to show the chat panel button in the status bar. Can be `never`, `always`, or `when_in_call`.
|
||||
- `dock`: Where to dock the chat panel. Can be 'left' or 'right'
|
||||
- `default_width`: Default width of the chat panel
|
||||
|
||||
## Debugger
|
||||
|
||||
- Description: Configuration for debugger panel and settings
|
||||
|
||||
@@ -93,9 +93,9 @@ rust-lldb -p <pid>
|
||||
|
||||
Where `<pid>` is the process ID of the Zed instance you want to attach to.
|
||||
|
||||
To get the process ID of a running Zed instance, you can use your systems process management tools such as `Task Manager` on windows or `Activity Monitor` on MacOS.
|
||||
To get the process ID of a running Zed instance, you can use your systems process management tools such as `Task Manager` on windows or `Activity Monitor` on macOS.
|
||||
|
||||
Alternatively, you can run the `ps aux | grep zed` command on MacOS and Linux or `Get-Process | Select-Object Id, ProcessName` in an instance of PowerShell on Windows.
|
||||
Alternatively, you can run the `ps aux | grep zed` command on macOS and Linux or `Get-Process | Select-Object Id, ProcessName` in an instance of PowerShell on Windows.
|
||||
|
||||
#### Debugging Panics and Crashes
|
||||
|
||||
|
||||
@@ -269,7 +269,7 @@ The `textobjects.scm` file defines rules for navigating by text objects. This wa
|
||||
|
||||
Vim provides two levels of granularity for navigating around files. Section-by-section with `[]` etc., and method-by-method with `]m` etc. Even languages that don't support functions and classes can work well by defining similar concepts. For example CSS defines a rule-set as a method, and a media-query as a class.
|
||||
|
||||
For languages with closures, these typically should not count as functions in Zed. This is best-effort however, as languages like Javascript do not syntactically differentiate syntactically between closures and top-level function declarations.
|
||||
For languages with closures, these typically should not count as functions in Zed. This is best-effort however, as languages like JavaScript do not syntactically differentiate syntactically between closures and top-level function declarations.
|
||||
|
||||
For languages with declarations like C, provide queries that match `@class.around` or `@function.around`. The `if` and `ic` text objects will default to these if there is no inside.
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ If you wanted to only search Markdown files add `*.md` to the "Include" search f
|
||||
|
||||
### Case insensitive matching
|
||||
|
||||
Globs in Zed are case-sensitive, so `*.c` will not match `main.C` (even on case-insensitive filesystems like HFS+/APFS on MacOS). Instead use brackets to match characters. So instead of `*.c` use `*.[cC]`.
|
||||
Globs in Zed are case-sensitive, so `*.c` will not match `main.C` (even on case-insensitive filesystems like HFS+/APFS on macOS). Instead use brackets to match characters. So instead of `*.c` use `*.[cC]`.
|
||||
|
||||
### Matching directories
|
||||
|
||||
@@ -70,7 +70,7 @@ Alternatively, if in your Zed settings you wanted a [`file_types`](./configuring
|
||||
|
||||
While globs in Zed are implemented as described above, when writing code using globs in other languages, please reference your platform's glob documentation:
|
||||
|
||||
- [MacOS fnmatch](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/fnmatch.3.html) (BSD C Standard Library)
|
||||
- [macOS fnmatch](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/fnmatch.3.html) (BSD C Standard Library)
|
||||
- [Linux fnmatch](https://www.gnu.org/software/libc/manual/html_node/Wildcard-Matching.html) (GNU C Standard Library)
|
||||
- [POSIX fnmatch](https://pubs.opengroup.org/onlinepubs/9699919799/functions/fnmatch.html) (POSIX Specification)
|
||||
- [node-glob](https://github.com/isaacs/node-glob) (Node.js `glob` package)
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
# Key bindings
|
||||
|
||||
Zed has a very customizable key binding system — you can tweak everything to work exactly how your fingers expect!
|
||||
Zed has a very customizable key binding system—you can tweak everything to work exactly how your fingers expect!
|
||||
|
||||
## Predefined keymaps
|
||||
|
||||
If you're used to a specific editor's defaults you can set a `base_keymap` in your [settings file](./configuring-zed.md). We currently have:
|
||||
If you're used to a specific editor's defaults, you can set a `base_keymap` in your [settings file](./configuring-zed.md).
|
||||
We currently support:
|
||||
|
||||
- VSCode (default)
|
||||
- VS Code (default)
|
||||
- Atom
|
||||
- Emacs (Beta)
|
||||
- JetBrains
|
||||
- SublimeText
|
||||
- Sublime Text
|
||||
- TextMate
|
||||
- Cursor
|
||||
- None (disables _all_ key bindings)
|
||||
|
||||
You can also enable `vim_mode` or `helix_mode`, which add modal bindings. For more information, see the documentation for [Vim mode](./vim.md) and [Helix mode](./helix.md).
|
||||
This setting can also be changed via the command palette through the `zed: toggle base keymap selector` action.
|
||||
|
||||
You can also enable `vim_mode` or `helix_mode`, which add modal bindings.
|
||||
For more information, see the documentation for [Vim mode](./vim.md) and [Helix mode](./helix.md).
|
||||
|
||||
## User keymaps
|
||||
|
||||
Zed reads your keymap from `~/.config/zed/keymap.json`. You can open the file within Zed with {#action zed::OpenKeymap} from the command palette or to spawn the Zed Keymap Editor ({#action zed::OpenKeymapEditor}) use {#kb zed::OpenKeymapEditor}.
|
||||
Zed reads your keymap from `~/.config/zed/keymap.json`, which you can open with the {#action zed::OpenKeymap} action from the command palette.
|
||||
You can also edit your keymap through the Zed Keymap Editor, accessible via the {#action zed::OpenKeymapEditor} action or the {#kb zed::OpenKeymapEditor} keybinding.
|
||||
|
||||
The file contains a JSON array of objects with `"bindings"`. If no `"context"` is set the bindings are always active. If it is set the binding is only active when the [context matches](#contexts).
|
||||
The `keymap.json` file contains a JSON array of objects with `"bindings"`. If no `"context"` is set, the bindings are always active. If it is set, the binding is only active when the [context matches](#contexts).
|
||||
|
||||
Within each binding section a [key sequence](#keybinding-syntax) is mapped to an [action](#actions). If conflicts are detected they are resolved as [described below](#precedence).
|
||||
Within each binding section, a [key sequence](#keybinding-syntax) is mapped to [an action](#actions). If conflicts are detected, they are resolved as [described below](#precedence).
|
||||
|
||||
If you are using a non-QWERTY, Latin-character keyboard, you may want to set `use_key_equivalents` to `true`. See [Non-QWERTY keyboards](#non-qwerty-keyboards) for more information.
|
||||
|
||||
@@ -45,9 +51,9 @@ For example:
|
||||
]
|
||||
```
|
||||
|
||||
You can see all of Zed's default bindings in the default keymaps for [MacOS](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-macos.json) or [Linux](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-linux.json).
|
||||
You can see all of Zed's default bindings in the default keymaps for [macOS](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-macos.json) or [Linux](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-linux.json).
|
||||
|
||||
If you want to debug problems with custom keymaps you can use `dev: Open Key Context View` from the command palette. Please file [an issue](https://github.com/zed-industries/zed) if you run into something you think should work but isn't.
|
||||
If you want to debug problems with custom keymaps, you can use `dev: Open Key Context View` from the command palette. Please file [an issue](https://github.com/zed-industries/zed) if you run into something you think should work but isn't.
|
||||
|
||||
### Keybinding syntax
|
||||
|
||||
@@ -62,7 +68,7 @@ Each keypress is a sequence of modifiers followed by a key. The modifiers are:
|
||||
- `fn-` The function key
|
||||
- `secondary-` Equivalent to `cmd` when Zed is running on macOS and `ctrl` when on Windows and Linux
|
||||
|
||||
The keys can be any single unicode codepoint that your keyboard generates (for example `a`, `0`, `£` or `ç`), or any named key (`tab`, `f1`, `shift`, or `cmd`). If you are using a non-Latin layout (e.g. Cyrillic), you can bind either to the cyrillic character, or the latin character that key generates with `cmd` pressed.
|
||||
The keys can be any single Unicode codepoint that your keyboard generates (for example `a`, `0`, `£` or `ç`), or any named key (`tab`, `f1`, `shift`, or `cmd`). If you are using a non-Latin layout (e.g. Cyrillic), you can bind either to the Cyrillic character or the Latin character that key generates with `cmd` pressed.
|
||||
|
||||
A few examples:
|
||||
|
||||
@@ -75,17 +81,17 @@ A few examples:
|
||||
}
|
||||
```
|
||||
|
||||
The `shift-` modifier can only be used in combination with a letter to indicate the uppercase version. For example `shift-g` matches typing `G`. Although on many keyboards shift is used to type punctuation characters like `(`, the keypress is not considered to be modified and so `shift-(` does not match.
|
||||
The `shift-` modifier can only be used in combination with a letter to indicate the uppercase version. For example, `shift-g` matches typing `G`. Although on many keyboards shift is used to type punctuation characters like `(`, the keypress is not considered to be modified, and so `shift-(` does not match.
|
||||
|
||||
The `alt-` modifier can be used on many layouts to generate a different key. For example on macOS US keyboard the combination `alt-c` types `ç`. You can match against either in your keymap file, though by convention Zed spells this combination as `alt-c`.
|
||||
The `alt-` modifier can be used on many layouts to generate a different key. For example, on a macOS US keyboard, the combination `alt-c` types `ç`. You can match against either in your keymap file, though by convention, Zed spells this combination as `alt-c`.
|
||||
|
||||
It is possible to match against typing a modifier key on its own. For example `shift shift` can be used to implement JetBrains search everywhere shortcut. In this case the binding happens on key release instead of keypress.
|
||||
It is possible to match against typing a modifier key on its own. For example, `shift shift` can be used to implement JetBrains' 'Search Everywhere' shortcut. In this case, the binding happens on key release instead of on keypress.
|
||||
|
||||
### Contexts
|
||||
|
||||
If a binding group has a `"context"` key it will be matched against the currently active contexts in Zed.
|
||||
If a binding group has a `"context"` key, it will be matched against the currently active contexts in Zed.
|
||||
|
||||
Zed's contexts make up a tree, with the root being `Workspace`. Workspaces contain Panes and Panels, and Panes contain Editors, etc. The easiest way to see what contexts are active at a given moment is the key context view, which you can get to with `dev: Open Key Context View` in the command palette.
|
||||
Zed's contexts make up a tree, with the root being `Workspace`. Workspaces contain Panes and Panels, and Panes contain Editors, etc. The easiest way to see what contexts are active at a given moment is the key context view, which you can get to with the `dev: open key context view` command in the command palette.
|
||||
|
||||
For example:
|
||||
|
||||
@@ -117,29 +123,25 @@ For example:
|
||||
|
||||
It's worth noting that attributes are only available on the node they are defined on. This means that if you want to (for example) only enable a keybinding when the debugger is stopped in vim normal mode, you need to do `debugger_stopped > vim_mode == normal`.
|
||||
|
||||
Note: Before Zed v0.197.x, the ! operator only looked at one node at a time, and `>` meant "parent" not "ancestor". This meant that `!Editor` would match the context `Workspace > Pane > Editor`, because (confusingly) the Pane matches `!Editor`, and that `os=macos > Editor` did not match the context `Workspace > Pane > Editor` because of the intermediate `Pane` node.
|
||||
> Note: Before Zed v0.197.x, the `!` operator only looked at one node at a time, and `>` meant "parent" not "ancestor". This meant that `!Editor` would match the context `Workspace > Pane > Editor`, because (confusingly) the Pane matches `!Editor`, and that `os=macos > Editor` did not match the context `Workspace > Pane > Editor` because of the intermediate `Pane` node.
|
||||
|
||||
If you're using Vim mode, we have information on how [vim modes influence the context](./vim.md#contexts). Helix mode is built on top of Vim mode and uses the same contexts.
|
||||
|
||||
### Actions
|
||||
|
||||
Pretty much all of Zed's functionality is exposed as actions. Although there is
|
||||
no explicitly documented list, you can find most of them by searching in the
|
||||
command palette, by looking in the default keymaps for
|
||||
[MacOS](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-macos.json)
|
||||
or
|
||||
[Linux](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-linux.json), or by using Zed's autocomplete in your keymap file.
|
||||
Almost all of Zed's functionality is exposed as actions.
|
||||
Although there is no explicitly documented list, you can find most of them by searching in the command palette, by looking in the default keymaps for [macOS](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-macos.json) or [Linux](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-linux.json), or by using Zed's autocomplete in your keymap file.
|
||||
|
||||
Most actions do not require any arguments, and so you can bind them as strings: `"ctrl-a": "language_selector::Toggle"`. Some require a single argument, and must be bound as an array: `"cmd-1": ["workspace::ActivatePane", 0]`. Some actions require multiple arguments, and are bound as an array of a string and an object: `"ctrl-a": ["pane::DeploySearch", { "replace_enabled": true }]`.
|
||||
Most actions do not require any arguments, and so you can bind them as strings: `"ctrl-a": "language_selector::Toggle"`. Some require a single argument and must be bound as an array: `"cmd-1": ["workspace::ActivatePane", 0]`. Some actions require multiple arguments and are bound as an array of a string and an object: `"ctrl-a": ["pane::DeploySearch", { "replace_enabled": true }]`.
|
||||
|
||||
### Precedence
|
||||
|
||||
When multiple keybindings have the same keystroke and are active at the same time, precedence is resolved in two ways:
|
||||
|
||||
- Bindings that match on lower nodes in the context tree win. This means that if you have a binding with a context of `Editor` it will take precedence over a binding with a context of `Workspace`. Bindings with no context match at the lowest level in the tree.
|
||||
- If there are multiple bindings that match at the same level in the tree, then the binding defined later takes precedence. As user keybindings are loaded after system keybindings, this allows user bindings to take precedence over builtin keybindings.
|
||||
- Bindings that match on lower nodes in the context tree win. This means that if you have a binding with a context of `Editor`, it will take precedence over a binding with a context of `Workspace`. Bindings with no context match at the lowest level in the tree.
|
||||
- If there are multiple bindings that match at the same level in the tree, then the binding defined later takes precedence. As user keybindings are loaded after system keybindings, this allows user bindings to take precedence over built-in keybindings.
|
||||
|
||||
The other kind of conflict that arises is when you have two bindings, one of which is a prefix of the other. For example if you have `"ctrl-w":"editor::DeleteToNextWordEnd"` and `"ctrl-w left":"editor::DeleteToEndOfLine"`.
|
||||
The other kind of conflict that arises is when you have two bindings, one of which is a prefix of the other. For example, if you have `"ctrl-w":"editor::DeleteToNextWordEnd"` and `"ctrl-w left":"editor::DeleteToEndOfLine"`.
|
||||
|
||||
When this happens, and both bindings are active in the current context, Zed will wait for 1 second after you type `ctrl-w` to see if you're about to type `left`. If you don't type anything, or if you type a different key, then `DeleteToNextWordEnd` will be triggered. If you do, then `DeleteToEndOfLine` will be triggered.
|
||||
|
||||
@@ -147,15 +149,15 @@ When this happens, and both bindings are active in the current context, Zed will
|
||||
|
||||
Zed's support for non-QWERTY keyboards is still a work in progress.
|
||||
|
||||
If your keyboard can type the full ASCII ranges (DVORAK, COLEMAK, etc.) then shortcuts should work as you expect.
|
||||
If your keyboard can type the full ASCII range (DVORAK, COLEMAK, etc.), then shortcuts should work as you expect.
|
||||
|
||||
Otherwise, read on...
|
||||
|
||||
#### macOS
|
||||
|
||||
On Cyrillic, Hebrew, Armenian, and other keyboards that are mostly non-ASCII; macOS automatically maps keys to the ASCII range when `cmd` is held. Zed takes this a step further and it can always match key-presses against either the ASCII layout, or the real layout regardless of modifiers, and regardless of the `use_key_equivalents` setting. For example in Thai, pressing `ctrl-ๆ` will match bindings associated with `ctrl-q` or `ctrl-ๆ`
|
||||
On Cyrillic, Hebrew, Armenian, and other keyboards that are mostly non-ASCII, macOS automatically maps keys to the ASCII range when `cmd` is held. Zed takes this a step further, and it can always match key-presses against either the ASCII layout or the real layout, regardless of modifiers and the `use_key_equivalents` setting. For example, in Thai, pressing `ctrl-ๆ` will match bindings associated with `ctrl-q` or `ctrl-ๆ`.
|
||||
|
||||
On keyboards that support extended Latin alphabets (French AZERTY, German QWERTZ, etc.) it is often not possible to type the entire ASCII range without `option`. This introduces an ambiguity, `option-2` produces `@`. To ensure that all the builtin keyboard shortcuts can still be typed on these keyboards we move key-bindings around. For example, shortcuts bound to `@` on QWERTY are moved to `"` on a Spanish layout. This mapping is based on the macOS system defaults and can be seen by running `dev: Open Key Context View` from the command palette.
|
||||
On keyboards that support extended Latin alphabets (French AZERTY, German QWERTZ, etc.), it is often not possible to type the entire ASCII range without `option`. This introduces an ambiguity: `option-2` produces `@`. To ensure that all the built-in keyboard shortcuts can still be typed on these keyboards, we move key bindings around. For example, shortcuts bound to `@` on QWERTY are moved to `"` on a Spanish layout. This mapping is based on the macOS system defaults and can be seen by running `dev: open key context view` from the command palette.
|
||||
|
||||
If you are defining shortcuts in your personal keymap, you can opt into the key equivalent mapping by setting `use_key_equivalents` to `true` in your keymap:
|
||||
|
||||
@@ -172,16 +174,16 @@ If you are defining shortcuts in your personal keymap, you can opt into the key
|
||||
|
||||
### Linux
|
||||
|
||||
Since v0.196.0 on Linux if the key that you type doesn't produce an ASCII character then we use the QWERTY-layout equivalent key for keyboard shortcuts. This means that many shortcuts can be typed on many layouts.
|
||||
Since v0.196.0, on Linux, if the key that you type doesn't produce an ASCII character, then we use the QWERTY-layout equivalent key for keyboard shortcuts. This means that many shortcuts can be typed on many layouts.
|
||||
|
||||
We do not yet move shortcuts around to ensure that all the builtin shortcuts can be typed on every layout; so if there are some ASCII characters that cannot be typed, and your keyboard layout has different ASCII characters on the same keys as would be needed to type them, you may need to add custom key bindings to make this work. We do intend to fix this at some point, and help is very much wanted!
|
||||
We do not yet move shortcuts around to ensure that all the built-in shortcuts can be typed on every layout, so if there are some ASCII characters that cannot be typed, and your keyboard layout has different ASCII characters on the same keys as would be needed to type them, you may need to add custom key bindings to make this work. We do intend to fix this at some point, and help is very much appreciated!
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
### Disabling a binding
|
||||
|
||||
If you'd like a given binding to do nothing in a given context you can use
|
||||
`null` as the action. This is useful if you hit the keybinding by accident and
|
||||
If you'd like a given binding to do nothing in a given context, you can use
|
||||
`null` as the action. This is useful if you hit the key binding by accident and
|
||||
want to disable it, or if you want to type the character that would be typed by
|
||||
the sequence, or if you want to disable multikey bindings starting with that key.
|
||||
|
||||
@@ -196,9 +198,9 @@ the sequence, or if you want to disable multikey bindings starting with that key
|
||||
]
|
||||
```
|
||||
|
||||
A `null` binding follows the same precedence rules as normal actions. So disables all bindings that would match further up in the tree too. If you'd like a binding that matches further up in the tree to take precedence over a lower binding, you need to rebind it to the action you want in the context you want.
|
||||
A `null` binding follows the same precedence rules as normal actions, so it disables all bindings that would match further up in the tree too. If you'd like a binding that matches further up in the tree to take precedence over a lower binding, you need to rebind it to the action you want in the context you want.
|
||||
|
||||
This is useful for preventing Zed from falling back to a default keybinding when the action you specified is conditional and propagates. For example, `buffer_search::DeployReplace` only triggers when the search bar is not in view. If the search bar is in view, it would propagate and trigger the default action set for that binding, such as opening the right dock. To prevent this from happening:
|
||||
This is useful for preventing Zed from falling back to a default key binding when the action you specified is conditional and propagates. For example, `buffer_search::DeployReplace` only triggers when the search bar is not in view. If the search bar is in view, it would propagate and trigger the default action set for that key binding, such as opening the right dock. To prevent this from happening:
|
||||
|
||||
```json
|
||||
[
|
||||
@@ -246,7 +248,7 @@ A common request is to be able to map from a single keystroke to a sequence. You
|
||||
|
||||
There are some limitations to this, notably:
|
||||
|
||||
- Any asynchronous operation will not happen until after all your key bindings have been dispatched. For example this means that while you can use a binding to open a file (as in the `cmd-alt-r` example) you cannot send further keystrokes and hope to have them interpreted by the new view.
|
||||
- Any asynchronous operation will not happen until after all your key bindings have been dispatched. For example, this means that while you can use a binding to open a file (as in the `cmd-alt-r` example), you cannot send further keystrokes and hope to have them interpreted by the new view.
|
||||
- Other examples of asynchronous things are: opening the command palette, communicating with a language server, changing the language of a buffer, anything that hits the network.
|
||||
- There is a limit of 100 simulated keys at a time.
|
||||
|
||||
@@ -271,5 +273,5 @@ For example, `ctrl-n` creates a new tab in Zed on Linux. If you want to send `ct
|
||||
|
||||
### Task Key bindings
|
||||
|
||||
You can also bind keys to launch Zed Tasks defined in your tasks.json.
|
||||
You can also bind keys to launch Zed Tasks defined in your `tasks.json`.
|
||||
See the [tasks documentation](tasks.md#custom-keybindings-for-tasks) for more.
|
||||
|
||||
@@ -54,7 +54,7 @@ To use the Deno Language Server with TypeScript and TSX files, you will likely w
|
||||
See [Configuring supported languages](../configuring-languages.md) in the Zed documentation for more information.
|
||||
|
||||
<!--
|
||||
TBD: Deno Typescript REPL instructions [docs/repl#typescript-deno](../repl.md#typescript-deno)
|
||||
TBD: Deno TypeScript REPL instructions [docs/repl#typescript-deno](../repl.md#typescript-deno)
|
||||
-->
|
||||
|
||||
## DAP support
|
||||
|
||||
@@ -8,7 +8,7 @@ Report issues to: [https://github.com/grndctrl/zed-gdscript/issues](https://gith
|
||||
|
||||
## Setup
|
||||
|
||||
1. Download and install [Godot for MacOS](https://godotengine.org/download/macos/).
|
||||
1. Download and install [Godot for macOS](https://godotengine.org/download/macos/).
|
||||
2. Unzip the Godot.app and drag it into your /Applications folder.
|
||||
3. Open Godot.app and open your project (an example project is fine)
|
||||
4. In Godot, Editor Menu -> Editor Settings; scroll down the left sidebar to `Text Editor -> External`
|
||||
|
||||
@@ -10,7 +10,7 @@ Java language support in Zed is provided by:
|
||||
|
||||
You will need to install a Java runtime (OpenJDK).
|
||||
|
||||
- MacOS: `brew install openjdk`
|
||||
- macOS: `brew install openjdk`
|
||||
- Ubuntu: `sudo add-apt-repository ppa:openjdk-23 && sudo apt-get install openjdk-23`
|
||||
- Windows: `choco install openjdk`
|
||||
- Arch Linux: `sudo pacman -S jre-openjdk-headless`
|
||||
@@ -154,7 +154,7 @@ There are also many more options you can pass directly to the language server, f
|
||||
|
||||
If you prefer, you can install JDTLS yourself and the extension can be configured to use that instead.
|
||||
|
||||
- MacOS: `brew install jdtls`
|
||||
- macOS: `brew install jdtls`
|
||||
- Arch: [`jdtls` from AUR](https://aur.archlinux.org/packages/jdtls)
|
||||
|
||||
Or manually download install:
|
||||
|
||||
@@ -18,7 +18,7 @@ To configure LuaLS you can create a `.luarc.json` file in the root of your works
|
||||
}
|
||||
```
|
||||
|
||||
See [LuaLS Settings Documentation](https://luals.github.io/wiki/settings/) for all available configuration options, or when editing this file in Zed available settings options will autocomplete, (e.g `runtime.version` will show `"Lua 5.1"`, `"Lua 5.2"`, `"Lua 5.3"`, `"Lua 5.4"` and `"LuaJIT"` as allowed values). Note when importing settings options from VSCode, remove the `Lua.` prefix. (e.g. `runtime.version` instead of `Lua.runtime.version`).
|
||||
See [LuaLS Settings Documentation](https://luals.github.io/wiki/settings/) for all available configuration options, or when editing this file in Zed available settings options will autocomplete, (e.g `runtime.version` will show `"Lua 5.1"`, `"Lua 5.2"`, `"Lua 5.3"`, `"Lua 5.4"` and `"LuaJIT"` as allowed values). Note when importing settings options from VS Code, remove the `Lua.` prefix. (e.g. `runtime.version` instead of `Lua.runtime.version`).
|
||||
|
||||
### LuaCATS Definitions
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
## Setup
|
||||
|
||||
1. Run `yarn dlx @yarnpkg/sdks base` to generate a `.yarn/sdks` directory.
|
||||
2. Set your language server (e.g. VTSLS) to use Typescript SDK from `.yarn/sdks/typescript/lib` directory in [LSP initialization options](../configuring-zed.md#lsp). The actual setting for that depends on language server; for example, for VTSLS you should set [`typescript.tsdk`](https://github.com/yioneko/vtsls/blob/6adfb5d3889ad4b82c5e238446b27ae3ee1e3767/packages/service/configuration.schema.json#L5).
|
||||
2. Set your language server (e.g. VTSLS) to use TypeScript SDK from `.yarn/sdks/typescript/lib` directory in [LSP initialization options](../configuring-zed.md#lsp). The actual setting for that depends on language server; for example, for VTSLS you should set [`typescript.tsdk`](https://github.com/yioneko/vtsls/blob/6adfb5d3889ad4b82c5e238446b27ae3ee1e3767/packages/service/configuration.schema.json#L5).
|
||||
3. Voilla! Language server functionalities such as Go to Definition, Code Completions and On Hover documentation should work.
|
||||
|
||||
@@ -114,7 +114,7 @@ ark --install
|
||||
TBD: Improve R REPL (Ark Kernel) instructions
|
||||
-->
|
||||
|
||||
### Typescript: Deno {#typescript-deno}
|
||||
### TypeScript: Deno {#typescript-deno}
|
||||
|
||||
- [Install Deno](https://docs.deno.com/runtime/manual/getting_started/installation/) and then install the Deno jupyter kernel:
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ The scope is determined by the language name in lowercase e.g. `python.json` for
|
||||
| JSX | javascript.json |
|
||||
| Plain Text | plaintext.json |
|
||||
|
||||
To create JSX snippets you have to use `javascript.json` snippets file, instead of `jsx.json`, but this does not apply to TSX and Typescript which follow the above rule.
|
||||
To create JSX snippets you have to use `javascript.json` snippets file, instead of `jsx.json`, but this does not apply to TSX and TypeScript which follow the above rule.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
|
||||
@@ -94,7 +94,6 @@ To disable this behavior use:
|
||||
// "project_panel": {"button": false },
|
||||
// "outline_panel": {"button": false },
|
||||
// "collaboration_panel": {"button": false },
|
||||
// "chat_panel": {"button": "never" },
|
||||
// "git_panel": {"button": false },
|
||||
// "notification_panel": {"button": false },
|
||||
// "agent": {"button": false },
|
||||
@@ -554,13 +553,6 @@ See [Terminal settings](./configuring-zed.md#terminal) for additional non-visual
|
||||
},
|
||||
"show_call_status_icon": true, // Shown call status in the OS status bar.
|
||||
|
||||
// Chat Panel
|
||||
"chat_panel": {
|
||||
"button": "when_in_call", // status bar icon (true, false, when_in_call)
|
||||
"dock": "right", // Where to dock: left, right
|
||||
"default_width": 240 // Default width of the chat panel
|
||||
},
|
||||
|
||||
// Notification Panel
|
||||
"notification_panel": {
|
||||
// Whether to show the notification panel button in the status bar.
|
||||
|
||||
Reference in New Issue
Block a user